feat(simulation): record viewport resizes (#36306)
This commit is contained in:
@@ -14,6 +14,7 @@ export interface Harness {
|
|||||||
readonly renderer: CliRenderer
|
readonly renderer: CliRenderer
|
||||||
readonly mockInput: MockInput
|
readonly mockInput: MockInput
|
||||||
readonly mockMouse: MockMouse
|
readonly mockMouse: MockMouse
|
||||||
|
readonly resize: (cols: number, rows: number) => void
|
||||||
readonly renderOnce: () => Promise<void>
|
readonly renderOnce: () => Promise<void>
|
||||||
readonly screen: () => string
|
readonly screen: () => string
|
||||||
}
|
}
|
||||||
@@ -61,15 +62,20 @@ export function createHarness(renderer: CliRenderer): Harness {
|
|||||||
renderer,
|
renderer,
|
||||||
mockInput: setup?.mockInput ?? createMockKeys(renderer),
|
mockInput: setup?.mockInput ?? createMockKeys(renderer),
|
||||||
mockMouse: setup?.mockMouse ?? createMockMouse(renderer),
|
mockMouse: setup?.mockMouse ?? createMockMouse(renderer),
|
||||||
|
resize: setup?.resize ?? ((cols, rows) => renderer.resize(cols, rows)),
|
||||||
renderOnce:
|
renderOnce:
|
||||||
setup?.renderOnce ??
|
setup?.renderOnce ??
|
||||||
(async () => {
|
(async () => {
|
||||||
renderer.requestRender()
|
renderer.requestRender()
|
||||||
await renderer.idle()
|
await renderer.idle()
|
||||||
}),
|
}),
|
||||||
screen:
|
// captureCharFrame follows the test renderer's output sink. Recording
|
||||||
setup?.captureCharFrame ??
|
// redirects that sink to the timeline, so read the live render buffer
|
||||||
(() => decoder.decode((Reflect.get(renderer, "currentRenderBuffer") as RenderBuffer).getRealCharBytes(true))),
|
// 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":
|
case "ui.click":
|
||||||
await harness.mockMouse.click(action.x, action.y)
|
await harness.mockMouse.click(action.x, action.y)
|
||||||
break
|
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()
|
await harness.renderOnce()
|
||||||
return state(harness)
|
return state(harness)
|
||||||
|
|||||||
@@ -11,21 +11,28 @@ const recordings = new WeakMap<CliRenderer, Timeline>()
|
|||||||
* module-side so the harness can use supported testing APIs without app
|
* module-side so the harness can use supported testing APIs without app
|
||||||
* code carrying it around.
|
* 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) {
|
if (!path) {
|
||||||
const setup = await createTestRenderer({
|
const setup = await createTestRenderer({
|
||||||
...options,
|
...options,
|
||||||
width: 100,
|
width: cols,
|
||||||
height: 40,
|
height: rows,
|
||||||
})
|
})
|
||||||
setups.set(setup.renderer, setup)
|
setups.set(setup.renderer, setup)
|
||||||
return setup.renderer
|
return setup.renderer
|
||||||
}
|
}
|
||||||
const recording = await Timeline.create(path, 100, 40)
|
const recording = await Timeline.create(path, cols, rows)
|
||||||
const setup = await createTestRenderer({
|
const setup = await createTestRenderer({
|
||||||
...options,
|
...options,
|
||||||
width: 100,
|
width: cols,
|
||||||
height: 40,
|
height: rows,
|
||||||
stdout: recording as unknown as NodeJS.WriteStream,
|
stdout: recording as unknown as NodeJS.WriteStream,
|
||||||
bufferedOutput: "stdout",
|
bufferedOutput: "stdout",
|
||||||
onDestroy: () => {
|
onDestroy: () => {
|
||||||
@@ -41,6 +48,10 @@ export async function create(options: CliRendererConfig, path?: string): Promise
|
|||||||
return setup.renderer
|
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 {
|
export function setupFor(renderer: CliRenderer): TestRendererSetup | undefined {
|
||||||
return setups.get(renderer)
|
return setups.get(renderer)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ async function handle(
|
|||||||
x: request.params.x,
|
x: request.params.x,
|
||||||
y: request.params.y,
|
y: request.params.y,
|
||||||
})
|
})
|
||||||
|
case "ui.resize":
|
||||||
|
return SimulationActions.execute(harness, { type: "ui.resize", cols: request.params.cols, rows: request.params.rows })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ export async function create(options: CliRendererConfig): Promise<CliRenderer> {
|
|||||||
const headless = process.env.OPENCODE_DRIVE_RENDERER === "headless"
|
const headless = process.env.OPENCODE_DRIVE_RENDERER === "headless"
|
||||||
const manifest = DriveManifest.resolve()
|
const manifest = DriveManifest.resolve()
|
||||||
const renderer = headless
|
const renderer = headless
|
||||||
? await SimulationRenderer.create(options, manifest.recording?.timeline)
|
? await SimulationRenderer.create(options, manifest.recording?.timeline, manifest.viewport)
|
||||||
: await createCliRenderer(options)
|
: await createCliRenderer(options)
|
||||||
|
if (!headless && manifest.viewport) renderer.resize(manifest.viewport.cols, manifest.viewport.rows)
|
||||||
const server = SimulationServer.start(
|
const server = SimulationServer.start(
|
||||||
SimulationActions.createHarness(renderer),
|
SimulationActions.createHarness(renderer),
|
||||||
manifest.endpoints.ui,
|
manifest.endpoints.ui,
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ export interface Manifest {
|
|||||||
readonly ui: string
|
readonly ui: string
|
||||||
readonly backend: string
|
readonly backend: string
|
||||||
}
|
}
|
||||||
|
readonly viewport?: {
|
||||||
|
readonly cols: number
|
||||||
|
readonly rows: number
|
||||||
|
}
|
||||||
readonly recording?: {
|
readonly recording?: {
|
||||||
readonly timeline: string
|
readonly timeline: string
|
||||||
}
|
}
|
||||||
@@ -35,6 +39,7 @@ export function resolve() {
|
|||||||
if (!isManifest(manifest)) throw new Error(`Invalid drive manifest: ${file}`)
|
if (!isManifest(manifest)) throw new Error(`Invalid drive manifest: ${file}`)
|
||||||
validateEndpoint(manifest.endpoints.ui, "ui")
|
validateEndpoint(manifest.endpoints.ui, "ui")
|
||||||
validateEndpoint(manifest.endpoints.backend, "backend")
|
validateEndpoint(manifest.endpoints.backend, "backend")
|
||||||
|
if (manifest.viewport) validateViewport(manifest.viewport)
|
||||||
if (manifest.recording && !isAbsolute(manifest.recording.timeline)) {
|
if (manifest.recording && !isAbsolute(manifest.recording.timeline)) {
|
||||||
throw new Error(`Invalid drive recording timeline path: ${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"
|
export * as DriveManifest from "./manifest"
|
||||||
|
|||||||
@@ -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.arrow"), direction: Schema.Literals(["up", "down", "left", "right"]) }),
|
||||||
Schema.Struct({ type: Schema.Literal("ui.focus"), target: Schema.Number }),
|
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.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>
|
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 const ClickParams = Schema.Struct({ target: Schema.Number, x: Schema.Number, y: Schema.Number })
|
||||||
export interface ClickParams extends Schema.Schema.Type<typeof ClickParams> {}
|
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([
|
export const Request = Schema.Union([
|
||||||
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.type"), params: TypeParams }),
|
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.press"), params: PressParams }),
|
||||||
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.arrow"), params: ArrowParams }),
|
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.focus"), params: FocusParams }),
|
||||||
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.click"), params: ClickParams }),
|
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, method: Schema.Literal("ui.matches"), params: MatchesParams }),
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
...JsonRpc.RequestFields,
|
...JsonRpc.RequestFields,
|
||||||
|
|||||||
@@ -21,7 +21,15 @@ export const Output = Schema.Struct({
|
|||||||
})
|
})
|
||||||
export interface Output extends Schema.Schema.Type<typeof Output> {}
|
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 type Event = Schema.Schema.Type<typeof Event>
|
||||||
|
|
||||||
export class Timeline extends Writable {
|
export class Timeline extends Writable {
|
||||||
@@ -98,6 +106,12 @@ export class Timeline extends Writable {
|
|||||||
return this.done
|
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() {
|
private elapsed() {
|
||||||
return Math.max(0, Math.round(performance.now() - this.started))
|
return Math.max(0, Math.round(performance.now() - this.started))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { expect, test } from "bun:test"
|
|||||||
import { mkdtemp, rm } from "node:fs/promises"
|
import { mkdtemp, rm } from "node:fs/promises"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
|
import { TextRenderable } from "@opentui/core"
|
||||||
|
import { createHarness, matches } from "../src/frontend/actions"
|
||||||
import { SimulationRenderer } from "../src/frontend/renderer"
|
import { SimulationRenderer } from "../src/frontend/renderer"
|
||||||
import { Timeline, type Event } from "../src/recording"
|
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) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
timeline.write(Buffer.from("\u001b[2Jhello"), (error) => (error ? reject(error) : resolve()))
|
timeline.write(Buffer.from("\u001b[2Jhello"), (error) => (error ? reject(error) : resolve()))
|
||||||
})
|
})
|
||||||
|
timeline.resize(100, 30)
|
||||||
expect(await timeline.finish()).toBe(path)
|
expect(await timeline.finish()).toBe(path)
|
||||||
await new Promise<void>((resolve) => timeline.write(Buffer.from("ignored"), () => resolve()))
|
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")
|
if (events[1]?.type !== "output") throw new Error("Missing output event")
|
||||||
expect(Buffer.from(events[1].data, "base64").toString()).toBe("\u001b[2Jhello")
|
expect(Buffer.from(events[1].data, "base64").toString()).toBe("\u001b[2Jhello")
|
||||||
expect(events[1].at_ms).toBeGreaterThanOrEqual(0)
|
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: "" })
|
expect(events.at(-1)).toMatchObject({ type: "output", data: "" })
|
||||||
} finally {
|
} finally {
|
||||||
await rm(directory, { recursive: true, force: true })
|
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 })
|
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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user