mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-10 13:31:38 +00:00
opentui(test): track the test harness (test/lib/) swallowed by global lib/ ignore
The Solid render-test harness (src/test/lib/render.ts + effect.ts) was never committed — a global ~/.gitignore_global `lib/` rule silently excluded it, so the opentui-v2 test suite wasn't reproducible from a clean checkout (render.test.tsx imports ./lib/render). Force-add both + add a repo .gitignore negation (!src/test/lib/). render.ts also carries the withKeymap() wrapper the keymap migration needs (view tests mount under a KeymapProvider). 91 pass.
This commit is contained in:
parent
01fa8dcc00
commit
79dc862680
3 changed files with 135 additions and 0 deletions
3
ui-tui-opentui-v2/.gitignore
vendored
3
ui-tui-opentui-v2/.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
42
ui-tui-opentui-v2/src/test/lib/effect.ts
Normal file
42
ui-tui-opentui-v2/src/test/lib/effect.ts
Normal file
|
|
@ -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<void>` 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.TestClock> = TestClock.layer()
|
||||
|
||||
/** Run an Effect with the test clock available; fresh runtime per call. */
|
||||
export async function testEffect<A, E, R extends TestClock.TestClock>(effect: Effect.Effect<A, E, R>): Promise<A> {
|
||||
const runtime = ManagedRuntime.make(TestClockLayer)
|
||||
try {
|
||||
return await runtime.runPromise(effect as Effect.Effect<A, E, TestClock.TestClock>)
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/** Run an Effect against a provided layer (+ test clock), fresh runtime per call. */
|
||||
export async function testLayer<A, E, ROut, RErr>(
|
||||
layer: Layer.Layer<ROut, RErr, never>,
|
||||
effect: Effect.Effect<A, E, ROut | TestClock.TestClock>
|
||||
): Promise<A> {
|
||||
const full = Layer.mergeAll(TestClock.layer(), layer)
|
||||
const runtime = ManagedRuntime.make(full)
|
||||
try {
|
||||
return await runtime.runPromise(effect)
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export { TestClock }
|
||||
90
ui-tui-opentui-v2/src/test/lib/render.ts
Normal file
90
ui-tui-opentui-v2/src/test/lib/render.ts
Normal file
|
|
@ -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 `<scrollbox>` needs a couple of
|
||||
* passes to measure content + apply stickyStart; and the native `<markdown>`
|
||||
* (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 `<KeymapProvider>`. 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<string>
|
||||
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<RenderProbe> {
|
||||
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 `<markdown internalBlockMode="top-level">` 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<string> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue