opencode/packages/client/test/node/chromium.test.ts
2026-07-28 16:55:53 +10:00

363 lines
13 KiB
TypeScript

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
}