diff --git a/ui-tui-opentui-v2/.gitignore b/ui-tui-opentui-v2/.gitignore index 563d877fb0b..ee97323c01f 100644 --- a/ui-tui-opentui-v2/.gitignore +++ b/ui-tui-opentui-v2/.gitignore @@ -4,3 +4,6 @@ dist/ *.frame.txt *.ansi bun.lockb + +# the global ~/.gitignore_global `lib/` rule swallows our test harness — re-include it +!src/test/lib/ diff --git a/ui-tui-opentui-v2/src/test/lib/effect.ts b/ui-tui-opentui-v2/src/test/lib/effect.ts new file mode 100644 index 00000000000..dab257689f1 --- /dev/null +++ b/ui-tui-opentui-v2/src/test/lib/effect.ts @@ -0,0 +1,42 @@ +/** + * test/lib/effect.ts — recovers `it.effect` ergonomics on plain `bun test` + * WITHOUT @effect/vitest (spec v4 §5 Layer 1). Each test gets a per-call + * ManagedRuntime so layers/services don't leak between tests; the TestClock + * is installed via `TestClock.layer()` (beta.78 replacement for 3.x + * `TestContext.layer()` — there is no `TestContext` on this line). + * + * Gotcha (verified against effect@4.0.0-beta.78 .d.ts): `TestClock.adjust`/ + * `setTime` return `Effect` with no R requirement, so a program that only + * uses them won't surface TestClock in its R channel — hence the + * `R extends TestClock.TestClock` constraint to force the layer to be provided. + */ +import { type Effect, Layer, ManagedRuntime } from 'effect' +import { TestClock } from 'effect/testing' + +export const TestClockLayer: Layer.Layer = TestClock.layer() + +/** Run an Effect with the test clock available; fresh runtime per call. */ +export async function testEffect(effect: Effect.Effect): Promise { + const runtime = ManagedRuntime.make(TestClockLayer) + try { + return await runtime.runPromise(effect as Effect.Effect) + } finally { + await runtime.dispose() + } +} + +/** Run an Effect against a provided layer (+ test clock), fresh runtime per call. */ +export async function testLayer( + layer: Layer.Layer, + effect: Effect.Effect +): Promise { + const full = Layer.mergeAll(TestClock.layer(), layer) + const runtime = ManagedRuntime.make(full) + try { + return await runtime.runPromise(effect) + } finally { + await runtime.dispose() + } +} + +export { TestClock } diff --git a/ui-tui-opentui-v2/src/test/lib/render.ts b/ui-tui-opentui-v2/src/test/lib/render.ts new file mode 100644 index 00000000000..6231625dec5 --- /dev/null +++ b/ui-tui-opentui-v2/src/test/lib/render.ts @@ -0,0 +1,90 @@ +/** + * 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 { 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' + +/** 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 + readonly waitForFrame: (predicate: (frame: string) => boolean) => Promise + readonly resize: (width: number, height: number) => void + 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 } +): Promise { + const setup = await testRender(withKeymap(node), { + width: options?.width ?? 80, + height: options?.height ?? 24, + exitOnCtrlC: 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(), + waitForFrame: predicate => setup.waitForFrame(predicate), + resize: (width, height) => setup.resize(width, height), + 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() + } +}