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
+22 -23
View File
@@ -4,14 +4,16 @@ The desktop capture harness is the real-Electron verification path for browser s
It validates the compositor behavior that unit tests cannot see: It validates the compositor behavior that unit tests cannot see:
- the resident automation `<webview>` starts in the production parking state; - the resident automation `<webview>` starts in the production parking state;
- the parked guest has no copyable viewport frame; - the parked guest remains paintable and has a copyable viewport frame;
- the resident webview guest is sized to 1280x800 logical pixels; - the resident webview guest is sized to 1280x800 logical pixels;
- multiple resident webviews are parked as an overlapping stack, and the capture - multiple resident webviews are parked as an overlapping stack without per-capture
target is raised above its sibling webviews before capture; stacking changes;
- a newly attached resident webview whose first useful frame is delayed can be captured - a newly attached resident webview whose first useful frame is delayed can be captured
by holding prep active and retrying until the frame appears; by retrying until the frame appears;
- the app-style prep sequence moves the host to a paintable 1x1 clipped state, waits two animation frames and a layout read, then both viewport `capturePage` and full-page CDP screenshots return real pixels; - both viewport `capturePage` and full-page CDP screenshots return real pixels from
- restore returns the host to offscreen parking after every capture. the permanent production parking state;
- guest background throttling can be disabled once at attach without per-capture
renderer coordination.
Run it with the repo Electron: Run it with the repo Electron:
@@ -34,25 +36,22 @@ The harness writes PNG evidence and `results.json` to:
packages/desktop/capture-harness/out/ packages/desktop/capture-harness/out/
``` ```
A passing run prints `PASS` lines for both guest sizes, the expected parked-capture A passing run prints `PASS` lines for the production P1 attach-off parking state,
failure, the legacy stacked-below-the-clip failure for the second webview, five viewport including fresh, settled, 75-second soak, multi-tab, viewport, and full-page checks. The
prep captures and five full-page prep captures for each of the first two webviews, PNG sizes may be device-pixel scaled; on a Retina display the 1280x800 logical viewport
fresh-delayed-first-frame viewport and full-page captures for a newly attached webview, is usually saved as 2560x1600.
and final completion. The PNG sizes may be device-pixel scaled; on a Retina display the
1280x800 logical viewport is usually saved as 2560x1600.
## Mechanism ## Mechanism
Electron captures copy from the guest web contents' compositor surface. A resident Electron captures copy from the guest web contents' compositor surface. A resident
webview parked at `left:-20000px` and `opacity:0` does not have a copyable surface, and webview parked with `display:none`, offscreen coordinates, or `opacity:0` can lose its
`capturePage({ stayHidden:false })` or CDP `Page.captureScreenshot` cannot rescue it. copyable surface. The production parking state keeps the host fixed at `left:0`, `top:0`,
`width:1px`, `height:1px`, `overflow:hidden`, `opacity:1`, and `pointer-events:none`.
The webviews inside stay full-size at 1280x800, `display:inline-flex`, and absolutely
overlap at `left:0`, `top:0`.
Before pixel capture, the app renderer temporarily makes the resident host paintable: There is no renderer prep/restore handshake. Main disables guest background throttling
`left:0`, `top:0`, `opacity:1`, `pointer-events:none`, host size `1x1`, and once when the webview attaches, then screenshot capture uses the shared serialized queue,
`overflow:hidden`, with the full-size 1280x800 webview inside. Resident webviews are invalidates before each attempt, and retries known first-frame failures within the
parked absolutely at `0,0` inside that host because a second webview stacked below the 5-second capture budget. Viewport screenshots use `capturePage({ stayHidden:false })`;
1px clip still has no copyable surface. During capture, the renderer raises the target full-page screenshots use the existing CDP path with layout metrics and screenshot clip.
webview above the other resident webviews; an overlay or sibling above the target can make
full-page CDP capture fail. Main captures only after the renderer acknowledges two
animation frames plus a `getBoundingClientRect()` read, and the renderer restores parking
in a `finally`.
@@ -1,9 +1,6 @@
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages"; import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { getDesktopHost, type DesktopHostBridge } from "@/desktop/host"; import { getDesktopHost, type DesktopHostBridge } from "@/desktop/host";
import { import { ensureResidentBrowserWebview as ensureResidentBrowserWebviewDefault } from "@/components/browser-webview-resident";
ensureResidentBrowserWebview as ensureResidentBrowserWebviewDefault,
installResidentBrowserCaptureBridge,
} from "@/components/browser-webview-resident";
import { createWorkspaceBrowser } from "@/stores/browser-store"; import { createWorkspaceBrowser } from "@/stores/browser-store";
import { import {
buildWorkspaceTabPersistenceKey, buildWorkspaceTabPersistenceKey,
@@ -43,7 +40,6 @@ export function mountBrowserAutomationHandler(
options: BrowserAutomationHandlerOptions, options: BrowserAutomationHandlerOptions,
): () => void { ): () => void {
const getHost = options.getHost ?? getDesktopHost; const getHost = options.getHost ?? getDesktopHost;
const uninstallCaptureBridge = installResidentBrowserCaptureBridge();
const unsubscribe = options.client.on("browser.automation.execute.request", (request) => { const unsubscribe = options.client.on("browser.automation.execute.request", (request) => {
void handleBrowserAutomationRequest({ void handleBrowserAutomationRequest({
client: options.client, client: options.client,
@@ -62,7 +58,6 @@ export function mountBrowserAutomationHandler(
}); });
return () => { return () => {
unsubscribe(); unsubscribe();
uninstallCaptureBridge();
}; };
} }
@@ -1,47 +1,67 @@
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { import {
cancelResidentBrowserWebviewPixelCapture,
clearResidentBrowserWebviewsForTests, clearResidentBrowserWebviewsForTests,
ensureResidentBrowserWebview, ensureResidentBrowserWebview,
prepareBrowserWebview, prepareBrowserWebview,
prepareResidentBrowserWebviewForPixelCapture,
releaseResidentBrowserWebview, releaseResidentBrowserWebview,
removeResidentBrowserWebview, removeResidentBrowserWebview,
restoreResidentBrowserWebviewAfterPixelCapture,
takeResidentBrowserWebview, takeResidentBrowserWebview,
} from "./browser-webview-resident"; } from "./browser-webview-resident";
const RESIDENT_HOST_ID = "paseo-browser-resident-webviews";
function residentHost(): HTMLElement {
const host = document.getElementById(RESIDENT_HOST_ID);
if (!host) {
throw new Error("Expected resident browser host");
}
return host;
}
function expectPermanentHostParking(host: HTMLElement): void {
expect(host.style.position).toBe("fixed");
expect(host.style.left).toBe("0px");
expect(host.style.top).toBe("0px");
expect(host.style.width).toBe("1px");
expect(host.style.height).toBe("1px");
expect(host.style.overflow).toBe("hidden");
expect(host.style.opacity).toBe("1");
expect(host.style.pointerEvents).toBe("none");
expect(host.style.display).toBe("block");
expect(host.style.visibility).toBe("visible");
expect(host.style.transform).toBe("");
}
function expectResidentWebviewParking(webview: HTMLElement): void {
expect(webview.style.display).toBe("inline-flex");
expect(webview.style.flex).toBe("0 0 auto");
expect(webview.style.width).toBe("1280px");
expect(webview.style.height).toBe("800px");
expect(webview.style.position).toBe("absolute");
expect(webview.style.left).toBe("0px");
expect(webview.style.top).toBe("0px");
expect(webview.style.zIndex).toBe("0");
}
describe("resident browser webviews", () => { describe("resident browser webviews", () => {
afterEach(() => { afterEach(() => {
clearResidentBrowserWebviewsForTests(); clearResidentBrowserWebviewsForTests();
}); });
it("keeps a browser webview mounted offscreen and reuses the same node", () => { it("parks a browser webview in the permanent paintable 1x1 host", () => {
const host = document.createElement("div"); const visibleHost = document.createElement("div");
const webview = document.createElement("webview"); const webview = document.createElement("webview");
host.appendChild(webview); visibleHost.appendChild(webview);
document.body.appendChild(host); document.body.appendChild(visibleHost);
releaseResidentBrowserWebview("browser-a", webview); releaseResidentBrowserWebview("browser-a", webview);
expect(host.children).toHaveLength(0); const host = residentHost();
expect(visibleHost.children).toHaveLength(0);
expect(Array.from(host.children)).toEqual([webview]);
expect(webview.isConnected).toBe(true); expect(webview.isConnected).toBe(true);
expect(webview.style.display).toBe("inline-flex"); expectPermanentHostParking(host);
expect(webview.style.width).toBe("1280px"); expectResidentWebviewParking(webview);
expect(webview.style.height).toBe("800px");
expect(webview.style.position).toBe("absolute");
expect(webview.style.left).toBe("0px");
expect(webview.style.top).toBe("0px");
expect(webview.style.zIndex).toBe("0");
const reused = takeResidentBrowserWebview("browser-a");
expect(reused).toBe(webview);
expect(webview.style.position).toBe("");
expect(webview.style.left).toBe("");
expect(webview.style.top).toBe("");
expect(webview.style.zIndex).toBe("");
expect(takeResidentBrowserWebview("browser-a")).toBeNull();
}); });
it("creates a resident webview for an agent-created unfocused tab", () => { it("creates a resident webview for an agent-created unfocused tab", () => {
@@ -57,6 +77,80 @@ describe("resident browser webviews", () => {
expect((webview as HTMLUnknownElement & { src?: string })?.src).toContain( expect((webview as HTMLUnknownElement & { src?: string })?.src).toContain(
"https://example.com", "https://example.com",
); );
expectPermanentHostParking(residentHost());
expectResidentWebviewParking(webview as HTMLElement);
});
it("normalizes an existing resident host back to permanent parking", () => {
const staleHost = document.createElement("div");
staleHost.id = RESIDENT_HOST_ID;
staleHost.style.left = "-20000px";
staleHost.style.width = "1280px";
staleHost.style.height = "800px";
staleHost.style.opacity = "0";
staleHost.style.display = "none";
document.body.appendChild(staleHost);
const webview = ensureResidentBrowserWebview({
browserId: "browser-stale-host",
url: "https://example.com",
});
expect(webview).not.toBeNull();
expectPermanentHostParking(staleHost);
expectResidentWebviewParking(webview as HTMLElement);
});
it("parks resident webviews as an overlapping stack", () => {
const firstWebview = ensureResidentBrowserWebview({
browserId: "browser-first",
url: "https://example.com/first",
});
const secondWebview = ensureResidentBrowserWebview({
browserId: "browser-second",
url: "https://example.com/second",
});
const host = residentHost();
expect(firstWebview?.parentElement).toBe(host);
expect(secondWebview?.parentElement).toBe(host);
expectResidentWebviewParking(firstWebview as HTMLElement);
expectResidentWebviewParking(secondWebview as HTMLElement);
});
it("moves a resident webview into a visible pane without recreating the node", () => {
const webview = ensureResidentBrowserWebview({
browserId: "browser-visible",
url: "https://example.com",
});
const visibleWebview = takeResidentBrowserWebview("browser-visible");
expect(visibleWebview).toBe(webview);
expect(webview?.style.position).toBe("");
expect(webview?.style.left).toBe("");
expect(webview?.style.top).toBe("");
expect(webview?.style.zIndex).toBe("");
expect(takeResidentBrowserWebview("browser-visible")).toBeNull();
});
it("returns an existing visible pane webview instead of creating a resident duplicate", () => {
const visibleHost = document.createElement("div");
const visibleWebview = document.createElement("webview");
prepareBrowserWebview(visibleWebview, {
browserId: "browser-visible-pane",
initialUrl: "https://example.com",
});
visibleHost.appendChild(visibleWebview);
document.body.appendChild(visibleHost);
const webview = ensureResidentBrowserWebview({
browserId: "browser-visible-pane",
url: "https://example.com/agent",
});
expect(webview).toBe(visibleWebview);
expect(document.getElementById(RESIDENT_HOST_ID)).toBeNull();
}); });
it("removes a resident webview when its browser tab closes", () => { it("removes a resident webview when its browser tab closes", () => {
@@ -70,231 +164,4 @@ describe("resident browser webviews", () => {
expect(webview?.isConnected).toBe(false); expect(webview?.isConnected).toBe(false);
expect(takeResidentBrowserWebview("browser-closed")).toBeNull(); expect(takeResidentBrowserWebview("browser-closed")).toBeNull();
}); });
it("temporarily makes resident webviews paintable for pixel capture", async () => {
const webview = ensureResidentBrowserWebview({
browserId: "browser-capture",
url: "https://example.com",
});
if (!webview) {
throw new Error("Expected resident browser webview");
}
const preparation = await prepareResidentBrowserWebviewForPixelCapture({
browserId: "browser-capture",
});
const host = document.getElementById("paseo-browser-resident-webviews");
expect(preparation.token).toBe("capture-1");
expect(host?.style.left).toBe("0px");
expect(host?.style.top).toBe("0px");
expect(host?.style.width).toBe("1px");
expect(host?.style.height).toBe("1px");
expect(host?.style.overflow).toBe("hidden");
expect(host?.style.opacity).toBe("1");
expect(host?.style.pointerEvents).toBe("none");
expect(webview.style.display).toBe("inline-flex");
expect(webview.style.width).toBe("1280px");
expect(webview.style.height).toBe("800px");
expect(webview.style.position).toBe("absolute");
expect(webview.style.left).toBe("0px");
expect(webview.style.top).toBe("0px");
expect(webview.style.zIndex).toBe("2");
await restoreResidentBrowserWebviewAfterPixelCapture(preparation);
expect(host?.style.left).toBe("-20000px");
expect(host?.style.width).toBe("1280px");
expect(host?.style.height).toBe("800px");
expect(host?.style.opacity).toBe("0");
expect(webview.style.zIndex).toBe("0");
});
it("parks resident webviews as an overlapping stack and raises the capture target", async () => {
const firstWebview = ensureResidentBrowserWebview({
browserId: "browser-first",
url: "https://example.com/first",
});
const secondWebview = ensureResidentBrowserWebview({
browserId: "browser-second",
url: "https://example.com/second",
});
if (!firstWebview || !secondWebview) {
throw new Error("Expected resident browser webviews");
}
expect(firstWebview.style.position).toBe("absolute");
expect(firstWebview.style.left).toBe("0px");
expect(firstWebview.style.top).toBe("0px");
expect(firstWebview.style.zIndex).toBe("0");
expect(secondWebview.style.position).toBe("absolute");
expect(secondWebview.style.left).toBe("0px");
expect(secondWebview.style.top).toBe("0px");
expect(secondWebview.style.zIndex).toBe("0");
const preparation = await prepareResidentBrowserWebviewForPixelCapture({
browserId: "browser-second",
});
expect(firstWebview.style.zIndex).toBe("0");
expect(secondWebview.style.zIndex).toBe("2");
await restoreResidentBrowserWebviewAfterPixelCapture(preparation);
expect(firstWebview.style.zIndex).toBe("0");
expect(secondWebview.style.zIndex).toBe("0");
});
it("prepares the physically resident webview when a focused duplicate exists outside the resident host", async () => {
const residentWebview = ensureResidentBrowserWebview({
browserId: "browser-focused-undisplayed",
url: "https://example.com/resident",
});
if (!residentWebview) {
throw new Error("Expected resident browser webview");
}
const visibleHost = document.createElement("div");
const duplicateWebview = document.createElement("webview");
prepareBrowserWebview(duplicateWebview, {
browserId: "browser-focused-undisplayed",
initialUrl: "https://example.com/duplicate",
});
visibleHost.appendChild(duplicateWebview);
document.body.prepend(visibleHost);
const preparation = await prepareResidentBrowserWebviewForPixelCapture({
browserId: "browser-focused-undisplayed",
});
const residentHost = document.getElementById("paseo-browser-resident-webviews");
expect(residentHost?.style.left).toBe("0px");
expect(residentHost?.style.width).toBe("1px");
expect(residentWebview.style.zIndex).toBe("2");
expect(duplicateWebview.style.zIndex).toBe("");
await restoreResidentBrowserWebviewAfterPixelCapture(preparation);
});
it("does not apply resident prep styles for a genuinely visible pane webview", async () => {
const visibleHost = document.createElement("div");
const visibleWebview = document.createElement("webview");
prepareBrowserWebview(visibleWebview, {
browserId: "browser-visible-pane",
initialUrl: "https://example.com",
});
visibleWebview.style.display = "flex";
visibleWebview.style.width = "100%";
visibleWebview.style.height = "100%";
visibleHost.appendChild(visibleWebview);
document.body.appendChild(visibleHost);
const preparation = await prepareResidentBrowserWebviewForPixelCapture({
browserId: "browser-visible-pane",
});
const residentHost = document.getElementById("paseo-browser-resident-webviews");
expect(residentHost?.style.left).toBe("-20000px");
expect(residentHost?.style.width).toBe("1280px");
expect(visibleWebview.style.position).toBe("");
expect(visibleWebview.style.zIndex).toBe("");
await restoreResidentBrowserWebviewAfterPixelCapture(preparation);
});
it("clears capture preparation when a resident webview is taken visible", async () => {
const webview = ensureResidentBrowserWebview({
browserId: "browser-visible",
url: "https://example.com",
});
if (!webview) {
throw new Error("Expected resident browser webview");
}
const preparation = await prepareResidentBrowserWebviewForPixelCapture({
browserId: "browser-visible",
});
const visibleWebview = takeResidentBrowserWebview("browser-visible");
expect(visibleWebview).toBe(webview);
expect(webview.style.position).toBe("");
expect(webview.style.left).toBe("");
expect(webview.style.top).toBe("");
expect(webview.style.zIndex).toBe("");
await restoreResidentBrowserWebviewAfterPixelCapture(preparation);
expect(webview.style.position).toBe("");
expect(webview.style.left).toBe("");
expect(webview.style.top).toBe("");
expect(webview.style.zIndex).toBe("");
});
it("keeps the resident host paintable until every capture token is restored", async () => {
ensureResidentBrowserWebview({
browserId: "browser-overlap",
url: "https://example.com",
});
const first = await prepareResidentBrowserWebviewForPixelCapture({
browserId: "browser-overlap",
});
const second = await prepareResidentBrowserWebviewForPixelCapture({
browserId: "browser-overlap",
});
const host = document.getElementById("paseo-browser-resident-webviews");
await restoreResidentBrowserWebviewAfterPixelCapture(first);
expect(host?.style.left).toBe("0px");
expect(host?.style.width).toBe("1px");
expect(host?.style.opacity).toBe("1");
await restoreResidentBrowserWebviewAfterPixelCapture(second);
expect(host?.style.left).toBe("-20000px");
expect(host?.style.width).toBe("1280px");
expect(host?.style.opacity).toBe("0");
});
it("cancels an in-flight pixel capture preparation by request id", async () => {
ensureResidentBrowserWebview({
browserId: "browser-cancel",
url: "https://example.com",
});
const preparation = prepareResidentBrowserWebviewForPixelCapture({
requestId: "prepare-1",
browserId: "browser-cancel",
});
const host = document.getElementById("paseo-browser-resident-webviews");
expect(host?.style.left).toBe("0px");
expect(host?.style.opacity).toBe("1");
await cancelResidentBrowserWebviewPixelCapture({ requestId: "prepare-1" });
await expect(preparation).rejects.toThrow("Browser pixel capture preparation was canceled.");
expect(host?.style.left).toBe("-20000px");
expect(host?.style.width).toBe("1280px");
expect(host?.style.opacity).toBe("0");
});
it("parks the resident host when a prepared browser tab is removed", async () => {
const webview = ensureResidentBrowserWebview({
browserId: "browser-detached",
url: "https://example.com",
});
const preparation = await prepareResidentBrowserWebviewForPixelCapture({
browserId: "browser-detached",
});
const host = document.getElementById("paseo-browser-resident-webviews");
removeResidentBrowserWebview("browser-detached");
expect(webview?.isConnected).toBe(false);
expect(host?.style.left).toBe("-20000px");
expect(host?.style.width).toBe("1280px");
expect(host?.style.opacity).toBe("0");
await restoreResidentBrowserWebviewAfterPixelCapture(preparation);
expect(host?.style.left).toBe("-20000px");
});
}); });
@@ -1,28 +1,14 @@
import { getDesktopHost } from "@/desktop/host";
const RESIDENT_BROWSER_HOST_ID = "paseo-browser-resident-webviews"; const RESIDENT_BROWSER_HOST_ID = "paseo-browser-resident-webviews";
const BROWSER_ID_ATTRIBUTE = "data-paseo-browser-id"; const BROWSER_ID_ATTRIBUTE = "data-paseo-browser-id";
const RESIDENT_VIEWPORT_WIDTH = 1280; const RESIDENT_VIEWPORT_WIDTH = 1280;
const RESIDENT_VIEWPORT_HEIGHT = 800; const RESIDENT_VIEWPORT_HEIGHT = 800;
const residentWebviewsByBrowserId = new Map<string, HTMLElement>(); const residentWebviewsByBrowserId = new Map<string, HTMLElement>();
const activeCapturePreparations = new Map<string, ActiveCapturePreparation>();
let captureBridgeInstallCount = 0;
let captureBridgeDisposer: (() => void) | null = null;
let nextCapturePreparationId = 0;
interface BrowserWebviewElement extends HTMLElement { interface BrowserWebviewElement extends HTMLElement {
src: string; src: string;
} }
interface ActiveCapturePreparation {
browserId: string;
requestId?: string;
preparesResidentHost: boolean;
webview: HTMLElement;
}
function trimNonEmpty(value: string | null | undefined): string | null { function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") { if (typeof value !== "string") {
return null; return null;
@@ -36,22 +22,8 @@ function readDocument(): Document | null {
} }
function applyResidentHostParkingStyle(host: HTMLElement): void { function applyResidentHostParkingStyle(host: HTMLElement): void {
host.setAttribute("aria-hidden", "true"); // Parked browser webviews must remain paintable at all times; screenshot
host.style.position = "fixed"; // correctness depends on the proven states in docs/browser-capture-harness.md.
host.style.left = "-20000px";
host.style.top = "0";
host.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
host.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
host.style.overflow = "hidden";
host.style.opacity = "0";
host.style.pointerEvents = "none";
host.style.zIndex = "";
host.style.clipPath = "";
host.style.visibility = "";
host.style.transform = "";
}
function applyResidentHostCaptureStyle(host: HTMLElement): void {
host.setAttribute("aria-hidden", "true"); host.setAttribute("aria-hidden", "true");
host.style.position = "fixed"; host.style.position = "fixed";
host.style.left = "0"; host.style.left = "0";
@@ -61,15 +33,17 @@ function applyResidentHostCaptureStyle(host: HTMLElement): void {
host.style.overflow = "hidden"; host.style.overflow = "hidden";
host.style.opacity = "1"; host.style.opacity = "1";
host.style.pointerEvents = "none"; host.style.pointerEvents = "none";
host.style.zIndex = "1"; host.style.display = "block";
host.style.zIndex = "";
host.style.clipPath = ""; host.style.clipPath = "";
host.style.visibility = ""; host.style.visibility = "visible";
host.style.transform = ""; host.style.transform = "";
} }
function getResidentBrowserHost(ownerDocument: Document): HTMLElement { function getResidentBrowserHost(ownerDocument: Document): HTMLElement {
const existing = ownerDocument.getElementById(RESIDENT_BROWSER_HOST_ID); const existing = ownerDocument.getElementById(RESIDENT_BROWSER_HOST_ID);
if (existing) { if (existing) {
applyResidentHostParkingStyle(existing);
return existing; return existing;
} }
@@ -92,17 +66,6 @@ function findBrowserWebview(browserId: string, ownerDocument: Document): HTMLEle
return null; return null;
} }
function findBrowserWebviewForPixelCapture(
browserId: string,
ownerDocument: Document,
): HTMLElement | null {
const resident = residentWebviewsByBrowserId.get(browserId) ?? null;
if (resident?.isConnected) {
return resident;
}
return findBrowserWebview(browserId, ownerDocument);
}
function applyResidentWebviewStyle(webview: HTMLElement): void { function applyResidentWebviewStyle(webview: HTMLElement): void {
webview.style.display = "inline-flex"; webview.style.display = "inline-flex";
webview.style.flex = "0 0 auto"; webview.style.flex = "0 0 auto";
@@ -125,116 +88,6 @@ function clearResidentWebviewParkingStyle(webview: HTMLElement): void {
webview.style.zIndex = ""; webview.style.zIndex = "";
} }
function residentWebviewChildren(host: HTMLElement): HTMLElement[] {
const webviews: HTMLElement[] = [];
for (const element of host.querySelectorAll(`[${BROWSER_ID_ATTRIBUTE}]`)) {
if (element instanceof HTMLElement) {
webviews.push(element);
}
}
return webviews;
}
function raiseResidentWebviewForCapture(host: HTMLElement, target: HTMLElement): void {
for (const webview of residentWebviewChildren(host)) {
applyResidentWebviewStyle(webview);
}
applyResidentWebviewStyle(target);
target.style.zIndex = "2";
}
function nextAnimationFrame(): Promise<void> {
if (typeof requestAnimationFrame !== "function") {
return Promise.resolve();
}
return new Promise((resolve) => {
requestAnimationFrame(() => {
resolve();
});
});
}
async function waitForCapturePaint(webview: HTMLElement): Promise<void> {
await nextAnimationFrame();
await nextAnimationFrame();
webview.getBoundingClientRect();
}
function activeCaptureTokenFor(input: { token?: string; requestId?: string }): string | null {
const token = trimNonEmpty(input.token);
if (token && activeCapturePreparations.has(token)) {
return token;
}
const requestId = trimNonEmpty(input.requestId);
if (!requestId) {
return null;
}
for (const [candidateToken, preparation] of activeCapturePreparations.entries()) {
if (preparation.requestId === requestId) {
return candidateToken;
}
}
return null;
}
function latestActiveResidentHostPreparation(): ActiveCapturePreparation | null {
let latest: ActiveCapturePreparation | null = null;
for (const preparation of activeCapturePreparations.values()) {
if (preparation.preparesResidentHost) {
latest = preparation;
}
}
return latest;
}
function syncResidentHostCaptureState(): void {
const host = readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID);
if (!(host instanceof HTMLElement)) {
return;
}
const latest = latestActiveResidentHostPreparation();
if (latest) {
applyResidentHostCaptureStyle(host);
raiseResidentWebviewForCapture(host, latest.webview);
return;
}
applyResidentHostParkingStyle(host);
for (const webview of residentWebviewChildren(host)) {
applyResidentWebviewStyle(webview);
}
}
function releaseCapturePreparationToken(token: string): void {
const preparation = activeCapturePreparations.get(token);
if (!preparation) {
return;
}
activeCapturePreparations.delete(token);
if (preparation.preparesResidentHost) {
const host = readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID);
if (host instanceof HTMLElement && preparation.webview.parentElement === host) {
applyResidentWebviewStyle(preparation.webview);
}
syncResidentHostCaptureState();
}
}
function releaseCapturePreparationsForBrowser(browserId: string): void {
for (const [token, preparation] of activeCapturePreparations.entries()) {
if (preparation.browserId === browserId) {
activeCapturePreparations.delete(token);
}
}
syncResidentHostCaptureState();
}
function releaseAllCapturePreparations(): void {
activeCapturePreparations.clear();
syncResidentHostCaptureState();
}
export function prepareBrowserWebview( export function prepareBrowserWebview(
webview: HTMLElement, webview: HTMLElement,
input: { browserId: string; initialUrl?: string | null }, input: { browserId: string; initialUrl?: string | null },
@@ -293,7 +146,6 @@ export function takeResidentBrowserWebview(browserId: string): HTMLElement | nul
} }
residentWebviewsByBrowserId.delete(normalizedBrowserId); residentWebviewsByBrowserId.delete(normalizedBrowserId);
releaseCapturePreparationsForBrowser(normalizedBrowserId);
clearResidentWebviewParkingStyle(webview); clearResidentWebviewParkingStyle(webview);
return webview; return webview;
} }
@@ -314,98 +166,6 @@ export function releaseResidentBrowserWebview(browserId: string, webview: HTMLEl
getResidentBrowserHost(ownerDocument).appendChild(webview); getResidentBrowserHost(ownerDocument).appendChild(webview);
} }
export async function prepareResidentBrowserWebviewForPixelCapture(input: {
browserId: string;
requestId?: string;
}): Promise<{ token: string }> {
const browserId = trimNonEmpty(input.browserId);
if (!browserId) {
throw new Error("Browser id is required for pixel capture preparation.");
}
const ownerDocument = readDocument();
if (!ownerDocument) {
throw new Error("Browser pixel capture preparation requires a document.");
}
const host = getResidentBrowserHost(ownerDocument);
const webview = findBrowserWebviewForPixelCapture(browserId, ownerDocument);
if (!webview) {
throw new Error(`Browser webview ${browserId} is not mounted.`);
}
const token = `capture-${++nextCapturePreparationId}`;
const preparesResidentHost = webview.parentElement === host;
const requestId = trimNonEmpty(input.requestId);
activeCapturePreparations.set(token, {
browserId,
...(requestId ? { requestId } : {}),
preparesResidentHost,
webview,
});
try {
if (preparesResidentHost) {
applyResidentHostCaptureStyle(host);
raiseResidentWebviewForCapture(host, webview);
}
await waitForCapturePaint(webview);
if (!activeCapturePreparations.has(token)) {
throw new Error("Browser pixel capture preparation was canceled.");
}
return { token };
} catch (error) {
releaseCapturePreparationToken(token);
throw error;
}
}
export async function restoreResidentBrowserWebviewAfterPixelCapture(input: {
token: string;
}): Promise<void> {
releaseCapturePreparationToken(input.token);
}
export async function cancelResidentBrowserWebviewPixelCapture(input: {
requestId?: string;
token?: string;
}): Promise<void> {
const token = activeCaptureTokenFor(input);
if (!token) {
return;
}
releaseCapturePreparationToken(token);
}
export function installResidentBrowserCaptureBridge(): () => void {
captureBridgeInstallCount += 1;
if (!captureBridgeDisposer) {
const browserBridge = getDesktopHost()?.browser;
const disposePrepare = browserBridge?.onPrepareForPixelCapture?.(
prepareResidentBrowserWebviewForPixelCapture,
);
const disposeRestore = browserBridge?.onRestorePixelCapture?.(
restoreResidentBrowserWebviewAfterPixelCapture,
);
const disposeCancel = browserBridge?.onCancelPixelCapture?.(
cancelResidentBrowserWebviewPixelCapture,
);
captureBridgeDisposer = () => {
disposePrepare?.();
disposeRestore?.();
disposeCancel?.();
};
}
return () => {
captureBridgeInstallCount = Math.max(0, captureBridgeInstallCount - 1);
if (captureBridgeInstallCount > 0) {
return;
}
captureBridgeDisposer?.();
captureBridgeDisposer = null;
releaseAllCapturePreparations();
};
}
export function removeResidentBrowserWebview(browserId: string): void { export function removeResidentBrowserWebview(browserId: string): void {
const normalizedBrowserId = trimNonEmpty(browserId); const normalizedBrowserId = trimNonEmpty(browserId);
if (!normalizedBrowserId) { if (!normalizedBrowserId) {
@@ -414,7 +174,6 @@ export function removeResidentBrowserWebview(browserId: string): void {
const resident = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null; const resident = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
residentWebviewsByBrowserId.delete(normalizedBrowserId); residentWebviewsByBrowserId.delete(normalizedBrowserId);
releaseCapturePreparationsForBrowser(normalizedBrowserId);
resident?.remove(); resident?.remove();
} }
@@ -423,7 +182,5 @@ export function clearResidentBrowserWebviewsForTests(): void {
webview.remove(); webview.remove();
} }
residentWebviewsByBrowserId.clear(); residentWebviewsByBrowserId.clear();
releaseAllCapturePreparations();
nextCapturePreparationId = 0;
readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID)?.remove(); readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID)?.remove();
} }
-16
View File
@@ -121,10 +121,6 @@ export interface DesktopBrowserShortcutEvent {
action: "focus-url"; action: "focus-url";
} }
export interface DesktopBrowserPixelCapturePreparation {
token: string;
}
export interface DesktopBrowserNewTabRequestEvent { export interface DesktopBrowserNewTabRequestEvent {
sourceBrowserId: string; sourceBrowserId: string;
url: string; url: string;
@@ -148,18 +144,6 @@ export interface DesktopBrowserBridge {
) => Promise<string | null>; ) => Promise<string | null>;
/** Copy element text and/or an image to the system clipboard from main. */ /** Copy element text and/or an image to the system clipboard from main. */
copyElement?: (payload: { text?: string; imageDataUrl?: string }) => Promise<boolean>; copyElement?: (payload: { text?: string; imageDataUrl?: string }) => Promise<boolean>;
onPrepareForPixelCapture?: (
handler: (input: {
requestId: string;
browserId: string;
}) => Promise<DesktopBrowserPixelCapturePreparation>,
) => () => void;
onRestorePixelCapture?: (
handler: (input: DesktopBrowserPixelCapturePreparation) => Promise<void>,
) => () => void;
onCancelPixelCapture?: (
handler: (input: { requestId?: string; token?: string }) => Promise<void>,
) => () => void;
} }
export interface DesktopInvokeBridge { export interface DesktopInvokeBridge {
+14 -14
View File
@@ -16,13 +16,15 @@
#paseo-browser-resident-webviews { #paseo-browser-resident-webviews {
position: fixed; position: fixed;
left: -20000px; left: 0;
top: 0; top: 0;
width: 1280px; width: 1px;
height: 800px; height: 1px;
overflow: hidden; overflow: hidden;
opacity: 0; opacity: 1;
pointer-events: none; pointer-events: none;
display: block;
visibility: visible;
} }
.capture-harness-webview { .capture-harness-webview {
@@ -152,23 +154,24 @@
host.style.position = "fixed"; host.style.position = "fixed";
host.style.left = "0"; host.style.left = "0";
host.style.top = "0"; host.style.top = "0";
host.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`; host.style.width = "1px";
host.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`; host.style.height = "1px";
host.style.overflow = "hidden"; host.style.overflow = "hidden";
host.style.opacity = "1"; host.style.opacity = "1";
host.style.pointerEvents = "none"; host.style.pointerEvents = "none";
host.style.zIndex = "1"; host.style.display = "block";
host.style.zIndex = "";
host.style.clipPath = ""; host.style.clipPath = "";
host.style.visibility = ""; host.style.visibility = "visible";
host.style.transform = ""; host.style.transform = "";
host.style.transformOrigin = "0 0"; host.style.transformOrigin = "";
} }
function applyPermanentWebviewBase(webview, index) { function applyPermanentWebviewBase(webview, index) {
applyStackedWebviewStyle(webview); applyStackedWebviewStyle(webview);
webview.style.opacity = "1"; webview.style.opacity = "";
webview.style.transform = ""; webview.style.transform = "";
webview.style.pointerEvents = "none"; webview.style.pointerEvents = "";
webview.style.zIndex = "0"; webview.style.zIndex = "0";
webview.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`; webview.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
webview.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`; webview.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
@@ -183,9 +186,6 @@
webviews.forEach(applyPermanentWebviewBase); webviews.forEach(applyPermanentWebviewBase);
if (stateName === "p1-overflow-1x1") { if (stateName === "p1-overflow-1x1") {
host.style.width = "1px";
host.style.height = "1px";
host.style.overflow = "hidden";
return state(); return state();
} }
+11 -19
View File
@@ -13,15 +13,15 @@ const CAPTURE_RETRY_INTERVAL_MS = 200;
const REPEAT_COUNT = 5; const REPEAT_COUNT = 5;
const FRESH_REPEAT_COUNT = 3; const FRESH_REPEAT_COUNT = 3;
const SOAK_MS = Number(process.env.PASEO_CAPTURE_HARNESS_SOAK_MS || 75000); const SOAK_MS = Number(process.env.PASEO_CAPTURE_HARNESS_SOAK_MS || 75000);
const HARNESS_GROUP = process.env.PASEO_CAPTURE_HARNESS_GROUP || "all"; const HARNESS_GROUP = process.env.PASEO_CAPTURE_HARNESS_GROUP || "permanent-parking";
const PERMANENT_STATE_FILTER = new Set( const PERMANENT_STATE_FILTER = new Set(
(process.env.PASEO_CAPTURE_HARNESS_STATES || "") (process.env.PASEO_CAPTURE_HARNESS_STATES || "P1")
.split(",") .split(",")
.map((state) => state.trim()) .map((state) => state.trim())
.filter(Boolean), .filter(Boolean),
); );
const PERMANENT_VARIANT_FILTER = new Set( const PERMANENT_VARIANT_FILTER = new Set(
(process.env.PASEO_CAPTURE_HARNESS_VARIANTS || "") (process.env.PASEO_CAPTURE_HARNESS_VARIANTS || "attach-off")
.split(",") .split(",")
.map((variant) => variant.trim()) .map((variant) => variant.trim())
.filter(Boolean), .filter(Boolean),
@@ -377,14 +377,8 @@ async function readGuestMetrics(contents) {
} }
async function capturePageSequence(contents) { async function capturePageSequence(contents) {
const previousBackgroundThrottling = contents.getBackgroundThrottling(); contents.invalidate();
contents.setBackgroundThrottling(false); return await withTimeout(contents.capturePage(undefined, { stayHidden: false }), "capturePage");
try {
contents.invalidate();
return await withTimeout(contents.capturePage(undefined, { stayHidden: false }), "capturePage");
} finally {
contents.setBackgroundThrottling(previousBackgroundThrottling);
}
} }
async function captureFullPage(contents) { async function captureFullPage(contents) {
@@ -426,14 +420,8 @@ async function captureFullPage(contents) {
} }
async function captureFullPageSequence(contents) { async function captureFullPageSequence(contents) {
const previousBackgroundThrottling = contents.getBackgroundThrottling(); contents.invalidate();
contents.setBackgroundThrottling(false); return await captureFullPage(contents);
try {
contents.invalidate();
return await captureFullPage(contents);
} finally {
contents.setBackgroundThrottling(previousBackgroundThrottling);
}
} }
function installHarnessWebviewGuards(win) { function installHarnessWebviewGuards(win) {
@@ -1100,6 +1088,10 @@ async function runPermanentParkingGroup() {
} finally { } finally {
await closeHarnessWindow(keeper); await closeHarnessWindow(keeper);
} }
const failedResults = results.filter((result) => !result.pass);
if (failedResults.length > 0) {
fail(`permanent parking failed ${failedResults.length}/${results.length} checks`);
}
return results; return results;
} }
@@ -1,5 +1,5 @@
import type { Rectangle } from "electron"; 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 type { TabImage } from "./service.js";
import { adaptWebContents } from "./ipc.js"; import { adaptWebContents } from "./ipc.js";
@@ -14,79 +14,43 @@ class FakeImage implements TabImage {
} }
class FakeDebugger { class FakeDebugger {
public attachedProtocolVersions: string[] = [];
public commands: Array<{ command: string; params: Record<string, unknown> }> = [];
public isAttached(): boolean { 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> { public async sendCommand(command: string, params?: Record<string, unknown>): Promise<unknown> {
return {}; this.commands.push({ command, params: params ?? {} });
return { ok: true };
} }
} }
class FakeHostWebContents { type ConsoleMessageListener = (
public readonly sentMessages: Array<{ channel: string; payload: unknown }> = []; event: unknown,
public destroyed = false; level: unknown,
message: unknown,
public constructor(public readonly id: number) {} line: unknown,
sourceId: unknown,
public isDestroyed(): boolean { ) => void;
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;
}
}
class FakeWebContents { class FakeWebContents {
public readonly debugger = new FakeDebugger(); public readonly debugger = new FakeDebugger();
public readonly consoleMessages: unknown[] = []; public readonly captures: Array<{
public readonly destroyedListeners: Array<() => void> = []; 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 destroyed = false;
public constructor( public constructor(public readonly id: number) {}
public readonly id: number,
public hostWebContents: FakeHostWebContents | null,
) {}
public getURL(): string { public getURL(): string {
return "https://example.com"; return "https://example.com";
@@ -125,362 +89,96 @@ class FakeWebContents {
public reload(): void {} public reload(): void {}
public async capturePage( public async capturePage(
_rect?: Rectangle, rect?: Rectangle,
_options?: { stayHidden?: boolean }, options?: { stayHidden?: boolean },
): Promise<TabImage> { ): Promise<TabImage> {
this.captures.push({ rect, options });
return new FakeImage(); return new FakeImage();
} }
public invalidate(): void {} public invalidate(): void {
this.invalidations.push("invalidate");
public getBackgroundThrottling(): boolean {
return true;
} }
public setBackgroundThrottling(): void {} public on(event: "console-message", listener: ConsoleMessageListener): void {
expect(event).toBe("console-message");
public on( this.consoleMessageListener = listener;
event: "console-message",
listener: (
event: unknown,
level: unknown,
message: unknown,
line: unknown,
sourceId: unknown,
) => void,
): void {
this.consoleMessages.push({ event, listener });
} }
public once(event: "destroyed", listener: () => void): void { public once(event: "destroyed", listener: () => void): void {
expect(event).toBe("destroyed"); 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", () => { describe("browser automation IPC adapter", () => {
test("prepareForPixelCapture asks the embedder renderer and resolves the ack token", async () => { test("delegates viewport capture to the guest without a renderer prep bridge", async () => {
const host = new FakeHostWebContents(10); const contents = new FakeWebContents(20);
const contents = new FakeWebContents(20, host); const tab = adaptWebContents(contents);
const ipc = new FakeIpcBridge();
const tab = adaptWebContents(contents, "browser-a", { const image = await tab.capturePage({ stayHidden: false });
ipc, tab.invalidate();
createRequestId: () => "prepare-1",
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(tab.getConsoleMessages?.()).toEqual([
expect(host.sentMessages).toEqual([
{ {
channel: "paseo:browser:capture-prepare", level: "warning",
payload: { requestId: "prepare-1", browserId: "browser-a" }, 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", { contents.destroy();
requestId: "prepare-1",
ok: true,
token: "token-a",
});
await expect(preparation).resolves.toEqual({ token: "token-a" }); expect(tab.getConsoleMessages?.()).toEqual([]);
expect(ipc.listenerCount("paseo:browser:capture-prepared")).toBe(0);
}); });
test("restorePixelCapture sends the capture token back to the embedder renderer", async () => { test("attaches the debugger before sending a CDP command", async () => {
const host = new FakeHostWebContents(10); const contents = new FakeWebContents(22);
const contents = new FakeWebContents(20, host); const tab = adaptWebContents(contents);
const ipc = new FakeIpcBridge();
const requestIds = ["prepare-1", "restore-1"]; const result = await tab.sendDebugCommand?.("Page.captureScreenshot", {
const tab = adaptWebContents(contents, "browser-a", { format: "png",
ipc,
createRequestId: () => {
const requestId = requestIds.shift();
if (!requestId) {
throw new Error("Missing request id");
}
return requestId;
},
}); });
const preparation = tab.prepareForPixelCapture(); expect(result).toEqual({ ok: true });
ipc.emit("paseo:browser:capture-prepared", { expect(contents.debugger.attachedProtocolVersions).toEqual(["1.3"]);
requestId: "prepare-1", expect(contents.debugger.commands).toEqual([
ok: true, { command: "Page.captureScreenshot", params: { format: "png" } },
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" },
},
]); ]);
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"; } from "../browser-webviews/index.js";
const MAX_CONSOLE_MESSAGES_PER_TAB = 200; 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 consoleMessagesByContentsId = new Map<number, BrowserAutomationConsoleLogEntry[]>();
const observedContentsIds = new Set<number>(); const observedContentsIds = new Set<number>();
let nextPixelCaptureBridgeRequest = 0;
interface IpcHandlerRegistry { interface IpcHandlerRegistry {
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): void; 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 { interface WebContentsDebugger {
isAttached(): boolean; isAttached(): boolean;
attach(protocolVersion?: string): void; attach(protocolVersion?: string): void;
@@ -65,7 +42,6 @@ interface ConsoleMessageEmitter {
interface BrowserAutomationWebContents extends ConsoleMessageEmitter { interface BrowserAutomationWebContents extends ConsoleMessageEmitter {
readonly id: number; readonly id: number;
readonly hostWebContents: HostWebContents | null;
readonly debugger: WebContentsDebugger; readonly debugger: WebContentsDebugger;
getURL(): string; getURL(): string;
getTitle(): string; getTitle(): string;
@@ -80,34 +56,10 @@ interface BrowserAutomationWebContents extends ConsoleMessageEmitter {
reload(): void; reload(): void;
capturePage(rect?: Rectangle, options?: { stayHidden?: boolean }): Promise<TabImage>; capturePage(rect?: Rectangle, options?: { stayHidden?: boolean }): Promise<TabImage>;
invalidate(): void; invalidate(): void;
getBackgroundThrottling(): boolean;
setBackgroundThrottling(allowed: boolean): void;
} }
type PixelCaptureBridgeKind = "prepare" | "restore"; export function adaptWebContents(contents: BrowserAutomationWebContents): TabContents {
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 {
observeConsoleMessages(contents); observeConsoleMessages(contents);
const preparedPixelCapturesByToken = new Map<string, PreparedPixelCapture>();
return { return {
id: contents.id, id: contents.id,
getURL: () => contents.getURL(), getURL: () => contents.getURL(),
@@ -122,34 +74,7 @@ export function adaptWebContents(
goForward: () => contents.goForward(), goForward: () => contents.goForward(),
reload: () => contents.reload(), reload: () => contents.reload(),
capturePage: (captureOptions) => contents.capturePage(undefined, captureOptions), 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(), invalidate: () => contents.invalidate(),
isBackgroundThrottlingAllowed: () => contents.getBackgroundThrottling(),
setBackgroundThrottling: (allowed) => contents.setBackgroundThrottling(allowed),
getConsoleMessages: () => consoleMessagesByContentsId.get(contents.id) ?? [], getConsoleMessages: () => consoleMessagesByContentsId.get(contents.id) ?? [],
sendDebugCommand: async (command: string, params?: Record<string, unknown>) => { sendDebugCommand: async (command: string, params?: Record<string, unknown>) => {
if (!contents.debugger.isAttached()) { 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 { function observeConsoleMessages(contents: BrowserAutomationWebContents): void {
if (observedContentsIds.has(contents.id)) { if (observedContentsIds.has(contents.id)) {
return; return;
@@ -398,7 +125,7 @@ function createRegistry(): BrowserRegistry {
listRegisteredBrowserIdsForWorkspace: listRegisteredPaseoBrowserIdsForWorkspace, listRegisteredBrowserIdsForWorkspace: listRegisteredPaseoBrowserIdsForWorkspace,
getTabContents(browserId: string): TabContents | null { getTabContents(browserId: string): TabContents | null {
const contents = getPaseoBrowserWebContents(browserId); const contents = getPaseoBrowserWebContents(browserId);
return contents ? adaptWebContents(contents, browserId) : null; return contents ? adaptWebContents(contents) : null;
}, },
getBrowserWorkspaceId: getPaseoBrowserWorkspaceId, getBrowserWorkspaceId: getPaseoBrowserWorkspaceId,
getWorkspaceActiveBrowserId: getWorkspaceActivePaseoBrowserId, getWorkspaceActiveBrowserId: getWorkspaceActivePaseoBrowserId,
@@ -7,12 +7,7 @@ import type {
BrowserAutomationExecuteRequest, BrowserAutomationExecuteRequest,
} from "@getpaseo/protocol/browser-automation/rpc-schemas"; } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import { BrowserSnapshotEngine } from "./snapshot-engine.js"; import { BrowserSnapshotEngine } from "./snapshot-engine.js";
import type { import type { BrowserRegistry, TabContents, TabImage } from "./service.js";
BrowserRegistry,
TabContents,
TabImage,
TabPixelCapturePreparation,
} from "./service.js";
import { executeAutomationCommand } from "./service.js"; import { executeAutomationCommand } from "./service.js";
const BROWSER_A = "11111111-1111-4111-8111-111111111111"; const BROWSER_A = "11111111-1111-4111-8111-111111111111";
@@ -41,7 +36,6 @@ class FakeTab implements TabContents {
public readonly actions: string[] = []; public readonly actions: string[] = [];
public readonly capturedViewports: Array<{ stayHidden?: boolean }> = []; public readonly capturedViewports: Array<{ stayHidden?: boolean }> = [];
public readonly debugCommands: Array<{ command: string; params?: Record<string, unknown> }> = []; public readonly debugCommands: Array<{ command: string; params?: Record<string, unknown> }> = [];
public readonly restoredPixelCaptureTokens: string[] = [];
private readonly captureStartWaiters: Array<() => void> = []; private readonly captureStartWaiters: Array<() => void> = [];
private readonly deferredCaptures: Array<(image: TabImage) => void> = []; private readonly deferredCaptures: Array<(image: TabImage) => void> = [];
@@ -56,8 +50,6 @@ class FakeTab implements TabContents {
public captureErrorMessage = "capture failed"; public captureErrorMessage = "capture failed";
public viewportCaptureFailuresBeforeSuccess = 0; public viewportCaptureFailuresBeforeSuccess = 0;
public deferCaptures = false; public deferCaptures = false;
public prepareNeverAcks = false;
public prepareErrorMessage: string | null = null;
public fullPageScreenshotThrows = false; public fullPageScreenshotThrows = false;
public fullPageScreenshotErrorMessage = "UnknownVizError"; public fullPageScreenshotErrorMessage = "UnknownVizError";
public fullPageCaptureFailuresBeforeSuccess = 0; public fullPageCaptureFailuresBeforeSuccess = 0;
@@ -68,8 +60,6 @@ class FakeTab implements TabContents {
public fullPageScreenshotData = "fullPagePng"; public fullPageScreenshotData = "fullPagePng";
public documentNodeId = 1; public documentNodeId = 1;
public queriedNodeId = 2; public queriedNodeId = 2;
public backgroundThrottlingAllowed = true;
private nextPixelCapturePreparationId = 0;
public constructor( public constructor(
public readonly id: number, public readonly id: number,
@@ -153,36 +143,10 @@ class FakeTab implements TabContents {
return new FakeImage(); 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 { public invalidate(): void {
this.actions.push("invalidate"); 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[] { public getConsoleMessages(): BrowserAutomationConsoleLogEntry[] {
return this.consoleMessages; 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 browser = new BrowserAutomationHarness();
const result = await browser.execute({ const result = await browser.execute({
@@ -934,14 +898,7 @@ describe("executeAutomationCommand", () => {
}, },
}); });
expect(browser.tab.capturedViewports).toEqual([{ stayHidden: false }]); expect(browser.tab.capturedViewports).toEqual([{ stayHidden: false }]);
expect(browser.tab.actions).toEqual([ expect(browser.tab.actions).toEqual(["invalidate", "capture"]);
"prepare",
"background:false",
"invalidate",
"capture",
"restore:capture-1",
"background:true",
]);
}); });
test("screenshot returns no-frame when the viewport never paints", async () => { test("screenshot returns no-frame when the viewport never paints", async () => {
@@ -961,25 +918,17 @@ describe("executeAutomationCommand", () => {
ok: false, ok: false,
error: { error: {
code: "screenshot_no_frame", code: "screenshot_no_frame",
message: message: "The tab has not painted yet. Retry the screenshot.",
"The browser tab has no painted frame. Focus the tab in the app, then try again.", retryable: true,
retryable: false,
}, },
}); });
expect(browser.tab.actions).toEqual([ expect(browser.tab.actions).toEqual(["invalidate", "capture"]);
"prepare",
"background:false",
"invalidate",
"capture",
"restore:capture-1",
"background:true",
]);
} finally { } finally {
vi.useRealTimers(); 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(); const browser = new BrowserAutomationHarness();
browser.tab.captureThrows = true; browser.tab.captureThrows = true;
@@ -990,14 +939,7 @@ describe("executeAutomationCommand", () => {
}), }),
).rejects.toThrow("capture failed"); ).rejects.toThrow("capture failed");
expect(browser.tab.actions).toEqual([ expect(browser.tab.actions).toEqual(["invalidate", "capture"]);
"prepare",
"background:false",
"invalidate",
"capture",
"restore:capture-1",
"background:true",
]);
}); });
test("screenshot retries UnknownVizError until the first viewport frame appears", async () => { 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.capturedViewports).toHaveLength(3);
expect(browser.tab.actions).toEqual([ expect(browser.tab.actions).toEqual([
"prepare",
"background:false",
"invalidate", "invalidate",
"capture", "capture",
"invalidate", "invalidate",
"capture", "capture",
"invalidate", "invalidate",
"capture", "capture",
"restore:capture-1",
"background:true",
]); ]);
} finally { } finally {
vi.useRealTimers(); vi.useRealTimers();
@@ -1061,74 +999,20 @@ describe("executeAutomationCommand", () => {
ok: false, ok: false,
error: { error: {
code: "screenshot_no_frame", code: "screenshot_no_frame",
message: message: "The tab has not painted yet. Retry the screenshot.",
"The browser tab has no painted frame. Focus the tab in the app, then try again.", retryable: true,
retryable: false,
}, },
}); });
expect(browser.tab.capturedViewports.length).toBeGreaterThan(1); expect(browser.tab.capturedViewports.length).toBeGreaterThan(1);
expect(browser.tab.actions.filter((action) => action === "invalidate")).toHaveLength( expect(browser.tab.actions.filter((action) => action === "invalidate")).toHaveLength(
browser.tab.capturedViewports.length, 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 { } finally {
vi.useRealTimers(); vi.useRealTimers();
} }
}); });
test("screenshot returns no-frame when capture preparation does not ack", async () => { test("overlapping screenshots serialize through the shared capture queue", 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 () => {
const browser = new BrowserAutomationHarness(); const browser = new BrowserAutomationHarness();
browser.tab.deferCaptures = true; browser.tab.deferCaptures = true;
@@ -1147,44 +1031,20 @@ describe("executeAutomationCommand", () => {
); );
await Promise.resolve(); await Promise.resolve();
expect(browser.tab.actions).toEqual(["prepare", "background:false", "invalidate", "capture"]); expect(browser.tab.actions).toEqual(["invalidate", "capture"]);
browser.tab.finishNextCapture(); browser.tab.finishNextCapture();
await browser.tab.waitForCaptureStart(2); await browser.tab.waitForCaptureStart(2);
expect(browser.tab.actions).toEqual([ expect(browser.tab.actions).toEqual(["invalidate", "capture", "invalidate", "capture"]);
"prepare",
"background:false",
"invalidate",
"capture",
"restore:capture-1",
"background:true",
"prepare",
"background:false",
"invalidate",
"capture",
]);
browser.tab.finishNextCapture(); browser.tab.finishNextCapture();
await expect(first).resolves.toMatchObject({ requestId: "req-screenshot", ok: true }); await expect(first).resolves.toMatchObject({ requestId: "req-screenshot", ok: true });
await expect(second).resolves.toMatchObject({ requestId: "req-screenshot-2", ok: true }); await expect(second).resolves.toMatchObject({ requestId: "req-screenshot-2", ok: true });
expect(browser.tab.actions).toEqual([ expect(browser.tab.actions).toEqual(["invalidate", "capture", "invalidate", "capture"]);
"prepare",
"background:false",
"invalidate",
"capture",
"restore:capture-1",
"background:true",
"prepare",
"background:false",
"invalidate",
"capture",
"restore:capture-2",
"background:true",
]);
}); });
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 registry = new FakeRegistry();
const firstTab = new FakeTab(1, "https://a.test", "A"); const firstTab = new FakeTab(1, "https://a.test", "A");
const secondTab = new FakeTab(2, "https://b.test", "B"); const secondTab = new FakeTab(2, "https://b.test", "B");
@@ -1214,33 +1074,19 @@ describe("executeAutomationCommand", () => {
); );
await Promise.resolve(); await Promise.resolve();
expect(firstTab.actions).toEqual(["prepare", "background:false", "invalidate", "capture"]); expect(firstTab.actions).toEqual(["invalidate", "capture"]);
expect(secondTab.actions).toEqual([]); expect(secondTab.actions).toEqual([]);
firstTab.finishNextCapture(); firstTab.finishNextCapture();
await secondTab.waitForCaptureStart(1); await secondTab.waitForCaptureStart(1);
expect(firstTab.actions).toEqual([ expect(firstTab.actions).toEqual(["invalidate", "capture"]);
"prepare", expect(secondTab.actions).toEqual(["invalidate", "capture"]);
"background:false",
"invalidate",
"capture",
"restore:capture-1",
"background:true",
]);
expect(secondTab.actions).toEqual(["prepare", "background:false", "invalidate", "capture"]);
secondTab.finishNextCapture(); secondTab.finishNextCapture();
await expect(first).resolves.toMatchObject({ requestId: "req-screenshot", ok: true }); await expect(first).resolves.toMatchObject({ requestId: "req-screenshot", ok: true });
await expect(second).resolves.toMatchObject({ requestId: "req-screenshot-2", ok: true }); await expect(second).resolves.toMatchObject({ requestId: "req-screenshot-2", ok: true });
expect(secondTab.actions).toEqual([ expect(secondTab.actions).toEqual(["invalidate", "capture"]);
"prepare",
"background:false",
"invalidate",
"capture",
"restore:capture-1",
"background:true",
]);
}); });
test("screenshot with fullPage captures the page content area through CDP", async () => { test("screenshot with fullPage captures the page content area through CDP", async () => {
@@ -1275,17 +1121,13 @@ describe("executeAutomationCommand", () => {
}, },
]); ]);
expect(browser.tab.actions).toEqual([ expect(browser.tab.actions).toEqual([
"prepare",
"background:false",
"invalidate", "invalidate",
"debug:Page.getLayoutMetrics", "debug:Page.getLayoutMetrics",
"debug:Page.captureScreenshot", "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(); const browser = new BrowserAutomationHarness();
browser.tab.fullPageScreenshotData = ""; browser.tab.fullPageScreenshotData = "";
@@ -1304,13 +1146,9 @@ describe("executeAutomationCommand", () => {
}, },
}); });
expect(browser.tab.actions).toEqual([ expect(browser.tab.actions).toEqual([
"prepare",
"background:false",
"invalidate", "invalidate",
"debug:Page.getLayoutMetrics", "debug:Page.getLayoutMetrics",
"debug:Page.captureScreenshot", "debug:Page.captureScreenshot",
"restore:capture-1",
"background:true",
]); ]);
}); });
@@ -1339,23 +1177,19 @@ describe("executeAutomationCommand", () => {
}, },
}); });
expect(browser.tab.actions).toEqual([ expect(browser.tab.actions).toEqual([
"prepare",
"background:false",
"invalidate", "invalidate",
"debug:Page.getLayoutMetrics", "debug:Page.getLayoutMetrics",
"debug:Page.captureScreenshot", "debug:Page.captureScreenshot",
"invalidate", "invalidate",
"debug:Page.getLayoutMetrics", "debug:Page.getLayoutMetrics",
"debug:Page.captureScreenshot", "debug:Page.captureScreenshot",
"restore:capture-1",
"background:true",
]); ]);
} finally { } finally {
vi.useRealTimers(); 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(); const browser = new BrowserAutomationHarness();
browser.tab.fullPageScreenshotThrows = true; browser.tab.fullPageScreenshotThrows = true;
browser.tab.fullPageScreenshotErrorMessage = "Debugger detached"; browser.tab.fullPageScreenshotErrorMessage = "Debugger detached";
@@ -1367,13 +1201,9 @@ describe("executeAutomationCommand", () => {
}), }),
).rejects.toThrow("Debugger detached"); ).rejects.toThrow("Debugger detached");
expect(browser.tab.actions).toEqual([ expect(browser.tab.actions).toEqual([
"prepare",
"background:false",
"invalidate", "invalidate",
"debug:Page.getLayoutMetrics", "debug:Page.getLayoutMetrics",
"debug:Page.captureScreenshot", "debug:Page.captureScreenshot",
"restore:capture-1",
"background:true",
]); ]);
}); });
@@ -24,19 +24,11 @@ export interface TabContents {
goForward(): void; goForward(): void;
reload(): void; reload(): void;
capturePage(options?: TabCapturePageOptions): Promise<TabImage>; capturePage(options?: TabCapturePageOptions): Promise<TabImage>;
prepareForPixelCapture(): Promise<TabPixelCapturePreparation>;
restorePixelCapture(preparation: TabPixelCapturePreparation): Promise<void>;
invalidate(): void; invalidate(): void;
isBackgroundThrottlingAllowed(): boolean;
setBackgroundThrottling(allowed: boolean): void;
getConsoleMessages?(): BrowserAutomationConsoleLogEntry[]; getConsoleMessages?(): BrowserAutomationConsoleLogEntry[];
sendDebugCommand?(command: string, params?: Record<string, unknown>): Promise<unknown>; sendDebugCommand?(command: string, params?: Record<string, unknown>): Promise<unknown>;
} }
export interface TabPixelCapturePreparation {
token: string;
}
export interface TabImage { export interface TabImage {
toPNG(): Uint8Array; toPNG(): Uint8Array;
getSize(): { width: number; height: number }; getSize(): { width: number; height: number };
@@ -62,9 +54,7 @@ const DEFAULT_WAIT_TIMEOUT_MS = 5_000;
const WAIT_POLL_INTERVAL_MS = 25; const WAIT_POLL_INTERVAL_MS = 25;
const PIXEL_CAPTURE_TIMEOUT_MS = 5_000; const PIXEL_CAPTURE_TIMEOUT_MS = 5_000;
const PIXEL_CAPTURE_RETRY_INTERVAL_MS = 200; const PIXEL_CAPTURE_RETRY_INTERVAL_MS = 200;
const SCREENSHOT_NO_FRAME_MESSAGE = const SCREENSHOT_NO_FRAME_MESSAGE = "The tab has not painted yet. Retry the screenshot.";
"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 ALLOWED_PAGE_URL_PROTOCOLS = new Set(["http:", "https:"]); const ALLOWED_PAGE_URL_PROTOCOLS = new Set(["http:", "https:"]);
let pixelCaptureQueue: Promise<void> = Promise.resolve(); let pixelCaptureQueue: Promise<void> = Promise.resolve();
@@ -92,7 +82,7 @@ function screenshotNoFrameFailure(
requestId: string, requestId: string,
error: ScreenshotNoFrameError, error: ScreenshotNoFrameError,
): FailurePayload { ): FailurePayload {
return fail(requestId, "screenshot_no_frame", error.message); return fail(requestId, "screenshot_no_frame", error.message, true);
} }
async function withPixelCaptureTimeout<T>( async function withPixelCaptureTimeout<T>(
@@ -138,26 +128,7 @@ async function runSerializedPixelCapture<T>(capture: () => Promise<T>): Promise<
} }
} }
async function prepareForPixelCapture(contents: TabContents): Promise<TabPixelCapturePreparation> { async function capturePixelFrameWithRetry<T>(
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>(
contents: TabContents, contents: TabContents,
capture: () => Promise<T>, capture: () => Promise<T>,
): Promise<T> { ): Promise<T> {
@@ -179,26 +150,6 @@ async function capturePreparedPixelFrame<T>(
throw new ScreenshotNoFrameError(); 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 { function isKnownNoFrameCaptureError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
return ( return (
@@ -208,34 +159,15 @@ function isKnownNoFrameCaptureError(error: unknown): boolean {
); );
} }
async function runPreparedPixelCapture<T>( async function runPaintedPixelCapture<T>(
contents: TabContents, contents: TabContents,
capture: () => Promise<T>, capture: () => Promise<T>,
): Promise<T> { ): Promise<T> {
return runSerializedPixelCapture(async () => { return runSerializedPixelCapture(() => capturePixelFrameWithRetry(contents, capture));
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);
}
}
});
} }
async function capturePaintedViewport(contents: TabContents): Promise<TabImage> { async function capturePaintedViewport(contents: TabContents): Promise<TabImage> {
return runPreparedPixelCapture(contents, () => contents.capturePage({ stayHidden: false })); return runPaintedPixelCapture(contents, () => contents.capturePage({ stayHidden: false }));
} }
function tabInfoFromContents( function tabInfoFromContents(
@@ -966,7 +898,7 @@ async function executeFullPageScreenshot(
let width = 0; let width = 0;
let height = 0; let height = 0;
try { try {
screenshot = await runPreparedPixelCapture(target.contents, async () => { screenshot = await runPaintedPixelCapture(target.contents, async () => {
const metrics = await getCdpLayoutMetrics(target.contents); const metrics = await getCdpLayoutMetrics(target.contents);
width = metrics.contentWidth; width = metrics.contentWidth;
height = metrics.contentHeight; 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(); 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 { function getBrowserIdFromWebviewPartition(partition: string | undefined): string | null {
const prefix = "persist:paseo-browser-"; const prefix = "persist:paseo-browser-";
if (!partition?.startsWith(prefix)) { if (!partition?.startsWith(prefix)) {
@@ -36,14 +46,20 @@ export function listRegisteredPaseoBrowserIds(): string[] {
.filter((browserId) => getPaseoBrowserWebContents(browserId)); .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 }); browserRegistry.registerWebContents({ webContentsId: contents.id, browserId });
contents.once("destroyed", () => { contents.once("destroyed", () => {
browserRegistry.unregisterWebContents(contents.id); browserRegistry.unregisterWebContents(contents.id);
}); });
} }
export function getPaseoBrowserIdForWebContents(contents: WebContents | null): string | null { export function getPaseoBrowserIdForWebContents(
contents: BrowserWebContentsIdentity | null,
): string | null {
if (!contents || contents.isDestroyed()) { if (!contents || contents.isDestroyed()) {
return null; return null;
} }
-135
View File
@@ -1,117 +1,6 @@
import { contextBridge, ipcRenderer, webUtils } from "electron"; import { contextBridge, ipcRenderer, webUtils } from "electron";
type EventHandler = (payload: unknown) => void; type EventHandler = (payload: unknown) => void;
type BrowserPixelCapturePrepareHandler = (input: {
requestId: string;
browserId: string;
}) => Promise<{ token: string }>;
type BrowserPixelCaptureRestoreHandler = (input: { token: string }) => Promise<void>;
type BrowserPixelCaptureCancelHandler = (input: {
requestId?: string;
token?: string;
}) => Promise<void>;
let prepareForPixelCaptureHandler: BrowserPixelCapturePrepareHandler | null = null;
let restorePixelCaptureHandler: BrowserPixelCaptureRestoreHandler | null = null;
let cancelPixelCaptureHandler: BrowserPixelCaptureCancelHandler | null = null;
const canceledPixelCaptureRequestIds = new Set<string>();
function readStringField(payload: unknown, key: string): string | null {
if (!isRecord(payload)) {
return null;
}
const value = payload[key];
return typeof value === "string" && value.length > 0 ? value : null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
ipcRenderer.on("paseo:browser:capture-prepare", async (_event, payload: unknown) => {
const requestId = readStringField(payload, "requestId");
const browserId = readStringField(payload, "browserId");
if (!requestId || !browserId || !prepareForPixelCaptureHandler) {
ipcRenderer.send("paseo:browser:capture-prepared", {
requestId: requestId ?? "unknown",
ok: false,
message: "Browser pixel capture preparation is unavailable.",
});
return;
}
try {
const preparation = await prepareForPixelCaptureHandler({ requestId, browserId });
if (canceledPixelCaptureRequestIds.delete(requestId)) {
await restorePixelCaptureHandler?.({ token: preparation.token });
ipcRenderer.send("paseo:browser:capture-prepared", {
requestId,
ok: false,
message: "Browser pixel capture preparation was canceled.",
});
return;
}
ipcRenderer.send("paseo:browser:capture-prepared", {
requestId,
ok: true,
token: preparation.token,
});
} catch (error) {
canceledPixelCaptureRequestIds.delete(requestId);
ipcRenderer.send("paseo:browser:capture-prepared", {
requestId,
ok: false,
message: errorMessage(error),
});
}
});
ipcRenderer.on("paseo:browser:capture-cancel", async (_event, payload: unknown) => {
const requestId = readStringField(payload, "requestId");
const token = readStringField(payload, "token");
if (requestId) {
canceledPixelCaptureRequestIds.add(requestId);
}
if (!requestId && !token) {
return;
}
try {
await cancelPixelCaptureHandler?.({
...(requestId ? { requestId } : {}),
...(token ? { token } : {}),
});
} catch {
// The original prepare/restore request owns the user-visible error.
}
});
ipcRenderer.on("paseo:browser:capture-restore", async (_event, payload: unknown) => {
const requestId = readStringField(payload, "requestId");
const token = readStringField(payload, "token");
if (!requestId || !token || !restorePixelCaptureHandler) {
ipcRenderer.send("paseo:browser:capture-restored", {
requestId: requestId ?? "unknown",
ok: false,
message: "Browser pixel capture restore is unavailable.",
});
return;
}
try {
await restorePixelCaptureHandler({ token });
ipcRenderer.send("paseo:browser:capture-restored", { requestId, ok: true });
} catch (error) {
ipcRenderer.send("paseo:browser:capture-restored", {
requestId,
ok: false,
message: errorMessage(error),
});
}
});
contextBridge.exposeInMainWorld("paseoDesktop", { contextBridge.exposeInMainWorld("paseoDesktop", {
platform: process.platform, platform: process.platform,
@@ -201,29 +90,5 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
) => ipcRenderer.invoke("paseo:browser:capture-element", browserId, rect), ) => ipcRenderer.invoke("paseo:browser:capture-element", browserId, rect),
copyElement: (payload: { text?: string; imageDataUrl?: string }) => copyElement: (payload: { text?: string; imageDataUrl?: string }) =>
ipcRenderer.invoke("paseo:browser:copy-element", payload), ipcRenderer.invoke("paseo:browser:copy-element", payload),
onPrepareForPixelCapture: (handler: BrowserPixelCapturePrepareHandler): (() => void) => {
prepareForPixelCaptureHandler = handler;
return () => {
if (prepareForPixelCaptureHandler === handler) {
prepareForPixelCaptureHandler = null;
}
};
},
onRestorePixelCapture: (handler: BrowserPixelCaptureRestoreHandler): (() => void) => {
restorePixelCaptureHandler = handler;
return () => {
if (restorePixelCaptureHandler === handler) {
restorePixelCaptureHandler = null;
}
};
},
onCancelPixelCapture: (handler: BrowserPixelCaptureCancelHandler): (() => void) => {
cancelPixelCaptureHandler = handler;
return () => {
if (cancelPixelCaptureHandler === handler) {
cancelPixelCaptureHandler = null;
}
};
},
}, },
}); });
@@ -410,14 +410,14 @@ const brokerErrorCases = [
ok: false, ok: false,
error: { error: {
code: "screenshot_no_frame", code: "screenshot_no_frame",
message: "The browser tab has no painted frame. Focus the tab in the app, then try again.", message: "The tab has not painted yet. Retry the screenshot.",
retryable: false, retryable: true,
}, },
}, },
content: [ content: [
{ {
type: "text", type: "text",
text: "The browser tab has no painted frame. Focus the tab in the app, then try again.", text: "The tab has not painted yet. Retry the screenshot.",
}, },
], ],
context: { context: {