diff --git a/bun.lock b/bun.lock index 753dd9ced71..6291c02b38e 100644 --- a/bun.lock +++ b/bun.lock @@ -774,11 +774,6 @@ "name": "@opencode-ai/simulation", "version": "1.17.13", "dependencies": { - "@fontsource/commit-mono": "5.2.5", - "@fontsource/noto-sans-math": "5.2.5", - "@fontsource/noto-sans-symbols": "5.2.5", - "@fontsource/noto-sans-symbols-2": "5.2.5", - "@napi-rs/canvas": "1.0.2", "@opencode-ai/ai": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -786,10 +781,12 @@ "@opencode-ai/util": "workspace:*", "@opentui/core": "catalog:", "effect": "catalog:", + "ws": "8.21.0", }, "devDependencies": { "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", + "@types/ws": "8.18.1", "@typescript/native-preview": "catalog:", }, }, @@ -6460,12 +6457,6 @@ "@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], - "@opencode-ai/simulation/@fontsource/noto-sans-math": ["@fontsource/noto-sans-math@5.2.5", "", {}, "sha512-1bxEvVlF51Vfgpju32mRZzI/CHvsfqjXjI2+sAuEyHYvXABUAIyj+93sCO3QZIoMG5drWyrzgoCqRQRaL6wQ8Q=="], - - "@opencode-ai/simulation/@fontsource/noto-sans-symbols": ["@fontsource/noto-sans-symbols@5.2.5", "", {}, "sha512-mxoIRstsmZpZFzd/SRWiD+l6T7TGhpgCrGs7TEnnuGSQIfjVMrQT9Zej2enh9pkfmPNAFyeaGJkHkszJ1hH++w=="], - - "@opencode-ai/simulation/@fontsource/noto-sans-symbols-2": ["@fontsource/noto-sans-symbols-2@5.2.5", "", {}, "sha512-F4O9WLifwoZS1quNzY1ebjMNo2cQPe/UP68Dmud0ONi2lOxaR6xp6fFPO2gG17MI7DwAnfMyQFl64A2tAd28hg=="], - "@opencode-ai/storybook/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="], "@opencode-ai/storybook/react-dom": ["react-dom@18.2.0", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g=="], diff --git a/packages/cli/script/app-assets.ts b/packages/cli/script/app-assets.ts index 1b6c08ca79a..ee4b3aa1c68 100644 --- a/packages/cli/script/app-assets.ts +++ b/packages/cli/script/app-assets.ts @@ -1,7 +1,7 @@ import { $ } from "bun" -import { readdir } from "node:fs/promises" import path from "node:path" import { brotliCompressSync, constants } from "node:zlib" +import { collectFiles } from "./files" export async function buildAppArchive(channel: string, options?: { skipBuild?: boolean }) { if (options?.skipBuild) return compress({}) @@ -9,8 +9,10 @@ export async function buildAppArchive(channel: string, options?: { skipBuild?: b await $`bun run build`.cwd(root).env({ ...process.env, OPENCODE_CHANNEL: channel }) const assets = Object.fromEntries( await Promise.all( - (await files(path.join(root, "dist"))) + (await collectFiles(path.join(root, "dist"))) + .map((key) => key.replaceAll(path.sep, "/")) .filter((key) => !key.endsWith(".map")) + .toSorted() .map(async (key) => { const source = path.join(root, "dist", key) const body = Buffer.from(await Bun.file(source).arrayBuffer()) @@ -31,16 +33,3 @@ function compress(assets: object) { function isText(key: string) { return key === "_headers" || /\.(?:css|html|js|json|svg|txt|webmanifest|xml)$/.test(key) } - -async function files(root: string, current = root): Promise { - return ( - await Promise.all( - (await readdir(current, { withFileTypes: true })).map((entry) => { - const target = path.join(current, entry.name) - return entry.isDirectory() ? files(root, target) : [path.relative(root, target).replaceAll(path.sep, "/")] - }), - ) - ) - .flat() - .toSorted() -} diff --git a/packages/cli/script/build-node.ts b/packages/cli/script/build-node.ts index ccb8de53cf1..294e567d661 100644 --- a/packages/cli/script/build-node.ts +++ b/packages/cli/script/build-node.ts @@ -12,6 +12,7 @@ import { collectNodeAssets, copyNodeAssets, hashNodeAssets, seaAssetMap } from " import { mainConfig } from "../vite.node.config" import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target" import { buildAppArchive } from "./app-assets" +import { verifyArtifact } from "./verify-artifact" const NODE_VERSION = "26.4.0" const dir = path.resolve(import.meta.dirname, "..") @@ -91,6 +92,7 @@ for (const target of targets) { await copyNodeAssets(assets) await build(mainConfig(input)) await assertTextImportsInlined("dist-node/opencode.mjs") + if (bundleOnly) await verifyArtifact("dist-node/opencode.mjs") const host = target.platform === process.platform && target.arch === process.arch if (host) { @@ -139,6 +141,7 @@ for (const target of targets) { 2, )}\n`, ) + await verifyArtifact(path.join(outdir, name)) if (host) await smoke(output) } diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index 918c430ab95..9452c30a66f 100755 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -8,6 +8,7 @@ import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin" import type { BunPlugin } from "bun" import pkg from "../package.json" import { buildAppArchive } from "./app-assets" +import { verifyArtifact, verifySimulationGraph } from "./verify-artifact" const dir = path.resolve(import.meta.dirname, "..") const binary = "opencode2" @@ -76,6 +77,16 @@ const appAssetsPlugin: BunPlugin = { } for (const item of targets) { + const simulationInputs = new Set() + const simulationGraphPlugin: BunPlugin = { + name: "opencode-simulation-graph", + setup(build) { + build.onLoad( + { filter: /packages[/\\]simulation[/\\]src[/\\](frontend[/\\](simulation|server)|control-server)\.ts$/ }, + (args) => void simulationInputs.add(args.path), + ) + }, + } const parcelWatcherPackage = `@parcel/watcher-${item.os}-${item.arch}${item.os === "linux" ? `-${item.abi ?? "glibc"}` : ""}` const parcelWatcherPlugin: BunPlugin = { name: "parcel-watcher-binding", @@ -92,7 +103,7 @@ for (const item of targets) { const result = await Bun.build({ entrypoints: ["./src/index.ts"], tsconfig: "./tsconfig.json", - plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin], + plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, simulationGraphPlugin], external: ["node-gyp"], format: "esm", minify: true, @@ -123,6 +134,7 @@ for (const item of targets) { for (const log of result.logs) console.error(log) process.exit(1) } + verifySimulationGraph(simulationInputs) await Bun.write( path.join(outdir, name, "package.json"), @@ -139,6 +151,7 @@ for (const item of targets) { 2, ), ) + await verifyArtifact(path.join(outdir, name)) } function targetName(item: (typeof allTargets)[number]) { diff --git a/packages/cli/script/files.ts b/packages/cli/script/files.ts new file mode 100644 index 00000000000..baeacf3a4ac --- /dev/null +++ b/packages/cli/script/files.ts @@ -0,0 +1,13 @@ +import { readdir } from "node:fs/promises" +import path from "node:path" + +export async function collectFiles(root: string, current = root): Promise { + return ( + await Promise.all( + (await readdir(current, { withFileTypes: true })).map(async (entry) => { + const target = path.join(current, entry.name) + return entry.isDirectory() ? collectFiles(root, target) : [path.relative(root, target)] + }), + ) + ).flat() +} diff --git a/packages/cli/script/node-assets.ts b/packages/cli/script/node-assets.ts index 192920a487b..118e7d9183c 100644 --- a/packages/cli/script/node-assets.ts +++ b/packages/cli/script/node-assets.ts @@ -1,9 +1,10 @@ import { createHash } from "node:crypto" -import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises" +import { copyFile, mkdir, readFile, stat } from "node:fs/promises" import path from "node:path" import { fileURLToPath } from "node:url" import { getNodeAssets } from "@opentui/core/node-assets" import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target" +import { collectFiles } from "./files" const dir = path.resolve(import.meta.dirname, "..") @@ -16,17 +17,6 @@ export type NodeAsset = { readonly source: string } -async function files(root: string, current = root): Promise { - return ( - await Promise.all( - (await readdir(current, { withFileTypes: true })).map((entry) => { - const target = path.join(current, entry.name) - return entry.isDirectory() ? files(root, target) : [path.relative(root, target)] - }), - ) - ).flat() -} - export async function collectNodeAssets(target: NodeTarget) { const ptyEntry = fileURLToPath(import.meta.resolve(target.nodePtyPackage)) const ptyRoot = path.resolve(path.dirname(ptyEntry), "..") @@ -51,7 +41,7 @@ export async function collectNodeAssets(target: NodeTarget) { key, source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)), })), - ...(await files(ptyRoot)) + ...(await collectFiles(ptyRoot)) .filter((relative) => !relative.endsWith(".map") && !relative.endsWith(".pdb")) .map((relative) => ({ key: `${target.nodePtyPackage}/${relative}`, @@ -85,5 +75,7 @@ export async function copyNodeAssets(assets: readonly NodeAsset[]) { export async function seaAssetMap() { const root = path.join(dir, "dist-node", "assets") - return Object.fromEntries((await files(root)).map((key) => [key.replaceAll(path.sep, "/"), path.join(root, key)])) + return Object.fromEntries( + (await collectFiles(root)).map((key) => [key.replaceAll(path.sep, "/"), path.join(root, key)]), + ) } diff --git a/packages/cli/script/service-smoke.ts b/packages/cli/script/service-smoke.ts index d74a47fe803..42b87eac435 100644 --- a/packages/cli/script/service-smoke.ts +++ b/packages/cli/script/service-smoke.ts @@ -88,6 +88,7 @@ try { if (!(await exitsWithin(winner, 10_000))) throw new Error("Compiled service did not stop") for (let attempt = 0; attempt < 200 && (await Bun.file(registration).exists()); attempt++) await Bun.sleep(25) if (await Bun.file(registration).exists()) throw new Error("Compiled service registration was not removed") + await driveSmoke() } catch (cause) { failure = cause } finally { @@ -111,6 +112,50 @@ function spawnService() { return process } +async function driveSmoke() { + const name = "compiled-artifact" + const drive = path.resolve(import.meta.dir, "../../drive/src/cli/index.ts") + const driveEnv = { + ...env, + DRIVE_REGISTRY_DIR: path.join(root, "drive-registry"), + OPENCODE_DRIVE_KEEP_ARTIFACTS: "1", + OPENCODE_DRIVE_MEDIA_DIR: path.join(root, "drive-media"), + } + let started = false + try { + await runDrive(["start", "--name", name, "--", binary], drive, driveEnv) + started = true + const screenshot = ( + await runDrive( + ["send", "--name", name, "--command.ui.screenshot", '{"name":"compiled-capture"}'], + drive, + driveEnv, + ) + ).trim() + const bytes = Buffer.from(await Bun.file(screenshot).arrayBuffer()) + if (!bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) + throw new Error("Compiled Drive bridge did not produce a PNG capture") + } finally { + if (started) await runDrive(["stop", "--name", name], drive, driveEnv) + } +} + +async function runDrive(args: ReadonlyArray, drive: string, driveEnv: Record) { + const child = Bun.spawn([process.execPath, drive, ...args], { + cwd: root, + env: driveEnv, + stdout: "pipe", + stderr: "pipe", + }) + const [status, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + if (status !== 0) throw new Error(`opencode-drive ${args[0]} failed:\n${stderr}`) + return stdout +} + async function waitForRegistration() { const directory = path.join(root, "state", "opencode") for (let attempt = 0; attempt < 400; attempt++) { diff --git a/packages/cli/script/verify-artifact.ts b/packages/cli/script/verify-artifact.ts new file mode 100644 index 00000000000..a17426920bd --- /dev/null +++ b/packages/cli/script/verify-artifact.ts @@ -0,0 +1,69 @@ +import { stat } from "node:fs/promises" +import path from "node:path" +import { collectFiles } from "./files" + +const forbidden = [ + "@napi-rs/canvas", + "@fontsource/commit-mono", + "@fontsource/noto-sans", + "SimulationPng", + "frontend/png", + "Failed to register screenshot font", + "commit-mono-latin-400-normal", + "noto-sans-symbols-symbols-400-normal", + "noto-sans-math-math-400-normal", + "CommitMono-400-Regular.otf", + "NotoSansSymbols.ttf", + "packages/drive/src/recording/render", + "src/frontend/png.ts", + "skia.darwin-", + "skia.linux-", + "skia.win32-", +] +const overlap = Math.max(...forbidden.map((value) => value.length)) - 1 + +export async function verifyArtifact(target: string) { + const files = await artifactFiles(target) + if (files.length === 0) throw new Error(`Artifact contains no published files: ${target}`) + for (const file of files) await scan(file) +} + +export function verifySimulationGraph(inputs: Iterable) { + const modules = Array.from(inputs, (input) => input.replaceAll("\\", "/")) + const required = [ + "/packages/simulation/src/frontend/simulation.ts", + "/packages/simulation/src/frontend/server.ts", + "/packages/simulation/src/control-server.ts", + ] + const missing = required.filter((input) => !modules.some((module) => module.endsWith(input))) + if (missing.length > 0) throw new Error(`Build graph is missing simulation bridge inputs: ${missing.join(", ")}`) + const leaked = modules.find( + (module) => + module.includes("/packages/simulation/src/frontend/png.") || module.includes("/packages/drive/src/recording/"), + ) + if (leaked) throw new Error(`Build graph contains Drive-only rendering input: ${leaked}`) +} + +async function artifactFiles(target: string): Promise { + if ((await stat(target)).isFile()) return [target] + return (await collectFiles(target)).map((file) => path.join(target, file)) +} + +async function scan(file: string) { + let trailing = "" + const reader = Bun.file(file).stream().getReader() + while (true) { + const chunk = await reader.read() + if (chunk.done) return + const text = trailing + Buffer.from(chunk.value).toString("latin1") + const leaked = forbidden.find((marker) => text.includes(marker)) + if (leaked) throw new Error(`Artifact file ${file} contains forbidden simulation payload: ${leaked}`) + trailing = text.slice(-overlap) + } +} + +if (import.meta.main) { + const target = process.argv[2] + if (!target) throw new Error("Usage: bun run script/verify-artifact.ts ") + await verifyArtifact(target) +} diff --git a/packages/cli/vite.node.config.ts b/packages/cli/vite.node.config.ts index 3cae0d7ec77..dc509fbaad9 100644 --- a/packages/cli/vite.node.config.ts +++ b/packages/cli/vite.node.config.ts @@ -4,6 +4,7 @@ import { createRequire } from "node:module" import { defineConfig, type Plugin, type UserConfig } from "vite" import solid from "vite-plugin-solid" import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "./src/node/target" +import { verifySimulationGraph } from "./script/verify-artifact" const dir = import.meta.dirname @@ -48,6 +49,15 @@ function runtimeRequirePlugin(): Plugin { } } +function simulationGraphPlugin(): Plugin { + return { + name: "opencode:simulation-graph", + generateBundle() { + verifySimulationGraph(this.getModuleIds()) + }, + } +} + function fffNodePlugin(): Plugin { return { name: "opencode:fff-node", @@ -240,6 +250,7 @@ export function mainConfig(input: NodeBuildInput): UserConfig { rawTextPlugin(), runtimeRequirePlugin(), fffNodePlugin(), + simulationGraphPlugin(), solid({ solid: { generate: "universal", @@ -255,6 +266,7 @@ export function mainConfig(input: NodeBuildInput): UserConfig { OPENCODE_CHANNEL: JSON.stringify(input.channel), OPENCODE_LIBC: input.target.platform === "linux" ? JSON.stringify("glibc") : "undefined", FFF_LIBC: input.target.platform === "linux" ? JSON.stringify("gnu") : "undefined", + "process.env.WS_NO_BUFFER_UTIL": JSON.stringify("1"), }, ssr: { noExternal: true }, build: { @@ -264,7 +276,6 @@ export function mainConfig(input: NodeBuildInput): UserConfig { emptyOutDir: false, minify: true, rollupOptions: { - external: [/^@opencode-ai\/simulation(?:\/|$)/], output: output("opencode.mjs", nodePrelude(input)), }, }, diff --git a/packages/drive/src/cli/commands.ts b/packages/drive/src/cli/commands.ts index 602f80585dc..6d13dd9a571 100644 --- a/packages/drive/src/cli/commands.ts +++ b/packages/drive/src/cli/commands.ts @@ -1,9 +1,11 @@ import * as Cause from "effect/Cause" import * as Effect from "effect/Effect" import * as Exit from "effect/Exit" +import * as Schema from "effect/Schema" import { Frontend } from "../client/protocol.js" import { recordLog } from "../log.js" import * as SimulationConnector from "../simulation/connector.js" +import * as OpenCodeUi from "../driver/ui.js" import type { DriveCommand } from "./types.js" export const commandInfo = { @@ -48,11 +50,11 @@ export const commandInfo = { description: "Finish recording and return the timeline path", }, } as const satisfies Record< - Exclude, + DriveCommand["operation"], { readonly value: boolean | "optional"; readonly description: string } > -type CommandName = Exclude +type CommandName = DriveCommand["operation"] export function isCommandName(operation: string): operation is CommandName { return Object.hasOwn(commandInfo, operation) @@ -90,9 +92,14 @@ export class CommandBatchError extends Error { } const callTimeout = 30_000 +const ScreenshotParams = Schema.Struct({ name: Schema.optional(Schema.String) }) -export async function executeCommands(endpoint: string, commands: ReadonlyArray) { - const exit = await Effect.runPromiseExit(Effect.scoped(executeBatch(endpoint, commands))) +export async function executeCommands( + endpoint: string, + commands: ReadonlyArray, + options?: OpenCodeUi.Options, +) { + const exit = await Effect.runPromiseExit(Effect.scoped(executeBatch(endpoint, commands, options))) if (Exit.isSuccess(exit)) return exit.value const reason = Cause.squash(exit.cause) throw reason instanceof CommandBatchError ? reason : new CommandBatchError([], reason) @@ -101,6 +108,7 @@ export async function executeCommands(endpoint: string, commands: ReadonlyArray< const executeBatch = Effect.fn("DriveCli.executeBatch")(function* ( endpoint: string, commands: ReadonlyArray, + options?: OpenCodeUi.Options, ) { const connection = yield* SimulationConnector.ui(endpoint, { connectTimeout: callTimeout, @@ -111,7 +119,7 @@ const executeBatch = Effect.fn("DriveCli.executeBatch")(function* ( ) const results: Array<{ readonly command: string; readonly result: unknown }> = [] for (const command of commands) { - const result = yield* execute(connection, command).pipe( + const result = yield* execute(connection, command, options).pipe( Effect.mapError((error) => new CommandBatchError(results, error)), ) results.push({ command: command.operation, result }) @@ -122,9 +130,17 @@ const executeBatch = Effect.fn("DriveCli.executeBatch")(function* ( const execute = ( connection: SimulationConnector.UiConnection, command: DriveCommand, + options?: OpenCodeUi.Options, ): Effect.Effect => Effect.suspend(() => { recordLog("INFO", `ui command ${command.operation} params=${command.value ?? "undefined"}`) + if (command.operation === "ui.screenshot") { + const params = Schema.decodeUnknownSync(ScreenshotParams)( + command.value === undefined ? {} : JSON.parse(command.value), + { onExcessProperty: "error" }, + ) + return OpenCodeUi.make(connection, options).screenshot(params.name) + } return dispatch(connection, decodeCommand(command)) }).pipe( Effect.timeoutOrElse({ @@ -145,10 +161,12 @@ const execute = ( function decodeCommand(command: DriveCommand): Frontend.Request { if (command.value === undefined && commandInfo[command.operation].value === true) throw new Error(`${command.operation} requires a value`) + const operation = command.operation + if (operation === "ui.screenshot") throw new Error("ui.screenshot must be decoded by Drive") return Frontend.decodeRequest( { jsonrpc: "2.0", - method: command.operation, + method: operation, ...(command.value === undefined ? {} : { params: JSON.parse(command.value) }), }, { onExcessProperty: "error" }, @@ -187,8 +205,6 @@ function dispatch( return connection.rpc["ui.click"](request.params) case "ui.resize": return connection.rpc["ui.resize"](request.params) - case "ui.screenshot": - return connection.rpc["ui.screenshot"](request.params) case "ui.capture": return connection.rpc["ui.capture"]() case "ui.state": diff --git a/packages/drive/src/cli/send.ts b/packages/drive/src/cli/send.ts index 2926f082640..00ca6cf02c5 100644 --- a/packages/drive/src/cli/send.ts +++ b/packages/drive/src/cli/send.ts @@ -3,10 +3,17 @@ import type { SendOptions } from "./types.js" import { defaultPort } from "../client/index.js" import { resolveInstance, resolveVisibleInstance } from "../instance/registry.js" import { configureLogFile } from "../log.js" +import { readInstanceMediaDirectory } from "../instance/media.js" export async function send(options: SendOptions) { if (options.commands.length === 0) throw new Error("send requires at least one --command.ui.* flag") - const result = await executeCommands(await resolveSendEndpoint(options.name), options.commands) + const target = await resolveSendTarget( + options.name, + options.commands.some((command) => command.operation === "ui.screenshot"), + ) + const result = await executeCommands(target.endpoint, options.commands, { + screenshotDirectory: target.screenshotDirectory, + }) if ( options.commands.length === 1 && ["ui.screenshot", "ui.matches", "ui.recording.finish"].includes(options.commands[0]?.operation ?? "") @@ -25,15 +32,29 @@ export async function send(options: SendOptions) { } export async function resolveSendEndpoint(name?: string) { + return (await resolveSendTarget(name)).endpoint +} + +async function resolveSendTarget(name?: string, screenshot = false) { if (name) { const manifest = await resolveInstance(name) configureLogFile(manifest.artifacts) - return manifest.endpoints.ui + return { + endpoint: manifest.endpoints.ui, + ...(screenshot + ? { screenshotDirectory: await readInstanceMediaDirectory(manifest.artifacts, manifest.name) } + : {}), + } } const manifest = await resolveVisibleInstance() if (manifest) { configureLogFile(manifest.artifacts) - return manifest.endpoints.ui + return { + endpoint: manifest.endpoints.ui, + ...(screenshot + ? { screenshotDirectory: await readInstanceMediaDirectory(manifest.artifacts, manifest.name) } + : {}), + } } - return `ws://127.0.0.1:${defaultPort}` + return { endpoint: `ws://127.0.0.1:${defaultPort}` } } diff --git a/packages/drive/src/cli/types.ts b/packages/drive/src/cli/types.ts index 532e23d62c8..3d22c93ac6d 100644 --- a/packages/drive/src/cli/types.ts +++ b/packages/drive/src/cli/types.ts @@ -1,7 +1,7 @@ import type { Frontend } from "../client/index.js" export interface DriveCommand { - readonly operation: Exclude + readonly operation: Exclude | "ui.screenshot" readonly value?: string } diff --git a/packages/drive/src/driver/client.ts b/packages/drive/src/driver/client.ts index 9b4aee5c69b..47d6e983177 100644 --- a/packages/drive/src/driver/client.ts +++ b/packages/drive/src/driver/client.ts @@ -59,7 +59,7 @@ export const make = Effect.fn("OpenCodeTui.make")(function* ( (client) => client.close.pipe(Effect.catchCause((cause) => Effect.logError("OpenCode TUI cleanup failed", cause))), ) const connection = yield* connector.ui(launched.endpoint, { compatibility }) - const ui = OpenCodeUi.make(connection) + const ui = OpenCodeUi.make(connection, { screenshotDirectory: launched.media }) yield* ui.waitFor((state) => state.focused.editor, { timeout: 30_000, interval: 50, diff --git a/packages/drive/src/driver/index.ts b/packages/drive/src/driver/index.ts index 790e9587de6..aaa92cc4d01 100644 --- a/packages/drive/src/driver/index.ts +++ b/packages/drive/src/driver/index.ts @@ -136,6 +136,7 @@ export { UiElementAmbiguousError, UiNodeAmbiguousError, UiPredicateError, + UiScreenshotError, UiTimeoutError, UiWaitOptionsError, } from "./ui.js" @@ -146,6 +147,6 @@ export type { Recording, Tui, TuiLaunchError, TuiOptions, Tuis } from "./client. export type { Llm } from "./llm.js" export type { Target as OpenCodeTarget } from "./server.js" export type { OpenCode } from "./opencode.js" -export type { Ui } from "./ui.js" +export type { ScreenshotError, Ui } from "./ui.js" export type { Project, ProjectFileSystem, Setup, SetupContext } from "../project.js" export * from "./report.js" diff --git a/packages/drive/src/driver/screenshot.ts b/packages/drive/src/driver/screenshot.ts new file mode 100644 index 00000000000..4971f12af21 --- /dev/null +++ b/packages/drive/src/driver/screenshot.ts @@ -0,0 +1,15 @@ +import { extname, join, resolve } from "node:path" +import { mkdir } from "node:fs/promises" +import type { Frontend } from "../client/protocol.js" + +export async function renderScreenshot(frame: Frontend.CapturedFrame, directory: string, 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") + const { renderFrame } = await import("../recording/render.js") + const output = resolve(directory) + await mkdir(output, { recursive: true }) + const path = join(output, `${filename}.png`) + await Bun.write(path, renderFrame(frame)) + return path +} diff --git a/packages/drive/src/driver/ui.ts b/packages/drive/src/driver/ui.ts index 703c505c727..07dbc5f5efa 100644 --- a/packages/drive/src/driver/ui.ts +++ b/packages/drive/src/driver/ui.ts @@ -5,6 +5,8 @@ import type { RpcClientError } from "effect/unstable/rpc" import { supportsCapability, type UiConnection } from "../simulation/connector.js" import { Frontend } from "../client/protocol.js" import type { SimulationRequestError } from "@opencode-ai/protocol/simulation" +import { mediaDirectory } from "../instance/media.js" +import { renderScreenshot } from "./screenshot.js" export interface WaitOptions { /** Maximum wait in milliseconds. Defaults to 5,000. */ @@ -71,9 +73,16 @@ export class UiPredicateError extends Schema.TaggedErrorClass( message: Schema.String, }) {} +export class UiScreenshotError extends Schema.TaggedErrorClass()("UiScreenshotError", { + cause: Schema.Defect(), + message: Schema.String, +}) {} + export interface Options { /** Per-RPC timeout in milliseconds. Defaults to 30,000. */ readonly requestTimeout?: number + /** Directory where Drive writes locally rendered screenshots. */ + readonly screenshotDirectory?: string } const RequestTimeout = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)) @@ -81,6 +90,7 @@ const RequestTimeout = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)) export type WaitError = UiTimeoutError | UiWaitOptionsError type RpcError = SimulationRequestError | RpcClientError.RpcClientError export type OperationError = RpcError | UiTimeoutError +export type ScreenshotError = OperationError | UiScreenshotError export type SemanticOperationError = OperationError | UiCapabilityError export interface Ui { @@ -88,7 +98,7 @@ export interface Ui { readonly snapshot: () => Effect.Effect readonly capture: () => Effect.Effect readonly matches: (text: string) => Effect.Effect - readonly screenshot: (name?: string) => Effect.Effect + readonly screenshot: (name?: string) => Effect.Effect readonly type: (text: string) => Effect.Effect readonly press: (key: string, modifiers?: Frontend.KeyModifiers) => Effect.Effect readonly enter: () => Effect.Effect @@ -172,9 +182,17 @@ export const make = (connection: UiConnection, options?: Options): Control => { }) const capture = Effect.fn("Ui.capture")(() => call("capture", rpc["ui.capture"]())) const matches = Effect.fn("Ui.matches")((text: string) => call("matches", rpc["ui.matches"]({ text }))) - const screenshot = Effect.fn("Ui.screenshot")((name?: string) => - call("screenshot", rpc["ui.screenshot"](name === undefined ? undefined : { name })), - ) + const screenshot = Effect.fn("Ui.screenshot")(function* (name?: string) { + const frame = yield* capture() + return yield* Effect.tryPromise({ + try: () => renderScreenshot(frame, options?.screenshotDirectory ?? mediaDirectory(), name), + catch: (cause) => + new UiScreenshotError({ + cause, + message: cause instanceof Error ? cause.message : String(cause), + }), + }) + }) const finishRecording = Effect.fn("Ui.finishRecording")(() => call("finishRecording", rpc["ui.recording.finish"]())) const type = Effect.fn("Ui.type")((text: string) => call("type", rpc["ui.type"]({ text }))) const press = Effect.fn("Ui.press")((key: string, modifiers?: Frontend.KeyModifiers) => diff --git a/packages/drive/src/instance/media.ts b/packages/drive/src/instance/media.ts index b8f21cc15a2..cbd16d1d339 100644 --- a/packages/drive/src/instance/media.ts +++ b/packages/drive/src/instance/media.ts @@ -7,3 +7,10 @@ export function mediaDirectory() { export const runMediaDirectory = (artifacts: string, generation: number) => join(mediaDirectory(), basename(resolve(artifacts)), `generation-${generation}`) + +export async function readInstanceMediaDirectory(artifacts: string, name: string) { + const value: unknown = await Bun.file(join(artifacts, "drive", `${name}.json`)).json() + if (typeof value !== "object" || value === null || !("media" in value) || typeof value.media !== "string") + throw new Error(`drive instance "${name}" has no media directory`) + return value.media +} diff --git a/packages/drive/src/instance/runtime.ts b/packages/drive/src/instance/runtime.ts index 755cca72ac9..276694e1af8 100644 --- a/packages/drive/src/instance/runtime.ts +++ b/packages/drive/src/instance/runtime.ts @@ -47,6 +47,7 @@ export interface Options { export interface TuiProcess { readonly endpoint: string + readonly media: string readonly process: Process.Running readonly recording?: RecordingPaths readonly close: Effect.Effect @@ -173,6 +174,7 @@ export const make = Effect.fn("OpenCodeInstance.make")(function* ( `${JSON.stringify( { endpoints: manifestEndpoints, + media, ...(viewport ? { viewport } : {}), ...(recording ? { recording: { timeline: recording.timeline } } : {}), }, @@ -325,10 +327,10 @@ export const make = Effect.fn("OpenCodeInstance.make")(function* ( ...value, pendingTuis: new Map(value.pendingTuis).set(name, tui), })) - return { tui, tuiEndpoints, primary, recording } + return { tui, tuiEndpoints, primary, recording, media } }), ) - const { tui, tuiEndpoints, primary, recording } = pending + const { tui, tuiEndpoints, primary, recording, media: tuiMedia } = pending const removePending = lock.withPermit( Ref.update(state, (value) => { if (value.pendingTuis.get(name) !== tui) return value @@ -384,6 +386,7 @@ export const make = Effect.fn("OpenCodeInstance.make")(function* ( }) return { endpoint: tuiEndpoints.ui, + media: tuiMedia, process: tui, recording, close, diff --git a/packages/drive/src/recording/render.ts b/packages/drive/src/recording/render.ts index 8fbda4b6cdc..e7c72ac2958 100644 --- a/packages/drive/src/recording/render.ts +++ b/packages/drive/src/recording/render.ts @@ -11,6 +11,7 @@ import { baselineOffset, drawBlockGlyph, } from "../frame/index.js" +import type { Frontend } from "../client/protocol.js" import type { CapturedFrame } from "./types.js" export { CellHeight, CellWidth } from "../frame/index.js" @@ -47,8 +48,10 @@ for (const [file, family] of [ if (!GlobalFonts.registerFromPath(path, family)) throw new Error(`Failed to register capture symbol font: ${path}`) } -function color(rgb: number, alpha = 1) { - return `rgba(${(rgb >> 16) & 255}, ${(rgb >> 8) & 255}, ${rgb & 255}, ${alpha})` +function color(value: number | Frontend.Color, opacity = 1) { + if (typeof value === "number") + return `rgba(${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255}, ${opacity})` + return `rgba(${value[0]}, ${value[1]}, ${value[2]}, ${(value[3] / 255) * opacity})` } export interface RenderFrameOptions { @@ -57,7 +60,7 @@ export interface RenderFrameOptions { readonly header?: string } -export function renderFrame(frame: CapturedFrame, options: RenderFrameOptions = {}): Buffer { +export function renderFrame(frame: CapturedFrame | Frontend.CapturedFrame, options: RenderFrameOptions = {}): Buffer { const cols = Math.max(frame.cols, options.cols ?? frame.cols) const rows = Math.max(frame.rows, options.rows ?? frame.rows) const headerHeight = options.header ? 40 : 0 @@ -118,12 +121,13 @@ export function renderFrame(frame: CapturedFrame, options: RenderFrameOptions = } }) - if (frame.cursor.visible && frame.cursor.row >= 0 && frame.cursor.row < frame.rows) { + const cursor = frame.cursor + if ("visible" in cursor && cursor.visible && cursor.row >= 0 && cursor.row < frame.rows) { context.strokeStyle = "#d8d8d8" context.lineWidth = 2 context.strokeRect( - frame.cursor.col * CellWidth + 1, - headerHeight + frame.cursor.row * CellHeight + 1, + cursor.col * CellWidth + 1, + headerHeight + cursor.row * CellHeight + 1, CellWidth - 2, CellHeight - 2, ) diff --git a/packages/drive/test/cli/integration.test.ts b/packages/drive/test/cli/integration.test.ts index 7573cabbdb4..339e47929c2 100644 --- a/packages/drive/test/cli/integration.test.ts +++ b/packages/drive/test/cli/integration.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test" -import { mkdir, mkdtemp, readdir, realpath, rm } from "node:fs/promises" +import { mkdir, mkdtemp, readdir, realpath, rename, rm } from "node:fs/promises" import { tmpdir } from "node:os" import { basename, dirname, join, resolve } from "node:path" import { @@ -186,6 +186,17 @@ describe("opencode-drive", () => { expect(manifest.endpoints.ui).toMatch(/^ws:\/\/127\.0\.0\.1:\d+$/) expect(manifest.endpoints.backend).toMatch(/^ws:\/\/127\.0\.0\.1:\d+$/) + const runtimeManifest = join(manifest.artifacts, "drive", `${name}.json`) + const hiddenRuntimeManifest = `${runtimeManifest}.hidden` + await rename(runtimeManifest, hiddenRuntimeManifest) + const batch = spawn( + ["send", "--name", name, "--command.ui.state", "--command.ui.matches", '{"text":"Fake OpenCode"}'], + root, + ) + expect(await batch.exited).toBe(0) + expect(await new Response(batch.stdout).text()).toBe("success\n") + await rename(hiddenRuntimeManifest, runtimeManifest) + const state = spawn(["send", "--name", name, "--command.ui.state"], root) expect(await state.exited).toBe(0) expect(JSON.parse(await new Response(state.stdout).text()).focused.editor).toBe(true) diff --git a/packages/drive/test/driver/ui.test.ts b/packages/drive/test/driver/ui.test.ts index 89f4c7365fc..62dc72086ee 100644 --- a/packages/drive/test/driver/ui.test.ts +++ b/packages/drive/test/driver/ui.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from "@effect/vitest" -import { Effect } from "effect" +import { Effect, type Types } from "effect" import * as OpenCodeUi from "../../src/driver/ui.js" import * as SimulationConnector from "../../src/simulation/connector.js" import { sendError, sendResult, startTransportPeer } from "../simulation/transport-peer.js" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { createCanvas, loadImage } from "@napi-rs/canvas" const editor = { id: "prompt", @@ -58,6 +62,15 @@ const frame = { ], } +type ScreenshotFailure = Effect.Effect.Error> +const operationErrorExcludesScreenshotError: Types.Equals< + Extract, + never +> = true +const screenshotErrorIsSpecific: Types.Equals = true +void operationErrorExcludesScreenshotError +void screenshotErrorIsSpecific + describe("OpenCodeUi", () => { it.live("captures a normalized terminal frame", () => { const peer = startTransportPeer(({ request, socket }) => sendResult(socket, request, frame)) @@ -78,10 +91,6 @@ describe("OpenCodeUi", () => { sendResult(socket, request, matchCalls > 1) return } - if (request.method === "ui.screenshot") { - sendResult(socket, request, "/tmp/home.png") - return - } sendResult(socket, request, state) }) @@ -93,7 +102,6 @@ describe("OpenCodeUi", () => { expect(yield* ui.submit("hello")).toEqual(state) expect(yield* ui.press("escape", { ctrl: true })).toEqual(state) expect(yield* ui.click(3)).toEqual(state) - expect(yield* ui.screenshot("home")).toBe("/tmp/home.png") expect(yield* ui.waitFor("ready", { timeout: 1_000, interval: 1 })).toEqual(state) expect(yield* ui.getElement({ editor: true })).toEqual(editor) @@ -121,8 +129,8 @@ describe("OpenCodeUi", () => { { jsonrpc: "2.0", id: 6, - method: "ui.screenshot", - params: { name: "home" }, + method: "ui.matches", + params: { text: "ready" }, }, { jsonrpc: "2.0", @@ -130,18 +138,60 @@ describe("OpenCodeUi", () => { method: "ui.matches", params: { text: "ready" }, }, - { - jsonrpc: "2.0", - id: 8, - method: "ui.matches", - params: { text: "ready" }, - }, + { jsonrpc: "2.0", id: 8, method: "ui.state" }, { jsonrpc: "2.0", id: 9, method: "ui.state" }, - { jsonrpc: "2.0", id: 10, method: "ui.state" }, ]) }) }) + it.live("renders negotiated screenshots from captured frames inside Drive", () => { + const peer = startTransportPeer(({ request, socket }) => sendResult(socket, request, frame)) + + return Effect.gen(function* () { + yield* Effect.addFinalizer(() => Effect.promise(() => peer.stop())) + const directory = yield* Effect.promise(() => mkdtemp(join(tmpdir(), "opencode-drive-screenshot-"))) + yield* Effect.addFinalizer(() => Effect.promise(() => rm(directory, { recursive: true, force: true }))) + const connection = yield* SimulationConnector.ui(peer.url) + const path = yield* OpenCodeUi.make(connection, { screenshotDirectory: directory }).screenshot("home") + + expect(path).toBe(join(directory, "home.png")) + const bytes = yield* Effect.promise(() => Bun.file(path).arrayBuffer()) + expect(Buffer.from(bytes).subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) + expect(peer.received.map(({ request }) => request)).toEqual([{ jsonrpc: "2.0", id: 1, method: "ui.capture" }]) + }) + }) + + it.live("preserves transparent foreground and background colors in screenshots", () => { + const transparent = { + cols: 2, + rows: 1, + cursor: [0, 0] as const, + lines: [ + { + spans: [ + { text: "█", fg: [0, 255, 0, 0] as const, bg: [255, 0, 0, 255] as const, attributes: 0, width: 1 }, + { text: " ", fg: [255, 255, 255, 255] as const, bg: [0, 0, 255, 0] as const, attributes: 0, width: 1 }, + ], + }, + ], + } + const peer = startTransportPeer(({ request, socket }) => sendResult(socket, request, transparent)) + return Effect.gen(function* () { + yield* Effect.addFinalizer(() => Effect.promise(() => peer.stop())) + const directory = yield* Effect.promise(() => mkdtemp(join(tmpdir(), "opencode-drive-alpha-"))) + yield* Effect.addFinalizer(() => Effect.promise(() => rm(directory, { recursive: true, force: true }))) + const connection = yield* SimulationConnector.ui(peer.url) + const path = yield* OpenCodeUi.make(connection, { screenshotDirectory: directory }).screenshot("alpha") + const image = yield* Effect.promise(() => loadImage(path)) + const canvas = createCanvas(image.width, image.height) + const context = canvas.getContext("2d") + context.drawImage(image, 0, 0) + + expect(Array.from(context.getImageData(5, 10, 1, 1).data)).toEqual([255, 0, 0, 255]) + expect(Array.from(context.getImageData(15, 10, 1, 1).data)).toEqual([8, 8, 8, 255]) + }) + }) + it.live("selects and clicks semantic UI nodes", () => { let snapshotCalls = 0 const peer = startTransportPeer(({ request, socket }) => { diff --git a/packages/drive/test/fixtures/fake-opencode.ts b/packages/drive/test/fixtures/fake-opencode.ts index c1ccc64eded..e44eb2f52fa 100644 --- a/packages/drive/test/fixtures/fake-opencode.ts +++ b/packages/drive/test/fixtures/fake-opencode.ts @@ -301,10 +301,6 @@ function frontend(method: string, params: unknown) { ], } } - if (method === "ui.screenshot") { - const name = isRecord(params) && typeof params.name === "string" ? params.name : `screenshot-${crypto.randomUUID()}` - return `${process.env.OPENCODE_DRIVE_MEDIA_DIR}/${name}.png` - } if (method === "ui.recording.finish") { if (!drive.recording) throw new Error("recording is not enabled") return drive.recording.timeline diff --git a/packages/drive/test/recording/export.test.ts b/packages/drive/test/recording/export.test.ts index e13b06a52a2..33e9cc0a046 100644 --- a/packages/drive/test/recording/export.test.ts +++ b/packages/drive/test/recording/export.test.ts @@ -168,7 +168,7 @@ test("joins rendered frames horizontally", async () => { }) test("renders the canonical OpenCode symbol set with the fallback font", async () => { - const symbols = [..."△⇆⊙⚙✱↳◌◈⟳▸▾■⬝⬥⬩⬪"] + const symbols = [..."△⇆⊙⚙✱↳◌◈⟳▸▾■⬝⬥⬩⬪⠹"] const image = await loadImage( renderFrame({ cols: symbols.length, @@ -212,6 +212,30 @@ test("renders the canonical OpenCode symbol set with the fallback font", async ( expect(new Set(masks).size).toBe(symbols.length) }) +test("draws heavy vertical box elements continuously across cell boundaries", async () => { + const image = await loadImage( + renderFrame({ + cols: 1, + rows: 2, + cursor: { row: 0, col: 0, visible: false }, + lines: [ + { spans: [{ text: "┃", width: 1, fg: 0xffffff, bg: 0x000000, attributes: 0 }] }, + { spans: [{ text: "╹", width: 1, fg: 0xffffff, bg: 0x000000, attributes: 0 }] }, + ], + }), + ) + const canvas = createCanvas(image.width, image.height) + const context = canvas.getContext("2d") + context.drawImage(image, 0, 0) + + expect(Array.from(context.getImageData(4, 0, 2, 30).data)).toEqual( + Array.from({ length: 60 }, () => [255, 255, 255, 255]).flat(), + ) + expect(Array.from(context.getImageData(4, 30, 2, 10).data)).toEqual( + Array.from({ length: 20 }, () => [0, 0, 0, 255]).flat(), + ) +}) + test("accepts valid capture font overrides", async () => { const font = new URL("../../assets/fonts/commit-mono/CommitMono-400-Regular.otf", import.meta.url) const child = renderImport({ OPENCODE_DRIVE_FONT: fileURLToPath(font) }) diff --git a/packages/drive/test/simulation/opencode-protocol.test.ts b/packages/drive/test/simulation/opencode-protocol.test.ts index 48392a30e96..c98a1fe28b1 100644 --- a/packages/drive/test/simulation/opencode-protocol.test.ts +++ b/packages/drive/test/simulation/opencode-protocol.test.ts @@ -18,11 +18,6 @@ describe("OpenCode Effect RPC compatibility protocol", () => { sendError(socket, request, "match failed") return } - if (request.method === "ui.screenshot") { - const params = request.params as { readonly name?: string } | undefined - sendResult(socket, request, `/tmp/${params?.name ?? "screen"}.png`) - return - } if (request.method === "ui.state") socket.send( JSON.stringify({ @@ -42,8 +37,6 @@ describe("OpenCode Effect RPC compatibility protocol", () => { const client = yield* RpcClient.make(UiRpcs).pipe(Effect.provideService(RpcClient.Protocol, protocol)) expect(yield* client["ui.state"]()).toEqual(state) - expect(yield* client["ui.screenshot"](undefined)).toBe("/tmp/screen.png") - expect(yield* client["ui.screenshot"]({ name: "home" })).toBe("/tmp/home.png") expect(yield* client["ui.press"]({ key: "right" })).toEqual(state) expect(yield* client["ui.press"]({ key: "down", modifiers: { meta: true } })).toEqual(state) expect(yield* client["ui.press"]({ key: "tab", modifiers: { ctrl: true } })).toEqual(state) @@ -64,35 +57,24 @@ describe("OpenCode Effect RPC compatibility protocol", () => { { jsonrpc: "2.0", id: firstId + 1, - method: "ui.screenshot", - }, - { - jsonrpc: "2.0", - id: firstId + 2, - method: "ui.screenshot", - params: { name: "home" }, - }, - { - jsonrpc: "2.0", - id: firstId + 3, method: "ui.press", params: { key: "\u001b[C" }, }, { jsonrpc: "2.0", - id: firstId + 4, + id: firstId + 2, method: "ui.press", params: { key: "\u001b[1;3B" }, }, { jsonrpc: "2.0", - id: firstId + 5, + id: firstId + 3, method: "ui.press", params: { key: "\u001b[9;5u" }, }, { jsonrpc: "2.0", - id: firstId + 6, + id: firstId + 4, method: "ui.matches", params: { text: "fail" }, }, diff --git a/packages/drive/test/simulation/rpc.test.ts b/packages/drive/test/simulation/rpc.test.ts index 43e95f8b3b2..b5a29f5db10 100644 --- a/packages/drive/test/simulation/rpc.test.ts +++ b/packages/drive/test/simulation/rpc.test.ts @@ -31,10 +31,6 @@ describe("OpenCode simulation RPC contracts", () => { ) return Effect.succeed(true) }, - "ui.screenshot": (payload) => { - calls.push({ method: "ui.screenshot", payload }) - return Effect.succeed(`/tmp/${payload?.name ?? "screen"}.png`) - }, "ui.recording.finish": (payload) => { calls.push({ method: "ui.recording.finish", payload }) return Effect.succeed("/tmp/recording.jsonl") @@ -62,8 +58,6 @@ describe("OpenCode simulation RPC contracts", () => { code: -32000, message: "match failed", }) - expect(yield* client["ui.screenshot"](undefined)).toBe("/tmp/screen.png") - expect(yield* client["ui.screenshot"]({ name: "home" })).toBe("/tmp/home.png") expect(yield* client["ui.recording.finish"]()).toBe("/tmp/recording.jsonl") expect(yield* client["ui.type"]({ text: "hello" })).toEqual(state) @@ -71,8 +65,6 @@ describe("OpenCode simulation RPC contracts", () => { { method: "ui.state", payload: undefined }, { method: "ui.matches", payload: { text: "ready" } }, { method: "ui.matches", payload: { text: "fail" } }, - { method: "ui.screenshot", payload: undefined }, - { method: "ui.screenshot", payload: { name: "home" } }, { method: "ui.recording.finish", payload: undefined }, { method: "ui.type", payload: { text: "hello" } }, ]) diff --git a/packages/drive/test/simulation/ui.test.ts b/packages/drive/test/simulation/ui.test.ts index e5e300ab14e..79549c544c5 100644 --- a/packages/drive/test/simulation/ui.test.ts +++ b/packages/drive/test/simulation/ui.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, test } from "@effect/vitest" import { Effect } from "effect" import { Frontend } from "../../src/client/index.js" import * as SimulationConnector from "../../src/simulation/connector.js" -import { sendError, sendResult, startTransportPeer } from "./transport-peer.js" +import { sendResult, startTransportPeer } from "./transport-peer.js" const state: Frontend.State = { focused: { renderable: 1, editor: true }, @@ -26,15 +26,6 @@ describe("OpenCode UI simulation transport", () => { sendResult(socket, request, true) return } - if (request.method === "ui.screenshot") { - const params = request.params as { readonly name?: string } | undefined - if (params?.name === "fail") { - sendError(socket, request, "screenshot failed") - return - } - sendResult(socket, request, `/tmp/${params?.name ?? "screenshot"}.png`) - return - } if (request.method === "ui.recording.finish") { sendResult(socket, request, "/tmp/recording.jsonl") return @@ -47,8 +38,6 @@ describe("OpenCode UI simulation transport", () => { expect(yield* rpc["ui.state"]()).toEqual(state) expect(yield* rpc["ui.snapshot"]()).toEqual(snapshot) expect(yield* rpc["ui.matches"]({ text: "needle" })).toBe(true) - expect(yield* rpc["ui.screenshot"](undefined)).toBe("/tmp/screenshot.png") - expect(yield* rpc["ui.screenshot"]({ name: "home" })).toBe("/tmp/home.png") expect(yield* rpc["ui.recording.finish"]()).toBe("/tmp/recording.jsonl") expect(yield* rpc["ui.type"]({ text: "hello" })).toEqual(state) expect(yield* rpc["ui.press"]({ key: "x" })).toEqual(state) @@ -60,13 +49,6 @@ describe("OpenCode UI simulation transport", () => { expect(yield* rpc["ui.click"]({ target: 7, x: 3, y: 2 })).toEqual(state) expect(yield* rpc["ui.resize"]({ cols: 120, rows: 40 })).toEqual(state) - const error = yield* rpc["ui.screenshot"]({ name: "fail" }).pipe(Effect.flip) - expect(error).toMatchObject({ - _tag: "SimulationRequestError", - message: "screenshot failed", - method: "ui.screenshot", - }) - expect(peer.received.map(({ request }) => request)).toEqual([ { jsonrpc: "2.0", id: 1, method: "ui.state" }, { jsonrpc: "2.0", id: 2, method: "ui.snapshot" }, @@ -76,69 +58,56 @@ describe("OpenCode UI simulation transport", () => { method: "ui.matches", params: { text: "needle" }, }, - { jsonrpc: "2.0", id: 4, method: "ui.screenshot" }, + { jsonrpc: "2.0", id: 4, method: "ui.recording.finish" }, { jsonrpc: "2.0", id: 5, - method: "ui.screenshot", - params: { name: "home" }, - }, - { jsonrpc: "2.0", id: 6, method: "ui.recording.finish" }, - { - jsonrpc: "2.0", - id: 7, method: "ui.type", params: { text: "hello" }, }, { jsonrpc: "2.0", - id: 8, + id: 6, method: "ui.press", params: { key: "x" }, }, { jsonrpc: "2.0", - id: 9, + id: 7, method: "ui.press", params: { key: "x", modifiers: { ctrl: true, shift: false } }, }, { jsonrpc: "2.0", - id: 10, + id: 8, method: "ui.press", params: { key: "escape" }, }, - { jsonrpc: "2.0", id: 11, method: "ui.enter" }, + { jsonrpc: "2.0", id: 9, method: "ui.enter" }, { jsonrpc: "2.0", - id: 12, + id: 10, method: "ui.arrow", params: { direction: "left" }, }, { jsonrpc: "2.0", - id: 13, + id: 11, method: "ui.focus", params: { target: 7 }, }, { jsonrpc: "2.0", - id: 14, + id: 12, method: "ui.click", params: { target: 7, x: 3, y: 2 }, }, { jsonrpc: "2.0", - id: 15, + id: 13, method: "ui.resize", params: { cols: 120, rows: 40 }, }, - { - jsonrpc: "2.0", - id: 16, - method: "ui.screenshot", - params: { name: "fail" }, - }, ]) for (const { request } of peer.received) expect(Frontend.decodeRequest(request)).toEqual(request) diff --git a/packages/protocol/src/simulation.ts b/packages/protocol/src/simulation.ts index 554d0e25270..89b82c2f283 100644 --- a/packages/protocol/src/simulation.ts +++ b/packages/protocol/src/simulation.ts @@ -175,7 +175,6 @@ export namespace Frontend { "ui.click.semantic", "ui.resize", "ui.matches", - "ui.screenshot", "ui.state", "ui.snapshot", "ui.capture", @@ -279,9 +278,6 @@ export namespace Frontend { }) export interface SemanticSnapshot extends Schema.Schema.Type {} - export const Screenshot = Schema.String - export type Screenshot = Schema.Schema.Type - export const Color = Schema.Tuple([Schema.Number, Schema.Number, Schema.Number, Schema.Number]) export type Color = Schema.Schema.Type @@ -311,9 +307,6 @@ export namespace Frontend { export const Matches = Schema.Boolean export type Matches = Schema.Schema.Type - export const ScreenshotParams = Schema.Struct({ name: Schema.optional(Schema.String) }) - export interface ScreenshotParams extends Schema.Schema.Type {} - export const TypeParams = Schema.Struct({ text: Schema.String }) export interface TypeParams extends Schema.Schema.Type {} @@ -354,11 +347,6 @@ export namespace Frontend { 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.screenshot"), - params: Schema.optional(ScreenshotParams), - }), Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literals(["ui.enter", "ui.state", "ui.snapshot", "ui.recording.finish"]), @@ -606,10 +594,6 @@ export const UiRpcs = RpcGroup.make( request("ui.snapshot", { success: Frontend.SemanticSnapshot }), request("ui.capture", { success: Frontend.CapturedFrame }), request("ui.matches", { payload: Frontend.MatchesParams, success: Frontend.Matches }), - request("ui.screenshot", { - payload: Schema.UndefinedOr(Frontend.ScreenshotParams), - success: Frontend.Screenshot, - }), request("ui.recording.finish", { success: Frontend.RecordingFinish }), request("ui.type", { payload: Frontend.TypeParams, success: Frontend.State }), request("ui.press", { payload: Frontend.PressParams, success: Frontend.State }), diff --git a/packages/simulation/package.json b/packages/simulation/package.json index 570619d276d..0b76f236051 100644 --- a/packages/simulation/package.json +++ b/packages/simulation/package.json @@ -17,22 +17,19 @@ "typecheck": "tsgo -b" }, "dependencies": { - "@fontsource/commit-mono": "5.2.5", - "@fontsource/noto-sans-math": "5.2.5", - "@fontsource/noto-sans-symbols": "5.2.5", - "@fontsource/noto-sans-symbols-2": "5.2.5", - "@napi-rs/canvas": "1.0.2", "@opencode-ai/core": "workspace:*", "@opencode-ai/ai": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/protocol": "workspace:*", "@opencode-ai/util": "workspace:*", "@opentui/core": "catalog:", - "effect": "catalog:" + "effect": "catalog:", + "ws": "8.21.0" }, "devDependencies": { "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", + "@types/ws": "8.18.1", "@typescript/native-preview": "catalog:" } } diff --git a/packages/simulation/src/assets.d.ts b/packages/simulation/src/assets.d.ts deleted file mode 100644 index f80c06f5d1b..00000000000 --- a/packages/simulation/src/assets.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module "*.woff2" { - const path: string - export default path -} diff --git a/packages/simulation/src/backend/simulated-provider.ts b/packages/simulation/src/backend/simulated-provider.ts index d2a90c362ad..ffc637d1697 100644 --- a/packages/simulation/src/backend/simulated-provider.ts +++ b/packages/simulation/src/backend/simulated-provider.ts @@ -332,9 +332,7 @@ function handle( fibers, driver.requests.pipe( Stream.runForEach((invocation) => - Effect.sync(() => { - socket.send(JSON.stringify({ jsonrpc: "2.0", method: "llm.request", params: invocation })) - }), + socket.send(JSON.stringify({ jsonrpc: "2.0", method: "llm.request", params: invocation })), ), ), ) @@ -405,10 +403,7 @@ const makeToolDriver = Effect.fn("SimulatedProvider.makeToolDriver")(function* ( socket: ControlSocket, method: "tool.invocation" | "tool.cancel", params: SimulationProtocol.Backend.ToolInvocation | SimulationProtocol.Backend.ToolCancellation, - ) => - Effect.sync(() => { - socket.send(JSON.stringify({ jsonrpc: "2.0", method, params })) - }) + ) => socket.send(JSON.stringify({ jsonrpc: "2.0", method, params })) const requireController = (socket: ControlSocket) => Effect.gen(function* () { diff --git a/packages/simulation/src/control-server.ts b/packages/simulation/src/control-server.ts index fd4211669e6..76449b33cbc 100644 --- a/packages/simulation/src/control-server.ts +++ b/packages/simulation/src/control-server.ts @@ -1,4 +1,4 @@ -import { Effect, Fiber, Queue, Stream } from "effect" +import { Effect, Fiber, FiberSet, Queue, Stream } from "effect" import { SimulationProtocol } from "./protocol" export interface Server { @@ -15,7 +15,12 @@ export interface SocketData { closed?: true } -export type Socket = Bun.ServerWebSocket +export interface Socket { + readonly data: SocketData + readonly send: (message: string) => Effect.Effect +} + +const maxOutboundBytes = 64 * 1024 * 1024 export function start(options: { readonly endpoint: string @@ -27,7 +32,6 @@ export function start(options: { }) { return Effect.gen(function* () { const messages = yield* Queue.bounded<{ readonly socket: Socket; readonly input: string }>(256) - const closures = yield* Queue.unbounded() yield* Stream.fromQueue(messages).pipe( Stream.runForEach((message) => options.decode(message.input).pipe( @@ -40,42 +44,133 @@ export function start(options: { ), ), Effect.catch((error) => send(message.socket, SimulationProtocol.JsonRpc.failure(undefined, error))), + Effect.catchCause((cause) => Effect.logWarning(`${options.label}: request failed`, cause)), ), ), Effect.forkScoped, ) - yield* Stream.fromQueue(closures).pipe( - Stream.runForEach((socket) => options.close?.(socket) ?? Effect.void), - Effect.forkScoped, - ) const url = yield* Effect.try({ try: () => new URL(options.endpoint), catch: (cause) => cause }) + const websocket = yield* Effect.promise(() => import("ws")) + const runPromise = yield* FiberSet.makeRuntimePromise() yield* Effect.acquireRelease( - Effect.sync(() => - Bun.serve({ - hostname: url.hostname, - port: Number(url.port), - fetch(request, server) { - if (server.upgrade(request, { data: options.data() })) return undefined - return new Response(options.label, { status: 426 }) - }, - websocket: { - close(socket) { - socket.data.closed = true - Queue.offerUnsafe(closures, socket) - }, - message(socket, message) { - const input = typeof message === "string" ? message : message.toString() - if (Queue.offerUnsafe(messages, { socket, input })) return - socket.send( - JSON.stringify( - SimulationProtocol.JsonRpc.failure(undefined, new Error("Simulation control queue is full")), - ), - ) - }, - }, - }), + Effect.tryPromise( + () => + new Promise<{ + readonly close: () => Promise + }>((resolve, reject) => { + const server = new websocket.WebSocketServer({ host: url.hostname, port: Number(url.port) }) + const sockets = new Map< + InstanceType, + { socket: Socket; cleanup: () => Promise } + >() + const report = (scope: string, cause: unknown) => + void runPromise(Effect.logWarning(`${options.label}: ${scope} error`, cause)) + const onServerError = (cause: Error) => report("server", cause) + const onStartupError = (cause: Error) => { + server.off("listening", onListening) + server.on("error", onServerError) + reject(cause) + } + const onListening = () => { + server.off("error", onStartupError) + server.on("error", onServerError) + resolve({ + close: async () => { + const accepted = Array.from(sockets.entries()) + accepted.forEach(([connection, record]) => { + record.socket.data.closed = true + connection.terminate() + }) + await Promise.all([ + new Promise((resolveClose, rejectClose) => { + server.close((cause) => (cause ? rejectClose(cause) : resolveClose())) + // Bun releases the listener but does not invoke ws' close callback after an upgraded socket. + queueMicrotask(() => { + if (server.address() === null) resolveClose() + }) + }), + Promise.all(accepted.map(([, record]) => record.cleanup())), + ]) + server.off("error", onServerError) + }, + }) + } + server.once("listening", onListening) + server.once("error", onStartupError) + server.on("connection", (connection) => { + let pendingBytes = 0 + let outbound = Promise.resolve() + let cleanup: Promise | undefined + const socket: Socket = { + data: options.data(), + send: (message) => + Effect.tryPromise({ + try: () => { + const bytes = Buffer.byteLength(message) + if (bytes > maxOutboundBytes || pendingBytes + bytes > maxOutboundBytes) + return Promise.reject(new Error(`Simulation outbound queue exceeds ${maxOutboundBytes} bytes`)) + pendingBytes += bytes + const current = outbound.then( + () => + new Promise((resolveSend, rejectSend) => { + if (connection.readyState !== websocket.WebSocket.OPEN) { + rejectSend(new Error("Simulation control socket is not open")) + return + } + connection.send(message, (cause) => (cause ? rejectSend(cause) : resolveSend())) + }), + ) + outbound = current.catch(() => undefined) + return current.finally(() => { + pendingBytes -= bytes + }) + }, + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }).pipe( + Effect.tapError(() => + Effect.sync(() => { + connection.terminate() + }), + ), + Effect.orDie, + ), + } + const close = () => { + socket.data.closed = true + cleanup ??= runPromise(options.close?.(socket) ?? Effect.void).finally(() => sockets.delete(connection)) + return cleanup + } + sockets.set(connection, { socket, cleanup: close }) + connection.on("close", () => void close()) + connection.on("error", (cause) => { + report("socket", cause) + connection.terminate() + }) + connection.on("message", (message) => { + const input = Array.isArray(message) + ? Buffer.concat(message).toString() + : message instanceof ArrayBuffer + ? Buffer.from(message).toString() + : message.toString() + if (Queue.offerUnsafe(messages, { socket, input })) return + void runPromise( + socket + .send( + JSON.stringify( + SimulationProtocol.JsonRpc.failure(undefined, new Error("Simulation control queue is full")), + ), + ) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning(`${options.label}: queue rejection failed`, cause), + ), + ), + ) + }) + }) + }), ), - (server) => Effect.promise(() => server.stop(true)), + ({ close }) => Effect.promise(close), ) return { url: options.endpoint } satisfies Server }) @@ -83,9 +178,7 @@ export function start(options: { function send(socket: Socket, response: SimulationProtocol.JsonRpc.Response | undefined) { if (!response) return Effect.void - return Effect.sync(() => { - socket.send(JSON.stringify(response)) - }) + return socket.send(JSON.stringify(response)) } export * as SimulationControlServer from "./control-server" diff --git a/packages/simulation/src/frontend/actions.ts b/packages/simulation/src/frontend/actions.ts index 0adcbdbf24f..588801e0d87 100644 --- a/packages/simulation/src/frontend/actions.ts +++ b/packages/simulation/src/frontend/actions.ts @@ -1,5 +1,3 @@ -import { tmpdir } from "node:os" -import { extname, join, resolve } from "node:path" import type { CliRenderer, Renderable } from "@opentui/core" import { createMockKeys, @@ -9,7 +7,7 @@ import { type MockInput, type MockMouse, } from "@opentui/core/testing" -import { Config, Effect, FileSystem, Schema } from "effect" +import { Effect, Schema } from "effect" import { SimulationProtocol } from "../protocol" import { SimulationRenderer } from "./renderer" import { SimulationSemantics } from "./semantics" @@ -167,25 +165,6 @@ export const capture = Effect.fn("SimulationActions.capture")(function* (harness } satisfies SimulationProtocol.Frontend.CapturedFrame }) -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)) - 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( - yield* Config.string("OPENCODE_DRIVE_MEDIA_DIR").pipe( - Config.withDefault(join(tmpdir(), "opencode-drive", "output")), - ), - ) - const fs = yield* FileSystem.FileSystem - yield* fs.makeDirectory(directory, { recursive: true }) - const path = join(directory, `${filename}.png`) - yield* fs.writeFile(path, image.data) - return path -}) - export const execute = Effect.fn("SimulationActions.execute")(function* (harness: Harness, action: Action) { switch (action.type) { case "ui.type": diff --git a/packages/simulation/src/frontend/png.ts b/packages/simulation/src/frontend/png.ts deleted file mode 100644 index 98fb9f3814b..00000000000 --- a/packages/simulation/src/frontend/png.ts +++ /dev/null @@ -1,117 +0,0 @@ -/// -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" } -import symbolFont from "@fontsource/noto-sans-symbols/files/noto-sans-symbols-symbols-400-normal.woff2" with { type: "file" } -import symbolFont2 from "@fontsource/noto-sans-symbols-2/files/noto-sans-symbols-2-symbols-400-normal.woff2" with { type: "file" } -import brailleFont from "@fontsource/noto-sans-symbols-2/files/noto-sans-symbols-2-braille-400-normal.woff2" with { type: "file" } -import mathFont from "@fontsource/noto-sans-math/files/noto-sans-math-math-400-normal.woff2" with { type: "file" } - -const CellWidth = 10 -const CellHeight = 20 -const FontSize = 16 -const FontFamily = "OpenCode Mono" -const SymbolFontFamily = "OpenCode Symbols" -const SymbolFontFamily2 = "OpenCode Symbols 2" -const MathFontFamily = "OpenCode Math" -const FontStack = `"${FontFamily}", "${SymbolFontFamily}", "${SymbolFontFamily2}", "${MathFontFamily}"` - -for (const [file, family] of [ - ...[regularFont, boldFont, italicFont, boldItalicFont].map((file) => [file, FontFamily] as const), - [symbolFont, SymbolFontFamily], - [symbolFont2, SymbolFontFamily2], - [brailleFont, SymbolFontFamily2], - [mathFont, MathFontFamily], -] as const) { - const font = Buffer.from(await Bun.file(file).arrayBuffer()) - if (!GlobalFonts.register(font, family)) throw new Error(`Failed to register screenshot font: ${file}`) -} - -export function screenshot(renderer: CliRenderer) { - return screenshotFrame({ - cols: renderer.currentRenderBuffer.width, - rows: renderer.currentRenderBuffer.height, - cursor: [0, 0], - lines: renderer.currentRenderBuffer.getSpanLines(), - }) -} - -export function screenshotFrame(frame: CapturedFrame) { - const canvas = createCanvas(frame.cols * CellWidth, frame.rows * CellHeight) - const context = canvas.getContext("2d") - context.fillStyle = "#080808" - context.fillRect(0, 0, canvas.width, canvas.height) - context.textBaseline = "top" - - frame.lines.forEach((line, row) => { - let column = 0 - line.spans.forEach((span) => { - const attributes = span.attributes & 0xff - const inverse = Boolean(attributes & TextAttributes.INVERSE) - const hidden = Boolean(attributes & TextAttributes.HIDDEN) - const foreground = inverse ? span.bg : span.fg - const background = inverse ? span.fg : span.bg - const chars = [...span.text] - let remaining = span.width - - chars.forEach((char, index) => { - const cells = Math.max(1, remaining - (chars.length - index - 1)) - if (background.a) { - context.fillStyle = color(background) - context.fillRect(column * CellWidth, row * CellHeight, cells * CellWidth, CellHeight) - } - if (!hidden && char.codePointAt(0) !== 0x0a00) { - context.fillStyle = color(foreground, attributes & TextAttributes.DIM ? 0.55 : 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 ${FontStack}` - context.fillText(char, x, y + 1) - } - if (attributes & TextAttributes.UNDERLINE) { - context.fillRect(x, y + 17, cells * CellWidth, 1) - } - if (attributes & TextAttributes.STRIKETHROUGH) { - context.fillRect(x, y + 10, cells * CellWidth, 1) - } - } - column += cells - remaining -= cells - }) - while (remaining-- > 0) { - if (background.a) { - context.fillStyle = color(background) - context.fillRect(column * CellWidth, row * CellHeight, CellWidth, CellHeight) - } - column++ - } - }) - }) - - return { - width: canvas.width, - height: canvas.height, - data: canvas.toBuffer("image/png"), - } -} - -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 if (char === "┃") context.fillRect(x + CellWidth / 2 - 1, y, 2, CellHeight) - else if (char === "╹") context.fillRect(x + CellWidth / 2 - 1, y, 2, 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})` -} - -export * as SimulationPng from "./png" diff --git a/packages/simulation/src/frontend/server.ts b/packages/simulation/src/frontend/server.ts index 2318a27adab..75b75cd9d42 100644 --- a/packages/simulation/src/frontend/server.ts +++ b/packages/simulation/src/frontend/server.ts @@ -17,8 +17,6 @@ function handle(harness: Harness, request: SimulationProtocol.Frontend.Request, ) case "ui.capture": return SimulationActions.capture(harness) - case "ui.screenshot": - return SimulationActions.screenshot(harness, request.params?.name) case "ui.state": return Effect.sync(() => SimulationActions.state(harness)) case "ui.snapshot": diff --git a/packages/simulation/test/control-server.test.ts b/packages/simulation/test/control-server.test.ts new file mode 100644 index 00000000000..d0e8e9a708d --- /dev/null +++ b/packages/simulation/test/control-server.test.ts @@ -0,0 +1,101 @@ +import { expect, test } from "bun:test" +import { Effect, Queue, Schema } from "effect" +import { SimulationControlServer } from "../src/control-server" +import { availableEndpoint, connect } from "./fixture/websocket" + +const Request = Schema.Struct({ id: Schema.optional(Schema.Number) }) + +test("awaits accepted socket cleanup before the server scope closes", async () => { + const endpoint = availableEndpoint() + let cleaned = false + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* SimulationControlServer.start({ + endpoint, + label: "control server test", + data: () => ({}), + decode: Schema.decodeUnknownEffect(Schema.fromJsonString(Request)), + handle: () => Effect.succeed({ ok: true }), + close: () => + Effect.promise(async () => { + await Bun.sleep(25) + cleaned = true + }), + }) + yield* connect(endpoint) + }), + ), + ) + + expect(cleaned).toBe(true) + const url = new URL(endpoint) + const rebound = Bun.serve({ hostname: url.hostname, port: Number(url.port), fetch: () => new Response() }) + await rebound.stop(true) +}) + +test("continues serving after a response targets a closed socket", async () => { + const endpoint = availableEndpoint() + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* SimulationControlServer.start({ + endpoint, + label: "control server test", + data: () => ({}), + decode: Schema.decodeUnknownEffect(Schema.fromJsonString(Request)), + handle: () => Effect.sleep(25).pipe(Effect.as({ ok: true })), + }) + const closed = yield* connect(endpoint) + closed.send(JSON.stringify({ id: 1 })) + closed.close() + yield* Effect.sleep(50) + + const socket = yield* connect(endpoint) + const messages = yield* Queue.unbounded() + socket.addEventListener("message", (event) => Queue.offerUnsafe(messages, JSON.parse(String(event.data)))) + socket.send(JSON.stringify({ id: 2 })) + expect(yield* Queue.take(messages)).toMatchObject({ id: 2, result: { ok: true } }) + }), + ), + ) +}) + +test("disconnects and cleans up when an outbound message exceeds the queue bound", async () => { + const endpoint = availableEndpoint() + let cleaned = false + let delivered = false + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* SimulationControlServer.start({ + endpoint, + label: "control server test", + data: () => ({}), + decode: Schema.decodeUnknownEffect(Schema.fromJsonString(Request)), + handle: (socket) => + Effect.gen(function* () { + yield* socket.send("x".repeat(64 * 1024 * 1024 + 1)) + delivered = true + return { ok: true } + }), + close: () => Effect.sync(() => void (cleaned = true)), + }) + const socket = yield* connect(endpoint) + const closed = yield* Queue.unbounded() + const messages: string[] = [] + socket.addEventListener("close", () => Queue.offerUnsafe(closed, undefined)) + socket.addEventListener("message", (event) => messages.push(String(event.data))) + socket.send(JSON.stringify({ id: 1 })) + yield* Queue.take(closed).pipe(Effect.timeout("5 seconds")) + + expect(cleaned).toBe(true) + expect(delivered).toBe(false) + expect(messages).toEqual([]) + }), + ), + ) +}) diff --git a/packages/simulation/test/png.test.ts b/packages/simulation/test/png.test.ts deleted file mode 100644 index 297ca18813e..00000000000 --- a/packages/simulation/test/png.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { expect, test } from "bun:test" -import { createCanvas, loadImage } from "@napi-rs/canvas" -import { RGBA, TextAttributes, type CapturedFrame } from "@opentui/core" -import { SimulationPng } from "../src/frontend/png" - -test("renders captured frames with bundled fonts", () => { - const frame: CapturedFrame = { - cols: 4, - rows: 1, - cursor: [0, 0], - lines: [ - { - spans: [ - { - text: "Test", - width: 4, - fg: RGBA.fromInts(255, 255, 255), - bg: RGBA.fromInts(0, 0, 0), - attributes: TextAttributes.BOLD | TextAttributes.ITALIC, - }, - ], - }, - ], - } - - const image = SimulationPng.screenshotFrame(frame) - expect(image.width).toBe(40) - expect(image.height).toBe(20) - expect(image.data.subarray(1, 4).toString()).toBe("PNG") -}) - -test("renders OpenCode symbols instead of missing-glyph boxes", async () => { - const pixels = async (text: string) => { - const image = SimulationPng.screenshotFrame({ - cols: 1, - rows: 1, - cursor: [0, 0], - lines: [ - { - spans: [ - { - text, - width: 1, - fg: RGBA.fromInts(255, 255, 255), - bg: RGBA.fromInts(0, 0, 0), - attributes: 0, - }, - ], - }, - ], - }) - const canvas = createCanvas(image.width, image.height) - const context = canvas.getContext("2d") - context.drawImage(await loadImage(image.data), 0, 0) - return [...context.getImageData(0, 0, image.width, image.height).data] - } - - const missingGlyph = await pixels("\u{10ffff}") - for (const symbol of ["△", "✱", "⇆", "⠹"]) { - expect(await pixels(symbol)).not.toEqual(missingGlyph) - } -}) - -test("fills adjacent block elements without glyph gaps", async () => { - const image = SimulationPng.screenshotFrame({ - cols: 2, - rows: 1, - cursor: [0, 0], - lines: [ - { - spans: [ - { - text: "▀▀", - width: 2, - fg: RGBA.fromInts(255, 255, 255), - bg: RGBA.fromInts(0, 0, 0), - attributes: 0, - }, - ], - }, - ], - }) - const canvas = createCanvas(image.width, image.height) - const context = canvas.getContext("2d") - context.drawImage(await loadImage(image.data), 0, 0) - - expect([...context.getImageData(0, 5, image.width, 1).data]).toEqual( - Array.from({ length: image.width }, () => [255, 255, 255, 255]).flat(), - ) - expect([...context.getImageData(0, 15, image.width, 1).data]).toEqual( - Array.from({ length: image.width }, () => [0, 0, 0, 255]).flat(), - ) -}) - -test("draws heavy vertical box elements on cell boundaries", async () => { - const image = SimulationPng.screenshotFrame({ - cols: 1, - rows: 2, - cursor: [0, 0], - lines: [ - { - spans: [ - { - text: "┃", - width: 1, - fg: RGBA.fromInts(255, 255, 255), - bg: RGBA.fromInts(0, 0, 0), - attributes: 0, - }, - ], - }, - { - spans: [ - { - text: "╹", - width: 1, - fg: RGBA.fromInts(255, 255, 255), - bg: RGBA.fromInts(0, 0, 0), - attributes: 0, - }, - ], - }, - ], - }) - const canvas = createCanvas(image.width, image.height) - const context = canvas.getContext("2d") - context.drawImage(await loadImage(image.data), 0, 0) - - expect([...context.getImageData(4, 0, 2, 30).data]).toEqual( - Array.from({ length: 60 }, () => [255, 255, 255, 255]).flat(), - ) - expect([...context.getImageData(4, 30, 2, 10).data]).toEqual(Array.from({ length: 20 }, () => [0, 0, 0, 255]).flat()) -})