actions
This commit is contained in:
parent
eab9a4f564
commit
f1c0b2651a
4 changed files with 207 additions and 0 deletions
|
|
@ -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(...)`.
|
||||
|
|
|
|||
|
|
@ -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
packages/opencode/src/testing/simulation/actions.ts
Normal file
129
packages/opencode/src/testing/simulation/actions.ts
Normal file
|
|
@ -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
packages/opencode/test/testing/simulation/actions.test.tsx
Normal file
64
packages/opencode/test/testing/simulation/actions.test.tsx
Normal file
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue