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.
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,15 +175,25 @@ For lessons with HTTP tests, you can configure the CLI with a base URL that over

### CLI colors

The CLI text output is rendered with extra colors: green (e.g., success messages), red (e.g., error messages), and gray (e.g., secondary text).
CLI colors are configured by their role.

- To customize these colors, run:

```sh
bootdev config colors --red VALUE --green VALUE --gray VALUE --magenta VALUE --yellow VALUE
bootdev config colors \
--header VALUE \
--subheader VALUE \
--information VALUE \
--warning VALUE \
--pass VALUE \
--fail VALUE \
--secondary VALUE \
--accent VALUE
```

_You can use an [ANSI color code](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit) or a hex string as the `VALUE`._
_You can use an [ANSI palette color index](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit) (`0`–`255`, such as `6` for cyan) or a hex string (such as `#7AA2F7`) as the `VALUE`. Palette indexes `0`–`15` follow your terminal's theme._

- Alternatively, edit the `color` section in your active configuration file (such as `~/.bootdev.yaml`). Omitted keys use the default colors.

- To get the current colors, run:

Expand Down
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
14 changes: 9 additions & 5 deletions cmd/configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"net/url"

"github.com/bootdotdev/bootdev/render"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
"github.com/spf13/viper"
Expand All @@ -18,11 +19,14 @@ var configureCmd = &cobra.Command{
}

var defaultColors = map[string]string{
"gray": "8",
"red": "1",
"green": "2",
"magenta": "5",
"yellow": "#f6bd45",
"secondary": render.DefaultSecondaryColor,
"fail": render.DefaultFailColor,
"pass": render.DefaultPassColor,
"accent": render.DefaultAccentColor,
"warning": render.DefaultWarningColor,
"header": render.DefaultHeaderColor,
"subheader": render.DefaultSubheaderColor,
"information": render.DefaultInformationColor,
}

// configureColorsCmd represents the `configure colors` command for changing
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
46 changes: 46 additions & 0 deletions cmd/localtest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,39 @@ func TestReadLocalCLIDataAcceptsLessonDirectory(t *testing.T) {
}
}

func TestLocalRunContext(t *testing.T) {
for _, tt := range []struct {
name string
readme string
wantTitle string
}{
{name: "lesson", readme: "# Sorting Chirps\n", wantTitle: "Sorting Chirps"},
{name: "missing README"},
{name: "README without title", readme: "No title"},
} {
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} {
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 +79,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
Loading
Loading