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