Files
summercms/bonfire/output_test.go
Jakub Zych dc7997e45c test(01-04): cover tool, output, watch loop and workspace modules
- Non-TTY widgets/prompts, flag parsing, secret non-leak and malicious IDs
- Real hello workspace rebuild latency line, debounce and ignored bin/tmp
- scripts/check-phase1.sh runs vet/test/race across root, hello, base, greeter, optional
2026-09-16 14:14:05 +02:00

265 lines
7.5 KiB
Go

package bonfire
import (
"bytes"
"context"
"errors"
"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)
}
}
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)
}
}