refactor(simulation): scope Drive lifecycle with Effect (#36908)

This commit is contained in:
Kit Langton 2026-07-14 17:16:50 -04:00 committed by GitHub
commit 947566f611
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1313 additions and 600 deletions

View file

@ -1,11 +1,10 @@
import { mkdir } from "node:fs/promises"
import { tmpdir } from "node:os"
import { extname, join, resolve } from "node:path"
import type { CliRenderer, Renderable } from "@opentui/core"
import { createMockKeys, createMockMouse, type MockInput, type MockMouse } from "@opentui/core/testing"
import { Config, Effect, FileSystem } from "effect"
import type { SimulationProtocol } from "../protocol"
import { SimulationRenderer } from "./renderer"
import { SimulationPng } from "./png"
export type Action = SimulationProtocol.Frontend.Action
export type Element = SimulationProtocol.Frontend.Element
@ -72,10 +71,7 @@ export function createHarness(renderer: CliRenderer): Harness {
// 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(),
),
screen: () => decoder.decode((Reflect.get(renderer, "currentRenderBuffer") as RenderBuffer).getRealCharBytes()),
}
}
@ -114,31 +110,29 @@ export function matches(harness: Pick<Harness, "screen">, text: string) {
return harness.screen().includes(text)
}
export async function screenshot(harness: Harness, name?: string) {
await harness.renderOnce()
const image = SimulationPng.screenshot(harness.renderer)
export const screenshot = Effect.fn("SimulationActions.screenshot")(function* (harness: Harness, name?: string) {
const filename = name ?? `screenshot-${crypto.randomUUID()}`
if (
!filename ||
filename.includes("/") ||
filename.includes("\\") ||
extname(filename)
)
throw new Error("screenshot name must not contain a path or extension")
if (!filename || filename.includes("/") || filename.includes("\\") || extname(filename))
return yield* Effect.fail(new Error("screenshot name must not contain a path or extension"))
yield* Effect.tryPromise(() => harness.renderOnce())
const { SimulationPng } = yield* Effect.promise(() => import("./png"))
const image = SimulationPng.screenshot(harness.renderer)
const directory = resolve(
process.env.OPENCODE_DRIVE_MEDIA_DIR ??
join(tmpdir(), "opencode-drive", "output"),
yield* Config.string("OPENCODE_DRIVE_MEDIA_DIR").pipe(
Config.withDefault(join(tmpdir(), "opencode-drive", "output")),
),
)
await mkdir(directory, { recursive: true })
const fs = yield* FileSystem.FileSystem
yield* fs.makeDirectory(directory, { recursive: true })
const path = join(directory, `${filename}.png`)
await Bun.write(path, image.data)
yield* fs.writeFile(path, image.data)
return path
}
})
export async function execute(harness: Harness, action: Action) {
export const execute = Effect.fn("SimulationActions.execute")(function* (harness: Harness, action: Action) {
switch (action.type) {
case "ui.type":
await harness.mockInput.typeText(action.text)
yield* Effect.tryPromise(() => harness.mockInput.typeText(action.text))
break
case "ui.press":
harness.mockInput.pressKey(action.key, action.modifiers)
@ -155,18 +149,23 @@ export async function execute(harness: Harness, action: Action) {
?.focus()
break
case "ui.click":
await harness.mockMouse.click(action.x, action.y)
yield* Effect.tryPromise(() => 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")
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
}
await harness.renderOnce()
yield* Effect.tryPromise(() => harness.renderOnce())
return state(harness)
}
})
export * as SimulationActions from "./actions"

View file

@ -1,22 +1,20 @@
import { fileURLToPath } from "node:url"
import { GlobalFonts, createCanvas } from "@napi-rs/canvas"
/// <reference path="../assets.d.ts" />
import { GlobalFonts, createCanvas, type SKRSContext2D } from "@napi-rs/canvas"
import { TextAttributes, type CapturedFrame, type CliRenderer, type RGBA } from "@opentui/core"
import regularFont from "@fontsource/commit-mono/files/commit-mono-latin-400-normal.woff2" with { type: "file" }
import boldFont from "@fontsource/commit-mono/files/commit-mono-latin-700-normal.woff2" with { type: "file" }
import italicFont from "@fontsource/commit-mono/files/commit-mono-latin-400-italic.woff2" with { type: "file" }
import boldItalicFont from "@fontsource/commit-mono/files/commit-mono-latin-700-italic.woff2" with { type: "file" }
const CellWidth = 10
const CellHeight = 20
const FontSize = 16
const FontFamily = "OpenCode Mono"
for (const file of [
"adwaita-mono-latin-400-normal.woff2",
"adwaita-mono-latin-700-normal.woff2",
"adwaita-mono-latin-400-italic.woff2",
"adwaita-mono-latin-700-italic.woff2",
]) {
GlobalFonts.registerFromPath(
fileURLToPath(import.meta.resolve(`@fontsource/adwaita-mono/files/${file}`)),
FontFamily,
)
for (const file of [regularFont, boldFont, italicFont, boldItalicFont]) {
const font = Buffer.from(await Bun.file(file).arrayBuffer())
if (!GlobalFonts.register(font, FontFamily))
throw new Error(`Failed to register screenshot font: ${file}`)
}
export function screenshot(renderer: CliRenderer) {
@ -54,13 +52,17 @@ export function screenshotFrame(frame: CapturedFrame) {
}
if (!hidden && char.codePointAt(0) !== 0x0a00) {
context.fillStyle = color(foreground, attributes & TextAttributes.DIM ? 0.55 : 1)
context.font = `${attributes & TextAttributes.ITALIC ? "italic " : ""}${attributes & TextAttributes.BOLD ? "bold " : ""}${FontSize}px "${FontFamily}"`
context.fillText(char, column * CellWidth, row * CellHeight + 1)
const x = column * CellWidth
const y = row * CellHeight
if (!drawBlockElement(context, char, x, y, cells)) {
context.font = `${attributes & TextAttributes.ITALIC ? "italic " : ""}${attributes & TextAttributes.BOLD ? "bold " : ""}${FontSize}px "${FontFamily}"`
context.fillText(char, x, y + 1)
}
if (attributes & TextAttributes.UNDERLINE) {
context.fillRect(column * CellWidth, row * CellHeight + 17, cells * CellWidth, 1)
context.fillRect(x, y + 17, cells * CellWidth, 1)
}
if (attributes & TextAttributes.STRIKETHROUGH) {
context.fillRect(column * CellWidth, row * CellHeight + 10, cells * CellWidth, 1)
context.fillRect(x, y + 10, cells * CellWidth, 1)
}
}
column += cells
@ -83,6 +85,15 @@ export function screenshotFrame(frame: CapturedFrame) {
}
}
function drawBlockElement(context: SKRSContext2D, char: string, x: number, y: number, cells: number) {
const width = cells * CellWidth
if (char === "█") context.fillRect(x, y, width, CellHeight)
else if (char === "▀") context.fillRect(x, y, width, CellHeight / 2)
else if (char === "▄") context.fillRect(x, y + CellHeight / 2, width, CellHeight / 2)
else return false
return true
}
function color(value: RGBA, opacity = 1) {
const [red, green, blue, alpha] = value.toInts()
return `rgba(${red}, ${green}, ${blue}, ${(alpha / 255) * opacity})`

View file

@ -1,5 +1,6 @@
import type { CliRenderer, CliRendererConfig } from "@opentui/core"
import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing"
import { Effect } from "effect"
import { Timeline } from "../recording"
const setups = new WeakMap<CliRenderer, TestRendererSetup>()
@ -16,37 +17,47 @@ export interface Viewport {
readonly rows: number
}
export async function create(options: CliRendererConfig, path?: string, viewport?: Viewport): Promise<CliRenderer> {
export const create = Effect.fn("SimulationRenderer.create")(function* (
options: CliRendererConfig,
path?: string,
viewport?: Viewport,
) {
const cols = viewport?.cols ?? 100
const rows = viewport?.rows ?? 40
if (!path) {
const setup = await createTestRenderer({
...options,
width: cols,
height: rows,
})
setups.set(setup.renderer, setup)
return setup.renderer
}
const recording = await Timeline.create(path, cols, rows)
const setup = await createTestRenderer({
...options,
width: cols,
height: rows,
stdout: recording as unknown as NodeJS.WriteStream,
bufferedOutput: "stdout",
onDestroy: () => {
void recording.finish().catch((error) => process.stderr.write(`Failed to finish UI recording: ${error}\n`))
options.onDestroy?.()
},
}).catch(async (error) => {
await recording.finish().catch(() => undefined)
throw error
})
const recording = path
? yield* Effect.acquireRelease(
Effect.tryPromise(() => Timeline.create(path, cols, rows)),
(recording) =>
Effect.tryPromise(() => recording.finish()).pipe(
Effect.catch((error) =>
Effect.sync(() => process.stderr.write(`Failed to finish UI recording: ${error}\n`)),
),
),
)
: undefined
const setup = yield* Effect.acquireRelease(
Effect.tryPromise(() =>
createTestRenderer({
...options,
width: cols,
height: rows,
...(recording
? {
stdout: recording as unknown as NodeJS.WriteStream,
bufferedOutput: "stdout" as const,
}
: {}),
}),
),
(setup) =>
Effect.sync(() => {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
}),
)
setups.set(setup.renderer, setup)
recordings.set(setup.renderer, recording)
if (recording) recordings.set(setup.renderer, recording)
return setup.renderer
}
})
export function recordResize(renderer: CliRenderer, cols: number, rows: number) {
recordings.get(renderer)?.resize(cols, rows)
@ -58,8 +69,8 @@ export function setupFor(renderer: CliRenderer): TestRendererSetup | undefined {
export function finish(renderer: CliRenderer) {
const recording = recordings.get(renderer)
if (!recording) throw new Error("UI recording is not available")
return recording.finish()
if (!recording) return Effect.fail(new Error("UI recording is not available"))
return Effect.tryPromise(() => recording.finish())
}
export * as SimulationRenderer from "./renderer"

View file

@ -1,31 +1,19 @@
import { Effect } from "effect"
import { SimulationControlServer } from "../control-server"
import { SimulationProtocol } from "../protocol"
import { SimulationActions, type Harness } from "./actions"
import { SimulationRenderer } from "./renderer"
export interface Server {
readonly url: string
readonly stop: () => void
}
function parseRequest(input: string | Buffer) {
return SimulationProtocol.Frontend.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString()))
}
async function handle(
harness: Harness,
request: SimulationProtocol.Frontend.Request,
finishRecording?: () => Promise<string>,
) {
function handle(harness: Harness, request: SimulationProtocol.Frontend.Request) {
switch (request.method) {
case "ui.screenshot":
return SimulationActions.screenshot(harness, request.params?.name)
case "ui.state": {
return SimulationActions.state(harness)
}
case "ui.state":
return Effect.sync(() => SimulationActions.state(harness))
case "ui.matches":
return SimulationActions.matches(harness, request.params.text)
return Effect.sync(() => SimulationActions.matches(harness, request.params.text))
case "ui.recording.finish":
if (!finishRecording) throw new Error("UI recording is not available")
return finishRecording()
return SimulationRenderer.finish(harness.renderer)
case "ui.type":
return SimulationActions.execute(harness, { type: "ui.type", text: request.params.text })
case "ui.enter":
@ -48,39 +36,22 @@ async function handle(
y: request.params.y,
})
case "ui.resize":
return SimulationActions.execute(harness, { type: "ui.resize", cols: request.params.cols, rows: request.params.rows })
return SimulationActions.execute(harness, {
type: "ui.resize",
cols: request.params.cols,
rows: request.params.rows,
})
}
}
export function start(harness: Harness, endpoint: string, finishRecording?: () => Promise<string>): Server {
const url = new URL(endpoint)
const server = Bun.serve<{ readonly drive: true }>({
hostname: url.hostname,
port: Number(url.port),
fetch(request, server) {
if (server.upgrade(request, { data: { drive: true } })) return undefined
return new Response("opencode drive ui websocket", { status: 426 })
},
websocket: {
async message(socket, message) {
let request: SimulationProtocol.Frontend.Request | undefined
try {
request = parseRequest(message)
const result = await handle(harness, request, finishRecording)
const next = SimulationProtocol.JsonRpc.success(request.id, result)
if (next) socket.send(JSON.stringify(next))
} catch (error) {
socket.send(JSON.stringify(SimulationProtocol.JsonRpc.failure(request?.id, error)))
}
},
},
export const start = Effect.fn("SimulationServer.start")(function* (harness: Harness, endpoint: string) {
return yield* SimulationControlServer.start({
endpoint,
label: "opencode drive ui websocket",
data: () => ({ drive: true as const }),
decode: SimulationProtocol.Frontend.decodeRequestEffect,
handle: (_socket, request) => handle(harness, request),
})
return {
url: endpoint,
stop: () => {
server.stop(true)
},
}
}
})
export * as SimulationServer from "./server"

View file

@ -1,32 +1,27 @@
import { createCliRenderer, type CliRenderer, type CliRendererConfig } from "@opentui/core"
import { createCliRenderer, type CliRendererConfig } from "@opentui/core"
import { Config, Effect } from "effect"
import { DriveManifest } from "../manifest"
import { SimulationActions } from "./actions"
import { SimulationRenderer } from "./renderer"
import { SimulationServer } from "./server"
/**
* Drive-mode renderer entry point.
*
* Creates the renderer (headless when OPENCODE_DRIVE_RENDERER=headless, the normal
* visible renderer otherwise) and starts the UI control
* server against it. The server stops when the renderer is destroyed, so the
* caller only manages the renderer lifecycle.
*/
export async function create(options: CliRendererConfig): Promise<CliRenderer> {
const headless = process.env.OPENCODE_DRIVE_RENDERER === "headless"
const manifest = DriveManifest.resolve()
/** Drive-mode renderer and control-server acquisition. */
export const create = Effect.fn("Drive.create")(function* (options: CliRendererConfig) {
const headless = (yield* Config.string("OPENCODE_DRIVE_RENDERER").pipe(Config.withDefault("visible"))) === "headless"
const manifest = yield* DriveManifest.resolve()
const renderer = headless
? await SimulationRenderer.create(options, manifest.recording?.timeline, manifest.viewport)
: await createCliRenderer(options)
? yield* SimulationRenderer.create(options, manifest.recording?.timeline, manifest.viewport)
: yield* Effect.acquireRelease(
Effect.tryPromise(() => createCliRenderer(options)),
(renderer) =>
Effect.sync(() => {
if (!renderer.isDestroyed) renderer.destroy()
}),
)
if (!headless && manifest.viewport) renderer.resize(manifest.viewport.cols, manifest.viewport.rows)
const server = SimulationServer.start(
SimulationActions.createHarness(renderer),
manifest.endpoints.ui,
headless && manifest.recording ? () => SimulationRenderer.finish(renderer) : undefined,
)
process.stderr.write(`opencode drive ui websocket: ${server.url}\n`)
renderer.once("destroy", () => server.stop())
const server = yield* SimulationServer.start(SimulationActions.createHarness(renderer), manifest.endpoints.ui)
yield* Effect.sync(() => process.stderr.write(`opencode drive ui websocket: ${server.url}\n`))
return renderer
}
})
export * as Drive from "./simulation"