Skip to content

Commit e34f5f9

Browse files
add local test command
1 parent e5931e5 commit e34f5f9

7 files changed

Lines changed: 610 additions & 58 deletions

File tree

checks/jq_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ func TestRunStdoutJqQuery(t *testing.T) {
5656
},
5757
{
5858
name: "returns jq error",
59-
stdout: `{"name":"Theo"}`,
59+
stdout: `{"name":"Kaladin"}`,
6060
test: api.StdoutJqTest{
6161
InputMode: "json",
6262
Query: `.name[`,
@@ -106,13 +106,13 @@ func TestFormatJqResults(t *testing.T) {
106106
}
107107

108108
func TestFormatJqExpectedValueInterpolatesOnlyStrings(t *testing.T) {
109-
variables := map[string]string{"name": "Theo"}
109+
variables := map[string]string{"name": "Allan"}
110110

111111
gotString := formatJqExpectedValue(api.JqExpectedResult{
112112
Type: api.JqTypeString,
113113
Value: "hello ${name}",
114114
}, variables)
115-
if gotString != `"hello Theo"` {
115+
if gotString != `"hello Allan"` {
116116
t.Fatalf("expected interpolated string value, got %q", gotString)
117117
}
118118

checks/local.go

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
package checks
2+
3+
import (
4+
"fmt"
5+
"math"
6+
"strconv"
7+
"strings"
8+
9+
api "github.com/bootdotdev/bootdev/client"
10+
)
11+
12+
func LocalSubmissionEvent(cliData api.CLIData, results []api.CLIStepResult) api.LessonSubmissionEvent {
13+
failure := EvaluateCLIResults(cliData, results)
14+
slug := api.VerificationResultSlugSuccess
15+
if failure != nil {
16+
slug = api.VerificationResultSlugFailure
17+
if failure.FailedStepIndex >= 0 &&
18+
failure.FailedStepIndex < len(cliData.Steps) &&
19+
cliData.Steps[failure.FailedStepIndex].NoPenaltyOnFail {
20+
slug = api.VerificationResultSlugNoop
21+
}
22+
}
23+
24+
return api.LessonSubmissionEvent{
25+
ResultSlug: slug,
26+
StructuredErrCLI: failure,
27+
XPReward: -1,
28+
}
29+
}
30+
31+
func EvaluateCLIResults(cliData api.CLIData, results []api.CLIStepResult) *api.StructuredErrCLI {
32+
for stepIndex, step := range cliData.Steps {
33+
if stepIndex >= len(results) {
34+
return localFailure(stepIndex, 0, "missing result for step")
35+
}
36+
37+
switch {
38+
case step.CLICommand != nil:
39+
result := results[stepIndex].CLICommandResult
40+
if result == nil {
41+
return localFailure(stepIndex, 0, "missing CLI command result")
42+
}
43+
if failure := evaluateCLICommandTests(stepIndex, *step.CLICommand, *result); failure != nil {
44+
return failure
45+
}
46+
case step.HTTPRequest != nil:
47+
result := results[stepIndex].HTTPRequestResult
48+
if result == nil {
49+
return localFailure(stepIndex, 0, "missing HTTP request result")
50+
}
51+
if failure := evaluateHTTPRequestTests(stepIndex, *step.HTTPRequest, *result); failure != nil {
52+
return failure
53+
}
54+
default:
55+
return localFailure(stepIndex, 0, "missing step definition")
56+
}
57+
}
58+
59+
return nil
60+
}
61+
62+
func evaluateCLICommandTests(stepIndex int, cmd api.CLIStepCLICommand, result api.CLICommandResult) *api.StructuredErrCLI {
63+
for testIndex, test := range cmd.Tests {
64+
var err error
65+
66+
switch {
67+
case test.ExitCode != nil:
68+
if result.ExitCode != *test.ExitCode {
69+
err = fmt.Errorf("expected exit code %d, got %d", *test.ExitCode, result.ExitCode)
70+
}
71+
case len(test.StdoutContainsAll) > 0:
72+
for _, contains := range test.StdoutContainsAll {
73+
needle := InterpolateVariables(contains, result.Variables)
74+
if !strings.Contains(result.Stdout, needle) {
75+
err = fmt.Errorf("expected stdout to contain %q", needle)
76+
break
77+
}
78+
}
79+
case len(test.StdoutContainsNone) > 0:
80+
for _, containsNone := range test.StdoutContainsNone {
81+
needle := InterpolateVariables(containsNone, result.Variables)
82+
if strings.Contains(result.Stdout, needle) {
83+
err = fmt.Errorf("expected stdout to not contain %q", needle)
84+
break
85+
}
86+
}
87+
case test.StdoutLinesGt != nil:
88+
lineCount := stdoutLineCount(result.Stdout)
89+
if lineCount <= *test.StdoutLinesGt {
90+
err = fmt.Errorf("expected stdout to have more than %d lines, got %d", *test.StdoutLinesGt, lineCount)
91+
}
92+
case test.StdoutJq != nil:
93+
err = evaluateStdoutJq(result.Stdout, *test.StdoutJq, result.Variables)
94+
default:
95+
err = fmt.Errorf("unsupported CLI command test")
96+
}
97+
98+
if err != nil {
99+
return localFailure(stepIndex, testIndex, err.Error())
100+
}
101+
}
102+
103+
return nil
104+
}
105+
106+
func evaluateHTTPRequestTests(stepIndex int, req api.CLIStepHTTPRequest, result api.HTTPRequestResult) *api.StructuredErrCLI {
107+
if result.Err != "" {
108+
return localFailure(stepIndex, 0, result.Err)
109+
}
110+
111+
for testIndex, test := range req.Tests {
112+
var err error
113+
114+
switch {
115+
case test.StatusCode != nil:
116+
if result.StatusCode != *test.StatusCode {
117+
err = fmt.Errorf("expected status code %d, got %d", *test.StatusCode, result.StatusCode)
118+
}
119+
case test.BodyContains != nil:
120+
needle := InterpolateVariables(*test.BodyContains, result.Variables)
121+
if !strings.Contains(result.BodyString, needle) {
122+
err = fmt.Errorf("expected body to contain %q", needle)
123+
}
124+
case test.BodyContainsNone != nil:
125+
needle := InterpolateVariables(*test.BodyContainsNone, result.Variables)
126+
if strings.Contains(result.BodyString, needle) {
127+
err = fmt.Errorf("expected body to not contain %q", needle)
128+
}
129+
case test.HeadersContain != nil:
130+
err = evaluateHeaderContains(result.ResponseHeaders, *test.HeadersContain, result.Variables, "header")
131+
case test.TrailersContain != nil:
132+
err = evaluateHeaderContains(result.ResponseTrailers, *test.TrailersContain, result.Variables, "trailer")
133+
case test.JSONValue != nil:
134+
err = evaluateHTTPJSONValue(result.BodyString, *test.JSONValue, result.Variables)
135+
default:
136+
err = fmt.Errorf("unsupported HTTP request test")
137+
}
138+
139+
if err != nil {
140+
return localFailure(stepIndex, testIndex, err.Error())
141+
}
142+
}
143+
144+
return nil
145+
}
146+
147+
func evaluateHeaderContains(headers map[string]string, test api.HTTPRequestTestHeader, variables map[string]string, label string) error {
148+
key := InterpolateVariables(test.Key, variables)
149+
want := InterpolateVariables(test.Value, variables)
150+
151+
got, ok := findHeaderValue(headers, key)
152+
if !ok {
153+
return fmt.Errorf("expected %s %q to exist", label, key)
154+
}
155+
if !strings.Contains(got, want) {
156+
return fmt.Errorf("expected %s %q to contain %q, got %q", label, key, want, got)
157+
}
158+
159+
return nil
160+
}
161+
162+
func evaluateHTTPJSONValue(body string, test api.HTTPRequestTestJSONValue, variables map[string]string) error {
163+
got, err := valFromJqPath(test.Path, body)
164+
if err != nil {
165+
return err
166+
}
167+
168+
want, err := httpJSONExpectedValue(test, variables)
169+
if err != nil {
170+
return err
171+
}
172+
173+
if !compareValues(got, test.Operator, want) {
174+
return fmt.Errorf("expected JSON at %s %s %v, got %v", test.Path, test.Operator, want, got)
175+
}
176+
177+
return nil
178+
}
179+
180+
func httpJSONExpectedValue(test api.HTTPRequestTestJSONValue, variables map[string]string) (any, error) {
181+
switch {
182+
case test.IntValue != nil:
183+
return *test.IntValue, nil
184+
case test.StringValue != nil:
185+
return InterpolateVariables(*test.StringValue, variables), nil
186+
case test.BoolValue != nil:
187+
return *test.BoolValue, nil
188+
default:
189+
return nil, fmt.Errorf("missing expected JSON value")
190+
}
191+
}
192+
193+
func evaluateStdoutJq(stdout string, test api.StdoutJqTest, variables map[string]string) error {
194+
queryText := InterpolateVariables(test.Query, variables)
195+
196+
input, err := parseJqInput(stdout, test.InputMode)
197+
if err != nil {
198+
return err
199+
}
200+
201+
results, err := executeJqQuery(queryText, input)
202+
if err != nil {
203+
return err
204+
}
205+
if len(results) != len(test.ExpectedResults) {
206+
return fmt.Errorf("expected jq query %q to return %d result(s), got %d", queryText, len(test.ExpectedResults), len(results))
207+
}
208+
209+
for i, expected := range test.ExpectedResults {
210+
want, err := jqExpectedValue(expected, variables)
211+
if err != nil {
212+
return err
213+
}
214+
if !compareValues(results[i], api.OperatorType(expected.Operator), want) {
215+
return fmt.Errorf("expected jq result %d to be %s %v, got %v", i+1, expected.Operator, want, results[i])
216+
}
217+
}
218+
219+
return nil
220+
}
221+
222+
func jqExpectedValue(expected api.JqExpectedResult, variables map[string]string) (any, error) {
223+
switch expected.Type {
224+
case api.JqTypeString:
225+
if str, ok := expected.Value.(string); ok {
226+
return InterpolateVariables(str, variables), nil
227+
}
228+
return expected.Value, nil
229+
case api.JqTypeInt:
230+
if str, ok := expected.Value.(string); ok {
231+
parsed, err := strconv.Atoi(InterpolateVariables(str, variables))
232+
if err != nil {
233+
return nil, err
234+
}
235+
return parsed, nil
236+
}
237+
return expected.Value, nil
238+
case api.JqTypeBool:
239+
if str, ok := expected.Value.(string); ok {
240+
parsed, err := strconv.ParseBool(InterpolateVariables(str, variables))
241+
if err != nil {
242+
return nil, err
243+
}
244+
return parsed, nil
245+
}
246+
return expected.Value, nil
247+
default:
248+
return nil, fmt.Errorf("unsupported jq expected result type %q", expected.Type)
249+
}
250+
}
251+
252+
func compareValues(got any, operator api.OperatorType, want any) bool {
253+
switch operator {
254+
case api.OpEquals, "==":
255+
return valuesEqual(got, want)
256+
case api.OpGreaterThan, ">":
257+
gotNum, gotOK := numberValue(got)
258+
wantNum, wantOK := numberValue(want)
259+
return gotOK && wantOK && gotNum > wantNum
260+
case api.OpContains:
261+
return strings.Contains(fmt.Sprintf("%v", got), fmt.Sprintf("%v", want))
262+
case api.OpNotContains:
263+
return !strings.Contains(fmt.Sprintf("%v", got), fmt.Sprintf("%v", want))
264+
default:
265+
return false
266+
}
267+
}
268+
269+
func valuesEqual(got any, want any) bool {
270+
if gotNum, gotOK := numberValue(got); gotOK {
271+
wantNum, wantOK := numberValue(want)
272+
return wantOK && math.Abs(gotNum-wantNum) < 0.000000001
273+
}
274+
return fmt.Sprintf("%v", got) == fmt.Sprintf("%v", want)
275+
}
276+
277+
func numberValue(value any) (float64, bool) {
278+
switch v := value.(type) {
279+
case int:
280+
return float64(v), true
281+
case int64:
282+
return float64(v), true
283+
case float64:
284+
return v, true
285+
case jsonNumber:
286+
parsed, err := strconv.ParseFloat(v.String(), 64)
287+
return parsed, err == nil
288+
default:
289+
return 0, false
290+
}
291+
}
292+
293+
func stdoutLineCount(stdout string) int {
294+
if stdout == "" {
295+
return 0
296+
}
297+
return strings.Count(stdout, "\n") + 1
298+
}
299+
300+
func localFailure(stepIndex int, testIndex int, message string) *api.StructuredErrCLI {
301+
return &api.StructuredErrCLI{
302+
ErrorMessage: message,
303+
FailedStepIndex: stepIndex,
304+
FailedTestIndex: testIndex,
305+
}
306+
}
307+
308+
type jsonNumber interface {
309+
String() string
310+
}

0 commit comments

Comments
 (0)