Files
summercms/modules/bonfire/live_test.go
Jakub Zych b077dc3598 fix(bonfire): keep the order of lines printed above a live view and drain them before it ends
- each PrintAbove block is one bubbletea print message sent straight to
  the program's queue under liveMu, so the event loop prints it in call
  order instead of a command goroutine racing it back
- runLive unregisters, sends finishMsg and waits under liveMu, so a late
  printer writes after the final frame and no line is lost
- TestPrintAboveOrderedNoLoss pins order and no loss for spinner,
  progress, process and a printer racing the finish (CR-01)
2026-10-22 02:07:45 +02:00

430 lines
13 KiB
Go

package bonfire
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"regexp"
"strings"
"sync"
"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) {
// A printed block is one message for the program's queue, not a
// command bubbletea would run on a goroutine of its own.
msg := printLine("first\nsecond")
if msg == nil {
t.Fatal("printLine returned no message")
}
if _, isCmd := msg.(tea.Cmd); isCmd {
t.Error("printLine returned a command")
}
})
t.Run("program end to end", func(t *testing.T) {
var buf bytes.Buffer
prog := tea.NewProgram(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(printLine("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)
}
}
})
}
// lockedBuffer is a bytes.Buffer safe for a live program and other
// goroutines writing at the same time.
type lockedBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
func (b *lockedBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *lockedBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
var numberedLine = regexp.MustCompile(`(line|bg)-\d{3}`)
// numbered returns the numbered lines of the ANSI-stripped output in the
// order they appear.
func numbered(out string) []string {
return numberedLine.FindAllString(ansi.Strip(out), -1)
}
// printNumbered prints line-<from> .. line-<to-1> through the console's
// print paths in turn: Println, Printf, Info and bonfire.PrintAbove.
func printNumbered(c *console, from, to int) {
for i := from; i < to; i++ {
line := fmt.Sprintf("line-%03d", i)
switch i % 4 {
case 0:
c.Println(line)
case 1:
c.Printf("%s\n", line)
case 2:
c.Info(line)
case 3:
if !PrintAbove(line) {
c.Println(line)
}
}
}
}
// checkNumbered fails unless out holds line-000 .. line-<n-1> exactly once
// and in order.
func checkNumbered(t *testing.T, run int, out string, n int) {
t.Helper()
got := numbered(out)
for i := 0; i < n; i++ {
want := fmt.Sprintf("line-%03d", i)
if i >= len(got) {
t.Fatalf("run %d: %d of %d lines printed, %s missing:\n%s", run, len(got), n, want, ansi.Strip(out))
}
if got[i] != want {
t.Fatalf("run %d: line %d is %s, want %s (lines: %v)", run, i, got[i], want, got)
}
}
if len(got) != n {
t.Fatalf("run %d: %d lines printed, want %d: %v", run, len(got), n, got)
}
}
// TestPrintAboveOrderedNoLoss (CR-01): every line printed while a spinner,
// progress or process view runs appears once and in order; a line printed
// from another goroutine while the view finishes is not lost.
func TestPrintAboveOrderedNoLoss(t *testing.T) {
const runs, lines = 20, 100
newTTY := func(out io.Writer) *console {
return newConsole(strings.NewReader(""), out, io.Discard, noEnv, boolPtr(true), boolPtr(true))
}
t.Run("spinner", func(t *testing.T) {
for run := range runs {
out := &lockedBuffer{}
c := newTTY(out)
if err := c.Spinner("working", func() error {
printNumbered(c, 0, lines)
return nil
}); err != nil {
t.Fatal(err)
}
checkNumbered(t, run, out.String(), lines)
}
})
t.Run("progress", func(t *testing.T) {
for run := range runs {
out := &lockedBuffer{}
c := newTTY(out)
if err := c.Progress(lines, func(p Progress) error {
printNumbered(c, 0, 40)
Failed(p, "item-a", errors.New("first failure"))
printNumbered(c, 40, 80)
Failed(p, "item-b", errors.New("second failure"))
printNumbered(c, 80, lines)
return nil
}); err != nil {
t.Fatal(err)
}
got := out.String()
checkNumbered(t, run, got, lines)
for _, want := range []string{"item-a: first failure", "item-b: second failure"} {
if !strings.Contains(ansi.Strip(got), want) {
t.Fatalf("run %d: failure line %q missing:\n%s", run, want, ansi.Strip(got))
}
}
}
})
t.Run("process", func(t *testing.T) {
items := []string{"a", "b", "c", "d"}
for run := range runs {
out := &lockedBuffer{}
c := newTTY(out)
next := 0
err := Process(context.Background(), c, "Pushing", items, func(context.Context, string) error {
printNumbered(c, next, next+lines/len(items))
next += lines / len(items)
return nil
})
if err != nil {
t.Fatal(err)
}
checkNumbered(t, run, out.String(), lines)
}
})
t.Run("concurrent_finish", func(t *testing.T) {
const bg = 200
for run := range runs {
out := &lockedBuffer{}
c := newTTY(out)
started := make(chan struct{})
done := make(chan struct{})
if err := c.Spinner("working", func() error {
go func() {
defer close(done)
for i := range bg {
line := fmt.Sprintf("bg-%03d", i)
if !PrintAbove(line) {
_, _ = io.WriteString(out, line+"\n")
}
if i == 5 {
close(started)
}
}
}()
<-started
return nil
}); err != nil {
t.Fatal(err)
}
<-done
count := map[string]int{}
for _, l := range numbered(out.String()) {
count[l]++
}
for i := range bg {
l := fmt.Sprintf("bg-%03d", i)
if count[l] != 1 {
t.Fatalf("run %d: %s printed %d times:\n%s", run, l, count[l], ansi.Strip(out.String()))
}
}
}
})
}