/** * test/lib/render.ts — headless renderable verification helpers (spec v4 §5 * Layer 2). Wraps the Solid binding's `testRender` + the settle dance. * * Settling needs care: Solid mounts async; a `` needs a couple of * passes to measure content + apply stickyStart; and the native `` * (Tree-sitter) tokenizes ASYNCHRONOUSLY — a plain `renderOnce` loop captures * before its text paints. So we `flush()` (wait until scheduled rendering * settles) between passes, and `captureFrame` can wait for specific content via * `until` (retries with `waitForFrame`) for markdown-bearing frames. * * `exitOnCtrlC: false` is forced (gotcha §8 #7 — the test renderer defaults true * and would tear down on the first simulated Ctrl+C, blanking later frames). * * Keymap (Phase 3): overlays/prompts register close layers via `@opentui/keymap`, * whose hooks throw without a ``. The entry provides one in the * real app; here we provide a test keymap built from the test renderer (read via * `useRenderer()` inside the tree) so headless mounts of those views work. */ import type { CapturedFrame } from '@opentui/core' import type { TestRendererSetup } from '@opentui/core/testing' import { createDefaultOpenTuiKeymap } from '@opentui/keymap/opentui' import { KeymapProvider } from '@opentui/keymap/solid' import { testRender, useRenderer } from '@opentui/solid' import type { JSX } from '@opentui/solid' import { createMemo } from 'solid-js' import { installFfiCoordSafety } from '../../boundary/ffiSafe.ts' // Headless renders go through the same node:ffi seam as the live TUI — install // the negative-coordinate shim here too (the live path installs it in // boundary/renderer.ts, which tests don't import). installFfiCoordSafety() /** Wrap a node in a KeymapProvider whose keymap is bound to the test renderer. */ function withKeymap(node: () => JSX.Element): () => JSX.Element { return () => { const renderer = useRenderer() const keymap = createMemo(() => createDefaultOpenTuiKeymap(renderer)) return KeymapProvider({ keymap: keymap(), get children() { return node() } }) } } export interface RenderProbe { readonly frame: () => string /** Styled spans of the current frame (per-span fg/bg/attributes) — for * asserting COLOR, e.g. the composer's slash-token highlight (Epic 6). */ readonly spans: () => CapturedFrame readonly waitForFrame: (predicate: (frame: string) => boolean) => Promise readonly resize: (width: number, height: number) => void /** Left-click at screen cell (x, y) via the mock mouse, then settle a pass. */ readonly click: (x: number, y: number) => Promise /** The mock keyboard (typeText / pressArrow / pressEnter / …) — pair with `settle()`. */ readonly keys: TestRendererSetup['mockInput'] /** Run a render pass + flush so simulated input lands in the next `frame()`. */ readonly settle: () => Promise readonly destroy: () => void } /** Mount a Solid node headlessly and return a probe with a settled first frame. */ export async function renderProbe( node: () => JSX.Element, options?: { width?: number; height?: number; kittyKeyboard?: boolean } ): Promise { const setup = await testRender(withKeymap(node), { width: options?.width ?? 80, height: options?.height ?? 24, exitOnCtrlC: false, // kitty protocol makes a SIMULATED lone ESC parse deterministically (legacy // input leaves it in the escape-sequence ambiguity window forever — the mock // never flushes it), so keyboard-driven tests can press Escape. kittyKeyboard: options?.kittyKeyboard ?? false }) // renderOnce → flush → renderOnce: flush awaits async work (scrollbox measure, // Tree-sitter markdown tokenization) that a single sync pass would miss. The // native `` commits blocks over several // native frames, so settle to visual idle too (best-effort). await setup.renderOnce() await setup.flush() await setup.waitForVisualIdle?.() await setup.renderOnce() await setup.flush() return { frame: () => setup.captureCharFrame(), spans: () => setup.captureSpans(), waitForFrame: predicate => setup.waitForFrame(predicate), resize: (width, height) => setup.resize(width, height), click: async (x, y) => { await setup.mockMouse.click(x, y) await setup.renderOnce() await setup.flush() }, keys: setup.mockInput, settle: async () => { await setup.renderOnce() await setup.flush() }, destroy: () => setup.renderer.destroy?.() } } /** * Mount, capture one settled frame, tear down. When `until` is given (string or * RegExp), waits for the frame to contain/match it first — use for async * markdown content that may not be painted on the first settled pass. */ export async function captureFrame( node: () => JSX.Element, options?: { width?: number; height?: number; until?: string | RegExp } ): Promise { const probe = await renderProbe(node, options) try { const until = options?.until if (until !== undefined) { const match = (frame: string) => (typeof until === 'string' ? frame.includes(until) : until.test(frame)) return await probe.waitForFrame(match) } return probe.frame() } finally { probe.destroy() } }