feat(simulation): record viewport resizes (#36306)
This commit is contained in:
parent
6216dbae15
commit
be18f22842
8 changed files with 90 additions and 11 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue