import { EventEmitter } from "node:events"; import { createServer, get, type ServerResponse } from "node:http"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { BrowserRuntime } from "./browser-runtime.ts"; import { HOLD_LIMITS } from "./browser/hold.ts"; import { BrowserActionFailedError, BrowserLive, browserRestartRefusal, browserStreamPort, normalizeBrowserLiveMessage, parseBrowserLiveAction } from "./browser-live.ts"; import type { Cdp, CdpListeners } from "./browser-sites.ts"; const execute = vi.hoisted(() => vi.fn()); const nativeClose = vi.hoisted(() => vi.fn()); const nativeInput = vi.fn(); vi.mock("./browser-engine.ts", () => ({ closeBrowserSession: nativeClose })); vi.mock("node:child_process", async (original) => { const actual = await original(); const { promisify } = await import("node:util"); return { ...actual, execFile: Object.assign(vi.fn(), { [promisify.custom]: execute }) }; }); class ResponseFixture extends EventEmitter { destroyed = false; writableEnded = false; headersSent = false; writableLength = 0; backpressure = false; chunks: string[] = []; headers: Record = {}; writeHead(_status: number, headers: Record) { this.headersSent = true; this.headers = headers; } flushHeaders() {} write(value: string) { this.chunks.push(value); return !this.backpressure; } end() { this.writableEnded = true; } destroy() { this.destroyed = true; this.emit("close"); } events(type: string) { return this.chunks.filter((chunk) => chunk.startsWith(`event: ${type}\n`)).map((chunk) => JSON.parse(chunk.split("\ndata: ")[1]!)); } get id(): string { return this.events("ready")[0]?.viewerId ?? ""; } } class SocketFixture extends EventTarget { static OPEN = 1; static instances: SocketFixture[] = []; static initialMessages: unknown[] = []; static failOpen = false; readyState = 1; bufferedAmount = 0; messages: unknown[] = []; readonly url: string; constructor(url: string) { super(); this.url = url; SocketFixture.instances.push(this); queueMicrotask(() => { this.dispatchEvent(new Event(SocketFixture.failOpen ? "error" : "open")); for (const message of SocketFixture.initialMessages) this.receive(message); }); } send(value: string) { this.messages.push(JSON.parse(value)); } close() { this.readyState = 3; this.dispatchEvent(new Event("close")); } receive(value: unknown) { this.dispatchEvent(new MessageEvent("message", { data: JSON.stringify(value) })); } } const frame = { type: "frame", seq: 1, data: "/9j/AAAA", metadata: { deviceWidth: 1280, deviceHeight: 720, offsetTop: 0, pageScaleFactor: 1, timestamp: 123 } }; const ready = { enabled: true, connected: true, port: 43210 }; function output(data: unknown) { return { stdout: JSON.stringify({ success: true, data }), stderr: "" }; } let live: BrowserLive; let held: Map; let runtime: BrowserRuntime; beforeEach(() => { held = new Map(); runtime = { heldBy: (session: string) => held.get(session), canControl: (session: string, owner: string) => held.get(session) === owner, take: vi.fn(async (session: string, owner: string) => { if (held.has(session) && held.get(session) !== owner) throw new Error("held"); held.set(session, owner); }), release: vi.fn((session: string, owner: string) => { if (held.get(session) === owner) held.delete(session); }), abandonHumanInput: vi.fn(), interrupted: vi.fn(() => false), withHumanAction: vi.fn(async (_session: string, _owner: string, fn: () => unknown) => fn()), watch: vi.fn(() => () => {}), onIdleClose: vi.fn(), settled: vi.fn(async () => {}), working: vi.fn(() => true), launchReady: vi.fn(async () => {}), launched: vi.fn(async () => {}), pageSizes: { resize: vi.fn(async () => {}) }, } as unknown as BrowserRuntime; live = new BrowserLive({ runtime }); SocketFixture.instances = []; SocketFixture.initialMessages = []; SocketFixture.failOpen = false; vi.stubGlobal("WebSocket", SocketFixture); vi.stubGlobal("fetch", nativeInput); nativeInput.mockReset().mockImplementation(async () => Response.json({ success: true, data: { dispatched: true } })); nativeClose.mockReset().mockResolvedValue(true); execute.mockReset().mockResolvedValue(output(ready)); }); afterEach(() => { live.closeAll(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); vi.useRealTimers(); }); async function open({ botId = "bot-a", session = "profile-a", owner = "admin-a", current = () => true, resume, size }: { botId?: string; session?: string; owner?: string; current?: () => boolean; resume?: string; size?: { width: number; height: number } } = {}) { const res = new ResponseFixture(); await live.open({ botId, session, owner, isCurrent: current, res: res as unknown as ServerResponse, ...(resume ? { resume } : {}), ...(size ? { size } : {}), spec: { command: "/trusted/agent-browser", args: ["mcp", "--tools", "core"], env: { AGENT_BROWSER_SESSION: session, AGENT_BROWSER_SOCKET_DIR: "/isolated/socket" } } }); return { res, socket: SocketFixture.instances.at(-1)!, action: (body: unknown) => live.action({ viewerId: res.id, botId, owner, body }) }; } describe("restarting a browser bots are using", () => { it("is refused while a busy bot still works in it, and allowed when it waits on the person there or the browser no longer answers", () => { expect(browserRestartRefusal(false, [])).toBeNull(); expect(browserRestartRefusal(false, [{ waitingOnBrowser: false }])).toBe("Stop every bot using this profile before restarting its browser."); expect(browserRestartRefusal(false, [{ waitingOnBrowser: true }])).toBeNull(); expect(browserRestartRefusal(false, [{ waitingOnBrowser: true }, { waitingOnBrowser: false }])).not.toBeNull(); expect(browserRestartRefusal(true, [{ waitingOnBrowser: false }])).toBeNull(); }); }); describe("browser viewer protocol boundary", () => { it.each(["console", "command", "result", "chat", "unknown"])("drops %s messages, including their private payloads", (type) => { expect(normalizeBrowserLiveMessage({ type, data: "secret", params: { password: "secret" } })).toBeNull(); }); it("projects frames and tab/status fields, never engine extras or raw error messages", () => { expect(normalizeBrowserLiveMessage({ ...frame, password: "secret" })).toEqual({ type: "frame", seq: 1, data: "/9j/AAAA", format: "jpeg", metadata: { ...frame.metadata, scrollOffsetX: 0, scrollOffsetY: 0 } }); expect(normalizeBrowserLiveMessage({ type: "error", message: "secret arguments" })).toEqual({ type: "error", retryable: true, message: "The browser stream was interrupted.", }); expect(normalizeBrowserLiveMessage({ type: "tabs", tabs: [{ tabId: "t1", title: "Page", url: "https://user:secret@example.com/", active: true, targetId: "private" }] })).toEqual({ type: "tabs", tabs: [{ tabId: "t1", title: "Page", url: "https://example.com/", active: true }] }); expect(normalizeBrowserLiveMessage({ type: "status", connected: true, engine: "private" })).not.toHaveProperty("engine"); }); it("rejects corrupt, oversized and non-image frames, and passes a busy page's frame at the largest size it sizes", () => { for (const patch of [{ seq: -1 }, { seq: 1.5 }, { data: "