package e2e_test // End-to-end data-corruption fuzz against the REAL filesystem: random edit // sequences (through the production edit entry points, including IME replace // ranges relative to the visible window) are mirrored on a shadow model; // after every batch the on-disk file must equal the model byte-for-byte // (forced via Flush), and the visible IME window must equal the model's slice // at its recorded start (a window/content desync is what made IME edits land // in the wrong place historically — Phase 3). Finally a SECOND logic instance // (simulating an app restart) reopens the file and must see exactly the // model, with a consistent line index — proving the full // edit -> persist -> reload -> re-render data path is lossless. import ( "fmt" "math/rand" "os" "path/filepath" "strings" "testing" "time" "pad/internal/editor" "pad/internal/io/pool/real" "pad/internal/test/e2e" "pad/internal/ui" ) var fuzzWords = []string{ "the", "quick", "brown", "fox", "jumps", "over", "lazy", "dog", "alpha", "bravo", "charlie", "delta", "echo", "foxtrot", } // buildFuzzFileContent builds a deterministic multi-line document mixing // ASCII and multi-byte content. func buildFuzzFileContent(seed int64, nLines int) string { rng := rand.New(rand.NewSource(seed)) var sb strings.Builder for i := 0; i < nLines; i++ { sb.WriteString(fmt.Sprintf("L%03d ", i)) n := rng.Intn(8) + 3 for w := 0; w < n; w++ { sb.WriteString(fuzzWords[rng.Intn(len(fuzzWords))]) sb.WriteByte(' ') } switch i % 17 { case 0: sb.WriteString("中文内容 ") case 1: sb.WriteString("héllo wörld ") case 2: sb.WriteString("😀 emoji ") } sb.WriteString("\n") } return sb.String() } // loadRealFile opens /name in h and waits until the load is fully applied // (content + line index), the same air-tight predicate the other real-file // tests use. func loadRealFile(t *testing.T, h *e2e.Harness, name string) { t.Helper() if err := h.WithState(func(st *editor.State) { editor.GoToBrowser(nil) }); err != nil { t.Fatalf("GoToBrowser: %v", err) } time.Sleep(100 * time.Millisecond) if err := h.WithState(func(st *editor.State) { editor.OpenFile("/" + name) }); err != nil { t.Fatalf("OpenFile: %v", err) } 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 } time.Sleep(50 * time.Millisecond) } t.Fatal("timed out waiting for real file to load") } // compactMismatch is a short failure message for two differing strings. func compactMismatch(got, want string) string { at := 0 for at < len(got) && at < len(want) && got[at] == want[at] { at++ } return fmt.Sprintf("first diff at byte %d (got %d bytes, want %d)\ngot [%d:%d]=%q\nwant [%d:%d]=%q", at, len(got), len(want), lo(at, 40), hi(at, 40), seg(got, at, 40), lo(at, 40), hi(at, 40), seg(want, at, 40)) } func TestRealFile_Fuzz_EditSaveReload(t *testing.T) { const ( name = "fuzz.txt" batches = 15 opsPer = 6 ) content := buildFuzzFileContent(11, 400) // ~15 KB dir := t.TempDir() diskPath := filepath.Join(dir, name) if err := os.WriteFile(diskPath, []byte(content), 0644); err != nil { t.Fatal(err) } h := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/")) h.Run() defer h.Cleanup() loadRealFile(t, h, name) model := content rng := rand.New(rand.NewSource(77)) for batch := 0; batch < batches; batch++ { for j := 0; j < opsPer; j++ { switch rng.Intn(100) { case 0: // 30%: insert at a random rune-aligned position pos := randomRunePos(rng, model) text := randomFuzzText(rng, 1, 24) if err := h.WithState(func(st *editor.State) { st.Editor.CursorPosition = pos editor.HandleInsert(text) }); err != nil { t.Fatalf("batch %d op %d: insert: %v", batch, j, err) } model = model[:pos] + text + model[pos:] case 1: // 15%: backspace pos := randomRunePos(rng, model) if err := h.WithState(func(st *editor.State) { st.Editor.CursorPosition = pos editor.HandleBackspace() }); err != nil { t.Fatalf("batch %d op %d: backspace: %v", batch, j, err) } if pos > 0 { model = model[:modelRuneBack(model, pos)] + model[pos:] } case 2: // 15%: delete pos := randomRunePos(rng, model) if err := h.WithState(func(st *editor.State) { st.Editor.CursorPosition = pos editor.HandleDelete() }); err != nil { t.Fatalf("batch %d op %d: delete: %v", batch, j, err) } if pos < len(model) { model = model[:pos] + model[modelRuneNext(model, pos):] } default: // 40%: IME replace, WINDOW-relative rune range (the real // contract: the IME only knows the visible snippet). // Force a fresh layout frame first: WithState edits do not // emit frames, so the window would otherwise be stale. fb := h.FrameCount() h.SendInput([]ui.InputEvent{}) if _, ferr := h.WaitForFrameCount(fb+1, 5*time.Second); ferr != nil { t.Fatalf("batch %d op %d: no frame: %v", batch, j, ferr) } type win struct { Start int Text string } raw, err := h.Inspect(func(st *editor.State) any { return win{st.Editor.IMEWindowStartByte, st.Editor.IMEWindowText} }) if err != nil { t.Fatalf("batch %d op %d: inspect: %v", batch, j, err) } w := raw.(win) if w.Text == "" { t.Fatalf("batch %d op %d: IME window empty after load+frames", batch, j) } // Window consistency: the snippet must be exactly the model's // slice at the recorded start. if w.Start < 0 || w.Start+len(w.Text) > len(model) || w.Text != model[w.Start:w.Start+len(w.Text)] { t.Fatalf("batch %d op %d: IME window desync: start=%d len=%d; %s", batch, j, w.Start, len(w.Text), compactMismatch(w.Text, model[w.Start:min(len(model), w.Start+len(w.Text))])) } wr := countRunes(w.Text) ra := rng.Intn(wr + 1) rb := ra + rng.Intn(wr-ra+1) text := randomFuzzText(rng, 0, 12) if err := h.WithState(func(st *editor.State) { editor.HandleReplaceRange(ra, rb, text) }); err != nil { t.Fatalf("batch %d op %d: replace: %v", batch, j, err) } bs := w.Start + runeToByte(w.Text, ra) be := w.Start + runeToByte(w.Text, rb) model = model[:bs] + text + model[be:] } } // Force a fresh layout frame so IMEWindowText reflects the new model, // then verify the window once per batch. before := h.FrameCount() h.SendInput([]ui.InputEvent{}) if _, err := h.WaitForFrameCount(before+1, 5*time.Second); err != nil { t.Fatalf("batch %d: no frame after input: %v", batch, err) } w, err := h.Inspect(func(st *editor.State) any { return struct { Start int Text string }{st.Editor.IMEWindowStartByte, st.Editor.IMEWindowText} }) if err != nil { t.Fatalf("batch %d: inspect: %v", batch, err) } ws := w.(struct { Start int Text string }) if ws.Text != "" && (ws.Start < 0 || ws.Start+len(ws.Text) > len(model) || ws.Text != model[ws.Start:ws.Start+len(ws.Text)]) { t.Fatalf("batch %d: window desync after edits: start=%d len=%d", batch, ws.Start, len(ws.Text)) } // Persistence: forced flush, then the on-disk file must be EXACTLY // the model (no lost bytes, no duplication, no reorder). if err := h.Flush(); err != nil { t.Fatalf("batch %d: flush: %v", batch, err) } if disk := readDisk(t, diskPath); disk != model { t.Fatalf("batch %d: disk != model: %s", batch, compactMismatch(disk, model)) } } // --- Restart simulation: a fresh logic instance (new process state) --- h2 := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/")) h2.Run() defer h2.Cleanup() loadRealFile(t, h2, name) got, err := h2.FullContent() if err != nil { t.Fatalf("restart FullContent: %v", err) } if got != model { t.Fatalf("after restart: buffer != model: %s", compactMismatch(got, model)) } if disk := readDisk(t, diskPath); disk != model { t.Fatalf("after restart: disk != model: %s", compactMismatch(disk, model)) } // Line index after reload must match a fresh recomputation. diag, err := h2.Inspect(func(st *editor.State) any { cb := st.Editor.ChunkedBuffer li := cb.LineIndex if li == nil { return "nil index" } want := freshLineOffsets(model) if len(li.Offsets) != len(want) || li.Size != int64(len(model)) { return fmt.Sprintf("size=%d lines=%d, want size=%d lines=%d", li.Size, len(li.Offsets), len(model), len(want)) } for i := range want { if li.Offsets[i] != want[i] { return fmt.Sprintf("offset %d: got %d want %d", i, li.Offsets[i], want[i]) } } return "ok" }) if err != nil { t.Fatalf("restart inspect: %v", err) } if diag.(string) != "ok" { t.Fatalf("after restart: line index inconsistent: %s", diag) } // No temp files left behind by the atomic writes. entries, err := os.ReadDir(dir) if err != nil { t.Fatal(err) } for _, e := range entries { if strings.HasSuffix(e.Name(), ".tmp") { t.Fatalf("stray temp file left behind: %s", e.Name()) } } } // randomRunePos returns a random rune-start byte offset in s. func randomRunePos(rng *rand.Rand, s string) int { runeStarts := []int{0} for i := 0; i < len(s); { _, size := decodeRuneAt(s[i:]) i += size runeStarts = append(runeStarts, i) } return runeStarts[rng.Intn(len(runeStarts))] } func decodeRuneAt(s string) (rune, int) { if len(s) == 0 { return 0, 0 } c := s[0] switch { case c < 0x80: return rune(c), 1 case c < 0xC0: // Invalid lead (should not happen with valid content); treat as 1 // byte so the fuzzer never advances past it in a loop. return rune(c), 1 case c < 0xE0: return rune(c), 2 case c < 0xF0: return rune(c), 3 default: return rune(c), 4 } } // modelRuneBack returns the byte offset of the start of the rune ending at // pos (pos must be a rune start > 0). func modelRuneBack(s string, pos int) int { i := pos - 1 for i > 0 && s[i] >= 0x80 && s[i] < 0xC0 { i-- } return i } // modelRuneNext returns the byte offset after the rune starting at pos. func modelRuneNext(s string, pos int) int { _, size := decodeRuneAt(s[pos:]) return pos + size } func countRunes(s string) int { n := 0 for i := 0; i < len(s); { _, size := decodeRuneAt(s[i:]) i += size n++ } return n } // runeToByte returns the byte offset of the n-th rune start in s (n may be // the rune count, yielding len(s)). func runeToByte(s string, n int) int { pos := 0 for i := 0; i < n && pos < len(s); i++ { _, size := decodeRuneAt(s[pos:]) pos += size } return pos } func randomFuzzText(rng *rand.Rand, min, max int) string { n := rng.Intn(max-min+1) + min sb := strings.Builder{} for i := 0; i < n; i++ { switch rng.Intn(10) { case 0: sb.WriteString("中文") case 1: sb.WriteString("é😀") case 2: sb.WriteByte('\n') default: sb.WriteString(fuzzWords[rng.Intn(len(fuzzWords))]) sb.WriteByte(' ') } } return sb.String() } // freshLineOffsets computes the ground-truth line-start offsets for s. func freshLineOffsets(s string) []int32 { off := []int32{0} for i, b := range s { if b == '\n' { off = append(off, int32(i+1)) } } return off }