fix(bonfire): reroute only the owning console's lines to a live view

- the registered view keeps the console that started it; Println, Printf
  and status lines go above it only from that console, other consoles
  write to their own writers (bonfire.PrintAbove still reaches any view)
- runLive claims the single live slot by check and set under liveMu and
  reports when it could not, so the widget prints plain lines
- unregister, finish and wait run deferred, so a panic in work leaves no
  registration behind and still propagates (WR-02)
This commit is contained in:
Jakub Zych
2026-10-22 02:10:48 +02:00
parent b077dc3598
commit 86e4e270d3
6 changed files with 200 additions and 56 deletions

View File

@@ -427,3 +427,106 @@ func TestPrintAboveOrderedNoLoss(t *testing.T) {
}
})
}
// 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()))
}
}