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
Binary file added .github/pr-previews/cli-output/long-run-fail.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-previews/cli-output/long-run-pass.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion checks/jq.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ func compareInt(actual int, expected int, operator api.JqOperator) bool {
}
}

func valFromJqPath(path string, jsn string) (any, error) {
func JSONValueAtPath(path string, jsn string) (any, error) {
vals, err := valsFromJqPath(path, jsn)
if err != nil {
return nil, err
Expand Down
2 changes: 1 addition & 1 deletion checks/jq_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func TestRunStdoutJqQuery(t *testing.T) {
}

func TestValFromJqPathRejectsMultipleValues(t *testing.T) {
_, err := valFromJqPath(`.items[].id`, `{"items":[{"id":1},{"id":2}]}`)
_, err := JSONValueAtPath(`.items[].id`, `{"items":[{"id":1},{"id":2}]}`)
if err == nil {
t.Fatal("expected error for multiple values")
}
Expand Down
2 changes: 1 addition & 1 deletion checks/local.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ func capturedVariableMatches(vars map[string]string, name, expectedValue string)
}

func jsonValOp(test api.HTTPRequestTestJSONValue, jsn string, variables map[string]string) error {
val, err := valFromJqPath(test.Path, jsn)
val, err := JSONValueAtPath(test.Path, jsn)
if err != nil {
return err
}
Expand Down
52 changes: 49 additions & 3 deletions cmd/localtest.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"

"github.com/bootdotdev/bootdev/checks"
api "github.com/bootdotdev/bootdev/client"
Expand All @@ -19,6 +21,7 @@ import (
func init() {
rootCmd.AddCommand(localTestCmd)
localTestCmd.Flags().Bool("ignore-os", false, "skip the lesson's allowed operating systems check")
localTestCmd.Flags().Bool("run", false, "run local lesson commands without grading")
localTestCmd.Flags().String("shell", "", "shell for lesson commands: sh or pwsh (default depends on host OS)")
localTestCmd.Flags().BoolVarP(&verboseOutput, "verbose", "v", false, "show detailed final output for every step")
}
Expand All @@ -33,6 +36,7 @@ var localTestCmd = &cobra.Command{

func localTestHandler(cmd *cobra.Command, args []string) error {
cmd.SilenceUsage = true
cmd.SilenceErrors = false

data, err := readLocalCLIData(args[0])
if err != nil {
Expand All @@ -51,14 +55,22 @@ func localTestHandler(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
runOnly, err := cmd.Flags().GetBool("run")
if err != nil {
return err
}

overrideBaseURL := viper.GetString("override_base_url")
runContext := localRunContext(args[0])
if runOnly {
runContext.Action = "Running lesson:"
}
runContext.BaseURL = data.BaseURLDefault
if overrideBaseURL != "" {
fmt.Printf("Using overridden base_url: %v\n", overrideBaseURL)
fmt.Printf("You can reset to the default with `bootdev config base_url --reset`\n\n")
runContext.BaseURL = overrideBaseURL
}

send, finish := render.StartRenderer(true, verboseOutput, false)
send, finish := render.StartRenderer(!runOnly, verboseOutput, false, runContext)
submissionEvent := api.LessonSubmissionEvent{}
defer func() {
finish(submissionEvent)
Expand All @@ -68,16 +80,50 @@ func localTestHandler(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
if runOnly {
return nil
}
submissionEvent = checks.LocalSubmissionEvent(data, cliResults)
checks.ApplySubmissionResults(data, submissionEvent.StructuredErrCLI, send)

if submissionEvent.ResultSlug != api.VerificationResultSlugSuccess {
cmd.SilenceErrors = true
return localTestFailureError(submissionEvent.StructuredErrCLI)
}

return nil
}

func localRunContext(path string) render.RunContext {
manifestPath := filepath.Clean(path)
if info, err := os.Stat(manifestPath); err == nil && info.IsDir() {
manifestPath = filepath.Join(manifestPath, "cli.yaml")
}
lessonDirectory := filepath.Dir(manifestPath)
context := render.RunContext{
Action: "Submitting lesson:",
LessonTitle: filepath.Base(lessonDirectory),
}
if readme, err := os.ReadFile(filepath.Join(lessonDirectory, "readme.md")); err == nil {
for _, line := range strings.Split(string(readme), "\n") {
if strings.HasPrefix(line, "# ") {
if title := strings.TrimSpace(strings.TrimPrefix(line, "# ")); title != "" {
context.LessonTitle = title
}
break
}
}
}
chapterPrefix, _, chapterFound := strings.Cut(filepath.Base(filepath.Dir(lessonDirectory)), "-")
lessonPrefix, _, lessonFound := strings.Cut(filepath.Base(lessonDirectory), "-")
chapterNumber, chapterErr := strconv.Atoi(chapterPrefix)
lessonNumber, lessonErr := strconv.Atoi(lessonPrefix)
if chapterFound && lessonFound && chapterErr == nil && lessonErr == nil && chapterNumber > 0 && lessonNumber > 0 {
context.LessonTitle = fmt.Sprintf("%d.%d - %s", chapterNumber, lessonNumber, context.LessonTitle)
}
return context
}

func localTestFailureError(failure *api.StructuredErrCLI) error {
if failure == nil {
return errors.New("local checks failed")
Expand Down
47 changes: 47 additions & 0 deletions cmd/localtest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,40 @@ func TestReadLocalCLIDataAcceptsLessonDirectory(t *testing.T) {
}
}

func TestLocalRunContext(t *testing.T) {
for _, tt := range []struct {
name string
readme string
metadata string
wantTitle string
}{
{name: "lesson", readme: "# Sorting Chirps\n", metadata: "uuid: lesson-id\n", wantTitle: "Sorting Chirps"},
{name: "missing sidecars"},
{name: "invalid sidecars", readme: "No title", metadata: "[invalid"},
} {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
for name, contents := range map[string]string{"cli.yaml": "steps: []", "readme.md": tt.readme, "metadata.yaml": tt.metadata} {
if contents != "" {
if err := os.WriteFile(filepath.Join(dir, name), []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}
}
for _, path := range []string{dir, filepath.Join(dir, "cli.yaml")} {
got := localRunContext(path)
title := tt.wantTitle
if title == "" {
title = filepath.Base(dir)
}
if got.LessonTitle != title {
t.Fatalf("unexpected context: %+v", got)
}
}
})
}
}

func TestLocalTestShellDoesNotBypassOSValidation(t *testing.T) {
for _, allowedOS := range []string{"[]", "[unsupported-os]"} {
t.Run(allowedOS, func(t *testing.T) {
Expand All @@ -46,3 +80,16 @@ func TestLocalTestShellDoesNotBypassOSValidation(t *testing.T) {
})
}
}

func TestLocalRunContextUsesCourseLessonNumber(t *testing.T) {
dir := filepath.Join(t.TempDir(), "9-documentation", "3-sorting-chirp")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "readme.md"), []byte("# Sorting Chirps\n"), 0o600); err != nil {
t.Fatal(err)
}
if got := localRunContext(dir); got.LessonTitle != "9.3 - Sorting Chirps" {
t.Fatalf("unexpected title: %s", got.LessonTitle)
}
}
38 changes: 18 additions & 20 deletions cmd/submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,6 @@ func submissionHandler(cmd *cobra.Command, args []string) error {
) {
return errors.New("submission canceled")
}
} else {
fmt.Printf("Running next lesson:\n%s\n", lessonLabel)
}
lessonUUID = nextLesson.LessonUUID
}
Expand All @@ -69,20 +67,6 @@ func submissionHandler(cmd *cobra.Command, args []string) error {
return err
}

if len(args) > 0 {
action := "Running"
if isSubmit {
action = "Submitting"
}
fmt.Printf(
"%s lesson:\n%d.%d - %s\n",
action,
lesson.ChapterNumber,
lesson.LessonNumber,
lesson.Lesson.Title,
)
}

if lesson.Lesson.Type != "type_cli" {
return errors.New("unable to run lesson: unsupported lesson type")
}
Expand All @@ -96,12 +80,15 @@ func submissionHandler(cmd *cobra.Command, args []string) error {
}

overrideBaseURL := viper.GetString("override_base_url")
runContext := render.RunContext{
Action: lessonRunAction(isSubmit, len(args) == 0),
LessonTitle: fmt.Sprintf("%d.%d - %s", lesson.ChapterNumber, lesson.LessonNumber, lesson.Lesson.Title),
BaseURL: data.BaseURLDefault,
}
if overrideBaseURL != "" {
fmt.Printf("Using overridden base_url: %v\n", overrideBaseURL)
fmt.Printf("You can reset to the default with `bootdev config base_url --reset`\n\n")
runContext.BaseURL = overrideBaseURL
}

send, finish := render.StartRenderer(isSubmit, verboseOutput, isSubmit && len(args) > 0)
send, finish := render.StartRenderer(isSubmit, verboseOutput, isSubmit && len(args) > 0, runContext)
finalEvent := api.LessonSubmissionEvent{}
var debugPath string
var debugWriteErr error
Expand Down Expand Up @@ -142,6 +129,17 @@ func applySubmissionEvent(data api.CLIData, event api.LessonSubmissionEvent, sen
return nil
}

func lessonRunAction(isSubmit, isNext bool) string {
action := "Running"
if isSubmit {
action = "Submitting"
}
if isNext {
return action + " next lesson:"
}
return action + " lesson:"
}

func reportDebugFileWrite(path string, err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "warning: failed to write submission debug output: %v\n", err)
Expand Down
40 changes: 30 additions & 10 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,14 @@ func TestLocalTestRedirectedOutput(t *testing.T) {
name string
fail bool
verbose bool
runOnly bool
}{
{"pass verbose", false, true},
{"fail verbose", true, true},
{"pass compact", false, false},
{"fail compact", true, false},
{"pass verbose", false, true, false},
{"fail verbose", true, true, false},
{"pass compact", false, false, false},
{"fail compact", true, false, false},
{"run matching expectation", false, false, true},
{"run mismatched expectation", true, false, true},
} {
t.Run(tt.name, func(t *testing.T) {
wantExit := 0
Expand All @@ -54,6 +57,10 @@ func TestLocalTestRedirectedOutput(t *testing.T) {
if tt.verbose {
args = append(args, "--verbose")
}
if tt.runOnly {
args = append(args, "--run")
wantExit = 0
}
cmd := exec.CommandContext(ctx, binary, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
Expand All @@ -65,17 +72,30 @@ func TestLocalTestRedirectedOutput(t *testing.T) {
if strings.Contains(output+stderr.String(), "\x1b") || strings.Contains(stderr.String(), "TTY") {
t.Fatalf("terminal output leaked: stdout=%q stderr=%q", output, stderr.String())
}
if strings.Count(output, "Output check") != 1 {
if strings.Count(output, "STEP 1 · Output check")+strings.Count(output, "PASS Step 1 · Output check") != 1 {
t.Fatalf("expected one final report: %s", output)
}
for _, diagnostic := range []string{"Command stdout:\n\nstdout-marker", "Command stderr:\n\nstderr-marker"} {
if got, want := strings.Contains(output, diagnostic), tt.verbose || tt.fail; got != want {
for _, diagnostic := range []string{"Standard output\n stdout-marker", "Standard error\n stderr-marker"} {
if got, want := strings.Contains(output, diagnostic), tt.verbose || tt.fail || tt.runOnly; got != want {
t.Fatalf("diagnostic %q present = %t, want %t; output: %s", diagnostic, got, want, output)
}
}
if tt.fail {
if !strings.Contains(stderr.String(), "local checks failed") {
t.Fatalf("missing failure error: %s", &stderr)
if tt.runOnly {
if !strings.Contains(output, "Running lesson:") || stderr.Len() != 0 {
t.Fatalf("unexpected run output: %s; stderr: %s", output, &stderr)
}
for _, forbidden := range []string{"PASS", "FAIL", "All tests passed!", "Submitting lesson:"} {
if strings.Contains(output, forbidden) {
t.Fatalf("run exposed %q: %s", forbidden, output)
}
}
} else if tt.fail {
_, recap, ok := strings.Cut(output, "FAILED · ")
if !ok || !strings.Contains(recap, "Step 1 · Output check") || !strings.Contains(recap, "Command: "+command) {
t.Fatalf("missing failure context in final recap: %s", output)
}
if stderr.Len() != 0 {
t.Fatalf("duplicate failure error: %s", &stderr)
}
} else if stderr.Len() != 0 || !strings.Contains(output, "All tests passed!") {
t.Fatalf("unexpected success output: stdout=%q stderr=%q", output, stderr.String())
Expand Down
32 changes: 32 additions & 0 deletions render/header.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package render

import (
"fmt"
"strings"

"github.com/charmbracelet/lipgloss"
)

type RunContext struct {
Action string
LessonTitle string
BaseURL string
}

func renderRunHeader(context RunContext) string {
if context.LessonTitle == "" {
return ""
}
var str strings.Builder
action := context.Action
if action == "" {
action = "Running lesson:"
}
fmt.Fprintln(&str, action)
fmt.Fprintln(&str, lipgloss.NewStyle().Bold(true).Render(context.LessonTitle))
if context.BaseURL != "" {
fmt.Fprintf(&str, "%s %s\n", gray.Render("Target:"), context.BaseURL)
}
str.WriteByte('\n')
return str.String()
}
31 changes: 31 additions & 0 deletions render/header_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package render

import (
"strings"
"testing"
)

func TestLessonHeaderPersistsInFinalReport(t *testing.T) {
m := initModel(true, false)
m.runContext = RunContext{Action: "Submitting lesson:", LessonTitle: "9.3 - Sorting Chirps", BaseURL: "http://localhost:8080"}
m.steps = []stepModel{{description: "Reset database"}}
for _, finalized := range []bool{false, true} {
m.finalized = finalized
view := m.View()
for _, expected := range []string{"Submitting lesson:\n9.3 - Sorting Chirps", "Target: http://localhost:8080"} {
if strings.Count(view, expected) != 1 {
t.Fatalf("expected one %q in:\n%s", expected, view)
}
}
if strings.Index(view, "Submitting lesson:") > strings.Index(view, "Reset database") {
t.Fatal("header follows steps")
}
}
}

func TestHeaderWithoutTargetShowsOnlyLesson(t *testing.T) {
view := renderRunHeader(RunContext{LessonTitle: "1.3 - Example"})
if view != "Running lesson:\n1.3 - Example\n\n" {
t.Fatalf("unexpected header: %s", view)
}
}
Loading
Loading