feat(client): add shared Chromium driver
This commit is contained in:
+21
-16
@@ -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:
|
||||
|
||||
|
||||
@@ -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<Resource> {
|
||||
readonly resource: Resource
|
||||
readonly state: () => ChromiumViewState
|
||||
readonly subscribe: (listener: (event: ChromiumViewEvent) => void) => () => void
|
||||
readonly navigate: (url: string) => PromiseLike<void>
|
||||
readonly back: () => PromiseLike<void> | void
|
||||
readonly forward: () => PromiseLike<void> | void
|
||||
readonly reload: () => PromiseLike<void> | void
|
||||
readonly stop: () => void
|
||||
/** Sends CDP commands in invocation order. */
|
||||
readonly send: (method: string, params?: Record<string, unknown>) => PromiseLike<unknown>
|
||||
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> | void
|
||||
}
|
||||
|
||||
/** Local controller exposed as the browser attachment resource. */
|
||||
export interface ChromiumController<Resource> extends AsyncDisposable {
|
||||
readonly resource: Resource
|
||||
readonly state: () => Browser.State
|
||||
readonly subscribe: (listener: (state: Browser.State) => void) => () => void
|
||||
readonly navigate: (url: string) => Promise<void>
|
||||
readonly back: () => Promise<void>
|
||||
readonly forward: () => Promise<void>
|
||||
readonly reload: () => Promise<void>
|
||||
readonly stop: () => void
|
||||
readonly dispose: () => Promise<void>
|
||||
}
|
||||
|
||||
export type ChromiumDriver<Resource> = BrowserDriver<ChromiumController<Resource>>
|
||||
|
||||
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<Resource> = {
|
||||
readonly port: ChromiumPort<Resource>
|
||||
readonly lifetime: AbortSignal
|
||||
document: number
|
||||
snapshot: number
|
||||
nextRef: number
|
||||
snapshotObjectID?: string
|
||||
readonly refs: Map<string, Ref>
|
||||
readonly listeners: Set<(state: Browser.State) => void>
|
||||
readonly requests: Set<AbortController>
|
||||
state: ChromiumViewState
|
||||
unsubscribe?: () => void
|
||||
active?: AbortController
|
||||
disposed: boolean
|
||||
disposal?: Promise<void>
|
||||
queue: Promise<void>
|
||||
}
|
||||
|
||||
const debuggerCommandTimeout = 10_000
|
||||
const browserSnapshotLimit = 500
|
||||
const screenshotDimensionLimit = 2_000
|
||||
const screenshotByteLimit = 5 * 1_024 * 1_024
|
||||
|
||||
export function chromiumDriver<Resource>(
|
||||
create: (context: BrowserDriverContext) => PromiseLike<ChromiumPort<Resource>> | ChromiumPort<Resource>,
|
||||
): ChromiumDriver<Resource> {
|
||||
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<Resource>(
|
||||
port: ChromiumPort<Resource>,
|
||||
lifetime: AbortSignal,
|
||||
): BrowserDriverInstance<ChromiumController<Resource>> {
|
||||
requirePort(port)
|
||||
const page: Page<Resource> = {
|
||||
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<Resource> = 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<Resource>(
|
||||
page: Page<Resource>,
|
||||
command: Browser.Command,
|
||||
signal: AbortSignal,
|
||||
): Promise<Browser.Result> {
|
||||
return schedule(page, [page.lifetime, signal], (abort, verify, operation) =>
|
||||
execute(page, command, abort, verify, operation),
|
||||
)
|
||||
}
|
||||
|
||||
async function execute<Resource>(
|
||||
page: Page<Resource>,
|
||||
command: Browser.Command,
|
||||
abort: AbortSignal,
|
||||
verify: () => void,
|
||||
operation: Operation,
|
||||
): Promise<Browser.Result> {
|
||||
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<Resource>(page: Page<Resource>, input: string) {
|
||||
return schedule(page, [page.lifetime], (abort, verify, operation) => navigate(page, input, abort, verify, operation))
|
||||
}
|
||||
|
||||
async function localAction<Resource>(page: Page<Resource>, run: () => PromiseLike<void> | void) {
|
||||
return schedule(page, [page.lifetime], async (abort, verify, operation) => {
|
||||
throwIfAborted(abort)
|
||||
verify()
|
||||
await startOperation(operation, run)
|
||||
verify()
|
||||
})
|
||||
}
|
||||
|
||||
async function navigate<Resource>(
|
||||
page: Page<Resource>,
|
||||
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<Resource>(
|
||||
page: Page<Resource>,
|
||||
generation: number,
|
||||
abort: AbortSignal,
|
||||
verify: () => void,
|
||||
operation: Operation,
|
||||
): Promise<Browser.SnapshotResult> {
|
||||
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<Resource>(
|
||||
page: Page<Resource>,
|
||||
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<Resource>(
|
||||
page: Page<Resource>,
|
||||
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<Resource>(
|
||||
page: Page<Resource>,
|
||||
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<Resource>(
|
||||
page: Page<Resource>,
|
||||
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<Resource>(
|
||||
page: Page<Resource>,
|
||||
generation: number,
|
||||
abort: AbortSignal,
|
||||
verify: () => void,
|
||||
operation: Operation,
|
||||
): Promise<Browser.ScreenshotResult> {
|
||||
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<Resource, Result>(
|
||||
page: Page<Resource>,
|
||||
signals: ReadonlyArray<AbortSignal>,
|
||||
run: (abort: AbortSignal, verify: () => void, operation: Operation) => Promise<Result>,
|
||||
) {
|
||||
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<Resource, Result>(
|
||||
page: Page<Resource>,
|
||||
operation: Operation,
|
||||
run: () => Promise<Result>,
|
||||
) {
|
||||
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<Resource, Result>(page: Page<Resource>, controller: AbortController, run: () => Promise<Result>) {
|
||||
page.active = controller
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
if (page.active === controller) page.active = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue<Resource, Result>(page: Page<Resource>, run: () => Promise<Result>) {
|
||||
const result = page.queue.then(run, run)
|
||||
page.queue = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
function stop<Resource>(page: Page<Resource>) {
|
||||
assertAttached(page)
|
||||
page.active?.abort()
|
||||
try {
|
||||
page.port.stop()
|
||||
} catch (error) {
|
||||
throw normalizeBrowserError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function subscribe<Resource>(page: Page<Resource>, listener: (state: Browser.State) => void) {
|
||||
assertAttached(page)
|
||||
page.listeners.add(listener)
|
||||
listener(contractState(page.state, page.document))
|
||||
return () => {
|
||||
page.listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
function publish<Resource>(page: Page<Resource>) {
|
||||
const state = contractState(page.state, page.document)
|
||||
page.listeners.forEach((listener) => listener(state))
|
||||
}
|
||||
|
||||
function refresh<Resource>(page: Page<Resource>) {
|
||||
page.state = readPortState(page.port.state())
|
||||
publish(page)
|
||||
return page.state
|
||||
}
|
||||
|
||||
function refreshedState<Resource>(page: Page<Resource>, verify: () => void) {
|
||||
const state = contractState(refresh(page), page.document)
|
||||
verify()
|
||||
return state
|
||||
}
|
||||
|
||||
function disposePage<Resource>(page: Page<Resource>) {
|
||||
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<Resource>(page: Page<Resource>) {
|
||||
const objectID = page.snapshotObjectID
|
||||
page.snapshotObjectID = undefined
|
||||
page.snapshot++
|
||||
page.refs.clear()
|
||||
if (objectID) releaseSnapshotObject(page, objectID)
|
||||
}
|
||||
|
||||
function releaseSnapshotObject<Resource>(page: Page<Resource>, objectID: string) {
|
||||
if (page.disposed) return
|
||||
void Promise.resolve(page.port.send("Runtime.releaseObject", { objectId: objectID })).catch(() => undefined)
|
||||
}
|
||||
|
||||
function resolveRef<Resource>(page: Page<Resource>, 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<Resource>(
|
||||
page: Page<Resource>,
|
||||
method: string,
|
||||
params: Record<string, unknown> | 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<Resource>(input: ChromiumPort<Resource>) {
|
||||
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<Resource>(page: Page<Resource>, generation: number) {
|
||||
if (page.document !== generation) {
|
||||
throw browserError("stale_ref", "The browser page changed. Call browser_snapshot again.")
|
||||
}
|
||||
}
|
||||
|
||||
function assertAttached<Resource>(page: Page<Resource>) {
|
||||
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<Result>(
|
||||
run: () => PromiseLike<Result>,
|
||||
input: {
|
||||
readonly signal?: AbortSignal
|
||||
readonly timeout: number
|
||||
readonly aborted: () => Error
|
||||
readonly timedOut: () => Error
|
||||
},
|
||||
) {
|
||||
return new Promise<Result>((resolve, reject) => {
|
||||
type OperationResult = { readonly ran: false } | { readonly ran: true; readonly value: Result }
|
||||
let settled = false
|
||||
let timer: ReturnType<typeof setTimeout> | 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<OperationResult>(() => {
|
||||
if (settled) return { ran: false }
|
||||
return new Promise<OperationResult>((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<void>
|
||||
readonly release: () => Promise<void>
|
||||
}) {
|
||||
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<Result>(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<string, unknown> {
|
||||
return typeof input === "object" && input !== null && !Array.isArray(input)
|
||||
}
|
||||
@@ -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<Resource>(
|
||||
create: (context: BrowserDriverContext) => PromiseLike<ChromiumPort<Resource>> | ChromiumPort<Resource>,
|
||||
): ChromiumDriver<Resource> {
|
||||
if (typeof create !== "function") throw new TypeError("Chromium port factory must be a function")
|
||||
return Object.freeze(chromiumDriver(create))
|
||||
},
|
||||
}
|
||||
|
||||
export function browserDriverFactory<Resource>(driver: BrowserDriver<Resource>) {
|
||||
|
||||
@@ -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<typeof import("./client.js").make>
|
||||
|
||||
@@ -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<ChromiumPort<unknown>["subscribe"]>[0]
|
||||
type PortState = ReturnType<ChromiumPort<unknown>["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<never>(() => 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<PortListener>()
|
||||
readonly calls: Array<{ readonly method: string; readonly params?: Record<string, unknown> }> = []
|
||||
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> | void
|
||||
onSend?: (method: string, params?: Record<string, unknown>) => 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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
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<PortState>, 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<void>((next) => {
|
||||
resolve = next
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function rejected(promise: Promise<unknown>) {
|
||||
return promise.then(
|
||||
() => new Error("Expected operation to reject"),
|
||||
(error: unknown) => error,
|
||||
)
|
||||
}
|
||||
|
||||
function requireDriverError(input: unknown) {
|
||||
if (!(input instanceof BrowserDriverError)) throw input
|
||||
return input
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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<typeof OpenCode.make>
|
||||
const attachment: Promise<BrowserAttachment<{ readonly proxyURL: string }>> = client.browser.attach({
|
||||
@@ -27,3 +38,9 @@ const attachment: Promise<BrowserAttachment<{ readonly proxyURL: string }>> = cl
|
||||
})
|
||||
|
||||
void attachment
|
||||
const chromiumAttachment: Promise<BrowserAttachment<ChromiumController<{ readonly page: true }>>> =
|
||||
client.browser.attach({
|
||||
sessionID: "ses_chromium_type_fixture",
|
||||
driver: chromium,
|
||||
})
|
||||
void chromiumAttachment
|
||||
|
||||
Reference in New Issue
Block a user