Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 139 additions & 5 deletions checks/checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package checks

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
Expand All @@ -18,6 +17,7 @@ import (
api "github.com/bootdotdev/bootdev/client"
"github.com/bootdotdev/bootdev/messages"
tea "github.com/charmbracelet/bubbletea"
"github.com/goccy/go-json"
"github.com/itchyny/gojq"
"github.com/spf13/cobra"
"github.com/spf13/viper"
Expand Down Expand Up @@ -162,6 +162,7 @@ func CLIChecks(cliData api.CLIData, overrideBaseURL string, ch chan tea.Msg) (re
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)
Expand Down Expand Up @@ -258,6 +259,136 @@ func ApplySubmissionResults(cliData api.CLIData, failure *api.VerificationResult
}
}

func prettyPrintStdoutJqTest(test api.StdoutJqTest, variables map[string]string) string {
queryText := InterpolateVariables(test.Query, variables)
var str strings.Builder
str.WriteString(fmt.Sprintf("Expect jq query '%s' to yield values satisfying:", queryText))
if len(test.ExpectedResults) == 0 {
str.WriteString("\n - [no expected results provided]")
return str.String()
}
for _, expected := range test.ExpectedResults {
value := formatJqExpectedValue(expected, variables)
fmt.Fprintf(&str, "\n - %s %s %s", expected.Type, expected.Operator, value)
}
return str.String()
}

func formatJqExpectedValue(expected api.JqExpectedResult, variables map[string]string) string {
value := expected.Value
if expected.Type == api.JqTypeString {
if stringValue, ok := expected.Value.(string); ok {
value = InterpolateVariables(stringValue, variables)
}
}
encoded, err := json.Marshal(value)
if err != nil {
return fmt.Sprintf("%v", value)
}
return string(encoded)
}

func collectStdoutJqOutputs(cmd api.CLIStepCLICommand, result api.CLICommandResult) []api.CLICommandJqOutput {
outputs := make([]api.CLICommandJqOutput, 0)
for _, test := range cmd.Tests {
if test.StdoutJq == nil {
continue
}
outputs = append(outputs, runStdoutJqQuery(result.Stdout, *test.StdoutJq, result.Variables))
}
return outputs
}

func runStdoutJqQuery(stdout string, test api.StdoutJqTest, variables map[string]string) api.CLICommandJqOutput {
queryText := InterpolateVariables(test.Query, variables)
input, err := parseJqInput(stdout, test.InputMode)
if err != nil {
return api.CLICommandJqOutput{Query: queryText, Error: err.Error()}
}
results, err := executeJqQuery(queryText, input)
if err != nil {
return api.CLICommandJqOutput{Query: queryText, Error: err.Error()}
}
return api.CLICommandJqOutput{Query: queryText, Results: formatJqResults(results)}
}

func parseJqInput(stdout string, inputMode string) (any, error) {
mode := strings.ToLower(strings.TrimSpace(inputMode))
if mode != "json" && mode != "jsonl" {
mode = "json"
}

decoder := json.NewDecoder(strings.NewReader(stdout))
if mode == "jsonl" {
values := make([]any, 0)
for {
var value any
err := decoder.Decode(&value)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
values = append(values, value)
}
return values, nil
}

var value any
if err := decoder.Decode(&value); err != nil {
return nil, err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return nil, errors.New("expected a single JSON value")
}
return nil, err
}

return value, nil
}

func executeJqQuery(queryText string, input any) ([]any, error) {
query, err := gojq.Parse(queryText)
if err != nil {
return nil, err
}
iter := query.Run(input)
results := make([]any, 0)
for {
val, ok := iter.Next()
if !ok {
break
}
if err, ok := val.(error); ok {
return results, err
}
results = append(results, val)
}
return results, nil
}

func formatJqResults(results []any) []string {
if len(results) == 0 {
return nil
}
formatted := make([]string, 0, len(results))
for _, result := range results {
if result == nil {
formatted = append(formatted, "null")
continue
}
encoded, err := json.Marshal(result)
if err != nil {
formatted = append(formatted, fmt.Sprintf("%v", result))
continue
}
formatted = append(formatted, string(encoded))
}
return formatted
}

func prettyPrintCLICommand(test api.CLICommandTest, variables map[string]string) string {
if test.ExitCode != nil {
return fmt.Sprintf("Expect exit code %d", *test.ExitCode)
Expand All @@ -283,6 +414,9 @@ func prettyPrintCLICommand(test api.CLICommandTest, variables map[string]string)
}
return str.String()
}
if test.StdoutJq != nil {
return prettyPrintStdoutJqTest(*test.StdoutJq, variables)
}
return ""
}

Expand Down Expand Up @@ -351,7 +485,7 @@ func truncateAndStringifyBody(body []byte) string {

func parseVariables(body []byte, vardefs []api.HTTPRequestResponseVariable, variables map[string]string) error {
for _, vardef := range vardefs {
val, err := valFromJQPath(vardef.Path, string(body))
val, err := valFromJqPath(vardef.Path, string(body))
if err != nil {
return err
}
Expand All @@ -360,8 +494,8 @@ func parseVariables(body []byte, vardefs []api.HTTPRequestResponseVariable, vari
return nil
}

func valFromJQPath(path string, jsn string) (any, error) {
vals, err := valsFromJQPath(path, jsn)
func valFromJqPath(path string, jsn string) (any, error) {
vals, err := valsFromJqPath(path, jsn)
if err != nil {
return nil, err
}
Expand All @@ -375,7 +509,7 @@ func valFromJQPath(path string, jsn string) (any, error) {
return val, nil
}

func valsFromJQPath(path string, jsn string) ([]any, error) {
func valsFromJqPath(path string, jsn string) ([]any, error) {
var parseable any
err := json.Unmarshal([]byte(jsn), &parseable)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion client/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ package api

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"

"github.com/goccy/go-json"
"github.com/spf13/viper"
)

Expand Down
42 changes: 41 additions & 1 deletion client/lessons.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package api

import (
"encoding/json"
"fmt"

"github.com/goccy/go-json"
)

type Lesson struct {
Expand Down Expand Up @@ -42,8 +43,40 @@ type CLICommandTest struct {
StdoutContainsAll []string
StdoutContainsNone []string
StdoutLinesGt *int
StdoutJq *StdoutJqTest
}

type StdoutJqTest struct {
InputMode string
Query string
ExpectedResults []JqExpectedResult
}

type JqExpectedResult struct {
Type JqValueType `yaml:"type"`
Operator JqOperator `yaml:"operator"`
Value any `yaml:"value"`
}

type (
JqValueType string
JqOperator string
)

const (
JqTypeString JqValueType = "string"
JqTypeInt JqValueType = "int"
JqTypeBool JqValueType = "bool"
)

const (
JqOpEquals JqOperator = "=="
JqOpGreaterThan JqOperator = ">"
JqOpGreaterThanOrEqual JqOperator = ">="
JqOpLessThan JqOperator = "<"
JqOpLessThanOrEqual JqOperator = "<="
)

type CLIStepHTTPRequest struct {
ResponseVariables []HTTPRequestResponseVariable
Tests []HTTPRequestTest
Expand Down Expand Up @@ -140,6 +173,13 @@ type CLICommandResult struct {
FinalCommand string `json:"-"`
Stdout string
Variables map[string]string
JqOutputs []CLICommandJqOutput `json:"-"`
}

type CLICommandJqOutput struct {
Query string
Results []string
Error string
}

type HTTPRequestResult struct {
Expand Down
68 changes: 35 additions & 33 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,51 +1,53 @@
module github.com/bootdotdev/bootdev

go 1.23
go 1.24.2

require (
github.com/charmbracelet/bubbles v0.18.0
github.com/charmbracelet/bubbletea v0.26.1
github.com/charmbracelet/lipgloss v0.10.0
github.com/itchyny/gojq v0.12.15
github.com/muesli/termenv v0.15.2
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10
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.8.0
github.com/spf13/viper v1.18.2
golang.org/x/mod v0.17.0
golang.org/x/term v0.19.0
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
golang.org/x/mod v0.32.0
golang.org/x/term v0.39.0
)

require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.4 // indirect
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.7.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/itchyny/timefmt-go v0.1.5 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/itchyny/timefmt-go v0.1.7 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
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/reflow v0.3.0 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
)
Loading