Skip to content

Commit 8f2c15f

Browse files
add option to capture cliCommand output
1 parent dd5ab5f commit 8f2c15f

5 files changed

Lines changed: 172 additions & 4 deletions

File tree

checks/cli.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"maps"
66
"os"
77
"os/exec"
8+
"regexp"
89
"runtime"
910
"strings"
1011

@@ -35,10 +36,38 @@ func runCLICommand(command api.CLIStepCLICommand, variables map[string]string) (
3536
if command.StdoutFilterTmdl != nil {
3637
result.Stdout = ExtractTmdlBlock(result.Stdout, *command.StdoutFilterTmdl)
3738
}
39+
if err := parseStdoutVariables(result.Stdout, command.StdoutVariables, variables); err != nil {
40+
result.Err = err.Error()
41+
}
3842
result.Variables = maps.Clone(variables)
3943
return result
4044
}
4145

46+
func parseStdoutVariables(stdout string, vardefs []api.CLICommandStdoutVariable, variables map[string]string) error {
47+
for _, vardef := range vardefs {
48+
if vardef.Name == "" {
49+
return fmt.Errorf("invalid stdout variable configuration")
50+
}
51+
if vardef.Regex == "" {
52+
return fmt.Errorf("invalid stdout variable configuration")
53+
}
54+
re, err := regexp.Compile(vardef.Regex)
55+
if err != nil {
56+
return fmt.Errorf("invalid stdout variable configuration")
57+
}
58+
if re.NumSubexp() != 1 {
59+
return fmt.Errorf("invalid stdout variable configuration")
60+
}
61+
62+
matches := re.FindStringSubmatch(stdout)
63+
if len(matches) == 2 {
64+
variables[vardef.Name] = matches[1]
65+
}
66+
}
67+
68+
return nil
69+
}
70+
4271
func prettyPrintCLICommand(test api.CLICommandTest, variables map[string]string) string {
4372
if test.ExitCode != nil {
4473
return fmt.Sprintf("Expect exit code %d", *test.ExitCode)

checks/cli_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package checks
2+
3+
import (
4+
"runtime"
5+
"testing"
6+
7+
api "github.com/bootdotdev/bootdev/client"
8+
)
9+
10+
func TestRunCLICommandCapturesStdoutVariables(t *testing.T) {
11+
variables := map[string]string{}
12+
result := runCLICommand(api.CLIStepCLICommand{
13+
Command: `go env GOOS`,
14+
StdoutVariables: []api.CLICommandStdoutVariable{{
15+
Name: "goos",
16+
Regex: `([a-z0-9]+)`,
17+
}},
18+
}, variables)
19+
20+
if result.Err != "" {
21+
t.Fatalf("unexpected command error: %s", result.Err)
22+
}
23+
if result.Variables["goos"] != runtime.GOOS {
24+
t.Fatalf("captured goos = %q, want %q", result.Variables["goos"], runtime.GOOS)
25+
}
26+
if variables["goos"] != runtime.GOOS {
27+
t.Fatalf("shared goos = %q, want %q", variables["goos"], runtime.GOOS)
28+
}
29+
}
30+
31+
func TestRunCLICommandInterpolatesCapturedStdoutVariables(t *testing.T) {
32+
variables := map[string]string{}
33+
34+
first := runCLICommand(api.CLIStepCLICommand{
35+
Command: `go env -json GOOS`,
36+
StdoutVariables: []api.CLICommandStdoutVariable{{
37+
Name: "goenv",
38+
Regex: `"([A-Z]+)"`,
39+
}},
40+
}, variables)
41+
if first.Err != "" {
42+
t.Fatalf("unexpected first command error: %s", first.Err)
43+
}
44+
45+
second := runCLICommand(api.CLIStepCLICommand{
46+
Command: `go env ${goenv}`,
47+
}, variables)
48+
if second.Stdout != runtime.GOOS {
49+
t.Fatalf("second stdout = %q, want %q", second.Stdout, runtime.GOOS)
50+
}
51+
}
52+
53+
func TestParseStdoutVariablesRequiresOneCaptureGroup(t *testing.T) {
54+
variables := map[string]string{}
55+
err := parseStdoutVariables("token=abc123", []api.CLICommandStdoutVariable{{
56+
Name: "token",
57+
Regex: `token=([a-z]+)([0-9]+)`,
58+
}}, variables)
59+
60+
if err == nil {
61+
t.Fatal("expected parse error")
62+
}
63+
if err.Error() != "invalid stdout variable configuration" {
64+
t.Fatalf("error = %q, want invalid stdout variable configuration", err.Error())
65+
}
66+
}
67+
68+
func TestParseStdoutVariablesUsesGenericConfigurationError(t *testing.T) {
69+
tests := []struct {
70+
name string
71+
vardef api.CLICommandStdoutVariable
72+
}{
73+
{
74+
name: "missing name",
75+
vardef: api.CLICommandStdoutVariable{Regex: `token=([a-z0-9]+)`},
76+
},
77+
{
78+
name: "missing regex",
79+
vardef: api.CLICommandStdoutVariable{Name: "token"},
80+
},
81+
{
82+
name: "invalid regex",
83+
vardef: api.CLICommandStdoutVariable{Name: "token", Regex: `token=([a-z0-9]+`},
84+
},
85+
{
86+
name: "too many capture groups",
87+
vardef: api.CLICommandStdoutVariable{Name: "token", Regex: `token=([a-z]+)([0-9]+)`},
88+
},
89+
}
90+
91+
for _, tt := range tests {
92+
t.Run(tt.name, func(t *testing.T) {
93+
variables := map[string]string{}
94+
err := parseStdoutVariables("token=abc123", []api.CLICommandStdoutVariable{tt.vardef}, variables)
95+
if err == nil {
96+
t.Fatal("expected parse error")
97+
}
98+
if err.Error() != "invalid stdout variable configuration" {
99+
t.Fatalf("error = %q, want invalid stdout variable configuration", err.Error())
100+
}
101+
})
102+
}
103+
}

checks/local.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ func EvaluateCLIResults(cliData api.CLIData, results []api.CLIStepResult) *api.S
6161
}
6262

6363
func evaluateCLICommandTests(stepIndex int, cmd api.CLIStepCLICommand, result api.CLICommandResult) *api.StructuredErrCLI {
64+
if result.Err != "" {
65+
return localFailure(stepIndex, 0, result.Err)
66+
}
67+
6468
for testIndex, test := range cmd.Tests {
6569
var err error
6670

checks/local_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,31 @@ func TestLocalSubmissionEventReportsFirstFailure(t *testing.T) {
7373
}
7474
}
7575

76+
func TestEvaluateCLICommandReportsStdoutVariableParseError(t *testing.T) {
77+
cliData := api.CLIData{Steps: []api.CLIStep{
78+
{CLICommand: &api.CLIStepCLICommand{Tests: []api.CLICommandTest{
79+
{ExitCode: intPtr(0)},
80+
}}},
81+
}}
82+
results := []api.CLIStepResult{
83+
{CLICommandResult: &api.CLICommandResult{
84+
ExitCode: 0,
85+
Err: "invalid stdout variable configuration",
86+
}},
87+
}
88+
89+
event := LocalSubmissionEvent(cliData, results)
90+
if event.ResultSlug != api.VerificationResultSlugFailure {
91+
t.Fatalf("ResultSlug = %q, want failure", event.ResultSlug)
92+
}
93+
if event.StructuredErrCLI == nil {
94+
t.Fatal("expected structured failure")
95+
}
96+
if event.StructuredErrCLI.ErrorMessage != "invalid stdout variable configuration" {
97+
t.Fatalf("ErrorMessage = %q, want stdout variable error", event.StructuredErrCLI.ErrorMessage)
98+
}
99+
}
100+
76101
func TestEvaluateStdoutJq(t *testing.T) {
77102
err := evaluateStdoutJq(
78103
"{\"ok\":true}",

client/lessons.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,16 @@ type CLIStep struct {
3434
}
3535

3636
type CLIStepCLICommand struct {
37-
Command string `yaml:"command"`
38-
Tests []CLICommandTest `yaml:"tests"`
39-
SleepAfterMs *int `yaml:"sleepAfterMs"`
40-
StdoutFilterTmdl *string `yaml:"stdoutFilterTmdl"`
37+
Command string `yaml:"command"`
38+
Tests []CLICommandTest `yaml:"tests"`
39+
StdoutVariables []CLICommandStdoutVariable `yaml:"stdoutVariables"`
40+
SleepAfterMs *int `yaml:"sleepAfterMs"`
41+
StdoutFilterTmdl *string `yaml:"stdoutFilterTmdl"`
42+
}
43+
44+
type CLICommandStdoutVariable struct {
45+
Name string `yaml:"name"`
46+
Regex string `yaml:"regex"`
4147
}
4248

4349
type CLICommandTest struct {
@@ -174,6 +180,7 @@ type CLIStepResult struct {
174180

175181
type CLICommandResult struct {
176182
ExitCode int
183+
Err string `json:"-"`
177184
FinalCommand string `json:"-"`
178185
Command CLIStepCLICommand `json:"-"`
179186
Stdout string

0 commit comments

Comments
 (0)