149 lines
5.6 KiB
TypeScript
149 lines
5.6 KiB
TypeScript
import { BrowserControlProtocol } from "@opencode-ai/protocol/browser-control"
|
|
import { BrowserControl } from "@opencode-ai/schema/browser-control"
|
|
import { describe, expect, test } from "bun:test"
|
|
import { Effect } from "effect"
|
|
import { once } from "node:events"
|
|
import { createServer } from "node:http"
|
|
import WebSocket, { WebSocketServer } from "ws"
|
|
import { Browser, BrowserDriver, OpenCode } from "@opencode-ai/client/node"
|
|
|
|
const state: Browser.State = {
|
|
url: "https://example.com/",
|
|
title: "Example",
|
|
loading: false,
|
|
canGoBack: false,
|
|
canGoForward: false,
|
|
generation: 1,
|
|
}
|
|
|
|
describe("Node browser client", () => {
|
|
test("registers one Session and handles attach, command, detach, and open", async () => {
|
|
const server = await controlServer()
|
|
let opened = 0
|
|
let disposed = 0
|
|
const client = OpenCode.make({ baseUrl: server.url })
|
|
try {
|
|
const registering = client.browser.register({ sessionID: "ses_node_browser", open: () => opened++ })
|
|
const socket = await server.connected
|
|
const next = reader(socket)
|
|
expect(await next()).toEqual({ type: "browser.control.register", sessionID: "ses_node_browser" })
|
|
socket.send(BrowserControlProtocol.encodeFromServer({ type: "browser.control.registered" }))
|
|
const registration = await registering
|
|
|
|
socket.send(BrowserControlProtocol.encodeFromServer({ type: "browser.control.open" }))
|
|
await waitFor(() => opened === 1)
|
|
expect(opened).toBe(1)
|
|
|
|
const attaching = registration.attach({
|
|
driver: BrowserDriver.define(({ proxy }) => ({
|
|
resource: proxy,
|
|
state: () => state,
|
|
subscribe: () => () => undefined,
|
|
execute: async () => ({ type: "snapshot", state, format: "opencode.semantic.v1", content: "snapshot" }),
|
|
dispose: () => disposed++,
|
|
})),
|
|
})
|
|
const attach = await next()
|
|
if (attach.type !== "browser.control.attach") throw new Error("expected browser attach")
|
|
expect(attach.state).toEqual(state)
|
|
socket.send(
|
|
BrowserControlProtocol.encodeFromServer({ type: "browser.control.attached", leaseID: attach.leaseID }),
|
|
)
|
|
const attachment = await attaching
|
|
expect(attachment.resource.url.startsWith("http://127.0.0.1:")).toBe(true)
|
|
expect((await next()).type).toBe("browser.control.state")
|
|
|
|
const requestID = BrowserControl.RequestID.create()
|
|
socket.send(
|
|
BrowserControlProtocol.encodeFromServer({
|
|
type: "browser.control.request",
|
|
requestID,
|
|
leaseID: attach.leaseID,
|
|
command: { type: "snapshot", generation: 1 },
|
|
}),
|
|
)
|
|
expect(await next()).toMatchObject({
|
|
type: "browser.control.response",
|
|
requestID,
|
|
outcome: { type: "success", result: { type: "snapshot", content: "snapshot" } },
|
|
})
|
|
|
|
await attachment.close()
|
|
expect(await next()).toEqual({ type: "browser.control.detach", leaseID: attach.leaseID })
|
|
expect(socket.readyState).toBe(WebSocket.OPEN)
|
|
expect(disposed).toBe(1)
|
|
const closed = once(socket, "close")
|
|
await registration.close()
|
|
await closed
|
|
} finally {
|
|
await server.close()
|
|
}
|
|
})
|
|
|
|
})
|
|
|
|
async function controlServer(authorization?: string) {
|
|
const http = createServer((request, response) => {
|
|
response.statusCode = request.headers.authorization === authorization ? 200 : 401
|
|
response.end()
|
|
})
|
|
const webSockets = new WebSocketServer({ noServer: true })
|
|
let resolveConnected!: (socket: WebSocket) => void
|
|
const connected = new Promise<WebSocket>((resolve) => {
|
|
resolveConnected = resolve
|
|
})
|
|
webSockets.once("connection", resolveConnected)
|
|
http.on("upgrade", (request, socket, head) => {
|
|
if (
|
|
request.url !== BrowserControlProtocol.Path ||
|
|
request.headers.authorization !== authorization ||
|
|
request.headers["sec-websocket-protocol"] !== BrowserControlProtocol.Subprotocol
|
|
) {
|
|
socket.end("HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n")
|
|
return
|
|
}
|
|
webSockets.handleUpgrade(request, socket, head, (webSocket) => webSockets.emit("connection", webSocket, request))
|
|
})
|
|
await new Promise<void>((resolve) => http.listen(0, "127.0.0.1", resolve))
|
|
const address = http.address()
|
|
if (!address || typeof address === "string") throw new Error("control server did not bind")
|
|
return {
|
|
connected,
|
|
url: `http://127.0.0.1:${address.port}`,
|
|
async close() {
|
|
webSockets.clients.forEach((socket) => socket.terminate())
|
|
webSockets.close()
|
|
http.closeAllConnections()
|
|
await new Promise<void>((resolve) => http.close(() => resolve()))
|
|
},
|
|
}
|
|
}
|
|
|
|
function reader(socket: WebSocket) {
|
|
const queued: WebSocket.RawData[] = []
|
|
const waiting: Array<(data: WebSocket.RawData) => void> = []
|
|
socket.on("message", (data, binary) => {
|
|
if (binary) throw new Error("expected text control message")
|
|
const resolve = waiting.shift()
|
|
if (resolve) resolve(data)
|
|
else queued.push(data)
|
|
})
|
|
return async () => {
|
|
const data = queued.shift() ?? (await new Promise<WebSocket.RawData>((resolve) => waiting.push(resolve)))
|
|
return Effect.runPromise(BrowserControlProtocol.decodeFromClient(Buffer.from(rawData(data)).toString("utf8")))
|
|
}
|
|
}
|
|
|
|
function rawData(data: WebSocket.RawData) {
|
|
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
|
if (Array.isArray(data)) return new Uint8Array(Buffer.concat(data))
|
|
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
|
|
}
|
|
|
|
async function waitFor(check: () => boolean) {
|
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
if (check()) return
|
|
await Bun.sleep(5)
|
|
}
|
|
throw new Error("timed out waiting for browser client")
|
|
}
|