Something went wrong. Try again.
opencode v2 mirror
Something went wrong. Try again.
12 kB · 309 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310import { ImageRenderable, type CliRenderer, type Renderable } from "@opentui/core"import { createMockKeys, createMockMouse, KeyCodes, type KeyInput, type MockInput, type MockMouse,} from "@opentui/core/testing"import { Effect, Encoding, Schema } from "effect"import { SimulationProtocol } from "../protocol"import { SimulationRenderer } from "./renderer"import { SimulationSemantics } from "./semantics"
export type Action = SimulationProtocol.Frontend.Actionexport type Element = SimulationProtocol.Frontend.Element
export interface Harness { readonly renderer: CliRenderer readonly mockInput: MockInput readonly mockMouse: MockMouse readonly resize: (cols: number, rows: number) => void readonly renderOnce: () => Promise<void> readonly screen: () => string}
type RenderBuffer = { readonly width: number readonly height: number getRealCharBytes(includeAnsi?: boolean): Uint8Array}
const decoder = new TextDecoder()
function isKeyCode(key: string): key is keyof typeof KeyCodes { return Object.hasOwn(KeyCodes, key)}
function keyInput(key: string): KeyInput { const named = key.toUpperCase() return isKeyCode(named) ? named : key}
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) { // oxlint-disable-next-line no-restricted-globals -- OpenTUI does not expose listener state through its public API. const general = Reflect.get(renderable, "_mouseListener") // oxlint-disable-next-line no-restricted-globals -- OpenTUI does not expose listener state through its public API. 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) const target = renderer.hitTest(x, y) return all(renderable).some((item) => item.num === target)}
/** * Builds the harness the simulation server drives. * * When the renderer is the headless simulation renderer, its TestRendererSetup * provides the supported testing APIs. For the visible terminal renderer the * harness falls back to `requestRender` + `idle` and reading the private * `currentRenderBuffer`. */export function createHarness(renderer: CliRenderer): Harness { const setup = SimulationRenderer.setupFor(renderer) return { renderer, mockInput: setup?.mockInput ?? createMockKeys(renderer), mockMouse: setup?.mockMouse ?? createMockMouse(renderer), resize: setup?.resize ?? ((cols, rows) => renderer.resize(cols, rows)), renderOnce: setup?.renderOnce ?? (async () => { renderer.requestRender() await renderer.idle() }), // captureCharFrame follows the test renderer's output sink. Recording // redirects that sink to the timeline, so read the live render buffer // instead; it is also the source used by screenshots. // oxlint-disable-next-line no-restricted-globals -- OpenTUI does not expose the live render buffer publicly. screen: () => decoder.decode((Reflect.get(renderer, "currentRenderBuffer") as RenderBuffer).getRealCharBytes()), }}
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 state(harness: Harness) { const renderable = harness.renderer.currentFocusedRenderable?.num return { focused: { ...(renderable === undefined ? {} : { renderable }), editor: Boolean(harness.renderer.currentFocusedEditor), }, elements: elements(harness.renderer), }}
export function snapshot(harness: Harness): SimulationProtocol.Frontend.SemanticSnapshot { const ids = new Set<string>() const visit = (renderable: Renderable, parent?: string): SimulationProtocol.Frontend.SemanticNode[] => { if (!renderable.visible || renderable.isDestroyed) return [] const definition = SimulationSemantics.read(renderable)?.() if (definition && ids.has(renderable.id)) throw new Error(`duplicate semantic UI id: ${renderable.id}`) if (definition) ids.add(renderable.id) const node = definition ? [{ id: renderable.id, ...definition, ...(parent === undefined ? {} : { parent }), element: renderable.num }] : [] const ancestor = definition ? renderable.id : parent return [...node, ...children(renderable).flatMap((child) => visit(child, ancestor))] } return Schema.decodeUnknownSync(SimulationProtocol.Frontend.SemanticSnapshot)({ format: "opencode-ui-snapshot-v1", nodes: visit(harness.renderer.root), })}
export function matches(harness: Pick<Harness, "screen">, text: string) { return harness.screen().includes(text)}
export const capture = Effect.fn("SimulationActions.capture")(function* (harness: Harness) { yield* Effect.tryPromise(() => harness.renderOnce()) const buffer = harness.renderer.currentRenderBuffer return { cols: buffer.width, rows: buffer.height, cursor: [0, 0] as const, lines: buffer.getSpanLines().map((line) => ({ spans: line.spans.map((span) => ({ text: span.text, fg: span.fg.toInts(), bg: span.bg.toInts(), attributes: span.attributes, width: span.width, })), })), images: captureImages(harness.renderer), } satisfies SimulationProtocol.Frontend.CapturedFrame})
function captureImages(renderer: CliRenderer): SimulationProtocol.Frontend.CapturedImage[] { return all(renderer.root).flatMap((renderable) => { if (!(renderable instanceof ImageRenderable) || !renderable.visible || renderable.isDestroyed || !renderable.image) return [] const fitted = renderable.fit === "cover" ? { width: renderable.width, height: renderable.height } : renderable.getFittedSize(renderable.width, renderable.height) if (fitted.width <= 0 || fitted.height <= 0) return [] const x = renderable.screenX + Math.floor((renderable.width - fitted.width) / 2) const y = renderable.screenY + Math.floor((renderable.height - fitted.height) / 2) let source = renderable.image let extracted: typeof source | undefined if (renderable.fit === "cover") { const targetAspect = renderable.width / (renderable.height * renderable.cellAspectRatio) const sourceAspect = source.width / source.height const width = sourceAspect > targetAspect ? Math.max(1, Math.round(source.height * targetAspect)) : source.width const height = sourceAspect > targetAspect ? source.height : Math.max(1, Math.round(source.width / targetAspect)) extracted = source.extract({ left: Math.floor((source.width - width) / 2), top: Math.floor((source.height - height) / 2), width, height, }) source = extracted } try { const raw = source.raw("rgba8") const rgba = new Uint8Array(raw.width * raw.height * 4) for (let row = 0; row < raw.height; row++) rgba.set(raw.data.subarray(row * raw.stride, row * raw.stride + raw.width * 4), row * raw.width * 4) return [{ x, y, width: fitted.width, height: fitted.height, pixelWidth: raw.width, pixelHeight: raw.height, rgba: Encoding.encodeBase64(rgba), }] } finally { extracted?.dispose() } })}
export const execute = Effect.fn("SimulationActions.execute")(function* (harness: Harness, action: Action) { switch (action.type) { case "ui.type": yield* Effect.tryPromise(() => harness.mockInput.typeText(action.text)) break case "ui.press": harness.mockInput.pressKey(keyInput(action.key), action.modifiers) break case "ui.enter": harness.mockInput.pressEnter() break case "ui.arrow": harness.mockInput.pressArrow(action.direction) break case "ui.focus": all(harness.renderer.root) .find((item) => item.num === action.target) ?.focus() break case "ui.mouse": { const params = action.params if (params.x >= harness.renderer.width || params.y >= harness.renderer.height) return yield* Effect.fail(new Error("mouse position must be within the terminal viewport")) const options = { modifiers: params.modifiers } SimulationRenderer.recordPointer(harness.renderer, params.action, params.x, params.y) switch (params.action) { case "move": yield* Effect.tryPromise(() => harness.mockMouse.moveTo(params.x, params.y, options)) break case "down": yield* Effect.tryPromise(() => harness.mockMouse.pressDown(params.x, params.y, mouseButton(params.button), options), ) break case "up": yield* Effect.tryPromise(() => harness.mockMouse.release(params.x, params.y, mouseButton(params.button), options), ) break case "scroll": yield* Effect.tryPromise(() => harness.mockMouse.scroll(params.x, params.y, params.direction, options)) break } break } case "ui.click": { const target = all(harness.renderer.root).find((item) => item.num === action.target) if (!target || !target.visible || target.isDestroyed) return yield* Effect.fail(new Error(`click target is stale or unavailable: ${action.target}`)) if (action.semantic) { const current = snapshot(harness).nodes.find((node) => node.element === action.target) if ( current?.id !== action.semantic.id || current.instance !== action.semantic.instance || current.element !== action.semantic.element ) return yield* Effect.fail(new Error(`semantic click target is stale or unavailable: ${action.semantic.id}`)) } if ( !Number.isFinite(action.x) || action.x < 0 || action.x >= target.width || !Number.isFinite(action.y) || action.y < 0 || action.y >= target.height ) return yield* Effect.fail(new Error("click position must be within the target element")) SimulationRenderer.recordPointer(harness.renderer, "click", target.screenX + action.x, target.screenY + action.y) yield* Effect.tryPromise(() => harness.mockMouse.click(target.screenX + action.x, target.screenY + action.y)) break } case "ui.resize": if ( !Number.isSafeInteger(action.cols) || action.cols <= 0 || !Number.isSafeInteger(action.rows) || action.rows <= 0 ) { return yield* Effect.fail(new Error("resize cols and rows must be positive integers")) } harness.resize(action.cols, action.rows) SimulationRenderer.recordResize(harness.renderer, action.cols, action.rows) break } yield* Effect.tryPromise(() => harness.renderOnce()) return state(harness)})
export * as SimulationActions from "./actions"
function mouseButton(button: "left" | "middle" | "right" = "left") { return ({ left: 0, middle: 1, right: 2 } as const)[button]}