diff --git a/checks/checks.go b/checks/checks.go index 49986e1..c3e165f 100644 --- a/checks/checks.go +++ b/checks/checks.go @@ -3,15 +3,18 @@ package checks import ( "bytes" "encoding/json" + "errors" "fmt" "io" "net/http" "os" "os/exec" + "regexp" "strings" "time" api "github.com/bootdotdev/bootdev/client" + "github.com/itchyny/gojq" "github.com/spf13/cobra" ) @@ -143,3 +146,82 @@ func CLIChecks(cliData api.CLIData, submitBaseURL *string) (results []api.CLISte } return results } + +// 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 +func truncateAndStringifyBody(body []byte) string { + bodyString := string(body) + const maxBodyLength = 1000000 + if len(bodyString) > maxBodyLength { + bodyString = bodyString[:maxBodyLength] + } + return bodyString +} + +func parseVariables(body []byte, vardefs []api.HTTPRequestResponseVariable, variables map[string]string) error { + for _, vardef := range vardefs { + val, err := valFromJQPath(vardef.Path, string(body)) + if err != nil { + return err + } + variables[vardef.Name] = fmt.Sprintf("%v", val) + } + return nil +} + +func valFromJQPath(path string, jsn string) (any, error) { + vals, err := valsFromJQPath(path, jsn) + if err != nil { + return nil, err + } + if len(vals) != 1 { + return nil, errors.New("invalid number of values found") + } + val := vals[0] + if val == nil { + return nil, errors.New("value not found") + } + return val, nil +} + +func valsFromJQPath(path string, jsn string) ([]any, error) { + var parseable any + err := json.Unmarshal([]byte(jsn), &parseable) + if err != nil { + return nil, err + } + + query, err := gojq.Parse(path) + if err != nil { + return nil, err + } + iter := query.Run(parseable) + vals := []any{} + for { + v, ok := iter.Next() + if !ok { + break + } + if err, ok := v.(error); ok { + if err, ok := err.(*gojq.HaltError); ok && err.Value() == nil { + break + } + return nil, err + } + vals = append(vals, v) + } + return vals, nil +} + +func InterpolateVariables(template string, vars map[string]string) string { + r := regexp.MustCompile(`\$\{([^}]+)\}`) + return r.ReplaceAllStringFunc(template, func(m string) string { + // Extract the key from the match, which is in the form ${key} + key := strings.TrimSuffix(strings.TrimPrefix(m, "${"), "}") + if val, ok := vars[key]; ok { + return val + } + return m // return the original placeholder if no substitution found + }) +} diff --git a/checks/command.go b/checks/command.go deleted file mode 100644 index 9a24806..0000000 --- a/checks/command.go +++ /dev/null @@ -1,28 +0,0 @@ -package checks - -import ( - "os/exec" - "strings" - - api "github.com/bootdotdev/bootdev/client" -) - -func CLICommand( - lesson api.Lesson, -) []api.CLICommandResult { - data := lesson.Lesson.LessonDataCLICommand.CLICommandData - responses := make([]api.CLICommandResult, len(data.Commands)) - for i, command := range data.Commands { - responses[i].FinalCommand = command.Command - - cmd := exec.Command("sh", "-c", "LANG=en_US.UTF-8 "+command.Command) - b, err := cmd.Output() - if ee, ok := err.(*exec.ExitError); ok { - responses[i].ExitCode = ee.ExitCode() - } else if err != nil { - responses[i].ExitCode = -2 - } - responses[i].Stdout = strings.TrimRight(string(b), " \n\t\r") - } - return responses -} diff --git a/checks/http.go b/checks/http.go deleted file mode 100644 index c9f1912..0000000 --- a/checks/http.go +++ /dev/null @@ -1,191 +0,0 @@ -package checks - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "regexp" - "strings" - "time" - - api "github.com/bootdotdev/bootdev/client" - "github.com/itchyny/gojq" - "github.com/spf13/cobra" -) - -type HttpTestResult struct { - Err string `json:"-"` - StatusCode int - ResponseHeaders map[string]string - BodyString string - Variables map[string]string - Request api.LessonDataHTTPTestsRequest -} - -func HttpTest( - lesson api.Lesson, - baseURL *string, -) ( - responses []HttpTestResult, - finalBaseURL string, -) { - data := lesson.Lesson.LessonDataHTTPTests - client := &http.Client{} - variables := make(map[string]string) - responses = make([]HttpTestResult, len(data.HttpTests.Requests)) - for i, request := range data.HttpTests.Requests { - if baseURL != nil && *baseURL != "" { - finalBaseURL = *baseURL - } else if data.HttpTests.BaseURL != nil { - finalBaseURL = *data.HttpTests.BaseURL - } else { - cobra.CheckErr("no base URL provided") - } - finalBaseURL = strings.TrimSuffix(finalBaseURL, "/") - interpolatedPath := InterpolateVariables(request.Request.Path, variables) - completeURL := fmt.Sprintf("%s%s", finalBaseURL, interpolatedPath) - if request.Request.FullURL != "" { - completeURL = InterpolateVariables(request.Request.FullURL, variables) - } - - var r *http.Request - if request.Request.BodyJSON != nil { - dat, err := json.Marshal(request.Request.BodyJSON) - cobra.CheckErr(err) - interpolatedBodyJSONStr := InterpolateVariables(string(dat), variables) - r, err = http.NewRequest(request.Request.Method, completeURL, - bytes.NewBuffer([]byte(interpolatedBodyJSONStr)), - ) - if err != nil { - cobra.CheckErr("Failed to create request") - } - r.Header.Add("Content-Type", "application/json") - } else { - var err error - r, err = http.NewRequest(request.Request.Method, completeURL, nil) - if err != nil { - cobra.CheckErr("Failed to create request") - } - } - - for k, v := range request.Request.Headers { - r.Header.Add(k, InterpolateVariables(v, variables)) - } - - if request.Request.BasicAuth != nil { - r.SetBasicAuth(request.Request.BasicAuth.Username, request.Request.BasicAuth.Password) - } - - if request.Request.Actions.DelayRequestByMs != nil { - time.Sleep(time.Duration(*request.Request.Actions.DelayRequestByMs) * time.Millisecond) - } - - resp, err := client.Do(r) - if err != nil { - responses[i] = HttpTestResult{Err: "Failed to fetch"} - continue - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - responses[i] = HttpTestResult{Err: "Failed to read response body"} - continue - } - headers := make(map[string]string) - for k, v := range resp.Header { - headers[k] = strings.Join(v, ",") - } - parseVariables(body, request.ResponseVariables, variables) - responses[i] = HttpTestResult{ - StatusCode: resp.StatusCode, - ResponseHeaders: headers, - BodyString: truncateAndStringifyBody(body), - Variables: variables, - Request: request, - } - } - return responses, finalBaseURL -} - -// 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 -func truncateAndStringifyBody(body []byte) string { - bodyString := string(body) - const maxBodyLength = 1000000 - if len(bodyString) > maxBodyLength { - bodyString = bodyString[:maxBodyLength] - } - return bodyString -} - -func parseVariables(body []byte, vardefs []api.ResponseVariable, variables map[string]string) error { - for _, vardef := range vardefs { - val, err := valFromJQPath(vardef.Path, string(body)) - if err != nil { - return err - } - variables[vardef.Name] = fmt.Sprintf("%v", val) - } - return nil -} - -func valFromJQPath(path string, jsn string) (any, error) { - vals, err := valsFromJQPath(path, jsn) - if err != nil { - return nil, err - } - if len(vals) != 1 { - return nil, errors.New("invalid number of values found") - } - val := vals[0] - if val == nil { - return nil, errors.New("value not found") - } - return val, nil -} - -func valsFromJQPath(path string, jsn string) ([]any, error) { - var parseable any - err := json.Unmarshal([]byte(jsn), &parseable) - if err != nil { - return nil, err - } - - query, err := gojq.Parse(path) - if err != nil { - return nil, err - } - iter := query.Run(parseable) - vals := []any{} - for { - v, ok := iter.Next() - if !ok { - break - } - if err, ok := v.(error); ok { - if err, ok := err.(*gojq.HaltError); ok && err.Value() == nil { - break - } - return nil, err - } - vals = append(vals, v) - } - return vals, nil -} - -func InterpolateVariables(template string, vars map[string]string) string { - r := regexp.MustCompile(`\$\{([^}]+)\}`) - return r.ReplaceAllStringFunc(template, func(m string) string { - // Extract the key from the match, which is in the form ${key} - key := strings.TrimSuffix(strings.TrimPrefix(m, "${"), "}") - if val, ok := vars[key]; ok { - return val - } - return m // return the original placeholder if no substitution found - }) -} diff --git a/client/lessons.go b/client/lessons.go index 1518879..d1365b2 100644 --- a/client/lessons.go +++ b/client/lessons.go @@ -5,82 +5,10 @@ import ( "fmt" ) -type ResponseVariable struct { - Name string - Path string -} - -// Only one of these fields should be set -type HTTPTest struct { - StatusCode *int - BodyContains *string - BodyContainsNone *string - HeadersContain *HTTPTestHeader - JSONValue *HTTPTestJSONValue -} - -type OperatorType string - -const ( - OpEquals OperatorType = "eq" - OpGreaterThan OperatorType = "gt" - OpContains OperatorType = "contains" - OpNotContains OperatorType = "not_contains" -) - -type HTTPTestJSONValue struct { - Path string - Operator OperatorType - IntValue *int - StringValue *string - BoolValue *bool -} - -type HTTPTestHeader struct { - Key string - Value string -} - -type LessonDataHTTPTests struct { - HttpTests struct { - BaseURL *string - ContainsCompleteDir bool - Requests []LessonDataHTTPTestsRequest - } -} - -type LessonDataHTTPTestsRequest struct { - ResponseVariables []ResponseVariable - Tests []HTTPTest - Request struct { - FullURL string // overrides BaseURL and Path if set - Path string - BasicAuth *struct { - Username string - Password string - } - Headers map[string]string - BodyJSON map[string]interface{} - Method string - Actions struct { - DelayRequestByMs *int32 - } - } -} - -type CLICommandTestCase struct { - ExitCode *int - StdoutContainsAll []string - StdoutContainsNone []string - StdoutLinesGt *int -} - -type LessonDataCLICommand struct { - CLICommandData struct { - Commands []struct { - Command string - Tests []CLICommandTestCase - } +type Lesson struct { + Lesson struct { + Type string + LessonDataCLI *LessonDataCLI } } @@ -100,12 +28,19 @@ type CLIData struct { type CLIStepCLICommand struct { Command string - Tests []CLICommandTestCase + Tests []CLICommandTest +} + +type CLICommandTest struct { + ExitCode *int + StdoutContainsAll []string + StdoutContainsNone []string + StdoutLinesGt *int } type CLIStepHTTPRequest struct { - ResponseVariables []ResponseVariable - Tests []HTTPTest + ResponseVariables []HTTPRequestResponseVariable + Tests []HTTPRequestTest Request struct { Method string Path string @@ -122,67 +57,59 @@ type CLIStepHTTPRequest struct { } } -type Lesson struct { - Lesson struct { - Type string - LessonDataHTTPTests *LessonDataHTTPTests - LessonDataCLICommand *LessonDataCLICommand - LessonDataCLI *LessonDataCLI - } +type HTTPRequestResponseVariable struct { + Name string + Path string } -func FetchLesson(uuid string) (*Lesson, error) { - resp, err := fetchWithAuth("GET", "/v1/static/lessons/"+uuid) - if err != nil { - return nil, err - } - - var data Lesson - err = json.Unmarshal(resp, &data) - if err != nil { - return nil, err - } - return &data, nil +// Only one of these fields should be set +type HTTPRequestTest struct { + StatusCode *int + BodyContains *string + BodyContainsNone *string + HeadersContain *HTTPRequestTestHeader + JSONValue *HTTPRequestTestJSONValue } -type HTTPTestValidationError struct { - ErrorMessage *string `json:"Error"` - FailedRequestIndex *int `json:"FailedRequestIndex"` - FailedTestIndex *int `json:"FailedTestIndex"` +type HTTPRequestTestHeader struct { + Key string + Value string } -type submitHTTPTestRequest struct { - ActualHTTPRequests any `json:"actualHTTPRequests"` +type HTTPRequestTestJSONValue struct { + Path string + Operator OperatorType + IntValue *int + StringValue *string + BoolValue *bool } -func SubmitHTTPTestLesson(uuid string, results any) (*HTTPTestValidationError, error) { - bytes, err := json.Marshal(submitHTTPTestRequest{ActualHTTPRequests: results}) +type OperatorType string + +const ( + OpEquals OperatorType = "eq" + OpGreaterThan OperatorType = "gt" + OpContains OperatorType = "contains" + OpNotContains OperatorType = "not_contains" +) + +func FetchLesson(uuid string) (*Lesson, error) { + resp, err := fetchWithAuth("GET", "/v1/static/lessons/"+uuid) if err != nil { return nil, err } - resp, code, err := fetchWithAuthAndPayload("POST", "/v1/lessons/"+uuid+"/http_tests", bytes) + + var data Lesson + err = json.Unmarshal(resp, &data) if err != nil { return nil, err } - if code != 200 { - return nil, fmt.Errorf("failed to submit HTTP tests. code: %v: %s", code, string(resp)) - } - var failure HTTPTestValidationError - err = json.Unmarshal(resp, &failure) - if err != nil || failure.ErrorMessage == nil { - return nil, nil - } - return &failure, nil -} - -type submitCLICommandRequest struct { - CLICommandResults []CLICommandResult `json:"cliCommandResults"` + return &data, nil } -type StructuredErrCLICommand struct { - ErrorMessage string `json:"Error"` - FailedCommandIndex int `json:"FailedCommandIndex"` - FailedTestIndex int `json:"FailedTestIndex"` +type CLIStepResult struct { + CLICommandResult *CLICommandResult + HTTPRequestResult *HTTPRequestResult } type CLICommandResult struct { @@ -192,27 +119,6 @@ type CLICommandResult struct { Variables map[string]string } -func SubmitCLICommandLesson(uuid string, results []CLICommandResult) (*StructuredErrCLICommand, error) { - bytes, err := json.Marshal(submitCLICommandRequest{CLICommandResults: results}) - if err != nil { - return nil, err - } - resp, code, err := fetchWithAuthAndPayload("POST", "/v1/lessons/"+uuid+"/cli_command", bytes) - if err != nil { - return nil, err - } - if code != 200 { - return nil, fmt.Errorf("failed to submit CLI command tests. code: %v: %s", code, string(resp)) - } - var failure StructuredErrCLICommand - err = json.Unmarshal(resp, &failure) - if err != nil || failure.ErrorMessage == "" { - // this is ok - it means we had success - return nil, nil - } - return &failure, nil -} - type HTTPRequestResult struct { Err string `json:"-"` StatusCode int @@ -222,11 +128,6 @@ type HTTPRequestResult struct { Request CLIStepHTTPRequest } -type CLIStepResult struct { - CLICommandResult *CLICommandResult - HTTPRequestResult *HTTPRequestResult -} - type lessonSubmissionCLI struct { CLIResults []CLIStepResult } diff --git a/cmd/submit.go b/cmd/submit.go index 44ec4b6..7f6b934 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -35,45 +35,23 @@ func submissionHandler(cmd *cobra.Command, args []string) error { if err != nil { return err } - switch { - case lesson.Lesson.Type == "type_http_tests" && lesson.Lesson.LessonDataHTTPTests != nil: - results, _ := checks.HttpTest(*lesson, &submitBaseURL) - data := *lesson.Lesson.LessonDataHTTPTests - if isSubmit { - failure, err := api.SubmitHTTPTestLesson(lessonUUID, results) - if err != nil { - return err - } - render.HTTPSubmission(data, results, failure) - } else { - render.HTTPRun(data, results) - } - case lesson.Lesson.Type == "type_cli_command" && lesson.Lesson.LessonDataCLICommand != nil: - results := checks.CLICommand(*lesson) - data := *lesson.Lesson.LessonDataCLICommand - if isSubmit { - failure, err := api.SubmitCLICommandLesson(lessonUUID, results) - if err != nil { - return err - } - render.CommandSubmission(data, results, failure) - } else { - render.CommandRun(data, results) - } - case lesson.Lesson.Type == "type_cli" && lesson.Lesson.LessonDataCLI != nil: - data := lesson.Lesson.LessonDataCLI.CLIData - results := checks.CLIChecks(data, &submitBaseURL) - if isSubmit { - failure, err := api.SubmitCLILesson(lessonUUID, results) - if err != nil { - return err - } - render.RenderSubmission(data, results, failure) - } else { - render.RenderRun(data, results) + if lesson.Lesson.Type != "type_cli" { + return errors.New("unable to run lesson: unsupported lesson type") + } + if lesson.Lesson.LessonDataCLI == nil { + return errors.New("unable to run lesson: missing lesson data") + } + + data := lesson.Lesson.LessonDataCLI.CLIData + results := checks.CLIChecks(data, &submitBaseURL) + if isSubmit { + failure, err := api.SubmitCLILesson(lessonUUID, results) + if err != nil { + return err } - default: - return errors.New("unsupported lesson type") + render.RenderSubmission(data, results, failure) + } else { + render.RenderRun(data, results) } return nil } diff --git a/render/command.go b/render/command.go deleted file mode 100644 index 6300427..0000000 --- a/render/command.go +++ /dev/null @@ -1,258 +0,0 @@ -package render - -import ( - "fmt" - "os" - "strings" - "sync" - "time" - - api "github.com/bootdotdev/bootdev/client" - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/muesli/termenv" - "github.com/spf13/viper" -) - -type doneCmdMsg struct { - failure *api.StructuredErrCLICommand -} - -type startCmdMsg struct { - cmd string -} - -type resolveCmdMsg struct { - index int - passed *bool - results *api.CLICommandResult -} - -type cmdModel struct { - command string - passed *bool - results *api.CLICommandResult - finished bool - tests []testModel -} - -type cmdRootModel struct { - cmds []cmdModel - spinner spinner.Model - failure *api.StructuredErrCLICommand - isSubmit bool - success bool - finalized bool - clear bool -} - -func initialModelCmd(isSubmit bool) cmdRootModel { - s := spinner.New() - s.Spinner = spinner.Dot - return cmdRootModel{ - spinner: s, - isSubmit: isSubmit, - cmds: []cmdModel{}, - } -} - -func (m cmdRootModel) Init() tea.Cmd { - green = lipgloss.NewStyle().Foreground(lipgloss.Color(viper.GetString("color.green"))) - red = lipgloss.NewStyle().Foreground(lipgloss.Color(viper.GetString("color.red"))) - gray = lipgloss.NewStyle().Foreground(lipgloss.Color(viper.GetString("color.gray"))) - return m.spinner.Tick -} - -func (m cmdRootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case doneCmdMsg: - m.failure = msg.failure - if m.failure == nil && m.isSubmit { - m.success = true - } - m.clear = true - return m, tea.Quit - - case startCmdMsg: - m.cmds = append(m.cmds, cmdModel{command: fmt.Sprintf("Running: %s", msg.cmd), tests: []testModel{}}) - return m, nil - - case resolveCmdMsg: - m.cmds[msg.index].passed = msg.passed - m.cmds[msg.index].finished = true - m.cmds[msg.index].results = msg.results - return m, nil - - case startTestMsg: - m.cmds[len(m.cmds)-1].tests = append( - m.cmds[len(m.cmds)-1].tests, - testModel{text: msg.text}, - ) - return m, nil - - case resolveTestMsg: - m.cmds[len(m.cmds)-1].tests[msg.index].passed = msg.passed - m.cmds[len(m.cmds)-1].tests[msg.index].finished = true - return m, nil - - default: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - } -} - -func (m cmdRootModel) View() string { - if m.clear { - return "" - } - s := m.spinner.View() - var str string - for _, cmd := range m.cmds { - str += renderTestHeader(cmd.command, m.spinner, cmd.finished, m.isSubmit, cmd.passed) - str += renderTests(cmd.tests, s) - if cmd.results != nil && m.finalized { - // render the results - for _, test := range cmd.tests { - // for clarity, only show exit code if it's tested - if strings.Contains(test.text, "exit code") { - str += fmt.Sprintf("\n > Command exit code: %d\n", cmd.results.ExitCode) - break - } - } - str += " > Command stdout:\n\n" - sliced := strings.Split(cmd.results.Stdout, "\n") - for _, s := range sliced { - str += gray.Render(s) + "\n" - } - } - } - if m.failure != nil { - str += red.Render("\n\nError: "+m.failure.ErrorMessage) + "\n\n" - } else if m.success { - str += "\n\n" + green.Render("All tests passed! 🎉") + "\n\n" - str += green.Render("Return to your browser to continue with the next lesson.") + "\n\n" - } - return str -} - -func CommandRun( - data api.LessonDataCLICommand, - results []api.CLICommandResult, -) { - commandRenderer(data, results, nil, false) -} - -func CommandSubmission( - data api.LessonDataCLICommand, - results []api.CLICommandResult, - failure *api.StructuredErrCLICommand, -) { - commandRenderer(data, results, failure, true) -} - -func commandRenderer( - data api.LessonDataCLICommand, - results []api.CLICommandResult, - failure *api.StructuredErrCLICommand, - isSubmit bool, -) { - var wg sync.WaitGroup - ch := make(chan tea.Msg, 1) - p := tea.NewProgram(initialModelCmd(isSubmit), tea.WithoutSignalHandler()) - wg.Add(1) - go func() { - defer wg.Done() - if model, err := p.Run(); err != nil { - fmt.Fprintln(os.Stderr, err) - } else if r, ok := model.(cmdRootModel); ok { - r.clear = false - r.finalized = true - output := termenv.NewOutput(os.Stdout) - output.WriteString(r.View()) - } - }() - go func() { - for { - msg := <-ch - p.Send(msg) - } - }() - wg.Add(1) - go func() { - defer wg.Done() - - for i, cmd := range data.CLICommandData.Commands { - ch <- startCmdMsg{cmd: results[i].FinalCommand} - for _, test := range cmd.Tests { - ch <- startTestMsg{text: prettyPrintCmd(test)} - } - time.Sleep(500 * time.Millisecond) - earlierCmdFailed := false - if failure != nil { - earlierCmdFailed = failure.FailedCommandIndex < i - } - for j := range cmd.Tests { - earlierTestFailed := false - if failure != nil { - if earlierCmdFailed { - earlierTestFailed = true - } else if failure.FailedCommandIndex == i { - earlierTestFailed = failure.FailedTestIndex < j - } - } - if !isSubmit { - ch <- resolveTestMsg{index: j} - } else if earlierTestFailed { - ch <- resolveTestMsg{index: j} - } else { - time.Sleep(350 * time.Millisecond) - passed := failure == nil || failure.FailedCommandIndex != i || failure.FailedTestIndex != j - ch <- resolveTestMsg{index: j, passed: pointerToBool(passed)} - } - } - if !isSubmit { - ch <- resolveCmdMsg{index: i, results: &results[i]} - - } else if earlierCmdFailed { - ch <- resolveCmdMsg{index: i} - } else { - passed := failure == nil || failure.FailedCommandIndex != i - if passed { - ch <- resolveCmdMsg{index: i, passed: pointerToBool(passed)} - } else { - ch <- resolveCmdMsg{index: i, passed: pointerToBool(passed), results: &results[i]} - } - } - } - time.Sleep(500 * time.Millisecond) - - ch <- doneCmdMsg{failure: failure} - }() - wg.Wait() -} - -func prettyPrintCmd(test api.CLICommandTestCase) 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 _, thing := range test.StdoutContainsAll { - str += fmt.Sprintf("\n - '%s'", thing) - } - return str - } - if test.StdoutContainsNone != nil { - str := "Expect stdout to contain none of:" - for _, thing := range test.StdoutContainsNone { - str += fmt.Sprintf("\n - '%s'", thing) - } - return str - } - return "" -} diff --git a/render/http.go b/render/http.go deleted file mode 100644 index c1dde5c..0000000 --- a/render/http.go +++ /dev/null @@ -1,340 +0,0 @@ -package render - -import ( - "encoding/json" - "fmt" - "net/http" - "os" - "strings" - "sync" - "time" - - "github.com/bootdotdev/bootdev/checks" - api "github.com/bootdotdev/bootdev/client" - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/muesli/termenv" - "github.com/spf13/viper" -) - -type doneHttpMsg struct { - failure *api.HTTPTestValidationError -} - -type startHttpMsg struct { - url string - method string - responseVariables []api.ResponseVariable -} - -type resolveHttpMsg struct { - index int - passed *bool - results *checks.HttpTestResult -} -type httpReqModel struct { - responseVariables []api.ResponseVariable - request string - passed *bool - results *checks.HttpTestResult - finished bool - tests []testModel -} - -type httpRootModel struct { - reqs []httpReqModel - spinner spinner.Model - failure *api.HTTPTestValidationError - isSubmit bool - success bool - finalized bool - clear bool -} - -func initialModelHTTP(isSubmit bool) httpRootModel { - s := spinner.New() - s.Spinner = spinner.Dot - return httpRootModel{ - spinner: s, - isSubmit: isSubmit, - reqs: []httpReqModel{}, - } -} - -func (m httpRootModel) Init() tea.Cmd { - green = lipgloss.NewStyle().Foreground(lipgloss.Color(viper.GetString("color.green"))) - red = lipgloss.NewStyle().Foreground(lipgloss.Color(viper.GetString("color.red"))) - gray = lipgloss.NewStyle().Foreground(lipgloss.Color(viper.GetString("color.gray"))) - return m.spinner.Tick -} - -func (m httpRootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case doneHttpMsg: - m.failure = msg.failure - if m.failure == nil && m.isSubmit { - m.success = true - } - m.clear = true - return m, tea.Quit - - case startHttpMsg: - m.reqs = append(m.reqs, httpReqModel{ - request: fmt.Sprintf("%s %s", msg.method, msg.url), - tests: []testModel{}, - responseVariables: msg.responseVariables, - }) - return m, nil - - case resolveHttpMsg: - m.reqs[msg.index].passed = msg.passed - m.reqs[msg.index].finished = true - m.reqs[msg.index].results = msg.results - return m, nil - - case startTestMsg: - m.reqs[len(m.reqs)-1].tests = append( - m.reqs[len(m.reqs)-1].tests, - testModel{text: msg.text}, - ) - return m, nil - - case resolveTestMsg: - m.reqs[len(m.reqs)-1].tests[msg.index].passed = msg.passed - m.reqs[len(m.reqs)-1].tests[msg.index].finished = true - return m, nil - - default: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - } -} - -func (m httpRootModel) View() string { - if m.clear { - return "" - } - s := m.spinner.View() - var str string - for _, req := range m.reqs { - str += renderTestHeader(req.request, m.spinner, req.finished, m.isSubmit, req.passed) - str += renderTests(req.tests, s) - str += renderTestResponseVars(req.responseVariables) - if req.results != nil && m.finalized { - str += printHTTPResult(req) - } - } - if m.failure != nil { - str += red.Render("\n\nError: "+*m.failure.ErrorMessage) + "\n\n" - } else if m.success { - str += "\n\n" + green.Render("All tests passed! 🎉") + "\n\n" - str += green.Render("Return to your browser to continue with the next lesson.") + "\n\n" - } - return str -} - -func printHTTPResult(req httpReqModel) string { - if req.results == nil { - return "" - } - if req.results.Err != "" { - return fmt.Sprintf(" Err: %v\n\n", req.results.Err) - } - - str := "" - - str += fmt.Sprintf(" Response Status Code: %v\n", req.results.StatusCode) - - filteredHeaders := make(map[string]string) - for respK, respV := range req.results.ResponseHeaders { - // only show headers that are tested - found := false - for _, test := range req.tests { - if strings.Contains(test.text, respK) { - found = true - break - } - } - if found { - filteredHeaders[respK] = respV - } - } - - if len(filteredHeaders) > 0 { - str += " Response Headers: \n" - for k, v := range filteredHeaders { - str += fmt.Sprintf(" - %v: %v\n", k, v) - } - } - - str += " Response Body: \n" - bytes := []byte(req.results.BodyString) - contentType := http.DetectContentType(bytes) - if contentType == "application/json" || strings.HasPrefix(contentType, "text/") { - var unmarshalled interface{} - err := json.Unmarshal([]byte(req.results.BodyString), &unmarshalled) - if err == nil { - pretty, err := json.MarshalIndent(unmarshalled, "", " ") - if err == nil { - str += string(pretty) - } else { - str += req.results.BodyString - } - } else { - str += req.results.BodyString - } - } else { - str += fmt.Sprintf("Binary %s file", contentType) - } - str += "\n" - - if len(req.results.Variables) > 0 { - str += " Variables available: \n" - for k, v := range req.results.Variables { - if v != "" { - str += fmt.Sprintf(" - %v: %v\n", k, v) - } else { - str += fmt.Sprintf(" - %v: [not found]\n", k) - } - } - } - str += "\n" - - return str -} - -func HTTPRun( - data api.LessonDataHTTPTests, - results []checks.HttpTestResult, -) { - httpRenderer(data, results, nil, false) -} - -func HTTPSubmission( - data api.LessonDataHTTPTests, - results []checks.HttpTestResult, - failure *api.HTTPTestValidationError, -) { - httpRenderer(data, results, failure, true) -} - -func httpRenderer( - data api.LessonDataHTTPTests, - results []checks.HttpTestResult, - failure *api.HTTPTestValidationError, - isSubmit bool, -) { - var wg sync.WaitGroup - ch := make(chan tea.Msg, 1) - p := tea.NewProgram(initialModelHTTP(isSubmit), tea.WithoutSignalHandler()) - wg.Add(1) - go func() { - defer wg.Done() - if model, err := p.Run(); err != nil { - fmt.Fprintln(os.Stderr, err) - } else if r, ok := model.(httpRootModel); ok { - r.clear = false - r.finalized = true - output := termenv.NewOutput(os.Stdout) - output.WriteString(r.View()) - } - }() - go func() { - for { - msg := <-ch - p.Send(msg) - } - }() - wg.Add(1) - go func() { - defer wg.Done() - - for i, req := range data.HttpTests.Requests { - url := req.Request.Path - if req.Request.FullURL != "" { - url = req.Request.FullURL - } - ch <- startHttpMsg{ - url: checks.InterpolateVariables(url, results[i].Variables), - method: req.Request.Method, - responseVariables: req.ResponseVariables, - } - for _, test := range req.Tests { - ch <- startTestMsg{ - text: prettyPrintHTTPTest(test, results[i].Variables), - } - } - time.Sleep(500 * time.Millisecond) - for j := range req.Tests { - if !isSubmit { - ch <- resolveTestMsg{index: j} - } else if failure != nil && (*failure.FailedRequestIndex < i || (*failure.FailedRequestIndex == i && *failure.FailedTestIndex < j)) { - ch <- resolveTestMsg{index: j} - } else { - time.Sleep(350 * time.Millisecond) - ch <- resolveTestMsg{index: j, passed: pointerToBool(failure == nil || !(*failure.FailedRequestIndex == i && *failure.FailedTestIndex == j))} - } - } - if !isSubmit { - ch <- resolveHttpMsg{index: i, results: &results[i]} - } else if failure != nil && *failure.FailedRequestIndex < i { - ch <- resolveHttpMsg{index: i} - } else { - passed := failure == nil || *failure.FailedRequestIndex != i - if passed { - ch <- resolveHttpMsg{index: i, passed: pointerToBool(passed)} - } else { - ch <- resolveHttpMsg{index: i, passed: pointerToBool(passed), results: &results[i]} - } - } - } - time.Sleep(500 * time.Millisecond) - - ch <- doneHttpMsg{failure: failure} - }() - wg.Wait() -} - -func prettyPrintHTTPTest(test api.HTTPTest, variables map[string]string) string { - if test.StatusCode != nil { - return fmt.Sprintf("Expecting status code: %d", *test.StatusCode) - } - if test.BodyContains != nil { - interpolated := checks.InterpolateVariables(*test.BodyContains, variables) - return fmt.Sprintf("Expecting body to contain: %s", interpolated) - } - if test.BodyContainsNone != nil { - interpolated := checks.InterpolateVariables(*test.BodyContainsNone, variables) - return fmt.Sprintf("Expecting JSON body to not contain: %s", interpolated) - } - if test.HeadersContain != nil { - interpolatedKey := checks.InterpolateVariables(test.HeadersContain.Key, variables) - interpolatedValue := checks.InterpolateVariables(test.HeadersContain.Value, variables) - return fmt.Sprintf("Expecting headers 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 checks.InterpolateVariables(expecting, variables) - } - return "" -} diff --git a/render/render.go b/render/render.go index 23010ef..c9ca413 100644 --- a/render/render.go +++ b/render/render.go @@ -47,7 +47,7 @@ func renderTestHeader(header string, spinner spinner.Model, isFinished bool, isS return strings.Join(sliced, "\n") + "\n" } -func renderTestResponseVars(respVars []api.ResponseVariable) string { +func renderTestResponseVars(respVars []api.HTTPRequestResponseVariable) string { var str string for _, respVar := range respVars { varStr := gray.Render(fmt.Sprintf(" * Saving `%s` from `%s`", respVar.Name, respVar.Path)) @@ -98,7 +98,7 @@ type doneStepMsg struct { } type startStepMsg struct { - responseVariables []api.ResponseVariable + responseVariables []api.HTTPRequestResponseVariable cmd string url string method string @@ -111,7 +111,7 @@ type resolveStepMsg struct { } type stepModel struct { - responseVariables []api.ResponseVariable + responseVariables []api.HTTPRequestResponseVariable step string passed *bool result *api.CLIStepResult @@ -237,7 +237,7 @@ func (m rootModel) View() string { return str } -func prettyPrintCLICommand(test api.CLICommandTestCase, variables map[string]string) string { +func prettyPrintCLICommand(test api.CLICommandTest, variables map[string]string) string { if test.ExitCode != nil { return fmt.Sprintf("Expect exit code %d", *test.ExitCode) } @@ -524,3 +524,45 @@ func renderHTTPRequest( } } } + +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 := checks.InterpolateVariables(*test.BodyContains, variables) + return fmt.Sprintf("Expecting body to contain: %s", interpolated) + } + if test.BodyContainsNone != nil { + interpolated := checks.InterpolateVariables(*test.BodyContainsNone, variables) + return fmt.Sprintf("Expecting JSON body to not contain: %s", interpolated) + } + if test.HeadersContain != nil { + interpolatedKey := checks.InterpolateVariables(test.HeadersContain.Key, variables) + interpolatedValue := checks.InterpolateVariables(test.HeadersContain.Value, variables) + return fmt.Sprintf("Expecting headers 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 checks.InterpolateVariables(expecting, variables) + } + return "" +} diff --git a/version.txt b/version.txt index 440ddd8..5257626 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v1.15.0 +v1.15.1