James Long 3 месяцев назад
Родитель
Сommit
f1c0b2651a

+ 11 - 0
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(...)`.

+ 3 - 0
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.

+ 129 - 0
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<void>
+  readonly pressEnter: () => void
+  readonly pressArrow: (direction: "up" | "down" | "left" | "right") => void
+}
+
+export interface MockMouse {
+  readonly click: (x: number, y: number) => Promise<void>
+}
+
+export interface Harness {
+  readonly renderer: CliRenderer
+  readonly mockInput: MockInput
+  readonly mockMouse: MockMouse
+  readonly renderOnce: () => Promise<void>
+}
+
+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"

+ 64 - 0
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(
+      () => <input focused onInput={setValue} width={20} />,
+      { 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(
+      () => <box ref={box} focusable style={{ width: 10, height: 3 }} />,
+      { 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(
+      () => <box onMouseDown={() => 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()
+    }
+  })
+})