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- .. line- 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- 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())) } } } }) } // TestPrintlnStaysWithItsConsole (WR-02): while a view runs on console A, // another console's Println, Printf and Info go to its own writer; A's own // Println still prints above its view. func TestPrintlnStaysWithItsConsole(t *testing.T) { bufA, bufB := &lockedBuffer{}, &lockedBuffer{} a := newConsole(strings.NewReader(""), bufA, io.Discard, noEnv, boolPtr(true), boolPtr(true)) b := newConsole(strings.NewReader(""), bufB, bufB, noEnv, boolPtr(false), boolPtr(false)) err := a.Spinner("working", func() error { b.Println("b-println") b.Printf("b-printf %d\n", 1) b.Info("b-info") a.Println("a-println") return nil }) if err != nil { t.Fatal(err) } gotA, gotB := ansi.Strip(bufA.String()), ansi.Strip(bufB.String()) for _, want := range []string{"b-println", "b-printf 1", "b-info"} { if !strings.Contains(gotB, want) { t.Errorf("console B's writer lacks %q:\n%s", want, gotB) } if strings.Contains(gotA, want) { t.Errorf("console B's %q printed above console A's view:\n%s", want, gotA) } } if !strings.Contains(gotA, "a-println") { t.Errorf("console A's own line missing above its view:\n%s", gotA) } } // TestLiveViewSingleOwner (WR-02): two consoles starting a spinner at the // same moment never both run a live view; the one that loses prints its // plain line to its own writer. func TestLiveViewSingleOwner(t *testing.T) { const rounds = 200 for round := range rounds { bufs := [2]*lockedBuffer{{}, {}} var start, entered sync.WaitGroup start.Add(1) entered.Add(2) var wg sync.WaitGroup for i := range 2 { wg.Add(1) go func() { defer wg.Done() c := newConsole(strings.NewReader(""), bufs[i], io.Discard, noEnv, boolPtr(true), boolPtr(true)) start.Wait() _ = c.Spinner(fmt.Sprintf("label-%d", i), func() error { entered.Done() entered.Wait() // both work functions run at once return nil }) }() } start.Done() wg.Wait() plain := 0 for i, b := range bufs { if strings.Contains(ansi.Strip(b.String()), fmt.Sprintf("[...] label-%d", i)) { plain++ } } if plain != 1 { t.Fatalf("round %d: %d consoles printed their plain line to their own writer, want 1:\nA: %q\nB: %q", round, plain, ansi.Strip(bufs[0].String()), ansi.Strip(bufs[1].String())) } if PrintAbove("after the round") { t.Fatalf("round %d: a view is still registered", round) } } } // TestLiveViewPanicUnregisters (WR-02): a panic in the work function still // ends and unregisters the view; the next widget runs as a live view. func TestLiveViewPanicUnregisters(t *testing.T) { out := &lockedBuffer{} c := newConsole(strings.NewReader(""), out, io.Discard, noEnv, boolPtr(true), boolPtr(true)) func() { defer func() { if r := recover(); r != "boom" { t.Fatalf("recovered %v, want the work function's panic", r) } }() _ = c.Spinner("panicking", func() error { panic("boom") }) }() if PrintAbove("after the panic") { t.Fatal("the panicked view is still registered") } next := &lockedBuffer{} c2 := newConsole(strings.NewReader(""), next, io.Discard, noEnv, boolPtr(true), boolPtr(true)) var during bool if err := c2.Spinner("next", func() error { during = PrintAbove("above the next view") return nil }); err != nil { t.Fatal(err) } if !during || strings.Contains(ansi.Strip(next.String()), "[...] next") { t.Errorf("the next spinner did not run as a live view:\n%s", ansi.Strip(next.String())) } }