From f147ff64f1493b28897e6ca07d2a21facf79025a Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:10:02 +1000 Subject: [PATCH] feat(client): add shared Chromium driver --- packages/client/README.md | 37 +- packages/client/src/node/browser/chromium.ts | 1137 ++++++++++++++++++ packages/client/src/node/browser/driver.ts | 7 + packages/client/src/node/index.ts | 1 + packages/client/test/node/chromium.test.ts | 363 ++++++ packages/client/test/node/package-smoke.ts | 1 + packages/client/test/types/node-consumer.ts | 19 +- 7 files changed, 1548 insertions(+), 17 deletions(-) create mode 100644 packages/client/src/node/browser/chromium.ts create mode 100644 packages/client/test/node/chromium.test.ts diff --git a/packages/client/README.md b/packages/client/README.md index 06d0dd17ea..dcdc4eb8ce 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -16,25 +16,26 @@ The Promise root remains structural and has no Core, Effect, Schema, Protocol, o ## Node browser attachments -The Node entrypoint owns the control connection, Session lease, authenticated proxy, and network tunnels. Consumers provide a browser adapter once with `BrowserDriver.define`; normal attachment calls only provide a Session ID and that descriptor. +The Node entrypoint owns the control connection, Session lease, authenticated proxy, and network tunnels. Chromium hosts provide a small platform port once with `BrowserDriver.chromium`; the SDK owns command semantics, CDP input, snapshots, generations, cancellation, and limits. ```ts -import { BrowserDriver, BrowserDriverError, OpenCode } from "@opencode-ai/client/node" +import { BrowserDriver, OpenCode } from "@opencode-ai/client/node" -const driver = BrowserDriver.define(async ({ proxy, signal }) => { - const browser = await launchBrowser({ proxy, signal }) +const driver = BrowserDriver.chromium(async ({ proxy, signal }) => { + const view = await createChromiumView({ proxy, signal }) return { - resource: browser, - state: () => browser.state(), - subscribe: (listener) => browser.subscribe(listener), - execute: async (command, options) => { - try { - return await browser.execute(command, options) - } catch (cause) { - throw new BrowserDriverError("internal", "Browser command failed", { cause }) - } - }, - dispose: () => browser.close(), + resource: view, + state: () => view.state(), + subscribe: (listener) => view.subscribe((state, mainDocumentChanged) => listener({ state, mainDocumentChanged })), + navigate: (url) => view.navigate(url), + back: () => view.back(), + forward: () => view.forward(), + reload: () => view.reload(), + stop: () => view.stop(), + send: (method, params) => view.sendCDP(method, params), + viewport: () => view.viewport(), + screenshot: ({ maxDimension }) => view.capturePNG({ maxDimension }), + dispose: () => view.close(), } }) @@ -44,6 +45,8 @@ const client = OpenCode.make({ }) const attachment = await client.browser.attach({ sessionID, driver }) +await attachment.resource.navigate("example.com") +const view = attachment.resource.resource await attachment.close() ``` @@ -51,7 +54,9 @@ await attachment.close() Driver factories should return after configuring their resource rather than await a proxied navigation: tunnel dialing is deliberately held behind the first lease acknowledgement, which is published after the driver supplies its initial state. -`BrowserDriver` descriptors are structural factory functions, so adapters remain compatible across duplicate client package instances. The Node entrypoint also re-exports canonical `Browser` contracts. Throw `BrowserDriverError` for typed command failures; structurally equivalent errors are accepted only when their `code` is a valid `Browser.ErrorCode`. +Port state events set `mainDocumentChanged` only when the main document changes; this advances the public generation and invalidates element refs. `send` must dispatch CDP calls in invocation order. `screenshot` returns PNG bytes and dimensions, proportionally scaled to `maxDimension` without upscaling. The returned controller serializes local navigation with remote commands; `stop` immediately interrupts active work, and controller disposal is idempotent. An aborted or timed-out operation that reached the platform disposes the port so late native completion cannot cross the queue fence. + +`BrowserDriver` descriptors are structural factory functions, so adapters remain compatible across duplicate client package instances. The Node entrypoint also re-exports canonical `Browser` contracts. `BrowserDriver.define` remains the advanced escape hatch for non-Chromium semantics; throw `BrowserDriverError` for typed command failures there. Structurally equivalent errors are accepted only when their `code` is a valid `Browser.ErrorCode`. Effect consumers construct canonical decoded inputs: diff --git a/packages/client/src/node/browser/chromium.ts b/packages/client/src/node/browser/chromium.ts new file mode 100644 index 0000000000..00b63b70b9 --- /dev/null +++ b/packages/client/src/node/browser/chromium.ts @@ -0,0 +1,1137 @@ +import { Browser } from "@opencode-ai/schema/browser" +import { + BrowserDriverError, + type BrowserDriver, + type BrowserDriverContext, + type BrowserDriverInstance, +} from "./driver.js" + +type ChromiumViewState = { + readonly url: string + readonly title: string + readonly loading: boolean + readonly canGoBack: boolean + readonly canGoForward: boolean +} + +type ChromiumViewEvent = { + readonly state: ChromiumViewState + readonly mainDocumentChanged: boolean +} + +/** Platform-specific Chromium primitives used by the shared semantic driver. */ +export interface ChromiumPort { + readonly resource: Resource + readonly state: () => ChromiumViewState + readonly subscribe: (listener: (event: ChromiumViewEvent) => void) => () => void + readonly navigate: (url: string) => PromiseLike + readonly back: () => PromiseLike | void + readonly forward: () => PromiseLike | void + readonly reload: () => PromiseLike | void + readonly stop: () => void + /** Sends CDP commands in invocation order. */ + readonly send: (method: string, params?: Record) => PromiseLike + readonly viewport: () => { readonly width: number; readonly height: number } + /** Captures a proportionally scaled viewport PNG without upscaling. */ + readonly screenshot: (options: { readonly maxDimension: number }) => PromiseLike<{ + readonly data: Uint8Array + readonly width: number + readonly height: number + }> + readonly dispose: () => PromiseLike | void +} + +/** Local controller exposed as the browser attachment resource. */ +export interface ChromiumController extends AsyncDisposable { + readonly resource: Resource + readonly state: () => Browser.State + readonly subscribe: (listener: (state: Browser.State) => void) => () => void + readonly navigate: (url: string) => Promise + readonly back: () => Promise + readonly forward: () => Promise + readonly reload: () => Promise + readonly stop: () => void + readonly dispose: () => Promise +} + +export type ChromiumDriver = BrowserDriver> + +type Ref = { readonly snapshot: number; readonly token: string; readonly objectID: string } +type SnapshotNode = { + readonly token?: string + readonly role: string + readonly name: string + readonly value: string + readonly depth: number + readonly checked?: boolean + readonly disabled?: boolean + readonly expanded?: boolean + readonly selected?: boolean +} +type Operation = { started: boolean } +type Page = { + readonly port: ChromiumPort + readonly lifetime: AbortSignal + document: number + snapshot: number + nextRef: number + snapshotObjectID?: string + readonly refs: Map + readonly listeners: Set<(state: Browser.State) => void> + readonly requests: Set + state: ChromiumViewState + unsubscribe?: () => void + active?: AbortController + disposed: boolean + disposal?: Promise + queue: Promise +} + +const debuggerCommandTimeout = 10_000 +const browserSnapshotLimit = 500 +const screenshotDimensionLimit = 2_000 +const screenshotByteLimit = 5 * 1_024 * 1_024 + +export function chromiumDriver( + create: (context: BrowserDriverContext) => PromiseLike> | ChromiumPort, +): ChromiumDriver { + return async (context) => { + const port = await create(context) + if (context.signal.aborted) { + await cleanup(() => port.dispose()) + throw abortReason(context.signal, "Chromium driver creation was aborted") + } + const initialized = Promise.resolve().then(() => createInstance(port, context.signal)) + return initialized.then( + async (instance) => { + if (!context.signal.aborted) return instance + await Promise.resolve(instance.dispose()).catch(() => undefined) + throw abortReason(context.signal, "Chromium driver creation was aborted") + }, + async (error) => { + await cleanup(() => port.dispose()) + throw error + }, + ) + } +} + +function createInstance( + port: ChromiumPort, + lifetime: AbortSignal, +): BrowserDriverInstance> { + requirePort(port) + const page: Page = { + port, + lifetime, + document: 0, + snapshot: 0, + nextRef: 0, + refs: new Map(), + listeners: new Set(), + requests: new Set(), + state: readPortState(port.state()), + disposed: false, + queue: Promise.resolve(), + } + page.unsubscribe = port.subscribe((event) => { + if (page.disposed) return + if (typeof event.mainDocumentChanged !== "boolean") { + throw new TypeError("Chromium port published an invalid view event") + } + if (event.mainDocumentChanged) { + page.document++ + invalidateRefs(page) + } + page.state = readPortState(event.state) + publish(page) + }) + if (typeof page.unsubscribe !== "function") { + throw new TypeError("Chromium port subscribe must return an unsubscribe function") + } + + const dispose = () => disposePage(page) + const controller: ChromiumController = Object.freeze({ + resource: port.resource, + state: () => contractState(page.state, page.document), + subscribe: (listener) => subscribe(page, listener), + navigate: (url) => localNavigate(page, url), + back: () => localAction(page, () => port.back()), + forward: () => localAction(page, () => port.forward()), + reload: () => localAction(page, () => port.reload()), + stop: () => stop(page), + dispose, + [Symbol.asyncDispose]: dispose, + }) + return Object.freeze({ + resource: controller, + state: controller.state, + subscribe: controller.subscribe, + execute: (command: Browser.Command, options: { readonly signal: AbortSignal }) => + executeDriver(page, command, options.signal), + dispose, + }) +} + +async function executeDriver( + page: Page, + command: Browser.Command, + signal: AbortSignal, +): Promise { + return schedule(page, [page.lifetime, signal], (abort, verify, operation) => + execute(page, command, abort, verify, operation), + ) +} + +async function execute( + page: Page, + command: Browser.Command, + abort: AbortSignal, + verify: () => void, + operation: Operation, +): Promise { + throwIfAborted(abort) + assertAttached(page) + assertDocument(page, command.generation) + switch (command.type) { + case "navigate": + await navigate(page, command.url, abort, verify, operation) + return { type: "navigate", state: contractState(page.state, page.document) } + case "snapshot": + return snapshot(page, command.generation, abort, verify, operation) + case "click": + await click(page, command.ref, command.generation, abort, verify, operation) + return { type: "click", state: refreshedState(page, verify) } + case "fill": + await fill(page, command.ref, command.text, command.generation, abort, verify, operation) + return { type: "fill", state: refreshedState(page, verify) } + case "press": + await press(page, command.key, command.generation, abort, verify, operation) + return { type: "press", state: refreshedState(page, verify) } + case "scroll": + await scroll(page, command.direction, command.pixels, command.generation, abort, verify, operation) + return { type: "scroll", state: refreshedState(page, verify) } + case "screenshot": + return screenshot(page, command.generation, abort, verify, operation) + } + throw browserError("internal", "Unsupported browser command.") +} + +async function localNavigate(page: Page, input: string) { + return schedule(page, [page.lifetime], (abort, verify, operation) => navigate(page, input, abort, verify, operation)) +} + +async function localAction(page: Page, run: () => PromiseLike | void) { + return schedule(page, [page.lifetime], async (abort, verify, operation) => { + throwIfAborted(abort) + verify() + await startOperation(operation, run) + verify() + }) +} + +async function navigate( + page: Page, + input: string, + abort: AbortSignal | undefined, + verify: (() => void) | undefined, + operation: Operation, +) { + const url = (() => { + try { + return normalizeBrowserURL(input) + } catch { + throw browserError("invalid_url", "Only HTTP, HTTPS, and about:blank URLs are supported.") + } + })() + throwIfAborted(abort) + const onAbort = () => page.port.stop() + abort?.addEventListener("abort", onAbort, { once: true }) + await boundedOperation(() => startOperation(operation, () => page.port.navigate(url)), { + signal: abort, + timeout: 30_000, + aborted: () => browserError("aborted", "The browser navigation was aborted."), + timedOut: () => { + page.port.stop() + return browserError("timeout", "The browser navigation timed out.") + }, + }) + .catch((error) => { + if (abort?.aborted) throw browserError("aborted", "The browser navigation was aborted.") + if (error instanceof BrowserDriverError) throw error + throw browserError("navigation_failed", error instanceof Error ? error.message : String(error)) + }) + .finally(() => abort?.removeEventListener("abort", onAbort)) + throwIfAborted(abort) + verify?.() + refresh(page) + verify?.() +} + +async function snapshot( + page: Page, + generation: number, + abort: AbortSignal, + verify: () => void, + operation: Operation, +): Promise { + throwIfAborted(abort) + // The bounded main-world traversal intentionally omits cross-origin iframe contents and editable values. + const object = await debuggerCommand( + page, + "Runtime.evaluate", + { expression: browserSnapshotExpression(page.nextRef) }, + abort, + operation, + ) + const objectID = readRuntimeObjectID(object) + const result = await debuggerCommand( + page, + "Runtime.callFunctionOn", + { + objectId: objectID, + functionDeclaration: "function() { return this.result }", + returnByValue: true, + }, + abort, + operation, + ) + .then((response) => { + verify() + throwIfAborted(abort) + assertDocument(page, generation) + return readSnapshot(response) + }) + .catch((error) => { + releaseSnapshotObject(page, objectID) + throw error + }) + + invalidateRefs(page) + page.snapshotObjectID = objectID + page.nextRef = Math.max(page.nextRef, result.nextRef) + const lines = result.nodes.map((node) => { + if (node.token) page.refs.set(node.token, { snapshot: page.snapshot, token: node.token, objectID }) + const properties = [ + node.checked === undefined ? undefined : `checked=${node.checked}`, + node.disabled === undefined ? undefined : `disabled=${node.disabled}`, + node.expanded === undefined ? undefined : `expanded=${node.expanded}`, + node.selected === undefined ? undefined : `selected=${node.selected}`, + ].filter((item): item is string => item !== undefined) + const detail = [ + node.name ? JSON.stringify(node.name) : undefined, + node.value && node.value !== node.name ? `value=${JSON.stringify(node.value)}` : undefined, + ] + .filter((item): item is string => item !== undefined) + .join(" ") + return `${" ".repeat(node.depth)}${node.token ? `${node.token} ` : ""}[${node.role}]${detail ? ` ${detail}` : ""}${properties.length ? ` ${properties.join(" ")}` : ""}` + }) + const state = readPortState(page.port.state()) + const content = [ + `Page: ${state.title.replaceAll(/\s+/g, " ").trim().slice(0, 1_024)}`, + `URL: ${state.url.slice(0, 16_384)}`, + "", + ...lines, + ] + .join("\n") + .slice(0, 40 * 1_024) + assertDocument(page, generation) + verify() + return { + type: "snapshot", + state: refreshedState(page, verify), + format: "opencode.semantic.v1", + content, + } +} + +async function click( + page: Page, + ref: Browser.Ref, + generation: number, + abort: AbortSignal, + verify: () => void, + operation: Operation, +) { + const node = resolveRef(page, ref) + const response = await debuggerCommand( + page, + "Runtime.callFunctionOn", + { + objectId: node.objectID, + functionDeclaration: + "function(token) { const element = this.refs[token]; if (!element || !element.isConnected) throw new Error('stale element'); element.scrollIntoView({ block: 'center', inline: 'center' }); const bounds = element.getBoundingClientRect(); if (bounds.width <= 0 || bounds.height <= 0) throw new Error('element has no bounds'); return { x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2 } }", + arguments: [{ value: node.token }], + returnByValue: true, + }, + abort, + operation, + ) + verify() + const point = readPoint(response) + throwIfAborted(abort) + assertDocument(page, generation) + await debuggerCommand(page, "Input.dispatchMouseEvent", { type: "mouseMoved", ...point }, abort, operation) + verify() + assertDocument(page, generation) + await runInputPair({ + assert: () => { + verify() + assertDocument(page, generation) + }, + press: () => + debuggerCommand( + page, + "Input.dispatchMouseEvent", + { type: "mousePressed", button: "left", clickCount: 1, ...point }, + abort, + operation, + ).then(() => undefined), + release: () => + debuggerCommand( + page, + "Input.dispatchMouseEvent", + { type: "mouseReleased", button: "left", clickCount: 1, ...point }, + undefined, + operation, + ).then(() => undefined), + }) +} + +async function fill( + page: Page, + ref: Browser.Ref, + text: string, + generation: number, + abort: AbortSignal, + verify: () => void, + operation: Operation, +) { + throwIfAborted(abort) + const node = resolveRef(page, ref) + const response = await debuggerCommand( + page, + "Runtime.callFunctionOn", + { + objectId: node.objectID, + functionDeclaration: browserFillFunction, + arguments: [{ value: node.token }], + returnByValue: true, + }, + abort, + operation, + ) + const editable = readRuntimeValue(response) + verify() + assertDocument(page, generation) + if (editable !== true) { + throw browserError("stale_ref", "The browser element is not editable. Call browser_snapshot again.") + } + const modifiers = process.platform === "darwin" ? 4 : 2 + const assert = () => { + verify() + assertDocument(page, generation) + } + await runInputPair({ + assert, + press: () => + debuggerCommand( + page, + "Input.dispatchKeyEvent", + { type: "keyDown", key: "a", code: "KeyA", modifiers }, + abort, + operation, + ).then(() => undefined), + release: () => + debuggerCommand( + page, + "Input.dispatchKeyEvent", + { type: "keyUp", key: "a", code: "KeyA", modifiers }, + undefined, + operation, + ).then(() => undefined), + }) + await runInputPair({ + assert, + press: () => + debuggerCommand( + page, + "Input.dispatchKeyEvent", + { type: "keyDown", key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 }, + abort, + operation, + ).then(() => undefined), + release: () => + debuggerCommand( + page, + "Input.dispatchKeyEvent", + { type: "keyUp", key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 }, + undefined, + operation, + ).then(() => undefined), + }) + await debuggerCommand(page, "Input.insertText", { text }, abort, operation) + verify() +} + +async function press( + page: Page, + key: Browser.Key, + generation: number, + abort: AbortSignal, + verify: () => void, + operation: Operation, +) { + throwIfAborted(abort) + assertDocument(page, generation) + const info = keyInfo(key) + await runInputPair({ + assert: () => { + verify() + assertDocument(page, generation) + }, + press: () => + debuggerCommand(page, "Input.dispatchKeyEvent", { type: "keyDown", ...info }, abort, operation).then( + () => undefined, + ), + release: () => + debuggerCommand(page, "Input.dispatchKeyEvent", { type: "keyUp", ...info }, undefined, operation).then( + () => undefined, + ), + }) +} + +async function scroll( + page: Page, + direction: Browser.Direction, + pixels: number, + generation: number, + abort: AbortSignal, + verify: () => void, + operation: Operation, +) { + throwIfAborted(abort) + assertDocument(page, generation) + const bounds = page.port.viewport() + const distance = Math.min(2_000, Math.max(1, pixels)) + await debuggerCommand( + page, + "Input.dispatchMouseEvent", + { + type: "mouseWheel", + x: Math.max(0, Math.round(bounds.width / 2)), + y: Math.max(0, Math.round(bounds.height / 2)), + deltaX: direction === "left" ? -distance : direction === "right" ? distance : 0, + deltaY: direction === "up" ? -distance : direction === "down" ? distance : 0, + }, + abort, + operation, + ) + verify() +} + +async function screenshot( + page: Page, + generation: number, + abort: AbortSignal, + verify: () => void, + operation: Operation, +): Promise { + throwIfAborted(abort) + const source = await boundedOperation( + () => startOperation(operation, () => page.port.screenshot({ maxDimension: screenshotDimensionLimit })), + { + signal: abort, + timeout: debuggerCommandTimeout, + aborted: () => browserError("aborted", "The browser screenshot was aborted."), + timedOut: () => browserError("timeout", "The browser screenshot timed out."), + }, + ) + verify() + throwIfAborted(abort) + assertDocument(page, generation) + if (!record(source) || !(source.data instanceof Uint8Array)) { + throw browserError("internal", "Invalid browser screenshot response.") + } + const output = new Uint8Array(source.data) + if (output.byteLength > screenshotByteLimit) { + throw browserError("result_too_large", "The browser screenshot exceeds 5 MiB.") + } + if ( + !Number.isSafeInteger(source.width) || + !Number.isSafeInteger(source.height) || + source.width < 1 || + source.height < 1 || + source.width > screenshotDimensionLimit || + source.height > screenshotDimensionLimit + ) { + throw browserError("internal", "The browser pane has no drawable area.") + } + assertDocument(page, generation) + verify() + return { + type: "screenshot", + state: refreshedState(page, verify), + mediaType: "image/png", + data: output, + width: source.width, + height: source.height, + } +} + +function schedule( + page: Page, + signals: ReadonlyArray, + run: (abort: AbortSignal, verify: () => void, operation: Operation) => Promise, +) { + signals.forEach((signal) => throwIfAborted(signal)) + assertAttached(page) + const controller = new AbortController() + const abort = () => controller.abort() + const observed = [...new Set(signals)] + observed.forEach((signal) => signal.addEventListener("abort", abort, { once: true })) + if (observed.some((signal) => signal.aborted)) controller.abort() + const operation = { started: false } + const verify = () => { + throwIfAborted(controller.signal) + assertAttached(page) + } + page.requests.add(controller) + return enqueue(page, () => + fenceOperation(page, operation, () => active(page, controller, () => run(controller.signal, verify, operation))), + ) + .finally(() => { + observed.forEach((signal) => signal.removeEventListener("abort", abort)) + page.requests.delete(controller) + }) + .catch((error) => { + throw normalizeBrowserError(error) + }) +} + +async function fenceOperation( + page: Page, + operation: Operation, + run: () => Promise, +) { + try { + return await run() + } catch (error) { + if (operation.started && interruptedOperation(error)) void disposePage(page).catch(() => undefined) + throw error + } +} + +function interruptedOperation(error: unknown) { + if (!error || typeof error !== "object" || !("code" in error)) return false + return error.code === "aborted" || error.code === "timeout" +} + +async function active(page: Page, controller: AbortController, run: () => Promise) { + page.active = controller + try { + return await run() + } finally { + if (page.active === controller) page.active = undefined + } +} + +function enqueue(page: Page, run: () => Promise) { + const result = page.queue.then(run, run) + page.queue = result.then( + () => undefined, + () => undefined, + ) + return result +} + +function stop(page: Page) { + assertAttached(page) + page.active?.abort() + try { + page.port.stop() + } catch (error) { + throw normalizeBrowserError(error) + } +} + +function subscribe(page: Page, listener: (state: Browser.State) => void) { + assertAttached(page) + page.listeners.add(listener) + listener(contractState(page.state, page.document)) + return () => { + page.listeners.delete(listener) + } +} + +function publish(page: Page) { + const state = contractState(page.state, page.document) + page.listeners.forEach((listener) => listener(state)) +} + +function refresh(page: Page) { + page.state = readPortState(page.port.state()) + publish(page) + return page.state +} + +function refreshedState(page: Page, verify: () => void) { + const state = contractState(refresh(page), page.document) + verify() + return state +} + +function disposePage(page: Page) { + if (page.disposal) return page.disposal + page.disposed = true + page.active?.abort() + page.active = undefined + page.requests.forEach((request) => request.abort()) + page.requests.clear() + page.listeners.clear() + page.snapshotObjectID = undefined + page.snapshot++ + page.refs.clear() + const unsubscribe = page.unsubscribe + page.unsubscribe = undefined + page.disposal = Promise.resolve().then(async () => { + const results = [ + await cleanup(() => unsubscribe?.()), + await cleanup(() => page.port.stop()), + await cleanup(() => page.port.dispose()), + ] + const failure = results.find((result): result is { readonly ok: false; readonly error: unknown } => !result.ok) + if (failure) throw failure.error + }) + return page.disposal +} + +function invalidateRefs(page: Page) { + const objectID = page.snapshotObjectID + page.snapshotObjectID = undefined + page.snapshot++ + page.refs.clear() + if (objectID) releaseSnapshotObject(page, objectID) +} + +function releaseSnapshotObject(page: Page, objectID: string) { + if (page.disposed) return + void Promise.resolve(page.port.send("Runtime.releaseObject", { objectId: objectID })).catch(() => undefined) +} + +function resolveRef(page: Page, ref: Browser.Ref) { + const node = page.refs.get(normalizeBrowserRef(ref)) + if (!node || node.snapshot !== page.snapshot) { + throw browserError("stale_ref", "The element reference is stale. Call browser_snapshot again.") + } + return node +} + +async function debuggerCommand( + page: Page, + method: string, + params: Record | undefined, + abort: AbortSignal | undefined, + operation: Operation, +) { + throwIfAborted(abort) + assertAttached(page) + return boundedOperation(() => startOperation(operation, () => page.port.send(method, params)), { + signal: abort, + timeout: debuggerCommandTimeout, + aborted: () => browserError("aborted", "The browser action was aborted."), + timedOut: () => browserError("timeout", "The browser command timed out."), + }).catch((error) => { + const stale = browserProtocolError(error) + if (stale) throw browserError("stale_ref", stale.message) + throw error + }) +} + +function readSnapshot(input: unknown) { + const value = readRuntimeValue(input) + if ( + !record(value) || + !Array.isArray(value.nodes) || + !Number.isSafeInteger(value.nextRef) || + Number(value.nextRef) < 0 + ) { + throw browserError("internal", "Invalid browser snapshot response.") + } + if (value.nodes.length > browserSnapshotLimit || !value.nodes.every(snapshotNode)) { + throw browserError("internal", "Invalid browser snapshot nodes.") + } + return { nodes: value.nodes, nextRef: Number(value.nextRef) } +} + +function snapshotNode(input: unknown): input is SnapshotNode { + if (!record(input)) return false + if ( + typeof input.role !== "string" || + !/^[a-zA-Z0-9_-]+$/.test(input.role) || + input.role.length > 40 || + typeof input.name !== "string" || + input.name.length > 300 || + typeof input.value !== "string" || + input.value.length > 300 || + !Number.isSafeInteger(input.depth) || + Number(input.depth) < 0 || + Number(input.depth) > 6 + ) + return false + if (input.token !== undefined && (typeof input.token !== "string" || !/^e[1-9][0-9]*$/.test(input.token))) { + return false + } + if (input.expanded !== undefined && typeof input.expanded !== "boolean") return false + return [input.checked, input.disabled, input.selected].every( + (property) => property === undefined || typeof property === "boolean", + ) +} + +function readRuntimeObjectID(input: unknown) { + if (!record(input) || !record(input.result) || typeof input.result.objectId !== "string") { + throw browserError("internal", "Invalid browser runtime object.") + } + return input.result.objectId +} + +function readRuntimeValue(input: unknown) { + if (!record(input)) throw browserError("internal", "Browser page operation failed.") + if (input.exceptionDetails !== undefined) { + const details = record(input.exceptionDetails) ? input.exceptionDetails : undefined + const exception = details && record(details.exception) ? details.exception : undefined + const message = + (exception && typeof exception.description === "string" && exception.description) || + (details && typeof details.text === "string" && details.text) || + "Browser page operation failed." + const stale = browserProtocolError(message) + if (stale) throw browserError("stale_ref", stale.message) + throw browserError("internal", message) + } + if (!record(input.result) || !("value" in input.result)) { + throw browserError("internal", "Browser page operation failed.") + } + return input.result.value +} + +function readPoint(input: unknown) { + const value = readRuntimeValue(input) + if ( + !record(value) || + typeof value.x !== "number" || + !Number.isFinite(value.x) || + typeof value.y !== "number" || + !Number.isFinite(value.y) + ) { + throw browserError("stale_ref", "The browser element has no clickable bounds.") + } + return { x: value.x, y: value.y } +} + +function keyInfo(key: Browser.Key) { + const value = key === "Space" ? " " : key + const code = key === "Space" ? "Space" : key + const windowsVirtualKeyCode = + key === "Enter" + ? 13 + : key === "Tab" + ? 9 + : key === "Escape" + ? 27 + : key === "Backspace" + ? 8 + : key === "Delete" + ? 46 + : key === "Space" + ? 32 + : undefined + return { key: value, code, ...(windowsVirtualKeyCode ? { windowsVirtualKeyCode } : {}) } +} + +function contractState(state: ChromiumViewState, generation: number): Browser.State { + return { + url: state.url.slice(0, 16_384), + title: state.title.slice(0, 1_024), + loading: state.loading, + canGoBack: state.canGoBack, + canGoForward: state.canGoForward, + generation, + } +} + +function readPortState(input: ChromiumViewState): ChromiumViewState { + if ( + !record(input) || + typeof input.url !== "string" || + typeof input.title !== "string" || + typeof input.loading !== "boolean" || + typeof input.canGoBack !== "boolean" || + typeof input.canGoForward !== "boolean" + ) { + throw new TypeError("Chromium port returned an invalid view state") + } + return { + url: input.url.slice(0, 16_384), + title: input.title.slice(0, 1_024), + loading: input.loading, + canGoBack: input.canGoBack, + canGoForward: input.canGoForward, + } +} + +function requirePort(input: ChromiumPort) { + if (!record(input)) throw new TypeError("Chromium driver factory returned an invalid port") + const methods = [ + input.state, + input.subscribe, + input.navigate, + input.back, + input.forward, + input.reload, + input.stop, + input.send, + input.viewport, + input.screenshot, + input.dispose, + ] + if (methods.some((method) => typeof method !== "function")) { + throw new TypeError("Chromium driver factory returned an invalid port") + } +} + +function assertDocument(page: Page, generation: number) { + if (page.document !== generation) { + throw browserError("stale_ref", "The browser page changed. Call browser_snapshot again.") + } +} + +function assertAttached(page: Page) { + if (page.disposed) throw browserError("not_attached", "The browser page is no longer attached.") +} + +function throwIfAborted(abort?: AbortSignal) { + if (abort?.aborted) throw browserError("aborted", "The browser action was aborted.") +} + +function browserError(code: Browser.ErrorCode, message: string) { + return new BrowserDriverError(code, message.slice(0, 1_024)) +} + +function normalizeBrowserError(error: unknown) { + if (error instanceof BrowserDriverError) return error + return browserError("internal", error instanceof Error ? error.message : String(error)) +} + +function normalizeBrowserURL(input: string) { + const value = input.trim() + if (value.length > 16_384) throw new Error("Browser URL is too long") + if (!value || value === "about:blank") return "about:blank" + const candidate = /^(localhost|127(?:\.\d{1,3}){3}|\[?::1\]?)(:\d+)?(?:\/|$)/i.test(value) + ? `http://${value}` + : /^[a-z][a-z\d+.-]*:/i.test(value) + ? value + : `https://${value}` + if (!URL.canParse(candidate)) throw new Error("Enter a valid HTTP or HTTPS URL") + const url = new URL(candidate) + if (!allowedBrowserURL(url.href)) { + throw new Error("Only HTTP, HTTPS, and about:blank URLs without credentials are supported") + } + if (url.href.length > 16_384) throw new Error("Browser URL is too long") + return url.href +} + +function allowedBrowserURL(input: string) { + if (input === "about:blank") return true + if (!URL.canParse(input)) return false + const url = new URL(input) + return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password +} + +function normalizeBrowserRef(input: string) { + const value = input.trim().replace(/^@/, "") + if (!/^e[1-9][0-9]*$/.test(value)) throw new Error("Enter a valid browser element reference") + return value +} + +function browserSnapshotExpression(nextRef: number, limit = browserSnapshotLimit) { + return `(() => { + const interactive = new Set(["button","checkbox","combobox","link","menuitem","option","radio","searchbox","slider","spinbutton","switch","tab","textbox"]) + const readable = new Set(["article","cell","columnheader","heading","img","list","listitem","p","region","row","rowheader","table"]) + const roleFor = (element) => { + const explicit = element.getAttribute("role") + if (explicit) return explicit.slice(0, 100).split(/\\s+/)[0] + if (/^H[1-6]$/.test(element.tagName)) return "heading" + if (element.tagName === "INPUT") { + if (element.type === "checkbox") return "checkbox" + if (element.type === "radio") return "radio" + if (element.type === "range") return "slider" + if (element.type === "number") return "spinbutton" + if (element.type === "search") return "searchbox" + return "textbox" + } + return ({A:"link",ARTICLE:"article",BUTTON:"button",IMG:"img",LI:"listitem",OL:"list",P:"p",SELECT:"combobox",TABLE:"table",TD:"cell",TH:"columnheader",TR:"row",TEXTAREA:"textbox",UL:"list"})[element.tagName] || element.tagName.toLowerCase() + } + const clean = (value) => String(value || "").slice(0, 1000).replace(/\\s+/g, " ").trim().slice(0, 300) + const textFor = (element) => { + const queue = [] + for (let index = 0; index < element.childNodes.length && queue.length < 20; index++) queue.push(element.childNodes[index]) + const parts = [] + let visited = 0 + while (queue.length && visited++ < 20) { + const item = queue.shift() + if (item.nodeType === Node.TEXT_NODE) parts.push(item.nodeValue || "") + for (let index = 0; index < item.childNodes.length && queue.length + visited < 20; index++) queue.push(item.childNodes[index]) + } + return parts.join(" ") + } + const nameFor = (element, editable) => { + const labelledBy = element.getAttribute("aria-labelledby") + const label = labelledBy && document.getElementById(labelledBy) + return element.getAttribute("aria-label") || (label && textFor(label)) || element.alt || (editable ? "" : textFor(element)) + } + const nodes = [] + const refs = Object.create(null) + const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_ELEMENT) + let visited = 0 + let ref = ${Math.max(0, Math.floor(nextRef))} + while (visited++ < ${Math.max(1, Math.floor(limit))}) { + const element = walker.nextNode() + if (!element) break + if (element.hidden || element.getAttribute("aria-hidden") === "true" || (element.tagName === "INPUT" && element.type === "hidden")) continue + const role = clean(roleFor(element)).replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 40) || "node" + const isInteractive = interactive.has(role) || element.tabIndex >= 0 + const isReadable = readable.has(role) + if (!isInteractive && !isReadable) continue + const editable = ["INPUT","TEXTAREA","SELECT"].includes(element.tagName) || ["textbox","searchbox","combobox","spinbutton"].includes(role) || element.isContentEditable + const token = isInteractive ? "e" + (++ref) : undefined + if (token) refs[token] = element + let depth = 0 + for (let item = element.parentElement; item && depth < 6; item = item.parentElement) depth++ + nodes.push({ + token, + role, + name: clean(nameFor(element, editable)), + value: editable ? "" : clean(element.value), + depth, + checked: "checked" in element ? Boolean(element.checked) : undefined, + disabled: "disabled" in element ? Boolean(element.disabled) : undefined, + expanded: element.getAttribute("aria-expanded") === "true" ? true : element.getAttribute("aria-expanded") === "false" ? false : undefined, + selected: "selected" in element ? Boolean(element.selected) : undefined, + }) + } + return { result: { nodes, nextRef: ref }, refs } + })()` +} + +const browserFillFunction = `function(token) { + const element = this.refs[token] + if (!element || !element.isConnected) throw new Error("stale element") + const role = String(element.getAttribute("role") || "").split(/\\s+/, 1)[0] + const input = element.tagName === "INPUT" && !["button","checkbox","color","file","hidden","image","radio","range","reset","submit"].includes(String(element.type).toLowerCase()) + const editable = input || element.tagName === "TEXTAREA" || element.isContentEditable || ["textbox","searchbox","combobox","spinbutton"].includes(role) + if (!editable || element.disabled || element.readOnly || element.getAttribute("aria-disabled") === "true" || element.getAttribute("aria-readonly") === "true") return false + element.focus() + return true +}` + +function boundedOperation( + run: () => PromiseLike, + input: { + readonly signal?: AbortSignal + readonly timeout: number + readonly aborted: () => Error + readonly timedOut: () => Error + }, +) { + return new Promise((resolve, reject) => { + type OperationResult = { readonly ran: false } | { readonly ran: true; readonly value: Result } + let settled = false + let timer: ReturnType | undefined + const cleanup = () => { + if (timer !== undefined) clearTimeout(timer) + input.signal?.removeEventListener("abort", onAbort) + } + const succeed = (value: Result) => { + if (settled) return + settled = true + cleanup() + resolve(value) + } + const fail = (error: unknown) => { + if (settled) return + settled = true + cleanup() + reject(error) + } + const onAbort = () => fail(input.aborted()) + + if (input.signal?.aborted) { + onAbort() + return + } + input.signal?.addEventListener("abort", onAbort, { once: true }) + timer = setTimeout(() => fail(input.timedOut()), input.timeout) + void Promise.resolve() + .then(() => { + if (settled) return { ran: false } + return new Promise((resolve, reject) => { + run().then( + (value) => resolve({ ran: true, value }), + (error) => reject(error), + ) + }) + }) + .then((result) => { + if (result.ran) succeed(result.value) + }, fail) + }) +} + +async function runInputPair(input: { + readonly assert: () => void + readonly press: () => Promise + readonly release: () => Promise +}) { + input.assert() + try { + await input.press() + } finally { + await input.release() + } + input.assert() +} + +function browserProtocolError(input: unknown): (Error & { readonly code: "stale_ref" }) | undefined { + const message = input instanceof Error ? input.message : String(input) + if ( + !/Could not find (node|object)|No node with given id|Node with given id does not belong|Could not push node|Could not compute box model|stale element/i.test( + message, + ) + ) + return undefined + return Object.assign(new Error("The element reference is stale. Call browser_snapshot again."), { + code: "stale_ref" as const, + }) +} + +function startOperation(operation: Operation, run: () => Result) { + operation.started = true + return run() +} + +function cleanup(task: () => unknown) { + return Promise.resolve() + .then(task) + .then( + () => ({ ok: true as const }), + (error: unknown) => ({ ok: false as const, error }), + ) +} + +function abortReason(signal: AbortSignal, message: string) { + return signal.reason instanceof Error ? signal.reason : new Error(message) +} + +function record(input: unknown): input is Record { + return typeof input === "object" && input !== null && !Array.isArray(input) +} diff --git a/packages/client/src/node/browser/driver.ts b/packages/client/src/node/browser/driver.ts index dda0103c91..39c9b9cfaf 100644 --- a/packages/client/src/node/browser/driver.ts +++ b/packages/client/src/node/browser/driver.ts @@ -1,4 +1,5 @@ import type { Browser } from "@opencode-ai/schema/browser" +import { chromiumDriver, type ChromiumDriver, type ChromiumPort } from "./chromium.js" /** Connection details for the attachment's private authenticated proxy. */ export interface BrowserProxy { @@ -50,6 +51,12 @@ export const BrowserDriver = { if (typeof create !== "function") throw new TypeError("Browser driver factory must be a function") return Object.freeze(create) }, + chromium( + create: (context: BrowserDriverContext) => PromiseLike> | ChromiumPort, + ): ChromiumDriver { + if (typeof create !== "function") throw new TypeError("Chromium port factory must be a function") + return Object.freeze(chromiumDriver(create)) + }, } export function browserDriverFactory(driver: BrowserDriver) { diff --git a/packages/client/src/node/index.ts b/packages/client/src/node/index.ts index df778c406d..bde5afb890 100644 --- a/packages/client/src/node/index.ts +++ b/packages/client/src/node/index.ts @@ -23,6 +23,7 @@ export type { BrowserDriverInstance, BrowserProxy, } from "./browser/driver.js" +export type { ChromiumController, ChromiumDriver, ChromiumPort } from "./browser/chromium.js" export type { BrowserAttachment, BrowserAttachOptions, BrowserClient } from "./browser/client.js" export type { EventSubscribeOutput as OpenCodeEvent } from "../promise/generated/types.js" export type OpenCodeClient = ReturnType diff --git a/packages/client/test/node/chromium.test.ts b/packages/client/test/node/chromium.test.ts new file mode 100644 index 0000000000..d09a91dc56 --- /dev/null +++ b/packages/client/test/node/chromium.test.ts @@ -0,0 +1,363 @@ +import { describe, expect, test } from "bun:test" +import { + Browser, + BrowserDriver, + BrowserDriverError, + type BrowserDriverContext, + type ChromiumPort, +} from "@opencode-ai/client/node" + +type PortListener = Parameters["subscribe"]>[0] +type PortState = ReturnType["state"]> + +describe("Chromium browser driver", () => { + test("increments generations and invalidates refs only for main-document changes", async () => { + const app = await setup() + const snapshot = await app.instance.execute( + { type: "snapshot", generation: 0 }, + { signal: new AbortController().signal }, + ) + if (snapshot.type !== "snapshot") throw new Error("Expected snapshot result") + expect(snapshot.content).toContain('e1 [button] "Save" disabled=false') + + await app.instance.execute( + { type: "click", ref: Browser.Ref.make("e1"), generation: 0 }, + { signal: new AbortController().signal }, + ) + app.port.emit({ title: "Updated" }, false) + expect(app.controller.state()).toMatchObject({ title: "Updated", generation: 0 }) + + app.port.emit({ url: "https://next.example/" }, true) + expect(app.controller.state()).toMatchObject({ url: "https://next.example/", generation: 1 }) + expect(app.port.calls.some((call) => call.method === "Runtime.releaseObject")).toBe(true) + + expect( + requireDriverError( + await rejected( + app.instance.execute( + { type: "click", ref: Browser.Ref.make("e1"), generation: 1 }, + { signal: new AbortController().signal }, + ), + ), + ), + ).toMatchObject({ code: "stale_ref", message: "The element reference is stale. Call browser_snapshot again." }) + expect( + requireDriverError( + await rejected( + app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }), + ), + ), + ).toMatchObject({ code: "stale_ref", message: "The browser page changed. Call browser_snapshot again." }) + await app.controller.dispose() + }) + + test("parses bounded snapshots and rejects untrusted oversized nodes", async () => { + const app = await setup() + const result = await app.instance.execute( + { type: "snapshot", generation: 0 }, + { signal: new AbortController().signal }, + ) + if (result.type !== "snapshot") throw new Error("Expected snapshot result") + expect(result.content).toBe( + ["Page: Example", "URL: https://example.com/", "", ' e1 [button] "Save" disabled=false'].join("\n"), + ) + expect(app.port.expressions[0]).toContain("while (visited++ < 500)") + expect(app.port.expressions[0]).toContain('editable ? ""') + expect(app.port.expressions[0]).not.toContain("textContent") + expect(() => new Bun.Transpiler({ loader: "js" }).transformSync(app.port.expressions[0] ?? "")).not.toThrow() + + app.port.snapshotValue = { + nodes: Array.from({ length: 501 }, () => ({ role: "button", name: "Save", value: "", depth: 0 })), + nextRef: 501, + } + expect( + requireDriverError( + await rejected( + app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }), + ), + ), + ).toMatchObject({ code: "internal", message: "Invalid browser snapshot nodes." }) + + app.port.snapshotValue = { + nodes: [{ token: "e2", role: "bad role", name: "Save", value: "", depth: 0 }], + nextRef: 2, + } + expect( + requireDriverError( + await rejected( + app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }), + ), + ), + ).toMatchObject({ code: "internal", message: "Invalid browser snapshot nodes." }) + expect(app.port.calls.filter((call) => call.method === "Runtime.releaseObject").length).toBeGreaterThanOrEqual(2) + await app.controller.dispose() + }) + + test("releases paired input and fences the port when cancellation races a press", async () => { + const app = await setup() + await app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }) + const abort = new AbortController() + const input: string[] = [] + app.port.onSend = (method, params) => { + if (method !== "Input.dispatchMouseEvent") return app.port.response(method, params) + if (params?.type === "mousePressed") { + input.push("press") + abort.abort() + return new Promise(() => undefined) + } + if (params?.type === "mouseReleased") input.push("release") + return app.port.response(method, params) + } + + expect( + requireDriverError( + await rejected( + app.instance.execute({ type: "click", ref: Browser.Ref.make("e1"), generation: 0 }, { signal: abort.signal }), + ), + ), + ).toMatchObject({ code: "aborted", message: "The browser action was aborted." }) + await app.controller.dispose() + expect(input).toEqual(["press", "release"]) + expect(app.port.stopCount).toBe(1) + expect(app.port.disposeCount).toBe(1) + expect( + requireDriverError( + await rejected( + app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }), + ), + ).code, + ).toBe("not_attached") + }) + + test("serializes local controller work with remote commands", async () => { + const app = await setup() + const navigation = deferred() + const started = deferred() + app.port.onNavigate = () => { + started.resolve() + return navigation.promise + } + + const local = app.controller.navigate("example.com") + await started.promise + const remote = app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }) + await Promise.resolve() + expect(app.port.calls.some((call) => call.method === "Runtime.evaluate")).toBe(false) + expect(app.port.navigateURLs).toEqual(["https://example.com/"]) + + navigation.resolve() + await local + expect((await remote).type).toBe("snapshot") + expect(app.port.calls.some((call) => call.method === "Runtime.evaluate")).toBe(true) + await app.controller.dispose() + }) + + test("maps navigation, CDP, and adapter failures to driver error codes", async () => { + const app = await setup() + expect(requireDriverError(await rejected(app.controller.navigate("javascript:alert(1)")))).toMatchObject({ + code: "invalid_url", + }) + expect(app.port.navigateURLs).toEqual([]) + + app.port.onNavigate = () => Promise.reject(new Error("net::ERR_NAME_NOT_RESOLVED")) + expect(requireDriverError(await rejected(app.controller.navigate("missing.example")))).toMatchObject({ + code: "navigation_failed", + message: "net::ERR_NAME_NOT_RESOLVED", + }) + + app.port.onSend = () => Promise.reject(new Error("Could not find object with given id")) + expect( + requireDriverError( + await rejected( + app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }), + ), + ), + ).toMatchObject({ code: "stale_ref", message: "The element reference is stale. Call browser_snapshot again." }) + + app.port.onSend = () => Promise.reject(new Error("Method not found")) + expect( + requireDriverError( + await rejected( + app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }), + ), + ), + ).toMatchObject({ code: "internal", message: "Method not found" }) + + app.port.onSend = () => Promise.reject(new BrowserDriverError("page_crashed", "Renderer gone")) + expect( + requireDriverError( + await rejected( + app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }), + ), + ), + ).toMatchObject({ code: "page_crashed", message: "Renderer gone" }) + await app.controller.dispose() + }) + + test("preserves viewport scroll and bounded PNG capture contracts", async () => { + const app = await setup() + app.port.viewportSize = { width: 101, height: 99 } + await app.instance.execute( + { type: "scroll", direction: "down", pixels: 5_000, generation: 0 }, + { signal: new AbortController().signal }, + ) + expect(app.port.calls.find((call) => call.params?.type === "mouseWheel")?.params).toEqual({ + type: "mouseWheel", + x: 51, + y: 50, + deltaX: 0, + deltaY: 2_000, + }) + + const result = await app.instance.execute( + { type: "screenshot", generation: 0 }, + { signal: new AbortController().signal }, + ) + if (result.type !== "screenshot") throw new Error("Expected screenshot result") + expect(app.port.screenshotLimits).toEqual([2_000]) + expect(result).toMatchObject({ mediaType: "image/png", width: 100, height: 50 }) + expect(result.data).toEqual(new Uint8Array([137, 80, 78, 71])) + + app.port.screenshotValue = { + data: new Uint8Array(5 * 1_024 * 1_024 + 1), + width: 100, + height: 50, + } + expect( + requireDriverError( + await rejected( + app.instance.execute({ type: "screenshot", generation: 0 }, { signal: new AbortController().signal }), + ), + ), + ).toMatchObject({ code: "result_too_large", message: "The browser screenshot exceeds 5 MiB." }) + await app.controller.dispose() + }) +}) + +class FakeChromiumPort implements ChromiumPort<{ readonly name: string }> { + readonly resource = { name: "fake-chromium" } + readonly listeners = new Set() + readonly calls: Array<{ readonly method: string; readonly params?: Record }> = [] + readonly expressions: string[] = [] + readonly navigateURLs: string[] = [] + readonly screenshotLimits: number[] = [] + current: PortState = { + url: "https://example.com/", + title: "Example", + loading: false, + canGoBack: false, + canGoForward: false, + } + snapshotValue: unknown = { + nodes: [{ token: "e1", role: "button", name: "Save", value: "", depth: 1, disabled: false }], + nextRef: 1, + } + screenshotValue = { data: new Uint8Array([137, 80, 78, 71]), width: 100, height: 50 } + viewportSize = { width: 800, height: 600 } + onNavigate?: (url: string) => PromiseLike | void + onSend?: (method: string, params?: Record) => unknown + stopCount = 0 + disposeCount = 0 + private object = 0 + + state() { + return this.current + } + + subscribe(listener: PortListener) { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + navigate(url: string) { + this.navigateURLs.push(url) + return Promise.resolve().then(() => this.onNavigate?.(url)) + } + + back() {} + + forward() {} + + reload() {} + + stop() { + this.stopCount++ + } + + send(method: string, params?: Record) { + this.calls.push({ method, ...(params ? { params } : {}) }) + if (method === "Runtime.evaluate" && typeof params?.expression === "string") { + this.expressions.push(params.expression) + } + return Promise.resolve().then(() => (this.onSend ? this.onSend(method, params) : this.response(method, params))) + } + + response(method: string, params?: Record) { + if (method === "Runtime.evaluate") return { result: { objectId: `snapshot-${++this.object}` } } + if (method !== "Runtime.callFunctionOn") return {} + if (params?.functionDeclaration === "function() { return this.result }") { + return { result: { value: this.snapshotValue } } + } + if (typeof params?.functionDeclaration === "string" && params.functionDeclaration.includes("const editable")) { + return { result: { value: true } } + } + return { result: { value: { x: 25, y: 40 } } } + } + + viewport() { + return this.viewportSize + } + + screenshot(options: { readonly maxDimension: number }) { + this.screenshotLimits.push(options.maxDimension) + return Promise.resolve(this.screenshotValue) + } + + dispose() { + this.disposeCount++ + } + + emit(state: Partial, mainDocumentChanged: boolean) { + this.current = { ...this.current, ...state } + this.listeners.forEach((listener) => listener({ state: this.current, mainDocumentChanged })) + } +} + +async function setup() { + const port = new FakeChromiumPort() + const lifetime = new AbortController() + const driver = BrowserDriver.chromium(() => port) + const instance = await driver({ + proxy: { + url: "https://127.0.0.1:1234", + host: "127.0.0.1", + port: 1234, + credentials: { username: "user", password: "pass" }, + certificateFingerprint: "fingerprint", + }, + signal: lifetime.signal, + } satisfies BrowserDriverContext) + return { port, lifetime, instance, controller: instance.resource } +} + +function deferred() { + let resolve!: () => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +function rejected(promise: Promise) { + return promise.then( + () => new Error("Expected operation to reject"), + (error: unknown) => error, + ) +} + +function requireDriverError(input: unknown) { + if (!(input instanceof BrowserDriverError)) throw input + return input +} diff --git a/packages/client/test/node/package-smoke.ts b/packages/client/test/node/package-smoke.ts index 33049538bb..d0cb6b2750 100644 --- a/packages/client/test/node/package-smoke.ts +++ b/packages/client/test/node/package-smoke.ts @@ -127,6 +127,7 @@ function nodeScenario(moduleURL: string) { const sdk = await import(${JSON.stringify(moduleURL)}) if (typeof sdk.OpenCode.make !== "function") throw new Error("Missing OpenCode.make") if (typeof sdk.BrowserDriver.define !== "function") throw new Error("Missing BrowserDriver.define") +if (typeof sdk.BrowserDriver.chromium !== "function") throw new Error("Missing BrowserDriver.chromium") if (typeof sdk.BrowserDriverError !== "function") throw new Error("Missing BrowserDriverError") if (!sdk.Browser.State) throw new Error("Missing canonical Browser export") diff --git a/packages/client/test/types/node-consumer.ts b/packages/client/test/types/node-consumer.ts index 5ce08473c2..d368aaf25e 100644 --- a/packages/client/test/types/node-consumer.ts +++ b/packages/client/test/types/node-consumer.ts @@ -1,4 +1,13 @@ -import { Browser, BrowserDriver, BrowserDriverError, OpenCode, type BrowserAttachment } from "@opencode-ai/client/node" +import { + Browser, + BrowserDriver, + BrowserDriverError, + OpenCode, + type BrowserAttachment, + type ChromiumController, + type ChromiumDriver, + type ChromiumPort, +} from "@opencode-ai/client/node" const state: Browser.State = { url: "about:blank", @@ -19,6 +28,8 @@ const factory: BrowserDriver<{ readonly proxyURL: string }> = (context) => ({ dispose: () => undefined, }) const driver = BrowserDriver.define(factory) +declare const port: ChromiumPort<{ readonly page: true }> +const chromium: ChromiumDriver<{ readonly page: true }> = BrowserDriver.chromium(() => port) declare const client: ReturnType const attachment: Promise> = client.browser.attach({ @@ -27,3 +38,9 @@ const attachment: Promise> = cl }) void attachment +const chromiumAttachment: Promise>> = + client.browser.attach({ + sessionID: "ses_chromium_type_fixture", + driver: chromium, + }) +void chromiumAttachment