diff --git a/.github/pr-previews/cli-output/long-run-fail.png b/.github/pr-previews/cli-output/long-run-fail.png new file mode 100644 index 0000000..fe0c32a Binary files /dev/null and b/.github/pr-previews/cli-output/long-run-fail.png differ diff --git a/.github/pr-previews/cli-output/long-run-pass.png b/.github/pr-previews/cli-output/long-run-pass.png new file mode 100644 index 0000000..1eae135 Binary files /dev/null and b/.github/pr-previews/cli-output/long-run-pass.png differ diff --git a/.github/pr-previews/cli-output/long-submit-fail-default.png b/.github/pr-previews/cli-output/long-submit-fail-default.png new file mode 100644 index 0000000..9a8ec99 Binary files /dev/null and b/.github/pr-previews/cli-output/long-submit-fail-default.png differ diff --git a/.github/pr-previews/cli-output/long-submit-fail-verbose.png b/.github/pr-previews/cli-output/long-submit-fail-verbose.png new file mode 100644 index 0000000..de5ee7e Binary files /dev/null and b/.github/pr-previews/cli-output/long-submit-fail-verbose.png differ diff --git a/.github/pr-previews/cli-output/long-submit-pass-default.png b/.github/pr-previews/cli-output/long-submit-pass-default.png new file mode 100644 index 0000000..6baea62 Binary files /dev/null and b/.github/pr-previews/cli-output/long-submit-pass-default.png differ diff --git a/.github/pr-previews/cli-output/long-submit-pass-verbose.png b/.github/pr-previews/cli-output/long-submit-pass-verbose.png new file mode 100644 index 0000000..15d87aa Binary files /dev/null and b/.github/pr-previews/cli-output/long-submit-pass-verbose.png differ diff --git a/checks/jq.go b/checks/jq.go index 7053a7d..38f2270 100644 --- a/checks/jq.go +++ b/checks/jq.go @@ -236,7 +236,7 @@ func compareInt(actual int, expected int, operator api.JqOperator) bool { } } -func valFromJqPath(path string, jsn string) (any, error) { +func JSONValueAtPath(path string, jsn string) (any, error) { vals, err := valsFromJqPath(path, jsn) if err != nil { return nil, err diff --git a/checks/jq_test.go b/checks/jq_test.go index 2177282..1faf0a2 100644 --- a/checks/jq_test.go +++ b/checks/jq_test.go @@ -60,7 +60,7 @@ func TestRunStdoutJqQuery(t *testing.T) { } func TestValFromJqPathRejectsMultipleValues(t *testing.T) { - _, err := valFromJqPath(`.items[].id`, `{"items":[{"id":1},{"id":2}]}`) + _, err := JSONValueAtPath(`.items[].id`, `{"items":[{"id":1},{"id":2}]}`) if err == nil { t.Fatal("expected error for multiple values") } diff --git a/checks/local.go b/checks/local.go index cc77934..cea5a43 100644 --- a/checks/local.go +++ b/checks/local.go @@ -253,7 +253,7 @@ func capturedVariableMatches(vars map[string]string, name, expectedValue string) } func jsonValOp(test api.HTTPRequestTestJSONValue, jsn string, variables map[string]string) error { - val, err := valFromJqPath(test.Path, jsn) + val, err := JSONValueAtPath(test.Path, jsn) if err != nil { return err } diff --git a/cmd/localtest.go b/cmd/localtest.go index 18c9cb4..e1e6458 100644 --- a/cmd/localtest.go +++ b/cmd/localtest.go @@ -7,6 +7,8 @@ import ( "path/filepath" "runtime" "slices" + "strconv" + "strings" "github.com/bootdotdev/bootdev/checks" api "github.com/bootdotdev/bootdev/client" @@ -19,6 +21,7 @@ import ( func init() { rootCmd.AddCommand(localTestCmd) localTestCmd.Flags().Bool("ignore-os", false, "skip the lesson's allowed operating systems check") + localTestCmd.Flags().Bool("run", false, "run local lesson commands without grading") localTestCmd.Flags().String("shell", "", "shell for lesson commands: sh or pwsh (default depends on host OS)") localTestCmd.Flags().BoolVarP(&verboseOutput, "verbose", "v", false, "show detailed final output for every step") } @@ -33,6 +36,7 @@ var localTestCmd = &cobra.Command{ func localTestHandler(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true + cmd.SilenceErrors = false data, err := readLocalCLIData(args[0]) if err != nil { @@ -51,14 +55,22 @@ func localTestHandler(cmd *cobra.Command, args []string) error { if err != nil { return err } + runOnly, err := cmd.Flags().GetBool("run") + if err != nil { + return err + } overrideBaseURL := viper.GetString("override_base_url") + runContext := localRunContext(args[0]) + if runOnly { + runContext.Action = "Running lesson:" + } + runContext.BaseURL = data.BaseURLDefault 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") + runContext.BaseURL = overrideBaseURL } - send, finish := render.StartRenderer(true, verboseOutput, false) + send, finish := render.StartRenderer(!runOnly, verboseOutput, false, runContext) submissionEvent := api.LessonSubmissionEvent{} defer func() { finish(submissionEvent) @@ -68,16 +80,50 @@ func localTestHandler(cmd *cobra.Command, args []string) error { if err != nil { return err } + if runOnly { + return nil + } submissionEvent = checks.LocalSubmissionEvent(data, cliResults) checks.ApplySubmissionResults(data, submissionEvent.StructuredErrCLI, send) if submissionEvent.ResultSlug != api.VerificationResultSlugSuccess { + cmd.SilenceErrors = true return localTestFailureError(submissionEvent.StructuredErrCLI) } return nil } +func localRunContext(path string) render.RunContext { + manifestPath := filepath.Clean(path) + if info, err := os.Stat(manifestPath); err == nil && info.IsDir() { + manifestPath = filepath.Join(manifestPath, "cli.yaml") + } + lessonDirectory := filepath.Dir(manifestPath) + context := render.RunContext{ + Action: "Submitting lesson:", + LessonTitle: filepath.Base(lessonDirectory), + } + if readme, err := os.ReadFile(filepath.Join(lessonDirectory, "readme.md")); err == nil { + for _, line := range strings.Split(string(readme), "\n") { + if strings.HasPrefix(line, "# ") { + if title := strings.TrimSpace(strings.TrimPrefix(line, "# ")); title != "" { + context.LessonTitle = title + } + break + } + } + } + chapterPrefix, _, chapterFound := strings.Cut(filepath.Base(filepath.Dir(lessonDirectory)), "-") + lessonPrefix, _, lessonFound := strings.Cut(filepath.Base(lessonDirectory), "-") + chapterNumber, chapterErr := strconv.Atoi(chapterPrefix) + lessonNumber, lessonErr := strconv.Atoi(lessonPrefix) + if chapterFound && lessonFound && chapterErr == nil && lessonErr == nil && chapterNumber > 0 && lessonNumber > 0 { + context.LessonTitle = fmt.Sprintf("%d.%d - %s", chapterNumber, lessonNumber, context.LessonTitle) + } + return context +} + func localTestFailureError(failure *api.StructuredErrCLI) error { if failure == nil { return errors.New("local checks failed") diff --git a/cmd/localtest_test.go b/cmd/localtest_test.go index 1fd6973..7e93c48 100644 --- a/cmd/localtest_test.go +++ b/cmd/localtest_test.go @@ -28,6 +28,40 @@ func TestReadLocalCLIDataAcceptsLessonDirectory(t *testing.T) { } } +func TestLocalRunContext(t *testing.T) { + for _, tt := range []struct { + name string + readme string + metadata string + wantTitle string + }{ + {name: "lesson", readme: "# Sorting Chirps\n", metadata: "uuid: lesson-id\n", wantTitle: "Sorting Chirps"}, + {name: "missing sidecars"}, + {name: "invalid sidecars", readme: "No title", metadata: "[invalid"}, + } { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + for name, contents := range map[string]string{"cli.yaml": "steps: []", "readme.md": tt.readme, "metadata.yaml": tt.metadata} { + if contents != "" { + if err := os.WriteFile(filepath.Join(dir, name), []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + } + } + for _, path := range []string{dir, filepath.Join(dir, "cli.yaml")} { + got := localRunContext(path) + title := tt.wantTitle + if title == "" { + title = filepath.Base(dir) + } + if got.LessonTitle != title { + t.Fatalf("unexpected context: %+v", got) + } + } + }) + } +} + func TestLocalTestShellDoesNotBypassOSValidation(t *testing.T) { for _, allowedOS := range []string{"[]", "[unsupported-os]"} { t.Run(allowedOS, func(t *testing.T) { @@ -46,3 +80,16 @@ func TestLocalTestShellDoesNotBypassOSValidation(t *testing.T) { }) } } + +func TestLocalRunContextUsesCourseLessonNumber(t *testing.T) { + dir := filepath.Join(t.TempDir(), "9-documentation", "3-sorting-chirp") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "readme.md"), []byte("# Sorting Chirps\n"), 0o600); err != nil { + t.Fatal(err) + } + if got := localRunContext(dir); got.LessonTitle != "9.3 - Sorting Chirps" { + t.Fatalf("unexpected title: %s", got.LessonTitle) + } +} diff --git a/cmd/submit.go b/cmd/submit.go index 6aca1c2..7a11d8d 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -58,8 +58,6 @@ func submissionHandler(cmd *cobra.Command, args []string) error { ) { return errors.New("submission canceled") } - } else { - fmt.Printf("Running next lesson:\n%s\n", lessonLabel) } lessonUUID = nextLesson.LessonUUID } @@ -69,20 +67,6 @@ func submissionHandler(cmd *cobra.Command, args []string) error { return err } - if len(args) > 0 { - action := "Running" - if isSubmit { - action = "Submitting" - } - fmt.Printf( - "%s lesson:\n%d.%d - %s\n", - action, - lesson.ChapterNumber, - lesson.LessonNumber, - lesson.Lesson.Title, - ) - } - if lesson.Lesson.Type != "type_cli" { return errors.New("unable to run lesson: unsupported lesson type") } @@ -96,12 +80,15 @@ func submissionHandler(cmd *cobra.Command, args []string) error { } overrideBaseURL := viper.GetString("override_base_url") + runContext := render.RunContext{ + Action: lessonRunAction(isSubmit, len(args) == 0), + LessonTitle: fmt.Sprintf("%d.%d - %s", lesson.ChapterNumber, lesson.LessonNumber, lesson.Lesson.Title), + BaseURL: data.BaseURLDefault, + } 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") + runContext.BaseURL = overrideBaseURL } - - send, finish := render.StartRenderer(isSubmit, verboseOutput, isSubmit && len(args) > 0) + send, finish := render.StartRenderer(isSubmit, verboseOutput, isSubmit && len(args) > 0, runContext) finalEvent := api.LessonSubmissionEvent{} var debugPath string var debugWriteErr error @@ -142,6 +129,17 @@ func applySubmissionEvent(data api.CLIData, event api.LessonSubmissionEvent, sen return nil } +func lessonRunAction(isSubmit, isNext bool) string { + action := "Running" + if isSubmit { + action = "Submitting" + } + if isNext { + return action + " next lesson:" + } + return action + " lesson:" +} + func reportDebugFileWrite(path string, err error) { if err != nil { fmt.Fprintf(os.Stderr, "warning: failed to write submission debug output: %v\n", err) diff --git a/main_test.go b/main_test.go index 316e1e9..2bdbf4b 100644 --- a/main_test.go +++ b/main_test.go @@ -32,11 +32,14 @@ func TestLocalTestRedirectedOutput(t *testing.T) { name string fail bool verbose bool + runOnly bool }{ - {"pass verbose", false, true}, - {"fail verbose", true, true}, - {"pass compact", false, false}, - {"fail compact", true, false}, + {"pass verbose", false, true, false}, + {"fail verbose", true, true, false}, + {"pass compact", false, false, false}, + {"fail compact", true, false, false}, + {"run matching expectation", false, false, true}, + {"run mismatched expectation", true, false, true}, } { t.Run(tt.name, func(t *testing.T) { wantExit := 0 @@ -54,6 +57,10 @@ func TestLocalTestRedirectedOutput(t *testing.T) { if tt.verbose { args = append(args, "--verbose") } + if tt.runOnly { + args = append(args, "--run") + wantExit = 0 + } cmd := exec.CommandContext(ctx, binary, args...) var stdout, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &stdout, &stderr @@ -65,17 +72,30 @@ func TestLocalTestRedirectedOutput(t *testing.T) { if strings.Contains(output+stderr.String(), "\x1b") || strings.Contains(stderr.String(), "TTY") { t.Fatalf("terminal output leaked: stdout=%q stderr=%q", output, stderr.String()) } - if strings.Count(output, "Output check") != 1 { + if strings.Count(output, "STEP 1 · Output check")+strings.Count(output, "PASS Step 1 · Output check") != 1 { t.Fatalf("expected one final report: %s", output) } - for _, diagnostic := range []string{"Command stdout:\n\nstdout-marker", "Command stderr:\n\nstderr-marker"} { - if got, want := strings.Contains(output, diagnostic), tt.verbose || tt.fail; got != want { + for _, diagnostic := range []string{"Standard output\n stdout-marker", "Standard error\n stderr-marker"} { + if got, want := strings.Contains(output, diagnostic), tt.verbose || tt.fail || tt.runOnly; got != want { t.Fatalf("diagnostic %q present = %t, want %t; output: %s", diagnostic, got, want, output) } } - if tt.fail { - if !strings.Contains(stderr.String(), "local checks failed") { - t.Fatalf("missing failure error: %s", &stderr) + if tt.runOnly { + if !strings.Contains(output, "Running lesson:") || stderr.Len() != 0 { + t.Fatalf("unexpected run output: %s; stderr: %s", output, &stderr) + } + for _, forbidden := range []string{"PASS", "FAIL", "All tests passed!", "Submitting lesson:"} { + if strings.Contains(output, forbidden) { + t.Fatalf("run exposed %q: %s", forbidden, output) + } + } + } else if tt.fail { + _, recap, ok := strings.Cut(output, "FAILED · ") + if !ok || !strings.Contains(recap, "Step 1 · Output check") || !strings.Contains(recap, "Command: "+command) { + t.Fatalf("missing failure context in final recap: %s", output) + } + if stderr.Len() != 0 { + t.Fatalf("duplicate failure error: %s", &stderr) } } else if stderr.Len() != 0 || !strings.Contains(output, "All tests passed!") { t.Fatalf("unexpected success output: stdout=%q stderr=%q", output, stderr.String()) diff --git a/render/header.go b/render/header.go new file mode 100644 index 0000000..e5f89f6 --- /dev/null +++ b/render/header.go @@ -0,0 +1,32 @@ +package render + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +type RunContext struct { + Action string + LessonTitle string + BaseURL string +} + +func renderRunHeader(context RunContext) string { + if context.LessonTitle == "" { + return "" + } + var str strings.Builder + action := context.Action + if action == "" { + action = "Running lesson:" + } + fmt.Fprintln(&str, action) + fmt.Fprintln(&str, lipgloss.NewStyle().Bold(true).Render(context.LessonTitle)) + if context.BaseURL != "" { + fmt.Fprintf(&str, "%s %s\n", gray.Render("Target:"), context.BaseURL) + } + str.WriteByte('\n') + return str.String() +} diff --git a/render/header_test.go b/render/header_test.go new file mode 100644 index 0000000..69f2d6c --- /dev/null +++ b/render/header_test.go @@ -0,0 +1,31 @@ +package render + +import ( + "strings" + "testing" +) + +func TestLessonHeaderPersistsInFinalReport(t *testing.T) { + m := initModel(true, false) + m.runContext = RunContext{Action: "Submitting lesson:", LessonTitle: "9.3 - Sorting Chirps", BaseURL: "http://localhost:8080"} + m.steps = []stepModel{{description: "Reset database"}} + for _, finalized := range []bool{false, true} { + m.finalized = finalized + view := m.View() + for _, expected := range []string{"Submitting lesson:\n9.3 - Sorting Chirps", "Target: http://localhost:8080"} { + if strings.Count(view, expected) != 1 { + t.Fatalf("expected one %q in:\n%s", expected, view) + } + } + if strings.Index(view, "Submitting lesson:") > strings.Index(view, "Reset database") { + t.Fatal("header follows steps") + } + } +} + +func TestHeaderWithoutTargetShowsOnlyLesson(t *testing.T) { + view := renderRunHeader(RunContext{LessonTitle: "1.3 - Example"}) + if view != "Running lesson:\n1.3 - Example\n\n" { + t.Fatalf("unexpected header: %s", view) + } +} diff --git a/render/http.go b/render/http.go index 925b835..7e1d709 100644 --- a/render/http.go +++ b/render/http.go @@ -12,11 +12,11 @@ import ( func printHTTPRequestResult(result api.HTTPRequestResult) string { if result.Err != "" { - return fmt.Sprintf(" Err: %v\n\n", result.Err) + return fmt.Sprintf("Request error\n%s\n", indentOutput(result.Err, 2)) } var str strings.Builder - fmt.Fprintf(&str, " Response Status Code: %v\n", result.StatusCode) + fmt.Fprintf(&str, "Response status: %v\n", result.StatusCode) filteredHeaders := make(map[string]string) for respK, respV := range result.ResponseHeaders { @@ -54,37 +54,39 @@ func printHTTPRequestResult(result api.HTTPRequestResult) string { } if len(filteredHeaders) > 0 { - str.WriteString(" Response Headers: \n") + str.WriteString("\nResponse headers\n") for k, v := range filteredHeaders { fmt.Fprintf(&str, " - %v: %v\n", k, v) } } - str.WriteString(" Response Body: \n") - bytes := []byte(result.BodyString) - contentType := http.DetectContentType(bytes) - if contentType == "application/json" || strings.HasPrefix(contentType, "text/") { - body := result.BodyString - var unmarshalled any - err := json.Unmarshal([]byte(result.BodyString), &unmarshalled) - if err == nil { - pretty, err := json.MarshalIndent(unmarshalled, "", " ") + if result.BodyString != "" { + str.WriteString("\n" + gray.Render("Response body") + "\n") + bytes := []byte(result.BodyString) + contentType := http.DetectContentType(bytes) + if contentType == "application/json" || strings.HasPrefix(contentType, "text/") { + body := result.BodyString + var unmarshalled any + err := json.Unmarshal([]byte(result.BodyString), &unmarshalled) if err == nil { - body = string(pretty) + pretty, err := json.MarshalIndent(unmarshalled, "", " ") + if err == nil { + body = string(pretty) + } } + str.WriteString(indentOutput(truncateVisualOutput(body), 2)) + } else { + fmt.Fprintf( + &str, + "Binary %s file. Raw data hidden. To manually debug, use curl -o myfile.bin and inspect the file", + contentType, + ) } - str.WriteString(truncateVisualOutput(body)) - } else { - fmt.Fprintf( - &str, - "Binary %s file. Raw data hidden. To manually debug, use curl -o myfile.bin and inspect the file", - contentType, - ) + str.WriteByte('\n') } - str.WriteByte('\n') if len(filteredTrailers) > 0 { - str.WriteString(" Response Trailers: \n") + str.WriteString("\nResponse trailers\n") for k, v := range filteredTrailers { fmt.Fprintf(&str, " - %v: %v\n", k, v) } diff --git a/render/json.go b/render/json.go new file mode 100644 index 0000000..323d27b --- /dev/null +++ b/render/json.go @@ -0,0 +1,51 @@ +package render + +import ( + "fmt" + + "github.com/bootdotdev/bootdev/checks" + api "github.com/bootdotdev/bootdev/client" + "github.com/goccy/go-json" +) + +func renderJSONMismatch(test api.HTTPRequestTestJSONValue, result api.HTTPRequestResult) string { + var expected any + switch { + case test.BoolValue != nil: + expected = *test.BoolValue + case test.IntValue != nil: + expected = *test.IntValue + case test.StringValue != nil: + expected = checks.InterpolateVariables(*test.StringValue, result.Variables) + default: + return "" + } + operator := "" + switch test.Operator { + case api.OpEquals: + case api.OpGreaterThan: + operator = "> " + case api.OpContains: + operator = "contains " + case api.OpNotContains: + operator = "does not contain " + default: + return "" + } + expectedJSON, err := json.Marshal(expected) + if err != nil { + return "" + } + actual, err := checks.JSONValueAtPath(test.Path, result.BodyString) + actualText := "" + if err != nil { + actualText = fmt.Sprintf("unavailable (%s)", err) + } else { + actualJSON, err := json.Marshal(actual) + if err != nil { + return "" + } + actualText = string(actualJSON) + } + return fmt.Sprintf("JSON %s\n Expected: %s%s\n Actual: %s", test.Path, operator, expectedJSON, actualText) +} diff --git a/render/json_test.go b/render/json_test.go new file mode 100644 index 0000000..91c534b --- /dev/null +++ b/render/json_test.go @@ -0,0 +1,66 @@ +package render + +import ( + "strings" + "testing" + + api "github.com/bootdotdev/bootdev/client" +) + +func TestFailedJSONCheckShowsSelectedValue(t *testing.T) { + expectedString := "Darn that fly, I just wanna cook" + variableString := "${name}" + expectedInt := 3 + expectedBool := true + for _, tt := range []struct { + name string + test api.HTTPRequestTestJSONValue + body string + want string + }{ + {name: "array field", test: api.HTTPRequestTestJSONValue{Path: ".[0].body", Operator: api.OpEquals, StringValue: &expectedString}, body: `[{"body":"I'm the one who knocks!"}]`, want: "JSON .[0].body\n Expected: \"Darn that fly, I just wanna cook\"\n Actual: \"I'm the one who knocks!\""}, + {name: "interpolation", test: api.HTTPRequestTestJSONValue{Path: ".name", Operator: api.OpEquals, StringValue: &variableString}, body: `{"name":"other"}`, want: "Expected: \"Walt\"\n Actual: \"other\""}, + {name: "integer comparison", test: api.HTTPRequestTestJSONValue{Path: ".count", Operator: api.OpGreaterThan, IntValue: &expectedInt}, body: `{"count":2}`, want: "Expected: > 3\n Actual: 2"}, + {name: "boolean", test: api.HTTPRequestTestJSONValue{Path: ".ok", Operator: api.OpEquals, BoolValue: &expectedBool}, body: `{"ok":false}`, want: "Expected: true\n Actual: false"}, + {name: "type mismatch", test: api.HTTPRequestTestJSONValue{Path: ".count", Operator: api.OpEquals, IntValue: &expectedInt}, body: `{"count":"3"}`, want: "Expected: 3\n Actual: \"3\""}, + {name: "contains", test: api.HTTPRequestTestJSONValue{Path: ".name", Operator: api.OpContains, StringValue: &variableString}, body: `{"name":"other"}`, want: "Expected: contains \"Walt\""}, + {name: "not contains", test: api.HTTPRequestTestJSONValue{Path: ".name", Operator: api.OpNotContains, StringValue: &variableString}, body: `{"name":"Walter"}`, want: "Expected: does not contain \"Walt\"\n Actual: \"Walter\""}, + {name: "missing", test: api.HTTPRequestTestJSONValue{Path: ".name", Operator: api.OpEquals, StringValue: &variableString}, body: `{}`, want: "Actual: unavailable (value not found)"}, + {name: "multiple results", test: api.HTTPRequestTestJSONValue{Path: ".[].name", Operator: api.OpEquals, StringValue: &variableString}, body: `[{"name":"one"},{"name":"two"}]`, want: "Actual: unavailable (invalid number of values found)"}, + {name: "invalid JSON", test: api.HTTPRequestTestJSONValue{Path: ".name", Operator: api.OpEquals, StringValue: &variableString}, body: `not json`, want: "Actual: unavailable ("}, + {name: "invalid query", test: api.HTTPRequestTestJSONValue{Path: ".[", Operator: api.OpEquals, StringValue: &variableString}, body: `{}`, want: "Actual: unavailable ("}, + } { + t.Run(tt.name, func(t *testing.T) { + step := stepModel{ + tests: []testModel{{text: "original condition"}}, + result: &api.CLIStepResult{HTTPRequestResult: &api.HTTPRequestResult{ + BodyString: tt.body, Variables: map[string]string{"name": "Walt"}, + Request: api.CLIStepHTTPRequest{Tests: []api.HTTPRequestTest{{JSONValue: &tt.test}}}, + }}, + } + got := renderCheck(step, 0, "FAIL") + if !strings.HasPrefix(got, "FAIL · JSON ") || !strings.Contains(got, tt.want) { + t.Fatalf("expected %q in:\n%s", tt.want, got) + } + if got := renderCheck(step, 0, "PASS"); got != "PASS · original condition" { + t.Fatalf("successful assertion should retain its concise description: %s", got) + } + }) + } +} + +func TestCompoundJSONCheckRetainsOtherConditions(t *testing.T) { + expectedStatus, expectedInt := 200, 3 + step := stepModel{ + tests: []testModel{{text: "status and JSON conditions"}}, + result: &api.CLIStepResult{HTTPRequestResult: &api.HTTPRequestResult{ + Request: api.CLIStepHTTPRequest{Tests: []api.HTTPRequestTest{{ + StatusCode: &expectedStatus, + JSONValue: &api.HTTPRequestTestJSONValue{Path: ".count", Operator: api.OpEquals, IntValue: &expectedInt}, + }}}, + }}, + } + if got := renderCheck(step, 0, "FAIL"); got != "FAIL · status and JSON conditions" { + t.Fatalf("compound assertion lost context: %s", got) + } +} diff --git a/render/models.go b/render/models.go index dc135de..8edc980 100644 --- a/render/models.go +++ b/render/models.go @@ -23,6 +23,7 @@ type stepModel struct { } type rootModel struct { + runContext RunContext steps []stepModel spinner spinner.Model result api.VerificationResultSlug diff --git a/render/render.go b/render/render.go index 672237c..7642057 100644 --- a/render/render.go +++ b/render/render.go @@ -15,13 +15,12 @@ import ( ) var ( - green lipgloss.Style - red lipgloss.Style - magenta lipgloss.Style - yellow lipgloss.Style - gray lipgloss.Style - white lipgloss.Style - borderBox = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()) + green lipgloss.Style + red lipgloss.Style + magenta lipgloss.Style + yellow lipgloss.Style + gray lipgloss.Style + white lipgloss.Style ) func (m rootModel) Init() tea.Cmd { @@ -106,8 +105,9 @@ func (m rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } -func StartRenderer(isSubmit bool, verbose bool, showOmitLessonIDTip bool) (func(tea.Msg), func(api.LessonSubmissionEvent)) { +func StartRenderer(isSubmit bool, verbose bool, showOmitLessonIDTip bool, runContext RunContext) (func(tea.Msg), func(api.LessonSubmissionEvent)) { m := initModel(isSubmit, verbose) + m.runContext = runContext m.showOmitLessonIDTip = showOmitLessonIDTip interactive := term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) options := []tea.ProgramOption{tea.WithoutSignalHandler(), tea.WithInput(nil)} diff --git a/render/step.go b/render/step.go new file mode 100644 index 0000000..6abebaa --- /dev/null +++ b/render/step.go @@ -0,0 +1,103 @@ +package render + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +func indentOutput(text string, spaces int) string { + prefix := strings.Repeat(" ", spaces) + lines := strings.Split(strings.TrimRight(text, "\n"), "\n") + for index, line := range lines { + if line != "" { + lines[index] = prefix + line + } + } + return strings.Join(lines, "\n") +} + +func renderDetailedStep(step stepModel, number int, isSubmit bool) string { + var str strings.Builder + fmt.Fprintf(&str, "%s\n%s\n", lipgloss.NewStyle().Bold(true).Render(fmt.Sprintf("STEP %d · %s", number, step.description)), step.detail) + if step.noPenaltyOnFail { + str.WriteString(gray.Render("Safe step") + "\n") + } + // Keep execution order intact while bringing failed assertions to the front. + for _, failed := range []bool{true, false} { + for index, test := range step.tests { + if !isSubmit || test.passed == nil || (!*test.passed) != failed { + continue + } + status := "PASS" + if failed { + status = "FAIL" + } + check := renderCheck(step, index, status) + check = renderStatus(status) + strings.TrimPrefix(check, status) + check = strings.ReplaceAll(check, "\n Expected:", "\n "+gray.Render("Expected:")) + check = strings.ReplaceAll(check, "\n Actual:", "\n "+gray.Render("Actual:")) + fmt.Fprintf(&str, "\n%s\n", indentOutput(check, 2)) + } + } + if step.result != nil { + str.WriteString(renderStepResult(step)) + } + unknownHeading := false + for _, test := range step.tests { + if isSubmit && test.passed != nil { + continue + } + if !unknownHeading { + heading := "Checks" + if isSubmit { + heading = "Checks not evaluated" + } + str.WriteString("\n " + gray.Render(heading) + "\n") + unknownHeading = true + } else { + str.WriteByte('\n') + } + fmt.Fprintf(&str, "%s\n", indentOutput(test.text, 4)) + } + if step.sleepAfter != "" { + fmt.Fprintf(&str, "\n %s\n", step.sleepAfter) + } + str.WriteByte('\n') + return str.String() +} + +func renderStatus(status string) string { + switch status { + case "PASS": + return green.Bold(true).Render(status) + case "FAIL", "FAILED": + return red.Bold(true).Render(status) + default: + return gray.Render(status) + } +} + +func renderCheck(step stepModel, index int, status string) string { + if step.result != nil { + if result := step.result.CLICommandResult; result != nil && index < len(result.Command.Tests) { + test := result.Command.Tests[index] + if test.ExitCode != nil && test.StdoutContainsAll == nil && test.StdoutContainsNone == nil && test.StdoutLinesGT == nil && test.StdoutJq == nil { + return fmt.Sprintf("%s · Exit code\n Expected: %d\n Actual: %d", status, *test.ExitCode, result.ExitCode) + } + } + if result := step.result.HTTPRequestResult; result != nil && index < len(result.Request.Tests) { + test := result.Request.Tests[index] + if status == "FAIL" && test.JSONValue != nil && test.StatusCode == nil && test.BodyContains == nil && test.BodyContainsNone == nil && test.HeadersEqual == nil && test.HeadersContain == nil && test.TrailersEqual == nil && test.TrailersContain == nil { + if detail := renderJSONMismatch(*test.JSONValue, *result); detail != "" { + return status + " · " + detail + } + } + if test.StatusCode != nil && test.BodyContains == nil && test.BodyContainsNone == nil && test.HeadersEqual == nil && test.HeadersContain == nil && test.TrailersEqual == nil && test.TrailersContain == nil && test.JSONValue == nil { + return fmt.Sprintf("%s · HTTP status\n Expected: %d\n Actual: %d", status, *test.StatusCode, result.StatusCode) + } + } + } + return status + " · " + step.tests[index].text +} diff --git a/render/step_test.go b/render/step_test.go new file mode 100644 index 0000000..282a59e --- /dev/null +++ b/render/step_test.go @@ -0,0 +1,53 @@ +package render + +import ( + "strings" + "testing" + + api "github.com/bootdotdev/bootdev/client" +) + +func TestDetailedStepGroupsFailureAndDiagnostics(t *testing.T) { + passed, failed := true, false + expectedExit := 0 + step := stepModel{ + description: "Read log", detail: "Command: cat repl.log", + tests: []testModel{ + {text: "earlier passing check", passed: &passed}, + {text: "Expect exit code 0", passed: &failed}, + {text: "Expect stdout to contain:\n greeting"}, + }, + result: &api.CLIStepResult{CLICommandResult: &api.CLICommandResult{ + ExitCode: 1, Stderr: "missing file\ncheck path", + Command: api.CLIStepCLICommand{Tests: []api.CLICommandTest{{}, {ExitCode: &expectedExit}, {}}}, + }}, + } + view := renderDetailedStep(step, 1, true) + for _, expected := range []string{ + "STEP 1 · Read log\nCommand: cat repl.log", + " FAIL · Exit code\n Expected: 0\n Actual: 1", + " Standard error\n missing file\n check path", + " Checks not evaluated\n Expect stdout to contain:\n greeting", + } { + if !strings.Contains(view, expected) { + t.Errorf("missing %q in:\n%s", expected, view) + } + } + if strings.Index(view, "FAIL ·") > strings.Index(view, "PASS ·") { + t.Error("passing check buried the failure") + } + if strings.Contains(view, "Standard output") || strings.Contains(view, "Command exit code") { + t.Errorf("empty or duplicate diagnostics in:\n%s", view) + } +} + +func TestDetailedRunDoesNotInventGrades(t *testing.T) { + step := stepModel{description: "Run", detail: "Command: ./app", tests: []testModel{{text: "Expect exit code 0"}}} + view := renderDetailedStep(step, 1, false) + if strings.Contains(view, "PASS") || strings.Contains(view, "FAIL") { + t.Fatalf("ungraded checks acquired a status: %s", view) + } + if !strings.Contains(view, "Expect exit code 0") { + t.Fatal("expected condition was lost") + } +} diff --git a/render/variables.go b/render/variables.go index 034651e..7206fcc 100644 --- a/render/variables.go +++ b/render/variables.go @@ -26,9 +26,9 @@ func renderVariableSection(title string, entries []variableEntry) string { }) var str strings.Builder - fmt.Fprintf(&str, " %s: \n", title) + fmt.Fprintf(&str, "\n%s:\n", title) for _, entry := range entries { - fmt.Fprintf(&str, " - %s: %s (%s)\n", entry.name, formatVariableValue(entry.value, entry.found), entry.description) + fmt.Fprintf(&str, " - %s: %s (%s)\n", entry.name, formatVariableValue(entry.value, entry.found), entry.description) } return str.String() } diff --git a/render/view.go b/render/view.go index faa96ab..d32130e 100644 --- a/render/view.go +++ b/render/view.go @@ -7,61 +7,10 @@ import ( "unicode/utf8" api "github.com/bootdotdev/bootdev/client" - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/lipgloss" ) const safeStepIcon = "🛡︎" -func renderTestHeader(header string, spinner spinner.Model, isFinished bool, isSubmit bool, passed *bool, noPenaltyOnFail bool) string { - if noPenaltyOnFail { - header = fmt.Sprintf("%s %s", header, white.Render(safeStepIcon)) - } - cmdStr := renderTest(header, spinner.View(), isFinished, isSubmit, passed) - box := borderBox.Render(fmt.Sprintf(" %s ", cmdStr)) - sliced := strings.Split(box, "\n") - sliced[2] = strings.Replace(sliced[2], "─", "┬", 1) - return strings.Join(sliced, "\n") + "\n" -} - -func renderTests(tests []testModel, spinner string) string { - var str strings.Builder - var edges strings.Builder - - for _, test := range tests { - testStr := renderTest(test.text, spinner, test.finished, true, test.passed) - testStr = fmt.Sprintf(" %s", testStr) - height := lipgloss.Height(testStr) - - edges.Reset() - edges.WriteString(" ├─") - for i := 1; i < height; i++ { - edges.WriteString("\n │ ") - } - - str.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, edges.String(), testStr)) - str.WriteByte('\n') - } - - return str.String() -} - -func renderTest(text string, spinner string, isFinished bool, showStatus bool, passed *bool) string { - testStr := "" - if !isFinished { - testStr += fmt.Sprintf("%s %s", spinner, text) - } else if !showStatus { - testStr += text - } else if passed == nil { - testStr += gray.Render(fmt.Sprintf("? %s", text)) - } else if *passed { - testStr += green.Render(fmt.Sprintf("✓ %s", text)) - } else { - testStr += red.Render(fmt.Sprintf("X %s", text)) - } - return testStr -} - func renderJqOutputs(outputs []api.CLICommandJqOutput) string { if len(outputs) == 0 { return "" @@ -101,8 +50,9 @@ func (m rootModel) View() string { } s := m.spinner.View() var str strings.Builder + str.WriteString(renderRunHeader(m.runContext)) failedStepIndex := -1 - if m.failure != nil && m.failure.FailedStepIndex >= 0 && m.failure.FailedStepIndex < len(m.steps) { + if m.isSubmit && m.failure != nil && m.failure.FailedStepIndex >= 0 && m.failure.FailedStepIndex < len(m.steps) { failedStepIndex = m.failure.FailedStepIndex } for i, step := range m.steps { @@ -112,29 +62,12 @@ func (m rootModel) View() string { showAllDetails := m.finalized && (m.verbose || !m.isSubmit) failed := step.passed != nil && !*step.passed - if showAllDetails { - str.WriteString(renderTestHeader(step.description, m.spinner, step.finished, m.isSubmit, step.passed, step.noPenaltyOnFail)) - fmt.Fprintf(&str, " > %s\n", step.detail) - str.WriteString(renderTests(step.tests, s)) + if showAllDetails || (failed && m.finalized) { + str.WriteString(renderDetailedStep(step, i+1, m.isSubmit)) } else { + step.description = fmt.Sprintf("Step %d · %s", i+1, step.description) str.WriteString(renderCompactStep(step, s, m.isSubmit)) - if failed && m.finalized { - fmt.Fprintf(&str, "\n > %s\n", step.detail) - str.WriteString(renderTests(step.tests, s)) - } - } - - if step.sleepAfter != "" && step.finished && showAllDetails { - sleepBox := borderBox.Render(fmt.Sprintf(" %s ", step.sleepAfter)) - str.WriteString(sleepBox) - str.WriteByte('\n') - } - - if step.result == nil || !m.finalized || (!showAllDetails && !failed) { - continue } - - str.WriteString(renderStepResult(step)) } if m.result == api.VerificationResultSlugSuccess && m.isSubmit { @@ -186,27 +119,15 @@ func (m rootModel) View() string { str.WriteString(red.Render("Please try again.")) str.WriteByte('\n') str.WriteByte('\n') - } else if m.result == api.VerificationResultSlugNoop { - str.WriteString("\n\nTests failed! ❌") - fmt.Fprintf(&str, "\n\nFailed Step: %v", m.failure.FailedStepIndex+1) - str.WriteString("\nError: ") - str.WriteString(m.failure.ErrorMessage) + } else if m.isSubmit && m.result == api.VerificationResultSlugNoop { + str.WriteString(m.renderFailureSummary()) str.WriteByte('\n') str.WriteByte('\n') str.WriteString(white.Render(safeStepIcon)) str.WriteString(" This was a safe step.\n") str.WriteString("You haven't passed, but you also haven't lost armor or Sharpshooter progress.\n\n") - } else if m.result == api.VerificationResultSlugFailure { - str.WriteByte('\n') - str.WriteByte('\n') - str.WriteString(red.Render("Tests failed! ❌")) - if m.failure != nil { - str.WriteString(red.Render(fmt.Sprintf("\n\nFailed Step: %v", m.failure.FailedStepIndex+1))) - str.WriteString(red.Render(fmt.Sprintf("\nError: %s", m.failure.ErrorMessage))) - } else { - str.WriteString(red.Render("\n\nFailed Step: unknown")) - str.WriteString(red.Render("\nError: unknown")) - } + } else if m.isSubmit && m.result == api.VerificationResultSlugFailure { + str.WriteString(m.renderFailureSummary()) str.WriteByte('\n') str.WriteByte('\n') currentDate := time.Now().Format("2006-01-02") @@ -218,8 +139,36 @@ func (m rootModel) View() string { return str.String() } +func (m rootModel) renderFailureSummary() string { + if m.failure == nil { + return "\n" + renderStatus("FAILED") + " · Step unknown\nError: unknown" + } + var str strings.Builder + index := m.failure.FailedStepIndex + if index >= 0 && index < len(m.steps) { + step := m.steps[index] + fmt.Fprintf(&str, "\n%s · Step %d · %s\n%s", renderStatus("FAILED"), index+1, step.description, step.detail) + } else { + str.WriteString("\n" + renderStatus("FAILED") + " · Step unknown") + } + fmt.Fprintf(&str, "\nError: %s", m.failure.ErrorMessage) + return str.String() +} + func renderCompactStep(step stepModel, spinner string, isSubmit bool) string { - line := renderTest(step.description, spinner, step.finished, isSubmit, step.passed) + line := step.description + if !step.finished { + line = spinner + " " + line + } else if isSubmit { + status := "?" + if step.passed != nil { + status = "PASS" + if !*step.passed { + status = "FAIL" + } + } + line = renderStatus(status) + " " + line + } if step.noPenaltyOnFail { line = fmt.Sprintf("%s %s", line, white.Render(safeStepIcon)) } @@ -228,33 +177,41 @@ func renderCompactStep(step stepModel, spinner string, isSubmit bool) string { func renderStepResult(step stepModel) string { if failure := step.result.DependencyFailure; failure != nil { - return " > Skipped: unavailable variables: " + strings.Join(failure.Names, ", ") + "\n" + return "\n Skipped: unavailable variables: " + strings.Join(failure.Names, ", ") + "\n" } var str strings.Builder if step.result.CLICommandResult != nil { - for _, test := range step.result.CLICommandResult.Command.Tests { - if test.ExitCode != nil { - fmt.Fprintf(&str, "\n > Command exit code: %d\n", step.result.CLICommandResult.ExitCode) - break + exitCodeShown := false + for index, test := range step.result.CLICommandResult.Command.Tests { + if test.ExitCode != nil && test.StdoutContainsAll == nil && test.StdoutContainsNone == nil && test.StdoutLinesGT == nil && test.StdoutJq == nil && index < len(step.tests) && step.tests[index].passed != nil { + exitCodeShown = true } } - str.WriteString(" > Command stdout:\n\n") - str.WriteString(gray.Render(truncateVisualOutput(step.result.CLICommandResult.Stdout))) - str.WriteByte('\n') + if !exitCodeShown { + fmt.Fprintf(&str, "\n Command exit code: %d\n", step.result.CLICommandResult.ExitCode) + } + if step.result.CLICommandResult.Stdout != "" { + str.WriteString("\n " + gray.Render("Standard output") + "\n") + str.WriteString(indentOutput(truncateVisualOutput(step.result.CLICommandResult.Stdout), 4)) + str.WriteByte('\n') + } if step.result.CLICommandResult.Stderr != "" { - str.WriteString(" > Command stderr:\n\n") - str.WriteString(gray.Render(truncateVisualOutput(step.result.CLICommandResult.Stderr))) + str.WriteString("\n " + gray.Render("Standard error") + "\n") + str.WriteString(indentOutput(truncateVisualOutput(step.result.CLICommandResult.Stderr), 4)) str.WriteByte('\n') } str.WriteString(renderJqOutputs(step.result.CLICommandResult.JqOutputs)) availableVariables, expectsVariables := availableVariablesForCLIResult(*step.result.CLICommandResult) if expectsVariables { - str.WriteString(renderVariableSection("Variables Available", availableVariables)) + str.WriteString(indentOutput(renderVariableSection("Variables Available", availableVariables), 2)) + str.WriteByte('\n') } } if step.result.HTTPRequestResult != nil { - str.WriteString(printHTTPRequestResult(*step.result.HTTPRequestResult)) + str.WriteByte('\n') + str.WriteString(indentOutput(printHTTPRequestResult(*step.result.HTTPRequestResult), 2)) + str.WriteByte('\n') } return str.String() } diff --git a/render/view_test.go b/render/view_test.go index 69a2627..588362a 100644 --- a/render/view_test.go +++ b/render/view_test.go @@ -48,8 +48,8 @@ func TestCompactViewHidesSuccessfulDetailsAndExpandsFailure(t *testing.T) { view := m.View() for _, expected := range []string{ - "✓ The health check succeeds", - "X Email changes require a password", + "PASS Step 1 · The health check succeeds", + "STEP 2 · Email changes require a password", "Request: POST http://localhost:3000/account/email", "Expecting status code: 403", "failed response body", @@ -69,6 +69,71 @@ func TestCompactViewHidesSuccessfulDetailsAndExpandsFailure(t *testing.T) { } } +func TestNumberedStepsAcrossOutputModes(t *testing.T) { + for _, tt := range []struct { + name string + submit bool + verbose bool + finalized bool + }{ + {name: "running"}, + {name: "run", finalized: true}, + {name: "submit", submit: true, finalized: true}, + {name: "verbose submit", submit: true, verbose: true, finalized: true}, + } { + t.Run(tt.name, func(t *testing.T) { + m := initModel(tt.submit, tt.verbose) + m.finalized = tt.finalized + m.steps = []stepModel{{description: "Build", finished: true}, {description: "Run", finished: true}} + for range 2 { + view := m.View() + for _, expected := range []string{"1 · Build", "2 · Run"} { + if strings.Count(view, expected) != 1 { + t.Fatalf("expected one %q in view:\n%s", expected, view) + } + } + } + if m.steps[0].description != "Build" { + t.Fatal("rendering changed the stored description") + } + }) + } +} + +func TestFailureSummaryIncludesFailedStepContext(t *testing.T) { + for _, tt := range []struct { + name string + failure *api.StructuredErrCLI + detail string + safe bool + want string + }{ + {name: "command", failure: &api.StructuredErrCLI{FailedStepIndex: 1, ErrorMessage: "wrong exit code"}, detail: "Command: ./app", want: "Step 2 · Run\nCommand: ./app\nError: wrong exit code"}, + {name: "request", failure: &api.StructuredErrCLI{FailedStepIndex: 1, ErrorMessage: "wrong status"}, detail: "Request: GET http://localhost/health", want: "Step 2 · Run\nRequest: GET http://localhost/health\nError: wrong status"}, + {name: "safe step", failure: &api.StructuredErrCLI{FailedStepIndex: 1, ErrorMessage: "missing configuration"}, detail: "Command: ./app", safe: true, want: "Step 2 · Run\nCommand: ./app\nError: missing configuration"}, + {name: "missing failure", want: "Step unknown\nError: unknown"}, + {name: "missing safe failure", safe: true, want: "Step unknown\nError: unknown"}, + {name: "negative index", failure: &api.StructuredErrCLI{FailedStepIndex: -1, ErrorMessage: "invalid"}, want: "Step unknown\nError: invalid"}, + {name: "out of range", failure: &api.StructuredErrCLI{FailedStepIndex: 2, ErrorMessage: "invalid"}, want: "Step unknown\nError: invalid"}, + } { + t.Run(tt.name, func(t *testing.T) { + m := initModel(true, false) + m.finalized = true + m.result = api.VerificationResultSlugFailure + if tt.safe { + m.result = api.VerificationResultSlugNoop + } + m.failure = tt.failure + m.steps = []stepModel{{description: "Build", finished: true}, {description: "Run", detail: tt.detail, finished: true}} + view := m.View() + _, recap, ok := strings.Cut(view, "FAILED · ") + if !ok || !strings.Contains(recap, tt.want) { + t.Fatalf("expected final recap containing %q, got:\n%s", tt.want, view) + } + }) + } +} + func TestCompactViewDoesNotHideStepsForInvalidFailureIndex(t *testing.T) { for _, failedStepIndex := range []int{-1, 2} { t.Run(fmt.Sprintf("index_%d", failedStepIndex), func(t *testing.T) { @@ -102,20 +167,43 @@ func TestSystemErrorViewDoesNotShowStepsAsPassed(t *testing.T) { view := m.View() for _, expected := range []string{ - "? The command prints a greeting", + "? Step 1 · The command prints a greeting", "system error", } { if !strings.Contains(view, expected) { t.Errorf("view missing %q\n%s", expected, view) } } - for _, unexpected := range []string{"✓ The command prints a greeting", "All tests passed!"} { + for _, unexpected := range []string{"PASS Step 1 · The command prints a greeting", "All tests passed!"} { if strings.Contains(view, unexpected) { t.Errorf("view unexpectedly contains %q\n%s", unexpected, view) } } } +func TestRunViewNeverShowsGradingResults(t *testing.T) { + passed, failed := true, false + m := initModel(false, false) + m.finalized = true + m.result = api.VerificationResultSlugFailure + m.failure = &api.StructuredErrCLI{FailedStepIndex: 0, ErrorMessage: "grading failure"} + m.steps = []stepModel{ + {description: "First command", finished: true, passed: &failed, tests: []testModel{{text: "Expect exit code 0", passed: &failed}}}, + {description: "Second command", finished: true, passed: &passed, tests: []testModel{{text: "Expect stdout to contain hello", passed: &passed}}}, + } + view := m.View() + for _, forbidden := range []string{"PASS", "FAIL", "grading failure", "Checks not evaluated"} { + if strings.Contains(view, forbidden) { + t.Fatalf("run exposed grading text %q:\n%s", forbidden, view) + } + } + for _, expected := range []string{"First command", "Second command", "Expect exit code 0", "Expect stdout to contain hello"} { + if !strings.Contains(view, expected) { + t.Fatalf("run lost %q:\n%s", expected, view) + } + } +} + func TestTruncateVisualOutputCapsLinesAndRunes(t *testing.T) { tests := []struct { name string diff --git a/version.txt b/version.txt index 7a48901..cde949b 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v1.32.6 +v1.33.0