feat(client): add shared Chromium driver
This commit is contained in:
parent
9721f7ab8b
commit
4205af5cde
7 changed files with 985 additions and 4 deletions
85
packages/client/test/node/chromium.test.ts
Normal file
85
packages/client/test/node/chromium.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { Browser, BrowserDriver, type BrowserDriverContext, type ChromiumPort } from "@opencode-ai/client/node"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
type Port = ChromiumPort<{ readonly name: string }>
|
||||
type Command = Parameters<Port["send"]>[0]
|
||||
type Listener = Parameters<Port["subscribe"]>[0]
|
||||
|
||||
describe("Chromium browser driver", () => {
|
||||
test("snapshots accessibility refs and invalidates them with the document generation", async () => {
|
||||
const port = new FakePort()
|
||||
const instance = await BrowserDriver.chromium(() => port)({
|
||||
proxy: { url: "http://127.0.0.1:1", host: "127.0.0.1", port: 1, credentials: { username: "u", password: "p" } },
|
||||
signal: new AbortController().signal,
|
||||
} satisfies BrowserDriverContext)
|
||||
const execute = (command: Browser.Command) => instance.execute(command, { signal: new AbortController().signal })
|
||||
|
||||
const snapshot = await execute({ type: "snapshot", generation: 0 })
|
||||
expect(snapshot).toMatchObject({
|
||||
type: "snapshot",
|
||||
content: expect.stringContaining('e1 [button] "Save" disabled=false'),
|
||||
})
|
||||
expect(port.expression).toContain("while (visited++ < 500)")
|
||||
expect(port.expression).not.toContain("textContent")
|
||||
await execute({ type: "click", ref: Browser.Ref.make("e1"), generation: 0 })
|
||||
|
||||
port.emit()
|
||||
expect(instance.resource.state().generation).toBe(1)
|
||||
expect(port.commands.some((command) => command.method === "Runtime.releaseObject")).toBe(true)
|
||||
await expect(execute({ type: "click", ref: Browser.Ref.make("e1"), generation: 1 })).rejects.toMatchObject({
|
||||
code: "stale_ref",
|
||||
})
|
||||
await instance.resource.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
class FakePort implements Port {
|
||||
readonly resource = { name: "chromium" }
|
||||
readonly listeners = new Set<Listener>()
|
||||
readonly commands: Command[] = []
|
||||
current = { url: "https://example.com/", title: "Example", loading: false, canGoBack: false, canGoForward: false }
|
||||
expression = ""
|
||||
|
||||
state() {
|
||||
return this.current
|
||||
}
|
||||
subscribe(listener: Listener) {
|
||||
this.listeners.add(listener)
|
||||
return () => this.listeners.delete(listener)
|
||||
}
|
||||
navigate() {}
|
||||
back() {}
|
||||
forward() {}
|
||||
reload() {}
|
||||
stop() {}
|
||||
send(command: Command) {
|
||||
this.commands.push(command)
|
||||
if (command.method === "Runtime.evaluate") {
|
||||
this.expression = command.params.expression
|
||||
return Promise.resolve({ result: { objectId: "snapshot" } })
|
||||
}
|
||||
if (command.method !== "Runtime.callFunctionOn") return Promise.resolve({})
|
||||
if (command.params.functionDeclaration === "function() { return this.result }") {
|
||||
return Promise.resolve({
|
||||
result: {
|
||||
value: {
|
||||
nodes: [{ token: "e1", role: "button", name: "Save", value: "", depth: 1, disabled: false }],
|
||||
nextRef: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
return Promise.resolve({ result: { value: { x: 25, y: 40 } } })
|
||||
}
|
||||
viewport() {
|
||||
return { width: 800, height: 600 }
|
||||
}
|
||||
screenshot() {
|
||||
return Promise.resolve({ data: new Uint8Array(), width: 800, height: 600 })
|
||||
}
|
||||
dispose() {}
|
||||
emit() {
|
||||
this.current = { ...this.current, url: "https://next.example/" }
|
||||
this.listeners.forEach((listener) => listener({ state: this.current, mainDocumentChanged: true }))
|
||||
}
|
||||
}
|
||||
133
packages/client/test/node/package-smoke.ts
Normal file
133
packages/client/test/node/package-smoke.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises"
|
||||
import { join, relative, resolve } from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
|
||||
const directory = resolve(import.meta.dir, "../..")
|
||||
|
||||
test("built Node entrypoint imports and exposes browser registration in Node", async () => {
|
||||
await buildClient()
|
||||
const output = await Bun.file(join(directory, "dist/node/index.js")).text()
|
||||
expect(output).not.toMatch(/(?:from\s+|import\s*)["']\.\.?\//)
|
||||
|
||||
const temporary = await mkdtemp(join(import.meta.dir, ".node-package-"))
|
||||
try {
|
||||
await Bun.write(join(temporary, "index.mjs"), output)
|
||||
await stageWorkspaceDependencies(temporary)
|
||||
const child = Bun.spawn(
|
||||
["node", "--input-type=module", "-e", nodeScenario(pathToFileURL(join(temporary, "index.mjs")).href)],
|
||||
{ cwd: temporary, stdout: "pipe", stderr: "pipe" },
|
||||
)
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
])
|
||||
if (exitCode !== 0) throw new Error(stderr || stdout)
|
||||
expect(stdout.trim()).toBe("ok")
|
||||
} finally {
|
||||
await rm(temporary, { recursive: true, force: true })
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
async function buildClient() {
|
||||
const child = Bun.spawn([process.execPath, "run", "build"], {
|
||||
cwd: directory,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
])
|
||||
if (exitCode !== 0) throw new Error(stdout + stderr)
|
||||
}
|
||||
|
||||
async function stageWorkspaceDependencies(temporary: string) {
|
||||
const schema = join(temporary, "node_modules/@opencode-ai/schema")
|
||||
const protocol = join(temporary, "node_modules/@opencode-ai/protocol")
|
||||
await Promise.all([mkdir(schema, { recursive: true }), mkdir(protocol, { recursive: true })])
|
||||
|
||||
const schemaEntry = join(temporary, "schema.ts")
|
||||
const protocolEntry = join(temporary, "protocol.ts")
|
||||
await Promise.all([
|
||||
Bun.write(
|
||||
schemaEntry,
|
||||
[
|
||||
`export { Browser } from ${JSON.stringify(importPath(temporary, resolve(directory, "../schema/src/browser.ts")))}`,
|
||||
`export { BrowserControl } from ${JSON.stringify(importPath(temporary, resolve(directory, "../schema/src/browser-control.ts")))}`,
|
||||
`export { BrowserTunnel } from ${JSON.stringify(importPath(temporary, resolve(directory, "../schema/src/browser-tunnel.ts")))}`,
|
||||
`export { Session } from ${JSON.stringify(importPath(temporary, resolve(directory, "../schema/src/session.ts")))}`,
|
||||
].join("\n"),
|
||||
),
|
||||
Bun.write(
|
||||
protocolEntry,
|
||||
[
|
||||
`export { BrowserControlProtocol } from ${JSON.stringify(importPath(temporary, resolve(directory, "../protocol/src/browser-control.ts")))}`,
|
||||
`export { BrowserTunnelProtocol } from ${JSON.stringify(importPath(temporary, resolve(directory, "../protocol/src/browser-tunnel.ts")))}`,
|
||||
].join("\n"),
|
||||
),
|
||||
])
|
||||
const [schemaBuild, protocolBuild] = await Promise.all([
|
||||
Bun.build({
|
||||
entrypoints: [schemaEntry],
|
||||
outdir: schema,
|
||||
naming: "index.js",
|
||||
target: "node",
|
||||
format: "esm",
|
||||
packages: "bundle",
|
||||
}),
|
||||
Bun.build({
|
||||
entrypoints: [protocolEntry],
|
||||
outdir: protocol,
|
||||
naming: "index.js",
|
||||
target: "node",
|
||||
format: "esm",
|
||||
packages: "bundle",
|
||||
}),
|
||||
])
|
||||
if (!schemaBuild.success) throw new Error(schemaBuild.logs.map((log) => log.message).join("\n"))
|
||||
if (!protocolBuild.success) throw new Error(protocolBuild.logs.map((log) => log.message).join("\n"))
|
||||
await Promise.all([
|
||||
Bun.write(
|
||||
join(schema, "package.json"),
|
||||
JSON.stringify({
|
||||
type: "module",
|
||||
exports: {
|
||||
"./browser": "./index.js",
|
||||
"./browser-control": "./index.js",
|
||||
"./browser-tunnel": "./index.js",
|
||||
"./session": "./index.js",
|
||||
},
|
||||
}),
|
||||
),
|
||||
Bun.write(
|
||||
join(protocol, "package.json"),
|
||||
JSON.stringify({
|
||||
type: "module",
|
||||
exports: {
|
||||
"./browser-control": "./index.js",
|
||||
"./browser-tunnel": "./index.js",
|
||||
},
|
||||
}),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
function importPath(from: string, to: string) {
|
||||
const path = relative(from, to).replaceAll("\\", "/")
|
||||
return path.startsWith(".") ? path : `./${path}`
|
||||
}
|
||||
|
||||
function nodeScenario(moduleURL: string) {
|
||||
return `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")
|
||||
const client = sdk.OpenCode.make({ baseUrl: "http://127.0.0.1:1" })
|
||||
if (typeof client.browser.register !== "function") throw new Error("Missing browser.register")
|
||||
console.log("ok")`
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue