feat(simulation): record viewport resizes (#36306)

This commit is contained in:
James Long 2026-07-10 15:08:43 -04:00 committed by GitHub
commit be18f22842
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 90 additions and 11 deletions

View file

@ -14,6 +14,7 @@ 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
}
@ -61,15 +62,20 @@ export function createHarness(renderer: CliRenderer): Harness {
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()
}),
screen:
setup?.captureCharFrame ??
(() => decoder.decode((Reflect.get(renderer, "currentRenderBuffer") as RenderBuffer).getRealCharBytes(true))),
// 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.
screen: () =>
decoder.decode(
(Reflect.get(renderer, "currentRenderBuffer") as RenderBuffer).getRealCharBytes(),
),
}
}
@ -151,6 +157,13 @@ export async function execute(harness: Harness, action: Action) {
case "ui.click":
await harness.mockMouse.click(action.x, action.y)
break
case "ui.resize":
if (!Number.isSafeInteger(action.cols) || action.cols <= 0 || !Number.isSafeInteger(action.rows) || action.rows <= 0) {
throw 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
}
await harness.renderOnce()
return state(harness)

View file

@ -11,21 +11,28 @@ const recordings = new WeakMap<CliRenderer, Timeline>()
* module-side so the harness can use supported testing APIs without app
* code carrying it around.
*/
export async function create(options: CliRendererConfig, path?: string): Promise<CliRenderer> {
export interface Viewport {
readonly cols: number
readonly rows: number
}
export async function create(options: CliRendererConfig, path?: string, viewport?: Viewport): Promise<CliRenderer> {
const cols = viewport?.cols ?? 100
const rows = viewport?.rows ?? 40
if (!path) {
const setup = await createTestRenderer({
...options,
width: 100,
height: 40,
width: cols,
height: rows,
})
setups.set(setup.renderer, setup)
return setup.renderer
}
const recording = await Timeline.create(path, 100, 40)
const recording = await Timeline.create(path, cols, rows)
const setup = await createTestRenderer({
...options,
width: 100,
height: 40,
width: cols,
height: rows,
stdout: recording as unknown as NodeJS.WriteStream,
bufferedOutput: "stdout",
onDestroy: () => {
@ -41,6 +48,10 @@ export async function create(options: CliRendererConfig, path?: string): Promise
return setup.renderer
}
export function recordResize(renderer: CliRenderer, cols: number, rows: number) {
recordings.get(renderer)?.resize(cols, rows)
}
export function setupFor(renderer: CliRenderer): TestRendererSetup | undefined {
return setups.get(renderer)
}

View file

@ -47,6 +47,8 @@ async function handle(
x: request.params.x,
y: request.params.y,
})
case "ui.resize":
return SimulationActions.execute(harness, { type: "ui.resize", cols: request.params.cols, rows: request.params.rows })
}
}

View file

@ -16,8 +16,9 @@ export async function create(options: CliRendererConfig): Promise<CliRenderer> {
const headless = process.env.OPENCODE_DRIVE_RENDERER === "headless"
const manifest = DriveManifest.resolve()
const renderer = headless
? await SimulationRenderer.create(options, manifest.recording?.timeline)
? await SimulationRenderer.create(options, manifest.recording?.timeline, manifest.viewport)
: await createCliRenderer(options)
if (!headless && manifest.viewport) renderer.resize(manifest.viewport.cols, manifest.viewport.rows)
const server = SimulationServer.start(
SimulationActions.createHarness(renderer),
manifest.endpoints.ui,

View file

@ -7,6 +7,10 @@ export interface Manifest {
readonly ui: string
readonly backend: string
}
readonly viewport?: {
readonly cols: number
readonly rows: number
}
readonly recording?: {
readonly timeline: string
}
@ -35,6 +39,7 @@ export function resolve() {
if (!isManifest(manifest)) throw new Error(`Invalid drive manifest: ${file}`)
validateEndpoint(manifest.endpoints.ui, "ui")
validateEndpoint(manifest.endpoints.backend, "backend")
if (manifest.viewport) validateViewport(manifest.viewport)
if (manifest.recording && !isAbsolute(manifest.recording.timeline)) {
throw new Error(`Invalid drive recording timeline path: ${manifest.recording.timeline}`)
}
@ -55,4 +60,11 @@ function validateEndpoint(value: string, name: string) {
}
}
function validateViewport(value: Manifest["viewport"]) {
if (!value) return
if (!Number.isSafeInteger(value.cols) || value.cols <= 0 || !Number.isSafeInteger(value.rows) || value.rows <= 0) {
throw new Error(`Invalid drive viewport: ${JSON.stringify(value)}`)
}
}
export * as DriveManifest from "./manifest"

View file

@ -65,6 +65,7 @@ export namespace Frontend {
Schema.Struct({ type: Schema.Literal("ui.arrow"), direction: Schema.Literals(["up", "down", "left", "right"]) }),
Schema.Struct({ type: Schema.Literal("ui.focus"), target: Schema.Number }),
Schema.Struct({ type: Schema.Literal("ui.click"), target: Schema.Number, x: Schema.Number, y: Schema.Number }),
Schema.Struct({ type: Schema.Literal("ui.resize"), cols: Schema.Number, rows: Schema.Number }),
])
export type Action = Schema.Schema.Type<typeof Action>
@ -121,12 +122,16 @@ export namespace Frontend {
export const ClickParams = Schema.Struct({ target: Schema.Number, x: Schema.Number, y: Schema.Number })
export interface ClickParams extends Schema.Schema.Type<typeof ClickParams> {}
export const ResizeParams = Schema.Struct({ cols: Schema.Number, rows: Schema.Number })
export interface ResizeParams extends Schema.Schema.Type<typeof ResizeParams> {}
export const Request = Schema.Union([
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.type"), params: TypeParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.press"), params: PressParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.arrow"), params: ArrowParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.focus"), params: FocusParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.click"), params: ClickParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.resize"), params: ResizeParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.matches"), params: MatchesParams }),
Schema.Struct({
...JsonRpc.RequestFields,

View file

@ -21,7 +21,15 @@ export const Output = Schema.Struct({
})
export interface Output extends Schema.Schema.Type<typeof Output> {}
export const Event = Schema.Union([Header, Output])
export const Resize = Schema.Struct({
type: Schema.Literal("resize"),
at_ms: Schema.Number,
cols: Schema.Number,
rows: Schema.Number,
})
export interface Resize extends Schema.Schema.Type<typeof Resize> {}
export const Event = Schema.Union([Header, Output, Resize])
export type Event = Schema.Schema.Type<typeof Event>
export class Timeline extends Writable {
@ -98,6 +106,12 @@ export class Timeline extends Writable {
return this.done
}
resize(cols: number, rows: number) {
if (this.writableEnded) return
const event = { type: "resize", at_ms: this.elapsed(), cols, rows } satisfies Resize
this.output.write(`${JSON.stringify(event)}\n`)
}
private elapsed() {
return Math.max(0, Math.round(performance.now() - this.started))
}

View file

@ -2,6 +2,8 @@ import { expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { TextRenderable } from "@opentui/core"
import { createHarness, matches } from "../src/frontend/actions"
import { SimulationRenderer } from "../src/frontend/renderer"
import { Timeline, type Event } from "../src/recording"
@ -14,6 +16,7 @@ test("streams ANSI chunks into a versioned JSONL timeline", async () => {
await new Promise<void>((resolve, reject) => {
timeline.write(Buffer.from("\u001b[2Jhello"), (error) => (error ? reject(error) : resolve()))
})
timeline.resize(100, 30)
expect(await timeline.finish()).toBe(path)
await new Promise<void>((resolve) => timeline.write(Buffer.from("ignored"), () => resolve()))
@ -26,6 +29,7 @@ test("streams ANSI chunks into a versioned JSONL timeline", async () => {
if (events[1]?.type !== "output") throw new Error("Missing output event")
expect(Buffer.from(events[1].data, "base64").toString()).toBe("\u001b[2Jhello")
expect(events[1].at_ms).toBeGreaterThanOrEqual(0)
expect(events[2]).toMatchObject({ type: "resize", cols: 100, rows: 30 })
expect(events.at(-1)).toMatchObject({ type: "output", data: "" })
} finally {
await rm(directory, { recursive: true, force: true })
@ -53,3 +57,20 @@ test("captures native renderer output and finishes on destroy", async () => {
await rm(directory, { recursive: true, force: true })
}
})
test("matches live screen text while recording", async () => {
const directory = await mkdtemp(join(tmpdir(), "simulation-recording-matches-"))
const path = join(directory, "timeline.jsonl")
const renderer = await SimulationRenderer.create({}, path)
try {
renderer.root.add(new TextRenderable(renderer, { content: "recorded screen text" }))
await SimulationRenderer.setupFor(renderer)?.renderOnce()
expect(matches(createHarness(renderer), "recorded screen text")).toBe(true)
} finally {
renderer.destroy()
await SimulationRenderer.finish(renderer)
await rm(directory, { recursive: true, force: true })
}
})