Files
summercms/modules/bonfire/help_smoke_test.go
Jakub Zych fb32886775 feat(bonfire): Artisan help, examples, JSON listing and error hints
- help <command> and <command> --help print Description, Usage,
  Arguments, Options with defaults, Global options, Help and Examples
- Command.Examples and Command.Hidden (appended, additive); list --all
  shows hidden commands, list --format=json prints a bonfire.Listing
- CheckDocs reports commands lacking description, long help, examples or
  argument and option descriptions
- Error block with the ERROR badge, WithHint explanation and Try lines
  ({bin} replaced), cobra suggestions as Try, -v error chain, messages
  scrubbed by sunscreen; Reported errors print nothing and exit 1
2026-10-21 12:21:24 +02:00

236 lines
9.3 KiB
Go

package bonfire
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
)
func smokeTokenCommand() 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.\nThe token is printed once.",
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", Description: "Tag to add", 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 },
}
}
// smokeRoot returns a runner that executes args on a fresh root each time
// (pflag keeps a command's local flag values between executions).
func smokeRoot(t *testing.T, out *bytes.Buffer, commands ...Command) func(args ...string) int {
t.Helper()
return func(args ...string) int {
t.Helper()
root, err := NewRootIO("acme", commands, strings.NewReader(""), out, out,
WithVersion(Version{App: "v1.4.0", Framework: "v0.3.2"}))
if err != nil {
t.Fatal(err)
}
out.Reset()
return Execute(context.Background(), root, args)
}
}
// TestHelpSmoke: <command> --help renders the Artisan sections in order,
// with defaults, the repeatable marker and examples prefixed by bin/<name>,
// and help <command> prints the same.
func TestHelpSmoke(t *testing.T) {
var out bytes.Buffer
run := smokeRoot(t, &out, smokeTokenCommand())
if code := run("admin:token:create", "--help"); code != ExitOK {
t.Fatalf("--help exit = %d\n%s", code, out.String())
}
got := out.String()
order := []string{
"Description:\n Create a personal API token for a backend admin\n",
"Usage:\n admin:token:create <login> [options]\n",
"Arguments:\n login ",
"Options:\n",
` --scopes=SCOPES Comma-separated scopes [default: "*"]`,
" --expires=EXPIRES Days until expiry [default: 90]",
" --json ",
"Tag to add (multiple values allowed)",
"Global options:\n -h, --help",
"Help:\n Creates a token for the admin with the given login.\n The token is printed once.\n",
"Examples:\n # Token for the MCP server, journal only\n bin/acme admin:token:create jane --scopes=journal.*\n\n # Script-friendly output\n bin/acme admin:token:create jane --json\n",
}
last := -1
for _, want := range order {
if want == ` --scopes=SCOPES Comma-separated scopes [default: "*"]` || want == " --expires=EXPIRES Days until expiry [default: 90]" {
// The detail column is aligned to the longest label.
label, detail, _ := strings.Cut(strings.TrimSpace(want), " ")
i := strings.Index(got, label)
if i < 0 || !strings.Contains(got[i:], detail) {
t.Fatalf("help missing %q / %q:\n%s", label, detail, got)
}
continue
}
i := strings.Index(got, want)
if i < 0 {
t.Fatalf("help missing %q:\n%s", want, got)
}
if i < last {
t.Fatalf("%q out of order:\n%s", want, got)
}
last = i
}
if strings.Contains(got, "--json=") || strings.Contains(got, "[default: \"true\"]") {
t.Fatalf("bare flag rendered with a value:\n%s", got)
}
viaFlag := got
if code := run("help", "admin:token:create"); code != ExitOK {
t.Fatalf("help exit = %d", code)
}
if out.String() != viaFlag {
t.Fatalf("help <command> differs from <command> --help:\n%s\n---\n%s", out.String(), viaFlag)
}
if code := run("help"); code != ExitOK || !strings.Contains(out.String(), "Available commands:") {
t.Fatalf("bare help exit = %d\n%s", code, out.String())
}
}
// TestListJSONSmoke: list --format=json decodes into Listing with every
// command, a Hidden one flagged; the text list hides it unless --all.
func TestListJSONSmoke(t *testing.T) {
hidden := Command{Name: "acme:secret", Description: "A hidden maintenance command", Hidden: true,
Run: func(context.Context, Input, Output) error { return nil }}
var out bytes.Buffer
run := smokeRoot(t, &out, smokeTokenCommand(), hidden)
if code := run("list", "--format=json"); code != ExitOK {
t.Fatalf("json exit = %d\n%s", code, out.String())
}
var l Listing
if err := json.Unmarshal(out.Bytes(), &l); err != nil {
t.Fatalf("decode: %v\n%s", err, out.String())
}
if l.Application.Name != "acme" || l.Application.Version != "v1.4.0" || l.Application.Framework != "v0.3.2" {
t.Fatalf("application = %+v", l.Application)
}
if len(l.GlobalOptions) == 0 {
t.Fatal("no global options")
}
byName := map[string]CommandInfo{}
for _, c := range l.Commands {
byName[c.Name] = c
}
for _, name := range []string{"acme:secret", "admin:token:create", "list", "help"} {
if _, ok := byName[name]; !ok {
t.Fatalf("listing misses %s: %+v", name, l.Commands)
}
}
if !byName["acme:secret"].Hidden {
t.Fatal("hidden command not flagged")
}
tok := byName["admin:token:create"]
if len(tok.Arguments) != 1 || len(tok.Options) != 4 || len(tok.Examples) != 2 || tok.Options[1].Default != "90" {
t.Fatalf("token command = %+v", tok)
}
if !strings.Contains(out.String(), `"global_options"`) {
t.Fatal("json keys are not snake case")
}
if code := run("list"); code != ExitOK || strings.Contains(out.String(), "acme:secret") {
t.Fatalf("list shows the hidden command (exit %d):\n%s", code, out.String())
}
if code := run("list", "--all"); code != ExitOK || !strings.Contains(out.String(), "acme:secret") {
t.Fatalf("list --all hides the command (exit %d):\n%s", code, out.String())
}
if code := run("acme:secret"); code != ExitOK {
t.Fatalf("hidden command does not run: %d", code)
}
if code := run("list", "--format=xml"); code != ExitUsage {
t.Fatalf("unknown format exit = %d", code)
}
problems := CheckDocs([]Command{smokeTokenCommand(), hidden, {Name: "acme:bare", Args: []Arg{{Name: "x"}}, Flags: []Flag{{Name: "y"}}}})
if len(problems) != 2 || problems[0].Command != "acme:bare" || problems[1].Command != "acme:secret" {
t.Fatalf("CheckDocs = %v", problems)
}
if got := problems[0].String(); got != "acme:bare: missing description, long help, examples, argument x description, option --y description" {
t.Fatalf("problem = %q", got)
}
}
// TestErrorRenderSmoke: the error block shows hints with {bin} replaced,
// the -v hint or the wrapped chain, nothing for Reported errors, the exit
// codes, the unknown command suggestion and scrubbed secrets.
func TestErrorRenderSmoke(t *testing.T) {
inner := errors.New("open summer.yaml: no such file")
hinted := WithHint(fmt.Errorf("no application found in /tmp/x: %w", inner),
"summer make:command needs an application root (summer.yaml).",
"cd ..; {bin} make:command acme.blog:publish")
var buf bytes.Buffer
RenderError(&buf, "acme", hinted, Globals{})
got := buf.String()
for _, want := range []string{
" ERROR no application found in /tmp/x: open summer.yaml: no such file\n",
"\n summer make:command needs an application root (summer.yaml).\n",
"\n Try:\n cd ..; bin/acme make:command acme.blog:publish\n",
"\n Run with -v for details.\n",
} {
if !strings.Contains(got, want) {
t.Fatalf("block missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "Caused by:") {
t.Fatalf("chain shown at verbosity 0:\n%s", got)
}
buf.Reset()
RenderError(&buf, "summer", hinted, Globals{Verbosity: 1})
got = buf.String()
if !strings.Contains(got, "Caused by:\n - open summer.yaml: no such file\n") || strings.Contains(got, "Run with -v") || !strings.Contains(got, "cd ..; summer make:command") {
t.Fatalf("verbose block:\n%s", got)
}
buf.Reset()
RenderError(&buf, "acme", errors.New("upstream said Bearer abcdefghijklmnop0123456789"), Globals{Verbosity: 1})
if strings.Contains(buf.String(), "abcdefghijklmnop0123456789") {
t.Fatalf("bearer token not scrubbed:\n%s", buf.String())
}
buf.Reset()
RenderError(&buf, "acme", Reported(errors.New("3 problems")), Globals{})
if buf.Len() != 0 {
t.Fatalf("reported error printed: %q", buf.String())
}
var out bytes.Buffer
run := smokeRoot(t, &out,
Command{Name: "migrate", Description: "Run the migrations", Run: func(context.Context, Input, Output) error { return nil }},
Command{Name: "docs:build", Description: "Build docs", Run: func(_ context.Context, _ Input, o Output) error {
o.Println("problem 1")
return Reported(errors.New("docs:build failed"))
}},
)
if code := run("docs:build"); code != ExitFailure || strings.Contains(out.String(), "ERROR") {
t.Fatalf("reported: exit %d\n%s", code, out.String())
}
if code := run("migrat"); code != ExitUsage {
t.Fatalf("unknown command exit = %d", code)
}
got = out.String()
if !strings.Contains(got, `ERROR Command "migrat" is not defined.`) || !strings.Contains(got, "Try:\n bin/acme migrate\n") {
t.Fatalf("unknown command block:\n%s", got)
}
if code := run("migrate", "extra", "--bogus"); code != ExitUsage || !strings.Contains(out.String(), "bin/acme help migrate") {
t.Fatalf("unknown flag: exit %d\n%s", code, out.String())
}
if code := run("help", "migrat"); code != ExitUsage || !strings.Contains(out.String(), "bin/acme help migrate") {
t.Fatalf("help unknown: exit %d\n%s", code, out.String())
}
}