Files
summercms/modules/bonfire/golden_test.go
Jakub Zych 7b58ad5fa5 test(bonfire): goldens for list, help and errors
- 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
2026-10-21 19:22:00 +02:00

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))
})
}
}