Quellcode durchsuchen

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

James Long vor 1 Monat
Ursprung
Commit
be18f22842

+ 16 - 3
packages/simulation/src/frontend/actions.ts

@@ -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)

+ 17 - 6
packages/simulation/src/frontend/renderer.ts

@@ -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)
 }

+ 2 - 0
packages/simulation/src/frontend/server.ts

@@ -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 })
   }
 }
 

+ 2 - 1
packages/simulation/src/frontend/simulation.ts

@@ -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,

+ 12 - 0
packages/simulation/src/manifest.ts

@@ -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"

+ 5 - 0
packages/simulation/src/protocol/index.ts

@@ -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,

+ 15 - 1
packages/simulation/src/recording.ts

@@ -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))
   }

+ 21 - 0
packages/simulation/test/recording.test.ts

@@ -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 })
+  }
+})