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: --help renders the Artisan sections in order, // with defaults, the repeatable marker and examples prefixed by bin/, // and help 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 [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 differs from --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()) } }