package e2e_test // Relaunch state restoration (spec ยง7): the last file, cursor, scroll, // selection and find bar state are persisted as a tiny JSON snapshot and // restored on the next launch. These tests drive the same entry points the // cmd layer uses: Logic.BeginRestore before Run (restore) and the session // saver callback (persist). import ( "fmt" "os" "path/filepath" "strings" "testing" "time" "pad/internal/editor" "pad/internal/io/pool/real" "pad/internal/test/e2e" "pad/internal/ui" ) // restoreHarness writes content to /name, builds a harness over the // real filesystem, hands sess to the logic BEFORE Run (as the cmd layer does // on relaunch), starts it with the standard 390x844@2x window, and waits for // the restored file to fully load (content + line index). func restoreHarness(t *testing.T, name, content string, sess editor.SessionState) *e2e.Harness { t.Helper() dir := t.TempDir() diskPath := filepath.Join(dir, name) if err := os.WriteFile(diskPath, []byte(content), 0644); err != nil { t.Fatal(err) } sess.File = "/" + name h := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/")) h.Logic().BeginRestore(sess) h.Run() h.SendConfig(780, 1688) // 390x844 @ 2x scale h.SendScale(2.0) // "Loaded" means both async results are fully applied: the ReadFile // content AND the BuildLineIndex result (same contract as // realFileHarness: FileLen>0 && LineIndex!=nil alone is a race). for i := 0; i < 100; i++ { v, err := h.Inspect(func(st *editor.State) any { cb := st.Editor.ChunkedBuffer if cb == nil || cb.FileLen() == 0 || cb.LineIndex == nil { return false } full, err := cb.FullContent() return err == nil && int64(len(full)) == cb.FileLen() && cb.LineIndex.Size == cb.FileLen() }) if err != nil { t.Fatalf("Inspect: %v", err) } if v.(bool) { return h } time.Sleep(50 * time.Millisecond) } t.Fatal("timed out waiting for restored file to load") return nil } // TestRestore_FileCursorScrollSelection restores a file with a cursor, a // non-zero scroll and a live selection; all three must land once the content // arrives, on the editor page (not the browser). func TestRestore_FileCursorScrollSelection(t *testing.T) { content := strings.Repeat("hello world line\n", 100) h := restoreHarness(t, "notes.txt", content, editor.SessionState{ Cursor: 123, Scroll: 100, SelStart: 100, SelEnd: 110, }) defer h.Cleanup() v, err := h.Inspect(func(st *editor.State) any { return struct { Page editor.Page File string Cursor int Scroll ui.Dp SelStart int SelEnd int Anchor int }{st.Page(), st.Editor.Filename, st.Editor.CursorPosition, st.ScrollOffset, st.Editor.SelectionStart, st.Editor.SelectionEnd, st.Editor.SelectionAnchor} }) if err != nil { t.Fatalf("Inspect: %v", err) } got := v.(struct { Page editor.Page File string Cursor int Scroll ui.Dp SelStart int SelEnd int Anchor int }) if got.Page != editor.EditorPage { t.Errorf("page = %v, want EditorPage (restore lands straight in the editor)", got.Page) } if got.File != "/notes.txt" { t.Errorf("file = %q, want /notes.txt", got.File) } if got.Cursor != 123 { t.Errorf("cursor = %d, want 123", got.Cursor) } if got.Scroll != ui.Dp(100) { t.Errorf("scroll = %v, want 100", got.Scroll) } if got.SelStart != 100 || got.SelEnd != 110 || got.Anchor != 100 { t.Errorf("selection = [%d,%d) anchor %d, want [100,110) anchor 100", got.SelStart, got.SelEnd, got.Anchor) } } // TestRestore_ScrollLandsWhenScalePrecedesSize reproduces the on-device // startup ordering, where the first FrameEvent (and hence the ScaleEvent) // arrives before the size ConfigEvent. The scroll must still land, clamped // by the layout at the real scale: an earlier revision applied it at the // ScaleEvent against a not-yet-sized viewport and lost it. func TestRestore_ScrollLandsWhenScalePrecedesSize(t *testing.T) { content := strings.Repeat("hello world line\n", 100) dir := t.TempDir() diskPath := filepath.Join(dir, "late.txt") if err := os.WriteFile(diskPath, []byte(content), 0644); err != nil { t.Fatal(err) } h := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/")) h.Logic().BeginRestore(editor.SessionState{File: "/late.txt", Cursor: 123, Scroll: 100}) h.Run() h.SendScale(2.0) // scale first h.SendConfig(780, 1688) // size second (on-device ordering) defer h.Cleanup() for i := 0; i < 100; i++ { v, err := h.Inspect(func(st *editor.State) any { cb := st.Editor.ChunkedBuffer if cb == nil || cb.FileLen() == 0 || cb.LineIndex == nil { return false } full, err := cb.FullContent() return err == nil && int64(len(full)) == cb.FileLen() && cb.LineIndex.Size == cb.FileLen() && st.ScrollOffset == ui.Dp(100) }) if err != nil { t.Fatalf("Inspect: %v", err) } if v.(bool) { return } time.Sleep(50 * time.Millisecond) } t.Fatal("timed out: scroll did not land when the scale event preceded the size") } // TestRestore_ClampsToShorterFile: the file was truncated since the last // session; the cursor and selection must clamp to the shorter content, not // restore past EOF. func TestRestore_ClampsToShorterFile(t *testing.T) { content := "0123456789" // 10 bytes h := restoreHarness(t, "short.txt", content, editor.SessionState{ Cursor: 57, // far past EOF SelStart: 5, SelEnd: 99, // spans EOF }) defer h.Cleanup() v, err := h.Inspect(func(st *editor.State) any { return struct{ Cursor, SelStart, SelEnd int }{ st.Editor.CursorPosition, st.Editor.SelectionStart, st.Editor.SelectionEnd, } }) if err != nil { t.Fatalf("Inspect: %v", err) } got := v.(struct{ Cursor, SelStart, SelEnd int }) if got.Cursor != 10 { t.Errorf("cursor = %d, want 10 (clamped to EOF)", got.Cursor) } if got.SelStart != 5 || got.SelEnd != 10 { t.Errorf("selection = [%d,%d), want [5,10) (end clamped to EOF)", got.SelStart, got.SelEnd) } } // TestRestore_MissingFileFallsBackToBrowser: the restored file was deleted // since the last session; the app must land on the browser page with a clean // editor, not an empty editor page. func TestRestore_MissingFileFallsBackToBrowser(t *testing.T) { dir := t.TempDir() h := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/")) h.Logic().BeginRestore(editor.SessionState{File: "/gone.txt", Cursor: 10}) h.Run() defer h.Cleanup() h.SendConfig(780, 1688) h.SendScale(2.0) deadline := time.Now().Add(5 * time.Second) for { v, err := h.Inspect(func(st *editor.State) any { return st.Page() == editor.BrowserPage && st.Editor.Filename == "" && st.Editor.ChunkedBuffer == nil }) if err != nil { t.Fatalf("Inspect: %v", err) } if v.(bool) { return } if time.Now().After(deadline) { t.Fatal("timed out: restore of a missing file did not fall back to the browser") } time.Sleep(25 * time.Millisecond) } } // TestRestore_FindBar reopens the file with the find bar open, a query and a // current match: the query must re-scan, select the saved current match (by // byte offset) and NOT re-scroll the restored viewport. func TestRestore_FindBar(t *testing.T) { var b strings.Builder for i := 0; i < 50; i++ { fmt.Fprintf(&b, "filler line number %02d\n", i) } b.WriteString("needle at start of the interesting part\n") b.WriteString("middle filler\n") b.WriteString("needle in the middle of it\n") b.WriteString("tail filler\n") b.WriteString("needle at the end\n") for i := 0; i < 50; i++ { fmt.Fprintf(&b, "trailing filler %02d\n", i) } content := b.String() // Offsets of the three "needle" occurrences. first := strings.Index(content, "needle") second := strings.Index(content[first+1:], "needle") + first + 1 third := strings.Index(content[second+1:], "needle") + second + 1 if first < 0 || second < 0 || third < 0 { t.Fatal("test content lost its needles") } h := restoreHarness(t, "findme.txt", content, editor.SessionState{ FindQuery: "needle", FindVisible: true, FindCurByte: second, // Cursor/scroll restored at the top: if the re-scan wrongly // re-scrolls to the match, ScrollOffset would no longer be 0. }) defer h.Cleanup() deadline := time.Now().Add(5 * time.Second) for { v, err := h.Inspect(func(st *editor.State) any { f := &st.Editor.Find return struct { Visible, Scanning bool N, Cur, SelStart, SelEnd int Scroll ui.Dp }{f.Visible, f.Scanning, len(f.Matches), f.Cur, st.Editor.SelectionStart, st.Editor.SelectionEnd, st.ScrollOffset} }) if err != nil { t.Fatalf("Inspect: %v", err) } got := v.(struct { Visible, Scanning bool N, Cur, SelStart, SelEnd int Scroll ui.Dp }) if !got.Scanning && got.N == 3 { if !got.Visible { t.Errorf("find bar visible = false, want true") } if got.Cur != 1 { t.Errorf("current match = %d, want 1 (the saved current item)", got.Cur) } if got.SelStart != second || got.SelEnd != second+len("needle") { t.Errorf("selection = [%d,%d), want [%d,%d)", got.SelStart, got.SelEnd, second, second+len("needle")) } if got.Scroll != 0 { t.Errorf("scroll = %v, want 0 (restore must not re-scroll to the match)", got.Scroll) } return } if time.Now().After(deadline) { t.Fatalf("timed out waiting for the restore re-scan (matches=%d scanning=%v)", got.N, got.Scanning) } time.Sleep(25 * time.Millisecond) } } // TestRestore_FindBarClosed keeps the query but does not open the bar: no // scan may run at restore (it would be dropped and leave Scanning stuck); // opening the bar afterwards must re-scan and produce the matches. func TestRestore_FindBarClosed(t *testing.T) { var b strings.Builder for i := 0; i < 60; i++ { fmt.Fprintf(&b, "padding line %02d\n", i) } b.WriteString("needle one\nneedle two\nneedle three\n") content := b.String() h := restoreHarness(t, "closed.txt", content, editor.SessionState{ FindQuery: "needle", FindVisible: false, }) defer h.Cleanup() // Give any (wrong) eager scan time to dispatch, then check state. time.Sleep(300 * time.Millisecond) v, err := h.Inspect(func(st *editor.State) any { f := &st.Editor.Find return struct { Visible, Scanning bool Query string N int }{f.Visible, f.Scanning, f.Query, len(f.Matches)} }) if err != nil { t.Fatalf("Inspect: %v", err) } got := v.(struct { Visible, Scanning bool Query string N int }) if got.Visible { t.Errorf("find bar visible = true, want false") } if got.Query != "needle" { t.Errorf("query = %q, want needle", got.Query) } if got.Scanning { t.Errorf("scanning = true after a closed-bar restore; the scan would be dropped and never re-run") } if got.N != 0 { t.Errorf("matches = %d, want 0 (no scan while the bar is closed)", got.N) } // Opening the bar now must re-scan (findShow) and find all three. if err := h.WithState(func(st *editor.State) { editor.ToggleFind(nil) }); err != nil { t.Fatalf("ToggleFind: %v", err) } deadline := time.Now().Add(5 * time.Second) for { n, err := h.Inspect(func(st *editor.State) any { return len(st.Editor.Find.Matches) }) if err != nil { t.Fatalf("Inspect: %v", err) } if n.(int) == 3 { return } if time.Now().After(deadline) { t.Fatal("timed out: opening the find bar did not re-scan the restored query") } time.Sleep(25 * time.Millisecond) } } // TestRestore_SaverSnapshot verifies the persist side: a state change // (cursor + selection + scroll) is picked up by the rate-limited saver as a // snapshot with the right values. func TestRestore_SaverSnapshot(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "small.txt"), []byte("hello world"), 0644); err != nil { t.Fatal(err) } saves := make(chan editor.SessionState, 16) h := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/")) h.Logic().SetSessionSaver(func(s editor.SessionState) { saves <- s }) h.Run() h.SendConfig(780, 1688) h.SendScale(2.0) defer h.Cleanup() // Open the file the usual way (browser-row tap path). if err := h.WithState(func(st *editor.State) { editor.OpenFile("/small.txt") }); err != nil { t.Fatalf("OpenFile: %v", err) } time.Sleep(300 * time.Millisecond) // let the load settle // Change state on the owner, then force a frame (emitFrame drives the // rate-limited save). if err := h.WithState(func(st *editor.State) { st.Editor.CursorPosition = 6 st.Editor.SelectionAnchor = 0 st.Editor.SelectionStart = 0 st.Editor.SelectionEnd = 5 st.ScrollOffset = 10 }); err != nil { t.Fatalf("WithState: %v", err) } // The save is rate-limited to one per second; wait past the limit, then // force a frame (emitFrame drives the check). time.Sleep(1100 * time.Millisecond) h.SendConfig(780, 1688) deadline := time.Now().Add(5 * time.Second) for { remaining := time.Until(deadline) if remaining <= 0 { t.Fatal("timed out: saver did not receive the expected snapshot") } select { case s := <-saves: if s.File == "/small.txt" && s.Cursor == 6 && s.SelStart == 0 && s.SelEnd == 5 && s.Scroll == 10 { return } case <-time.After(remaining): t.Fatal("timed out: saver did not receive the expected snapshot") } } } // TestRestore_ShutdownSavesFinalSnapshot verifies Shutdown's unconditional // final save: the last state is persisted even without a rate-limited tick. func TestRestore_ShutdownSavesFinalSnapshot(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("abcdef"), 0644); err != nil { t.Fatal(err) } saves := make(chan editor.SessionState, 16) l := editor.NewLogic(real.NewRealFileSystem(dir), "/", func(string) {}) l.SetSessionSaver(func(s editor.SessionState) { saves <- s }) go l.Run() // Open the file (the goroutine-send keeps this off the owner's select). if _, ok := l.Inspect(func(st *editor.State) any { editor.OpenFile("/a.txt") return nil }); !ok { t.Fatal("Inspect timed out") } for i := 0; i < 100; i++ { loaded, ok := l.Inspect(func(st *editor.State) any { cb := st.Editor.ChunkedBuffer return cb != nil && cb.FileLen() == 6 }) if ok && loaded.(bool) { break } time.Sleep(20 * time.Millisecond) } if _, ok := l.Inspect(func(st *editor.State) any { st.Editor.CursorPosition = 3 return nil }); !ok { t.Fatal("Inspect timed out") } l.Shutdown() deadline := time.After(3 * time.Second) for { select { case s := <-saves: if s.File == "/a.txt" && s.Cursor == 3 { return } case <-deadline: t.Fatal("timed out: Shutdown did not persist a snapshot with the final state") } } } // TestRestore_UrgentSaveOnSelection verifies the kill-race fix: a selection // appearing (the user just highlighted text) saves immediately, even inside // the rate-limit window that the file-open save opened. Without the urgent // path, killing the app (recents-wipe) less than a second after highlighting // lost the selection and cursor. func TestRestore_UrgentSaveOnSelection(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("abcdef"), 0644); err != nil { t.Fatal(err) } saves := make(chan editor.SessionState, 16) l := editor.NewLogic(real.NewRealFileSystem(dir), "/", func(string) {}) l.SetSessionSaver(func(s editor.SessionState) { saves <- s }) go l.Run() defer l.Shutdown() // Open the file: the file change is itself urgent, so a save lands as // the load settles. Drain everything up to and including the loaded // snapshot. if _, ok := l.Inspect(func(st *editor.State) any { editor.OpenFile("/a.txt") return nil }); !ok { t.Fatal("Inspect timed out") } loaded := false for i := 0; i < 100 && !loaded; i++ { v, ok := l.Inspect(func(st *editor.State) any { cb := st.Editor.ChunkedBuffer return cb != nil && cb.FileLen() == 6 }) if ok { loaded = v.(bool) } if loaded { break } time.Sleep(20 * time.Millisecond) } if !loaded { t.Fatal("file did not load") } // Drain saves up to the first one describing the loaded file. deadline := time.After(3 * time.Second) for { select { case s := <-saves: if s.File == "/a.txt" { goto drainDone } case <-deadline: t.Fatal("timed out: no save for the opened file") } } drainDone: // Immediately (well inside any rate-limit window after the saves above), // make a selection appear and force a frame: the urgent path must save it // now, not after the interval elapses. if _, ok := l.Inspect(func(st *editor.State) any { st.Editor.SelectionAnchor = 1 st.Editor.SelectionStart = 1 st.Editor.SelectionEnd = 4 st.Editor.CursorPosition = 4 st.ScrollOffset = 3 return nil }); !ok { t.Fatal("Inspect timed out") } l.ConfigChan() <- editor.ConfigEvent{PixelWidth: 780, PixelHeight: 1688} // force emitFrame select { case s := <-saves: if s.SelStart == 1 && s.SelEnd == 4 && s.Cursor == 4 { return } t.Fatalf("unexpected snapshot: %+v", s) case <-time.After(2 * time.Second): t.Fatal("timed out: selection appearance was not saved immediately (urgent path)") } }