Files
paseo/packages/desktop/src/features/browser-automation/service.test.ts
T
Mohamed Boudra ab62a023fd fix(browser): park webviews permanently paintable
Delete the renderer capture prep handshake and keep screenshots on the serialized invalidate/retry path. The capture harness now defaults to the production P1 attach-off parking check.
2026-07-03 11:34:48 +02:00

1322 lines
39 KiB
TypeScript

import { resolve as resolvePath } from "node:path";
import { describe, expect, test, vi } from "vitest";
import type {
BrowserAutomationCommand,
BrowserAutomationConsoleLogEntry,
BrowserAutomationExecuteRequest,
} from "@getpaseo/protocol/browser-automation/rpc-schemas";
import { BrowserSnapshotEngine } from "./snapshot-engine.js";
import type { BrowserRegistry, TabContents, TabImage } from "./service.js";
import { executeAutomationCommand } from "./service.js";
const BROWSER_A = "11111111-1111-4111-8111-111111111111";
const BROWSER_B = "22222222-2222-4222-8222-222222222222";
const WORKSPACE_A = "workspace-a";
const WORKSPACE_B = "workspace-b";
class FakeImage implements TabImage {
public constructor(
private readonly bytes = new Uint8Array([137, 80, 78, 71, 1, 2, 3]),
private readonly size = { width: 640, height: 480 },
) {}
public toPNG(): Uint8Array {
return this.bytes;
}
public getSize(): { width: number; height: number } {
return this.size;
}
}
class FakeTab implements TabContents {
public readonly loadedUrls: string[] = [];
public readonly scripts: string[] = [];
public readonly actions: string[] = [];
public readonly capturedViewports: Array<{ stayHidden?: boolean }> = [];
public readonly debugCommands: Array<{ command: string; params?: Record<string, unknown> }> = [];
private readonly captureStartWaiters: Array<() => void> = [];
private readonly deferredCaptures: Array<(image: TabImage) => void> = [];
public destroyed = false;
public bodyText = "";
public snapshotElements: unknown[] = [];
public actionScriptResult: unknown = true;
public networkEntries: unknown[] = [];
public consoleMessages: BrowserAutomationConsoleLogEntry[] = [];
public captureNeverPaints = false;
public captureThrows = false;
public captureErrorMessage = "capture failed";
public viewportCaptureFailuresBeforeSuccess = 0;
public deferCaptures = false;
public fullPageScreenshotThrows = false;
public fullPageScreenshotErrorMessage = "UnknownVizError";
public fullPageCaptureFailuresBeforeSuccess = 0;
public layoutMetrics = {
cssLayoutViewport: { clientWidth: 390, clientHeight: 844 },
cssContentSize: { width: 390, height: 1200 },
};
public fullPageScreenshotData = "fullPagePng";
public documentNodeId = 1;
public queriedNodeId = 2;
public constructor(
public readonly id: number,
private readonly initialUrl: string,
private readonly title: string,
) {}
public getURL(): string {
return this.loadedUrls.at(-1) ?? this.initialUrl;
}
public getTitle(): string {
return this.title;
}
public canGoBack(): boolean {
return true;
}
public canGoForward(): boolean {
return false;
}
public isLoading(): boolean {
return false;
}
public isDestroyed(): boolean {
return this.destroyed;
}
public async executeJavaScript(code: string): Promise<unknown> {
this.scripts.push(code);
if (code.includes("document.body.innerText")) {
return this.bodyText;
}
if (code.includes("CANDIDATE_SELECTOR")) {
return JSON.stringify(this.snapshotElements);
}
if (code.includes("performance.getEntriesByType")) {
return JSON.stringify(this.networkEntries);
}
return this.actionScriptResult;
}
public async loadURL(url: string): Promise<void> {
this.loadedUrls.push(url);
}
public goBack(): void {
this.actions.push("back");
}
public goForward(): void {
this.actions.push("forward");
}
public reload(): void {
this.actions.push("reload");
}
public async capturePage(options?: { stayHidden?: boolean }): Promise<TabImage> {
this.capturedViewports.push(options ?? {});
this.actions.push("capture");
this.resolveCaptureStartWaiters();
if (this.viewportCaptureFailuresBeforeSuccess > 0) {
this.viewportCaptureFailuresBeforeSuccess -= 1;
throw new Error(this.captureErrorMessage);
}
if (this.captureThrows) {
throw new Error(this.captureErrorMessage);
}
if (this.captureNeverPaints) {
return new Promise<never>(() => {});
}
if (this.deferCaptures) {
return new Promise<TabImage>((resolve) => {
this.deferredCaptures.push(resolve);
});
}
return new FakeImage();
}
public invalidate(): void {
this.actions.push("invalidate");
}
public getConsoleMessages(): BrowserAutomationConsoleLogEntry[] {
return this.consoleMessages;
}
public async sendDebugCommand(
command: string,
params?: Record<string, unknown>,
): Promise<unknown> {
this.actions.push(`debug:${command}`);
this.debugCommands.push({ command, ...(params ? { params } : {}) });
if (command === "Page.getLayoutMetrics") {
return this.layoutMetrics;
}
if (command === "Page.captureScreenshot") {
if (this.fullPageCaptureFailuresBeforeSuccess > 0) {
this.fullPageCaptureFailuresBeforeSuccess -= 1;
throw new Error(this.fullPageScreenshotErrorMessage);
}
if (this.fullPageScreenshotThrows) {
throw new Error(this.fullPageScreenshotErrorMessage);
}
return { data: this.fullPageScreenshotData };
}
if (command === "DOM.getDocument") {
return { root: { nodeId: this.documentNodeId } };
}
if (command === "DOM.querySelector") {
return { nodeId: this.queriedNodeId };
}
return {};
}
public waitForCaptureStart(count: number): Promise<void> {
if (this.capturedViewports.length >= count) {
return Promise.resolve();
}
return new Promise((resolve) => {
this.captureStartWaiters.push(() => {
if (this.capturedViewports.length >= count) {
resolve();
}
});
});
}
public finishNextCapture(): void {
const resolve = this.deferredCaptures.shift();
if (!resolve) {
throw new Error("No deferred capture is waiting");
}
resolve(new FakeImage());
}
private resolveCaptureStartWaiters(): void {
for (const waiter of this.captureStartWaiters.splice(0)) {
waiter();
}
}
}
class FakeRegistry implements BrowserRegistry {
private readonly tabs = new Map<string, { workspaceId: string; tab: FakeTab }>();
private readonly activeBrowserIdsByWorkspace = new Map<string, string>();
public register(browserId: string, workspaceId: string, tab: FakeTab): void {
this.tabs.set(browserId, { workspaceId, tab });
}
public setActiveBrowser(workspaceId: string, browserId: string): void {
this.activeBrowserIdsByWorkspace.set(workspaceId, browserId);
}
public listRegisteredBrowserIds(): string[] {
return Array.from(this.tabs.keys());
}
public listRegisteredBrowserIdsForWorkspace(workspaceId: string): string[] {
return Array.from(this.tabs.entries())
.filter((entry) => entry[1].workspaceId === workspaceId)
.map((entry) => entry[0]);
}
public getTabContents(browserId: string): TabContents | null {
return this.tabs.get(browserId)?.tab ?? null;
}
public getBrowserWorkspaceId(browserId: string): string | null {
return this.tabs.get(browserId)?.workspaceId ?? null;
}
public getWorkspaceActiveBrowserId(workspaceId: string): string | null {
return this.activeBrowserIdsByWorkspace.get(workspaceId) ?? null;
}
}
class BrowserAutomationHarness {
public readonly registry = new FakeRegistry();
public readonly snapshotEngine = new BrowserSnapshotEngine();
public readonly tab = new FakeTab(1, "https://a.test/form", "Fixture");
public constructor() {
this.registry.register(BROWSER_A, WORKSPACE_A, this.tab);
this.registry.setActiveBrowser(WORKSPACE_A, BROWSER_A);
}
public async execute(
command: BrowserAutomationCommand,
input: { requestId?: string; workspaceId?: string; cwd?: string } = {},
) {
return executeAutomationCommand(automationRequest(command, input), this.registry, {
snapshotEngine: this.snapshotEngine,
});
}
public async snapshot() {
return this.execute({
command: "snapshot",
args: { browserId: BROWSER_A },
});
}
}
function automationRequest(
command: BrowserAutomationCommand,
input: { requestId?: string; workspaceId?: string; cwd?: string } = {},
): BrowserAutomationExecuteRequest {
const workspaceFields: { workspaceId?: string } = {};
if (input.workspaceId === undefined) {
workspaceFields.workspaceId = WORKSPACE_A;
} else if (input.workspaceId) {
workspaceFields.workspaceId = input.workspaceId;
}
return {
type: "browser.automation.execute.request",
requestId: input.requestId ?? `req-${command.command}`,
...(input.cwd ? { cwd: input.cwd } : {}),
...workspaceFields,
command,
};
}
function formElements() {
return [
{
role: "textbox",
tagName: "input",
text: "Name",
selector: "#name",
attributes: { id: "name", type: "text" },
},
{
role: "checkbox",
tagName: "input",
text: "Agree",
selector: "#agree",
attributes: { id: "agree", type: "checkbox" },
},
{
role: "combobox",
tagName: "select",
text: "Country",
selector: "#country",
attributes: { id: "country" },
},
{
role: "button",
tagName: "button",
text: "Source",
selector: "#source",
attributes: { id: "source" },
},
{
role: "button",
tagName: "button",
text: "Target",
selector: "#target",
attributes: { id: "target" },
},
];
}
function containsScript(tab: FakeTab, ...parts: string[]): boolean {
return tab.scripts.some((script) => parts.every((part) => script.includes(part)));
}
function requireSnapshotRefs(result: Awaited<ReturnType<BrowserAutomationHarness["snapshot"]>>) {
expect(result).toEqual({
requestId: "req-snapshot",
ok: true,
result: {
command: "snapshot",
browserId: BROWSER_A,
workspaceId: WORKSPACE_A,
url: "https://a.test/form",
title: "Fixture",
elements: [
{
ref: "@e1",
role: "textbox",
tagName: "input",
text: "Name",
selector: "#name",
attributes: { id: "name", type: "text" },
},
{
ref: "@e2",
role: "checkbox",
tagName: "input",
text: "Agree",
selector: "#agree",
attributes: { id: "agree", type: "checkbox" },
},
{
ref: "@e3",
role: "combobox",
tagName: "select",
text: "Country",
selector: "#country",
attributes: { id: "country" },
},
{
ref: "@e4",
role: "button",
tagName: "button",
text: "Source",
selector: "#source",
attributes: { id: "source" },
},
{
ref: "@e5",
role: "button",
tagName: "button",
text: "Target",
selector: "#target",
attributes: { id: "target" },
},
],
},
});
}
describe("executeAutomationCommand", () => {
test("list tabs without a workspace reports every live registered tab", () => {
const registry = new FakeRegistry();
registry.register(BROWSER_A, WORKSPACE_A, new FakeTab(1, "https://a.test", "A"));
registry.register(BROWSER_B, WORKSPACE_B, new FakeTab(2, "https://b.test", "B"));
const result = executeAutomationCommand(
automationRequest(
{ command: "list_tabs", args: {} },
{ requestId: "req-list", workspaceId: "" },
),
registry,
);
expect(result).toEqual({
requestId: "req-list",
ok: true,
result: {
command: "list_tabs",
tabs: [
{
browserId: BROWSER_A,
workspaceId: WORKSPACE_A,
url: "https://a.test",
title: "A",
isActive: false,
isLoading: false,
canGoBack: true,
canGoForward: false,
},
{
browserId: BROWSER_B,
workspaceId: WORKSPACE_B,
url: "https://b.test",
title: "B",
isActive: false,
isLoading: false,
canGoBack: true,
canGoForward: false,
},
],
},
});
});
test("list tabs filters destroyed tabs out of the visible tab list", () => {
const liveTab = new FakeTab(1, "https://a.test", "A");
const destroyedTab = new FakeTab(2, "https://dead.test", "Dead");
destroyedTab.destroyed = true;
const registry = new FakeRegistry();
registry.register(BROWSER_A, WORKSPACE_A, liveTab);
registry.register(BROWSER_B, WORKSPACE_A, destroyedTab);
registry.setActiveBrowser(WORKSPACE_A, BROWSER_A);
const result = executeAutomationCommand(
automationRequest({ command: "list_tabs", args: {} }, { requestId: "req-live" }),
registry,
);
expect(result).toEqual({
requestId: "req-live",
ok: true,
result: {
command: "list_tabs",
tabs: [
{
browserId: BROWSER_A,
workspaceId: WORKSPACE_A,
url: "https://a.test",
title: "A",
isActive: true,
isLoading: false,
canGoBack: true,
canGoForward: false,
},
],
},
});
});
test("list tabs returns an empty list when the workspace has no registered tabs", () => {
const result = executeAutomationCommand(
automationRequest({ command: "list_tabs", args: {} }, { requestId: "req-empty" }),
new FakeRegistry(),
);
expect(result).toEqual({
requestId: "req-empty",
ok: true,
result: { command: "list_tabs", tabs: [] },
});
});
test("tab commands return tab not found for an id in another workspace", async () => {
const registry = new FakeRegistry();
registry.register(BROWSER_A, WORKSPACE_B, new FakeTab(1, "https://a.test", "A"));
const result = await executeAutomationCommand(
automationRequest(
{ command: "snapshot", args: { browserId: BROWSER_A } },
{ requestId: "req-snapshot" },
),
registry,
);
expect(result).toEqual({
requestId: "req-snapshot",
ok: false,
error: {
code: "browser_tab_not_found",
message: `No browser tab found for ID: ${BROWSER_A}`,
retryable: false,
},
});
});
test("tab commands return tab closed for a destroyed explicit tab", async () => {
const tab = new FakeTab(1, "https://a.test", "A");
tab.destroyed = true;
const registry = new FakeRegistry();
registry.register(BROWSER_A, WORKSPACE_A, tab);
const result = await executeAutomationCommand(
automationRequest(
{ command: "snapshot", args: { browserId: BROWSER_A } },
{ requestId: "req-snapshot" },
),
registry,
);
expect(result).toEqual({
requestId: "req-snapshot",
ok: false,
error: {
code: "browser_tab_closed",
message: `Browser tab ${BROWSER_A} has been closed`,
retryable: false,
},
});
});
test("snapshot and click use refs from the same explicit tab", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.snapshotElements = [
{
role: "button",
tagName: "button",
text: "Submit",
selector: "#submit",
attributes: { id: "submit" },
},
];
const snapshot = await browser.snapshot();
const click = await browser.execute({
command: "click",
args: { browserId: BROWSER_A, ref: "@e1" },
});
expect(snapshot).toEqual({
requestId: "req-snapshot",
ok: true,
result: {
command: "snapshot",
browserId: BROWSER_A,
workspaceId: WORKSPACE_A,
url: "https://a.test/form",
title: "Fixture",
elements: [
{
ref: "@e1",
role: "button",
tagName: "button",
text: "Submit",
selector: "#submit",
attributes: { id: "submit" },
},
],
},
});
expect(click).toEqual({
requestId: "req-click",
ok: true,
result: { command: "click", browserId: BROWSER_A, ref: "@e1" },
});
expect(containsScript(browser.tab, "#submit", ".click()")).toBe(true);
});
test.each([
{
name: "fill updates a ref from the latest snapshot",
command: { command: "fill", args: { browserId: BROWSER_A, ref: "@e1", value: "Ada" } },
result: { command: "fill", browserId: BROWSER_A, ref: "@e1" },
scriptParts: ["#name", "Ada"],
},
{
name: "select sets the requested value on a ref",
command: { command: "select", args: { browserId: BROWSER_A, ref: "@e3", value: "us" } },
result: { command: "select", browserId: BROWSER_A, ref: "@e3", value: "us" },
scriptParts: ["#country", "us"],
},
{
name: "hover dispatches hover events to a ref",
command: { command: "hover", args: { browserId: BROWSER_A, ref: "@e4" } },
result: { command: "hover", browserId: BROWSER_A, ref: "@e4" },
scriptParts: ["#source", "mouseover"],
},
{
name: "drag dispatches drag events between refs",
command: {
command: "drag",
args: { browserId: BROWSER_A, sourceRef: "@e4", targetRef: "@e5" },
},
result: { command: "drag", browserId: BROWSER_A, sourceRef: "@e4", targetRef: "@e5" },
scriptParts: ["#source", "#target", "dragstart"],
},
] as const)("$name", async ({ command, result, scriptParts }) => {
const browser = new BrowserAutomationHarness();
browser.tab.snapshotElements = formElements();
requireSnapshotRefs(await browser.snapshot());
const action = await browser.execute(command);
expect(action).toEqual({
requestId: `req-${command.command}`,
ok: true,
result,
});
expect(containsScript(browser.tab, ...scriptParts)).toBe(true);
});
test("fill with an empty string clears a ref through the regular fill path", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.snapshotElements = formElements();
requireSnapshotRefs(await browser.snapshot());
const action = await browser.execute({
command: "fill",
args: { browserId: BROWSER_A, ref: "@e1", value: "" },
});
expect(action).toEqual({
requestId: "req-fill",
ok: true,
result: { command: "fill", browserId: BROWSER_A, ref: "@e1" },
});
expect(containsScript(browser.tab, "#name", 'const nextValue = "";')).toBe(true);
});
test("refs become stale after navigation changes the tab URL", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.snapshotElements = formElements();
requireSnapshotRefs(await browser.snapshot());
await browser.execute({
command: "navigate",
args: { browserId: BROWSER_A, url: "https://a.test/next" },
});
const click = await browser.execute({
command: "click",
args: { browserId: BROWSER_A, ref: "@e1" },
});
expect(click).toEqual({
requestId: "req-click",
ok: false,
error: {
code: "browser_stale_ref",
message: "Browser element reference @e1 is stale. Take a new snapshot and try again.",
retryable: false,
},
});
});
test("refs become stale when the same URL no longer contains the element", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.snapshotElements = formElements();
requireSnapshotRefs(await browser.snapshot());
browser.tab.actionScriptResult = false;
const fill = await browser.execute({
command: "fill",
args: { browserId: BROWSER_A, ref: "@e1", value: "Ada" },
});
expect(fill).toEqual({
requestId: "req-fill",
ok: false,
error: {
code: "browser_stale_ref",
message: "Browser element reference @e1 is stale. Take a new snapshot and try again.",
retryable: false,
},
});
});
test("wait resolves when the explicit tab contains the requested text", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.bodyText = "Ready";
const result = await browser.execute({
command: "wait",
args: { browserId: BROWSER_A, text: "Ready", timeoutMs: 100 },
});
expect(result).toEqual({
requestId: "req-wait",
ok: true,
result: { command: "wait", browserId: BROWSER_A, matched: "text" },
});
});
test("wait returns a retryable timeout when text never appears", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.bodyText = "Still loading";
const result = await browser.execute({
command: "wait",
args: { browserId: BROWSER_A, text: "Ready", timeoutMs: 1 },
});
expect(result).toEqual({
requestId: "req-wait",
ok: false,
error: {
code: "browser_timeout",
message: "Timed out waiting for browser text: Ready",
retryable: true,
},
});
});
test.each([
{
name: "type writes text into a ref",
command: { command: "type", args: { browserId: BROWSER_A, ref: "@e1", text: "Ada" } },
result: { command: "type", browserId: BROWSER_A, ref: "@e1" },
scriptParts: ["#name", "Ada"],
needsSnapshot: true,
},
{
name: "keypress dispatches a key to the focused element when ref is omitted",
command: { command: "keypress", args: { browserId: BROWSER_A, key: "Enter" } },
result: { command: "keypress", browserId: BROWSER_A, key: "Enter" },
scriptParts: ["document.activeElement", "Enter"],
needsSnapshot: false,
},
] as const)("$name", async ({ command, result, scriptParts, needsSnapshot }) => {
const browser = new BrowserAutomationHarness();
browser.tab.snapshotElements = formElements();
if (needsSnapshot) {
requireSnapshotRefs(await browser.snapshot());
}
const action = await browser.execute(command);
expect(action).toEqual({
requestId: `req-${command.command}`,
ok: true,
result,
});
expect(containsScript(browser.tab, ...scriptParts)).toBe(true);
});
test("navigate loads the requested HTTP URL in the explicit tab", async () => {
const browser = new BrowserAutomationHarness();
const result = await browser.execute({
command: "navigate",
args: { browserId: BROWSER_A, url: "https://example.com/next" },
});
expect(result).toEqual({
requestId: "req-navigate",
ok: true,
result: { command: "navigate", browserId: BROWSER_A, url: "https://example.com/next" },
});
expect(browser.tab.loadedUrls).toEqual(["https://example.com/next"]);
});
test("navigate denies non-http URLs before loading the explicit tab", async () => {
const browser = new BrowserAutomationHarness();
const result = await browser.execute({
command: "navigate",
args: { browserId: BROWSER_A, url: "file:///tmp/secret.txt" },
});
expect(result).toEqual({
requestId: "req-navigate",
ok: false,
error: {
code: "browser_denied",
message: "Browser navigation only supports http and https URLs.",
retryable: false,
},
});
expect(browser.tab.loadedUrls).toEqual([]);
});
test("navigation actions dispatch to the explicit tab", () => {
const browser = new BrowserAutomationHarness();
const back = executeAutomationCommand(
automationRequest(
{ command: "back", args: { browserId: BROWSER_A } },
{ requestId: "req-back" },
),
browser.registry,
);
const forward = executeAutomationCommand(
automationRequest(
{ command: "forward", args: { browserId: BROWSER_A } },
{ requestId: "req-forward" },
),
browser.registry,
);
const reload = executeAutomationCommand(
automationRequest(
{ command: "reload", args: { browserId: BROWSER_A } },
{ requestId: "req-reload" },
),
browser.registry,
);
expect(back).toEqual({
requestId: "req-back",
ok: true,
result: { command: "back", browserId: BROWSER_A },
});
expect(forward).toEqual({
requestId: "req-forward",
ok: true,
result: { command: "forward", browserId: BROWSER_A },
});
expect(reload).toEqual({
requestId: "req-reload",
ok: true,
result: { command: "reload", browserId: BROWSER_A },
});
expect(browser.tab.actions).toEqual(["back", "forward", "reload"]);
});
test("logs returns bounded console messages and network entries from the explicit tab", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.consoleMessages = [
{ level: "debug", message: "boot", timestamp: 1 },
{ level: "info", message: "ready", source: "console", line: 3, timestamp: 2 },
];
browser.tab.networkEntries = [
{ url: "https://a.test/ignored", startTime: 1, duration: 2 },
{
url: "https://a.test/app.js",
method: "GET",
status: 200,
type: "script",
startTime: 3,
duration: 4,
transferSize: 123,
},
];
const result = await browser.execute({
command: "logs",
args: { browserId: BROWSER_A, maxEntries: 1 },
});
expect(result).toEqual({
requestId: "req-logs",
ok: true,
result: {
command: "logs",
browserId: BROWSER_A,
console: [{ level: "info", message: "ready", source: "console", line: 3, timestamp: 2 }],
network: [
{
url: "https://a.test/app.js",
method: "GET",
status: 200,
type: "script",
startTime: 3,
duration: 4,
transferSize: 123,
},
],
},
});
});
test("screenshot captures the painted viewport", async () => {
const browser = new BrowserAutomationHarness();
const result = await browser.execute({
command: "screenshot",
args: { browserId: BROWSER_A },
});
expect(result).toEqual({
requestId: "req-screenshot",
ok: true,
result: {
command: "screenshot",
browserId: BROWSER_A,
mimeType: "image/png",
dataBase64: "iVBORwECAw==",
width: 640,
height: 480,
},
});
expect(browser.tab.capturedViewports).toEqual([{ stayHidden: false }]);
expect(browser.tab.actions).toEqual(["invalidate", "capture"]);
});
test("screenshot returns no-frame when the viewport never paints", async () => {
vi.useFakeTimers();
try {
const browser = new BrowserAutomationHarness();
browser.tab.captureNeverPaints = true;
const resultPromise = browser.execute({
command: "screenshot",
args: { browserId: BROWSER_A },
});
await vi.advanceTimersByTimeAsync(5_000);
await expect(resultPromise).resolves.toEqual({
requestId: "req-screenshot",
ok: false,
error: {
code: "screenshot_no_frame",
message: "The tab has not painted yet. Retry the screenshot.",
retryable: true,
},
});
expect(browser.tab.actions).toEqual(["invalidate", "capture"]);
} finally {
vi.useRealTimers();
}
});
test("screenshot surfaces ordinary viewport capture errors", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.captureThrows = true;
await expect(
browser.execute({
command: "screenshot",
args: { browserId: BROWSER_A },
}),
).rejects.toThrow("capture failed");
expect(browser.tab.actions).toEqual(["invalidate", "capture"]);
});
test("screenshot retries UnknownVizError until the first viewport frame appears", async () => {
vi.useFakeTimers();
try {
const browser = new BrowserAutomationHarness();
browser.tab.captureErrorMessage = "UnknownVizError";
browser.tab.viewportCaptureFailuresBeforeSuccess = 2;
const resultPromise = browser.execute({
command: "screenshot",
args: { browserId: BROWSER_A },
});
await vi.advanceTimersByTimeAsync(400);
await expect(resultPromise).resolves.toEqual({
requestId: "req-screenshot",
ok: true,
result: {
command: "screenshot",
browserId: BROWSER_A,
mimeType: "image/png",
dataBase64: "iVBORwECAw==",
width: 640,
height: 480,
},
});
expect(browser.tab.capturedViewports).toHaveLength(3);
expect(browser.tab.actions).toEqual([
"invalidate",
"capture",
"invalidate",
"capture",
"invalidate",
"capture",
]);
} finally {
vi.useRealTimers();
}
});
test("screenshot returns no-frame after UnknownVizError spends the retry budget", async () => {
vi.useFakeTimers();
try {
const browser = new BrowserAutomationHarness();
browser.tab.captureThrows = true;
browser.tab.captureErrorMessage = "UnknownVizError";
const resultPromise = browser.execute({
command: "screenshot",
args: { browserId: BROWSER_A },
});
await vi.advanceTimersByTimeAsync(5_000);
await expect(resultPromise).resolves.toEqual({
requestId: "req-screenshot",
ok: false,
error: {
code: "screenshot_no_frame",
message: "The tab has not painted yet. Retry the screenshot.",
retryable: true,
},
});
expect(browser.tab.capturedViewports.length).toBeGreaterThan(1);
expect(browser.tab.actions.filter((action) => action === "invalidate")).toHaveLength(
browser.tab.capturedViewports.length,
);
} finally {
vi.useRealTimers();
}
});
test("overlapping screenshots serialize through the shared capture queue", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.deferCaptures = true;
const first = browser.execute({
command: "screenshot",
args: { browserId: BROWSER_A },
});
await browser.tab.waitForCaptureStart(1);
const second = browser.execute(
{
command: "screenshot",
args: { browserId: BROWSER_A },
},
{ requestId: "req-screenshot-2" },
);
await Promise.resolve();
expect(browser.tab.actions).toEqual(["invalidate", "capture"]);
browser.tab.finishNextCapture();
await browser.tab.waitForCaptureStart(2);
expect(browser.tab.actions).toEqual(["invalidate", "capture", "invalidate", "capture"]);
browser.tab.finishNextCapture();
await expect(first).resolves.toMatchObject({ requestId: "req-screenshot", ok: true });
await expect(second).resolves.toMatchObject({ requestId: "req-screenshot-2", ok: true });
expect(browser.tab.actions).toEqual(["invalidate", "capture", "invalidate", "capture"]);
});
test("overlapping screenshots across browser tabs serialize through the shared capture queue", async () => {
const registry = new FakeRegistry();
const firstTab = new FakeTab(1, "https://a.test", "A");
const secondTab = new FakeTab(2, "https://b.test", "B");
firstTab.deferCaptures = true;
secondTab.deferCaptures = true;
registry.register(BROWSER_A, WORKSPACE_A, firstTab);
registry.register(BROWSER_B, WORKSPACE_A, secondTab);
const first = executeAutomationCommand(
automationRequest({
command: "screenshot",
args: { browserId: BROWSER_A },
}),
registry,
);
await firstTab.waitForCaptureStart(1);
const second = executeAutomationCommand(
automationRequest(
{
command: "screenshot",
args: { browserId: BROWSER_B },
},
{ requestId: "req-screenshot-2" },
),
registry,
);
await Promise.resolve();
expect(firstTab.actions).toEqual(["invalidate", "capture"]);
expect(secondTab.actions).toEqual([]);
firstTab.finishNextCapture();
await secondTab.waitForCaptureStart(1);
expect(firstTab.actions).toEqual(["invalidate", "capture"]);
expect(secondTab.actions).toEqual(["invalidate", "capture"]);
secondTab.finishNextCapture();
await expect(first).resolves.toMatchObject({ requestId: "req-screenshot", ok: true });
await expect(second).resolves.toMatchObject({ requestId: "req-screenshot-2", ok: true });
expect(secondTab.actions).toEqual(["invalidate", "capture"]);
});
test("screenshot with fullPage captures the page content area through CDP", async () => {
const browser = new BrowserAutomationHarness();
const result = await browser.execute({
command: "screenshot",
args: { browserId: BROWSER_A, fullPage: true },
});
expect(result).toEqual({
requestId: "req-screenshot",
ok: true,
result: {
command: "screenshot",
browserId: BROWSER_A,
mimeType: "image/png",
dataBase64: "fullPagePng",
width: 390,
height: 1200,
},
});
expect(browser.tab.debugCommands).toEqual([
{ command: "Page.getLayoutMetrics" },
{
command: "Page.captureScreenshot",
params: {
format: "png",
captureBeyondViewport: true,
clip: { x: 0, y: 0, width: 390, height: 1200, scale: 1 },
},
},
]);
expect(browser.tab.actions).toEqual([
"invalidate",
"debug:Page.getLayoutMetrics",
"debug:Page.captureScreenshot",
]);
});
test("screenshot with fullPage returns unsupported when CDP returns no image", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.fullPageScreenshotData = "";
const result = await browser.execute({
command: "screenshot",
args: { browserId: BROWSER_A, fullPage: true },
});
expect(result).toEqual({
requestId: "req-screenshot",
ok: false,
error: {
code: "browser_unsupported",
message: "browser_screenshot fullPage returned no data",
retryable: false,
},
});
expect(browser.tab.actions).toEqual([
"invalidate",
"debug:Page.getLayoutMetrics",
"debug:Page.captureScreenshot",
]);
});
test("screenshot with fullPage retries UnknownVizError until the first CDP frame appears", async () => {
vi.useFakeTimers();
try {
const browser = new BrowserAutomationHarness();
browser.tab.fullPageCaptureFailuresBeforeSuccess = 1;
const resultPromise = browser.execute({
command: "screenshot",
args: { browserId: BROWSER_A, fullPage: true },
});
await vi.advanceTimersByTimeAsync(200);
await expect(resultPromise).resolves.toEqual({
requestId: "req-screenshot",
ok: true,
result: {
command: "screenshot",
browserId: BROWSER_A,
mimeType: "image/png",
dataBase64: "fullPagePng",
width: 390,
height: 1200,
},
});
expect(browser.tab.actions).toEqual([
"invalidate",
"debug:Page.getLayoutMetrics",
"debug:Page.captureScreenshot",
"invalidate",
"debug:Page.getLayoutMetrics",
"debug:Page.captureScreenshot",
]);
} finally {
vi.useRealTimers();
}
});
test("screenshot with fullPage surfaces ordinary CDP capture errors", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.fullPageScreenshotThrows = true;
browser.tab.fullPageScreenshotErrorMessage = "Debugger detached";
await expect(
browser.execute({
command: "screenshot",
args: { browserId: BROWSER_A, fullPage: true },
}),
).rejects.toThrow("Debugger detached");
expect(browser.tab.actions).toEqual([
"invalidate",
"debug:Page.getLayoutMetrics",
"debug:Page.captureScreenshot",
]);
});
test("upload resolves workspace files before setting them on the file input", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.snapshotElements = [
{
role: "textbox",
tagName: "input",
text: "",
selector: "#file",
attributes: { id: "file", type: "file" },
},
];
const workspaceRoot = resolvePath("/workspace/project");
await browser.snapshot();
const result = await browser.execute(
{
command: "upload",
args: { browserId: BROWSER_A, ref: "@e1", filePaths: ["uploads/a.txt"] },
},
{ cwd: workspaceRoot },
);
expect(result).toEqual({
requestId: "req-upload",
ok: true,
result: {
command: "upload",
browserId: BROWSER_A,
ref: "@e1",
filePaths: [resolvePath(workspaceRoot, "uploads/a.txt")],
},
});
expect(browser.tab.debugCommands).toEqual([
{ command: "DOM.getDocument", params: { depth: -1, pierce: true } },
{ command: "DOM.querySelector", params: { nodeId: 1, selector: "#file" } },
{
command: "DOM.setFileInputFiles",
params: { nodeId: 2, files: [resolvePath(workspaceRoot, "uploads/a.txt")] },
},
]);
});
test("upload denies paths outside the agent workspace", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.snapshotElements = [
{
role: "textbox",
tagName: "input",
text: "",
selector: "#file",
attributes: { id: "file", type: "file" },
},
];
const workspaceRoot = resolvePath("/workspace/project");
await browser.snapshot();
const result = await browser.execute(
{
command: "upload",
args: { browserId: BROWSER_A, ref: "@e1", filePaths: ["../secret.txt"] },
},
{ cwd: workspaceRoot },
);
expect(result).toEqual({
requestId: "req-upload",
ok: false,
error: {
code: "browser_unsupported",
message: "browser_upload only accepts files inside the agent workspace.",
retryable: false,
},
});
expect(browser.tab.debugCommands).toEqual([
{ command: "DOM.getDocument", params: { depth: -1, pierce: true } },
{ command: "DOM.querySelector", params: { nodeId: 1, selector: "#file" } },
]);
});
test("upload reports missing cwd before setting files on the file input", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.snapshotElements = [
{
role: "textbox",
tagName: "input",
text: "",
selector: "#file",
attributes: { id: "file", type: "file" },
},
];
await browser.snapshot();
const result = await browser.execute({
command: "upload",
args: { browserId: BROWSER_A, ref: "@e1", filePaths: ["uploads/a.txt"] },
});
expect(result).toEqual({
requestId: "req-upload",
ok: false,
error: {
code: "browser_unsupported",
message: "browser_upload requires request cwd",
retryable: false,
},
});
expect(browser.tab.debugCommands).toEqual([
{ command: "DOM.getDocument", params: { depth: -1, pierce: true } },
{ command: "DOM.querySelector", params: { nodeId: 1, selector: "#file" } },
]);
});
});