diff --git a/checks/runner.go b/checks/runner.go index b6c54ee..fc080d9 100644 --- a/checks/runner.go +++ b/checks/runner.go @@ -33,6 +33,7 @@ func CLIChecks(cliData api.CLIData, overrideBaseURL string, ch chan tea.Msg) (re // This is the magic of the initial message sent before executing the test if step.CLICommand != nil { ch <- messages.StartStepMsg{ + Description: step.Description, CMD: step.CLICommand.Command, TmdlQuery: step.CLICommand.StdoutFilterTmdl, NoPenaltyOnFail: step.NoPenaltyOnFail, @@ -42,6 +43,7 @@ func CLIChecks(cliData api.CLIData, overrideBaseURL string, ch chan tea.Msg) (re interpolatedURL := InterpolateVariables(fullURL, variables) ch <- messages.StartStepMsg{ + Description: step.Description, URL: interpolatedURL, Method: step.HTTPRequest.Request.Method, NoPenaltyOnFail: step.NoPenaltyOnFail, diff --git a/client/lessons.go b/client/lessons.go index 5875d99..481e452 100644 --- a/client/lessons.go +++ b/client/lessons.go @@ -28,6 +28,7 @@ type CLIData struct { } type CLIStep struct { + Description string `yaml:"description"` CLICommand *CLIStepCLICommand `yaml:"cliCommand"` HTTPRequest *CLIStepHTTPRequest `yaml:"httpRequest"` NoPenaltyOnFail bool `yaml:"noPenaltyOnFail"` diff --git a/cmd/localtest.go b/cmd/localtest.go index 1d42ebd..5d64279 100644 --- a/cmd/localtest.go +++ b/cmd/localtest.go @@ -19,6 +19,7 @@ import ( func init() { rootCmd.AddCommand(localTestCmd) + localTestCmd.Flags().BoolVarP(&verboseOutput, "verbose", "v", false, "show detailed output for every step") } var localTestCmd = &cobra.Command{ @@ -46,7 +47,7 @@ func localTestHandler(cmd *cobra.Command, args []string) error { } ch := make(chan tea.Msg, 1) - finalise := render.StartRenderer(data, true, ch) + finalise := render.StartRenderer(data, true, verboseOutput, ch) cliResults := checks.CLIChecks(data, overrideBaseURL, ch) submissionEvent := checks.LocalSubmissionEvent(data, cliResults) diff --git a/cmd/localtest_test.go b/cmd/localtest_test.go index 1ed69fd..913eff9 100644 --- a/cmd/localtest_test.go +++ b/cmd/localtest_test.go @@ -15,7 +15,8 @@ func TestReadLocalCLIDataAcceptsLessonDirectory(t *testing.T) { - darwin baseURLDefault: http://localhost:3000 steps: - - cliCommand: + - description: Prints a greeting + cliCommand: command: echo hello tests: - exitCode: 0 @@ -36,6 +37,9 @@ steps: if len(data.Steps) != 1 || data.Steps[0].CLICommand == nil { t.Fatalf("expected one CLI command step, got %#v", data.Steps) } + if data.Steps[0].Description != "Prints a greeting" { + t.Fatalf("Description = %q, want manifest description", data.Steps[0].Description) + } if len(data.Steps[0].CLICommand.Tests[1].StdoutContainsAll) != 1 { t.Fatalf("expected stdoutContainsAll test to load") } diff --git a/cmd/run.go b/cmd/run.go index a40146f..4032bf0 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -8,6 +8,7 @@ func init() { rootCmd.AddCommand(runCmd) runCmd.Flags().BoolVarP(&forceSubmit, "submit", "s", false, "shortcut flag to submit after running") runCmd.Flags().BoolVar(&debugSubmission, "debug", false, "log submission request/response debug output") + runCmd.Flags().BoolVarP(&verboseOutput, "verbose", "v", false, "show detailed output for every step") } // runCmd represents the run command diff --git a/cmd/submit.go b/cmd/submit.go index bb47f12..3e9f9b2 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -20,11 +20,13 @@ import ( var ( forceSubmit bool debugSubmission bool + verboseOutput bool ) func init() { rootCmd.AddCommand(submitCmd) submitCmd.Flags().BoolVar(&debugSubmission, "debug", false, "log submission request/response debug output") + submitCmd.Flags().BoolVarP(&verboseOutput, "verbose", "v", false, "show detailed output for every step") } // submitCmd represents the submit command @@ -73,7 +75,7 @@ func submissionHandler(cmd *cobra.Command, args []string) error { ch := make(chan tea.Msg, 1) // StartRenderer and returns immediately, finalise function blocks the execution until the renderer is closed. - finalise := render.StartRenderer(data, isSubmit, ch) + finalise := render.StartRenderer(data, isSubmit, verboseOutput, ch) cliResults := checks.CLIChecks(data, overrideBaseURL, ch) diff --git a/messages/messages.go b/messages/messages.go index 073fa8c..475b8c4 100644 --- a/messages/messages.go +++ b/messages/messages.go @@ -3,6 +3,7 @@ package messages import api "github.com/bootdotdev/bootdev/client" type StartStepMsg struct { + Description string CMD string URL string Method string diff --git a/render/http.go b/render/http.go index e46b3e5..3eccb0c 100644 --- a/render/http.go +++ b/render/http.go @@ -56,18 +56,16 @@ func printHTTPRequestResult(result api.HTTPRequestResult) string { 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 err == nil { - str.Write(pretty) - } else { - str.WriteString(result.BodyString) + body = string(pretty) } - } else { - str.WriteString(result.BodyString) } + str.WriteString(truncateVisualOutput(body)) } else { fmt.Fprintf( &str, diff --git a/render/models.go b/render/models.go index 1574b6e..47160b6 100644 --- a/render/models.go +++ b/render/models.go @@ -12,7 +12,8 @@ type testModel struct { } type stepModel struct { - step string + description string + detail string passed *bool result *api.CLIStepResult finished bool @@ -29,16 +30,18 @@ type rootModel struct { xpReward int xpBreakdown []api.XPBreakdownItem isSubmit bool + verbose bool finalized bool clear bool } -func initModel(isSubmit bool) rootModel { +func initModel(isSubmit bool, verbose bool) rootModel { s := spinner.New() s.Spinner = spinner.Dot return rootModel{ spinner: s, isSubmit: isSubmit, + verbose: verbose, steps: []stepModel{}, } } diff --git a/render/render.go b/render/render.go index 91dbe94..f3b61c1 100644 --- a/render/render.go +++ b/render/render.go @@ -3,6 +3,7 @@ package render import ( "fmt" "os" + "strings" "sync" api "github.com/bootdotdev/bootdev/client" @@ -42,15 +43,21 @@ func (m rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case messages.StartStepMsg: - step := fmt.Sprintf("Running: %s", msg.CMD) + description := strings.TrimSpace(msg.Description) + detail := fmt.Sprintf("Command: %s", msg.CMD) if msg.TmdlQuery != nil { - step += fmt.Sprintf(" (TMDL query: '%s')", *msg.TmdlQuery) + detail += fmt.Sprintf(" (TMDL query: '%s')", *msg.TmdlQuery) } if msg.CMD == "" { - step = fmt.Sprintf("%s %s", msg.Method, msg.URL) + detail = fmt.Sprintf("Request: %s %s", msg.Method, msg.URL) + } + if description == "" { + description = strings.TrimPrefix(detail, "Command: ") + description = strings.TrimPrefix(description, "Request: ") } m.steps = append(m.steps, stepModel{ - step: step, + description: description, + detail: detail, tests: []testModel{}, noPenaltyOnFail: msg.NoPenaltyOnFail, }) @@ -97,9 +104,9 @@ func (m rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } -func StartRenderer(data api.CLIData, isSubmit bool, ch chan tea.Msg) func(api.LessonSubmissionEvent) { +func StartRenderer(data api.CLIData, isSubmit bool, verbose bool, ch chan tea.Msg) func(api.LessonSubmissionEvent) { var wg sync.WaitGroup - p := tea.NewProgram(initModel(isSubmit), tea.WithoutSignalHandler()) + p := tea.NewProgram(initModel(isSubmit, verbose), tea.WithoutSignalHandler()) wg.Add(1) go func() { diff --git a/render/view.go b/render/view.go index c67fa57..b340e73 100644 --- a/render/view.go +++ b/render/view.go @@ -101,53 +101,40 @@ func (m rootModel) View() string { } s := m.spinner.View() var str strings.Builder - for _, step := range m.steps { - str.WriteString(renderTestHeader(step.step, m.spinner, step.finished, m.isSubmit, step.passed, step.noPenaltyOnFail)) - str.WriteString(renderTests(step.tests, s)) + failedStepIndex := -1 + if m.failure != nil && m.failure.FailedStepIndex >= 0 && m.failure.FailedStepIndex < len(m.steps) { + failedStepIndex = m.failure.FailedStepIndex + } + for i, step := range m.steps { + if m.finalized && !m.verbose && failedStepIndex >= 0 && i > failedStepIndex { + break + } + + showAllDetails := m.verbose || (!m.isSubmit && m.finalized) + 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)) + } else { + 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 { + 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 { + if step.result == nil || !m.finalized || (!showAllDetails && !failed) { continue } - if step.result.CLICommandResult != nil { - for _, test := range step.tests { - if strings.Contains(test.text, "exit code") { - fmt.Fprintf(&str, "\n > Command exit code: %d\n", step.result.CLICommandResult.ExitCode) - break - } - } - str.WriteString(" > Command stdout:\n\n") - sliced := strings.SplitSeq(step.result.CLICommandResult.Stdout, "\n") - i := 0 - runeCount := 0 - const maxLines, maxRunes = 32, 5120 - for s := range sliced { - if i >= maxLines || runeCount >= maxRunes { - str.WriteString(gray.Render("... output visually truncated, full output captured")) - str.WriteByte('\n') - break - } - runeCount += utf8.RuneCountInString(s) - str.WriteString(gray.Render(s)) - str.WriteByte('\n') - i++ - } - str.WriteString(renderJqOutputs(step.result.CLICommandResult.JqOutputs)) - availableVariables, expectsVariables := availableVariablesForCLIResult(*step.result.CLICommandResult) - if expectsVariables { - str.WriteString(renderVariableSection("Variables Available", availableVariables)) - } - } - - if step.result.HTTPRequestResult != nil { - str.WriteString(printHTTPRequestResult(*step.result.HTTPRequestResult)) - } + str.WriteString(renderStepResult(step)) } if m.result == api.VerificationResultSlugSuccess && m.isSubmit { @@ -196,9 +183,6 @@ func (m rootModel) View() string { str.WriteByte('\n') str.WriteString(red.Render("Tests failed! ❌")) if m.failure != nil { - if m.failure.FailedStepIndex >= 0 && m.failure.FailedStepIndex < len(m.steps) { - str.WriteString(red.Render(fmt.Sprintf("\n\nFailed Command: %s", m.steps[m.failure.FailedStepIndex].step))) - } 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 { @@ -215,3 +199,76 @@ func (m rootModel) View() string { return str.String() } + +func renderCompactStep(step stepModel, spinner string, isSubmit bool) string { + line := renderTest(step.description, spinner, step.finished, &isSubmit, step.passed) + if step.noPenaltyOnFail { + line = fmt.Sprintf("%s %s", line, white.Render(safeStepIcon)) + } + return line + "\n" +} + +func renderStepResult(step stepModel) string { + var str strings.Builder + if step.result.CLICommandResult != nil { + for _, test := range step.tests { + if strings.Contains(strings.ToLower(test.text), "exit code") { + fmt.Fprintf(&str, "\n > Command exit code: %d\n", step.result.CLICommandResult.ExitCode) + break + } + } + str.WriteString(" > Command stdout:\n\n") + str.WriteString(gray.Render(truncateVisualOutput(step.result.CLICommandResult.Stdout))) + str.WriteByte('\n') + str.WriteString(renderJqOutputs(step.result.CLICommandResult.JqOutputs)) + availableVariables, expectsVariables := availableVariablesForCLIResult(*step.result.CLICommandResult) + if expectsVariables { + str.WriteString(renderVariableSection("Variables Available", availableVariables)) + } + } + + if step.result.HTTPRequestResult != nil { + str.WriteString(printHTTPRequestResult(*step.result.HTTPRequestResult)) + } + return str.String() +} + +func truncateVisualOutput(output string) string { + const maxLines, maxRunes = 32, 5120 + var str strings.Builder + str.Grow(min(len(output), maxRunes*utf8.UTFMax)) + lineCount := 1 + runeCount := 0 + offset := 0 + endsWithNewline := false + + for offset < len(output) { + r, size := utf8.DecodeRuneInString(output[offset:]) + if r == '\n' { + if lineCount >= maxLines { + break + } + str.WriteByte('\n') + offset += size + lineCount++ + endsWithNewline = true + continue + } + if runeCount >= maxRunes { + break + } + + str.WriteString(output[offset : offset+size]) + offset += size + runeCount++ + endsWithNewline = false + } + + if offset < len(output) { + if str.Len() > 0 && !endsWithNewline { + str.WriteByte('\n') + } + str.WriteString("... output visually truncated") + } + return str.String() +} diff --git a/render/view_test.go b/render/view_test.go new file mode 100644 index 0000000..1cc946f --- /dev/null +++ b/render/view_test.go @@ -0,0 +1,187 @@ +package render + +import ( + "fmt" + "strings" + "testing" + "unicode/utf8" + + api "github.com/bootdotdev/bootdev/client" + "github.com/bootdotdev/bootdev/messages" +) + +func TestCompactViewHidesSuccessfulDetailsAndExpandsFailure(t *testing.T) { + passed := true + failed := false + m := initModel(true, false) + m.finalized = true + m.result = api.VerificationResultSlugFailure + m.failure = &api.StructuredErrCLI{FailedStepIndex: 1, FailedTestIndex: 0, ErrorMessage: "expected failure"} + m.steps = []stepModel{ + { + description: "The health check succeeds", + detail: "Request: GET http://localhost:3000/health", + passed: &passed, + finished: true, + tests: []testModel{{text: "Expecting status code: 200", passed: &passed, finished: true}}, + result: &api.CLIStepResult{HTTPRequestResult: &api.HTTPRequestResult{ + StatusCode: 200, + BodyString: "successful response body", + }}, + }, + { + description: "Email changes require a password", + detail: "Request: POST http://localhost:3000/account/email", + passed: &failed, + finished: true, + tests: []testModel{{text: "Expecting status code: 403", passed: &failed, finished: true}}, + result: &api.CLIStepResult{HTTPRequestResult: &api.HTTPRequestResult{ + StatusCode: 302, + BodyString: "failed response body", + }}, + }, + { + description: "A later check", + detail: "Request: GET http://localhost:3000/later", + finished: true, + }, + } + + view := m.View() + for _, expected := range []string{ + "✓ The health check succeeds", + "X Email changes require a password", + "Request: POST http://localhost:3000/account/email", + "Expecting status code: 403", + "failed response body", + } { + if !strings.Contains(view, expected) { + t.Errorf("view missing %q\n%s", expected, view) + } + } + for _, unexpected := range []string{ + "successful response body", + "Expecting status code: 200", + "A later check", + } { + if strings.Contains(view, unexpected) { + t.Errorf("view unexpectedly contains %q\n%s", unexpected, view) + } + } +} + +func TestCompactViewDoesNotHideStepsForInvalidFailureIndex(t *testing.T) { + for _, failedStepIndex := range []int{-1, 2} { + t.Run(fmt.Sprintf("index_%d", failedStepIndex), func(t *testing.T) { + m := initModel(true, false) + m.finalized = true + m.result = api.VerificationResultSlugFailure + m.failure = &api.StructuredErrCLI{FailedStepIndex: failedStepIndex} + m.steps = []stepModel{ + {description: "The first step", finished: true}, + {description: "The second step", finished: true}, + } + + view := m.View() + for _, description := range []string{"The first step", "The second step"} { + if !strings.Contains(view, description) { + t.Errorf("view missing %q for invalid failure index %d\n%s", description, failedStepIndex, view) + } + } + }) + } +} + +func TestVerboseViewShowsSuccessfulDetails(t *testing.T) { + passed := true + m := initModel(true, true) + m.finalized = true + m.result = api.VerificationResultSlugSuccess + m.steps = []stepModel{{ + description: "The command prints a greeting", + detail: "Command: echo hello", + passed: &passed, + finished: true, + tests: []testModel{{text: "Expect stdout to contain all of: hello", passed: &passed, finished: true}}, + result: &api.CLIStepResult{CLICommandResult: &api.CLICommandResult{ + Stdout: "hello", + }}, + }} + + view := m.View() + for _, expected := range []string{ + "The command prints a greeting", + "Command: echo hello", + "Expect stdout to contain all of: hello", + "Command stdout:", + "hello", + } { + if !strings.Contains(view, expected) { + t.Errorf("view missing %q\n%s", expected, view) + } + } +} + +func TestStartStepFallsBackToTechnicalDescription(t *testing.T) { + m := initModel(true, false) + updated, _ := m.Update(messages.StartStepMsg{CMD: "go test ./..."}) + got := updated.(rootModel).steps[0] + + if got.description != "go test ./..." { + t.Fatalf("description = %q, want command fallback", got.description) + } + if got.detail != "Command: go test ./..." { + t.Fatalf("detail = %q, want technical command", got.detail) + } +} + +func TestCompactStepHonorsSubmitMode(t *testing.T) { + step := stepModel{description: "A completed step", finished: true} + + submit := renderCompactStep(step, "", true) + if !strings.Contains(submit, "? A completed step") { + t.Fatalf("submit output = %q, want unresolved marker", submit) + } + + run := renderCompactStep(step, "", false) + if run != "A completed step\n" { + t.Fatalf("run output = %q, want plain description", run) + } +} + +func TestTruncateVisualOutputCapsLinesAndRunes(t *testing.T) { + tests := []struct { + name string + output string + }{ + {name: "many lines", output: strings.Repeat("line\n", 100_000)}, + {name: "long ASCII line", output: strings.Repeat("x", 1_000_000)}, + {name: "long Unicode line", output: strings.Repeat("界", 1_000_000)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncateVisualOutput(tt.output) + if !strings.HasSuffix(got, "... output visually truncated") { + t.Fatalf("expected truncation marker") + } + if strings.Count(got, "\n") > 32 { + t.Fatalf("truncated output has too many lines: %d", strings.Count(got, "\n")+1) + } + if utf8.RuneCountInString(got) > 5120+1+utf8.RuneCountInString("... output visually truncated") { + t.Fatalf("truncated output exceeds the rune limit") + } + }) + } +} + +func TestHTTPRequestBodyUsesVisualOutputLimit(t *testing.T) { + got := printHTTPRequestResult(api.HTTPRequestResult{ + StatusCode: 200, + BodyString: strings.Repeat("x", 6000), + }) + + if !strings.Contains(got, "... output visually truncated") { + t.Fatalf("expected HTTP response body to be visually truncated") + } +}