feat(client): add shared Chromium driver
This commit is contained in:
parent
f814e4758c
commit
f147ff64f1
7 changed files with 1548 additions and 17 deletions
363
packages/client/test/node/chromium.test.ts
Normal file
363
packages/client/test/node/chromium.test.ts
Normal file
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue