- 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
236 lines
9.3 KiB
Go
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())
|
|
}
|
|
}
|