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
This commit is contained in:
297
modules/bonfire/widgets_test.go
Normal file
297
modules/bonfire/widgets_test.go
Normal file
@@ -0,0 +1,297 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user