Pad/internal/test/e2e/harness.go
Greg Pomerantz 58725a5e6c Make state single-owner and stabilize race detector
Frame now carries view state (scale/focus/query) to the main goroutine,
which reads only the frame-receiver-stored snapshot. Renderer owns Gio
widget editors (registered by ID) and the draw scale. Autosave timer
sends a token to the logic goroutine instead of touching state;
Shutdown waits for the owner to exit before FlushAll.

Tests access state only through owner-side Inspect/WithState helpers
(harness + in-package). Fixed double-Harness.Run race in two e2e tests,
made TestWorkerPool_PriorityPreemption deterministic, and raised the
lazy-loading test timeout that was too short under -race.

go test -race ./... is now green.
2026-08-16 01:32:27 -04:00

195 lines
4.8 KiB
Go

package e2e
import (
"fmt"
"sync"
"time"
"pad/internal/editor"
"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
}
// HarnessOption configures the test harness.
type HarnessOption func(*Harness)
// NewHarness creates a test harness with the given options.
func NewHarness(opts ...HarnessOption) *Harness {
h := &Harness{
logic: editor.NewLogic(nil, "/", func(string) {}), // no-op openfunc (matches impl_other.go)
capture: NewFrameCapture(),
frameReceiverDone: make(chan struct{}),
}
for _, opt := range opts {
opt(h)
}
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
}
// 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