refactor(simulation): scope Drive lifecycle with Effect (#36908)
This commit is contained in:
parent
a149a61a89
commit
947566f611
30 changed files with 1313 additions and 600 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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})`
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue