diff --git a/packages/opencode/specs/property-based-tui-testing-working-notes.md b/packages/opencode/specs/property-based-tui-testing-working-notes.md index 6e882c06f4..fa8957ec88 100644 --- a/packages/opencode/specs/property-based-tui-testing-working-notes.md +++ b/packages/opencode/specs/property-based-tui-testing-working-notes.md @@ -25,3 +25,14 @@ - Implemented `TuiSimulation.createSimulationRenderer(...)` beside `thread.ts`. It creates a test renderer and exposes `renderOnce`, `screen`, `spans`, and `destroy`. - `thread.ts` checks `OPENCODE_SIMULATION`, creates the fake renderer there, starts the normal worker/backend, and passes the renderer into `tui(...)`. - `tui(...)` now accepts an injected `CliRenderer`, test mode, and an `onReady` callback. Production still creates the real renderer. + +## OpenTUI Action APIs + +- Interactable discovery can walk `renderer.root.getChildren()` recursively. +- `Renderable.focusable` and `Renderable.focused` are public and enough to discover focus targets. +- `renderer.currentFocusedEditor` identifies active text input/edit-buffer targets for typing/submission. +- `renderer.hitTest(x, y)` maps terminal coordinates through the hit grid to a renderable id. +- Renderables have public geometry: `screenX`, `screenY`, `width`, `height`, and `num`. +- Mouse listener metadata is stored internally on renderables; first pass checks `_mouseListener` / `_mouseListeners` at runtime to identify clickable targets. This is pragmatic but not a stable public API. +- Test execution uses `mockInput.typeText`, `mockInput.pressEnter`, `mockInput.pressArrow`, and `mockMouse.click` from OpenTUI testing. +- Implemented `SimulationActions` with `elements(...)`, `actions(...)`, and `execute(...)`. diff --git a/packages/opencode/specs/property-based-tui-testing.md b/packages/opencode/specs/property-based-tui-testing.md index 6abe7f7503..76b7ecce8d 100644 --- a/packages/opencode/specs/property-based-tui-testing.md +++ b/packages/opencode/specs/property-based-tui-testing.md @@ -260,6 +260,8 @@ Implementation shape: - Add a renderer factory/testing hook to `tui(...)` so tests can pass a fake renderer. - Current first pass checks `OPENCODE_SIMULATION` in `cli/cmd/tui/thread.ts`, starts the normal worker/backend, and injects an OpenTUI test renderer into `tui(...)`. - Fake renderer setup lives in `cli/cmd/tui/simulation.ts` and returns `renderOnce`, `screen`, and `spans` helpers for the thread-side simulation runner. +- Initial action discovery lives in `packages/opencode/src/testing/simulation/actions.ts`. +- OpenTUI exposes `renderer.root` for walking renderables, `Renderable.focusable`, `renderer.currentFocusedEditor`, `renderer.hitTest(...)`, and test `mockInput` / `mockMouse` APIs for execution. - Do not render to a real terminal in simulation mode. - Investigate OpenTUI APIs for walking the render tree and extracting focusable/clickable/editable elements. - Investigate OpenTUI APIs for reading the screen buffer from the fake renderer. @@ -271,6 +273,7 @@ Todos: - [x] Inspect `@opentui/solid` `testRender` capabilities. - [x] Determine how to get a screen buffer string/snapshot from the fake renderer. - [x] Determine first structured capture API for interactable discovery: `captureSpans()`. +- [x] Add first pass renderable-based interactable discovery for focused editors, focusable elements, and mouse handlers. - [x] Add a minimal renderer factory override to `tui(...)` or app startup. - [ ] Expose prompt ref, route, sync state, keymap, and renderer to the simulation harness. - [ ] Verify TUI starts in fake renderer with no real terminal output. diff --git a/packages/opencode/src/testing/simulation/actions.ts b/packages/opencode/src/testing/simulation/actions.ts new file mode 100644 index 0000000000..9a060a513a --- /dev/null +++ b/packages/opencode/src/testing/simulation/actions.ts @@ -0,0 +1,129 @@ +import type { CliRenderer, Renderable } from "@opentui/core" + +export interface MockInput { + readonly typeText: (text: string) => Promise + readonly pressEnter: () => void + readonly pressArrow: (direction: "up" | "down" | "left" | "right") => void +} + +export interface MockMouse { + readonly click: (x: number, y: number) => Promise +} + +export interface Harness { + readonly renderer: CliRenderer + readonly mockInput: MockInput + readonly mockMouse: MockMouse + readonly renderOnce: () => Promise +} + +export interface Element { + readonly id: string + readonly num: number + readonly x: number + readonly y: number + readonly width: number + readonly height: number + readonly focusable: boolean + readonly focused: boolean + readonly clickable: boolean + readonly editor: boolean +} + +export type Action = + | { readonly type: "typeText"; readonly text: string } + | { readonly type: "pressEnter" } + | { readonly type: "pressArrow"; readonly direction: "up" | "down" | "left" | "right" } + | { readonly type: "focus"; readonly target: number } + | { readonly type: "click"; readonly target: number; readonly x: number; readonly y: number } + +function children(renderable: Renderable) { + return renderable.getChildren().filter((child): child is Renderable => "num" in child) +} + +function all(renderable: Renderable): Renderable[] { + return [renderable, ...children(renderable).flatMap(all)] +} + +function mouseListeners(renderable: Renderable) { + const general = Reflect.get(renderable, "_mouseListener") + const specific = Reflect.get(renderable, "_mouseListeners") + return Boolean(general) || (specific && typeof specific === "object" && Object.keys(specific).length > 0) +} + +function hit(renderer: CliRenderer, renderable: Renderable) { + if (renderable.width <= 0 || renderable.height <= 0) return false + const x = Math.floor(renderable.screenX + renderable.width / 2) + const y = Math.floor(renderable.screenY + renderable.height / 2) + return renderer.hitTest(x, y) === renderable.num +} + +export function elements(renderer: CliRenderer): Element[] { + return all(renderer.root) + .filter((renderable) => renderable.visible && !renderable.isDestroyed) + .map((renderable) => { + const clickable = mouseListeners(renderable) && hit(renderer, renderable) + return { + id: renderable.id, + num: renderable.num, + x: renderable.screenX, + y: renderable.screenY, + width: renderable.width, + height: renderable.height, + focusable: renderable.focusable, + focused: renderable.focused, + clickable, + editor: renderer.currentFocusedEditor === renderable, + } satisfies Element + }) + .filter((element) => element.focusable || element.clickable || element.editor) +} + +export function actions(renderer: CliRenderer, options: { text?: string } = {}): Action[] { + const result: Action[] = [] + const items = elements(renderer) + if (renderer.currentFocusedEditor) { + result.push({ type: "typeText", text: options.text ?? "hello" }, { type: "pressEnter" }) + } + result.push(...items.filter((item) => item.focusable && !item.focused).map((item) => ({ type: "focus" as const, target: item.num }))) + result.push( + ...items + .filter((item) => item.clickable) + .map((item) => ({ + type: "click" as const, + target: item.num, + x: Math.floor(item.x + item.width / 2), + y: Math.floor(item.y + item.height / 2), + })), + ) + result.push( + { type: "pressArrow", direction: "down" }, + { type: "pressArrow", direction: "up" }, + ) + return result +} + +export async function execute(harness: Harness, action: Action) { + switch (action.type) { + case "typeText": + await harness.mockInput.typeText(action.text) + break + case "pressEnter": + harness.mockInput.pressEnter() + break + case "pressArrow": + harness.mockInput.pressArrow(action.direction) + break + case "focus": { + const renderable = all(harness.renderer.root).find((item) => item.num === action.target) + renderable?.focus() + break + } + case "click": + await harness.mockMouse.click(action.x, action.y) + break + } + await harness.renderOnce() +} + +export * as SimulationActions from "./actions" diff --git a/packages/opencode/test/testing/simulation/actions.test.tsx b/packages/opencode/test/testing/simulation/actions.test.tsx new file mode 100644 index 0000000000..f74e5ecdca --- /dev/null +++ b/packages/opencode/test/testing/simulation/actions.test.tsx @@ -0,0 +1,64 @@ +/** @jsxImportSource @opentui/solid */ +import { describe, expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import { createSignal } from "solid-js" +import { SimulationActions } from "../../../src/testing/simulation/actions" + +describe("SimulationActions", () => { + test("discovers focused editors and executes text actions", async () => { + const [value, setValue] = createSignal("") + const app = await testRender( + () => , + { width: 40, height: 8 }, + ) + + try { + await app.renderOnce() + const items = SimulationActions.elements(app.renderer) + expect(items.some((item) => item.editor)).toBe(true) + + await SimulationActions.execute(app, { type: "typeText", text: "hello" }) + expect(value()).toBe("hello") + } finally { + app.renderer.destroy() + } + }) + + test("discovers focusable elements and executes focus actions", async () => { + let box: any + const app = await testRender( + () => , + { width: 40, height: 8 }, + ) + + try { + await app.renderOnce() + const target = SimulationActions.elements(app.renderer).find((item) => item.id === box.id) + expect(target?.focusable).toBe(true) + expect(box.focused).toBe(false) + + await SimulationActions.execute(app, { type: "focus", target: box.num }) + expect(box.focused).toBe(true) + } finally { + app.renderer.destroy() + } + }) + + test("discovers clickable elements and executes click actions", async () => { + let clicked = 0 + const app = await testRender( + () => clicked++} style={{ width: 10, height: 3 }} />, + { width: 40, height: 8, useMouse: true }, + ) + + try { + await app.renderOnce() + const click = SimulationActions.actions(app.renderer).find((action) => action.type === "click") + expect(click).toBeDefined() + await SimulationActions.execute(app, click!) + expect(clicked).toBe(1) + } finally { + app.renderer.destroy() + } + }) +})