Pad/internal/test/e2e/harness.go
Greg Pomerantz ec11abf8f1 Add text selection, real-file e2e tests, and Android arrow-key support
Selection: shift+arrow extends a selection (absolute byte offsets,
anchor/caret model); insert/backspace/delete replace the selection; the
IME unions its reported range with the active selection; the highlight
is drawn in the TextField and the selection is pushed to the IME.

Android key input (the blocker found during on-device validation):
Gio v0.10 on Android (a) drops modifier state in the JNI bridge and
(b) wraps plain arrow-key presses in input.SystemEvent for focus
navigation, so arrow keys never reached the editor. main.go now
registers explicit named key.Filters for the four arrows (delivers the
press and suppresses the focus jump) and tracks the shift key itself.
Verified on the emulator: plain arrows move the caret, shift+arrow
shows a highlight, typing replaces the selection.

Real-file e2e tests (real on-disk files via the real FileSystem,
multi-chunk 256KB files, chunk-boundary and multi-byte edits) found
and fixed two real bugs:
  1. Line index: UpdateLineIndexAfterEdit only shifted offsets; edits
     involving newlines left it permanently inconsistent. Replaced with
     newline-aware UpdateLineIndexAfterInsert/UpdateLineIndexAfterDelete.
  2. Rune granularity: HandleBackspace/HandleDelete deleted one byte,
     corrupting multi-byte UTF-8 characters (e.g. a 2-byte char
     straddling a chunk boundary). Now rune-granular.

Also: airtight e2e harness load-wait (StatFile/ReadFile/BuildLineIndex
interleaving could satisfy the old condition early).

Full suite green under -race; on-device verified.
2026-08-17 00:32:53 -04:00

221 lines
5.9 KiB
Go

package e2e
import (
"fmt"
"sync"
"time"
"pad/internal/editor"
"pad/internal/io/pool"
"pad/internal/ui"
)
var errInspectTimeout = fmt.Errorf("e2e: inspect timed out waiting for the logic goroutine")
// Harness orchestrates the test environment for e2e tests.
type Harness struct {
logic *editor.Logic
capture *FrameCapture
logicWg sync.WaitGroup
frameReceiverDone chan struct{}
wg sync.WaitGroup
started bool
fs pool.FileSystem // nil = mock filesystem (default)
startPath string // browser root (default "/")
}
// HarnessOption configures the test harness.
type HarnessOption func(*Harness)
// WithFileSystem uses the given filesystem instead of the mock and startPath
// as the browser root. Pass real.NewRealFileSystem(t.TempDir()) to run the
// full open/edit/autosave cycle against real on-disk files.
func WithFileSystem(fs pool.FileSystem, startPath string) HarnessOption {
return func(h *Harness) {
h.fs = fs
h.startPath = startPath
}
}
// NewHarness creates a test harness with the given options. The logic
// instance is created here (after options are applied) so WithFileSystem can
// substitute the filesystem; the no-op openfunc matches impl_other.go.
func NewHarness(opts ...HarnessOption) *Harness {
h := &Harness{
capture: NewFrameCapture(),
frameReceiverDone: make(chan struct{}),
startPath: "/",
}
for _, opt := range opts {
opt(h)
}
h.logic = editor.NewLogic(h.fs, h.startPath, func(string) {})
return h
}
// Run starts the logic goroutine and frame capture.
// It must be called at most once: two Run loops on the same channels and
// state would race (NewHarnessWithDefaults already calls it).
func (h *Harness) Run() {
if h.started {
panic("e2e: Harness.Run called twice")
}
h.started = true
h.logicWg.Add(1)
h.wg.Add(1)
go func() {
defer h.wg.Done()
defer h.logicWg.Done()
h.logic.Run()
}()
h.wg.Add(1)
go func() {
defer h.wg.Done()
for {
select {
case frame := <-h.logic.FrameChan():
h.capture.CaptureFrame(frame.Elems)
case <-h.frameReceiverDone:
return
}
}
}()
}
// SendConfig simulates a window config event (resize).
func (h *Harness) SendConfig(width, height int) {
h.logic.ConfigChan() <- editor.ConfigEvent{
PixelWidth: width,
PixelHeight: height,
}
}
// SendScale simulates a scale factor change.
func (h *Harness) SendScale(scale float32) {
h.logic.ConfigChan() <- editor.ScaleEvent{Scale: scale}
}
// SendInput simulates user input events.
func (h *Harness) SendInput(events []ui.InputEvent) {
h.logic.InputChan() <- events
}
// SendSearchQuery simulates a search query update.
func (h *Harness) SendSearchQuery(query string) {
h.logic.SearchQueryChan() <- query
}
// GetFrames returns all captured frames.
func (h *Harness) GetFrames() [][]ui.Element {
return h.capture.GetFrames()
}
// FrameCount returns the number of frames captured so far.
func (h *Harness) FrameCount() int {
return h.capture.FrameCount()
}
// Inspect runs fn on the logic goroutine and returns its result.
// This is the ONLY sanctioned way for a test to read or write state: the fn
// executes on the owner, preserving the single-owner invariant
// (architecture.md §1). fn must not block on sends to logic channels.
func (h *Harness) Inspect(fn func(st *editor.State) any) (any, error) {
v, ok := h.logic.Inspect(fn)
if !ok {
return nil, errInspectTimeout
}
return v, nil
}
// WithState runs fn on the logic goroutine for state setup or mutation.
func (h *Harness) WithState(fn func(st *editor.State)) error {
_, err := h.Inspect(func(st *editor.State) any {
fn(st)
return nil
})
return err
}
// Logic exposes the underlying editor.Logic for owner-side operations that
// have no State-level equivalent (e.g. FlushAll).
func (h *Harness) Logic() *editor.Logic { return h.logic }
// Flush forces an immediate autosave flush (owner-side), so tests can verify
// on-disk content without waiting for the 1 s autosave debounce.
func (h *Harness) Flush() error {
return h.WithState(func(st *editor.State) { h.logic.FlushAll() })
}
// FileLoaded reports whether the active file's chunked buffer has loaded
// content (owner-side check).
func (h *Harness) FileLoaded() (bool, error) {
v, err := h.Inspect(func(st *editor.State) any {
cb := st.Editor.ChunkedBuffer
return cb != nil && cb.FileLen() > 0
})
if err != nil {
return false, err
}
return v.(bool), nil
}
// FullContent returns the active file's full content (owner-side).
func (h *Harness) FullContent() (string, error) {
v, err := h.Inspect(func(st *editor.State) any {
if st.Editor.ChunkedBuffer != nil {
full, err := st.Editor.ChunkedBuffer.FullContent()
if err != nil {
return ""
}
return full
}
return st.Editor.Buffer
})
if err != nil {
return "", err
}
return v.(string), nil
}
// CursorPosition returns the editor cursor position (owner-side).
func (h *Harness) CursorPosition() (int, error) {
v, err := h.Inspect(func(st *editor.State) any { return st.Editor.CursorPosition })
if err != nil {
return 0, err
}
return v.(int), nil
}
// WaitForFrameCount blocks until at least N frames are captured.
func (h *Harness) WaitForFrameCount(count int, timeout time.Duration) ([][]ui.Element, error) {
return h.capture.WaitForFrameCount(count, timeout)
}
// WaitForFrame blocks until at least one frame is captured.
func (h *Harness) WaitForFrame(timeout time.Duration) ([][]ui.Element, error) {
return h.capture.WaitForFrame(timeout)
}
// Cleanup stops all goroutines.
func (h *Harness) Cleanup() {
// 1. Signal logic to stop sending frames
h.logic.Done()
// 2. Wait for logic goroutine to fully exit (no more frameChan sends)
h.logicWg.Wait()
// 3. Now safe to stop the frame receiver
close(h.frameReceiverDone)
// 4. Wait for frame receiver to finish
h.wg.Wait()
h.capture.Close()
}
// DefaultTimeout is the default timeout for waiting operations.
const DefaultTimeout = 5 * time.Second