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.
This commit is contained in:
Mohamed Boudra
2026-07-03 11:34:48 +02:00
parent cc21262130
commit ab62a023fd
15 changed files with 358 additions and 1652 deletions
@@ -1,5 +1,5 @@
import type { Rectangle } from "electron";
import { describe, expect, test, vi } from "vitest";
import { describe, expect, test } from "vitest";
import type { TabImage } from "./service.js";
import { adaptWebContents } from "./ipc.js";
@@ -14,79 +14,43 @@ class FakeImage implements TabImage {
}
class FakeDebugger {
public attachedProtocolVersions: string[] = [];
public commands: Array<{ command: string; params: Record<string, unknown> }> = [];
public isAttached(): boolean {
return false;
return this.attachedProtocolVersions.length > 0;
}
public attach(): void {}
public attach(protocolVersion?: string): void {
this.attachedProtocolVersions.push(protocolVersion ?? "");
}
public async sendCommand(): Promise<unknown> {
return {};
public async sendCommand(command: string, params?: Record<string, unknown>): Promise<unknown> {
this.commands.push({ command, params: params ?? {} });
return { ok: true };
}
}
class FakeHostWebContents {
public readonly sentMessages: Array<{ channel: string; payload: unknown }> = [];
public destroyed = false;
public constructor(public readonly id: number) {}
public isDestroyed(): boolean {
return this.destroyed;
}
public send(channel: string, payload: unknown): void {
this.sentMessages.push({ channel, payload });
}
}
interface FakeIpcEvent {
sender: {
id: number;
};
}
type IpcListener = (event: FakeIpcEvent, payload: unknown) => void;
class FakeIpcBridge {
private readonly listeners = new Map<string, IpcListener[]>();
public on(channel: string, listener: IpcListener): void {
const listeners = this.listeners.get(channel) ?? [];
listeners.push(listener);
this.listeners.set(channel, listeners);
}
public removeListener(channel: string, listener: IpcListener): void {
const listeners = this.listeners.get(channel) ?? [];
this.listeners.set(
channel,
listeners.filter((candidate) => candidate !== listener),
);
}
public emit(channel: string, payload: unknown, input: { senderId?: number } = {}): void {
const event = { sender: { id: input.senderId ?? 10 } };
for (const listener of this.listeners.get(channel) ?? []) {
listener(event, payload);
}
}
public listenerCount(channel: string): number {
return this.listeners.get(channel)?.length ?? 0;
}
}
type ConsoleMessageListener = (
event: unknown,
level: unknown,
message: unknown,
line: unknown,
sourceId: unknown,
) => void;
class FakeWebContents {
public readonly debugger = new FakeDebugger();
public readonly consoleMessages: unknown[] = [];
public readonly destroyedListeners: Array<() => void> = [];
public readonly captures: Array<{
rect: Rectangle | undefined;
options: { stayHidden?: boolean } | undefined;
}> = [];
public readonly invalidations: string[] = [];
private consoleMessageListener: ConsoleMessageListener | null = null;
private destroyedListener: (() => void) | null = null;
public destroyed = false;
public constructor(
public readonly id: number,
public hostWebContents: FakeHostWebContents | null,
) {}
public constructor(public readonly id: number) {}
public getURL(): string {
return "https://example.com";
@@ -125,362 +89,96 @@ class FakeWebContents {
public reload(): void {}
public async capturePage(
_rect?: Rectangle,
_options?: { stayHidden?: boolean },
rect?: Rectangle,
options?: { stayHidden?: boolean },
): Promise<TabImage> {
this.captures.push({ rect, options });
return new FakeImage();
}
public invalidate(): void {}
public getBackgroundThrottling(): boolean {
return true;
public invalidate(): void {
this.invalidations.push("invalidate");
}
public setBackgroundThrottling(): void {}
public on(
event: "console-message",
listener: (
event: unknown,
level: unknown,
message: unknown,
line: unknown,
sourceId: unknown,
) => void,
): void {
this.consoleMessages.push({ event, listener });
public on(event: "console-message", listener: ConsoleMessageListener): void {
expect(event).toBe("console-message");
this.consoleMessageListener = listener;
}
public once(event: "destroyed", listener: () => void): void {
expect(event).toBe("destroyed");
this.destroyedListeners.push(listener);
this.destroyedListener = listener;
}
public emitConsoleMessage(input: {
level: unknown;
message: unknown;
line: unknown;
sourceId: unknown;
}): void {
if (!this.consoleMessageListener) {
throw new Error("Console listener was not registered");
}
this.consoleMessageListener({}, input.level, input.message, input.line, input.sourceId);
}
public destroy(): void {
this.destroyed = true;
this.destroyedListener?.();
}
}
describe("browser automation IPC adapter", () => {
test("prepareForPixelCapture asks the embedder renderer and resolves the ack token", async () => {
const host = new FakeHostWebContents(10);
const contents = new FakeWebContents(20, host);
const ipc = new FakeIpcBridge();
const tab = adaptWebContents(contents, "browser-a", {
ipc,
createRequestId: () => "prepare-1",
test("delegates viewport capture to the guest without a renderer prep bridge", async () => {
const contents = new FakeWebContents(20);
const tab = adaptWebContents(contents);
const image = await tab.capturePage({ stayHidden: false });
tab.invalidate();
expect(image.getSize()).toEqual({ width: 640, height: 480 });
expect(contents.captures).toEqual([{ rect: undefined, options: { stayHidden: false } }]);
expect(contents.invalidations).toEqual(["invalidate"]);
});
test("collects console messages until the guest is destroyed", () => {
const contents = new FakeWebContents(21);
const tab = adaptWebContents(contents);
contents.emitConsoleMessage({
level: "warning",
message: "hello",
line: 12,
sourceId: "https://example.com/app.js",
});
const preparation = tab.prepareForPixelCapture();
expect(host.sentMessages).toEqual([
expect(tab.getConsoleMessages?.()).toEqual([
{
channel: "paseo:browser:capture-prepare",
payload: { requestId: "prepare-1", browserId: "browser-a" },
level: "warning",
message: "hello",
line: 12,
source: "https://example.com/app.js",
timestamp: expect.any(Number),
},
]);
ipc.emit("paseo:browser:capture-prepared", {
requestId: "other",
ok: true,
token: "wrong-token",
});
expect(ipc.listenerCount("paseo:browser:capture-prepared")).toBe(1);
ipc.emit("paseo:browser:capture-prepared", {
requestId: "prepare-1",
ok: true,
token: "token-a",
});
contents.destroy();
await expect(preparation).resolves.toEqual({ token: "token-a" });
expect(ipc.listenerCount("paseo:browser:capture-prepared")).toBe(0);
expect(tab.getConsoleMessages?.()).toEqual([]);
});
test("restorePixelCapture sends the capture token back to the embedder renderer", async () => {
const host = new FakeHostWebContents(10);
const contents = new FakeWebContents(20, host);
const ipc = new FakeIpcBridge();
const requestIds = ["prepare-1", "restore-1"];
const tab = adaptWebContents(contents, "browser-a", {
ipc,
createRequestId: () => {
const requestId = requestIds.shift();
if (!requestId) {
throw new Error("Missing request id");
}
return requestId;
},
test("attaches the debugger before sending a CDP command", async () => {
const contents = new FakeWebContents(22);
const tab = adaptWebContents(contents);
const result = await tab.sendDebugCommand?.("Page.captureScreenshot", {
format: "png",
});
const preparation = tab.prepareForPixelCapture();
ipc.emit("paseo:browser:capture-prepared", {
requestId: "prepare-1",
ok: true,
token: "token-a",
});
await expect(preparation).resolves.toEqual({ token: "token-a" });
const restored = tab.restorePixelCapture({ token: "token-a" });
expect(host.sentMessages).toEqual([
{
channel: "paseo:browser:capture-prepare",
payload: { requestId: "prepare-1", browserId: "browser-a" },
},
{
channel: "paseo:browser:capture-restore",
payload: { requestId: "restore-1", browserId: "browser-a", token: "token-a" },
},
expect(result).toEqual({ ok: true });
expect(contents.debugger.attachedProtocolVersions).toEqual(["1.3"]);
expect(contents.debugger.commands).toEqual([
{ command: "Page.captureScreenshot", params: { format: "png" } },
]);
ipc.emit("paseo:browser:capture-restored", { requestId: "restore-1", ok: true });
await expect(restored).resolves.toBeUndefined();
});
test("restorePixelCapture uses the host captured during preparation when the guest detaches", async () => {
const host = new FakeHostWebContents(10);
const contents = new FakeWebContents(20, host);
const ipc = new FakeIpcBridge();
const requestIds = ["prepare-1", "restore-1"];
const tab = adaptWebContents(contents, "browser-a", {
ipc,
createRequestId: () => {
const requestId = requestIds.shift();
if (!requestId) {
throw new Error("Missing request id");
}
return requestId;
},
});
const preparationPromise = tab.prepareForPixelCapture();
ipc.emit("paseo:browser:capture-prepared", {
requestId: "prepare-1",
ok: true,
token: "token-a",
});
const preparation = await preparationPromise;
contents.hostWebContents = null;
const restored = tab.restorePixelCapture(preparation);
expect(host.sentMessages).toEqual([
{
channel: "paseo:browser:capture-prepare",
payload: { requestId: "prepare-1", browserId: "browser-a" },
},
{
channel: "paseo:browser:capture-restore",
payload: { requestId: "restore-1", browserId: "browser-a", token: "token-a" },
},
]);
ipc.emit("paseo:browser:capture-restored", { requestId: "restore-1", ok: true });
await expect(restored).resolves.toBeUndefined();
await expect(tab.restorePixelCapture(preparation)).rejects.toThrow(
"Browser pixel capture preparation is no longer active.",
);
});
test("prepareForPixelCapture rejects when the renderer reports preparation failure", async () => {
const host = new FakeHostWebContents(10);
const contents = new FakeWebContents(20, host);
const ipc = new FakeIpcBridge();
const tab = adaptWebContents(contents, "browser-a", {
ipc,
createRequestId: () => "prepare-1",
});
const preparation = tab.prepareForPixelCapture();
ipc.emit("paseo:browser:capture-prepared", {
requestId: "prepare-1",
ok: false,
message: "renderer could not prep",
});
await expect(preparation).rejects.toThrow("renderer could not prep");
expect(ipc.listenerCount("paseo:browser:capture-prepared")).toBe(0);
});
test("prepareForPixelCapture waits for the guest host renderer before asking for prep", async () => {
vi.useFakeTimers();
try {
const host = new FakeHostWebContents(10);
const contents = new FakeWebContents(20, null);
const ipc = new FakeIpcBridge();
const tab = adaptWebContents(contents, "browser-a", {
ipc,
createRequestId: () => "prepare-1",
timeoutMs: 250,
});
const preparation = tab.prepareForPixelCapture();
await vi.advanceTimersByTimeAsync(49);
expect(host.sentMessages).toEqual([]);
contents.hostWebContents = host;
await vi.advanceTimersByTimeAsync(1);
expect(host.sentMessages).toEqual([
{
channel: "paseo:browser:capture-prepare",
payload: { requestId: "prepare-1", browserId: "browser-a" },
},
]);
ipc.emit("paseo:browser:capture-prepared", {
requestId: "prepare-1",
ok: true,
token: "token-a",
});
await expect(preparation).resolves.toEqual({ token: "token-a" });
} finally {
vi.useRealTimers();
}
});
test("prepareForPixelCapture retries when the renderer prep handler is not registered yet", async () => {
vi.useFakeTimers();
try {
const host = new FakeHostWebContents(10);
const contents = new FakeWebContents(20, host);
const ipc = new FakeIpcBridge();
const requestIds = ["prepare-1", "prepare-2"];
const tab = adaptWebContents(contents, "browser-a", {
ipc,
createRequestId: () => {
const requestId = requestIds.shift();
if (!requestId) {
throw new Error("Missing request id");
}
return requestId;
},
timeoutMs: 250,
});
const preparation = tab.prepareForPixelCapture();
expect(host.sentMessages).toEqual([
{
channel: "paseo:browser:capture-prepare",
payload: { requestId: "prepare-1", browserId: "browser-a" },
},
]);
ipc.emit("paseo:browser:capture-prepared", {
requestId: "prepare-1",
ok: false,
message: "Browser pixel capture preparation is unavailable.",
});
await vi.advanceTimersByTimeAsync(50);
expect(host.sentMessages).toEqual([
{
channel: "paseo:browser:capture-prepare",
payload: { requestId: "prepare-1", browserId: "browser-a" },
},
{
channel: "paseo:browser:capture-prepare",
payload: { requestId: "prepare-2", browserId: "browser-a" },
},
]);
ipc.emit("paseo:browser:capture-prepared", {
requestId: "prepare-2",
ok: true,
token: "token-a",
});
await expect(preparation).resolves.toEqual({ token: "token-a" });
} finally {
vi.useRealTimers();
}
});
test("prepareForPixelCapture ignores matching responses from the wrong sender", async () => {
const host = new FakeHostWebContents(10);
const contents = new FakeWebContents(20, host);
const ipc = new FakeIpcBridge();
const tab = adaptWebContents(contents, "browser-a", {
ipc,
createRequestId: () => "prepare-1",
});
const preparation = tab.prepareForPixelCapture();
ipc.emit(
"paseo:browser:capture-prepared",
{
requestId: "prepare-1",
ok: true,
token: "spoofed-token",
},
{ senderId: 99 },
);
expect(ipc.listenerCount("paseo:browser:capture-prepared")).toBe(1);
ipc.emit("paseo:browser:capture-prepared", {
requestId: "prepare-1",
ok: true,
token: "token-a",
});
await expect(preparation).resolves.toEqual({ token: "token-a" });
});
test("prepareForPixelCapture cancels the renderer request when the ack times out", async () => {
vi.useFakeTimers();
try {
const host = new FakeHostWebContents(10);
const contents = new FakeWebContents(20, host);
const ipc = new FakeIpcBridge();
const tab = adaptWebContents(contents, "browser-a", {
ipc,
createRequestId: () => "prepare-1",
timeoutMs: 25,
});
const preparation = tab.prepareForPixelCapture();
const rejection = expect(preparation).rejects.toThrow(
"Browser pixel capture prepare timed out.",
);
await vi.advanceTimersByTimeAsync(25);
await rejection;
expect(host.sentMessages).toEqual([
{
channel: "paseo:browser:capture-prepare",
payload: { requestId: "prepare-1", browserId: "browser-a" },
},
{
channel: "paseo:browser:capture-cancel",
payload: { requestId: "prepare-1", browserId: "browser-a" },
},
]);
expect(ipc.listenerCount("paseo:browser:capture-prepared")).toBe(0);
} finally {
vi.useRealTimers();
}
});
test("prepareForPixelCapture reports prep_unavailable when the guest host renderer never appears", async () => {
vi.useFakeTimers();
try {
const contents = new FakeWebContents(20, null);
const tab = adaptWebContents(contents, "browser-a", { timeoutMs: 25 });
const preparation = tab.prepareForPixelCapture();
const rejection = expect(preparation).rejects.toThrow(
"Browser screenshot prep_unavailable: Browser host renderer is not available.",
);
await vi.advanceTimersByTimeAsync(25);
await rejection;
} finally {
vi.useRealTimers();
}
});
test("prepareForPixelCapture rejects when the guest has no embedder renderer", async () => {
const contents = new FakeWebContents(20, null);
const tab = adaptWebContents(contents, "browser-a", { timeoutMs: 1 });
await expect(tab.prepareForPixelCapture()).rejects.toThrow("prep_unavailable");
});
});
@@ -13,36 +13,13 @@ import {
} from "../browser-webviews/index.js";
const MAX_CONSOLE_MESSAGES_PER_TAB = 200;
const PIXEL_CAPTURE_BRIDGE_TIMEOUT_MS = 5_000;
const PIXEL_CAPTURE_PREP_RETRY_INTERVAL_MS = 50;
const PIXEL_CAPTURE_PREP_UNAVAILABLE_PREFIX = "Browser screenshot prep_unavailable:";
const consoleMessagesByContentsId = new Map<number, BrowserAutomationConsoleLogEntry[]>();
const observedContentsIds = new Set<number>();
let nextPixelCaptureBridgeRequest = 0;
interface IpcHandlerRegistry {
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): void;
}
interface IpcBridgeEvent {
sender?: {
id?: number;
};
}
type IpcListener = (event: IpcBridgeEvent, payload: unknown) => void;
interface IpcCaptureBridge {
on(channel: string, listener: IpcListener): void;
removeListener(channel: string, listener: IpcListener): void;
}
interface HostWebContents {
readonly id: number;
isDestroyed(): boolean;
send(channel: string, payload: unknown): void;
}
interface WebContentsDebugger {
isAttached(): boolean;
attach(protocolVersion?: string): void;
@@ -65,7 +42,6 @@ interface ConsoleMessageEmitter {
interface BrowserAutomationWebContents extends ConsoleMessageEmitter {
readonly id: number;
readonly hostWebContents: HostWebContents | null;
readonly debugger: WebContentsDebugger;
getURL(): string;
getTitle(): string;
@@ -80,34 +56,10 @@ interface BrowserAutomationWebContents extends ConsoleMessageEmitter {
reload(): void;
capturePage(rect?: Rectangle, options?: { stayHidden?: boolean }): Promise<TabImage>;
invalidate(): void;
getBackgroundThrottling(): boolean;
setBackgroundThrottling(allowed: boolean): void;
}
type PixelCaptureBridgeKind = "prepare" | "restore";
interface PixelCaptureBridgeSuccess {
token?: string;
}
interface PixelCaptureBridgeOptions {
ipc?: IpcCaptureBridge;
createRequestId?: () => string;
timeoutMs?: number;
}
interface PreparedPixelCapture {
browserId: string;
host: HostWebContents;
}
export function adaptWebContents(
contents: BrowserAutomationWebContents,
browserId: string,
options?: PixelCaptureBridgeOptions,
): TabContents {
export function adaptWebContents(contents: BrowserAutomationWebContents): TabContents {
observeConsoleMessages(contents);
const preparedPixelCapturesByToken = new Map<string, PreparedPixelCapture>();
return {
id: contents.id,
getURL: () => contents.getURL(),
@@ -122,34 +74,7 @@ export function adaptWebContents(
goForward: () => contents.goForward(),
reload: () => contents.reload(),
capturePage: (captureOptions) => contents.capturePage(undefined, captureOptions),
prepareForPixelCapture: async () => {
const result = await preparePixelCaptureBridgeWithRetry({ contents, browserId, options });
if (!result.token) {
throw new Error("Browser pixel capture preparation did not return a token.");
}
preparedPixelCapturesByToken.set(result.token, { browserId, host: result.host });
return { token: result.token };
},
restorePixelCapture: async (preparation) => {
const prepared = preparedPixelCapturesByToken.get(preparation.token);
if (!prepared) {
throw new Error("Browser pixel capture preparation is no longer active.");
}
try {
await requestPixelCaptureBridge({
host: prepared.host,
browserId: prepared.browserId,
kind: "restore",
options,
extraPayload: { token: preparation.token },
});
} finally {
preparedPixelCapturesByToken.delete(preparation.token);
}
},
invalidate: () => contents.invalidate(),
isBackgroundThrottlingAllowed: () => contents.getBackgroundThrottling(),
setBackgroundThrottling: (allowed) => contents.setBackgroundThrottling(allowed),
getConsoleMessages: () => consoleMessagesByContentsId.get(contents.id) ?? [],
sendDebugCommand: async (command: string, params?: Record<string, unknown>) => {
if (!contents.debugger.isAttached()) {
@@ -160,204 +85,6 @@ export function adaptWebContents(
};
}
function getPixelCaptureHost(contents: BrowserAutomationWebContents): HostWebContents | null {
const host = contents.hostWebContents;
if (!host || host.isDestroyed()) {
return null;
}
return host;
}
async function preparePixelCaptureBridgeWithRetry(input: {
contents: BrowserAutomationWebContents;
browserId: string;
options: PixelCaptureBridgeOptions | undefined;
}): Promise<PixelCaptureBridgeSuccess & { host: HostWebContents }> {
const timeoutMs = input.options?.timeoutMs ?? PIXEL_CAPTURE_BRIDGE_TIMEOUT_MS;
const deadline = Date.now() + timeoutMs;
let lastUnavailableReason = "Browser host renderer is not available.";
while (Date.now() < deadline) {
const host = getPixelCaptureHost(input.contents);
if (!host) {
await delayUntilRetry(deadline);
continue;
}
try {
const result = await requestPixelCaptureBridge({
host,
browserId: input.browserId,
kind: "prepare",
options: withBridgeTimeout(input.options, Math.max(1, deadline - Date.now())),
});
return { ...result, host };
} catch (error) {
if (!isRetryablePrepareUnavailableError(error)) {
throw error;
}
lastUnavailableReason = errorMessage(error);
await delayUntilRetry(deadline);
}
}
throw new Error(`${PIXEL_CAPTURE_PREP_UNAVAILABLE_PREFIX} ${lastUnavailableReason}`);
}
function withBridgeTimeout(
options: PixelCaptureBridgeOptions | undefined,
timeoutMs: number,
): PixelCaptureBridgeOptions {
return {
...options,
timeoutMs,
};
}
function isRetryablePrepareUnavailableError(error: unknown): boolean {
const message = errorMessage(error);
return (
message.includes("Browser pixel capture preparation is unavailable.") ||
message.includes("Browser host renderer is not available.") ||
message.includes("is not mounted.")
);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function delayUntilRetry(deadline: number): Promise<void> {
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) {
return;
}
await new Promise((resolve) => {
setTimeout(resolve, Math.min(PIXEL_CAPTURE_PREP_RETRY_INTERVAL_MS, remainingMs));
});
}
function requestPixelCaptureBridge(input: {
host: HostWebContents;
browserId: string;
kind: PixelCaptureBridgeKind;
options: PixelCaptureBridgeOptions | undefined;
extraPayload?: { token: string };
}): Promise<PixelCaptureBridgeSuccess> {
const { host, browserId, kind, options, extraPayload } = input;
if (host.isDestroyed()) {
return Promise.reject(new Error("Browser host renderer is not available."));
}
const ipc = options?.ipc ?? ipcMain;
const requestId =
options?.createRequestId?.() ?? `browser-pixel-capture-${++nextPixelCaptureBridgeRequest}`;
const timeoutMs = options?.timeoutMs ?? PIXEL_CAPTURE_BRIDGE_TIMEOUT_MS;
const requestChannel =
kind === "prepare" ? "paseo:browser:capture-prepare" : "paseo:browser:capture-restore";
const responseChannel =
kind === "prepare" ? "paseo:browser:capture-prepared" : "paseo:browser:capture-restored";
return new Promise((resolve, reject) => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const cleanup = () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
ipc.removeListener(responseChannel, listener);
};
const listener: IpcListener = (event, payload) => {
if (readSenderId(event) !== host.id) {
return;
}
const response = readPixelCaptureBridgeResponse(payload, requestId);
if (!response) {
return;
}
cleanup();
if (response.ok) {
resolve(response);
} else {
reject(new Error(response.message));
}
};
ipc.on(responseChannel, listener);
timeoutId = setTimeout(() => {
cleanup();
sendPixelCaptureCancel(host, {
requestId,
browserId,
...(extraPayload ? { token: extraPayload.token } : {}),
});
reject(new Error(`Browser pixel capture ${kind} timed out.`));
}, timeoutMs);
try {
host.send(requestChannel, {
requestId,
browserId,
...(extraPayload ? { token: extraPayload.token } : {}),
});
} catch (error) {
cleanup();
reject(error);
}
});
}
function sendPixelCaptureCancel(
host: HostWebContents,
payload: { browserId: string; requestId?: string; token?: string },
): void {
if (host.isDestroyed()) {
return;
}
try {
host.send("paseo:browser:capture-cancel", payload);
} catch {
// The original prepare/restore request owns the user-visible error.
}
}
function readSenderId(event: IpcBridgeEvent): number | null {
const senderId = event.sender?.id;
return typeof senderId === "number" ? senderId : null;
}
function readPixelCaptureBridgeResponse(
payload: unknown,
requestId: string,
): ({ ok: true } & PixelCaptureBridgeSuccess) | { ok: false; message: string } | null {
if (!isRecord(payload)) {
return null;
}
const record = payload;
if (record.requestId !== requestId) {
return null;
}
if (record.ok === true) {
return {
ok: true,
...(typeof record.token === "string" && record.token.length > 0
? { token: record.token }
: {}),
};
}
if (record.ok === false) {
const message =
typeof record.message === "string" && record.message.length > 0
? record.message
: "Browser pixel capture bridge failed.";
return { ok: false, message };
}
return null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function observeConsoleMessages(contents: BrowserAutomationWebContents): void {
if (observedContentsIds.has(contents.id)) {
return;
@@ -398,7 +125,7 @@ function createRegistry(): BrowserRegistry {
listRegisteredBrowserIdsForWorkspace: listRegisteredPaseoBrowserIdsForWorkspace,
getTabContents(browserId: string): TabContents | null {
const contents = getPaseoBrowserWebContents(browserId);
return contents ? adaptWebContents(contents, browserId) : null;
return contents ? adaptWebContents(contents) : null;
},
getBrowserWorkspaceId: getPaseoBrowserWorkspaceId,
getWorkspaceActiveBrowserId: getWorkspaceActivePaseoBrowserId,
@@ -7,12 +7,7 @@ import type {
BrowserAutomationExecuteRequest,
} from "@getpaseo/protocol/browser-automation/rpc-schemas";
import { BrowserSnapshotEngine } from "./snapshot-engine.js";
import type {
BrowserRegistry,
TabContents,
TabImage,
TabPixelCapturePreparation,
} from "./service.js";
import type { BrowserRegistry, TabContents, TabImage } from "./service.js";
import { executeAutomationCommand } from "./service.js";
const BROWSER_A = "11111111-1111-4111-8111-111111111111";
@@ -41,7 +36,6 @@ class FakeTab implements TabContents {
public readonly actions: string[] = [];
public readonly capturedViewports: Array<{ stayHidden?: boolean }> = [];
public readonly debugCommands: Array<{ command: string; params?: Record<string, unknown> }> = [];
public readonly restoredPixelCaptureTokens: string[] = [];
private readonly captureStartWaiters: Array<() => void> = [];
private readonly deferredCaptures: Array<(image: TabImage) => void> = [];
@@ -56,8 +50,6 @@ class FakeTab implements TabContents {
public captureErrorMessage = "capture failed";
public viewportCaptureFailuresBeforeSuccess = 0;
public deferCaptures = false;
public prepareNeverAcks = false;
public prepareErrorMessage: string | null = null;
public fullPageScreenshotThrows = false;
public fullPageScreenshotErrorMessage = "UnknownVizError";
public fullPageCaptureFailuresBeforeSuccess = 0;
@@ -68,8 +60,6 @@ class FakeTab implements TabContents {
public fullPageScreenshotData = "fullPagePng";
public documentNodeId = 1;
public queriedNodeId = 2;
public backgroundThrottlingAllowed = true;
private nextPixelCapturePreparationId = 0;
public constructor(
public readonly id: number,
@@ -153,36 +143,10 @@ class FakeTab implements TabContents {
return new FakeImage();
}
public async prepareForPixelCapture(): Promise<TabPixelCapturePreparation> {
this.actions.push("prepare");
if (this.prepareErrorMessage) {
throw new Error(this.prepareErrorMessage);
}
if (this.prepareNeverAcks) {
return new Promise<never>(() => {});
}
const token = `capture-${++this.nextPixelCapturePreparationId}`;
return { token };
}
public async restorePixelCapture(preparation: TabPixelCapturePreparation): Promise<void> {
this.actions.push(`restore:${preparation.token}`);
this.restoredPixelCaptureTokens.push(preparation.token);
}
public invalidate(): void {
this.actions.push("invalidate");
}
public isBackgroundThrottlingAllowed(): boolean {
return this.backgroundThrottlingAllowed;
}
public setBackgroundThrottling(allowed: boolean): void {
this.backgroundThrottlingAllowed = allowed;
this.actions.push(`background:${allowed}`);
}
public getConsoleMessages(): BrowserAutomationConsoleLogEntry[] {
return this.consoleMessages;
}
@@ -913,7 +877,7 @@ describe("executeAutomationCommand", () => {
});
});
test("screenshot serializes the painted viewport and restores throttling", async () => {
test("screenshot captures the painted viewport", async () => {
const browser = new BrowserAutomationHarness();
const result = await browser.execute({
@@ -934,14 +898,7 @@ describe("executeAutomationCommand", () => {
},
});
expect(browser.tab.capturedViewports).toEqual([{ stayHidden: false }]);
expect(browser.tab.actions).toEqual([
"prepare",
"background:false",
"invalidate",
"capture",
"restore:capture-1",
"background:true",
]);
expect(browser.tab.actions).toEqual(["invalidate", "capture"]);
});
test("screenshot returns no-frame when the viewport never paints", async () => {
@@ -961,25 +918,17 @@ describe("executeAutomationCommand", () => {
ok: false,
error: {
code: "screenshot_no_frame",
message:
"The browser tab has no painted frame. Focus the tab in the app, then try again.",
retryable: false,
message: "The tab has not painted yet. Retry the screenshot.",
retryable: true,
},
});
expect(browser.tab.actions).toEqual([
"prepare",
"background:false",
"invalidate",
"capture",
"restore:capture-1",
"background:true",
]);
expect(browser.tab.actions).toEqual(["invalidate", "capture"]);
} finally {
vi.useRealTimers();
}
});
test("screenshot restores capture preparation when viewport capture fails with an ordinary error", async () => {
test("screenshot surfaces ordinary viewport capture errors", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.captureThrows = true;
@@ -990,14 +939,7 @@ describe("executeAutomationCommand", () => {
}),
).rejects.toThrow("capture failed");
expect(browser.tab.actions).toEqual([
"prepare",
"background:false",
"invalidate",
"capture",
"restore:capture-1",
"background:true",
]);
expect(browser.tab.actions).toEqual(["invalidate", "capture"]);
});
test("screenshot retries UnknownVizError until the first viewport frame appears", async () => {
@@ -1027,16 +969,12 @@ describe("executeAutomationCommand", () => {
});
expect(browser.tab.capturedViewports).toHaveLength(3);
expect(browser.tab.actions).toEqual([
"prepare",
"background:false",
"invalidate",
"capture",
"invalidate",
"capture",
"invalidate",
"capture",
"restore:capture-1",
"background:true",
]);
} finally {
vi.useRealTimers();
@@ -1061,74 +999,20 @@ describe("executeAutomationCommand", () => {
ok: false,
error: {
code: "screenshot_no_frame",
message:
"The browser tab has no painted frame. Focus the tab in the app, then try again.",
retryable: false,
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,
);
expect(browser.tab.restoredPixelCaptureTokens).toEqual(["capture-1"]);
expect(browser.tab.actions.at(-2)).toBe("restore:capture-1");
expect(browser.tab.actions.at(-1)).toBe("background:true");
} finally {
vi.useRealTimers();
}
});
test("screenshot returns no-frame when capture preparation does not ack", async () => {
vi.useFakeTimers();
try {
const browser = new BrowserAutomationHarness();
browser.tab.prepareNeverAcks = 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:
"Browser screenshot prep_unavailable: the app renderer did not acknowledge capture preparation before the timeout.",
retryable: false,
},
});
expect(browser.tab.actions).toEqual(["prepare", "background:true"]);
} finally {
vi.useRealTimers();
}
});
test("screenshot returns prep_unavailable and does not capture when preparation cannot be honored", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.prepareErrorMessage =
"Browser screenshot prep_unavailable: Browser host renderer is not available.";
const result = await browser.execute({
command: "screenshot",
args: { browserId: BROWSER_A },
});
expect(result).toEqual({
requestId: "req-screenshot",
ok: false,
error: {
code: "screenshot_no_frame",
message: "Browser screenshot prep_unavailable: Browser host renderer is not available.",
retryable: false,
},
});
expect(browser.tab.actions).toEqual(["prepare", "background:true"]);
});
test("overlapping screenshots serialize capture preparation and restore", async () => {
test("overlapping screenshots serialize through the shared capture queue", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.deferCaptures = true;
@@ -1147,44 +1031,20 @@ describe("executeAutomationCommand", () => {
);
await Promise.resolve();
expect(browser.tab.actions).toEqual(["prepare", "background:false", "invalidate", "capture"]);
expect(browser.tab.actions).toEqual(["invalidate", "capture"]);
browser.tab.finishNextCapture();
await browser.tab.waitForCaptureStart(2);
expect(browser.tab.actions).toEqual([
"prepare",
"background:false",
"invalidate",
"capture",
"restore:capture-1",
"background:true",
"prepare",
"background:false",
"invalidate",
"capture",
]);
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([
"prepare",
"background:false",
"invalidate",
"capture",
"restore:capture-1",
"background:true",
"prepare",
"background:false",
"invalidate",
"capture",
"restore:capture-2",
"background:true",
]);
expect(browser.tab.actions).toEqual(["invalidate", "capture", "invalidate", "capture"]);
});
test("overlapping screenshots across browser tabs serialize capture preparation and restore", async () => {
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");
@@ -1214,33 +1074,19 @@ describe("executeAutomationCommand", () => {
);
await Promise.resolve();
expect(firstTab.actions).toEqual(["prepare", "background:false", "invalidate", "capture"]);
expect(firstTab.actions).toEqual(["invalidate", "capture"]);
expect(secondTab.actions).toEqual([]);
firstTab.finishNextCapture();
await secondTab.waitForCaptureStart(1);
expect(firstTab.actions).toEqual([
"prepare",
"background:false",
"invalidate",
"capture",
"restore:capture-1",
"background:true",
]);
expect(secondTab.actions).toEqual(["prepare", "background:false", "invalidate", "capture"]);
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([
"prepare",
"background:false",
"invalidate",
"capture",
"restore:capture-1",
"background:true",
]);
expect(secondTab.actions).toEqual(["invalidate", "capture"]);
});
test("screenshot with fullPage captures the page content area through CDP", async () => {
@@ -1275,17 +1121,13 @@ describe("executeAutomationCommand", () => {
},
]);
expect(browser.tab.actions).toEqual([
"prepare",
"background:false",
"invalidate",
"debug:Page.getLayoutMetrics",
"debug:Page.captureScreenshot",
"restore:capture-1",
"background:true",
]);
});
test("screenshot with fullPage restores capture preparation when CDP returns no image", async () => {
test("screenshot with fullPage returns unsupported when CDP returns no image", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.fullPageScreenshotData = "";
@@ -1304,13 +1146,9 @@ describe("executeAutomationCommand", () => {
},
});
expect(browser.tab.actions).toEqual([
"prepare",
"background:false",
"invalidate",
"debug:Page.getLayoutMetrics",
"debug:Page.captureScreenshot",
"restore:capture-1",
"background:true",
]);
});
@@ -1339,23 +1177,19 @@ describe("executeAutomationCommand", () => {
},
});
expect(browser.tab.actions).toEqual([
"prepare",
"background:false",
"invalidate",
"debug:Page.getLayoutMetrics",
"debug:Page.captureScreenshot",
"invalidate",
"debug:Page.getLayoutMetrics",
"debug:Page.captureScreenshot",
"restore:capture-1",
"background:true",
]);
} finally {
vi.useRealTimers();
}
});
test("screenshot with fullPage restores capture preparation when CDP capture fails with an ordinary error", async () => {
test("screenshot with fullPage surfaces ordinary CDP capture errors", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.fullPageScreenshotThrows = true;
browser.tab.fullPageScreenshotErrorMessage = "Debugger detached";
@@ -1367,13 +1201,9 @@ describe("executeAutomationCommand", () => {
}),
).rejects.toThrow("Debugger detached");
expect(browser.tab.actions).toEqual([
"prepare",
"background:false",
"invalidate",
"debug:Page.getLayoutMetrics",
"debug:Page.captureScreenshot",
"restore:capture-1",
"background:true",
]);
});
@@ -24,19 +24,11 @@ export interface TabContents {
goForward(): void;
reload(): void;
capturePage(options?: TabCapturePageOptions): Promise<TabImage>;
prepareForPixelCapture(): Promise<TabPixelCapturePreparation>;
restorePixelCapture(preparation: TabPixelCapturePreparation): Promise<void>;
invalidate(): void;
isBackgroundThrottlingAllowed(): boolean;
setBackgroundThrottling(allowed: boolean): void;
getConsoleMessages?(): BrowserAutomationConsoleLogEntry[];
sendDebugCommand?(command: string, params?: Record<string, unknown>): Promise<unknown>;
}
export interface TabPixelCapturePreparation {
token: string;
}
export interface TabImage {
toPNG(): Uint8Array;
getSize(): { width: number; height: number };
@@ -62,9 +54,7 @@ const DEFAULT_WAIT_TIMEOUT_MS = 5_000;
const WAIT_POLL_INTERVAL_MS = 25;
const PIXEL_CAPTURE_TIMEOUT_MS = 5_000;
const PIXEL_CAPTURE_RETRY_INTERVAL_MS = 200;
const SCREENSHOT_NO_FRAME_MESSAGE =
"The browser tab has no painted frame. Focus the tab in the app, then try again.";
const SCREENSHOT_PREP_UNAVAILABLE_PREFIX = "Browser screenshot prep_unavailable:";
const SCREENSHOT_NO_FRAME_MESSAGE = "The tab has not painted yet. Retry the screenshot.";
const ALLOWED_PAGE_URL_PROTOCOLS = new Set(["http:", "https:"]);
let pixelCaptureQueue: Promise<void> = Promise.resolve();
@@ -92,7 +82,7 @@ function screenshotNoFrameFailure(
requestId: string,
error: ScreenshotNoFrameError,
): FailurePayload {
return fail(requestId, "screenshot_no_frame", error.message);
return fail(requestId, "screenshot_no_frame", error.message, true);
}
async function withPixelCaptureTimeout<T>(
@@ -138,26 +128,7 @@ async function runSerializedPixelCapture<T>(capture: () => Promise<T>): Promise<
}
}
async function prepareForPixelCapture(contents: TabContents): Promise<TabPixelCapturePreparation> {
try {
return await withPixelCaptureTimeout(contents.prepareForPixelCapture());
} catch (error) {
throw screenshotPreparationError(error);
}
}
async function restorePixelCapture(
contents: TabContents,
preparation: TabPixelCapturePreparation,
): Promise<void> {
try {
await withPixelCaptureTimeout(contents.restorePixelCapture(preparation));
} catch {
throw new ScreenshotNoFrameError();
}
}
async function capturePreparedPixelFrame<T>(
async function capturePixelFrameWithRetry<T>(
contents: TabContents,
capture: () => Promise<T>,
): Promise<T> {
@@ -179,26 +150,6 @@ async function capturePreparedPixelFrame<T>(
throw new ScreenshotNoFrameError();
}
function screenshotPreparationError(error: unknown): ScreenshotNoFrameError {
const message = error instanceof Error ? error.message : String(error);
if (isScreenshotNoFrameError(error)) {
return new ScreenshotNoFrameError(
`${SCREENSHOT_PREP_UNAVAILABLE_PREFIX} the app renderer did not acknowledge capture preparation before the timeout.`,
);
}
if (message.includes(SCREENSHOT_PREP_UNAVAILABLE_PREFIX)) {
return new ScreenshotNoFrameError(message);
}
if (
message.includes("Browser pixel capture preparation is unavailable.") ||
message.includes("Browser host renderer is not available.") ||
message.includes("is not mounted.")
) {
return new ScreenshotNoFrameError(`${SCREENSHOT_PREP_UNAVAILABLE_PREFIX} ${message}`);
}
return new ScreenshotNoFrameError(`${SCREENSHOT_PREP_UNAVAILABLE_PREFIX} ${message}`);
}
function isKnownNoFrameCaptureError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
@@ -208,34 +159,15 @@ function isKnownNoFrameCaptureError(error: unknown): boolean {
);
}
async function runPreparedPixelCapture<T>(
async function runPaintedPixelCapture<T>(
contents: TabContents,
capture: () => Promise<T>,
): Promise<T> {
return runSerializedPixelCapture(async () => {
const previousBackgroundThrottling = contents.isBackgroundThrottlingAllowed();
let preparation: TabPixelCapturePreparation | null = null;
try {
// Offscreen-parked webview guests have no compositor surface for
// capturePage/CDP to copy. The renderer must briefly make the host
// paintable before capture; see docs/browser-capture-harness.md.
preparation = await prepareForPixelCapture(contents);
contents.setBackgroundThrottling(false);
return await capturePreparedPixelFrame(contents, capture);
} finally {
try {
if (preparation) {
await restorePixelCapture(contents, preparation);
}
} finally {
contents.setBackgroundThrottling(previousBackgroundThrottling);
}
}
});
return runSerializedPixelCapture(() => capturePixelFrameWithRetry(contents, capture));
}
async function capturePaintedViewport(contents: TabContents): Promise<TabImage> {
return runPreparedPixelCapture(contents, () => contents.capturePage({ stayHidden: false }));
return runPaintedPixelCapture(contents, () => contents.capturePage({ stayHidden: false }));
}
function tabInfoFromContents(
@@ -966,7 +898,7 @@ async function executeFullPageScreenshot(
let width = 0;
let height = 0;
try {
screenshot = await runPreparedPixelCapture(target.contents, async () => {
screenshot = await runPaintedPixelCapture(target.contents, async () => {
const metrics = await getCdpLayoutMetrics(target.contents);
width = metrics.contentWidth;
height = metrics.contentHeight;
@@ -0,0 +1,44 @@
import { describe, expect, test } from "vitest";
import { getPaseoBrowserIdForWebContents, registerPaseoBrowserWebContents } from "./index.js";
class FakeRegisteredWebContents {
public readonly backgroundThrottlingCalls: boolean[] = [];
private destroyedListener: (() => void) | null = null;
private destroyed = false;
public constructor(public readonly id: number) {}
public isDestroyed(): boolean {
return this.destroyed;
}
public setBackgroundThrottling(allowed: boolean): void {
this.backgroundThrottlingCalls.push(allowed);
}
public once(event: "destroyed", listener: () => void): void {
expect(event).toBe("destroyed");
this.destroyedListener = listener;
}
public destroy(): void {
this.destroyed = true;
this.destroyedListener?.();
}
}
describe("registerPaseoBrowserWebContents", () => {
test("disables guest background throttling once when the webview is registered", () => {
const contents = new FakeRegisteredWebContents(9001);
registerPaseoBrowserWebContents(contents, "browser-throttle");
expect(contents.backgroundThrottlingCalls).toEqual([false]);
expect(getPaseoBrowserIdForWebContents(contents)).toBe("browser-throttle");
contents.destroy();
expect(getPaseoBrowserIdForWebContents(contents)).toBeNull();
expect(contents.backgroundThrottlingCalls).toEqual([false]);
});
});
@@ -11,6 +11,16 @@ export type { BrowserWorkspaceRegistration };
const browserRegistry = new PaseoBrowserWebviewRegistry();
interface BrowserWebContentsIdentity {
readonly id: number;
isDestroyed(): boolean;
}
interface RegisteredBrowserWebContents extends BrowserWebContentsIdentity {
setBackgroundThrottling(allowed: boolean): void;
once(event: "destroyed", listener: () => void): void;
}
function getBrowserIdFromWebviewPartition(partition: string | undefined): string | null {
const prefix = "persist:paseo-browser-";
if (!partition?.startsWith(prefix)) {
@@ -36,14 +46,20 @@ export function listRegisteredPaseoBrowserIds(): string[] {
.filter((browserId) => getPaseoBrowserWebContents(browserId));
}
export function registerPaseoBrowserWebContents(contents: WebContents, browserId: string): void {
export function registerPaseoBrowserWebContents(
contents: RegisteredBrowserWebContents,
browserId: string,
): void {
contents.setBackgroundThrottling(false);
browserRegistry.registerWebContents({ webContentsId: contents.id, browserId });
contents.once("destroyed", () => {
browserRegistry.unregisterWebContents(contents.id);
});
}
export function getPaseoBrowserIdForWebContents(contents: WebContents | null): string | null {
export function getPaseoBrowserIdForWebContents(
contents: BrowserWebContentsIdentity | null,
): string | null {
if (!contents || contents.isDestroyed()) {
return null;
}