diff --git a/checks/jq_test.go b/checks/jq_test.go index 8a8963f..c5f838f 100644 --- a/checks/jq_test.go +++ b/checks/jq_test.go @@ -56,7 +56,7 @@ func TestRunStdoutJqQuery(t *testing.T) { }, { name: "returns jq error", - stdout: `{"name":"Theo"}`, + stdout: `{"name":"Kaladin"}`, test: api.StdoutJqTest{ InputMode: "json", Query: `.name[`, @@ -106,13 +106,13 @@ func TestFormatJqResults(t *testing.T) { } func TestFormatJqExpectedValueInterpolatesOnlyStrings(t *testing.T) { - variables := map[string]string{"name": "Theo"} + variables := map[string]string{"name": "Allan"} gotString := formatJqExpectedValue(api.JqExpectedResult{ Type: api.JqTypeString, Value: "hello ${name}", }, variables) - if gotString != `"hello Theo"` { + if gotString != `"hello Allan"` { t.Fatalf("expected interpolated string value, got %q", gotString) } diff --git a/checks/local.go b/checks/local.go new file mode 100644 index 0000000..b45b947 --- /dev/null +++ b/checks/local.go @@ -0,0 +1,311 @@ +package checks + +import ( + "fmt" + "math" + "reflect" + "strconv" + "strings" + + api "github.com/bootdotdev/bootdev/client" +) + +func LocalSubmissionEvent(cliData api.CLIData, results []api.CLIStepResult) api.LessonSubmissionEvent { + failure := EvaluateCLIResults(cliData, results) + slug := api.VerificationResultSlugSuccess + if failure != nil { + slug = api.VerificationResultSlugFailure + if failure.FailedStepIndex >= 0 && + failure.FailedStepIndex < len(cliData.Steps) && + cliData.Steps[failure.FailedStepIndex].NoPenaltyOnFail { + slug = api.VerificationResultSlugNoop + } + } + + return api.LessonSubmissionEvent{ + ResultSlug: slug, + StructuredErrCLI: failure, + XPReward: -1, + } +} + +func EvaluateCLIResults(cliData api.CLIData, results []api.CLIStepResult) *api.StructuredErrCLI { + for stepIndex, step := range cliData.Steps { + if stepIndex >= len(results) { + return localFailure(stepIndex, 0, "missing result for step") + } + + switch { + case step.CLICommand != nil: + result := results[stepIndex].CLICommandResult + if result == nil { + return localFailure(stepIndex, 0, "missing CLI command result") + } + if failure := evaluateCLICommandTests(stepIndex, *step.CLICommand, *result); failure != nil { + return failure + } + case step.HTTPRequest != nil: + result := results[stepIndex].HTTPRequestResult + if result == nil { + return localFailure(stepIndex, 0, "missing HTTP request result") + } + if failure := evaluateHTTPRequestTests(stepIndex, *step.HTTPRequest, *result); failure != nil { + return failure + } + default: + return localFailure(stepIndex, 0, "missing step definition") + } + } + + return nil +} + +func evaluateCLICommandTests(stepIndex int, cmd api.CLIStepCLICommand, result api.CLICommandResult) *api.StructuredErrCLI { + for testIndex, test := range cmd.Tests { + var err error + + switch { + case test.ExitCode != nil: + if result.ExitCode != *test.ExitCode { + err = fmt.Errorf("expected exit code %d, got %d", *test.ExitCode, result.ExitCode) + } + case len(test.StdoutContainsAll) > 0: + for _, contains := range test.StdoutContainsAll { + needle := InterpolateVariables(contains, result.Variables) + if !strings.Contains(result.Stdout, needle) { + err = fmt.Errorf("expected stdout to contain %q", needle) + break + } + } + case len(test.StdoutContainsNone) > 0: + for _, containsNone := range test.StdoutContainsNone { + needle := InterpolateVariables(containsNone, result.Variables) + if strings.Contains(result.Stdout, needle) { + err = fmt.Errorf("expected stdout to not contain %q", needle) + break + } + } + case test.StdoutLinesGt != nil: + lineCount := stdoutLineCount(result.Stdout) + if lineCount <= *test.StdoutLinesGt { + err = fmt.Errorf("expected stdout to have more than %d lines, got %d", *test.StdoutLinesGt, lineCount) + } + case test.StdoutJq != nil: + err = evaluateStdoutJq(result.Stdout, *test.StdoutJq, result.Variables) + default: + err = fmt.Errorf("unsupported CLI command test") + } + + if err != nil { + return localFailure(stepIndex, testIndex, err.Error()) + } + } + + return nil +} + +func evaluateHTTPRequestTests(stepIndex int, req api.CLIStepHTTPRequest, result api.HTTPRequestResult) *api.StructuredErrCLI { + if result.Err != "" { + return localFailure(stepIndex, 0, result.Err) + } + + for testIndex, test := range req.Tests { + var err error + + switch { + case test.StatusCode != nil: + if result.StatusCode != *test.StatusCode { + err = fmt.Errorf("expected status code %d, got %d", *test.StatusCode, result.StatusCode) + } + case test.BodyContains != nil: + needle := InterpolateVariables(*test.BodyContains, result.Variables) + if !strings.Contains(result.BodyString, needle) { + err = fmt.Errorf("expected body to contain %q", needle) + } + case test.BodyContainsNone != nil: + needle := InterpolateVariables(*test.BodyContainsNone, result.Variables) + if strings.Contains(result.BodyString, needle) { + err = fmt.Errorf("expected body to not contain %q", needle) + } + case test.HeadersContain != nil: + err = evaluateHeaderContains(result.ResponseHeaders, *test.HeadersContain, result.Variables, "header") + case test.TrailersContain != nil: + err = evaluateHeaderContains(result.ResponseTrailers, *test.TrailersContain, result.Variables, "trailer") + case test.JSONValue != nil: + err = evaluateHTTPJSONValue(result.BodyString, *test.JSONValue, result.Variables) + default: + err = fmt.Errorf("unsupported HTTP request test") + } + + if err != nil { + return localFailure(stepIndex, testIndex, err.Error()) + } + } + + return nil +} + +func evaluateHeaderContains(headers map[string]string, test api.HTTPRequestTestHeader, variables map[string]string, label string) error { + key := InterpolateVariables(test.Key, variables) + want := InterpolateVariables(test.Value, variables) + + got, ok := findHeaderValue(headers, key) + if !ok { + return fmt.Errorf("expected %s %q to exist", label, key) + } + if !strings.Contains(got, want) { + return fmt.Errorf("expected %s %q to contain %q, got %q", label, key, want, got) + } + + return nil +} + +func evaluateHTTPJSONValue(body string, test api.HTTPRequestTestJSONValue, variables map[string]string) error { + got, err := valFromJqPath(test.Path, body) + if err != nil { + return err + } + + want, err := httpJSONExpectedValue(test, variables) + if err != nil { + return err + } + + if !compareValues(got, test.Operator, want) { + return fmt.Errorf("expected JSON at %s %s %v, got %v", test.Path, test.Operator, want, got) + } + + return nil +} + +func httpJSONExpectedValue(test api.HTTPRequestTestJSONValue, variables map[string]string) (any, error) { + switch { + case test.IntValue != nil: + return *test.IntValue, nil + case test.StringValue != nil: + return InterpolateVariables(*test.StringValue, variables), nil + case test.BoolValue != nil: + return *test.BoolValue, nil + default: + return nil, fmt.Errorf("missing expected JSON value") + } +} + +func evaluateStdoutJq(stdout string, test api.StdoutJqTest, variables map[string]string) error { + queryText := InterpolateVariables(test.Query, variables) + + input, err := parseJqInput(stdout, test.InputMode) + if err != nil { + return err + } + + results, err := executeJqQuery(queryText, input) + if err != nil { + return err + } + if len(results) != len(test.ExpectedResults) { + return fmt.Errorf("expected jq query %q to return %d result(s), got %d", queryText, len(test.ExpectedResults), len(results)) + } + + for i, expected := range test.ExpectedResults { + want, err := jqExpectedValue(expected, variables) + if err != nil { + return err + } + if !compareValues(results[i], api.OperatorType(expected.Operator), want) { + return fmt.Errorf("expected jq result %d to be %s %v, got %v", i+1, expected.Operator, want, results[i]) + } + } + + return nil +} + +func jqExpectedValue(expected api.JqExpectedResult, variables map[string]string) (any, error) { + switch expected.Type { + case api.JqTypeString: + if str, ok := expected.Value.(string); ok { + return InterpolateVariables(str, variables), nil + } + return expected.Value, nil + case api.JqTypeInt: + if str, ok := expected.Value.(string); ok { + parsed, err := strconv.Atoi(InterpolateVariables(str, variables)) + if err != nil { + return nil, err + } + return parsed, nil + } + return expected.Value, nil + case api.JqTypeBool: + if str, ok := expected.Value.(string); ok { + parsed, err := strconv.ParseBool(InterpolateVariables(str, variables)) + if err != nil { + return nil, err + } + return parsed, nil + } + return expected.Value, nil + default: + return nil, fmt.Errorf("unsupported jq expected result type %q", expected.Type) + } +} + +func compareValues(got any, operator api.OperatorType, want any) bool { + switch operator { + case api.OpEquals, "==": + return valuesEqual(got, want) + case api.OpGreaterThan, ">": + gotNum, gotOK := numberValue(got) + wantNum, wantOK := numberValue(want) + return gotOK && wantOK && gotNum > wantNum + case api.OpContains: + return strings.Contains(fmt.Sprintf("%v", got), fmt.Sprintf("%v", want)) + case api.OpNotContains: + return !strings.Contains(fmt.Sprintf("%v", got), fmt.Sprintf("%v", want)) + default: + return false + } +} + +func valuesEqual(got any, want any) bool { + if gotNum, gotOK := numberValue(got); gotOK { + wantNum, wantOK := numberValue(want) + return wantOK && math.Abs(gotNum-wantNum) < 0.000000001 + } + return reflect.DeepEqual(got, want) +} + +func numberValue(value any) (float64, bool) { + switch v := value.(type) { + case int: + return float64(v), true + case int64: + return float64(v), true + case float64: + return v, true + case jsonNumber: + parsed, err := strconv.ParseFloat(v.String(), 64) + return parsed, err == nil + default: + return 0, false + } +} + +func stdoutLineCount(stdout string) int { + if stdout == "" { + return 0 + } + return strings.Count(stdout, "\n") + 1 +} + +func localFailure(stepIndex int, testIndex int, message string) *api.StructuredErrCLI { + return &api.StructuredErrCLI{ + ErrorMessage: message, + FailedStepIndex: stepIndex, + FailedTestIndex: testIndex, + } +} + +type jsonNumber interface { + String() string +} diff --git a/checks/local_test.go b/checks/local_test.go new file mode 100644 index 0000000..f4dd1ae --- /dev/null +++ b/checks/local_test.go @@ -0,0 +1,130 @@ +package checks + +import ( + "testing" + + api "github.com/bootdotdev/bootdev/client" +) + +func TestLocalSubmissionEventPassesCLIAndHTTPResults(t *testing.T) { + cliData := api.CLIData{Steps: []api.CLIStep{ + {CLICommand: &api.CLIStepCLICommand{Tests: []api.CLICommandTest{ + {ExitCode: intPtr(0)}, + {StdoutContainsAll: []string{"hello ${name}"}}, + }}}, + {HTTPRequest: &api.CLIStepHTTPRequest{Tests: []api.HTTPRequestTest{ + {StatusCode: intPtr(200)}, + {HeadersContain: &api.HTTPRequestTestHeader{Key: "Set-Cookie", Value: "session_id="}}, + {JSONValue: &api.HTTPRequestTestJSONValue{ + Path: ".app", + Operator: api.OpEquals, + StringValue: stringPtr("bearly-secure"), + }}, + }}}, + }} + + results := []api.CLIStepResult{ + {CLICommandResult: &api.CLICommandResult{ + ExitCode: 0, + Stdout: "hello Boots", + Variables: map[string]string{"name": "Boots"}, + }}, + {HTTPRequestResult: &api.HTTPRequestResult{ + StatusCode: 200, + ResponseHeaders: map[string]string{"Set-Cookie": "session_id=abc123; Path=/"}, + BodyString: `{"app":"bearly-secure"}`, + Variables: map[string]string{}, + }}, + } + + event := LocalSubmissionEvent(cliData, results) + if event.ResultSlug != api.VerificationResultSlugSuccess { + t.Fatalf("ResultSlug = %q, want success; failure = %#v", event.ResultSlug, event.StructuredErrCLI) + } + if event.StructuredErrCLI != nil { + t.Fatalf("unexpected failure: %#v", event.StructuredErrCLI) + } +} + +func TestLocalSubmissionEventReportsFirstFailure(t *testing.T) { + cliData := api.CLIData{Steps: []api.CLIStep{ + {CLICommand: &api.CLIStepCLICommand{Tests: []api.CLICommandTest{ + {ExitCode: intPtr(0)}, + {StdoutContainsAll: []string{"expected"}}, + }}}, + }} + results := []api.CLIStepResult{ + {CLICommandResult: &api.CLICommandResult{ + ExitCode: 0, + Stdout: "actual", + Variables: map[string]string{}, + }}, + } + + event := LocalSubmissionEvent(cliData, results) + if event.ResultSlug != api.VerificationResultSlugFailure { + t.Fatalf("ResultSlug = %q, want failure", event.ResultSlug) + } + if event.StructuredErrCLI == nil { + t.Fatal("expected structured failure") + } + if event.StructuredErrCLI.FailedStepIndex != 0 || event.StructuredErrCLI.FailedTestIndex != 1 { + t.Fatalf("failure = %#v, want step 0 test 1", event.StructuredErrCLI) + } +} + +func TestEvaluateStdoutJq(t *testing.T) { + err := evaluateStdoutJq( + "{\"ok\":true}", + api.StdoutJqTest{ + InputMode: "json", + Query: ".ok", + ExpectedResults: []api.JqExpectedResult{ + {Type: api.JqTypeBool, Operator: "==", Value: true}, + }, + }, + map[string]string{}, + ) + if err != nil { + t.Fatalf("unexpected jq failure: %v", err) + } +} + +func TestValuesEqualPreservesTypes(t *testing.T) { + tests := []struct { + name string + got any + want any + ok bool + }{ + {name: "same strings", got: "1", want: "1", ok: true}, + {name: "string and int", got: "1", want: 1, ok: false}, + {name: "string and bool", got: "true", want: true, ok: false}, + {name: "same bools", got: true, want: true, ok: true}, + {name: "numeric int and float", got: 1, want: 1.0, ok: true}, + {name: "numeric json number and int", got: testJSONNumber("1"), want: 1, ok: true}, + {name: "nil and string", got: nil, want: "", ok: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := valuesEqual(tt.got, tt.want); got != tt.ok { + t.Fatalf("valuesEqual(%#v, %#v) = %v, want %v", tt.got, tt.want, got, tt.ok) + } + }) + } +} + +type testJSONNumber string + +func (n testJSONNumber) String() string { + return string(n) +} + +func intPtr(v int) *int { + return &v +} + +func stringPtr(v string) *string { + return &v +} diff --git a/client/lessons.go b/client/lessons.go index 90d7b5f..2b5ea9b 100644 --- a/client/lessons.go +++ b/client/lessons.go @@ -22,42 +22,42 @@ const BaseURLOverrideRequired = "override" type CLIData struct { // ContainsCompleteDir bool - BaseURLDefault string - Steps []CLIStep - AllowedOperatingSystems []string + BaseURLDefault string `yaml:"baseURLDefault"` + Steps []CLIStep `yaml:"steps"` + AllowedOperatingSystems []string `yaml:"allowedOperatingSystems"` } type CLIStep struct { - CLICommand *CLIStepCLICommand - HTTPRequest *CLIStepHTTPRequest - NoPenaltyOnFail bool + CLICommand *CLIStepCLICommand `yaml:"cliCommand"` + HTTPRequest *CLIStepHTTPRequest `yaml:"httpRequest"` + NoPenaltyOnFail bool `yaml:"noPenaltyOnFail"` } type CLIStepCLICommand struct { - Command string - Tests []CLICommandTest - SleepAfterMs *int - StdoutFilterTmdl *string + Command string `yaml:"command"` + Tests []CLICommandTest `yaml:"tests"` + SleepAfterMs *int `yaml:"sleepAfterMs"` + StdoutFilterTmdl *string `yaml:"stdoutFilterTmdl"` } type CLICommandTest struct { - ExitCode *int - StdoutContainsAll []string - StdoutContainsNone []string - StdoutLinesGt *int - StdoutJq *StdoutJqTest + ExitCode *int `yaml:"exitCode"` + StdoutContainsAll []string `yaml:"stdoutContainsAll"` + StdoutContainsNone []string `yaml:"stdoutContainsNone"` + StdoutLinesGt *int `yaml:"stdoutLinesGt"` + StdoutJq *StdoutJqTest `yaml:"stdoutJq"` } type StdoutJqTest struct { - InputMode string // "json" or "jsonl" - Query string - ExpectedResults []JqExpectedResult + InputMode string `yaml:"inputMode"` // "json" or "jsonl" + Query string `yaml:"query"` + ExpectedResults []JqExpectedResult `yaml:"expectedResults"` } type JqExpectedResult struct { - Type JqValueType - Operator JqOperator - Value any + Type JqValueType `yaml:"type"` + Operator JqOperator `yaml:"operator"` + Value any `yaml:"value"` } type ( @@ -72,11 +72,11 @@ const ( ) type CLIStepHTTPRequest struct { - ResponseVariables []HTTPRequestResponseVariable - ResponseHeaderVariables []HTTPRequestResponseHeaderVariable - Tests []HTTPRequestTest - Request HTTPRequest - SleepAfterMs *int + ResponseVariables []HTTPRequestResponseVariable `yaml:"responseVariables"` + ResponseHeaderVariables []HTTPRequestResponseHeaderVariable `yaml:"responseHeaderVariables"` + Tests []HTTPRequestTest `yaml:"tests"` + Request HTTPRequest `yaml:"request"` + SleepAfterMs *int `yaml:"sleepAfterMs"` } type Sleepable interface { @@ -94,53 +94,53 @@ func (h *CLIStepHTTPRequest) GetSleepAfterMs() *int { const BaseURLPlaceholder = "${baseURL}" type HTTPRequest struct { - Method string - FullURL string - Headers map[string]string - BodyJSON map[string]any - BodyForm map[string]string - FollowRedirects *bool - - BasicAuth *HTTPBasicAuth + Method string `yaml:"method"` + FullURL string `yaml:"fullURL"` + Headers map[string]string `yaml:"headers"` + BodyJSON map[string]any `yaml:"bodyJSON"` + BodyForm map[string]string `yaml:"bodyForm"` + FollowRedirects *bool `yaml:"followRedirects"` + + BasicAuth *HTTPBasicAuth `yaml:"basicAuth"` } type HTTPBasicAuth struct { - Username string - Password string + Username string `yaml:"username"` + Password string `yaml:"password"` } type HTTPRequestResponseVariable struct { - Name string - Path string + Name string `yaml:"name"` + Path string `yaml:"path"` } type HTTPRequestResponseHeaderVariable struct { - Name string - Header string - Regex string + Name string `yaml:"name"` + Header string `yaml:"header"` + Regex string `yaml:"regex"` } // HTTPRequestTest should have only one field set type HTTPRequestTest struct { - StatusCode *int - BodyContains *string - BodyContainsNone *string - HeadersContain *HTTPRequestTestHeader - TrailersContain *HTTPRequestTestHeader - JSONValue *HTTPRequestTestJSONValue + StatusCode *int `yaml:"statusCode"` + BodyContains *string `yaml:"bodyContains"` + BodyContainsNone *string `yaml:"bodyContainsNone"` + HeadersContain *HTTPRequestTestHeader `yaml:"headersContain"` + TrailersContain *HTTPRequestTestHeader `yaml:"trailersContain"` + JSONValue *HTTPRequestTestJSONValue `yaml:"jsonValue"` } type HTTPRequestTestHeader struct { - Key string - Value string + Key string `yaml:"key"` + Value string `yaml:"value"` } type HTTPRequestTestJSONValue struct { - Path string - Operator OperatorType - IntValue *int - StringValue *string - BoolValue *bool + Path string `yaml:"path"` + Operator OperatorType `yaml:"operator"` + IntValue *int `yaml:"intValue"` + StringValue *string `yaml:"stringValue"` + BoolValue *bool `yaml:"boolValue"` } type OperatorType string diff --git a/cmd/localtest.go b/cmd/localtest.go new file mode 100644 index 0000000..1d42ebd --- /dev/null +++ b/cmd/localtest.go @@ -0,0 +1,115 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "slices" + + "github.com/bootdotdev/bootdev/checks" + api "github.com/bootdotdev/bootdev/client" + "github.com/bootdotdev/bootdev/render" + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "go.yaml.in/yaml/v3" +) + +func init() { + rootCmd.AddCommand(localTestCmd) +} + +var localTestCmd = &cobra.Command{ + Use: "local-test PATH", + Args: cobra.ExactArgs(1), + Hidden: true, + RunE: localTestHandler, +} + +func localTestHandler(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + data, err := readLocalCLIData(args[0]) + if err != nil { + return err + } + if err := validateAllowedOS(data); err != nil { + return err + } + + overrideBaseURL := viper.GetString("override_base_url") + if overrideBaseURL != "" { + fmt.Printf("Using overridden base_url: %v\n", overrideBaseURL) + fmt.Printf("You can reset to the default with `bootdev config base_url --reset`\n\n") + } + + ch := make(chan tea.Msg, 1) + finalise := render.StartRenderer(data, true, ch) + + cliResults := checks.CLIChecks(data, overrideBaseURL, ch) + submissionEvent := checks.LocalSubmissionEvent(data, cliResults) + checks.ApplySubmissionResults(data, submissionEvent.StructuredErrCLI, ch) + finalise(submissionEvent) + + if submissionEvent.ResultSlug != api.VerificationResultSlugSuccess { + return localTestFailureError(submissionEvent.StructuredErrCLI) + } + + return nil +} + +func localTestFailureError(failure *api.StructuredErrCLI) error { + if failure == nil { + return errors.New("local checks failed") + } + return fmt.Errorf( + "local checks failed: step %d, test %d\n%s", + failure.FailedStepIndex+1, + failure.FailedTestIndex+1, + failure.ErrorMessage, + ) +} + +func readLocalCLIData(path string) (api.CLIData, error) { + cleanPath := filepath.Clean(path) + info, err := os.Stat(cleanPath) + if err != nil { + return api.CLIData{}, err + } + if info.IsDir() { + cleanPath = filepath.Join(cleanPath, "cli.yaml") + } + + bytes, err := os.ReadFile(cleanPath) + if err != nil { + return api.CLIData{}, err + } + + var data api.CLIData + if err := yaml.Unmarshal(bytes, &data); err != nil { + return api.CLIData{}, err + } + if len(data.Steps) == 0 { + return api.CLIData{}, errors.New("test manifest should include at least one step") + } + + return data, nil +} + +func validateAllowedOS(data api.CLIData) error { + if len(data.AllowedOperatingSystems) == 0 { + return nil + } + + if slices.Contains(data.AllowedOperatingSystems, runtime.GOOS) { + return nil + } + + return fmt.Errorf( + "lesson is not supported for your operating system (%s)\ntry again with one of the following: %v", + runtime.GOOS, + data.AllowedOperatingSystems, + ) +} diff --git a/cmd/localtest_test.go b/cmd/localtest_test.go new file mode 100644 index 0000000..1ed69fd --- /dev/null +++ b/cmd/localtest_test.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + api "github.com/bootdotdev/bootdev/client" +) + +func TestReadLocalCLIDataAcceptsLessonDirectory(t *testing.T) { + dir := t.TempDir() + manifest := []byte(`allowedOperatingSystems: + - linux + - darwin +baseURLDefault: http://localhost:3000 +steps: + - cliCommand: + command: echo hello + tests: + - exitCode: 0 + - stdoutContainsAll: + - hello +`) + if err := os.WriteFile(filepath.Join(dir, "cli.yaml"), manifest, 0o600); err != nil { + t.Fatalf("failed to write test manifest: %v", err) + } + + data, err := readLocalCLIData(dir) + if err != nil { + t.Fatalf("readLocalCLIData() error = %v", err) + } + if data.BaseURLDefault != "http://localhost:3000" { + t.Fatalf("BaseURLDefault = %q, want localhost default", data.BaseURLDefault) + } + if len(data.Steps) != 1 || data.Steps[0].CLICommand == nil { + t.Fatalf("expected one CLI command step, got %#v", data.Steps) + } + if len(data.Steps[0].CLICommand.Tests[1].StdoutContainsAll) != 1 { + t.Fatalf("expected stdoutContainsAll test to load") + } +} + +func TestLocalTestFailureErrorIncludesStructuredContext(t *testing.T) { + err := localTestFailureError(&api.StructuredErrCLI{ + ErrorMessage: `expected stdout to contain "hello"`, + FailedStepIndex: 1, + FailedTestIndex: 2, + }) + + want := "local checks failed: step 2, test 3\nexpected stdout to contain \"hello\"" + if err == nil || err.Error() != want { + t.Fatalf("localTestFailureError() = %v, want %q", err, want) + } +} diff --git a/go.mod b/go.mod index 2a46606..3513930 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 + go.yaml.in/yaml/v3 v3.0.4 golang.org/x/mod v0.32.0 golang.org/x/term v0.39.0 ) @@ -45,7 +46,6 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect