Files
summercms/modules/bonfire/widgets_test.go
Jakub Zych eeeee59ead test(bonfire): flags, exit codes and the widget matrix
- PeekGlobals table, Args round trip, hook and PeekGlobals agree (clusters too)
- reserved flags, duplicate names, Call runs no OnGlobals hook
- exit codes 0/1/2, Reported silence, scrubbed errors, wrong-directory hint
- widget matrix: not a terminal byte for byte, -q, --no-ansi, -n, terminal
- live models through Update and View and one program run into a buffer
- prompts under -n, Secret from piped stdin, ConfirmProceed, PrintAbove
2026-10-21 19:22:00 +02:00

298 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package bonfire
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"github.com/charmbracelet/x/ansi"
)
// noEnv is a lookup with no environment variables set.
func noEnv(string) string { return "" }
// exerciseWidgets runs every widget once on c: status lines, a spinner, a
// progress bar of four steps, the process view over a, b (failing) and c,
// a table, a data line and an error.
func exerciseWidgets(t *testing.T, c *console) {
t.Helper()
c.Info("info line")
c.Success("success line")
c.Warning("warning line")
if err := c.Spinner("spinning", func() error { return nil }); err != nil {
t.Fatalf("Spinner: %v", err)
}
if err := c.Progress(4, func(p Progress) error {
for range 4 {
p.Advance(1)
}
return nil
}); err != nil {
t.Fatalf("Progress: %v", err)
}
err := Process(context.Background(), c, "Importing", []string{"a", "b", "c"}, func(_ context.Context, item string) error {
if item == "b" {
return errors.New("bad row")
}
return nil
})
if err == nil || !strings.Contains(err.Error(), "b: bad row") {
t.Fatalf("Process err = %v", err)
}
c.Table([]string{"key", "value"}, [][]string{{"k1", "v1"}, {"k2"}})
c.Println("data line")
c.Error("boom")
}
// TestWidgetMatrix: every widget degrades by mode. Not a terminal gives
// the pre-phase plain lines byte for byte; -q keeps data and errors only;
// --no-ansi and -n on a terminal print plain lines (no live view), and
// --no-ansi writes no escape codes; a terminal runs the live views.
func TestWidgetMatrix(t *testing.T) {
type streams struct{ out, err bytes.Buffer }
newC := func(s *streams, tty bool, g Globals) *console {
c := newConsole(strings.NewReader(""), &s.out, &s.err, noEnv, boolPtr(tty), boolPtr(tty))
c.setGlobals(g)
return c
}
t.Run("not a terminal", func(t *testing.T) {
var s streams
exerciseWidgets(t, newC(&s, false, Globals{}))
wantOut := "ℹ info line\n✓ success line\n⚠ warning line\n" +
"[...] spinning\n" +
"[2/4] 50%\n[4/4] 100%\n" +
"✓ a\n✓ c\n" +
"key\tvalue\nk1\tv1\nk2\t\n" +
"data line\n"
if got := s.out.String(); got != wantOut {
t.Errorf("stdout:\n got %q\nwant %q", got, wantOut)
}
if got, want := s.err.String(), "✗ b: bad row\n✗ boom\n"; got != want {
t.Errorf("stderr:\n got %q\nwant %q", got, want)
}
})
t.Run("quiet", func(t *testing.T) {
for _, tty := range []bool{false, true} {
var s streams
exerciseWidgets(t, newC(&s, tty, Globals{Quiet: true, NoInteraction: true}))
out := ansi.Strip(s.out.String())
for _, gone := range []string{"info line", "success line", "warning line", "spinning", "[4/4]", "4/4", "✓ a", "Importing"} {
if strings.Contains(out, gone) {
t.Errorf("tty %v: -q printed %q:\n%s", tty, gone, out)
}
}
for _, kept := range []string{"k1", "v1", "data line\n"} {
if !strings.Contains(out, kept) {
t.Errorf("tty %v: -q dropped data %q:\n%s", tty, kept, out)
}
}
errOut := ansi.Strip(s.err.String())
if !strings.Contains(errOut, "✗ b: bad row") || !strings.Contains(errOut, "✗ boom") {
t.Errorf("tty %v: -q dropped errors: %q", tty, errOut)
}
}
})
t.Run("no-ansi on a terminal", func(t *testing.T) {
var s streams
exerciseWidgets(t, newC(&s, true, Globals{Color: ColorNever}))
out := s.out.String()
if strings.Contains(out, "\x1b") || strings.Contains(s.err.String(), "\x1b") {
t.Errorf("--no-ansi wrote escape codes:\n%q\n%q", out, s.err.String())
}
for _, want := range []string{"ℹ info line\n", "[...] spinning\n", "[2/4] 50%\n", "✓ a\n", "data line\n", "┌"} {
if !strings.Contains(out, want) {
t.Errorf("--no-ansi lacks %q:\n%s", want, out)
}
}
})
t.Run("no-interaction on a terminal", func(t *testing.T) {
var s streams
exerciseWidgets(t, newC(&s, true, Globals{NoInteraction: true}))
out := s.out.String()
if strings.Contains(out, "\x1b[?25l") {
t.Errorf("-n ran a live view (cursor hidden):\n%q", out)
}
plain := ansi.Strip(out)
for _, want := range []string{"ℹ info line\n", "[...] spinning\n", "[4/4] 100%\n", "✓ a\n", "data line\n"} {
if !strings.Contains(plain, want) {
t.Errorf("-n lacks %q:\n%s", want, plain)
}
}
if !strings.Contains(out, "\x1b[") {
t.Errorf("-n on a color terminal lost its colors:\n%q", out)
}
})
t.Run("terminal", func(t *testing.T) {
var s streams
c := newC(&s, true, Globals{})
if !c.live() {
t.Fatal("a terminal console without flags does not animate")
}
exerciseWidgets(t, c)
// The live views ran as bubbletea programs (the renderer hides the
// cursor). A buffer has no window size, so the renderer draws no
// frames here; TestLiveModels renders them with a fixed size.
if n := strings.Count(s.out.String(), "\x1b[?25l"); n != 3 {
t.Errorf("want 3 live views (spinner, progress, process), cursor hidden %d times:\n%q", n, s.out.String())
}
out := ansi.Strip(s.out.String())
for _, want := range []string{"ℹ info line", "┌", "k1", "data line"} {
if !strings.Contains(out, want) {
t.Errorf("terminal output lacks %q:\n%s", want, out)
}
}
if strings.Contains(out, "[...] spinning") || strings.Contains(out, "[2/4] 50%") || strings.Contains(out, "✓ a\n") {
t.Errorf("terminal printed the plain fallback lines:\n%s", out)
}
if !strings.Contains(ansi.Strip(s.err.String()), "✗ boom") {
t.Errorf("terminal error line missing: %q", s.err.String())
}
})
}
// TestPromptsNoInteraction: with -n or -q every prompt returns its default
// without reading stdin or printing, even on an interactive terminal.
func TestPromptsNoInteraction(t *testing.T) {
for _, g := range []Globals{{NoInteraction: true}, {Quiet: true, NoInteraction: true}} {
in := strings.NewReader("typed\n")
var buf bytes.Buffer
c := newConsole(in, &buf, &buf, noEnv, boolPtr(true), boolPtr(true))
c.setGlobals(g)
if got, err := c.Ask("name", "bob"); err != nil || got != "bob" {
t.Errorf("%+v: Ask = %q, %v", g, got, err)
}
for _, def := range []bool{true, false} {
if got, err := c.Confirm("sure?", def); err != nil || got != def {
t.Errorf("%+v: Confirm(def %v) = %v, %v", g, def, got, err)
}
}
choices := []string{"a", "b", "c"}
for def, want := range map[int]string{0: "a", 2: "c", 5: "a", -1: "a"} {
if got, err := c.Choice("pick", choices, def); err != nil || got != want {
t.Errorf("%+v: Choice(def %d) = %q, %v; want %q", g, def, got, err, want)
}
}
if _, err := c.Choice("pick", nil, 0); err == nil {
t.Errorf("%+v: Choice without options did not fail", g)
}
if in.Len() != len("typed\n") || buf.Len() != 0 {
t.Errorf("%+v: prompts read stdin or printed: left %d, out %q", g, in.Len(), buf.String())
}
if Interactive(c) {
t.Errorf("%+v: Interactive is true", g)
}
}
}
// TestSecretWithNoInteraction: under -n a secret is read from piped stdin
// (empty on EOF); with a terminal stdin it fails with a hint naming the
// piped form, because a secret has no default.
func TestSecretWithNoInteraction(t *testing.T) {
var buf bytes.Buffer
c := newConsole(strings.NewReader(" s3cret-value \nnext\n"), &buf, &buf, noEnv, boolPtr(false), boolPtr(false))
c.setGlobals(Globals{NoInteraction: true})
if got, err := c.Secret("password"); err != nil || got != "s3cret-value" {
t.Fatalf("piped Secret = %q, %v", got, err)
}
if buf.Len() != 0 {
t.Errorf("piped Secret printed a prompt: %q", buf.String())
}
empty := newConsole(strings.NewReader(""), &buf, &buf, noEnv, boolPtr(false), boolPtr(false))
empty.setGlobals(Globals{NoInteraction: true})
if got, err := empty.Secret("password"); err != nil || got != "" {
t.Errorf("Secret on empty stdin = %q, %v", got, err)
}
term := newConsole(strings.NewReader("never read\n"), &buf, &buf, noEnv, boolPtr(true), boolPtr(true))
term.setGlobals(Globals{NoInteraction: true})
term.inTTY = true
_, err := term.Secret("password")
var hint *HintError
if !errors.As(err, &hint) || !errors.Is(err, errSecretNoInteraction) {
t.Fatalf("terminal Secret under -n: %v", err)
}
var block bytes.Buffer
RenderError(&block, "acme", err, Globals{Color: ColorNever})
if !strings.Contains(block.String(), `printf '%s\n' "$VALUE" | bin/acme <command> -n`) {
t.Errorf("hint does not show the piped form:\n%s", block.String())
}
}
// TestConfirmProceed: off a terminal, under -n and for an Output that is
// not a bonfire console it proceeds without reading stdin; on an
// interactive terminal it asks, defaulting to no.
func TestConfirmProceed(t *testing.T) {
in := strings.NewReader("n\n")
var buf bytes.Buffer
for name, c := range map[string]*console{
"not a terminal": newConsole(in, &buf, &buf, noEnv, boolPtr(false), boolPtr(false)),
"-n": newConsole(in, &buf, &buf, noEnv, boolPtr(true), boolPtr(true)),
} {
if name == "-n" {
c.setGlobals(Globals{NoInteraction: true})
}
if ok, err := ConfirmProceed(c, "Drop every table?"); err != nil || !ok {
t.Errorf("%s: ConfirmProceed = %v, %v", name, ok, err)
}
}
if in.Len() != 2 || buf.Len() != 0 {
t.Errorf("ConfirmProceed read stdin or printed: left %d, out %q", in.Len(), buf.String())
}
if ok, err := ConfirmProceed(struct{ Output }{}, "Drop?"); err != nil || !ok {
t.Errorf("foreign Output: ConfirmProceed = %v, %v", ok, err)
}
for answer, want := range map[string]bool{"n\n": false, "y\n": true, "yes\n": true, "\n": false, "": false} {
var out bytes.Buffer
c := newConsole(strings.NewReader(answer), &out, &out, noEnv, boolPtr(true), boolPtr(true))
// --no-ansi keeps the line prompt on a terminal (huh needs a real
// terminal).
c.setGlobals(Globals{Color: ColorNever})
ok, err := ConfirmProceed(c, "Drop every table?")
if err != nil || ok != want {
t.Errorf("answer %q: ConfirmProceed = %v, %v; want %v", answer, ok, err, want)
}
if !strings.Contains(out.String(), "Drop every table? [y/N]") {
t.Errorf("answer %q: question not asked: %q", answer, out.String())
}
}
}
// TestPrintAbove: without a live view PrintAbove reports false; while one
// runs, the line (and Println output) prints above it.
func TestPrintAbove(t *testing.T) {
if PrintAbove("nobody listens") {
t.Fatal("PrintAbove reported a running view")
}
var out, errW bytes.Buffer
c := newConsole(strings.NewReader(""), &out, &errW, noEnv, boolPtr(true), boolPtr(true))
var during bool
err := c.Spinner("working", func() error {
during = PrintAbove("log line above the view")
c.Println("println above the view")
return nil
})
if err != nil || !during {
t.Fatalf("Spinner err %v, PrintAbove during the view %v", err, during)
}
if PrintAbove("after") {
t.Error("PrintAbove reported a view after it ended")
}
got := ansi.Strip(out.String())
for _, want := range []string{"log line above the view", "println above the view"} {
if !strings.Contains(got, want) {
t.Errorf("output lacks %q:\n%s", want, got)
}
}
if strings.Contains(got, "[...] working") {
t.Errorf("the spinner did not run as a live view:\n%s", got)
}
}