Files
summercms/modules/bonfire/widgets_test.go
Jakub Zych a9025c0d12 fix(bonfire): strip control characters from error blocks, process views and tables
- bonfire.Sanitize (additive), moved from diary's console channel: tab
  kept, CR and LF to a space, every other control character dropped
- applied before styling to every error-block line (message,
  explanation, Try lines, -v chain), process-view item names and errors,
  the current item of spinner and progress, failure lines, and table
  headers and cells in both layouts
- diary's console handler calls bonfire.Sanitize; README and the
  writing-commands page say so (WR-05)
2026-10-22 02:31:55 +02:00

321 lines
12 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)
}
}
// TestTableStripsControl (WR-05): table headers and cells carrying control
// sequences print without them, boxed with color on a terminal and as
// tab-separated lines off one.
func TestTableStripsControl(t *testing.T) {
headers := []string{"Name\x1b]0;pwned\x07", "Scope"}
rows := [][]string{{"token \x1b[2J one", "journal\x07.posts"}}
for _, tty := range []bool{true, false} {
var out bytes.Buffer
c := newConsole(strings.NewReader(""), &out, &out, noEnv, boolPtr(tty), boolPtr(tty))
c.setGlobals(Globals{Color: ColorAlways})
c.Table(headers, rows)
got := out.String()
if bad := carriesInjected(got); bad != "" {
t.Errorf("terminal %v: table carries %q:\n%q", tty, bad, got)
}
for _, want := range []string{"Name", "token", "one", "journal", "posts"} {
if !strings.Contains(got, want) {
t.Errorf("terminal %v: table lacks %q:\n%q", tty, want, got)
}
}
}
}