package bonfire import ( "bytes" "context" "errors" "flag" "fmt" "os" "path/filepath" "strings" "testing" ) var update = flag.Bool("update", false, "rewrite golden files") // goldenVersion is the fixed version line of every golden. var goldenVersion = Version{App: "v1.4.0", Framework: "v0.3.2"} // plainEnv clears the environment variables that force or detect color, so // goldens render the same on every machine: no escape codes unless --ansi. func plainEnv(t *testing.T) { t.Helper() for _, k := range []string{"FORCE_COLOR", "CLICOLOR_FORCE", "CLICOLOR", "COLORTERM", "NO_COLOR"} { t.Setenv(k, "") _ = os.Unsetenv(k) } t.Setenv("TERM", "dumb") } // goldenTokenCommand is the admin:token:create command of the D-14 // preview: value, bare and repeatable flags with defaults, long help and // two examples. The values are fake. func goldenTokenCommand() Command { return Command{ Name: "admin:token:create", Description: "Create a personal API token for a backend admin", Long: "Creates a token for the admin with the given login and prints it once.\nStore it in the client right away; it cannot be shown again.", Args: []Arg{{Name: "login", Description: "The admin's login", Required: true}}, Flags: []Flag{ {Name: "scopes", Description: "Comma-separated scopes", Default: "*"}, {Name: "expires", Description: "Days until expiry", Default: "90"}, {Name: "json", Description: "Print the token as JSON", Bare: true}, {Name: "tag", Shorthand: "t", Description: "A tag for the token", Repeatable: true}, }, Examples: []Example{ {Command: "admin:token:create jane --scopes=journal.*", Description: "Token for the MCP server, journal only"}, {Command: "admin:token:create jane --json", Description: "Script-friendly output"}, }, Run: func(context.Context, Input, Output) error { return nil }, } } // goldenCommands is the command set of the list goldens (D-13 preview plus // a hidden and a passthrough command). func goldenCommands() []Command { ok := func(context.Context, Input, Output) error { return nil } return []Command{ {Name: "migrate", Description: "Run the database migrations", Run: ok}, {Name: "serve", Description: "Serve the application", Run: ok}, {Name: "admin:create", Description: "Create a backend administrator", Run: ok}, goldenTokenCommand(), {Name: "make:command", Description: "Create a new console command", Run: ok}, {Name: "make:model", Description: "Create a new model and migration", Run: ok}, {Name: "acme:secret", Description: "Rotate the internal signing secret", Hidden: true, Run: ok}, goldenPassthroughCommand(), } } // goldenPassthroughCommand forwards everything after its name. func goldenPassthroughCommand() Command { return Command{ Name: "tool:exec", Description: "Run the bundled tool with its own arguments", Long: "Everything after the command name reaches the tool unchanged, global flags included.", Passthrough: true, Args: []Arg{{Name: "args", Description: "The tool's arguments"}}, Examples: []Example{{Command: "tool:exec --version", Description: "The tool's own version"}}, Run: func(context.Context, Input, Output) error { return nil }, } } // runGolden executes args on a fresh root over commands and returns stdout // and stderr. func runGolden(t *testing.T, commands []Command, args ...string) (string, string, int) { t.Helper() var out, errW bytes.Buffer root, err := NewRootIO("acme", commands, strings.NewReader(""), &out, &errW, WithVersion(goldenVersion)) if err != nil { t.Fatal(err) } code := Execute(context.Background(), root, args) return out.String(), errW.String(), code } // assertGolden compares got with testdata/.golden, rewriting the file // first when -update is set. func assertGolden(t *testing.T, name string, got []byte) { t.Helper() path := filepath.Join("testdata", name+".golden") if *update { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, got, 0o644); err != nil { t.Fatal(err) } } want, err := os.ReadFile(path) if err != nil { t.Fatalf("%s: %v (run go test -run %s -update to create it)", name, err, t.Name()) } if !bytes.Equal(got, want) { t.Errorf("%s differs from %s\n%s", name, path, firstDiff(want, got)) } } // firstDiff prints the first differing line of want and got with a little // context, in a unified-diff-like form. func firstDiff(want, got []byte) string { wl := strings.Split(string(want), "\n") gl := strings.Split(string(got), "\n") for i := 0; i < max(len(wl), len(gl)); i++ { var w, g string if i < len(wl) { w = wl[i] } if i < len(gl) { g = gl[i] } if w == g { continue } var b strings.Builder for j := max(0, i-2); j < i; j++ { fmt.Fprintf(&b, " %q\n", wl[j]) } fmt.Fprintf(&b, "- %q\n+ %q\n", w, g) return b.String() } return "(same lines, different line endings)" } // TestListGolden: the bare binary prints the D-13 list. func TestListGolden(t *testing.T) { plainEnv(t) out, errOut, code := runGolden(t, goldenCommands()) if code != ExitOK || errOut != "" { t.Fatalf("exit %d, stderr %q", code, errOut) } assertGolden(t, "list", []byte(out)) for _, args := range [][]string{{"list"}, {"--help"}, {"-h"}, {"help"}, {"-v"}, {"--no-ansi"}} { again, _, code := runGolden(t, goldenCommands(), args...) if code != ExitOK || again != out { t.Errorf("%v: exit %d, output differs from the bare list:\n%s", args, code, firstDiff([]byte(out), []byte(again))) } } } // TestListAllGolden: list --all adds the hidden command. func TestListAllGolden(t *testing.T) { plainEnv(t) out, _, code := runGolden(t, goldenCommands(), "list", "--all") if code != ExitOK { t.Fatalf("exit %d", code) } assertGolden(t, "list_all", []byte(out)) } // TestListJSONGolden: list --format=json prints the catalog. func TestListJSONGolden(t *testing.T) { plainEnv(t) out, _, code := runGolden(t, goldenCommands(), "list", "--format=json") if code != ExitOK { t.Fatalf("exit %d", code) } assertGolden(t, "list.json", []byte(out)) } // TestListStyledGolden: --ansi styles the list in the palette (ANSI256), // so a palette change shows up in review. func TestListStyledGolden(t *testing.T) { plainEnv(t) out, _, code := runGolden(t, goldenCommands(), "--ansi") if code != ExitOK { t.Fatalf("exit %d", code) } if !strings.Contains(out, "\x1b[") { t.Fatalf("--ansi list has no escape codes:\n%q", out) } assertGolden(t, "list_styled", []byte(out)) } // TestHelpGolden: admin:token:create --help in the D-14 layout. func TestHelpGolden(t *testing.T) { plainEnv(t) out, _, code := runGolden(t, goldenCommands(), "admin:token:create", "--help") if code != ExitOK { t.Fatalf("exit %d", code) } assertGolden(t, "help_token_create", []byte(out)) } // TestHelpPassthroughGolden: the help of a passthrough command, reached // through help because --help after its name is forwarded. func TestHelpPassthroughGolden(t *testing.T) { plainEnv(t) out, _, code := runGolden(t, goldenCommands(), "help", "tool:exec") if code != ExitOK { t.Fatalf("exit %d", code) } assertGolden(t, "help_passthrough", []byte(out)) } // TestHelpEqualsHelpFlag: help , --help and // -h print the same bytes, with or without global flags. func TestHelpEqualsHelpFlag(t *testing.T) { plainEnv(t) for _, name := range []string{"admin:token:create", "migrate", "list", "help"} { want, _, code := runGolden(t, goldenCommands(), "help", name) if code != ExitOK || !strings.Contains(want, "Usage:\n") { t.Fatalf("help %s: exit %d\n%s", name, code, want) } for _, args := range [][]string{{name, "--help"}, {name, "-h"}, {"-v", "help", name}, {"--no-ansi", name, "--help"}} { got, _, code := runGolden(t, goldenCommands(), args...) if code != ExitOK || got != want { t.Errorf("%v: exit %d, differs from help %s:\n%s", args, code, name, firstDiff([]byte(want), []byte(got))) } } } } // TestErrorGolden: the error block of D-18 for a hint with {bin}, an // unknown command with a suggestion and the -v chain, on stderr. func TestErrorGolden(t *testing.T) { plainEnv(t) hinted := Command{Name: "make:plugin", Description: "Create a plugin", Run: func(context.Context, Input, Output) error { return WithHint(errors.New("No application found in /home/jane/acme/plugins."), "summer make:command needs an application root (summer.yaml).", "cd ..; {bin} make:command acme.blog:publish") }} chained := Command{Name: "db:check", Description: "Check the database", Run: func(context.Context, Input, Output) error { return fmt.Errorf("db:check: %w", fmt.Errorf("connect to 127.0.0.1:5432: %w", errors.New("connection refused"))) }} commands := append(goldenCommands(), hinted, chained) cases := []struct { golden string args []string code int }{ {"error_hint", []string{"make:plugin"}, ExitFailure}, {"error_unknown_command", []string{"migrat"}, ExitUsage}, {"error_chain_verbose", []string{"-v", "db:check"}, ExitFailure}, } for _, tc := range cases { t.Run(tc.golden, func(t *testing.T) { out, errOut, code := runGolden(t, commands, tc.args...) if code != tc.code { t.Fatalf("exit %d, want %d", code, tc.code) } if out != "" { t.Errorf("the error block reached stdout:\n%s", out) } assertGolden(t, tc.golden, []byte(errOut)) }) } }