Files
summercms/modules/bonfire/widgets_smoke_test.go
Jakub Zych 3f38862b22 feat(bonfire): SSU widgets on charm v2 behind the Output interface
- Spinner, gradient progress bar and the multi-row process view ported
  from SSU to bubbletea v2 (input off, no signal handler, command
  context); boxed tables through lipgloss/table
- Prompts use huh fields and a single-select port of SSU's selector with
  its confirmation screen on an interactive terminal; line prompts off a
  terminal and under --no-ansi are unchanged (piped passwords still work)
- -q hides decoration but keeps data and errors; -n returns defaults and
  Secret fails with a hint on a terminal stdin; --no-ansi and non-TTY
  runs print today's plain lines
- Helpers: GlobalsOf, VerbosityOf, IsQuiet, Interactive, ConfirmProceed,
  Process, Current, Failed, PrintAbove, ItemProgress, ErrAborted
- diary's styled console lines print above a running live view
2026-10-21 12:30:53 +02:00

185 lines
6.5 KiB
Go

package bonfire
import (
"bytes"
"context"
"errors"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
)
// frame is a model's view without escape codes.
func frame(m tea.Model) string { return ansi.Strip(m.View().Content) }
// TestWidgetMatrixSmoke: -q keeps data and errors but drops decoration, -n
// makes prompts return their defaults without reading stdin (Secret with a
// terminal stdin fails with a hint), ConfirmProceed never asks off a
// terminal, and the ported live models render frames from Update and View.
func TestWidgetMatrixSmoke(t *testing.T) {
t.Run("quiet", func(t *testing.T) {
var stdout, stderr bytes.Buffer
c := newConsole(strings.NewReader(""), &stdout, &stderr, func(string) string { return "" }, boolPtr(true), boolPtr(false))
c.setGlobals(Globals{Quiet: true, NoInteraction: true})
c.Info("info")
c.Success("success")
c.Warning("warning")
if err := c.Spinner("spinning", func() error { return nil }); err != nil {
t.Fatal(err)
}
if err := c.Progress(4, func(p Progress) error {
for range 4 {
p.Advance(1)
}
Failed(p, "item-2", errors.New("broken"))
return nil
}); err != nil {
t.Fatal(err)
}
c.Println("data line")
c.Table([]string{"key"}, [][]string{{"value"}})
c.Error("boom")
out := stdout.String()
for _, gone := range []string{"info", "success", "warning", "spinning", "[4/4]"} {
if strings.Contains(out, gone) {
t.Fatalf("-q printed %q:\n%s", gone, out)
}
}
if !strings.Contains(out, "data line\n") || !strings.Contains(out, "value") {
t.Fatalf("-q dropped data:\n%s", out)
}
if !strings.Contains(stderr.String(), "boom") || !strings.Contains(stderr.String(), "item-2: broken") {
t.Fatalf("-q dropped errors: %q", stderr.String())
}
})
t.Run("no-interaction", func(t *testing.T) {
in := strings.NewReader("typed\n")
var buf bytes.Buffer
c := newConsole(in, &buf, &buf, func(string) string { return "" }, boolPtr(true), boolPtr(true))
c.setGlobals(Globals{NoInteraction: true})
if got, err := c.Ask("name", "bob"); err != nil || got != "bob" {
t.Fatalf("Ask = %q, %v", got, err)
}
if got, err := c.Confirm("sure?", true); err != nil || !got {
t.Fatalf("Confirm = %v, %v", got, err)
}
if got, err := c.Choice("pick", []string{"a", "b", "c"}, 2); err != nil || got != "c" {
t.Fatalf("Choice = %q, %v", got, err)
}
if in.Len() != len("typed\n") || buf.Len() != 0 {
t.Fatalf("-n prompts read stdin or printed: left %d, out %q", in.Len(), buf.String())
}
if ok, err := ConfirmProceed(c, "Drop everything?"); err != nil || !ok {
t.Fatalf("ConfirmProceed = %v, %v", ok, err)
}
if Interactive(c) || !GlobalsOf(c).NoInteraction {
t.Fatal("console with -n reports interactive")
}
c.inTTY = true
_, err := c.Secret("password")
var hint *HintError
if !errors.As(err, &hint) || !strings.Contains(hint.Try, "-n") {
t.Fatalf("Secret with a terminal stdin under -n: %v", err)
}
c.inTTY = false
if got, err := c.Secret("password"); err != nil || got != "typed" {
t.Fatalf("Secret with piped stdin under -n = %q, %v", got, err)
}
})
t.Run("not interactive", func(t *testing.T) {
in := strings.NewReader("y\n")
var buf bytes.Buffer
out := NewOutput(in, &buf, &buf)
if ok, err := ConfirmProceed(out, "Drop everything?"); err != nil || !ok || in.Len() != 2 {
t.Fatalf("ConfirmProceed off a terminal = %v, %v (stdin left %d)", ok, err, in.Len())
}
if VerbosityOf(out) != 0 || IsQuiet(out) {
t.Fatal("zero globals expected")
}
var lines bytes.Buffer
errOut := NewOutput(strings.NewReader(""), &lines, &lines)
err := Process(context.Background(), errOut, "Importing", []string{"a", "b"}, 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)
}
if got := lines.String(); got != "✓ a\n✗ b: bad row\n" {
t.Fatalf("Process lines = %q", got)
}
})
size := tea.WindowSizeMsg{Width: 80, Height: 24}
p := DefaultPalette()
t.Run("spinner model", func(t *testing.T) {
var m tea.Model = newSpinnerModel(p, "Importing posts")
m, _ = m.Update(size)
m, _ = m.Update(itemMsg{done: 2, total: 5, item: "post-2"})
if v := frame(m); !strings.Contains(v, "Importing posts 2/5") || !strings.Contains(v, "post-2") {
t.Fatalf("spinner frame = %q", v)
}
m, cmd := m.Update(finishMsg{})
if cmd == nil || !strings.Contains(frame(m), symCheck+" Importing posts") {
t.Fatalf("spinner final frame = %q", frame(m))
}
})
t.Run("progress model", func(t *testing.T) {
var m tea.Model = newProgressModel(p, 10)
m, _ = m.Update(size)
m, _ = m.Update(itemMsg{done: 4, total: 10, item: "row 4"})
if v := frame(m); !strings.Contains(v, "4/10") || !strings.Contains(v, "row 4") {
t.Fatalf("progress frame = %q", v)
}
})
t.Run("process model", func(t *testing.T) {
var m tea.Model = newProcessModel(p, "Pushing", []string{"blog", "shop", "forum"})
m, _ = m.Update(size)
m, _ = m.Update(stepMsg{item: "blog", state: itemDone, done: 1})
m, _ = m.Update(stepMsg{item: "shop", state: itemFailed, err: errors.New("rejected"), done: 2})
m, _ = m.Update(stepMsg{item: "forum", state: itemRunning, done: 2})
v := frame(m)
for _, want := range []string{symCheck + " blog", symCross + " shop", "rejected", "forum", "Pushing 2/3"} {
if !strings.Contains(v, want) {
t.Fatalf("process frame missing %q:\n%s", want, v)
}
}
})
t.Run("selector model", func(t *testing.T) {
var m tea.Model = newSelectorModel(p, "Pick a plugin", []string{"blog", "shop", "forum"}, 0)
m, _ = m.Update(size)
if v := frame(m); !strings.Contains(v, "> blog") {
t.Fatalf("selector frame = %q", v)
}
m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
if v := frame(m); !strings.Contains(v, "Selected:") || !strings.Contains(v, "shop") {
t.Fatalf("confirmation frame = %q", v)
}
m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
m, _ = m.Update(tea.KeyPressMsg{Code: 'j', Text: "j"})
m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
m, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
sel := m.(selectorModel)
if cmd == nil || sel.chosen != 2 || sel.items[sel.chosen] != "forum" {
t.Fatalf("selector chose %d (cmd %v)", sel.chosen, cmd != nil)
}
var c tea.Model = newSelectorModel(p, "Pick", []string{"a"}, 0)
c, _ = c.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
if !c.(selectorModel).cancelled {
t.Fatal("esc did not cancel")
}
})
}