diff --git a/bootdev b/bootdev new file mode 100755 index 0000000..7a7e8dc Binary files /dev/null and b/bootdev differ diff --git a/checks/checks.go b/checks/checks.go index 6a8a267..e88a609 100644 --- a/checks/checks.go +++ b/checks/checks.go @@ -15,8 +15,11 @@ import ( "time" api "github.com/bootdotdev/bootdev/client" + "github.com/bootdotdev/bootdev/messages" + tea "github.com/charmbracelet/bubbletea" "github.com/itchyny/gojq" "github.com/spf13/cobra" + "github.com/spf13/viper" ) func runCLICommand(command api.CLIStepCLICommand, variables map[string]string) (result api.CLICommandResult) { @@ -124,7 +127,7 @@ func runHTTPRequest( return result } -func CLIChecks(cliData api.CLIData, overrideBaseURL string) (results []api.CLIStepResult) { +func CLIChecks(cliData api.CLIData, overrideBaseURL string, ch chan tea.Msg) (results []api.CLIStepResult) { client := &http.Client{} variables := make(map[string]string) results = make([]api.CLIStepResult, len(cliData.Steps)) @@ -140,16 +143,41 @@ func CLIChecks(cliData api.CLIData, overrideBaseURL string) (results []api.CLISt } for i, step := range cliData.Steps { + // This is the magic of the initial message sent before executing the test + if step.CLICommand != nil { + ch <- messages.StartStepMsg{CMD: step.CLICommand.Command} + } else if step.HTTPRequest != nil { + finalBaseURL := baseURL + overrideURL := viper.GetString("override_base_url") + if overrideURL != "" { + finalBaseURL = overrideURL + } + fullURL := strings.Replace(step.HTTPRequest.Request.FullURL, api.BaseURLPlaceholder, finalBaseURL, 1) + interpolatedURL := InterpolateVariables(fullURL, variables) + + ch <- messages.StartStepMsg{ + URL: interpolatedURL, + Method: step.HTTPRequest.Request.Method, + ResponseVariables: step.HTTPRequest.ResponseVariables, + } + } + switch { case step.CLICommand != nil: result := runCLICommand(*step.CLICommand, variables) results[i].CLICommandResult = &result + + sendCLICommandResults(ch, *step.CLICommand, result, i) + case step.HTTPRequest != nil: result := runHTTPRequest(client, baseURL, variables, *step.HTTPRequest) results[i].HTTPRequestResult = &result if result.Variables != nil { variables = result.Variables } + + sendHTTPRequestResults(ch, *step.HTTPRequest, result, i) + default: cobra.CheckErr("unable to run lesson: missing step") } @@ -157,6 +185,113 @@ func CLIChecks(cliData api.CLIData, overrideBaseURL string) (results []api.CLISt return results } +func sendCLICommandResults(ch chan tea.Msg, cmd api.CLIStepCLICommand, result api.CLICommandResult, index int) { + for _, test := range cmd.Tests { + ch <- messages.StartTestMsg{Text: prettyPrintCLICommand(test, result.Variables)} + } + + for j := range cmd.Tests { + ch <- messages.ResolveTestMsg{Index: j} + } + + ch <- messages.ResolveStepMsg{ + Index: index, + Result: &api.CLIStepResult{ + CLICommandResult: &result, + }, + } +} + +func sendHTTPRequestResults(ch chan tea.Msg, req api.CLIStepHTTPRequest, result api.HTTPRequestResult, index int) { + for _, test := range req.Tests { + ch <- messages.StartTestMsg{Text: prettyPrintHTTPTest(test, result.Variables)} + } + + for j := range req.Tests { + ch <- messages.ResolveTestMsg{Index: j} + } + + ch <- messages.ResolveStepMsg{ + Index: index, + Result: &api.CLIStepResult{ + HTTPRequestResult: &result, + }, + } +} + +func prettyPrintCLICommand(test api.CLICommandTest, variables map[string]string) string { + if test.ExitCode != nil { + return fmt.Sprintf("Expect exit code %d", *test.ExitCode) + } + if test.StdoutLinesGt != nil { + return fmt.Sprintf("Expect > %d lines on stdout", *test.StdoutLinesGt) + } + if test.StdoutContainsAll != nil { + str := "Expect stdout to contain all of:" + for _, contains := range test.StdoutContainsAll { + interpolatedContains := InterpolateVariables(contains, variables) + str += fmt.Sprintf("\n - '%s'", interpolatedContains) + } + return str + } + if test.StdoutContainsNone != nil { + str := "Expect stdout to contain none of:" + for _, containsNone := range test.StdoutContainsNone { + interpolatedContainsNone := InterpolateVariables(containsNone, variables) + str += fmt.Sprintf("\n - '%s'", interpolatedContainsNone) + } + return str + } + return "" +} + +func prettyPrintHTTPTest(test api.HTTPRequestTest, variables map[string]string) string { + if test.StatusCode != nil { + return fmt.Sprintf("Expecting status code: %d", *test.StatusCode) + } + if test.BodyContains != nil { + interpolated := InterpolateVariables(*test.BodyContains, variables) + return fmt.Sprintf("Expecting body to contain: %s", interpolated) + } + if test.BodyContainsNone != nil { + interpolated := InterpolateVariables(*test.BodyContainsNone, variables) + return fmt.Sprintf("Expecting JSON body to not contain: %s", interpolated) + } + if test.HeadersContain != nil { + interpolatedKey := InterpolateVariables(test.HeadersContain.Key, variables) + interpolatedValue := InterpolateVariables(test.HeadersContain.Value, variables) + return fmt.Sprintf("Expecting headers to contain: '%s: %v'", interpolatedKey, interpolatedValue) + } + if test.TrailersContain != nil { + interpolatedKey := InterpolateVariables(test.TrailersContain.Key, variables) + interpolatedValue := InterpolateVariables(test.TrailersContain.Value, variables) + return fmt.Sprintf("Expecting trailers to contain: '%s: %v'", interpolatedKey, interpolatedValue) + } + if test.JSONValue != nil { + var val any + var op any + if test.JSONValue.IntValue != nil { + val = *test.JSONValue.IntValue + } else if test.JSONValue.StringValue != nil { + val = *test.JSONValue.StringValue + } else if test.JSONValue.BoolValue != nil { + val = *test.JSONValue.BoolValue + } + if test.JSONValue.Operator == api.OpEquals { + op = "to be equal to" + } else if test.JSONValue.Operator == api.OpGreaterThan { + op = "to be greater than" + } else if test.JSONValue.Operator == api.OpContains { + op = "contains" + } else if test.JSONValue.Operator == api.OpNotContains { + op = "to not contain" + } + expecting := fmt.Sprintf("Expecting JSON at %v %s %v", test.JSONValue.Path, op, val) + return InterpolateVariables(expecting, variables) + } + return "" +} + // truncateAndStringifyBody // in some lessons we yeet the entire body up to the server, but we really shouldn't ever care // about more than 100,000 stringified characters of it, so this protects against giant bodies diff --git a/cmd/submit.go b/cmd/submit.go index 126014e..db831f0 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -7,6 +7,8 @@ import ( "github.com/bootdotdev/bootdev/checks" api "github.com/bootdotdev/bootdev/client" + tea "github.com/charmbracelet/bubbletea" + "github.com/bootdotdev/bootdev/render" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -62,15 +64,20 @@ func submissionHandler(cmd *cobra.Command, args []string) error { fmt.Printf("You can reset to the default with `bootdev config base_url --reset`\n\n") } - results := checks.CLIChecks(data, overrideBaseURL) + ch := make(chan tea.Msg, 1) + // StartRenderer and returns immediately, finalise function blocks the execution until the renderer is closed. + finalise := render.StartRenderer(data, isSubmit, ch) + + results := checks.CLIChecks(data, overrideBaseURL, ch) + if isSubmit { failure, err := api.SubmitCLILesson(lessonUUID, results) if err != nil { return err } - render.RenderSubmission(data, results, failure) + finalise(failure) } else { - render.RenderRun(data, results) + finalise(nil) } return nil } diff --git a/messages/messages.go b/messages/messages.go new file mode 100644 index 0000000..62825e3 --- /dev/null +++ b/messages/messages.go @@ -0,0 +1,29 @@ +package messages + +import api "github.com/bootdotdev/bootdev/client" + +type StartStepMsg struct { + ResponseVariables []api.HTTPRequestResponseVariable + CMD string + URL string + Method string +} + +type StartTestMsg struct { + Text string +} + +type ResolveTestMsg struct { + Index int + Passed *bool +} + +type DoneStepMsg struct { + Failure *api.VerificationResultStructuredErrCLI +} + +type ResolveStepMsg struct { + Index int + Passed *bool + Result *api.CLIStepResult +} diff --git a/render/render.go b/render/render.go index 1fe5a58..92996a7 100644 --- a/render/render.go +++ b/render/render.go @@ -10,11 +10,11 @@ import ( "github.com/bootdotdev/bootdev/checks" api "github.com/bootdotdev/bootdev/client" + "github.com/bootdotdev/bootdev/messages" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/muesli/termenv" - "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -29,15 +29,6 @@ type testModel struct { finished bool } -type startTestMsg struct { - text string -} - -type resolveTestMsg struct { - index int - passed *bool -} - func renderTestHeader(header string, spinner spinner.Model, isFinished bool, isSubmit bool, passed *bool) string { cmdStr := renderTest(header, spinner.View(), isFinished, &isSubmit, passed) box := borderBox.Render(fmt.Sprintf(" %s ", cmdStr)) @@ -92,23 +83,6 @@ func renderTest(text string, spinner string, isFinished bool, isSubmit *bool, pa return testStr } -type doneStepMsg struct { - failure *api.VerificationResultStructuredErrCLI -} - -type startStepMsg struct { - responseVariables []api.HTTPRequestResponseVariable - cmd string - url string - method string -} - -type resolveStepMsg struct { - index int - passed *bool - result *api.CLIStepResult -} - type stepModel struct { responseVariables []api.HTTPRequestResponseVariable step string @@ -147,42 +121,42 @@ func (m rootModel) Init() tea.Cmd { func (m rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case doneStepMsg: - m.failure = msg.failure + case messages.DoneStepMsg: + m.failure = msg.Failure if m.failure == nil && m.isSubmit { m.success = true } m.clear = true return m, tea.Quit - case startStepMsg: - step := fmt.Sprintf("Running: %s", msg.cmd) - if msg.cmd == "" { - step = fmt.Sprintf("%s %s", msg.method, msg.url) + case messages.StartStepMsg: + step := fmt.Sprintf("Running: %s", msg.CMD) + if msg.CMD == "" { + step = fmt.Sprintf("%s %s", msg.Method, msg.URL) } m.steps = append(m.steps, stepModel{ step: step, tests: []testModel{}, - responseVariables: msg.responseVariables, + responseVariables: msg.ResponseVariables, }) return m, nil - case resolveStepMsg: - m.steps[msg.index].passed = msg.passed - m.steps[msg.index].finished = true - m.steps[msg.index].result = msg.result + case messages.ResolveStepMsg: + m.steps[msg.Index].passed = msg.Passed + m.steps[msg.Index].finished = true + m.steps[msg.Index].result = msg.Result return m, nil - case startTestMsg: + case messages.StartTestMsg: m.steps[len(m.steps)-1].tests = append( m.steps[len(m.steps)-1].tests, - testModel{text: msg.text}, + testModel{text: msg.Text}, ) return m, nil - case resolveTestMsg: - m.steps[len(m.steps)-1].tests[msg.index].passed = msg.passed - m.steps[len(m.steps)-1].tests[msg.index].finished = true + case messages.ResolveTestMsg: + m.steps[len(m.steps)-1].tests[msg.Index].passed = msg.Passed + m.steps[len(m.steps)-1].tests[msg.Index].finished = true return m, nil default: @@ -355,30 +329,10 @@ func printHTTPRequestResult(result api.HTTPRequestResult) string { return str } -func RenderRun( - data api.CLIData, - results []api.CLIStepResult, -) { - renderer(data, results, nil, false) -} - -func RenderSubmission( - data api.CLIData, - results []api.CLIStepResult, - failure *api.VerificationResultStructuredErrCLI, -) { - renderer(data, results, failure, true) -} - -func renderer( - data api.CLIData, - results []api.CLIStepResult, - failure *api.VerificationResultStructuredErrCLI, - isSubmit bool, -) { +func StartRenderer(data api.CLIData, isSubmit bool, ch chan tea.Msg) func(*api.VerificationResultStructuredErrCLI) { var wg sync.WaitGroup - ch := make(chan tea.Msg, 1) p := tea.NewProgram(initModel(isSubmit), tea.WithoutSignalHandler()) + wg.Add(1) go func() { defer wg.Done() @@ -391,30 +345,18 @@ func renderer( output.WriteString(r.View()) } }() + go func() { for { msg := <-ch p.Send(msg) } }() - wg.Add(1) - go func() { - defer wg.Done() - - for i, step := range data.Steps { - switch { - case step.CLICommand != nil && results[i].CLICommandResult != nil: - renderCLICommand(*step.CLICommand, *results[i].CLICommandResult, failure, isSubmit, ch, i) - case step.HTTPRequest != nil && results[i].HTTPRequestResult != nil: - renderHTTPRequest(*step.HTTPRequest, *results[i].HTTPRequestResult, failure, isSubmit, data.BaseURLDefault, ch, i) - default: - cobra.CheckErr("unable to run lesson: missing results") - } - } - ch <- doneStepMsg{failure: failure} - }() - wg.Wait() + return func(failure *api.VerificationResultStructuredErrCLI) { + ch <- messages.DoneStepMsg{Failure: failure} + wg.Wait() + } } func renderCLICommand( @@ -425,10 +367,8 @@ func renderCLICommand( ch chan tea.Msg, index int, ) { - ch <- startStepMsg{cmd: result.FinalCommand} - for _, test := range cmd.Tests { - ch <- startTestMsg{text: prettyPrintCLICommand(test, result.Variables)} + ch <- messages.StartTestMsg{Text: prettyPrintCLICommand(test, result.Variables)} } earlierCmdFailed := false @@ -445,39 +385,39 @@ func renderCLICommand( } } if !isSubmit { - ch <- resolveTestMsg{index: j} + ch <- messages.ResolveTestMsg{Index: j} } else if earlierTestFailed { - ch <- resolveTestMsg{index: j} + ch <- messages.ResolveTestMsg{Index: j} } else { passed := failure == nil || failure.FailedStepIndex != index || failure.FailedTestIndex != j - ch <- resolveTestMsg{ - index: j, - passed: pointerToBool(passed), + ch <- messages.ResolveTestMsg{ + Index: j, + Passed: pointerToBool(passed), } } } if !isSubmit { - ch <- resolveStepMsg{ - index: index, - result: &api.CLIStepResult{ + ch <- messages.ResolveStepMsg{ + Index: index, + Result: &api.CLIStepResult{ CLICommandResult: &result, }, } } else if earlierCmdFailed { - ch <- resolveStepMsg{index: index} + ch <- messages.ResolveStepMsg{Index: index} } else { passed := failure == nil || failure.FailedStepIndex != index if passed { - ch <- resolveStepMsg{ - index: index, - passed: pointerToBool(passed), + ch <- messages.ResolveStepMsg{ + Index: index, + Passed: pointerToBool(passed), } } else { - ch <- resolveStepMsg{ - index: index, - passed: pointerToBool(passed), - result: &api.CLIStepResult{ + ch <- messages.ResolveStepMsg{ + Index: index, + Passed: pointerToBool(passed), + Result: &api.CLIStepResult{ CLICommandResult: &result, }, } @@ -494,54 +434,41 @@ func renderHTTPRequest( ch chan tea.Msg, index int, ) { - - baseURL := viper.GetString("override_base_url") - if baseURL == "" { - baseURL = baseURLDefault - } - fullURL := strings.Replace(req.Request.FullURL, api.BaseURLPlaceholder, baseURL, 1) - - ch <- startStepMsg{ - url: checks.InterpolateVariables(fullURL, result.Variables), - method: req.Request.Method, - responseVariables: req.ResponseVariables, - } - for _, test := range req.Tests { - ch <- startTestMsg{text: prettyPrintHTTPTest(test, result.Variables)} + ch <- messages.StartTestMsg{Text: prettyPrintHTTPTest(test, result.Variables)} } for j := range req.Tests { if !isSubmit { - ch <- resolveTestMsg{index: j} + ch <- messages.ResolveTestMsg{Index: j} } else if failure != nil && (failure.FailedStepIndex < index || (failure.FailedStepIndex == index && failure.FailedTestIndex < j)) { - ch <- resolveTestMsg{index: j} + ch <- messages.ResolveTestMsg{Index: j} } else { - ch <- resolveTestMsg{index: j, passed: pointerToBool(failure == nil || !(failure.FailedStepIndex == index && failure.FailedTestIndex == j))} + ch <- messages.ResolveTestMsg{Index: j, Passed: pointerToBool(failure == nil || !(failure.FailedStepIndex == index && failure.FailedTestIndex == j))} } } if !isSubmit { - ch <- resolveStepMsg{ - index: index, - result: &api.CLIStepResult{ + ch <- messages.ResolveStepMsg{ + Index: index, + Result: &api.CLIStepResult{ HTTPRequestResult: &result, }, } } else if failure != nil && failure.FailedStepIndex < index { - ch <- resolveStepMsg{index: index} + ch <- messages.ResolveStepMsg{Index: index} } else { passed := failure == nil || failure.FailedStepIndex != index if passed { - ch <- resolveStepMsg{ - index: index, - passed: pointerToBool(passed), + ch <- messages.ResolveStepMsg{ + Index: index, + Passed: pointerToBool(passed), } } else { - ch <- resolveStepMsg{ - index: index, - passed: pointerToBool(passed), - result: &api.CLIStepResult{ + ch <- messages.ResolveStepMsg{ + Index: index, + Passed: pointerToBool(passed), + Result: &api.CLIStepResult{ HTTPRequestResult: &result, }, }