- bonfire.wrap registers a Repeatable Flag as a Cobra StringSlice so Input.Flags returns every repeated --name=value occurrence in order; scalar/bare flags are unaffected (D-19) - wristband.IssueClientCredentials/RejectRedirectURI export the exact random-id/secret/hash and redirect-URI validation RFC 7591 registration already uses, so the fonoteka:oauth-client operator command shares one hash/validation path with DCR (T-08-SECRET-TIMING)
390 lines
12 KiB
Go
390 lines
12 KiB
Go
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)
|
|
}
|
|
}
|