diff --git a/checks/cli.go b/checks/cli.go index 956d056..4dc95df 100644 --- a/checks/cli.go +++ b/checks/cli.go @@ -2,8 +2,6 @@ package checks import ( "bytes" - "context" - "errors" "fmt" "maps" "os" @@ -11,46 +9,34 @@ import ( "regexp" "runtime" "strings" - "time" api "github.com/bootdotdev/bootdev/client" ) -const ( - cliCommandTimeout = 5 * time.Minute - maxCLIOutputBytesPerStream = 1024 * 1024 - commandWaitDelay = 2 * time.Second -) - -var errCLIOutputLimitExceeded = errors.New("CLI command output limit exceeded") +const maxCLIOutputBytesPerStream = 1024 * 1024 type boundedBuffer struct { - buffer bytes.Buffer - limit int - truncated bool - onTruncate func() + buffer bytes.Buffer + limit int + truncated bool } -func newBoundedBuffer(limit int, onTruncate func()) *boundedBuffer { - return &boundedBuffer{ - limit: max(limit, 0), - onTruncate: onTruncate, - } +func newBoundedBuffer(limit int) *boundedBuffer { + return &boundedBuffer{limit: max(limit, 0)} } func (b *boundedBuffer) Write(p []byte) (int, error) { originalLength := len(p) + remaining := max(b.limit-b.buffer.Len(), 0) toWrite := min(len(p), remaining) if toWrite > 0 { _, _ = b.buffer.Write(p[:toWrite]) } - if toWrite < len(p) && !b.truncated { + if toWrite < len(p) { b.truncated = true - if b.onTruncate != nil { - b.onTruncate() - } } + return originalLength, nil } @@ -59,71 +45,52 @@ func (b *boundedBuffer) String() string { } func runCLICommand(command api.CLIStepCLICommand, variables map[string]string) (result api.CLICommandResult) { - return runCLICommandWithLimits(command, variables, cliCommandTimeout, maxCLIOutputBytesPerStream) + return runCLICommandWithOutputLimit(command, variables, maxCLIOutputBytesPerStream) } -func runCLICommandWithLimits( +func runCLICommandWithOutputLimit( command api.CLIStepCLICommand, variables map[string]string, - timeout time.Duration, maxOutputBytesPerStream int, ) (result api.CLICommandResult) { finalCommand := InterpolateVariables(command.Command, variables) result.FinalCommand = finalCommand result.Command = command - timeoutCtx, cancelTimeout := context.WithTimeout(context.Background(), timeout) - defer cancelTimeout() - ctx, cancelCommand := context.WithCancelCause(timeoutCtx) - defer cancelCommand(nil) - var cmd *exec.Cmd - if runtime.GOOS == "windows" { - cmd = exec.CommandContext(ctx, "powershell", "-Command", finalCommand) + cmd = exec.Command("powershell", "-Command", finalCommand) } else { - cmd = exec.CommandContext(ctx, "sh", "-c", finalCommand) + cmd = exec.Command("sh", "-c", finalCommand) } - configureCommandCancellation(cmd) cmd.Env = append(os.Environ(), "LANG=en_US.UTF-8") - cmd.WaitDelay = commandWaitDelay - cancelForOutputLimit := func() { - cancelCommand(errCLIOutputLimitExceeded) - } - stdout := newBoundedBuffer(maxOutputBytesPerStream, cancelForOutputLimit) - stderr := newBoundedBuffer(maxOutputBytesPerStream, cancelForOutputLimit) + stdout := newBoundedBuffer(maxOutputBytesPerStream) + stderr := newBoundedBuffer(maxOutputBytesPerStream) cmd.Stdout = stdout cmd.Stderr = stderr - stopSignalForwarding := forwardSignalsToCommand(cmd) - defer stopSignalForwarding() + err := cmd.Run() if ee, ok := err.(*exec.ExitError); ok { result.ExitCode = ee.ExitCode() } else if err != nil { result.ExitCode = -2 } + result.Stdout = strings.TrimRight(stdout.String(), " \n\t\r") result.Stderr = strings.TrimRight(stderr.String(), " \n\t\r") if command.StdoutFilterTmdl != nil { result.Stdout = ExtractTmdlBlock(result.Stdout, *command.StdoutFilterTmdl) } - switch { - case errors.Is(context.Cause(ctx), errCLIOutputLimitExceeded): + if stdout.truncated || stderr.truncated { result.Err = fmt.Sprintf("command output exceeded the %d-byte per-stream limit", maxOutputBytesPerStream) result.ExitCode = -2 - case errors.Is(context.Cause(ctx), context.DeadlineExceeded): - result.Err = fmt.Sprintf("command timed out after %s", timeout) - result.ExitCode = -2 - } - - if result.Err == "" { - if err := parseStdoutVariables(result.Stdout, command.StdoutVariables, variables); err != nil { - result.Err = err.Error() - } + } else if err := parseStdoutVariables(result.Stdout, command.StdoutVariables, variables); err != nil { + result.Err = err.Error() } result.Variables = maps.Clone(variables) + return result } @@ -156,9 +123,11 @@ func prettyPrintCLICommand(test api.CLICommandTest, variables map[string]string) if test.ExitCode != nil { return fmt.Sprintf("Expect exit code %d", *test.ExitCode) } + if test.StdoutLinesGT != nil { return fmt.Sprintf("Expect > %d lines on stdout", *test.StdoutLinesGT) } + if test.StdoutContainsAll != nil { var str strings.Builder str.WriteString("Expect stdout to contain all of:") @@ -168,6 +137,7 @@ func prettyPrintCLICommand(test api.CLICommandTest, variables map[string]string) } return str.String() } + if test.StdoutContainsNone != nil { var str strings.Builder str.WriteString("Expect stdout to contain none of:") @@ -177,8 +147,10 @@ func prettyPrintCLICommand(test api.CLICommandTest, variables map[string]string) } return str.String() } + if test.StdoutJq != nil { return prettyPrintStdoutJqTest(*test.StdoutJq, variables) } + return "" } diff --git a/checks/cli_test.go b/checks/cli_test.go index f9923e1..8752776 100644 --- a/checks/cli_test.go +++ b/checks/cli_test.go @@ -4,46 +4,18 @@ import ( "runtime" "strings" "testing" - "time" api "github.com/bootdotdev/bootdev/client" ) -func TestRunCLICommandTimesOut(t *testing.T) { - command := `while :; do :; done` - if runtime.GOOS == "windows" { - command = `while ($true) {}` - } - - start := time.Now() - result := runCLICommandWithLimits( - api.CLIStepCLICommand{Command: command}, - map[string]string{}, - 20*time.Millisecond, - 1024, - ) - elapsed := time.Since(start) - - if !strings.Contains(result.Err, "command timed out") { - t.Fatalf("command error = %q, want timeout error", result.Err) - } - if result.ExitCode >= 0 { - t.Fatalf("exit code = %d, want internal failure", result.ExitCode) - } - if elapsed > time.Second { - t.Fatalf("command took %v, want a prompt timeout", elapsed) - } -} - func TestRunCLICommandCapsOutput(t *testing.T) { - command := `printf 'abcdefgh'; while :; do :; done` + command := `printf 'abcdefgh'` if runtime.GOOS == "windows" { - command = `[Console]::Out.Write('abcdefgh'); while ($true) {}` + command = `[Console]::Out.Write('abcdefgh')` } variables := map[string]string{} - start := time.Now() - result := runCLICommandWithLimits( + result := runCLICommandWithOutputLimit( api.CLIStepCLICommand{ Command: command, StdoutVariables: []api.CLICommandStdoutVariable{{ @@ -52,10 +24,8 @@ func TestRunCLICommandCapsOutput(t *testing.T) { }}, }, variables, - 5*time.Second, 4, ) - elapsed := time.Since(start) if !strings.Contains(result.Err, "per-stream limit") { t.Fatalf("command error = %q, want per-stream output limit error", result.Err) @@ -69,9 +39,6 @@ func TestRunCLICommandCapsOutput(t *testing.T) { if _, ok := variables["partial"]; ok { t.Fatal("truncated output unexpectedly populated a stdout variable") } - if elapsed > time.Second { - t.Fatalf("command took %v, want cancellation immediately after exceeding the output limit", elapsed) - } } func TestRunCLICommandCapturesStdoutVariables(t *testing.T) { diff --git a/checks/command_process_other.go b/checks/command_process_other.go deleted file mode 100644 index 9c647f1..0000000 --- a/checks/command_process_other.go +++ /dev/null @@ -1,11 +0,0 @@ -//go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris && !windows - -package checks - -import "os/exec" - -func configureCommandCancellation(cmd *exec.Cmd) {} - -func forwardSignalsToCommand(cmd *exec.Cmd) func() { - return func() {} -} diff --git a/checks/command_process_unix.go b/checks/command_process_unix.go deleted file mode 100644 index b286981..0000000 --- a/checks/command_process_unix.go +++ /dev/null @@ -1,63 +0,0 @@ -//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris - -package checks - -import ( - "errors" - "os" - "os/exec" - "os/signal" - "sync" - "syscall" -) - -func configureCommandCancellation(cmd *exec.Cmd) { - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - cmd.Cancel = func() error { return killCommandProcessGroup(cmd) } -} - -func forwardSignalsToCommand(cmd *exec.Cmd) func() { - signals := make(chan os.Signal, 1) - done := make(chan struct{}) - signal.Notify(signals, os.Interrupt, syscall.SIGTERM) - var forwardOnce sync.Once - forward := func(received os.Signal) { - forwardOnce.Do(func() { - _ = killCommandProcessGroup(cmd) - signal.Reset(received) - if unixSignal, ok := received.(syscall.Signal); ok { - _ = syscall.Kill(os.Getpid(), unixSignal) - } - }) - } - - go func() { - select { - case received := <-signals: - forward(received) - case <-done: - } - }() - - return func() { - signal.Stop(signals) - select { - case received := <-signals: - forward(received) - default: - } - close(done) - } -} - -func killCommandProcessGroup(cmd *exec.Cmd) error { - if cmd.Process == nil { - return os.ErrProcessDone - } - - err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) - if errors.Is(err, syscall.ESRCH) { - return os.ErrProcessDone - } - return err -} diff --git a/checks/command_process_unix_test.go b/checks/command_process_unix_test.go deleted file mode 100644 index 51d6579..0000000 --- a/checks/command_process_unix_test.go +++ /dev/null @@ -1,52 +0,0 @@ -//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris - -package checks - -import ( - "errors" - "strconv" - "strings" - "syscall" - "testing" - "time" - - api "github.com/bootdotdev/bootdev/client" -) - -func TestRunCLICommandTimeoutKillsDescendants(t *testing.T) { - result := runCLICommandWithLimits( - api.CLIStepCLICommand{Command: "sleep 30 & echo $!; wait"}, - map[string]string{}, - 100*time.Millisecond, - 1024, - ) - if !strings.Contains(result.Err, "command timed out") { - t.Fatalf("command error = %q, want timeout error", result.Err) - } - - pid, err := strconv.Atoi(strings.TrimSpace(result.Stdout)) - if err != nil { - t.Fatalf("child PID output = %q: %v", result.Stdout, err) - } - childAlive := true - t.Cleanup(func() { - if childAlive { - _ = syscall.Kill(pid, syscall.SIGKILL) - } - }) - - deadline := time.Now().Add(time.Second) - for time.Now().Before(deadline) { - err := syscall.Kill(pid, 0) - if errors.Is(err, syscall.ESRCH) { - childAlive = false - return - } - if err != nil { - t.Fatalf("check child process %d: %v", pid, err) - } - time.Sleep(10 * time.Millisecond) - } - - t.Fatalf("child process %d survived command cancellation", pid) -} diff --git a/checks/command_process_windows.go b/checks/command_process_windows.go deleted file mode 100644 index 7683909..0000000 --- a/checks/command_process_windows.go +++ /dev/null @@ -1,38 +0,0 @@ -//go:build windows - -package checks - -import ( - "errors" - "os" - "os/exec" - "strconv" -) - -func configureCommandCancellation(cmd *exec.Cmd) { - cmd.Cancel = func() error { - if cmd.Process == nil { - return os.ErrProcessDone - } - - treeKill := exec.Command( - "taskkill.exe", - "/PID", strconv.Itoa(cmd.Process.Pid), - "/T", - "/F", - ) - if err := treeKill.Run(); err == nil { - return nil - } - - err := cmd.Process.Kill() - if errors.Is(err, os.ErrProcessDone) { - return os.ErrProcessDone - } - return err - } -} - -func forwardSignalsToCommand(cmd *exec.Cmd) func() { - return func() {} -} diff --git a/checks/http.go b/checks/http.go index 7770656..72e2b7d 100644 --- a/checks/http.go +++ b/checks/http.go @@ -14,7 +14,6 @@ import ( api "github.com/bootdotdev/bootdev/client" "github.com/goccy/go-json" - "github.com/spf13/cobra" ) const ( @@ -22,6 +21,8 @@ const ( maxBinaryBodyBytes = 16 * 1024 ) +var interpolationPattern = regexp.MustCompile(`\$\{([^}]+)\}`) + func runHTTPRequest( client *http.Client, baseURL string, @@ -34,19 +35,16 @@ func runHTTPRequest( interpolatedURL := InterpolateVariables(requestStep.Request.FullURL, variables) completeURL := strings.Replace(interpolatedURL, api.BaseURLPlaceholder, finalBaseURL, 1) - var req *http.Request + var requestBody io.Reader + var contentType string if requestStep.Request.BodyJSON != nil { bodyJSON := interpolateJSONStrings(requestStep.Request.BodyJSON, variables) dat, err := json.Marshal(bodyJSON) - cobra.CheckErr(err) - req, err = http.NewRequest( - requestStep.Request.Method, completeURL, - bytes.NewReader(dat), - ) if err != nil { - cobra.CheckErr("Failed to create request") + return api.HTTPRequestResult{Err: fmt.Sprintf("Failed to marshal request body: %s", err)} } - req.Header.Set("Content-Type", "application/json") + requestBody = bytes.NewReader(dat) + contentType = "application/json" } else if requestStep.Request.BodyForm != nil { formValues := url.Values{} for key, val := range requestStep.Request.BodyForm { @@ -54,23 +52,16 @@ func runHTTPRequest( formValues.Add(key, interpolatedVal) } - encodedFormStr := formValues.Encode() - var err error - req, err = http.NewRequest( - requestStep.Request.Method, completeURL, - strings.NewReader(encodedFormStr), - ) - if err != nil { - cobra.CheckErr("Failed to create request") - } + requestBody = strings.NewReader(formValues.Encode()) + contentType = "application/x-www-form-urlencoded" + } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - } else { - var err error - req, err = http.NewRequest(requestStep.Request.Method, completeURL, nil) - if err != nil { - cobra.CheckErr("Failed to create request") - } + req, err := http.NewRequest(requestStep.Request.Method, completeURL, requestBody) + if err != nil { + return api.HTTPRequestResult{Err: fmt.Sprintf("Failed to create request: %s", err)} + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) } for k, v := range requestStep.Request.Headers { @@ -303,8 +294,7 @@ func findHeaderValue(headers map[string]string, key string) (string, bool) { } func InterpolateVariables(template string, vars map[string]string) string { - r := regexp.MustCompile(`\$\{([^}]+)\}`) - return r.ReplaceAllStringFunc(template, func(m string) string { + return interpolationPattern.ReplaceAllStringFunc(template, func(m string) string { // Extract the key from the match, which is in the form ${key} key := strings.TrimSuffix(strings.TrimPrefix(m, "${"), "}") if val, ok := vars[key]; ok { @@ -315,8 +305,7 @@ func InterpolateVariables(template string, vars map[string]string) string { } func InterpolationNames(template string) []string { - r := regexp.MustCompile(`\$\{([^}]+)\}`) - matches := r.FindAllStringSubmatch(template, -1) + matches := interpolationPattern.FindAllStringSubmatch(template, -1) names := make([]string, 0, len(matches)) for _, match := range matches { if len(match) > 1 { diff --git a/checks/http_test.go b/checks/http_test.go index 3e8fa11..b9af6af 100644 --- a/checks/http_test.go +++ b/checks/http_test.go @@ -198,6 +198,18 @@ func TestRunHTTPRequestSafelyInterpolatesNestedJSONStrings(t *testing.T) { } } +func TestRunHTTPRequestReportsInvalidRequest(t *testing.T) { + requestStep := api.CLIStepHTTPRequest{Request: api.HTTPRequest{ + Method: http.MethodGet, + FullURL: "://invalid", + }} + + result := runHTTPRequest(http.DefaultClient, "", map[string]string{}, requestStep) + if !strings.Contains(result.Err, "Failed to create request") { + t.Fatalf("runHTTPRequest() error = %q, want request creation error", result.Err) + } +} + func TestTruncateAndStringifyBodyCapsBinaryBody(t *testing.T) { body := []byte(strings.Repeat("a", 20*1024)) body[0] = 0 diff --git a/checks/runner.go b/checks/runner.go index c41e152..8e2424b 100644 --- a/checks/runner.go +++ b/checks/runner.go @@ -1,6 +1,7 @@ package checks import ( + "errors" "net/http" "strings" "time" @@ -8,23 +9,18 @@ import ( api "github.com/bootdotdev/bootdev/client" "github.com/bootdotdev/bootdev/messages" tea "github.com/charmbracelet/bubbletea" - "github.com/spf13/cobra" ) const lessonHTTPRequestTimeout = 30 * time.Second -func newLessonHTTPClient() *http.Client { - return &http.Client{Timeout: lessonHTTPRequestTimeout} -} - -func CLIChecks(cliData api.CLIData, overrideBaseURL string, ch chan tea.Msg) (results []api.CLIStepResult) { - client := newLessonHTTPClient() - results = make([]api.CLIStepResult, len(cliData.Steps)) - +func CLIChecks(cliData api.CLIData, overrideBaseURL string, send func(tea.Msg)) ([]api.CLIStepResult, error) { if cliData.BaseURLDefault == api.BaseURLOverrideRequired && overrideBaseURL == "" { - cobra.CheckErr("lesson requires a base URL override: `bootdev configure base_url `") + return nil, errors.New("lesson requires a base URL override: `bootdev configure base_url `") } + client := &http.Client{Timeout: lessonHTTPRequestTimeout} + results := make([]api.CLIStepResult, len(cliData.Steps)) + baseURL := overrideBaseURL if overrideBaseURL == "" { baseURL = cliData.BaseURLDefault @@ -36,89 +32,87 @@ func CLIChecks(cliData api.CLIData, overrideBaseURL string, ch chan tea.Msg) (re } for i, step := range cliData.Steps { - // This is the magic of the initial message sent before executing the test - if step.CLICommand != nil { - ch <- messages.StartStepMsg{ + switch { + case step.CLICommand != nil: + send(messages.StartStepMsg{ Description: step.Description, CMD: step.CLICommand.Command, TmdlQuery: step.CLICommand.StdoutFilterTmdl, NoPenaltyOnFail: step.NoPenaltyOnFail, - } - } else if step.HTTPRequest != nil { + }) + + result := runCLICommand(*step.CLICommand, variables) + result.JqOutputs = collectStdoutJqOutputs(*step.CLICommand, result) + results[i].CLICommandResult = &result + + sendCLICommandResults(send, *step.CLICommand, result, i) + handleSleep(step.CLICommand.SleepAfterMs, send) + + case step.HTTPRequest != nil: fullURL := strings.Replace(step.HTTPRequest.Request.FullURL, api.BaseURLPlaceholder, baseURL, 1) interpolatedURL := InterpolateVariables(fullURL, variables) - ch <- messages.StartStepMsg{ + send(messages.StartStepMsg{ Description: step.Description, URL: interpolatedURL, Method: step.HTTPRequest.Request.Method, NoPenaltyOnFail: step.NoPenaltyOnFail, - } - } + }) - switch { - case step.CLICommand != nil: - result := runCLICommand(*step.CLICommand, variables) - result.JqOutputs = collectStdoutJqOutputs(*step.CLICommand, result) - results[i].CLICommandResult = &result - - sendCLICommandResults(ch, *step.CLICommand, result, i) - handleSleep(step.CLICommand, ch) - - case step.HTTPRequest != nil: result := runHTTPRequest(client, baseURL, variables, *step.HTTPRequest) results[i].HTTPRequestResult = &result - sendHTTPRequestResults(ch, *step.HTTPRequest, result, i) - handleSleep(step.HTTPRequest, ch) + sendHTTPRequestResults(send, *step.HTTPRequest, result, i) + handleSleep(step.HTTPRequest.SleepAfterMs, send) default: - cobra.CheckErr("unable to run lesson: missing step") + return nil, errors.New("unable to run lesson: missing step") } } - return results + + return results, nil } -func sendCLICommandResults(ch chan tea.Msg, cmd api.CLIStepCLICommand, result api.CLICommandResult, index int) { +func sendCLICommandResults(send func(tea.Msg), cmd api.CLIStepCLICommand, result api.CLICommandResult, index int) { for _, test := range cmd.Tests { - ch <- messages.StartTestMsg{Text: prettyPrintCLICommand(test, result.Variables)} + send(messages.StartTestMsg{Text: prettyPrintCLICommand(test, result.Variables)}) } for j := range cmd.Tests { - ch <- messages.ResolveTestMsg{ + send(messages.ResolveTestMsg{ StepIndex: index, TestIndex: j, - } + }) } - ch <- messages.ResolveStepMsg{ + send(messages.ResolveStepMsg{ Index: index, Result: &api.CLIStepResult{ CLICommandResult: &result, }, - } + }) } -func sendHTTPRequestResults(ch chan tea.Msg, req api.CLIStepHTTPRequest, result api.HTTPRequestResult, index int) { +func sendHTTPRequestResults(send func(tea.Msg), req api.CLIStepHTTPRequest, result api.HTTPRequestResult, index int) { for _, test := range req.Tests { - ch <- messages.StartTestMsg{Text: prettyPrintHTTPTest(test, result.Variables)} + send(messages.StartTestMsg{Text: prettyPrintHTTPTest(test, result.Variables)}) } for j := range req.Tests { - ch <- messages.ResolveTestMsg{ + send(messages.ResolveTestMsg{ StepIndex: index, TestIndex: j, - } + }) } - ch <- messages.ResolveStepMsg{ + send(messages.ResolveStepMsg{ Index: index, Result: &api.CLIStepResult{ HTTPRequestResult: &result, }, - } + }) } -func ApplySubmissionResults(cliData api.CLIData, failure *api.StructuredErrCLI, ch chan tea.Msg) { +func ApplySubmissionResults(cliData api.CLIData, failure *api.StructuredErrCLI, send func(tea.Msg)) { for i, step := range cliData.Steps { stepPass := true isFailedStep := false @@ -127,38 +121,29 @@ func ApplySubmissionResults(cliData api.CLIData, failure *api.StructuredErrCLI, isFailedStep = i == failure.FailedStepIndex } - ch <- messages.ResolveStepMsg{ + send(messages.ResolveStepMsg{ Index: i, Passed: &stepPass, - } + }) + testCount := 0 if step.CLICommand != nil { - for j := range step.CLICommand.Tests { - if isFailedStep && j > failure.FailedTestIndex { - break - } - - testPass := stepPass || (isFailedStep && j < failure.FailedTestIndex) - ch <- messages.ResolveTestMsg{ - StepIndex: i, - TestIndex: j, - Passed: &testPass, - } - } + testCount = len(step.CLICommand.Tests) + } else if step.HTTPRequest != nil { + testCount = len(step.HTTPRequest.Tests) } - if step.HTTPRequest != nil { - for j := range step.HTTPRequest.Tests { - if isFailedStep && j > failure.FailedTestIndex { - break - } - - testPass := stepPass || (isFailedStep && j < failure.FailedTestIndex) - ch <- messages.ResolveTestMsg{ - StepIndex: i, - TestIndex: j, - Passed: &testPass, - } + + for j := range testCount { + if isFailedStep && j > failure.FailedTestIndex { + break } + + testPass := stepPass || (isFailedStep && j < failure.FailedTestIndex) + send(messages.ResolveTestMsg{ + StepIndex: i, + TestIndex: j, + Passed: &testPass, + }) } if !stepPass { @@ -167,10 +152,9 @@ func ApplySubmissionResults(cliData api.CLIData, failure *api.StructuredErrCLI, } } -func handleSleep(s api.Sleepable, ch chan tea.Msg) { - sleepMs := s.GetSleepAfterMs() +func handleSleep(sleepMs *int, send func(tea.Msg)) { if sleepMs != nil && *sleepMs > 0 { - ch <- messages.SleepMsg{DurationMs: *sleepMs} + send(messages.SleepMsg{DurationMs: *sleepMs}) time.Sleep(time.Duration(*sleepMs) * time.Millisecond) } } diff --git a/checks/runner_test.go b/checks/runner_test.go index 62885c0..f866e79 100644 --- a/checks/runner_test.go +++ b/checks/runner_test.go @@ -4,6 +4,7 @@ import ( "net/http" "net/http/httptest" "reflect" + "strings" "testing" api "github.com/bootdotdev/bootdev/client" @@ -47,9 +48,10 @@ func TestCLIChecksInterpolatesResolvedBaseURLInCommands(t *testing.T) { }, }}, } - ch := make(chan tea.Msg, 10) - - results := CLIChecks(cliData, tt.overrideBaseURL, ch) + results, err := CLIChecks(cliData, tt.overrideBaseURL, func(tea.Msg) {}) + if err != nil { + t.Fatalf("CLIChecks() error = %v", err) + } if got := results[0].CLICommandResult.Stdout; got != tt.want { t.Fatalf("command stdout = %q, want %q", got, tt.want) @@ -81,11 +83,15 @@ func TestCLIChecksUsesOverrideParameterForHTTPRequestPreview(t *testing.T) { }, }}, } - messageChannel := make(chan tea.Msg, 10) - - results := CLIChecks(cliData, server.URL+"/", messageChannel) + var sent []tea.Msg + results, err := CLIChecks(cliData, server.URL+"/", func(msg tea.Msg) { + sent = append(sent, msg) + }) + if err != nil { + t.Fatalf("CLIChecks() error = %v", err) + } - startMessage, ok := (<-messageChannel).(messages.StartStepMsg) + startMessage, ok := sent[0].(messages.StartStepMsg) if !ok { t.Fatal("expected start step message") } @@ -97,6 +103,34 @@ func TestCLIChecksUsesOverrideParameterForHTTPRequestPreview(t *testing.T) { } } +func TestCLIChecksReturnsManifestErrors(t *testing.T) { + tests := []struct { + name string + data api.CLIData + want string + }{ + { + name: "missing required base URL override", + data: api.CLIData{BaseURLDefault: api.BaseURLOverrideRequired}, + want: "lesson requires a base URL override", + }, + { + name: "missing step type", + data: api.CLIData{Steps: []api.CLIStep{{}}}, + want: "unable to run lesson: missing step", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := CLIChecks(tt.data, "", func(tea.Msg) {}) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("CLIChecks() error = %v, want error containing %q", err, tt.want) + } + }) + } +} + func TestApplySubmissionResultsMarksAllStepsAndTestsPassedWhenNoFailure(t *testing.T) { cliData := api.CLIData{Steps: []api.CLIStep{ {CLICommand: &api.CLIStepCLICommand{Tests: []api.CLICommandTest{{}, {}}}}, @@ -135,36 +169,11 @@ func TestApplySubmissionResultsStopsAfterFailedCLITest(t *testing.T) { assertMessages(t, got, want) } -func TestApplySubmissionResultsStopsAfterFailedHTTPTest(t *testing.T) { - cliData := api.CLIData{Steps: []api.CLIStep{ - {CLICommand: &api.CLIStepCLICommand{Tests: []api.CLICommandTest{{}}}}, - {HTTPRequest: &api.CLIStepHTTPRequest{Tests: []api.HTTPRequestTest{{}, {}, {}}}}, - }} - failure := &api.StructuredErrCLI{FailedStepIndex: 1, FailedTestIndex: 1} - - got := applySubmissionResultsMessages(cliData, failure) - want := []tea.Msg{ - messages.ResolveStepMsg{Index: 0, Passed: boolPtr(true)}, - messages.ResolveTestMsg{StepIndex: 0, TestIndex: 0, Passed: boolPtr(true)}, - messages.ResolveStepMsg{Index: 1, Passed: boolPtr(false)}, - messages.ResolveTestMsg{StepIndex: 1, TestIndex: 0, Passed: boolPtr(true)}, - messages.ResolveTestMsg{StepIndex: 1, TestIndex: 1, Passed: boolPtr(false)}, - } - - assertMessages(t, got, want) -} - func applySubmissionResultsMessages(cliData api.CLIData, failure *api.StructuredErrCLI) []tea.Msg { - ch := make(chan tea.Msg) - go func() { - defer close(ch) - ApplySubmissionResults(cliData, failure, ch) - }() - var msgs []tea.Msg - for msg := range ch { + ApplySubmissionResults(cliData, failure, func(msg tea.Msg) { msgs = append(msgs, msg) - } + }) return msgs } diff --git a/client/auth.go b/client/auth.go index 30182f3..e4eec34 100644 --- a/client/auth.go +++ b/client/auth.go @@ -2,6 +2,7 @@ package api import ( "bytes" + "context" "errors" "fmt" "io" @@ -12,7 +13,10 @@ import ( "github.com/spf13/viper" ) -const apiRequestTimeout = 30 * time.Second +const ( + apiRequestTimeout = 30 * time.Second + logoutRequestTimeout = 3 * time.Second +) var apiHTTPClient = &http.Client{ Timeout: apiRequestTimeout, @@ -109,8 +113,11 @@ func FetchCurrentUser() (*CurrentUserResponse, error) { } func Logout() error { + ctx, cancel := context.WithTimeout(context.Background(), logoutRequestTimeout) + defer cancel() + apiURL := viper.GetString("api_url") - r, err := http.NewRequest("POST", apiURL+"/v1/auth/logout", bytes.NewBuffer([]byte{})) + r, err := http.NewRequestWithContext(ctx, "POST", apiURL+"/v1/auth/logout", bytes.NewBuffer([]byte{})) if err != nil { return err } diff --git a/client/lessons.go b/client/lessons.go index d5bbf05..dedb834 100644 --- a/client/lessons.go +++ b/client/lessons.go @@ -90,18 +90,6 @@ type CLIStepHTTPRequest struct { SleepAfterMs *int `yaml:"sleepAfterMs"` } -type Sleepable interface { - GetSleepAfterMs() *int -} - -func (c *CLIStepCLICommand) GetSleepAfterMs() *int { - return c.SleepAfterMs -} - -func (h *CLIStepHTTPRequest) GetSleepAfterMs() *int { - return h.SleepAfterMs -} - const BaseURLPlaceholder = "${baseURL}" type HTTPRequest struct { diff --git a/cmd/configure.go b/cmd/configure.go index 3f4c6b9..8499a99 100644 --- a/cmd/configure.go +++ b/cmd/configure.go @@ -40,13 +40,12 @@ var configureColorsCmd = &cobra.Command{ viper.Set("color."+color, defaultVal) } - err := viper.WriteConfig() - if err != nil { + if err := viper.WriteConfig(); err != nil { return fmt.Errorf("failed to write config: %v", err) } fmt.Println("Colors reset!") - return err + return nil } configColors := map[string]string{} @@ -81,11 +80,10 @@ var configureColorsCmd = &cobra.Command{ return nil } - err = viper.WriteConfig() - if err != nil { + if err := viper.WriteConfig(); err != nil { return fmt.Errorf("failed to write config: %v", err) } - return err + return nil }, } @@ -102,12 +100,11 @@ var configureBaseURLCmd = &cobra.Command{ if resetOverrideBaseURL { viper.Set("override_base_url", "") - err := viper.WriteConfig() - if err != nil { + if err := viper.WriteConfig(); err != nil { return fmt.Errorf("failed to write config: %v", err) } fmt.Println("Base URL reset!") - return err + return nil } if len(args) == 0 { @@ -135,12 +132,11 @@ var configureBaseURLCmd = &cobra.Command{ } viper.Set("override_base_url", overrideBaseURL.String()) - err = viper.WriteConfig() - if err != nil { + if err := viper.WriteConfig(); err != nil { return fmt.Errorf("failed to write config: %v", err) } fmt.Printf("Base URL set to %v\n", overrideBaseURL.String()) - return err + return nil }, } diff --git a/cmd/localtest.go b/cmd/localtest.go index 554bfbf..ec45bd4 100644 --- a/cmd/localtest.go +++ b/cmd/localtest.go @@ -11,7 +11,6 @@ import ( "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" @@ -46,13 +45,18 @@ func localTestHandler(cmd *cobra.Command, args []string) error { 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, verboseOutput, ch) + send, finish := render.StartRenderer(true, verboseOutput) + submissionEvent := api.LessonSubmissionEvent{} + defer func() { + finish(submissionEvent) + }() - cliResults := checks.CLIChecks(data, overrideBaseURL, ch) - submissionEvent := checks.LocalSubmissionEvent(data, cliResults) - checks.ApplySubmissionResults(data, submissionEvent.StructuredErrCLI, ch) - finalise(submissionEvent) + cliResults, err := checks.CLIChecks(data, overrideBaseURL, send) + if err != nil { + return err + } + submissionEvent = checks.LocalSubmissionEvent(data, cliResults) + checks.ApplySubmissionResults(data, submissionEvent.StructuredErrCLI, send) if submissionEvent.ResultSlug != api.VerificationResultSlugSuccess { return localTestFailureError(submissionEvent.StructuredErrCLI) @@ -101,7 +105,7 @@ func readLocalCLIData(path string) (api.CLIData, error) { func validateAllowedOS(data api.CLIData) error { if len(data.AllowedOperatingSystems) == 0 { - return nil + return errors.New("lesson does not specify any allowed operating systems") } if slices.Contains(data.AllowedOperatingSystems, runtime.GOOS) { diff --git a/cmd/login.go b/cmd/login.go index 6366e62..e4c9f38 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -23,10 +23,6 @@ import ( "golang.org/x/term" ) -func logoRenderer() string { - return logo -} - //go:embed boots.txt var logo string @@ -42,10 +38,10 @@ var loginCmd = &cobra.Command{ w = 0 } // Pad the logo with whitespace - welcome := lipgloss.PlaceHorizontal(lipgloss.Width(logoRenderer()), lipgloss.Center, "Welcome to the Boot.dev CLI!") + welcome := lipgloss.PlaceHorizontal(lipgloss.Width(logo), lipgloss.Center, "Welcome to the Boot.dev CLI!") if w >= lipgloss.Width(welcome) { - fmt.Print(logoRenderer()) + fmt.Print(logo) fmt.Print(welcome, "\n\n") } else { fmt.Print("Welcome to the Boot.dev CLI!\n\n") diff --git a/cmd/root.go b/cmd/root.go index 4710335..710d4d1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -130,13 +130,9 @@ func secureConfigFile(path string) error { return nil } -// Chain multiple commands together. -func compose(commands ...func(cmd *cobra.Command, args []string)) func(cmd *cobra.Command, args []string) { - return func(cmd *cobra.Command, args []string) { - for _, command := range commands { - command(cmd, args) - } - } +func requireUpdatedAndAuth(cmd *cobra.Command, args []string) { + requireUpdated(cmd, args) + requireAuth() } // Call this function at the beginning of a command handler @@ -208,7 +204,7 @@ func refreshCredentials() error { // if you need to make authenticated requests. This will // automatically refresh the tokens, if necessary, and prompt // the user to re-login if anything goes wrong. -func requireAuth(cmd *cobra.Command, args []string) { +func requireAuth() { promptLoginAndExitIf := func(condition bool) { if condition { fmt.Fprintln(os.Stderr, "You must be logged in to use that command.") diff --git a/cmd/run.go b/cmd/run.go index 0cadb87..c54e89d 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -14,8 +14,8 @@ func init() { // runCmd represents the run command var runCmd = &cobra.Command{ Use: "run [UUID]", - Args: cobra.MatchAll(cobra.MaximumNArgs(1)), + Args: cobra.MaximumNArgs(1), Short: "Run a lesson without submitting. Runs your next lesson when no UUID is given", - PreRun: compose(requireUpdated, requireAuth), + PreRun: requireUpdatedAndAuth, RunE: submissionHandler, } diff --git a/cmd/submit.go b/cmd/submit.go index 21b3c7c..e0c7f78 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "path/filepath" - "runtime" "time" "github.com/bootdotdev/bootdev/checks" @@ -32,9 +31,9 @@ func init() { // submitCmd represents the submit command var submitCmd = &cobra.Command{ Use: "submit [UUID]", - Args: cobra.MatchAll(cobra.MaximumNArgs(1)), + Args: cobra.MaximumNArgs(1), Short: "Submit a lesson. Submits your next lesson when no UUID is given", - PreRun: compose(requireUpdated, requireAuth), + PreRun: requireUpdatedAndAuth, RunE: submissionHandler, } @@ -42,26 +41,9 @@ func submissionHandler(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true isSubmit := cmd.Name() == "submit" || forceSubmit - var lesson *api.Lesson var lessonUUID string if len(args) > 0 { lessonUUID = args[0] - fetchedLesson, err := api.FetchLesson(lessonUUID) - if err != nil { - return err - } - action := "Running" - if isSubmit { - action = "Submitting" - } - fmt.Printf( - "%s lesson:\n%d.%d - %s\n", - action, - fetchedLesson.ChapterNumber, - fetchedLesson.LessonNumber, - fetchedLesson.Lesson.Title, - ) - lesson = fetchedLesson } else { nextLesson, err := api.FetchNextCLILesson() if err != nil { @@ -80,11 +62,25 @@ func submissionHandler(cmd *cobra.Command, args []string) error { fmt.Printf("Running next lesson:\n%s\n", lessonLabel) } lessonUUID = nextLesson.LessonUUID - fetchedLesson, err := api.FetchLesson(lessonUUID) - if err != nil { - return err + } + + lesson, err := api.FetchLesson(lessonUUID) + if err != nil { + return err + } + + if len(args) > 0 { + action := "Running" + if isSubmit { + action = "Submitting" } - lesson = fetchedLesson + fmt.Printf( + "%s lesson:\n%d.%d - %s\n", + action, + lesson.ChapterNumber, + lesson.LessonNumber, + lesson.Lesson.Title, + ) } if lesson.Lesson.Type != "type_cli" { @@ -95,16 +91,8 @@ func submissionHandler(cmd *cobra.Command, args []string) error { } data := lesson.Lesson.LessonDataCLI.CLIData - - isAllowedOS := false - for _, system := range data.AllowedOperatingSystems { - if system == runtime.GOOS { - isAllowedOS = true - } - } - - if !isAllowedOS { - return fmt.Errorf("lesson is not supported for your operating system (%s); try again with one of the following: %v", runtime.GOOS, data.AllowedOperatingSystems) + if err := validateAllowedOS(data); err != nil { + return err } overrideBaseURL := viper.GetString("override_base_url") @@ -113,41 +101,44 @@ func submissionHandler(cmd *cobra.Command, args []string) error { fmt.Printf("You can reset to the default with `bootdev config base_url --reset`\n\n") } - 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, verboseOutput, ch) + send, finish := render.StartRenderer(isSubmit, verboseOutput) + finalEvent := api.LessonSubmissionEvent{} + var debugPath string + var debugWriteErr error + defer func() { + finish(finalEvent) + if debugSubmission && isSubmit { + reportDebugFileWrite(debugPath, debugWriteErr) + } + }() - cliResults := checks.CLIChecks(data, overrideBaseURL, ch) + cliResults, err := checks.CLIChecks(data, overrideBaseURL, send) + if err != nil { + return err + } if isSubmit { submissionEvent, debugData, err := api.SubmitCLILesson(lessonUUID, cliResults, debugSubmission) if debugSubmission { - var debugPath string - var debugWriteErr error - defer func() { - reportDebugFileWrite(debugPath, debugWriteErr) - }() debugPath, debugWriteErr = writeSubmissionDebugFile(lessonUUID, debugData) } if err != nil { return err } - submissionErr := applySubmissionEvent(data, submissionEvent, ch) - finalise(submissionEvent) - if submissionErr != nil { - return submissionErr + finalEvent = submissionEvent + if err := applySubmissionEvent(data, submissionEvent, send); err != nil { + return err } - } else { - finalise(api.LessonSubmissionEvent{}) } + return nil } -func applySubmissionEvent(data api.CLIData, event api.LessonSubmissionEvent, ch chan tea.Msg) error { +func applySubmissionEvent(data api.CLIData, event api.LessonSubmissionEvent, send func(tea.Msg)) error { if event.ResultSlug == api.VerificationResultSlugSystemError { return errors.New("lesson verification failed due to a system error; please try again") } - checks.ApplySubmissionResults(data, event.StructuredErrCLI, ch) + checks.ApplySubmissionResults(data, event.StructuredErrCLI, send) return nil } diff --git a/cmd/submit_test.go b/cmd/submit_test.go index 0a0909a..87c9c70 100644 --- a/cmd/submit_test.go +++ b/cmd/submit_test.go @@ -12,18 +12,17 @@ func TestApplySubmissionEventRejectsSystemErrorWithoutMarkingStepsPassed(t *test data := api.CLIData{Steps: []api.CLIStep{{ CLICommand: &api.CLIStepCLICommand{Tests: []api.CLICommandTest{{}}}, }}} - ch := make(chan tea.Msg, 1) + var sent []tea.Msg err := applySubmissionEvent(data, api.LessonSubmissionEvent{ ResultSlug: api.VerificationResultSlugSystemError, - }, ch) + }, func(msg tea.Msg) { + sent = append(sent, msg) + }) if err == nil || !strings.Contains(err.Error(), "system error") { t.Fatalf("applySubmissionEvent() error = %v, want system error", err) } - - select { - case msg := <-ch: - t.Fatalf("system error unexpectedly emitted result message: %#v", msg) - default: + if len(sent) != 0 { + t.Fatalf("system error unexpectedly emitted result messages: %#v", sent) } } diff --git a/go.mod b/go.mod index 3513930..2720fa2 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,6 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/goccy/go-json v0.10.5 github.com/itchyny/gojq v0.12.18 - github.com/muesli/termenv v0.16.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 @@ -37,6 +36,7 @@ require ( github.com/mattn/go-runewidth v0.0.19 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect diff --git a/render/http.go b/render/http.go index 3eccb0c..45eec68 100644 --- a/render/http.go +++ b/render/http.go @@ -82,10 +82,11 @@ func printHTTPRequestResult(result api.HTTPRequestResult) string { } } - if savedVariables := savedVariablesForHTTPResult(result); len(savedVariables) > 0 { + savedVariables, missingVariables := savedAndMissingVariablesForHTTPResult(result) + if len(savedVariables) > 0 { str.WriteString(renderVariableSection("Variables Saved", savedVariables)) } - if missingVariables := missingSaveVariablesForHTTPResult(result); len(missingVariables) > 0 { + if len(missingVariables) > 0 { str.WriteString(renderVariableSection("Variables Missing", missingVariables)) } availableVariables, expectsVariables := availableVariablesForHTTPResult(result) diff --git a/render/render.go b/render/render.go index f3b61c1..d1d0274 100644 --- a/render/render.go +++ b/render/render.go @@ -4,13 +4,11 @@ import ( "fmt" "os" "strings" - "sync" api "github.com/bootdotdev/bootdev/client" "github.com/bootdotdev/bootdev/messages" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/muesli/termenv" "github.com/spf13/viper" ) @@ -104,37 +102,30 @@ func (m rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } -func StartRenderer(data api.CLIData, isSubmit bool, verbose bool, ch chan tea.Msg) func(api.LessonSubmissionEvent) { - var wg sync.WaitGroup +func StartRenderer(isSubmit bool, verbose bool) (func(tea.Msg), func(api.LessonSubmissionEvent)) { p := tea.NewProgram(initModel(isSubmit, verbose), tea.WithoutSignalHandler()) + done := make(chan struct{}) - wg.Add(1) go func() { - defer wg.Done() + defer close(done) if model, err := p.Run(); err != nil { fmt.Fprintln(os.Stderr, err) } else if r, ok := model.(rootModel); ok { r.clear = false r.finalized = true - output := termenv.NewOutput(os.Stdout) - output.WriteString(r.View()) + fmt.Fprint(os.Stdout, r.View()) } }() - go func() { - for { - msg := <-ch - p.Send(msg) - } - }() - - return func(submissionEvent api.LessonSubmissionEvent) { - ch <- messages.DoneStepMsg{ + finish := func(submissionEvent api.LessonSubmissionEvent) { + p.Send(messages.DoneStepMsg{ Result: submissionEvent.ResultSlug, Failure: submissionEvent.StructuredErrCLI, XPReward: submissionEvent.XPReward, XPBreakdown: submissionEvent.XPBreakdown, - } - wg.Wait() + }) + <-done } + + return p.Send, finish } diff --git a/render/variables.go b/render/variables.go index 9c2ed29..fedf01a 100644 --- a/render/variables.go +++ b/render/variables.go @@ -43,60 +43,38 @@ func formatVariableValue(value string, found bool) string { return value } -func savedVariablesForHTTPResult(result api.HTTPRequestResult) []variableEntry { - var entries []variableEntry +func savedAndMissingVariablesForHTTPResult(result api.HTTPRequestResult) (saved, missing []variableEntry) { for _, responseVariable := range result.Request.ResponseVariables { value, found := result.Variables[responseVariable.Name] - if !found { - continue - } - - description := responseVariableDescription(responseVariable) - entries = append(entries, variableEntry{ + entry := variableEntry{ name: responseVariable.Name, value: value, - found: true, - description: description, - }) + found: found, + description: responseVariableDescription(responseVariable), + } + if found { + saved = append(saved, entry) + } else { + missing = append(missing, entry) + } } + for _, responseHeaderVariable := range result.Request.ResponseHeaderVariables { value, found := result.Variables[responseHeaderVariable.Name] - if !found { - continue - } - entries = append(entries, variableEntry{ + entry := variableEntry{ name: responseHeaderVariable.Name, value: value, - found: true, + found: found, description: responseHeaderVariableDescription(responseHeaderVariable), - }) - } - return entries -} - -func missingSaveVariablesForHTTPResult(result api.HTTPRequestResult) []variableEntry { - var entries []variableEntry - for _, responseVariable := range result.Request.ResponseVariables { - if _, found := result.Variables[responseVariable.Name]; found { - continue } - - description := responseVariableDescription(responseVariable) - entries = append(entries, variableEntry{ - name: responseVariable.Name, - description: description, - }) - } - for _, responseHeaderVariable := range result.Request.ResponseHeaderVariables { - if _, found := result.Variables[responseHeaderVariable.Name]; found { - continue + if found { + saved = append(saved, entry) + } else { + missing = append(missing, entry) } - entries = append(entries, variableEntry{ - name: responseHeaderVariable.Name, - description: responseHeaderVariableDescription(responseHeaderVariable), - }) } - return entries + + return saved, missing } func responseHeaderVariableDescription(v api.HTTPRequestResponseHeaderVariable) string { diff --git a/render/view.go b/render/view.go index 85ebb3e..d6964e4 100644 --- a/render/view.go +++ b/render/view.go @@ -17,7 +17,7 @@ func renderTestHeader(header string, spinner spinner.Model, isFinished bool, isS if noPenaltyOnFail { header = fmt.Sprintf("%s %s", header, white.Render(safeStepIcon)) } - cmdStr := renderTest(header, spinner.View(), isFinished, &isSubmit, passed) + 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) @@ -29,7 +29,7 @@ func renderTests(tests []testModel, spinner string) string { var edges strings.Builder for _, test := range tests { - testStr := renderTest(test.text, spinner, test.finished, nil, test.passed) + testStr := renderTest(test.text, spinner, test.finished, true, test.passed) testStr = fmt.Sprintf(" %s", testStr) height := lipgloss.Height(testStr) @@ -46,11 +46,11 @@ func renderTests(tests []testModel, spinner string) string { return str.String() } -func renderTest(text string, spinner string, isFinished bool, isSubmit *bool, passed *bool) 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 isSubmit != nil && !*isSubmit { + } else if !showStatus { testStr += text } else if passed == nil { testStr += gray.Render(fmt.Sprintf("? %s", text)) @@ -212,7 +212,7 @@ func (m rootModel) View() string { } func renderCompactStep(step stepModel, spinner string, isSubmit bool) string { - line := renderTest(step.description, spinner, step.finished, &isSubmit, step.passed) + line := renderTest(step.description, spinner, step.finished, isSubmit, step.passed) if step.noPenaltyOnFail { line = fmt.Sprintf("%s %s", line, white.Render(safeStepIcon)) } diff --git a/version/context.go b/version/context.go index 576ab95..34ce37b 100644 --- a/version/context.go +++ b/version/context.go @@ -2,14 +2,14 @@ package version import "context" -var ContextKey = struct{ string }{"version"} +var contextKey = struct{ string }{"version"} func WithContext(ctx context.Context, version *VersionInfo) context.Context { - return context.WithValue(ctx, ContextKey, version) + return context.WithValue(ctx, contextKey, version) } func FromContext(ctx context.Context) *VersionInfo { - if c, ok := ctx.Value(ContextKey).(*VersionInfo); ok { + if c, ok := ctx.Value(contextKey).(*VersionInfo); ok { return c }