diff --git a/checks/captures.go b/checks/captures.go new file mode 100644 index 0000000..787800a --- /dev/null +++ b/checks/captures.go @@ -0,0 +1,91 @@ +package checks + +import ( + "fmt" + "slices" + + api "github.com/bootdotdev/bootdev/client" +) + +func captureNames(step api.CLIStep) []string { + var names []string + if step.CLICommand != nil { + for _, capture := range step.CLICommand.StdoutVariables { + names = append(names, capture.Name) + } + } + if step.HTTPRequest != nil { + for _, capture := range step.HTTPRequest.ResponseVariables { + names = append(names, capture.Name) + } + for _, capture := range step.HTTPRequest.ResponseHeaderVariables { + names = append(names, capture.Name) + } + } + return names +} + +// Missing and invalidated workflow bindings both block execution. Keeping the +// namespace separate from values prevents failed or forward captures being +// mistaken for shell variables. +func missingDependencies(step api.CLIStep, namespace map[string]bool, values map[string]string) []string { + var names []string + add := func(text string) { + for _, name := range InterpolationNames(text) { + if _, available := values[name]; namespace[name] && !available { + names = append(names, name) + } + } + } + var visit func(any) + visit = func(value any) { + switch value := value.(type) { + case string: + add(value) + case []any: + for _, item := range value { + visit(item) + } + case map[string]any: + for _, item := range value { + visit(item) + } + } + } + if step.CLICommand != nil { + add(step.CLICommand.Command) + } + if step.HTTPRequest != nil { + request := step.HTTPRequest.Request + add(request.FullURL) + for _, value := range request.Headers { + add(value) + } + if request.BodyJSON != nil { + visit(request.BodyJSON) + } else { + for _, value := range request.BodyForm { + add(value) + } + } + } + slices.Sort(names) + return slices.Compact(names) +} + +// Finalize even on early execution errors so previous output values cannot leak +// into later steps. Extraction writes only to captured, never to values. +func publishCaptures(names []string, values, captured map[string]string, captureErr string) string { + for _, name := range names { + delete(values, name) + if _, found := captured[name]; !found && captureErr == "" { + captureErr = fmt.Sprintf("missing value for variable '%s'", name) + } + } + if captureErr == "" { + for _, name := range names { + values[name] = captured[name] + } + } + return captureErr +} diff --git a/checks/cli.go b/checks/cli.go index 88bfa2d..8d86e8f 100644 --- a/checks/cli.go +++ b/checks/cli.go @@ -85,6 +85,11 @@ func runCLICommandWithOutputLimit( maxOutputBytesPerStream int, shell commandShell, ) (result api.CLICommandResult) { + captured := make(map[string]string) + defer func() { + result.Err = publishCaptures(captureNames(api.CLIStep{CLICommand: &command}), variables, captured, result.Err) + result.Variables = maps.Clone(variables) + }() finalCommand := InterpolateVariables(command.Command, variables) result.FinalCommand = finalCommand result.Command = command @@ -103,6 +108,9 @@ func runCLICommandWithOutputLimit( } else if err != nil { result.ExitCode = -2 } + if result.ExitCode < 0 && err != nil { + result.Err = err.Error() + } result.Stdout = strings.TrimRight(stdout.String(), " \n\t\r") result.Stderr = strings.TrimRight(stderr.String(), " \n\t\r") @@ -110,14 +118,9 @@ func runCLICommandWithOutputLimit( result.Stdout = ExtractTmdlBlock(result.Stdout, *command.StdoutFilterTmdl) } - if stdout.truncated || stderr.truncated { - result.Err = fmt.Sprintf("command output exceeded the %d-byte per-stream limit", maxOutputBytesPerStream) - result.ExitCode = -2 - } else if err := parseStdoutVariables(result.Stdout, command.StdoutVariables, variables); err != nil { + if err := parseStdoutVariables(result.Stdout, command.StdoutVariables, captured); err != nil { result.Err = err.Error() } - result.Variables = maps.Clone(variables) - return result } diff --git a/checks/cli_test.go b/checks/cli_test.go index 9c1d043..afd28d5 100644 --- a/checks/cli_test.go +++ b/checks/cli_test.go @@ -2,43 +2,75 @@ package checks import ( "runtime" - "strings" "testing" api "github.com/bootdotdev/bootdev/client" ) func TestRunCLICommandCapsOutput(t *testing.T) { - command := `printf 'abcdefgh'` - if runtime.GOOS == "windows" { - command = `[Console]::Out.Write('abcdefgh')` - } - - variables := map[string]string{} - result := runCLICommandWithOutputLimit( - api.CLIStepCLICommand{ - Command: command, - StdoutVariables: []api.CLICommandStdoutVariable{{ - Name: "partial", - Regex: `(abcd)`, - }}, + for _, tc := range []struct { + name, command, windowsCommand, stdout, stderr, pattern, value string + exitCode int + wantFailure bool + }{ + { + name: "stdout overflow", + command: `printf 'abcdefgh'`, + windowsCommand: `[Console]::Out.Write('abcdefgh')`, + stdout: "abcd", pattern: `^(abcd)$`, value: "abcd", }, - variables, - 4, - defaultShell(), - ) - - if !strings.Contains(result.Err, "per-stream limit") { - t.Fatalf("command error = %q, want per-stream output limit error", result.Err) - } - if result.ExitCode >= 0 { - t.Fatalf("exit code = %d, want internal failure", result.ExitCode) - } - if result.Stdout != "abcd" { - t.Fatalf("stdout = %q, want capped output %q", result.Stdout, "abcd") - } - if _, ok := variables["partial"]; ok { - t.Fatal("truncated output unexpectedly populated a stdout variable") + { + name: "stderr overflow", + command: `printf 'ok'; printf 'abcdefgh' >&2`, + windowsCommand: `[Console]::Out.Write('ok'); [Console]::Error.Write('abcdefgh')`, + stdout: "ok", stderr: "abcd", pattern: `^(ok)$`, value: "ok", + }, + { + name: "nonzero exit", + command: `printf 'abcdefgh'; exit 7`, + windowsCommand: `[Console]::Out.Write('abcdefgh'); exit 7`, + stdout: "abcd", pattern: `^(abcd)$`, value: "abcd", exitCode: 7, + }, + { + name: "capture beyond limit", + command: `printf 'abcdefgh'`, + windowsCommand: `[Console]::Out.Write('abcdefgh')`, + stdout: "abcd", pattern: `(efgh)`, wantFailure: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + command := tc.command + if runtime.GOOS == "windows" { + command = tc.windowsCommand + } + step := api.CLIStepCLICommand{ + Command: command, + StdoutVariables: []api.CLICommandStdoutVariable{{Name: "token", Regex: tc.pattern}}, + } + variables := map[string]string{"token": "old"} + result := runCLICommandWithOutputLimit(step, variables, 4, defaultShell()) + if result.ExitCode != tc.exitCode { + t.Fatalf("exit code = %d, want %d", result.ExitCode, tc.exitCode) + } + if result.Stdout != tc.stdout || result.Stderr != tc.stderr { + t.Fatalf("stdout/stderr = %q/%q, want %q/%q", result.Stdout, result.Stderr, tc.stdout, tc.stderr) + } + if tc.wantFailure { + if result.Err == "" { + t.Fatal("missing capture should fail") + } + if _, found := variables["token"]; found { + t.Fatal("failed capture retained the old token") + } + return + } + if result.Err != "" { + t.Fatalf("unexpected command error: %s", result.Err) + } + if variables["token"] != tc.value { + t.Fatalf("capture = %q, want %q", variables["token"], tc.value) + } + }) } } diff --git a/checks/http.go b/checks/http.go index 3466512..ab58ea5 100644 --- a/checks/http.go +++ b/checks/http.go @@ -28,9 +28,20 @@ func runHTTPRequest( ) ( result api.HTTPRequestResult, ) { - finalBaseURL := strings.TrimSuffix(baseURL, "/") - interpolatedURL := InterpolateVariables(requestStep.Request.FullURL, variables) - completeURL := strings.Replace(interpolatedURL, api.BaseURLPlaceholder, finalBaseURL, 1) + captured := make(map[string]string) + defer func() { + result.Err = publishCaptures(captureNames(api.CLIStep{HTTPRequest: &requestStep}), variables, captured, result.Err) + result.Variables = maps.Clone(variables) + result.Request = requestStep + }() + requestVariables := maps.Clone(variables) + if requestVariables == nil { + requestVariables = make(map[string]string) + } + if _, ok := requestVariables["baseURL"]; !ok { + requestVariables["baseURL"] = strings.TrimSuffix(baseURL, "/") + } + completeURL := InterpolateVariables(requestStep.Request.FullURL, requestVariables) var requestBody io.Reader var contentType string @@ -103,21 +114,20 @@ func runHTTPRequest( } bodyString := truncateAndStringifyBody(body) - if err := parseVariables([]byte(bodyString), requestStep.ResponseVariables, variables); err != nil { - return api.HTTPRequestResult{Err: fmt.Sprintf("Failed to parse response variable: %s", err)} - } - if err := parseHeaderVariables(headers, requestStep.ResponseHeaderVariables, variables); err != nil { - return api.HTTPRequestResult{Err: fmt.Sprintf("Failed to parse response header variable: %s", err)} - } - result = api.HTTPRequestResult{ StatusCode: resp.StatusCode, ResponseHeaders: headers, ResponseTrailers: trailers, BodyString: bodyString, - Variables: maps.Clone(variables), - Request: requestStep, } + if err := parseVariables([]byte(bodyString), requestStep.ResponseVariables, captured); err != nil { + result.Err = fmt.Sprintf("Failed to parse response variable: %s", err) + return result + } + if err := parseHeaderVariables(headers, requestStep.ResponseHeaderVariables, captured); err != nil { + result.Err = fmt.Sprintf("Failed to parse response header variable: %s", err) + } + return result } diff --git a/checks/local.go b/checks/local.go index e5ab555..cc77934 100644 --- a/checks/local.go +++ b/checks/local.go @@ -36,6 +36,23 @@ func EvaluateCLIResults(cliData api.CLIData, results []api.CLIStepResult) *api.S for i, step := range cliData.Steps { actual := results[i] + variants := 0 + if actual.CLICommandResult != nil { + variants++ + } + if actual.HTTPRequestResult != nil { + variants++ + } + if actual.DependencyFailure != nil { + variants++ + } + if variants != 1 { + return localFailure(i, -1, "invalid step result: expected exactly one result variant") + } + if actual.DependencyFailure != nil { + return localFailure(i, -1, "step skipped: unavailable variables: "+strings.Join(actual.DependencyFailure.Names, ", ")) + } + if step.CLICommand != nil && actual.CLICommandResult != nil { verificationErr := evaluateCLICommandTests(i, *step.CLICommand, *actual.CLICommandResult) if verificationErr != nil { @@ -120,6 +137,19 @@ func evaluateCLICommandTests(stepIndex int, expect api.CLIStepCLICommand, actual } } + for _, expectedVar := range expect.StdoutVariables { + expectedValue, found, err := regexCapture(expectedVar.Regex, actual.Stdout) + if err != nil { + return localFailure(stepIndex, len(expect.Tests)+1, fmt.Sprintf("invalid regex for stdout variable '%s'", expectedVar.Name)) + } + if !found { + return localFailure(stepIndex, len(expect.Tests)+1, fmt.Sprintf("missing value for variable '%s'", expectedVar.Name)) + } + if !capturedVariableMatches(actual.Variables, expectedVar.Name, expectedValue) { + return localFailure(stepIndex, len(expect.Tests)+1, fmt.Sprintf("captured variable '%s' did not match expected stdout value", expectedVar.Name)) + } + } + return nil } diff --git a/checks/runner.go b/checks/runner.go index 9dbcb1a..16c6ea4 100644 --- a/checks/runner.go +++ b/checks/runner.go @@ -2,6 +2,7 @@ package checks import ( "errors" + "fmt" "net/http" "strings" "time" @@ -22,6 +23,18 @@ func CLIChecks(cliData api.CLIData, options RunOptions, send func(tea.Msg)) ([]a if err := validateCLIAssertions(cliData); err != nil { return nil, err } + namespace := map[string]bool{"baseURL": true} + for i, step := range cliData.Steps { + seen := make(map[string]bool) + for _, name := range captureNames(step) { + if name == "" || name == "baseURL" || seen[name] { + return nil, fmt.Errorf("step %d: invalid or duplicate capture name %q", i+1, name) + } + seen[name] = true + namespace[name] = true + } + } + shell, err := resolveShell(options.Shell) if err != nil { return nil, err @@ -45,6 +58,15 @@ func CLIChecks(cliData api.CLIData, options RunOptions, send func(tea.Msg)) ([]a } for i, step := range cliData.Steps { + if missing := missingDependencies(step, namespace, variables); len(missing) > 0 { + for _, name := range captureNames(step) { + delete(variables, name) + } + results[i].DependencyFailure = &api.DependencyFailure{Names: missing} + send(messages.StartStepMsg{Description: step.Description, NoPenaltyOnFail: step.NoPenaltyOnFail}) + send(messages.ResolveStepMsg{Index: i, Result: &results[i]}) + continue + } switch { case step.CLICommand != nil: send(messages.StartStepMsg{ @@ -62,8 +84,7 @@ func CLIChecks(cliData api.CLIData, options RunOptions, send func(tea.Msg)) ([]a handleSleep(step.CLICommand.SleepAfterMs, send) case step.HTTPRequest != nil: - fullURL := strings.Replace(step.HTTPRequest.Request.FullURL, api.BaseURLPlaceholder, baseURL, 1) - interpolatedURL := InterpolateVariables(fullURL, variables) + interpolatedURL := InterpolateVariables(step.HTTPRequest.Request.FullURL, variables) send(messages.StartStepMsg{ Description: step.Description, diff --git a/client/lessons.go b/client/lessons.go index a66b6d4..0e00cc2 100644 --- a/client/lessons.go +++ b/client/lessons.go @@ -198,9 +198,14 @@ func FetchNextCLILesson() (*NextCLILesson, error) { return &data, nil } +type DependencyFailure struct { + Names []string +} + type CLIStepResult struct { CLICommandResult *CLICommandResult HTTPRequestResult *HTTPRequestResult + DependencyFailure *DependencyFailure `json:",omitempty"` } type CLICommandResult struct { diff --git a/render/view.go b/render/view.go index 711020f..faa96ab 100644 --- a/render/view.go +++ b/render/view.go @@ -227,6 +227,9 @@ 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" + } var str strings.Builder if step.result.CLICommandResult != nil { for _, test := range step.result.CLICommandResult.Command.Tests {