package bonfire import ( "bytes" "context" "errors" "slices" "strings" "testing" ) func TestNonTTYSpinner(t *testing.T) { var buf bytes.Buffer out := NewOutput(strings.NewReader(""), &buf, &buf) if err := out.Spinner("building", func() error { return nil }); err != nil { t.Fatal(err) } if got := buf.String(); got != "[...] building\n" { t.Fatalf("spinner = %q", got) } } func TestNonTTYProgress(t *testing.T) { var buf bytes.Buffer out := NewOutput(strings.NewReader(""), &buf, &buf) if err := out.Progress(10, func(p Progress) error { for i := 0; i < 10; i++ { p.Advance(1) } return nil }); err != nil { t.Fatal(err) } got := buf.String() for _, want := range []string{"[1/10] 10%", "[5/10] 50%", "[10/10] 100%"} { if !strings.Contains(got, want) { t.Fatalf("missing %q in %q", want, got) } } if strings.Contains(got, "\x1b[") { t.Fatalf("non-tty progress has ansi: %q", got) } } func TestNonTTYTable(t *testing.T) { var buf bytes.Buffer out := NewOutput(strings.NewReader(""), &buf, &buf) out.Table([]string{"plugin", "status"}, [][]string{{"golem15.blog", "active"}}) got := buf.String() if got != "plugin\tstatus\ngolem15.blog\tactive\n" { t.Fatalf("table = %q", got) } } func TestColorPolicy(t *testing.T) { t.Run("NO_COLOR", func(t *testing.T) { var buf bytes.Buffer out := newConsole(strings.NewReader(""), &buf, &buf, func(k string) string { if k == "NO_COLOR" { return "1" } if k == "FORCE_COLOR" { return "1" } return "" }, boolPtr(true), boolPtr(true)) out.Success("ok") if strings.Contains(buf.String(), "\x1b[") { t.Fatalf("NO_COLOR leaked ansi: %q", buf.String()) } }) t.Run("TERM=dumb", func(t *testing.T) { var buf bytes.Buffer out := newConsole(strings.NewReader(""), &buf, &buf, func(k string) string { if k == "TERM" { return "dumb" } if k == "FORCE_COLOR" { return "1" } return "" }, boolPtr(true), boolPtr(true)) out.Info("hi") if strings.Contains(buf.String(), "\x1b[") { t.Fatalf("TERM=dumb leaked ansi: %q", buf.String()) } }) t.Run("FORCE_COLOR", func(t *testing.T) { var buf bytes.Buffer out := newConsole(strings.NewReader(""), &buf, &buf, func(k string) string { if k == "FORCE_COLOR" { return "1" } return "" }, boolPtr(false), boolPtr(false)) out.Success("ok") if !strings.Contains(buf.String(), "\x1b[") { t.Fatalf("FORCE_COLOR did not enable ansi: %q", buf.String()) } }) t.Run("default non-tty", func(t *testing.T) { var buf bytes.Buffer out := NewOutput(strings.NewReader(""), &buf, &buf) out.Success("ok") if strings.Contains(buf.String(), "\x1b[") { t.Fatalf("non-tty default has ansi: %q", buf.String()) } }) } func TestPluginCommandRequiresColon(t *testing.T) { var buf bytes.Buffer _, err := NewRoot("app", []Command{{Name: "hello", Description: "bad"}}, &buf) if err == nil || !errors.Is(err, ErrCommandName) { t.Fatalf("want ErrCommandName, got %v", err) } if !strings.Contains(err.Error(), "hello") { t.Fatalf("error should name the command: %v", err) } } func TestKernelAndNamespacedCommands(t *testing.T) { var buf bytes.Buffer root, err := NewRoot("summer", []Command{ {Name: "build", Description: "Build the app"}, {Name: "dev", Description: "Watch and rebuild"}, {Name: "make:plugin", Description: "Scaffold a plugin from a vendor.plugin id"}, {Name: "greeter:hello", Description: "Say hello"}, }, &buf) if err != nil { t.Fatal(err) } root.SetArgs([]string{"make:plugin", "--help"}) if err := root.Execute(); err != nil { t.Fatal(err) } if !strings.Contains(buf.String(), "vendor.plugin") && !strings.Contains(buf.String(), "make:plugin") { t.Fatalf("help = %q", buf.String()) } } func TestFlagAndArgumentParsing(t *testing.T) { var buf bytes.Buffer var gotArgs []string var gotTarget, gotMode, gotUnset string var gotTargetOK, gotModeOK, gotUnsetOK bool root, err := NewRootIO("app", []Command{{ Name: "demo:run", Description: "Run a demo", Args: []Arg{ {Name: "target", Description: "who", Required: true}, {Name: "extra", Description: "optional"}, }, Flags: []Flag{ {Name: "mode", Shorthand: "m", Default: "slow", Description: "speed"}, {Name: "unused", Default: "", Description: "empty default"}, }, Run: func(ctx context.Context, in Input, out Output) error { gotArgs = append([]string(nil), in.Args()...) gotTarget, gotTargetOK = in.Argument("target") gotMode, gotModeOK = in.Flag("mode") gotUnset, gotUnsetOK = in.Flag("unused") out.Println("ran " + gotTarget) return nil }, }}, strings.NewReader(""), &buf, &buf) if err != nil { t.Fatal(err) } root.SetArgs([]string{"demo:run", "-m", "fast", "world"}) if err := root.Execute(); err != nil { t.Fatal(err) } if !strings.Contains(buf.String(), "ran world") { t.Fatalf("captured output = %q", buf.String()) } if strings.Join(gotArgs, ",") != "world" { t.Fatalf("Args = %v", gotArgs) } if !gotTargetOK || gotTarget != "world" { t.Fatalf("Argument(target) = (%q, %v)", gotTarget, gotTargetOK) } if !gotModeOK || gotMode != "fast" { t.Fatalf("Flag(mode) = (%q, %v)", gotMode, gotModeOK) } if gotUnsetOK || gotUnset != "" { t.Fatalf("unset flag = (%q, %v)", gotUnset, gotUnsetOK) } } func TestInjectedOutputCapture(t *testing.T) { var stdout, stderr bytes.Buffer out := NewOutput(strings.NewReader(""), &stdout, &stderr) out.Info("heads up") out.Success("done") out.Warning("careful") out.Error("boom") out.Printf("plain %s\n", "line") if !strings.Contains(stdout.String(), "heads up") || !strings.Contains(stdout.String(), "done") || !strings.Contains(stdout.String(), "careful") { t.Fatalf("stdout = %q", stdout.String()) } if !strings.Contains(stdout.String(), "plain line") { t.Fatalf("Printf missing from stdout: %q", stdout.String()) } if !strings.Contains(stderr.String(), "boom") { t.Fatalf("Error should go to stderr, got %q", stderr.String()) } if strings.Contains(stdout.String(), "boom") { t.Fatalf("Error leaked to stdout: %q", stdout.String()) } if strings.Contains(stdout.String()+stderr.String(), "\x1b[") { t.Fatalf("non-tty messages have ansi: stdout=%q stderr=%q", stdout.String(), stderr.String()) } } func TestNonTTYSpinnerError(t *testing.T) { var buf bytes.Buffer out := NewOutput(strings.NewReader(""), &buf, &buf) err := out.Spinner("building", func() error { return errors.New("compile failed") }) if err == nil || err.Error() != "compile failed" { t.Fatalf("spinner err = %v", err) } if got := buf.String(); got != "[...] building\n" { t.Fatalf("spinner = %q", got) } } func TestNonTTYProgressSkipsIntraDecile(t *testing.T) { var buf bytes.Buffer out := NewOutput(strings.NewReader(""), &buf, &buf) if err := out.Progress(20, func(p Progress) error { p.Advance(1) // 5% p.Advance(1) // 10% p.Advance(1) // 15% return nil }); err != nil { t.Fatal(err) } got := buf.String() if !strings.Contains(got, "[2/20] 10%") { t.Fatalf("missing 10%% step in %q", got) } if strings.Contains(got, "[1/20]") || strings.Contains(got, "[3/20]") { t.Fatalf("intra-decile progress leaked: %q", got) } } // TestPhase8RedBonfireFlags is the Phase 8 Wave 7 RED anchor (08-07-PLAN.md // Task 1, D-19). bonfire.Flag/Input are scalar-only today // (08-PATTERNS.md "bonfire repeatable-flag gap"): a Repeatable flag // registered through Command.Flags must preserve every repeated // `--name=value` occurrence in order via Input.Flags, while an ordinary // scalar flag declared alongside it keeps working through Input.Flag // unmodified. It fails with the PHASE8_RED:bonfire-flags sentinel while // Repeatable is not yet wired into Cobra registration; // scripts/check-phase8-red.sh verifies this failure is fail-closed. func TestPhase8RedBonfireFlags(t *testing.T) { var buf bytes.Buffer var gotRedirect, gotScope []string var gotMode string var gotModeOK bool root, err := NewRootIO("app", []Command{{ Name: "demo:repeat", Description: "Demo repeatable flags", Flags: []Flag{ {Name: "redirect-uri", Repeatable: true, Description: "repeatable redirect URI"}, {Name: "scope", Repeatable: true, Description: "repeatable scope"}, {Name: "mode", Default: "slow", Description: "scalar, unaffected by D-19"}, }, Run: func(ctx context.Context, in Input, out Output) error { gotRedirect = in.Flags("redirect-uri") gotScope = in.Flags("scope") gotMode, gotModeOK = in.Flag("mode") return nil }, }}, strings.NewReader(""), &buf, &buf) if err != nil { t.Fatal(err) } root.SetArgs([]string{ "demo:repeat", "--redirect-uri=https://a.example/callback", "--redirect-uri=https://b.example/callback", "--scope=read", "--mode=fast", }) if err := root.Execute(); err != nil { t.Fatal(err) } wantRedirect := []string{"https://a.example/callback", "https://b.example/callback"} if !slices.Equal(gotRedirect, wantRedirect) { t.Fatalf("PHASE8_RED:bonfire-flags: repeated --redirect-uri via Input.Flags = %v, want %v in insertion order", gotRedirect, wantRedirect) } if !slices.Equal(gotScope, []string{"read"}) { t.Fatalf("PHASE8_RED:bonfire-flags: repeated --scope via Input.Flags = %v, want [read]", gotScope) } if !gotModeOK || gotMode != "fast" { t.Fatalf("PHASE8_RED:bonfire-flags: existing scalar --mode via Input.Flag = (%q, %v), want (\"fast\", true) unaffected by Repeatable", gotMode, gotModeOK) } } // TestRepeatableFlagUnsetReturnsEmpty proves an unset Repeatable flag comes // back as an empty/nil slice, not an error or a panic (D-19 GREEN // companion to TestPhase8RedBonfireFlags). func TestRepeatableFlagUnsetReturnsEmpty(t *testing.T) { var buf bytes.Buffer var got []string root, err := NewRootIO("app", []Command{{ Name: "demo:unset", Flags: []Flag{ {Name: "scope", Repeatable: true}, }, Run: func(ctx context.Context, in Input, out Output) error { got = in.Flags("scope") return nil }, }}, strings.NewReader(""), &buf, &buf) if err != nil { t.Fatal(err) } root.SetArgs([]string{"demo:unset"}) if err := root.Execute(); err != nil { t.Fatal(err) } if len(got) != 0 { t.Fatalf("unset repeatable flag = %v, want empty", got) } } // TestRepeatableFlagCoexistsWithBareAndScalar proves a Repeatable flag, // a Bare flag and a scalar flag on the same command parse independently // (D-19 acceptance criteria: existing scalar/bare callers stay unaffected). func TestRepeatableFlagCoexistsWithBareAndScalar(t *testing.T) { var buf bytes.Buffer var gotRedirect []string var gotList, gotListOK bool var gotName string root, err := NewRootIO("app", []Command{{ Name: "demo:mixed", Flags: []Flag{ {Name: "redirect-uri", Repeatable: true}, {Name: "list", Bare: true}, {Name: "name", Default: ""}, }, Run: func(ctx context.Context, in Input, out Output) error { gotRedirect = in.Flags("redirect-uri") listVal, ok := in.Flag("list") gotList, gotListOK = listVal == "true", ok gotName, _ = in.Flag("name") return nil }, }}, strings.NewReader(""), &buf, &buf) if err != nil { t.Fatal(err) } root.SetArgs([]string{"demo:mixed", "--redirect-uri=https://x.example", "--list", "--name=Catalog"}) if err := root.Execute(); err != nil { t.Fatal(err) } if !slices.Equal(gotRedirect, []string{"https://x.example"}) { t.Fatalf("redirect-uri = %v", gotRedirect) } if !gotListOK || !gotList { t.Fatalf("list = (%v, %v), want (true, true)", gotList, gotListOK) } if gotName != "Catalog" { t.Fatalf("name = %q", gotName) } } func TestHelpUsesSharedAdapter(t *testing.T) { var buf bytes.Buffer root, err := NewRoot("hello", []Command{{ Name: "greeter:hello", Description: "Print the configured application name", }}, &buf) if err != nil { t.Fatal(err) } root.SetArgs([]string{"greeter:hello", "--help"}) if err := root.Execute(); err != nil { t.Fatal(err) } got := buf.String() if !strings.Contains(got, "greeter:hello") || !strings.Contains(got, "Print the configured application name") { t.Fatalf("help = %q", got) } }