- list, list --all, list --format=json and an --ansi styled list (D-13, D-16)
- admin:token:create help and a passthrough help; help <cmd> equals --help (D-14)
- error blocks: hint with {bin}, unknown command, -v chain on stderr (D-18)
- CheckDocs gaps in order and sorted; the version header and BuildVersion
- goldens render with color detection cleared; -update rewrites them
269 lines
9.2 KiB
Go
269 lines
9.2 KiB
Go
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/<name>.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 <command> 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 <command>, <command> --help and <command>
|
|
// -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))
|
|
})
|
|
}
|
|
}
|