feat(simulation): slim production bridge (#42584)

This commit is contained in:
Kit Langton
2026-08-14 11:57:44 -04:00
committed by GitHub
parent 6d0c30a125
commit e3bbfdf270
36 changed files with 630 additions and 502 deletions
+2 -11
View File
@@ -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=="],
+4 -15
View File
@@ -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<string[]> {
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()
}
+3
View File
@@ -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)
}
+14 -1
View File
@@ -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<string>()
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]) {
+13
View File
@@ -0,0 +1,13 @@
import { readdir } from "node:fs/promises"
import path from "node:path"
export async function collectFiles(root: string, current = root): Promise<string[]> {
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()
}
+6 -14
View File
@@ -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<string[]> {
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)]),
)
}
+45
View File
@@ -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<string>, drive: string, driveEnv: Record<string, string | undefined>) {
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++) {
+69
View File
@@ -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<string>) {
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<string[]> {
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 <file-or-directory>")
await verifyArtifact(target)
}
+12 -1
View File
@@ -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)),
},
},
+24 -8
View File
@@ -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<Frontend.Capability, "ui.click.semantic">,
DriveCommand["operation"],
{ readonly value: boolean | "optional"; readonly description: string }
>
type CommandName = Exclude<Frontend.Capability, "ui.click.semantic">
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<DriveCommand>) {
const exit = await Effect.runPromiseExit(Effect.scoped(executeBatch(endpoint, commands)))
export async function executeCommands(
endpoint: string,
commands: ReadonlyArray<DriveCommand>,
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<DriveCommand>,
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<unknown, SimulationError> =>
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":
+25 -4
View File
@@ -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}` }
}
+1 -1
View File
@@ -1,7 +1,7 @@
import type { Frontend } from "../client/index.js"
export interface DriveCommand {
readonly operation: Exclude<Frontend.Capability, "ui.click.semantic">
readonly operation: Exclude<Frontend.Capability, "ui.click.semantic"> | "ui.screenshot"
readonly value?: string
}
+1 -1
View File
@@ -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,
+2 -1
View File
@@ -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"
+15
View File
@@ -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
}
+22 -4
View File
@@ -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<UiPredicateError>(
message: Schema.String,
}) {}
export class UiScreenshotError extends Schema.TaggedErrorClass<UiScreenshotError>()("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<Frontend.SemanticSnapshot, SemanticOperationError>
readonly capture: () => Effect.Effect<Frontend.CapturedFrame, OperationError>
readonly matches: (text: string) => Effect.Effect<boolean, OperationError>
readonly screenshot: (name?: string) => Effect.Effect<string, OperationError>
readonly screenshot: (name?: string) => Effect.Effect<string, ScreenshotError>
readonly type: (text: string) => Effect.Effect<Frontend.State, OperationError>
readonly press: (key: string, modifiers?: Frontend.KeyModifiers) => Effect.Effect<Frontend.State, OperationError>
readonly enter: () => Effect.Effect<Frontend.State, OperationError>
@@ -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) =>
+7
View File
@@ -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
}
+5 -2
View File
@@ -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<void, OpenCodeInstanceError>
@@ -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,
+10 -6
View File
@@ -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,
)
+12 -1
View File
@@ -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)
+65 -15
View File
@@ -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<ReturnType<OpenCodeUi.Ui["screenshot"]>>
const operationErrorExcludesScreenshotError: Types.Equals<
Extract<OpenCodeUi.OperationError, OpenCodeUi.UiScreenshotError>,
never
> = true
const screenshotErrorIsSpecific: Types.Equals<ScreenshotFailure, OpenCodeUi.ScreenshotError> = 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 }) => {
-4
View File
@@ -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
+25 -1
View File
@@ -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) })
@@ -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" },
},
@@ -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" } },
])
+10 -41
View File
@@ -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)
-16
View File
@@ -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<typeof SemanticSnapshot> {}
export const Screenshot = Schema.String
export type Screenshot = Schema.Schema.Type<typeof Screenshot>
export const Color = Schema.Tuple([Schema.Number, Schema.Number, Schema.Number, Schema.Number])
export type Color = Schema.Schema.Type<typeof Color>
@@ -311,9 +307,6 @@ export namespace Frontend {
export const Matches = Schema.Boolean
export type Matches = Schema.Schema.Type<typeof Matches>
export const ScreenshotParams = Schema.Struct({ name: Schema.optional(Schema.String) })
export interface ScreenshotParams extends Schema.Schema.Type<typeof ScreenshotParams> {}
export const TypeParams = Schema.Struct({ text: Schema.String })
export interface TypeParams extends Schema.Schema.Type<typeof TypeParams> {}
@@ -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 }),
+3 -6
View File
@@ -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:"
}
}
-4
View File
@@ -1,4 +0,0 @@
declare module "*.woff2" {
const path: string
export default path
}
@@ -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* () {
+128 -35
View File
@@ -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<SocketData>
export interface Socket {
readonly data: SocketData
readonly send: (message: string) => Effect.Effect<void>
}
const maxOutboundBytes = 64 * 1024 * 1024
export function start<RequestType extends Request, Error, Services>(options: {
readonly endpoint: string
@@ -27,7 +32,6 @@ export function start<RequestType extends Request, Error, Services>(options: {
}) {
return Effect.gen(function* () {
const messages = yield* Queue.bounded<{ readonly socket: Socket; readonly input: string }>(256)
const closures = yield* Queue.unbounded<Socket>()
yield* Stream.fromQueue(messages).pipe(
Stream.runForEach((message) =>
options.decode(message.input).pipe(
@@ -40,42 +44,133 @@ export function start<RequestType extends Request, Error, Services>(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<Services, void, never>()
yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.serve<SocketData>({
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<void>
}>((resolve, reject) => {
const server = new websocket.WebSocketServer({ host: url.hostname, port: Number(url.port) })
const sockets = new Map<
InstanceType<typeof websocket.WebSocket>,
{ socket: Socket; cleanup: () => Promise<void> }
>()
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<void>((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<void> | 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<void>((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<RequestType extends Request, Error, Services>(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"
+1 -22
View File
@@ -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":
-117
View File
@@ -1,117 +0,0 @@
/// <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" }
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"
@@ -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":
@@ -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<unknown>()
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<void>()
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([])
}),
),
)
})
-133
View File
@@ -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())
})