1package ui23import (4 "errors"5 "fmt"6 "os"7 "path/filepath"8 "strings"9 "testing"10 "time"1112 tea "charm.land/bubbletea/v2"13)1415// panicOn makes F12 panic where, as the crashtest build does, for the16// test.17func panicOn(t *testing.T, where string) {18 t.Helper()19 crashTest = func(msg tea.Msg) string {20 if k, ok := msg.(tea.KeyPressMsg); ok && k.Code == tea.KeyF12 {21 return where22 }23 return ""24 }25 t.Cleanup(func() { crashTest = nil })26}2728var f12 = tea.KeyPressMsg{Code: tea.KeyF12}2930// isQuit reports whether cmd quits the program.31func isQuit(cmd tea.Cmd) bool {32 if cmd == nil {33 return false34 }35 _, ok := cmd().(tea.QuitMsg)36 return ok37}3839// A panic in Update quits the program, and the crash says where and40// what with; the model's unsaved work is kept in the recovery directory41// and offered back when the file is opened again.42func TestGuardUpdatePanicKeepsWork(t *testing.T) {43 t.Chdir(t.TempDir())44 dir := filepath.Join(t.TempDir(), "recovery")45 panicOn(t, "update")46 m := newModel()47 m.prefs.RecoveryDir = dir48 m.filename = "book.012"49 press(t, m, "mine", "<enter>")50 g := Guard(m)51 next, cmd := g.Update(f12)52 if !isQuit(cmd) {53 t.Fatal("a panic in Update didn't quit")54 }55 if next != g {56 t.Fatalf("Update returned %v after a panic; Bubble Tea draws what it returns", next)57 }58 next.View()59 if _, cmd := g.Update(tea.KeyPressMsg{Code: 'x', Text: "x"}); cmd != nil {60 t.Error("a crashed model took more input")61 }62 c := g.Finish(nil)63 if c == nil || c.Where != "update" || fmt.Sprint(c.Value) != "crash test in update" || !strings.Contains(string(c.Stack), "crash.go") {64 t.Fatalf("crash %+v", c)65 }66 kept, err := g.Keep(time.Now())67 if err != nil || filepath.Dir(kept) != dir || !strings.Contains(filepath.Base(kept), "book-") {68 t.Fatalf("kept %q, %v", kept, err)69 }7071 again := New(m.sheet, "book.012") // the file on disk doesn't matter here72 again.Update(tea.WindowSizeMsg{Width: 80, Height: 20})73 again.prefs.RecoveryDir = dir74 again.OfferKept()75 run(again, again.Init())76 if !strings.Contains(line(again, contextLine), "Unsaved changes to book.012 were kept.") {77 t.Fatalf("no offer: %q", line(again, contextLine))78 }79 // Another directory's book.012 is another file.80 other := newModel()81 other.prefs.RecoveryDir = dir82 t.Chdir(t.TempDir())83 other.filename = "book.012"84 other.OfferKept()85 run(other, other.Init())86 if strings.Contains(line(other, contextLine), "were kept") {87 t.Errorf("offered another directory's book: %q", line(other, contextLine))88 }89}9091// Nothing unsaved, nothing kept.92func TestGuardKeepsOnlyUnsaved(t *testing.T) {93 m := newModel()94 m.prefs.RecoveryDir = t.TempDir()95 if kept, err := Guard(m).Keep(time.Now()); kept != "" || err != nil {96 t.Errorf("kept %q, %v", kept, err)97 }98}99100// A panic in a command, or in one of a batch it returns, comes back to101// Update as a crash, which quits.102func TestGuardCommandPanic(t *testing.T) {103 panicOn(t, "command")104 g := Guard(newModel())105 _, cmd := g.Update(f12)106 msg := cmd()107 if _, ok := msg.(crashMsg); !ok {108 t.Fatalf("got %T", msg)109 }110 if _, cmd := g.Update(msg); !isQuit(cmd) {111 t.Error("a crash didn't quit")112 }113 if c := g.Finish(nil); c == nil || c.Where != "a command" {114 t.Errorf("crash %+v", c)115 }116117 g = Guard(newModel())118 fine := func() tea.Msg { return nil }119 boom := func() tea.Msg { panic("in a batch") }120 batch, ok := g.guard(tea.Batch(fine, boom))().(tea.BatchMsg)121 if !ok || len(batch) != 2 {122 t.Fatalf("batch %v", batch)123 }124 if c, ok := batch[1]().(crashMsg); !ok || fmt.Sprint(c.crash.Value) != "in a batch" {125 t.Errorf("a batch's panic: %v", batch[1]())126 }127}128129// A panic in View goes on to Bubble Tea, to stop the program, once the130// crash is noted; the last frame is shown after it.131func TestGuardViewPanic(t *testing.T) {132 panicOn(t, "view")133 g := Guard(newModel())134 before := g.View()135 g.Update(f12)136 func() {137 defer func() {138 if recover() == nil {139 t.Error("View didn't panic")140 }141 }()142 g.View()143 }()144 if c := g.Finish(errors.Join(tea.ErrProgramKilled, tea.ErrProgramPanic)); c == nil || c.Where != "view" {145 t.Fatalf("crash %+v", c)146 }147 if g.View().Content != before.Content {148 t.Error("after the crash, View isn't the last frame")149 }150}151152// A panic only Bubble Tea caught is still a crash.153func TestGuardFinishBubbleTeaPanic(t *testing.T) {154 g := Guard(newModel())155 if c := g.Finish(nil); c != nil {156 t.Errorf("a clean quit crashed: %+v", c)157 }158 if c := g.Finish(tea.ErrProgramPanic); c == nil || c.Where != "a command" {159 t.Errorf("crash %+v", c)160 }161}162163// The report says where, what with, the build, the file and where its164// work went, with the stack; at most crashReportsKept are left.165func TestCrashReport(t *testing.T) {166 dir := t.TempDir()167 c := &Crash{Where: "update", Value: "boom", Stack: []byte("goroutine 1 [running]:\nmain.main()\n")}168 at := time.Date(2026, 9, 29, 10, 11, 12, 0, time.Local)169 path, err := c.Report(dir, "v9.9.9", "book.012", "/r/book-1.012", nil, at)170 if err != nil {171 t.Fatal(err)172 }173 if filepath.Base(path) != "crash-20260929-101112.txt" {174 t.Errorf("path %s", path)175 }176 data, _ := os.ReadFile(path)177 for _, want := range []string{"a panic in update", "Version: v9.9.9", "File: book.012", "Kept: /r/book-1.012", "Panic: boom", "main.main()"} {178 if !strings.Contains(string(data), want) {179 t.Errorf("report lacks %q:\n%s", want, data)180 }181 }182 for i := range crashReportsKept + 3 {183 if _, err := c.Report(dir, "", "", "", errors.New("disk full"), at.Add(time.Duration(i+1)*time.Second)); err != nil {184 t.Fatal(err)185 }186 }187 names, _ := filepath.Glob(filepath.Join(dir, "crash-*.txt"))188 if len(names) != crashReportsKept || filepath.Base(names[0]) != "crash-20260929-101116.txt" {189 t.Errorf("kept %d reports, first %s", len(names), filepath.Base(names[0]))190 }191 data, _ = os.ReadFile(names[0])192 if !strings.Contains(string(data), "couldn't keep the unsaved changes: disk full") || !strings.Contains(string(data), "(untitled)") {193 t.Errorf("report:\n%s", data)194 }195}196197// A path too long for a file name still makes a key that fits, and198// different long paths make different keys.199func TestRecoveryKeyLong(t *testing.T) {200 long := "/" + strings.Repeat("dir/", 80)201 a, b := recoveryKey(long+"a.012"), recoveryKey(long+"b.012")202 if len(a) > recoveryKeyMax || a == b || !strings.HasSuffix(a, "a") {203 t.Errorf("keys %q, %q", a, b)204 }205}