Files
summercms/modules/bonfire/live_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

256 lines
8.6 KiB
Go

package bonfire
import (
"bytes"
"errors"
"fmt"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/colorprofile"
"github.com/charmbracelet/x/ansi"
)
// TestLiveModels: the spinner, progress, process and selector models
// render the right frames through Update and View, without a terminal; one
// program runs end to end into a buffer with input off.
func TestLiveModels(t *testing.T) {
p := DefaultPalette()
size := tea.WindowSizeMsg{Width: 80, Height: 24}
t.Run("spinner", func(t *testing.T) {
var m tea.Model = newSpinnerModel(p, "Syncing")
if m.Init() == nil {
t.Fatal("spinner does not tick")
}
first := frame(m)
m, cmd := m.Update(m.(spinnerModel).spin.Tick())
if cmd == nil || frame(m) == first {
t.Errorf("tick did not advance the spinner: %q -> %q", first, frame(m))
}
m, _ = m.Update(itemMsg{done: 1, total: 3, item: "post-1"})
m, _ = m.Update(itemMsg{done: 2, total: 3, item: "post-2", err: errors.New("x")})
v := frame(m)
for _, want := range []string{"Syncing 2/3", "post-2…", "! 1 failed"} {
if !strings.Contains(v, want) {
t.Errorf("spinner frame lacks %q: %q", want, v)
}
}
done, cmd := m.Update(finishMsg{})
if cmd == nil || frame(done) != symCheck+" Syncing\n" {
t.Errorf("success frame = %q", frame(done))
}
failed, _ := m.Update(finishMsg{err: errors.New("no")})
if frame(failed) != symCross+" Syncing\n" {
t.Errorf("failure frame = %q", frame(failed))
}
})
t.Run("progress", func(t *testing.T) {
var m tea.Model = newProgressModel(p, 10)
if m.Init() != nil {
t.Error("progress has an init command")
}
m, _ = m.Update(tea.WindowSizeMsg{Width: 20, Height: 5})
m, _ = m.Update(itemMsg{done: 0, total: 10})
if v := frame(m); !strings.Contains(v, "0/10") {
t.Errorf("empty bar = %q", v)
}
m, _ = m.Update(itemMsg{done: 4, total: 10, item: "row 4"})
m, _ = m.Update(itemMsg{done: 5, total: 10})
if v := frame(m); !strings.Contains(v, "5/10") || !strings.Contains(v, "row 4") {
t.Errorf("Current kept across steps: %q", v)
}
m, _ = m.Update(itemMsg{done: 5, total: 10, item: "row 5", err: errors.New("bad")})
if v := frame(m); !strings.Contains(v, "! 1 failed") || !strings.Contains(v, "row 5") {
t.Errorf("Fail frame = %q", v)
}
m, cmd := m.Update(finishMsg{})
v := frame(m)
if cmd == nil || strings.Contains(v, "row 5") || !strings.HasSuffix(v, "\n") {
t.Errorf("final frame = %q", v)
}
zero := newProgressModel(p, 0)
if v := frame(zero); !strings.Contains(v, "0/0") {
t.Errorf("zero total frame = %q", v)
}
})
t.Run("process", func(t *testing.T) {
var m tea.Model = newProcessModel(p, "", []string{"blog", "shop", "forum"})
m, _ = m.Update(size)
v := frame(m)
for _, name := range []string{"blog", "shop", "forum"} {
if !strings.Contains(v, symWaiting+" "+name) {
t.Errorf("pending row %s missing: %q", name, v)
}
}
if !strings.Contains(v, "Processing 0/3") || !strings.Contains(v, "0%") {
t.Errorf("default operation label missing: %q", v)
}
m, _ = m.Update(stepMsg{item: "blog", state: itemRunning, done: 0})
if v := frame(m); strings.Contains(v, symWaiting+" blog") {
t.Errorf("running row still pending: %q", v)
}
m, _ = m.Update(stepMsg{item: "blog", state: itemDone, done: 1})
m, _ = m.Update(stepMsg{item: "blog", state: itemRunning, done: 1})
m, _ = m.Update(stepMsg{item: "shop", state: itemFailed, err: errors.New("rejected"), done: 2})
v = frame(m)
for _, want := range []string{symCheck + " blog", symCross + " shop rejected", symWaiting + " forum", "Processing 2/3", "66%"} {
if !strings.Contains(v, want) {
t.Errorf("process frame lacks %q:\n%s", want, v)
}
}
m, cmd := m.Update(finishMsg{})
if cmd == nil || !strings.HasSuffix(frame(m), "\n") {
t.Errorf("final process frame = %q", frame(m))
}
// Ten rows on a six-line terminal: three rows around the running one.
var items []string
for i := range 10 {
items = append(items, fmt.Sprintf("item-%d", i))
}
var big tea.Model = newProcessModel(p, "Pushing", items)
big, _ = big.Update(tea.WindowSizeMsg{Width: 80, Height: 6})
big, _ = big.Update(stepMsg{item: "item-6", state: itemRunning, done: 6})
v = frame(big)
for _, want := range []string{"item-5", "item-6", "item-7"} {
if !strings.Contains(v, want) {
t.Errorf("window lacks %s:\n%s", want, v)
}
}
for _, gone := range []string{"item-0", "item-4", "item-8"} {
if strings.Contains(v, gone+"\n") || strings.Contains(v, gone+" ") {
t.Errorf("window shows %s:\n%s", gone, v)
}
}
})
t.Run("selector", func(t *testing.T) {
key := func(m tea.Model, msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m.Update(msg) }
up, down := tea.KeyPressMsg{Code: tea.KeyUp}, tea.KeyPressMsg{Code: tea.KeyDown}
enter, esc := tea.KeyPressMsg{Code: tea.KeyEnter}, tea.KeyPressMsg{Code: tea.KeyEscape}
ctrlC := tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}
var m tea.Model = newSelectorModel(p, "Pick a plugin", []string{"blog", "shop", "forum"}, 1)
m, _ = m.Update(size)
if v := frame(m); !strings.Contains(v, "> shop") || !strings.Contains(v, "? Pick a plugin") {
t.Errorf("default cursor frame = %q", v)
}
m, _ = key(m, up)
m, _ = key(m, up)
m, _ = key(m, tea.KeyPressMsg{Code: 'k', Text: "k"})
if c := m.(selectorModel).cursor; c != 0 {
t.Errorf("cursor moved above the top: %d", c)
}
for range 5 {
m, _ = key(m, down)
}
if c := m.(selectorModel).cursor; c != 2 {
t.Errorf("cursor moved below the bottom: %d", c)
}
m, _ = key(m, enter)
if v := frame(m); !strings.Contains(v, "Selected:") || !strings.Contains(v, "forum") || !strings.Contains(v, "esc back") {
t.Errorf("confirmation frame = %q", v)
}
m, _ = key(m, esc)
if m.(selectorModel).confirming || m.(selectorModel).cancelled {
t.Error("esc on the confirmation screen did not go back")
}
m, _ = key(m, tea.KeyPressMsg{Code: 'k', Text: "k"})
m, _ = key(m, enter)
m, cmd := key(m, enter)
sel := m.(selectorModel)
if cmd == nil || sel.chosen != 1 || sel.cancelled {
t.Fatalf("selector chose %d (cancelled %v)", sel.chosen, sel.cancelled)
}
if v := frame(m); v != "? Pick a plugin shop\n" {
t.Errorf("summary line = %q", v)
}
for name, keys := range map[string][]tea.KeyPressMsg{
"esc in the list": {esc},
"ctrl+c in the list": {ctrlC},
"ctrl+c on confirmation": {enter, ctrlC},
} {
var s tea.Model = newSelectorModel(p, "Pick", []string{"a", "b"}, 9)
var cmd tea.Cmd
for _, k := range keys {
s, cmd = key(s, k)
}
if !s.(selectorModel).cancelled || cmd == nil || s.(selectorModel).chosen != -1 {
t.Errorf("%s: not cancelled", name)
}
if v := frame(s); v != "? Pick\n" {
t.Errorf("%s: summary line = %q", name, v)
}
}
// A short terminal shows a window that follows the cursor.
var long []string
for i := range 20 {
long = append(long, fmt.Sprintf("choice-%02d", i))
}
var w tea.Model = newSelectorModel(p, "Pick", long, 0)
w, _ = w.Update(tea.WindowSizeMsg{Width: 80, Height: 8})
for range 10 {
w, _ = key(w, down)
}
v := frame(w)
if !strings.Contains(v, "> choice-10") || strings.Contains(v, "choice-00") || strings.Contains(v, "choice-11") {
t.Errorf("selector window:\n%s", v)
}
})
t.Run("print above", func(t *testing.T) {
var m tea.Model = liveModel{inner: newSpinnerModel(p, "x")}
before := frame(m)
m, cmd := m.Update(printAboveMsg("a log line"))
if cmd == nil || frame(m) != before {
t.Error("printAboveMsg did not become a Println command or reached the inner model")
}
m, _ = m.Update(itemMsg{done: 1, total: 2})
if !strings.Contains(frame(m), "1/2") {
t.Errorf("other messages do not reach the inner model: %q", frame(m))
}
})
t.Run("program end to end", func(t *testing.T) {
var buf bytes.Buffer
prog := tea.NewProgram(liveModel{inner: newSpinnerModel(p, "Importing posts")},
tea.WithInput(nil),
tea.WithOutput(&buf),
tea.WithoutSignalHandler(),
tea.WithWindowSize(80, 24),
tea.WithColorProfile(colorprofile.NoTTY),
)
done := make(chan error, 1)
go func() {
_, err := prog.Run()
done <- err
}()
prog.Send(itemMsg{done: 1, total: 2, item: "first"})
prog.Send(printAboveMsg("imported first"))
prog.Send(itemMsg{done: 2, total: 2, item: "second"})
prog.Send(finishMsg{})
select {
case err := <-done:
if err != nil {
t.Fatalf("program: %v", err)
}
case <-time.After(10 * time.Second):
prog.Kill()
t.Fatal("program did not finish")
}
out := ansi.Strip(buf.String())
for _, want := range []string{"imported first", symCheck + " Importing posts"} {
if !strings.Contains(out, want) {
t.Errorf("program output lacks %q:\n%q", want, out)
}
}
})
}