Skip to content

Commit b42fa70

Browse files
skip steps that depend on failed captures
1 parent f08c6d3 commit b42fa70

5 files changed

Lines changed: 148 additions & 17 deletions

File tree

‎checks/captures.go‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package checks
2+
3+
import (
4+
"fmt"
5+
"slices"
6+
7+
api "github.com/bootdotdev/bootdev/client"
8+
)
9+
10+
func captureNames(step api.CLIStep) []string {
11+
var names []string
12+
if step.CLICommand != nil {
13+
for _, capture := range step.CLICommand.StdoutVariables {
14+
names = append(names, capture.Name)
15+
}
16+
}
17+
if step.HTTPRequest != nil {
18+
for _, capture := range step.HTTPRequest.ResponseVariables {
19+
names = append(names, capture.Name)
20+
}
21+
for _, capture := range step.HTTPRequest.ResponseHeaderVariables {
22+
names = append(names, capture.Name)
23+
}
24+
}
25+
return names
26+
}
27+
28+
// Missing and invalidated workflow bindings both block execution. Keeping the
29+
// namespace separate from values prevents failed or forward captures being
30+
// mistaken for shell variables.
31+
func missingDependencies(step api.CLIStep, namespace map[string]bool, values map[string]string) []string {
32+
var names []string
33+
add := func(text string) {
34+
for _, name := range InterpolationNames(text) {
35+
if _, available := values[name]; namespace[name] && !available {
36+
names = append(names, name)
37+
}
38+
}
39+
}
40+
var visit func(any)
41+
visit = func(value any) {
42+
switch value := value.(type) {
43+
case string:
44+
add(value)
45+
case []any:
46+
for _, item := range value {
47+
visit(item)
48+
}
49+
case map[string]any:
50+
for _, item := range value {
51+
visit(item)
52+
}
53+
}
54+
}
55+
if step.CLICommand != nil {
56+
add(step.CLICommand.Command)
57+
}
58+
if step.HTTPRequest != nil {
59+
request := step.HTTPRequest.Request
60+
add(request.FullURL)
61+
for _, value := range request.Headers {
62+
add(value)
63+
}
64+
if request.BodyJSON != nil {
65+
visit(request.BodyJSON)
66+
} else {
67+
for _, value := range request.BodyForm {
68+
add(value)
69+
}
70+
}
71+
}
72+
slices.Sort(names)
73+
return slices.Compact(names)
74+
}
75+
76+
// Finalize even on early execution errors so previous output values cannot leak
77+
// into later steps. Extraction writes only to captured, never to values.
78+
func publishCaptures(names []string, values, captured map[string]string, captureErr string) string {
79+
for _, name := range names {
80+
delete(values, name)
81+
if _, found := captured[name]; !found && captureErr == "" {
82+
captureErr = fmt.Sprintf("missing value for variable '%s'", name)
83+
}
84+
}
85+
if captureErr == "" {
86+
for _, name := range names {
87+
values[name] = captured[name]
88+
}
89+
}
90+
return captureErr
91+
}

‎checks/cli.go‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ func runCLICommandWithOutputLimit(
8585
maxOutputBytesPerStream int,
8686
shell commandShell,
8787
) (result api.CLICommandResult) {
88+
captured := make(map[string]string)
89+
defer func() {
90+
result.Err = publishCaptures(captureNames(api.CLIStep{CLICommand: &command}), variables, captured, result.Err)
91+
result.Variables = maps.Clone(variables)
92+
}()
8893
finalCommand := InterpolateVariables(command.Command, variables)
8994
result.FinalCommand = finalCommand
9095
result.Command = command
@@ -103,6 +108,9 @@ func runCLICommandWithOutputLimit(
103108
} else if err != nil {
104109
result.ExitCode = -2
105110
}
111+
if result.ExitCode < 0 && err != nil {
112+
result.Err = err.Error()
113+
}
106114

107115
result.Stdout = strings.TrimRight(stdout.String(), " \n\t\r")
108116
result.Stderr = strings.TrimRight(stderr.String(), " \n\t\r")
@@ -113,11 +121,9 @@ func runCLICommandWithOutputLimit(
113121
if stdout.truncated || stderr.truncated {
114122
result.Err = fmt.Sprintf("command output exceeded the %d-byte per-stream limit", maxOutputBytesPerStream)
115123
result.ExitCode = -2
116-
} else if err := parseStdoutVariables(result.Stdout, command.StdoutVariables, variables); err != nil {
124+
} else if err := parseStdoutVariables(result.Stdout, command.StdoutVariables, captured); err != nil {
117125
result.Err = err.Error()
118126
}
119-
result.Variables = maps.Clone(variables)
120-
121127
return result
122128
}
123129

‎checks/http.go‎

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,20 @@ func runHTTPRequest(
2828
) (
2929
result api.HTTPRequestResult,
3030
) {
31-
finalBaseURL := strings.TrimSuffix(baseURL, "/")
32-
interpolatedURL := InterpolateVariables(requestStep.Request.FullURL, variables)
33-
completeURL := strings.Replace(interpolatedURL, api.BaseURLPlaceholder, finalBaseURL, 1)
31+
captured := make(map[string]string)
32+
defer func() {
33+
result.Err = publishCaptures(captureNames(api.CLIStep{HTTPRequest: &requestStep}), variables, captured, result.Err)
34+
result.Variables = maps.Clone(variables)
35+
result.Request = requestStep
36+
}()
37+
requestVariables := maps.Clone(variables)
38+
if requestVariables == nil {
39+
requestVariables = make(map[string]string)
40+
}
41+
if _, ok := requestVariables["baseURL"]; !ok {
42+
requestVariables["baseURL"] = strings.TrimSuffix(baseURL, "/")
43+
}
44+
completeURL := InterpolateVariables(requestStep.Request.FullURL, requestVariables)
3445

3546
var requestBody io.Reader
3647
var contentType string
@@ -103,21 +114,20 @@ func runHTTPRequest(
103114
}
104115

105116
bodyString := truncateAndStringifyBody(body)
106-
if err := parseVariables([]byte(bodyString), requestStep.ResponseVariables, variables); err != nil {
107-
return api.HTTPRequestResult{Err: fmt.Sprintf("Failed to parse response variable: %s", err)}
108-
}
109-
if err := parseHeaderVariables(headers, requestStep.ResponseHeaderVariables, variables); err != nil {
110-
return api.HTTPRequestResult{Err: fmt.Sprintf("Failed to parse response header variable: %s", err)}
111-
}
112-
113117
result = api.HTTPRequestResult{
114118
StatusCode: resp.StatusCode,
115119
ResponseHeaders: headers,
116120
ResponseTrailers: trailers,
117121
BodyString: bodyString,
118-
Variables: maps.Clone(variables),
119-
Request: requestStep,
120122
}
123+
if err := parseVariables([]byte(bodyString), requestStep.ResponseVariables, captured); err != nil {
124+
result.Err = fmt.Sprintf("Failed to parse response variable: %s", err)
125+
return result
126+
}
127+
if err := parseHeaderVariables(headers, requestStep.ResponseHeaderVariables, captured); err != nil {
128+
result.Err = fmt.Sprintf("Failed to parse response header variable: %s", err)
129+
}
130+
121131
return result
122132
}
123133

‎checks/runner.go‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package checks
22

33
import (
44
"errors"
5+
"fmt"
56
"net/http"
67
"strings"
78
"time"
@@ -22,6 +23,18 @@ func CLIChecks(cliData api.CLIData, options RunOptions, send func(tea.Msg)) ([]a
2223
if err := validateCLIAssertions(cliData); err != nil {
2324
return nil, err
2425
}
26+
namespace := map[string]bool{"baseURL": true}
27+
for i, step := range cliData.Steps {
28+
seen := make(map[string]bool)
29+
for _, name := range captureNames(step) {
30+
if name == "" || name == "baseURL" || seen[name] {
31+
return nil, fmt.Errorf("step %d: invalid or duplicate capture name %q", i+1, name)
32+
}
33+
seen[name] = true
34+
namespace[name] = true
35+
}
36+
}
37+
2538
shell, err := resolveShell(options.Shell)
2639
if err != nil {
2740
return nil, err
@@ -45,6 +58,15 @@ func CLIChecks(cliData api.CLIData, options RunOptions, send func(tea.Msg)) ([]a
4558
}
4659

4760
for i, step := range cliData.Steps {
61+
if missing := missingDependencies(step, namespace, variables); len(missing) > 0 {
62+
for _, name := range captureNames(step) {
63+
delete(variables, name)
64+
}
65+
results[i].DependencyFailure = &api.DependencyFailure{Names: missing}
66+
send(messages.StartStepMsg{Description: step.Description, NoPenaltyOnFail: step.NoPenaltyOnFail})
67+
send(messages.ResolveStepMsg{Index: i, Result: &results[i]})
68+
continue
69+
}
4870
switch {
4971
case step.CLICommand != nil:
5072
send(messages.StartStepMsg{
@@ -62,8 +84,7 @@ func CLIChecks(cliData api.CLIData, options RunOptions, send func(tea.Msg)) ([]a
6284
handleSleep(step.CLICommand.SleepAfterMs, send)
6385

6486
case step.HTTPRequest != nil:
65-
fullURL := strings.Replace(step.HTTPRequest.Request.FullURL, api.BaseURLPlaceholder, baseURL, 1)
66-
interpolatedURL := InterpolateVariables(fullURL, variables)
87+
interpolatedURL := InterpolateVariables(step.HTTPRequest.Request.FullURL, variables)
6788

6889
send(messages.StartStepMsg{
6990
Description: step.Description,

‎render/view.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,9 @@ func renderCompactStep(step stepModel, spinner string, isSubmit bool) string {
227227
}
228228

229229
func renderStepResult(step stepModel) string {
230+
if failure := step.result.DependencyFailure; failure != nil {
231+
return " > Skipped: unavailable variables: " + strings.Join(failure.Names, ", ") + "\n"
232+
}
230233
var str strings.Builder
231234
if step.result.CLICommandResult != nil {
232235
for _, test := range step.result.CLICommandResult.Command.Tests {

0 commit comments

Comments
 (0)