server/browser-live.test.ts
Rama main · réplica de lectura del repositorio original.
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<typeof import("node:child_process")>();
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<string, string> = {};
writeHead(_status: number, headers: Record<string, string>) { 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<string, string>;
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: "<script>" }, { data: "/9j/" + "A".repeat(4 * 1024 * 1024) }, { metadata: { deviceWidth: 100000, deviceHeight: 100000 } }]) {
expect(normalizeBrowserLiveMessage({ ...frame, ...patch })).toBeNull();
}
// 2,768,256 characters: a 1280x2560 frame of a page of noise from agent-browser 0.37.0.
expect(normalizeBrowserLiveMessage({ ...frame, data: "/9j/" + "A".repeat(2_768_252), metadata: { ...frame.metadata, deviceWidth: 1280, deviceHeight: 2560 } })).toMatchObject({ type: "frame" });
});
it("requires a valid integer port from successful stream status, not a caller URL", () => {
expect(browserStreamPort(ready)).toBe(43210);
for (const value of [null, {}, { enabled: false, port: 42 }, { enabled: true, port: "42" }, { enabled: true, port: 0 }, { enabled: true, port: 65536 }, { enabled: true, port: 2.2 }]) expect(() => browserStreamPort(value)).toThrow();
});
it("maps only fixed navigation/tab verbs and rejects executable URLs and flags", () => {
expect(parseBrowserLiveAction({ type: "navigate", url: "https://example.com/?q=hi", command: "eval", args: ["secret"] })).toEqual({ type: "command", args: ["open", "https://example.com/?q=hi"] });
expect(parseBrowserLiveAction({ type: "tab-new" })).toEqual({ type: "command", args: ["tab", "new"] });
expect(parseBrowserLiveAction({ type: "tab-select", tabId: "t2" })).toEqual({ type: "command", args: ["tab", "t2"] });
for (const url of ["javascript:alert(1)", "file:///etc/passwd", "data:text/html,x", "--cdp=9222", "https://a:secret@example.com", "https://example.com\n--headed", "http:\\example.com"]) expect(() => parseBrowserLiveAction({ type: "navigate", url })).toThrow();
for (const body of [{ type: "eval", script: "1" }, { type: "press", key: "Control+a" }, { type: "tab-close", tabId: "--all" }, { type: "tab-select", tabId: 1 }, { type: "config", port: 1 }]) expect(() => parseBrowserLiveAction(body)).toThrow();
});
it("bounds input coordinates, text and modifiers, and strips arbitrary CDP fields", () => {
expect(parseBrowserLiveAction({ type: "input_mouse", eventType: "mousePressed", x: 12, y: 20, button: "left", clickCount: 1, method: "Runtime.evaluate" })).toEqual({ type: "input", message: { type: "input_mouse", eventType: "mousePressed", x: 12, y: 20, button: "left", clickCount: 1, deltaX: 0, deltaY: 0, modifiers: 0 } });
expect(parseBrowserLiveAction({ type: "input_keyboard", eventType: "keyDown", key: "a", text: "a" })).toEqual({ type: "input", message: { type: "input_keyboard", eventType: "keyDown", key: "a", text: "a", modifiers: 0, windowsVirtualKeyCode: 0 } });
for (const body of [{ type: "input_mouse", eventType: "mouseMoved", x: Infinity, y: 1 }, { type: "input_mouse", eventType: "mouseMoved", x: -1, y: 1 }, { type: "input_keyboard", eventType: "keyDown", key: "a", modifiers: 16 }, { type: "input_keyboard", eventType: "keyDown", key: "a", text: "a".repeat(4097) }]) expect(() => parseBrowserLiveAction(body)).toThrow();
});
});
describe("authenticated browser viewer relay", () => {
it("fences a late stream startup when that owner is closed during discovery", async () => {
let finish!: (value: ReturnType<typeof output>) => void;
execute.mockImplementationOnce(() => new Promise((resolve) => finish = resolve));
const opening = open();
const rejected = expect(opening).rejects.toThrow();
await expect.poll(() => execute.mock.calls.length).toBe(1);
live.closeForOwner("admin-a");
finish(output(ready));
await rejected;
expect(SocketFixture.instances).toHaveLength(0);
});
it("closes every stream and upstream socket for a revoked owner without touching other owners", async () => {
const a = await open();
const b = await open({ botId: "bot-b", session: "profile-b" });
const other = await open({ owner: "other" });
live.closeForOwner("admin-a");
expect(a.res.writableEnded).toBe(true);
expect(b.res.writableEnded).toBe(true);
expect(a.socket.readyState).toBe(3);
expect(b.socket.readyState).toBe(3);
expect(other.res.writableEnded).toBe(false);
expect(other.socket.readyState).toBe(1);
});
it("keeps opening status, tabs and the seed frame even if they arrive immediately with the WebSocket upgrade", async () => {
SocketFixture.initialMessages = [{ type: "status", connected: true }, { type: "tabs", tabs: [] }, frame];
const a = await open();
expect(a.res.events("status")).toHaveLength(1);
expect(a.res.events("tabs")).toHaveLength(1);
expect(a.res.events("frame")).toHaveLength(1);
});
it("reports post-header WebSocket failures as SSE errors without throwing into a JSON route handler", async () => {
SocketFixture.failOpen = true;
const a = await open();
expect(a.res.writableEnded).toBe(true);
expect(a.res.events("error")).toHaveLength(1);
});
it("does not write premature headers when another view takes control during startup", async () => {
const a = await open();
let finish!: (value: ReturnType<typeof output>) => void;
execute.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; }));
const res = new ResponseFixture();
const opening = live.open({ botId: "b", session: "profile-a", owner: "admin-a", isCurrent: () => true, res: res as unknown as ServerResponse, spec: { command: "/engine" } });
await vi.waitFor(() => expect(finish).toBeTypeOf("function"));
await a.action({ type: "take" });
expect(res.headersSent).toBe(false); expect(res.chunks).toHaveLength(0);
finish(output(ready)); await opening;
expect(res.id).toBeTruthy();
expect(res.headers["Content-Type"]).toBe("text/event-stream");
});
it("uses session-owned CLI environment and a loopback-only paced socket without browser flags", async () => {
vi.stubEnv("PRIVATE_SERVER_SECRET", "secret"); vi.stubEnv("AGENT_BROWSER_CDP", "https://unrelated-browser.invalid");
const { res, socket } = await open();
expect(res.id).toMatch(/^[a-f0-9-]{36}$/);
expect(res.headers).toMatchObject({ "Cache-Control": "no-store", "Content-Type": "text/event-stream" });
expect(socket.url).toBe("ws://127.0.0.1:43210/?pacing=ack&maxFps=15");
expect(execute).toHaveBeenCalledTimes(1);
expect(execute.mock.calls[0]?.[1]).toEqual(["stream", "status", "--json", "--no-webmcp"]);
expect(execute.mock.calls[0]?.[2].env).toMatchObject({ AGENT_BROWSER_SESSION: "profile-a", AGENT_BROWSER_SOCKET_DIR: "/isolated/socket" });
expect(execute.mock.calls[0]?.[2].env).not.toHaveProperty("PRIVATE_SERVER_SECRET");
expect(execute.mock.calls[0]?.[2].env).not.toHaveProperty("AGENT_BROWSER_CDP");
});
it("only enables disabled streams and launches a blank browser without navigating an existing one", async () => {
execute.mockResolvedValueOnce(output({ enabled: false, connected: false, port: null }))
.mockResolvedValueOnce(output({ ...ready, connected: false })).mockResolvedValueOnce(output({ launched: true }));
await open();
expect(execute.mock.calls.map((call) => call[1])).toEqual([
["stream", "status", "--json", "--no-webmcp"], ["stream", "enable", "--json", "--no-webmcp"], ["open", "--json", "--no-webmcp"],
]);
});
it("waits for the prepared Chrome before the engine may start it, and gives it its identity once it runs", async () => {
const order: string[] = [];
vi.mocked(runtime.launchReady).mockImplementation(async () => { order.push("ready"); });
vi.mocked(runtime.launched).mockImplementation(async () => { order.push("launched"); });
execute.mockImplementation(async (_command: string, args: string[]) => { order.push(args[0] === "stream" ? `stream ${args[1]}` : args[0]!); return output({ ...ready, connected: false }); });
await open();
expect(order).toEqual(["ready", "stream status", "open", "launched"]);
expect(runtime.launched).toHaveBeenCalledWith("profile-a", expect.objectContaining({ command: "/trusted/agent-browser" }));
});
it("tells the viewer why the browser cannot start yet, and starts nothing", async () => {
vi.mocked(runtime.launchReady).mockRejectedValue(new Error("Getting the browser ready (52 of 182 MB). Try again in a minute."));
await expect(open()).rejects.toThrow("Getting the browser ready (52 of 182 MB). Try again in a minute.");
expect(execute).not.toHaveBeenCalled();
});
it("fails closed on missing ports and sanitizes CLI errors without ending an unwritten error response", async () => {
execute.mockResolvedValueOnce(output({ enabled: true, connected: true, port: null }));
await expect(open()).rejects.toThrow("valid local stream");
expect(SocketFixture.instances).toHaveLength(0);
execute.mockRejectedValueOnce(new Error("PASSWORD=secret CLI args"));
const res = new ResponseFixture();
await expect(live.open({ botId: "a", session: "s", owner: "a", isCurrent: () => true, res: res as unknown as ServerResponse, spec: { command: "/engine" } })).rejects.toThrow("browser could not complete");
expect(res.writableEnded).toBe(false);
expect(res.chunks).toHaveLength(0);
});
it("reports a page that would not open as settled, without the engine's own error, and keeps the view usable", async () => {
const { action } = await open();
await action({ type: "take" });
const failed = Object.assign(new Error("Command failed: agent-browser open https://google/"), {
code: 1, stdout: JSON.stringify({ success: false, data: null, error: "Navigation failed: net::ERR_NAME_NOT_RESOLVED" }) + "\n", stderr: "",
});
execute.mockRejectedValueOnce(failed);
const refused = action({ type: "navigate", url: "https://google/" });
await expect(refused).rejects.toMatchObject({ status: 422, settled: true, message: "This page could not be opened. Check the address and try again." });
await expect(refused).rejects.toBeInstanceOf(BrowserActionFailedError);
// The engine answered with success:false and exit 0 too.
execute.mockResolvedValueOnce({ stdout: JSON.stringify({ success: false, error: "element not found" }), stderr: "" });
await expect(action({ type: "tab-new" })).rejects.toMatchObject({ settled: true, message: "The browser could not do that. Try again." });
execute.mockResolvedValueOnce(output({ url: "https://duckduckgo.com/?q=google" }));
await expect(action({ type: "navigate", url: "https://duckduckgo.com/?q=google" })).resolves.toEqual({ ok: true });
});
it("still treats a command with no answer (killed, aborted) as interrupted, not settled", async () => {
const { action } = await open();
await action({ type: "take" });
execute.mockRejectedValueOnce(Object.assign(new Error("Command failed"), { code: null, signal: "SIGTERM", stdout: "", stderr: "" }));
const refused = action({ type: "navigate", url: "https://example.com/" });
await expect(refused).rejects.not.toHaveProperty("settled");
await expect(refused).rejects.toMatchObject({ status: 503 });
});
it("resets a daemon stuck on a failed launch so reconnecting starts a fresh session (#1383)", async () => {
const openS = () => live.open({ botId: "a", session: "s", owner: "a", isCurrent: () => true,
res: new ResponseFixture() as unknown as ServerResponse, spec: { command: "/engine", env: { AGENT_BROWSER_SESSION: "s" } } });
// A daemon that kept a headed launch config fails the same way forever;
// the failure is reported retryable and the session is discarded.
execute.mockRejectedValueOnce(Object.assign(new Error("Command failed"), {
stderr: "Chrome exited early (exit code: 1) without writing DevToolsActivePort\nMissing X server or $DISPLAY",
}));
await expect(openS()).rejects.toThrow("Reconnect to retry");
expect(nativeClose).toHaveBeenCalledTimes(1);
expect(nativeClose).toHaveBeenCalledWith("/engine", expect.objectContaining({ AGENT_BROWSER_SESSION: "s" }));
// The same recognition applies when the engine answers success:false JSON.
execute.mockResolvedValueOnce({ stdout: JSON.stringify({ success: false, error: "Chrome exited before providing DevTools URL" }), stderr: "" });
await expect(openS()).rejects.toThrow("Reconnect to retry");
expect(nativeClose).toHaveBeenCalledTimes(2);
// A non-launch failure keeps the install guidance and leaves the session alone.
execute.mockRejectedValueOnce(Object.assign(new Error("spawn /engine ENOENT"), { stderr: "" }));
await expect(openS()).rejects.toThrow("browser engine is installed");
expect(nativeClose).toHaveBeenCalledTimes(2);
});
it("gates a reconnect on the pending session reset instead of racing the stuck daemon (#1383)", async () => {
const openS = () => live.open({ botId: "a", session: "s", owner: "a", isCurrent: () => true,
res: new ResponseFixture() as unknown as ServerResponse, spec: { command: "/engine", env: { AGENT_BROWSER_SESSION: "s" } } });
let finishReset!: (closed: boolean) => void;
nativeClose.mockImplementationOnce(() => new Promise((resolve) => { finishReset = resolve; }));
execute.mockRejectedValueOnce(Object.assign(new Error("Command failed"), { stderr: "Missing X server or $DISPLAY" }));
await expect(openS()).rejects.toThrow("Reconnect to retry");
expect(nativeClose).toHaveBeenCalledTimes(1);
// While teardown is still polling, a reconnect must not issue another
// command or start a second reset against the same daemon.
const reconnecting = openS();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(execute).toHaveBeenCalledTimes(1);
expect(nativeClose).toHaveBeenCalledTimes(1);
finishReset(true);
await reconnecting;
expect(execute).toHaveBeenCalledTimes(2);
expect(execute.mock.calls[1]?.[1]).toEqual(["stream", "status", "--json", "--no-webmcp"]);
expect(nativeClose).toHaveBeenCalledTimes(1);
});
it("starts a fresh reset when the reconnect probe finds the daemon still stuck (#1383)", async () => {
const openS = () => live.open({ botId: "a", session: "s", owner: "a", isCurrent: () => true,
res: new ResponseFixture() as unknown as ServerResponse, spec: { command: "/engine", env: { AGENT_BROWSER_SESSION: "s" } } });
let finishReset!: (closed: boolean) => void;
nativeClose.mockImplementationOnce(() => new Promise((resolve) => { finishReset = resolve; }));
execute.mockRejectedValueOnce(Object.assign(new Error("Command failed"), { stderr: "Missing X server or $DISPLAY" }));
await expect(openS()).rejects.toThrow("Reconnect to retry");
const reconnecting = openS();
// The daemon outlived the first close; the probe fails the same way and
// schedules another reset instead of reporting a healthy session.
execute.mockRejectedValueOnce(Object.assign(new Error("Command failed"), { stderr: "Missing X server or $DISPLAY" }));
nativeClose.mockResolvedValueOnce(true);
finishReset(false);
await expect(reconnecting).rejects.toThrow("Reconnect to retry");
expect(nativeClose).toHaveBeenCalledTimes(2);
});
it("has the page fill the panel of the view that asked last, once the bot's call ends, and the standard page when no view is left", async () => {
const resize = vi.mocked(runtime.pageSizes.resize);
const first = await open({ size: { width: 400, height: 750 } });
expect(resize).toHaveBeenLastCalledWith("profile-a", { width: 400, height: 750 }, true);
expect(execute.mock.calls.map((call) => call[1][0])).not.toContain("set");
const second = await open({ owner: "admin-b", size: { width: 900, height: 700 } });
expect(resize).toHaveBeenLastCalledWith("profile-a", { width: 900, height: 700 }, true);
expect(await first.action({ type: "resize", width: 380, height: 760 })).toEqual({ ok: true });
expect(resize).toHaveBeenLastCalledWith("profile-a", { width: 380, height: 760 }, false);
const calls = resize.mock.calls.length;
expect(await first.action({ type: "resize", width: 380, height: 40 })).toEqual({ ok: true });
expect(resize).toHaveBeenCalledTimes(calls);
await expect(first.action({ type: "resize", width: "wide", height: 760 })).rejects.toThrow("Invalid browser action.");
first.res.destroy();
expect(resize).toHaveBeenLastCalledWith("profile-a", { width: 900, height: 700 }, false);
second.res.destroy();
expect(resize).toHaveBeenLastCalledWith("profile-a", null, false);
});
it("leaves the page of the person holding the browser as it is when another view opens or resizes", async () => {
const resize = vi.mocked(runtime.pageSizes.resize);
const holder = await open({ size: { width: 1400, height: 900 } });
await holder.action({ type: "take" });
const other = await open({ owner: "admin-b", size: { width: 400, height: 750 } });
expect(resize).toHaveBeenLastCalledWith("profile-a", { width: 1400, height: 900 }, true);
await other.action({ type: "resize", width: 420, height: 800 });
expect(resize).toHaveBeenLastCalledWith("profile-a", { width: 1400, height: 900 }, true);
await holder.action({ type: "resize", width: 1500, height: 900 });
expect(resize).toHaveBeenLastCalledWith("profile-a", { width: 1500, height: 900 }, false);
});
it("still opens the view when sizing the page fails", async () => {
vi.mocked(runtime.pageSizes.resize).mockRejectedValue(new Error("engine without viewport"));
const { res } = await open({ size: { width: 400, height: 750 } });
expect(res.id).toBeTruthy();
expect(res.events("ready")).toHaveLength(1);
});
it("opens no stream socket when the viewer closes while the browser is still starting", async () => {
let finish!: (value: ReturnType<typeof output>) => void;
execute.mockReset().mockResolvedValueOnce(output({ ...ready, connected: false })).mockImplementationOnce(() => new Promise((resolve) => finish = resolve));
const res = new ResponseFixture();
const opening = live.open({ botId: "a", session: "s", owner: "a", isCurrent: () => true, res: res as unknown as ServerResponse,
spec: { command: "/engine", env: { AGENT_BROWSER_SESSION: "s", AGENT_BROWSER_HEADLESS: "1" } } });
const rejected = expect(opening).rejects.toThrow();
await expect.poll(() => execute.mock.calls.length).toBe(2);
live.closeForOwner("a");
finish(output({}));
await rejected;
expect(SocketFixture.instances).toHaveLength(0);
});
it("lets two frames travel to its view unacknowledged, and the next only on the view's own ACK of a frame it was sent", async () => {
const { res, socket, action } = await open();
socket.receive(frame);
expect(res.events("frame")).toHaveLength(1);
expect(socket.messages).toEqual([{ type: "ack", seq: 1 }]);
socket.receive({ ...frame, seq: 2 });
expect(res.events("frame").map((event) => event.seq)).toEqual([1, 2]);
expect(socket.messages).toEqual([{ type: "ack", seq: 1 }]);
await action({ type: "ack", seq: 999 });
expect(socket.messages).toEqual([{ type: "ack", seq: 1 }]);
await action({ type: "ack", seq: 1 });
expect(socket.messages).toEqual([{ type: "ack", seq: 1 }, { type: "ack", seq: 2 }]);
socket.receive({ ...frame, seq: 3 });
socket.receive({ ...frame, seq: 4 });
expect(socket.messages).toHaveLength(2);
// An ACK of a later frame stands for the ones before it.
await action({ type: "ack", seq: 3 });
expect(socket.messages.at(-1)).toEqual({ type: "ack", seq: 4 });
await expect(live.action({ viewerId: res.id, botId: "bot-a", owner: "another-admin", body: { type: "take" } })).rejects.toThrow("no longer available");
await expect(live.action({ viewerId: res.id, botId: "other-bot", owner: "admin-a", body: { type: "take" } })).rejects.toThrow("no longer available");
});
it.each([1, 2])("accepts a rendered ACK for identical image bytes with frame sequence %s without timing out", async (seq) => {
vi.useFakeTimers();
const a = await open();
a.socket.receive(frame); await a.action({ type: "ack", seq: 1 });
await vi.advanceTimersByTimeAsync(15_000);
a.socket.receive({ ...frame, seq });
expect(a.res.events("frame").map((event) => event.seq)).toEqual([1, seq]);
await a.action({ type: "ack", seq });
await vi.advanceTimersByTimeAsync(30_000);
expect(a.res.writableEnded).toBe(false); expect(a.res.destroyed).toBe(false);
expect(a.socket.messages).toEqual([{ type: "ack", seq: 1 }, { type: "ack", seq }]);
});
it("keeps the newest frame until drain if the last rendered ACK arrives during SSE backpressure", async () => {
vi.useFakeTimers();
const a = await open();
a.res.backpressure = true;
a.socket.receive(frame);
await a.action({ type: "ack", seq: 1 });
a.socket.receive({ ...frame, seq: 2 });
a.socket.receive({ ...frame, seq: 3 });
await a.action({ type: "ack", seq: 2 }); // Not delivered, so cannot acknowledge it.
expect(a.res.events("frame").map((event) => event.seq)).toEqual([1]);
expect(a.socket.messages).toEqual([{ type: "ack", seq: 1 }]);
a.res.backpressure = false; a.res.emit("drain");
expect(a.res.events("frame").map((event) => event.seq)).toEqual([1, 3]);
await a.action({ type: "ack", seq: 3 });
await vi.advanceTimersByTimeAsync(30_000);
expect(a.res.writableEnded).toBe(false); expect(a.res.destroyed).toBe(false);
expect(a.socket.messages).toEqual([{ type: "ack", seq: 1 }, { type: "ack", seq: 3 }]);
});
it("tells its view how many inputs one request may carry", async () => {
const { res } = await open();
expect(res.events("ready")[0]).toMatchObject({ inputBatch: 64 });
});
it("sends a batch of inputs in order in one request, without the moves a later move replaces", async () => {
const a = await open();
await a.action({ type: "take" });
const move = (x: number) => ({ type: "input_mouse", eventType: "mouseMoved", x, y: 10 });
await a.action({ type: "input_batch", events: [
move(1), move(2), move(3),
{ type: "input_keyboard", eventType: "keyDown", key: "a", code: "KeyA", text: "a" },
{ type: "input_keyboard", eventType: "keyUp", key: "a", code: "KeyA" },
move(4), { type: "input_keyboard", eventType: "char", text: "bc" },
] });
const sent = nativeInput.mock.calls.map((call) => JSON.parse(call[1].body));
expect(sent.map((command) => [command.action, command.type ?? command.subaction, command.x ?? command.key ?? command.text])).toEqual([
["input_mouse", "mouseMoved", 3], ["input_keyboard", "keyDown", "a"], ["input_keyboard", "keyUp", "a"],
["input_mouse", "mouseMoved", 4], ["keyboard", "insertText", "bc"],
]);
});
it("goes on with a batch past an input the browser refused, and tells the person", async () => {
const a = await open();
await a.action({ type: "take" });
nativeInput.mockImplementation(async (_url: string, init: { body: string }) => Response.json({ success: !JSON.parse(init.body).text?.includes("b") }));
await expect(a.action({ type: "input_batch", events: [
{ type: "input_keyboard", eventType: "char", text: "a" },
{ type: "input_keyboard", eventType: "char", text: "b" },
{ type: "input_keyboard", eventType: "char", text: "c" },
] })).resolves.toEqual({ ok: true, notice: expect.stringContaining("did not take") });
expect(nativeInput.mock.calls.map((call) => JSON.parse(call[1].body).text)).toEqual(["a", "b", "b", "c"]);
});
it("stops a batch at the first release the browser does not confirm, and sends nothing after it", async () => {
const a = await open();
await a.action({ type: "take" });
await a.action({ type: "input_keyboard", eventType: "keyDown", key: "a", code: "KeyA" });
nativeInput.mockReset().mockImplementation(async (_url: string, init: { body: string }) => Response.json({ success: JSON.parse(init.body).type !== "keyUp" }));
await expect(a.action({ type: "input_batch", events: [
{ type: "input_keyboard", eventType: "char", text: "x" },
{ type: "input_keyboard", eventType: "keyUp", key: "a", code: "KeyA" },
{ type: "input_keyboard", eventType: "char", text: "y" },
] })).rejects.toThrow();
expect(nativeInput.mock.calls.map((call) => JSON.parse(call[1].body).text ?? JSON.parse(call[1].body).type)).toEqual(["x", "keyUp", "keyUp"]);
});
it.each([
["no inputs", []],
["too many inputs", Array.from({ length: 65 }, () => ({ type: "input_keyboard", eventType: "char", text: "a" }))],
["a command", [{ type: "navigate", url: "https://example.com" }]],
["a batch inside a batch", [{ type: "input_batch", events: [{ type: "input_keyboard", eventType: "char", text: "a" }] }]],
["a malformed input", [{ type: "input_mouse", eventType: "mouseMoved", x: -1, y: 0 }]],
])("refuses a batch with %s", (_name, events) => {
expect(() => parseBrowserLiveAction({ type: "input_batch", events })).toThrow("Invalid browser action.");
});
it("requires the person's own human lease and releases only theirs on disconnect", async () => {
const a = await open(); const b = await open({ botId: "bot-b", owner: "admin-b" });
await expect(a.action({ type: "navigate", url: "https://example.com" })).rejects.toThrow("Browser control changed");
await a.action({ type: "take" });
await expect(b.action({ type: "take" })).rejects.toThrow("Another window or device controls this browser. Hand control back there");
await expect(b.action({ type: "back" })).rejects.toThrow("Another window or device controls this browser");
await b.action({ type: "release" });
b.socket.close();
expect(held.get("profile-a")).toBe("admin-a");
await a.action({ type: "input_keyboard", eventType: "keyDown", key: "a", text: "a" });
expect(nativeInput.mock.calls[0]?.[0]).toBe("http://127.0.0.1:43210/api/command");
expect(nativeInput.mock.calls[0]?.[1]).toMatchObject({ method: "POST", redirect: "error", headers: { Origin: "http://127.0.0.1:43210" } });
expect(JSON.parse(nativeInput.mock.calls[0]?.[1].body)).toEqual({ action: "input_keyboard", type: "keyDown", key: "a", text: "a" });
expect(a.socket.messages).toHaveLength(0);
a.socket.close(); expect(held.has("profile-a")).toBe(false);
expect(execute).toHaveBeenCalledTimes(2); // No close command: logins/browser survive viewers.
});
it("withholds login frames/tabs/URLs from another person's views, advances their upstream ACK and restores the newest frame on release", async () => {
const a = await open(); const b = await open({ botId: "bot-b", owner: "admin-b" });
await a.action({ type: "take" });
a.socket.receive(frame); b.socket.receive(frame);
b.socket.receive({ type: "url", url: "https://private.example/login" });
b.socket.receive({ type: "tabs", tabs: [] });
expect(a.res.events("frame")).toHaveLength(1);
expect(b.res.events("frame")).toHaveLength(0);
expect(b.res.events("tabs")).toHaveLength(0);
expect(b.res.events("url")).toHaveLength(0);
expect(b.socket.messages).toEqual([{ type: "ack", seq: 1 }]);
expect(b.res.events("control").at(-1)).toEqual({ held: true, owned: false, controlling: false });
await a.action({ type: "release" });
expect(b.res.events("frame")).toHaveLength(1);
});
it("closes stale authenticated viewers, slow readers and viewers that never acknowledge frames", async () => {
vi.useFakeTimers();
let current = true;
const stale = await open({ current: () => current });
current = false;
await vi.advanceTimersByTimeAsync(10_000);
expect(stale.res.writableEnded).toBe(true);
const slow = await open(); slow.res.backpressure = true; slow.socket.receive(frame);
await vi.advanceTimersByTimeAsync(10_000);
expect(slow.res.destroyed).toBe(true);
const noAck = await open(); noAck.socket.receive(frame);
await vi.advanceTimersByTimeAsync(30_000);
expect(noAck.res.writableEnded).toBe(true);
});
it("caps views per profile and closes all associated viewers on profile deletion", async () => {
const a = await open(); const b = await open({ botId: "b" });
await expect(open({ botId: "c" })).rejects.toThrow("Too many");
live.closeForSession("profile-a");
expect(a.res.writableEnded).toBe(true); expect(b.res.writableEnded).toBe(true);
await expect(a.action({ type: "take" })).rejects.toThrow("no longer available");
});
it("closing one bot's view does not close peers that share its profile", async () => {
const a = await open(); const b = await open({ botId: "bot-b" });
await b.action({ type: "take" });
live.closeForBot("bot-a");
expect(a.res.writableEnded).toBe(true);
expect(b.res.writableEnded).toBe(false);
expect(held.get("profile-a")).toBe("admin-a");
expect(b.res.events("control").at(-1)).toEqual({ held: true, owned: true, controlling: true });
});
it("sends a typed password to the page only: never logged, echoed or sent to another view", async () => {
const secret = "hunter2-correct-horse";
const logs = (["log", "info", "warn", "error", "debug"] as const).map((level) => vi.spyOn(console, level).mockImplementation(() => {}));
const a = await open(); const b = await open({ botId: "bot-b" });
await a.action({ type: "take" });
const reply = await a.action({ type: "input_keyboard", eventType: "char", text: secret });
expect(reply).toEqual({ ok: true });
expect(nativeInput.mock.calls.map((call) => JSON.parse(call[1].body))).toEqual([{ action: "keyboard", subaction: "insertText", text: secret }]);
nativeInput.mockImplementationOnce(async () => Response.json({ success: false, error: `bad input ${secret}` }));
const failure = await a.action({ type: "input_keyboard", eventType: "char", text: secret }).catch((error: Error) => error);
expect(String(failure)).not.toContain(secret);
for (const log of logs) expect(JSON.stringify(log.mock.calls)).not.toContain(secret);
expect([...a.res.chunks, ...b.res.chunks].join("")).not.toContain(secret);
expect(JSON.stringify([a.socket.messages, b.socket.messages])).not.toContain(secret);
});
it("hands back whichever view holds the browser when its sign-in card is answered elsewhere", async () => {
const a = await open(); const b = await open({ botId: "bot-b" });
await a.action({ type: "take" });
live.handBack("profile-b");
expect(held.get("profile-a")).toBe("admin-a");
live.handBack("profile-a");
expect(held.has("profile-a")).toBe(false);
expect(a.res.events("control").at(-1)).toEqual({ held: false, owned: false, controlling: false });
expect(b.res.events("control").at(-1)).toEqual({ held: false, owned: false, controlling: false });
expect(a.res.writableEnded).toBe(false);
await a.action({ type: "take" });
expect(held.get("profile-a")).toBe("admin-a");
});
it("inserts pasted text literally and preserves modifier shortcuts via native acknowledged actions", async () => {
const a = await open(); await a.action({ type: "take" });
await a.action({ type: "input_keyboard", eventType: "char", text: "--cdp=secret\nhello" });
await a.action({ type: "input_keyboard", eventType: "keyDown", key: "a", modifiers: 2 });
await a.action({ type: "input_keyboard", eventType: "keyUp", key: "a", modifiers: 2 });
expect(nativeInput.mock.calls.map((call) => JSON.parse(call[1].body))).toEqual([
{ action: "keyboard", subaction: "insertText", text: "--cdp=secret\nhello" }, { action: "press", key: "Control+a" },
]);
});
it.each(["Backspace", "Enter", "Tab", "Escape", "Delete", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown"])("uses an acknowledged native press for %s without a duplicate key release", async (key) => {
const a = await open(); await a.action({ type: "take" });
await a.action({ type: "input_keyboard", eventType: "keyDown", key, code: key });
await a.action({ type: "input_keyboard", eventType: "keyUp", key, code: key });
expect(nativeInput.mock.calls.map(([, options]) => JSON.parse(options.body))).toEqual([{ action: "press", key }]);
await a.action({ type: "release" });
expect(runtime.abandonHumanInput).not.toHaveBeenCalled();
});
it.each([
{ key: "a", code: "KeyA", modifiers: 2, chord: "Control+a" },
{ key: "a", code: "KeyA", modifiers: 4, chord: "Meta+a" },
{ key: "Tab", code: "Tab", modifiers: 8, chord: "Shift+Tab" },
{ key: "ArrowLeft", code: "ArrowLeft", modifiers: 3, chord: "Alt+Control+ArrowLeft" },
])("does not resend $chord when its modifiers change before keyUp", async ({ key, code, modifiers, chord }) => {
const a = await open(); await a.action({ type: "take" });
await a.action({ type: "input_keyboard", eventType: "keyDown", key, code, modifiers });
await a.action({ type: "input_keyboard", eventType: "keyUp", key, code, modifiers: 0 });
expect(nativeInput.mock.calls.map(([, options]) => JSON.parse(options.body))).toEqual([{ action: "press", key: chord }]);
});
it("preserves printable text and releases raw held keys even if modifiers change", async () => {
const a = await open(); await a.action({ type: "take" });
await a.action({ type: "input_keyboard", eventType: "keyDown", key: "+", code: "Equal", text: "+", modifiers: 8 });
await a.action({ type: "input_keyboard", eventType: "keyUp", key: "=", code: "Equal", modifiers: 2 });
await a.action({ type: "input_keyboard", eventType: "keyDown", key: "a", code: "KeyA", text: "a" });
await a.action({ type: "input_keyboard", eventType: "keyUp", key: "a", code: "KeyA" });
expect(nativeInput.mock.calls.map(([, options]) => JSON.parse(options.body))).toEqual([
{ action: "input_keyboard", type: "keyDown", key: "+", code: "Equal", text: "+" },
{ action: "input_keyboard", type: "keyUp", key: "=", code: "Equal" },
{ action: "input_keyboard", type: "keyDown", key: "a", code: "KeyA", text: "a" },
{ action: "input_keyboard", type: "keyUp", key: "a", code: "KeyA" },
]);
await a.action({ type: "release" });
expect(runtime.abandonHumanInput).not.toHaveBeenCalled();
});
it("keeps unknown raw key names literal instead of interpreting them as chords or commands", async () => {
const a = await open(); await a.action({ type: "take" });
await a.action({ type: "input_keyboard", eventType: "keyDown", key: "Control+a", command: "eval", script: "private" });
expect(nativeInput.mock.calls.map(([, options]) => JSON.parse(options.body))).toEqual([
{ action: "input_keyboard", type: "keyDown", key: "Control+a" },
]);
});
it("handles repeated discrete keyDowns once each and does not mark them held", async () => {
const a = await open(); await a.action({ type: "take" });
await a.action({ type: "input_keyboard", eventType: "keyDown", key: "Backspace", code: "Backspace" });
await a.action({ type: "input_keyboard", eventType: "keyDown", key: "Backspace", code: "Backspace" });
await a.action({ type: "release" });
expect(nativeInput.mock.calls.map(([, options]) => JSON.parse(options.body))).toEqual([
{ action: "press", key: "Backspace" }, { action: "press", key: "Backspace" },
]);
expect(runtime.abandonHumanInput).not.toHaveBeenCalled();
});
it("clears a formerly raw held key after an acknowledged repeat becomes a shortcut", async () => {
const a = await open(); await a.action({ type: "take" });
await a.action({ type: "input_keyboard", eventType: "keyDown", key: "a", code: "KeyA", text: "a" });
await a.action({ type: "input_keyboard", eventType: "keyDown", key: "a", code: "KeyA", modifiers: 2 });
await a.action({ type: "input_keyboard", eventType: "keyUp", key: "a", code: "KeyA", modifiers: 0 });
await a.action({ type: "release" });
expect(nativeInput.mock.calls.map(([, options]) => JSON.parse(options.body))).toEqual([
{ action: "input_keyboard", type: "keyDown", key: "a", code: "KeyA", text: "a" }, { action: "press", key: "Control+a" },
]);
expect(runtime.abandonHumanInput).not.toHaveBeenCalled();
});
it("closes the browser a view only watches once it sat unused, first telling the view it closed for good so it does not reconnect", async () => {
const heard: unknown[] = [];
const closeBrowser = vi.fn(async () => { heard.push(...a.res.events("error")); a.socket.close(); return true; });
runtime = new BrowserRuntime({ closeBrowser }); live = new BrowserLive({ runtime });
const a = await open();
const b = await open({ session: "profile-b" });
expect(await runtime.closeIfIdle("profile-a", 10)).toBe(false);
await new Promise((resolve) => setTimeout(resolve, 15));
expect(await runtime.closeIfIdle("profile-a", 10)).toBe(true);
expect(closeBrowser).toHaveBeenCalledWith("profile-a", expect.objectContaining({ command: "/trusted/agent-browser" }));
expect(heard).toEqual([{ retryable: false, asleep: true, message: expect.stringContaining("sat unused") }]);
expect(a.res.events("error")).toHaveLength(1);
expect(a.res.writableEnded).toBe(true);
expect(b.res.writableEnded).toBe(false);
expect(b.res.events("error")).toEqual([]);
});
it("counts a person's input through the view as use, and never closes a browser they hold", async () => {
runtime = new BrowserRuntime({ closeBrowser: async () => true }); live = new BrowserLive({ runtime });
const a = await open();
await a.action({ type: "take" });
await new Promise((resolve) => setTimeout(resolve, 15));
expect(await runtime.closeIfIdle("profile-a", 10)).toBe(false);
await a.action({ type: "input_mouse", eventType: "mouseMoved", x: 5, y: 5 });
await a.action({ type: "release" });
expect(await runtime.closeIfIdle("profile-a", 10)).toBe(false);
await new Promise((resolve) => setTimeout(resolve, 15));
expect(await runtime.closeIfIdle("profile-a", 10)).toBe(true);
expect(a.res.writableEnded).toBe(true);
});
it("waits out an idle close in progress before starting a view", async () => {
let finish!: (closed: boolean) => void;
runtime = new BrowserRuntime({ closeBrowser: () => new Promise<boolean>((resolve) => { finish = resolve; }) }); live = new BrowserLive({ runtime });
const first = await open();
first.socket.close();
await new Promise((resolve) => setTimeout(resolve, 15));
const closing = runtime.closeIfIdle("profile-a", 10);
execute.mockClear();
const opening = open();
await new Promise((resolve) => setTimeout(resolve, 15));
expect(execute).not.toHaveBeenCalled();
finish(true);
expect(await closing).toBe(true);
await opening;
expect(execute.mock.calls[0]![1]).toEqual(["stream", "status", "--json", "--no-webmcp"]);
});
it("still requires restart when a held modifier has not been released after a chord", async () => {
runtime = new BrowserRuntime(); live = new BrowserLive({ runtime });
const a = await open(); await a.action({ type: "take" });
await a.action({ type: "input_keyboard", eventType: "keyDown", key: "Shift", code: "ShiftLeft", modifiers: 8 });
await a.action({ type: "input_keyboard", eventType: "keyDown", key: "Tab", code: "Tab", modifiers: 8 });
await a.action({ type: "input_keyboard", eventType: "keyUp", key: "Tab", code: "Tab", modifiers: 8 });
await a.action({ type: "release" });
await expect(runtime.withAgentAction("profile-a", async () => true)).rejects.toThrow("Restart");
});
it("restarts the browser for the person when a discrete key press is not confirmed, keeping its profile, and hides raw failures", async () => {
runtime = new BrowserRuntime(); live = new BrowserLive({ runtime });
const a = await open(); await a.action({ type: "take" });
nativeInput.mockImplementationOnce(async () => Response.json({ success: false, error: "password=private" }));
const failure = await a.action({ type: "input_keyboard", eventType: "keyDown", key: "Enter" }).catch((error: Error) => error);
expect(failure).toMatchObject({ message: "The page stopped responding, so the browser was restarted. Your sign-ins are kept; open the page again." });
expect(String(failure)).not.toContain("private");
expect(JSON.parse(nativeInput.mock.calls[0]![1].body)).toEqual({ action: "press", key: "Enter" });
expect(nativeClose).toHaveBeenCalledOnce();
expect(a.res.events("error").at(-1)).toMatchObject({ retryable: true, message: expect.stringContaining("restarted") });
expect(a.res.writableEnded).toBe(true);
await expect(runtime.withAgentAction("profile-a", async () => true)).resolves.toBe(true);
});
it("points an interrupted browser it could not restart at Restart browser…, never at a take-control button", async () => {
runtime = new BrowserRuntime(); live = new BrowserLive({ runtime });
const a = await open(); await a.action({ type: "take" });
nativeClose.mockResolvedValue(false);
nativeInput.mockImplementationOnce(async () => Response.json({ success: false }));
await expect(a.action({ type: "input_keyboard", eventType: "keyDown", key: "Enter" })).rejects.toThrow("could not confirm this input");
await expect(a.action({ type: "back" })).rejects.toThrow("Choose Restart browser… in the browser menu");
await a.action({ type: "release" });
await expect(a.action({ type: "take" })).rejects.toThrow("Choose Restart browser… in the browser menu");
});
it("sends a single input event the browser refused once more, then tells the person it did not take it, without a restart", async () => {
runtime = new BrowserRuntime(); live = new BrowserLive({ runtime });
const a = await open(); await a.action({ type: "take" });
const click = { type: "input_mouse", eventType: "mousePressed", x: 10, y: 20, button: "left", clickCount: 1 };
nativeInput.mockImplementationOnce(async () => Response.json({ success: false }));
await expect(a.action(click)).resolves.toEqual({ ok: true });
expect(nativeInput.mock.calls.map(([, init]) => JSON.parse(init.body).type)).toEqual(["mousePressed", "mousePressed"]);
expect(runtime.interrupted("profile-a")).toBe(false);
await a.action({ ...click, eventType: "mouseReleased" });
nativeInput.mockImplementationOnce(async () => Response.json({ success: false })).mockImplementationOnce(async () => Response.json({ success: false }));
await expect(a.action({ type: "input_keyboard", eventType: "char", text: "ada" })).resolves.toEqual({ ok: true, notice: expect.stringContaining("did not take") });
expect(runtime.interrupted("profile-a")).toBe(false);
// A refused key-down never counts as held.
nativeInput.mockImplementationOnce(async () => Response.json({ success: false })).mockImplementationOnce(async () => Response.json({ success: false }));
await a.action({ type: "input_keyboard", eventType: "keyDown", key: "a", code: "KeyA" });
const sent = nativeInput.mock.calls.length;
await a.action({ type: "input_keyboard", eventType: "keyUp", key: "a", code: "KeyA" });
expect(nativeInput.mock.calls).toHaveLength(sent);
expect(nativeClose).not.toHaveBeenCalled();
});
it("settles an input or command the browser was slow to take once a read behind it answers, without a restart", async () => {
runtime = new BrowserRuntime(); live = new BrowserLive({ runtime });
const a = await open(); await a.action({ type: "take" });
nativeInput.mockImplementationOnce(async () => { throw new Error("relay timed out"); });
await expect(a.action({ type: "input_keyboard", eventType: "char", text: "ada" })).rejects.toMatchObject({ settled: true, message: expect.stringContaining("slow to take") });
expect(JSON.parse(nativeInput.mock.calls[1]![1].body)).toEqual({ action: "stream_status" });
expect(runtime.interrupted("profile-a")).toBe(false);
execute.mockRejectedValueOnce(Object.assign(new Error("Command failed"), { killed: true, signal: "SIGKILL", code: null, stdout: "", stderr: "" }));
await expect(a.action({ type: "reload" })).rejects.toMatchObject({ settled: true, message: expect.stringContaining("slow to take") });
expect(runtime.interrupted("profile-a")).toBe(false);
// A slow release is sent once more, so no key is left held for the bot.
await a.action({ type: "input_keyboard", eventType: "keyDown", key: "a", code: "KeyA" });
nativeInput.mockImplementationOnce(async () => { throw new Error("relay timed out"); });
await expect(a.action({ type: "input_keyboard", eventType: "keyUp", key: "a", code: "KeyA" })).resolves.toEqual({ ok: true });
await a.action({ type: "release" });
await expect(runtime.withAgentAction("profile-a", async () => true)).resolves.toBe(true);
// No answer to the read either: the browser is restarted for the person.
await a.action({ type: "take" });
nativeInput.mockImplementationOnce(async () => { throw new Error("relay timed out"); }).mockImplementationOnce(async () => { throw new Error("relay timed out"); });
await expect(a.action({ type: "input_keyboard", eventType: "char", text: "x" })).rejects.toThrow("so the browser was restarted");
expect(nativeClose).toHaveBeenCalledOnce();
expect(runtime.interrupted("profile-a")).toBe(false);
});
describe("the page's own dialogs", () => {
const URL = "ws://127.0.0.1:9222/devtools/browser/abc-123";
function devTools() {
const sent: Array<{ method: string; params?: Record<string, unknown>; sessionId?: string }> = [];
let listeners: CdpListeners = {};
let answered!: () => void;
const answer = new Promise<void>((resolve) => { answered = resolve; });
const cdp: Cdp = {
send: vi.fn(async (method: string, params?: Record<string, unknown>, sessionId?: string) => {
sent.push({ method, params, sessionId });
if (method === "Page.handleJavaScriptDialog") { answered(); listeners.event?.({ method: "Page.javascriptDialogClosed", params: {}, sessionId }); }
return {};
}),
close: vi.fn(),
};
const url = vi.fn(async () => URL);
const connect = vi.fn(async (_url: string, _timeout: number, given: CdpListeners) => { listeners = given; return cdp; });
const emit = (method: string, params: Record<string, unknown>, sessionId?: string) => listeners.event?.({ method, params, sessionId });
return { sent, cdp, url, connect, emit, answer };
}
const confirm = { type: "confirm", message: "Leave this page?", defaultPrompt: "" };
it("shows the page's question to the view holding the browser, not to another, and its answer lets the input it held through", async () => {
const tools = devTools();
live = new BrowserLive({ runtime, devTools: { url: tools.url, connect: tools.connect } });
const a = await open(); const b = await open({ botId: "bot-b", owner: "admin-b" });
expect(tools.connect).toHaveBeenCalledOnce();
expect(tools.connect.mock.calls[0]![0]).toBe(URL);
expect(tools.sent[0]).toMatchObject({ method: "Target.setAutoAttach", params: { autoAttach: true, flatten: true } });
tools.emit("Target.attachedToTarget", { sessionId: "page-1", targetInfo: { type: "page" } });
expect(tools.sent.at(-1)).toEqual({ method: "Page.enable", params: {}, sessionId: "page-1" });
await a.action({ type: "take" });
nativeInput.mockImplementation(async () => { await tools.answer; return Response.json({ success: true }); });
const click = a.action({ type: "input_mouse", eventType: "mouseReleased", x: 5, y: 5, button: "left", clickCount: 1 });
tools.emit("Page.javascriptDialogOpening", confirm, "page-1");
expect(a.res.events("control").at(-1)).toMatchObject({ owned: true, dialog: { type: "confirm", message: "Leave this page?", defaultValue: "" } });
expect(b.res.events("control").at(-1)).not.toHaveProperty("dialog");
await expect(b.action({ type: "dialog", accept: true })).rejects.toThrow();
await expect(a.action({ type: "dialog", accept: false })).resolves.toEqual({ ok: true });
expect(tools.sent.at(-1)).toEqual({ method: "Page.handleJavaScriptDialog", params: { accept: false }, sessionId: "page-1" });
await expect(click).resolves.toEqual({ ok: true });
expect(a.res.events("control").at(-1)).not.toHaveProperty("dialog");
await expect(a.action({ type: "dialog", accept: true })).rejects.toThrow("no longer open");
});
it("waits for the person's answer when the browser stops waiting for the input the question holds, and needs no restart", async () => {
const tools = devTools();
runtime = new BrowserRuntime(); live = new BrowserLive({ runtime, devTools: { url: tools.url, connect: tools.connect } });
const a = await open();
tools.emit("Target.attachedToTarget", { sessionId: "page-1", targetInfo: { type: "page" } });
await a.action({ type: "take" });
nativeInput.mockImplementationOnce(async () => { tools.emit("Page.javascriptDialogOpening", { ...confirm, type: "prompt", defaultPrompt: "Ada" }, "page-1"); throw new Error("relay timed out"); });
const typed = a.action({ type: "input_keyboard", eventType: "keyDown", key: "a", code: "KeyA", text: "a" });
await vi.waitFor(() => expect(a.res.events("control").at(-1)).toMatchObject({ dialog: { type: "prompt", defaultValue: "Ada" } }));
await a.action({ type: "dialog", accept: true, promptText: "Grace" });
expect(tools.sent.at(-1)).toMatchObject({ params: { accept: true, promptText: "Grace" } });
await expect(typed).resolves.toEqual({ ok: true });
expect(runtime.interrupted("profile-a")).toBe(false);
expect(nativeClose).not.toHaveBeenCalled();
});
it("watches nothing in the person's own Chrome, and stops watching with the last view", async () => {
const mine = devTools();
live = new BrowserLive({ runtime, devTools: { url: mine.url, connect: mine.connect } });
const res = new ResponseFixture();
await live.open({ botId: "bot-a", session: "mine", owner: "admin-a", isCurrent: () => true, res: res as unknown as ServerResponse,
spec: { command: "/trusted/agent-browser", env: { AGENT_BROWSER_SESSION: "mine", AGENT_BROWSER_AUTO_CONNECT: "1" } } });
expect(mine.url).not.toHaveBeenCalled();
const tools = devTools();
live = new BrowserLive({ runtime, devTools: { url: tools.url, connect: tools.connect } });
const a = await open(); const b = await open({ botId: "bot-b" });
a.socket.close();
expect(tools.cdp.close).not.toHaveBeenCalled();
b.socket.close();
expect(tools.cdp.close).toHaveBeenCalledOnce();
});
it("never connects to a DevTools address off this machine", async () => {
const tools = devTools();
tools.url.mockResolvedValue("ws://browser.example.net:9222/devtools/browser/x");
live = new BrowserLive({ runtime, devTools: { url: tools.url, connect: tools.connect } });
await open();
expect(tools.connect).not.toHaveBeenCalled();
});
it.each([
[{ type: "dialog" }], [{ type: "dialog", accept: "yes" }], [{ type: "dialog", accept: true, promptText: "x".repeat(4_097) }],
])("refuses a malformed answer %j", (body) => {
expect(() => parseBrowserLiveAction(body)).toThrow("Invalid browser action.");
});
});
it("tells the panel when a take had to wait for a bot command", async () => {
runtime = new BrowserRuntime(); live = new BrowserLive({ runtime });
const a = await open();
expect(await a.action({ type: "take" })).toEqual({ ok: true, waited: false });
await a.action({ type: "release" });
// A frame, not a bot command, held the browser: the person's input stands.
let finish!: () => void;
const frame = runtime.previewFrame("profile-a", () => new Promise<boolean>((resolve) => { finish = () => resolve(true); }));
const discarded = expect(frame).rejects.toThrow("paused");
const taking = a.action({ type: "take" });
finish(); await discarded;
expect(await taking).toEqual({ ok: true, waited: false });
await a.action({ type: "release" });
vi.spyOn(runtime, "take").mockResolvedValueOnce(true);
expect(await a.action({ type: "take" })).toEqual({ ok: true, waited: true });
});
it("releases Shift-only printable keys even when keyUp contains no text", async () => {
const a = await open(); await a.action({ type: "take" });
await a.action({ type: "input_keyboard", eventType: "keyDown", key: "A", code: "KeyA", text: "A", modifiers: 8 });
await a.action({ type: "input_keyboard", eventType: "keyUp", key: "A", code: "KeyA", modifiers: 8 });
expect(nativeInput.mock.calls.map(([, options]) => JSON.parse(options.body))).toEqual([
{ action: "input_keyboard", type: "keyDown", key: "A", code: "KeyA", text: "A" },
{ action: "input_keyboard", type: "keyUp", key: "A", code: "KeyA" },
]);
});
it.each(["release", "disconnect"])("requires restart if %s leaves a native key or button held", async (ending) => {
runtime = new BrowserRuntime(); live = new BrowserLive({ runtime });
const a = await open(); await a.action({ type: "take" });
await a.action({ type: "input_keyboard", eventType: "keyDown", key: "Shift", code: "ShiftLeft", modifiers: 8 });
await a.action({ type: "input_mouse", eventType: "mousePressed", x: 12, y: 20, button: "left" });
if (ending === "release") await a.action({ type: "release" }); else a.socket.close();
await expect(runtime.withAgentAction("profile-a", async () => true)).rejects.toThrow("Restart");
const b = ending === "release" ? a : await open();
await b.action({ type: "restart" });
await expect(runtime.withAgentAction("profile-a", async () => true)).resolves.toBe(true);
});
it("hands back normally after matching key and mouse releases are confirmed", async () => {
runtime = new BrowserRuntime(); live = new BrowserLive({ runtime });
const a = await open(); await a.action({ type: "take" });
await a.action({ type: "input_keyboard", eventType: "keyDown", key: "Shift", code: "ShiftLeft", modifiers: 8 });
await a.action({ type: "input_mouse", eventType: "mousePressed", x: 12, y: 20, button: "left" });
await a.action({ type: "input_keyboard", eventType: "keyUp", key: "Shift", code: "ShiftLeft", modifiers: 0 });
await a.action({ type: "input_mouse", eventType: "mouseReleased", x: 12, y: 20, button: "left" });
await a.action({ type: "release" });
await expect(runtime.withAgentAction("profile-a", async () => true)).resolves.toBe(true);
});
it.each(["release", "disconnect"].flatMap((ending) => [
{ ending, name: "pasted text", body: { type: "input_keyboard", eventType: "char", text: "private" } },
{ ending, name: "discrete press", body: { type: "input_keyboard", eventType: "keyDown", key: "Enter" } },
]))("does not admit bot work on $ending until native $name is confirmed", async ({ ending, body }) => {
runtime = new BrowserRuntime(); live = new BrowserLive({ runtime });
const a = await open(); await a.action({ type: "take" });
let finish!: (value: Response) => void;
nativeInput.mockImplementationOnce(() => new Promise<Response>((resolve) => { finish = resolve; }));
const input = a.action(body);
await vi.waitFor(() => expect(nativeInput).toHaveBeenCalled());
if (ending === "release") await a.action({ type: "release" }); else a.socket.close();
await expect(runtime.withAgentAction("profile-a", async () => true)).rejects.toThrow("paused");
finish(Response.json({ success: true })); await input;
await expect(runtime.withAgentAction("profile-a", async () => true)).resolves.toBe(true);
});
it("restarts only through exclusive recovery and closes all views after native close succeeds", async () => {
runtime = new BrowserRuntime(); live = new BrowserLive({ runtime });
const a = await open(); const b = await open({ botId: "bot-b" });
nativeClose.mockImplementationOnce(async (file, env) => {
expect(file).toBe("/trusted/agent-browser");
expect(env.AGENT_BROWSER_SESSION).toBe("profile-a");
a.socket.close(); b.socket.close();
await expect(runtime.withAgentAction("profile-a", async () => true)).rejects.toThrow();
return true;
});
await a.action({ type: "restart" });
expect(a.res.writableEnded).toBe(true); expect(b.res.writableEnded).toBe(true);
await expect(runtime.withAgentAction("profile-a", async () => true)).resolves.toBe(true);
});
it("does not clear uncertain input or other owners when native restart fails", async () => {
runtime = new BrowserRuntime(); live = new BrowserLive({ runtime });
const a = await open(); const b = await open({ botId: "bot-b", owner: "admin-b" });
await a.action({ type: "take" });
await expect(b.action({ type: "restart" })).rejects.toThrow("could not restart");
nativeClose.mockResolvedValueOnce(false);
await expect(a.action({ type: "restart" })).rejects.toThrow("could not restart");
expect(runtime.heldBy("profile-a")).toBe("admin-a");
expect(runtime.canControl("profile-a", "admin-a")).toBe(false);
await expect(runtime.withAgentAction("profile-a", async () => true)).rejects.toThrow();
await a.action({ type: "restart" });
await expect(runtime.withAgentAction("profile-a", async () => true)).resolves.toBe(true);
});
});
describe("a person's view whose stream drops while they hold the browser", () => {
const bodies = () => nativeInput.mock.calls.map(([, init]) => JSON.parse(init.body));
const botWorks = () => runtime.withAgentAction("profile-a", async () => true);
beforeEach(() => { runtime = new BrowserRuntime(); live = new BrowserLive({ runtime }); });
it("keeps their control for the same panel's reconnect, takes its input there, and hands back afterwards", async () => {
const a = await open(); await a.action({ type: "take" });
await a.action({ type: "input_keyboard", eventType: "char", text: "person@example.test" });
// The proxy logs the client's live stream as "context canceled".
a.res.destroy();
await expect(botWorks()).rejects.toThrow("paused while a person controls");
const b = await open({ resume: a.res.id });
expect(b.res.events("control").at(-1)).toEqual({ held: true, owned: true, controlling: true });
nativeInput.mockClear();
await b.action({ type: "input_keyboard", eventType: "char", text: "typed after reconnecting" });
expect(bodies()).toEqual([{ action: "keyboard", subaction: "insertText", text: "typed after reconnecting" }]);
await b.action({ type: "release" });
await expect(botWorks()).resolves.toBe(true);
expect(runtime.interrupted("profile-a")).toBe(false);
});
it("gives the hold to the same person's next view, which can hand it back, though it never named the view that dropped", async () => {
const phone = await open(); await phone.action({ type: "take" });
// The phone leaves the browser, or the app is closed: its stream ends.
phone.res.destroy();
const reopened = await open();
expect(reopened.res.events("control").at(-1)).toEqual({ held: true, owned: true, controlling: true });
await reopened.action({ type: "release" });
await expect(botWorks()).resolves.toBe(true);
});
it("lets go of a key and button it held in the page instead of asking for a restart", async () => {
const a = await open(); await a.action({ type: "take" });
// Alt+Tab to another app: Alt went down here, its key-up never reached the server.
await a.action({ type: "input_keyboard", eventType: "keyDown", key: "Alt", code: "AltLeft", modifiers: 1 });
await a.action({ type: "input_mouse", eventType: "mousePressed", x: 12, y: 20, button: "left", clickCount: 1 });
nativeInput.mockClear();
a.res.destroy();
await vi.waitFor(() => expect(bodies()).toHaveLength(2));
expect(bodies()).toEqual([
{ action: "input_keyboard", type: "keyUp", key: "Alt", code: "AltLeft" },
{ action: "input_mouse", type: "mouseReleased", x: 12, y: 20, button: "left", clickCount: 1, modifiers: 0, deltaX: 0, deltaY: 0 },
]);
const b = await open({ resume: a.res.id });
expect(b.res.events("control").at(-1)).toEqual({ held: true, owned: true, controlling: true });
await b.action({ type: "release" });
await expect(botWorks()).resolves.toBe(true);
});
it("keeps the restart barrier when the browser does not confirm that release", async () => {
const a = await open(); await a.action({ type: "take" });
await a.action({ type: "input_keyboard", eventType: "keyDown", key: "Shift", code: "ShiftLeft", modifiers: 8 });
nativeInput.mockRejectedValue(new Error("relay closed"));
a.res.destroy();
await vi.waitFor(() => expect(runtime.interrupted("profile-a")).toBe(true));
const b = await open({ resume: a.res.id });
await expect(b.action({ type: "take" })).rejects.toThrow("Choose Restart browser…");
await b.action({ type: "release" });
await expect(botWorks()).rejects.toThrow("Restart");
});
it("finishes an input that was on its way before letting go of what it pressed", async () => {
const a = await open(); await a.action({ type: "take" });
let finish!: (value: Response) => void;
nativeInput.mockImplementationOnce(() => new Promise<Response>((resolve) => { finish = resolve; }));
const typing = a.action({ type: "input_keyboard", eventType: "keyDown", key: "m", code: "KeyM", text: "m" });
await vi.waitFor(() => expect(nativeInput).toHaveBeenCalledOnce());
a.res.destroy();
const reopening = open({ resume: a.res.id });
await Promise.resolve();
expect(bodies()).toHaveLength(1);
finish(Response.json({ success: true })); await typing;
const b = await reopening;
expect(bodies().at(-1)).toEqual({ action: "input_keyboard", type: "keyUp", key: "m", code: "KeyM" });
expect(b.res.events("control").at(-1)).toEqual({ held: true, owned: true, controlling: true });
});
it("hands back when the sign-in card's Done is pressed before the panel reconnects", async () => {
const a = await open(); await a.action({ type: "take" });
a.res.destroy();
live.handBack("profile-a");
await expect.poll(() => runtime.heldBy("profile-a")).toBeNull();
await expect(botWorks()).resolves.toBe(true);
const b = await open({ resume: a.res.id });
expect(b.res.events("control").at(-1)).toEqual({ held: false, owned: false, controlling: false });
});
it("hands back a late hand-back from the dropped view itself", async () => {
const a = await open(); await a.action({ type: "take" });
a.res.destroy();
await expect(a.action({ type: "release" })).resolves.toEqual({ ok: true });
await expect.poll(() => runtime.heldBy("profile-a")).toBeNull();
await expect(a.action({ type: "take" })).rejects.toThrow("no longer available");
});
it("hands back by itself when the panel does not come back", async () => {
vi.useFakeTimers();
const a = await open(); await a.action({ type: "take" });
a.res.destroy();
await vi.advanceTimersByTimeAsync(110_000);
expect(runtime.heldBy("profile-a")).not.toBeNull();
await vi.advanceTimersByTimeAsync(10_000);
expect(runtime.heldBy("profile-a")).toBeNull();
await expect(botWorks()).resolves.toBe(true);
});
it("gives the hold to no other person, even one naming the view that dropped", async () => {
const a = await open(); await a.action({ type: "take" });
a.res.destroy();
const stranger = await open({ owner: "other", resume: a.res.id });
expect(stranger.res.events("control").at(-1)).toEqual({ held: true, owned: false, controlling: false });
await expect(stranger.action({ type: "take" })).rejects.toThrow("Another window or device controls this browser");
await expect(stranger.action({ type: "release" })).resolves.toEqual({ ok: true });
await expect(botWorks()).rejects.toThrow("paused while a person controls");
const reloaded = await open({ botId: "bot-a" });
expect(reloaded.res.events("control").at(-1)).toEqual({ held: true, owned: true, controlling: true });
await expect(reloaded.action({ type: "take" })).resolves.toEqual({ ok: true, waited: false });
expect(runtime.canControl("profile-a", "admin-a")).toBe(true);
});
it("does not keep a hold for a view closed on purpose: a revoked owner, a deleted profile, a changed session", async () => {
let current = true;
const a = await open({ current: () => current }); await a.action({ type: "take" });
current = false;
a.res.destroy();
expect(runtime.heldBy("profile-a")).toBeNull();
const b = await open(); await b.action({ type: "take" });
live.closeForOwner("admin-a");
expect(runtime.heldBy("profile-a")).toBeNull();
const c = await open(); await c.action({ type: "take" });
c.res.destroy();
live.closeForSession("profile-a");
await expect.poll(() => runtime.heldBy("profile-a")).toBeNull();
});
});
describe("one person's hold, across their views and devices", () => {
const bodies = () => nativeInput.mock.calls.map(([, init]) => JSON.parse(init.body));
const botWorks = () => runtime.withAgentAction("profile-a", async () => true);
const control = (view: { res: ResponseFixture }) => view.res.events("control").at(-1);
beforeEach(() => { runtime = new BrowserRuntime(); live = new BrowserLive({ runtime }); });
it("lets their other view see the hold, take it over and hand it back while the first is still open", async () => {
const phone = await open(); await phone.action({ type: "take" });
const laptop = await open({ botId: "bot-b" });
expect(control(laptop)).toEqual({ held: true, owned: true, controlling: false });
await expect(laptop.action({ type: "input_keyboard", eventType: "char", text: "x" })).rejects.toThrow("Browser control changed");
expect(await laptop.action({ type: "take" })).toEqual({ ok: true, waited: false });
expect(control(laptop)).toEqual({ held: true, owned: true, controlling: true });
expect(control(phone)).toEqual({ held: true, owned: true, controlling: false });
await expect(phone.action({ type: "input_keyboard", eventType: "char", text: "y" })).rejects.toThrow("Browser control changed");
await expect(botWorks()).rejects.toThrow("paused while a person controls");
await phone.action({ type: "release" });
expect(runtime.heldBy("profile-a")).toBeNull();
expect(control(laptop)).toEqual({ held: false, owned: false, controlling: false });
await expect(botWorks()).resolves.toBe(true);
});
it("lets go of what the view it was taken from pressed before the new one types", async () => {
const phone = await open(); await phone.action({ type: "take" });
await phone.action({ type: "input_keyboard", eventType: "keyDown", key: "Shift", code: "ShiftLeft", modifiers: 8 });
const laptop = await open({ botId: "bot-b" });
nativeInput.mockClear();
await laptop.action({ type: "take" });
await laptop.action({ type: "input_keyboard", eventType: "char", text: "a" });
expect(bodies()).toEqual([
{ action: "input_keyboard", type: "keyUp", key: "Shift", code: "ShiftLeft" },
{ action: "keyboard", subaction: "insertText", text: "a" },
]);
expect(runtime.interrupted("profile-a")).toBe(false);
});
it("ends a hold its person stopped using, kept or not, while every key they type keeps it", async () => {
vi.useFakeTimers();
// A view the person left open, whose stream the server never sees end.
const phone = await open(); await phone.action({ type: "take" });
await vi.advanceTimersByTimeAsync(HOLD_LIMITS.idleMs - 10_000);
await phone.action({ type: "input_keyboard", eventType: "char", text: "still typing" });
await vi.advanceTimersByTimeAsync(HOLD_LIMITS.idleMs - 10_000);
expect(runtime.heldBy("profile-a")).toBe("admin-a");
await expect(botWorks()).rejects.toThrow("paused while a person controls");
await vi.advanceTimersByTimeAsync(20_000);
expect(runtime.heldBy("profile-a")).toBeNull();
expect(control(phone)).toEqual({ held: false, owned: false, controlling: false });
await expect(botWorks()).resolves.toBe(true);
});
it("still refuses another person, from any view", async () => {
const phone = await open(); await phone.action({ type: "take" });
phone.res.destroy();
const someoneElse = await open({ owner: "admin-b" });
expect(control(someoneElse)).toEqual({ held: true, owned: false, controlling: false });
await expect(someoneElse.action({ type: "take" })).rejects.toThrow("Another window or device controls this browser");
await someoneElse.action({ type: "release" });
await expect(botWorks()).rejects.toThrow("paused while a person controls");
});
});
describe("opening a view while the bot holds the browser", () => {
const busy = () => Object.assign(new Error("Command failed: agent-browser stream status --json --no-webmcp"), { killed: true, signal: "SIGKILL", code: null });
const start = () => {
const res = new ResponseFixture();
const opening = live.open({ botId: "bot-a", session: "profile-a", owner: "admin-a", isCurrent: () => true, res: res as unknown as ServerResponse,
spec: { command: "/engine", env: { AGENT_BROWSER_SESSION: "profile-a" } } });
return { res, opening };
};
const types = (res: ResponseFixture) => res.chunks.map((chunk) => chunk.slice("event: ".length, chunk.indexOf("\n")));
it("waits in an open event stream and connects as soon as the browser answers", async () => {
vi.useFakeTimers();
execute.mockRejectedValueOnce(busy()).mockRejectedValueOnce(busy()).mockResolvedValue(output(ready));
const { res, opening } = start();
await vi.advanceTimersByTimeAsync(0);
expect(res.headers["Content-Type"]).toBe("text/event-stream");
expect(res.events("waiting")).toEqual([{ reason: "busy" }]);
await vi.advanceTimersByTimeAsync(1_000);
expect(res.events("waiting")).toHaveLength(2);
expect(res.events("ready")).toHaveLength(0);
await vi.advanceTimersByTimeAsync(2_000);
await opening;
expect(res.id).toBeTruthy();
expect(types(res).slice(0, 3)).toEqual(["waiting", "waiting", "ready"]);
expect(execute).toHaveBeenCalledTimes(3);
expect(execute.mock.calls[0]?.[2]).toMatchObject({ timeout: 10_000, killSignal: "SIGKILL" });
});
it("resets an engine that stops answering while nothing works in it, says so, and reconnects to a fresh session (#3728)", async () => {
vi.mocked(runtime.working).mockReturnValue(false);
execute.mockRejectedValueOnce(busy());
const { res, opening } = start();
await opening;
expect(res.events("waiting")).toHaveLength(0);
expect(res.events("error")).toEqual([{ retryable: true, message: "The browser stopped responding, so it is being restarted. The live view reconnects by itself.", errorKey: "serverError.browser.restartingUnresponsive" }]);
expect(nativeClose).toHaveBeenCalledTimes(1);
expect(nativeClose).toHaveBeenCalledWith("/engine", expect.objectContaining({ AGENT_BROWSER_SESSION: "profile-a" }));
// The next view waits for that reset, then meets the fresh session.
const again = start();
await again.opening;
expect(again.res.events("ready")).toHaveLength(1);
expect(nativeClose).toHaveBeenCalledTimes(1);
});
it("carries the restart error code and original English over an isolated HTTP event stream", async () => {
vi.mocked(runtime.working).mockReturnValue(false);
execute.mockRejectedValueOnce(busy());
const server = createServer((_req, res) => {
void live.open({ botId: "bot-a", session: "profile-a", owner: "admin-a", isCurrent: () => true, res,
spec: { command: "/engine", env: { AGENT_BROWSER_SESSION: "profile-a" } } })
.catch(error => res.destroy(error instanceof Error ? error : new Error(String(error))));
});
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
try {
const address = server.address();
if (!address || typeof address === "string") throw new Error("The isolated SSE fixture has no TCP address");
const received = await new Promise<{ status?: number; contentType?: string; body: string }>((resolve, reject) => {
const request = get(`http://127.0.0.1:${address.port}/`, response => {
let body = "";
response.setEncoding("utf8");
response.on("data", chunk => { body += chunk; });
response.on("error", reject);
response.on("end", () => resolve({ status: response.statusCode, contentType: response.headers["content-type"], body }));
});
request.on("error", reject);
request.setTimeout(5_000, () => request.destroy(new Error("The isolated SSE fixture timed out")));
});
expect(received).toEqual({ status: 200, contentType: "text/event-stream", body: `event: error\ndata: ${JSON.stringify({
retryable: true,
message: "The browser stopped responding, so it is being restarted. The live view reconnects by itself.",
errorKey: "serverError.browser.restartingUnresponsive",
})}\n\n` });
expect(nativeClose).toHaveBeenCalledOnce();
} finally {
server.closeAllConnections();
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
}
});
it("keeps waiting, and resets nothing, while a bot call still works in the browser", async () => {
vi.useFakeTimers();
execute.mockRejectedValueOnce(busy()).mockResolvedValue(output(ready));
const { res, opening } = start();
await vi.advanceTimersByTimeAsync(1_000);
await opening;
expect(types(res).slice(0, 2)).toEqual(["waiting", "ready"]);
expect(nativeClose).not.toHaveBeenCalled();
});
it("does not show a peer's control changes before it is ready", async () => {
vi.useFakeTimers();
const a = await open();
execute.mockRejectedValueOnce(busy()).mockResolvedValue(output(ready));
const { res, opening } = start();
await vi.advanceTimersByTimeAsync(0);
await a.action({ type: "take" });
expect(res.events("control")).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1_000);
await opening;
// The same person's other view has their hold: this one watches it, and may take it over or hand it back.
expect(res.events("control").at(-1)).toEqual({ held: true, owned: true, controlling: false });
});
it("stops waiting, and stops asking the browser, when the viewer leaves", async () => {
vi.useFakeTimers();
execute.mockRejectedValue(busy());
const { res, opening } = start();
const settled = opening.then(() => "resolved", () => "rejected");
await vi.advanceTimersByTimeAsync(0);
expect(res.events("waiting")).toHaveLength(1);
res.destroy();
await vi.advanceTimersByTimeAsync(60_000);
await settled;
expect(execute).toHaveBeenCalledTimes(1);
expect(execute.mock.calls[0]?.[2].signal.aborted).toBe(true);
expect(SocketFixture.instances).toHaveLength(0);
});
it("gives up after a bounded wait with a reason, not an install hint and no automatic retry", async () => {
vi.useFakeTimers();
execute.mockRejectedValue(busy());
const { res, opening } = start();
await vi.advanceTimersByTimeAsync(11 * 60_000);
await opening;
expect(res.events("error")).toEqual([{ retryable: false, message: "The bot is still using this browser. Reconnect when it has finished." }]);
expect(res.writableEnded).toBe(true);
const delays = execute.mock.calls.length;
await vi.advanceTimersByTimeAsync(60_000);
expect(execute).toHaveBeenCalledTimes(delays);
});
it("reports a busy browser to a person's action without blaming the installation", async () => {
const a = await open();
await a.action({ type: "take" });
execute.mockRejectedValueOnce(busy());
nativeInput.mockRejectedValueOnce(new Error("relay timed out"));
await expect(a.action({ type: "reload" })).rejects.toThrow(/busy with another action/);
expect(JSON.parse(nativeInput.mock.calls.at(-1)![1].body)).toEqual({ action: "stream_status" });
execute.mockRejectedValueOnce(Object.assign(new Error("spawn /engine ENOENT"), { code: "ENOENT" }));
await expect(a.action({ type: "reload" })).rejects.toThrow(/browser engine is installed/);
});
});