mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
fix(desktop): capture resized browser tabs without hanging
A resident guest can paint fresh pixels after resize while capturePage's native surface-copy request remains pending. The same failed guest returns a fresh frame through frame subscription, so waiting for resize or another animation frame does not correct the failing copy request. Capture one subscribed frame within the existing five-second budget and release the subscription on completion or cancellation. Preserve temporary frame production and its original throttling policy. Remove viewport copy retries and E2E screenshot polling; assert the resized image dimensions. Refs #4866. Three full real-Electron browser journeys passed under Xvfb, including physical clicks and fresh screenshots while the window is hidden.
This commit is contained in:
@@ -103,6 +103,9 @@ layering inside `overlay-root`. Activating a presented browser also focuses its
|
||||
|
||||
There is no renderer prep/restore handshake or lifetime background-throttling override.
|
||||
Screenshot capture temporarily enables frame production inside the shared serialized queue,
|
||||
restores the previous throttling policy on success or failure, invalidates before each attempt,
|
||||
and retries known first-frame failures within the 5-second capture budget. Viewport screenshots use `capturePage({ stayHidden:false })`;
|
||||
full-page screenshots use the existing CDP path with layout metrics and screenshot clip.
|
||||
restores the previous throttling policy on success or failure, and retains a 5-second capture budget.
|
||||
The browser tool takes one viewport frame through Electron's frame subscription and releases the
|
||||
subscription on completion or cancellation. A resized resident guest can produce fresh pixels
|
||||
while `capturePage()` leaves its surface-copy request pending; waiting for animation frames or
|
||||
retrying the copy does not repair that state. Full-page screenshots retain the CDP path with layout
|
||||
metrics and screenshot clip.
|
||||
|
||||
@@ -240,20 +240,6 @@ async function callBrowserTool(client, name, args = {}) {
|
||||
return mcpPayload(await client.callTool({ name, args }), name);
|
||||
}
|
||||
|
||||
async function callBrowserToolUntilReady(client, name, args = {}) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const result = await client.callTool({ name, args });
|
||||
const payload = result.structuredContent;
|
||||
if (payload?.ok === true) return payload.result;
|
||||
if (payload?.ok !== false || payload.error?.retryable !== true) {
|
||||
return mcpPayload(result, name);
|
||||
}
|
||||
await delay(100);
|
||||
}
|
||||
throw new Error(`${name} remained unavailable for ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
async function waitForGuestSelector(client, browserId) {
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (Date.now() < deadline) {
|
||||
@@ -373,7 +359,8 @@ async function readPresentation(page, browserId) {
|
||||
async function readViewport(client, browserId) {
|
||||
const evaluated = await callBrowserTool(client, "browser_evaluate", {
|
||||
browserId,
|
||||
function: "() => ({ width: window.innerWidth, height: window.innerHeight })",
|
||||
function:
|
||||
"() => ({ width: window.innerWidth, height: window.innerHeight, scale: window.devicePixelRatio })",
|
||||
});
|
||||
return JSON.parse(evaluated.resultJson);
|
||||
}
|
||||
@@ -639,10 +626,11 @@ async function runRegression({
|
||||
|
||||
const deviceSizeMenuPainted = await selectDeviceSize(page, "iPhone SE · 375×667");
|
||||
assert(deviceSizeMenuPainted, "Device size menu did not paint above the browser surface");
|
||||
const deviceViewport = await readViewport(client, browserId);
|
||||
recordViewportMismatch(
|
||||
failures,
|
||||
"device size menu paints and receives input above the browser surface",
|
||||
await readViewport(client, browserId),
|
||||
deviceViewport,
|
||||
{ width: 375, height: 667 },
|
||||
);
|
||||
|
||||
@@ -651,6 +639,12 @@ async function runRegression({
|
||||
text: "Bridge target",
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
const resizedScreenshot = await callBrowserTool(client, "browser_screenshot", { browserId });
|
||||
assert(
|
||||
resizedScreenshot.width === Math.round(375 * deviceViewport.scale) &&
|
||||
resizedScreenshot.height === Math.round(667 * deviceViewport.scale),
|
||||
`Screenshot after resize returned ${resizedScreenshot.width}×${resizedScreenshot.height}`,
|
||||
);
|
||||
const requestedViewport = { width: 640, height: 480 };
|
||||
await callBrowserTool(client, "browser_resize", { browserId, ...requestedViewport });
|
||||
recordViewportMismatch(
|
||||
@@ -726,7 +720,7 @@ async function runRegression({
|
||||
{ timeout: timeoutMs },
|
||||
);
|
||||
try {
|
||||
await callBrowserToolUntilReady(client, "browser_screenshot", { browserId });
|
||||
await callBrowserTool(client, "browser_screenshot", { browserId });
|
||||
} catch (error) {
|
||||
failures.push(`inactive browser remains captureable: ${String(error)}`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Rectangle } from "electron";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type { TabImage } from "./service.js";
|
||||
import { adaptWebContents, HostSnapshotEngineRegistry } from "./ipc.js";
|
||||
@@ -119,10 +118,8 @@ class FakeWebContents {
|
||||
public readonly debugger = new FakeDebugger();
|
||||
public readonly inputEvents: IsolatedKeyboardInputEvent[] = [];
|
||||
public readonly loadedUrls: string[] = [];
|
||||
public readonly captures: Array<{
|
||||
rect: Rectangle | undefined;
|
||||
options: { stayHidden?: boolean } | undefined;
|
||||
}> = [];
|
||||
public frameListener: ((image: TabImage) => void) | null = null;
|
||||
public endedFrameSubscriptions = 0;
|
||||
public readonly invalidations: string[] = [];
|
||||
private consoleMessageListener: ConsoleMessageListener | null = null;
|
||||
private destroyedListener: (() => void) | null = null;
|
||||
@@ -175,12 +172,14 @@ class FakeWebContents {
|
||||
|
||||
public reload(): void {}
|
||||
|
||||
public async capturePage(
|
||||
rect?: Rectangle,
|
||||
options?: { stayHidden?: boolean },
|
||||
): Promise<TabImage> {
|
||||
this.captures.push({ rect, options });
|
||||
return new FakeImage();
|
||||
public beginFrameSubscription(onlyDirty: boolean, callback: (image: TabImage) => void): void {
|
||||
expect(onlyDirty).toBe(false);
|
||||
this.frameListener = callback;
|
||||
}
|
||||
|
||||
public endFrameSubscription(): void {
|
||||
this.frameListener = null;
|
||||
this.endedFrameSubscriptions += 1;
|
||||
}
|
||||
|
||||
public invalidate(): void {
|
||||
@@ -245,16 +244,30 @@ describe("browser automation IPC adapter", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("delegates viewport capture to the guest without a renderer prep bridge", async () => {
|
||||
test("captures one rendered frame and releases the subscription", async () => {
|
||||
const contents = new FakeWebContents(20);
|
||||
const tab = adaptWebContents(contents);
|
||||
const controller = new AbortController();
|
||||
const capture = tab.captureFrame(controller.signal);
|
||||
contents.frameListener?.(new FakeImage());
|
||||
|
||||
const image = await tab.capturePage({ stayHidden: false });
|
||||
tab.invalidate();
|
||||
expect((await capture).getSize()).toEqual({ width: 640, height: 480 });
|
||||
expect(contents.frameListener).toBeNull();
|
||||
controller.abort();
|
||||
expect(contents.endedFrameSubscriptions).toBe(1);
|
||||
});
|
||||
|
||||
expect(image.getSize()).toEqual({ width: 640, height: 480 });
|
||||
expect(contents.captures).toEqual([{ rect: undefined, options: { stayHidden: false } }]);
|
||||
expect(contents.invalidations).toEqual(["invalidate"]);
|
||||
test("cancels a pending frame subscription when the capture budget expires", async () => {
|
||||
const contents = new FakeWebContents(2001);
|
||||
const tab = adaptWebContents(contents);
|
||||
const controller = new AbortController();
|
||||
const capture = tab.captureFrame(controller.signal);
|
||||
const failure = expect(capture).rejects.toThrow("capture deadline");
|
||||
controller.abort(new Error("capture deadline"));
|
||||
|
||||
await failure;
|
||||
expect(contents.frameListener).toBeNull();
|
||||
expect(contents.endedFrameSubscriptions).toBe(1);
|
||||
});
|
||||
|
||||
test("collects console messages until the guest is destroyed", () => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Rectangle } from "electron";
|
||||
import { ipcMain } from "electron";
|
||||
import { BrowserAutomationExecuteRequestSchema } from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
||||
import type {
|
||||
@@ -105,7 +104,8 @@ interface BrowserAutomationWebContents extends ConsoleMessageEmitter {
|
||||
goBack(): void;
|
||||
goForward(): void;
|
||||
reload(): void;
|
||||
capturePage(rect?: Rectangle, options?: { stayHidden?: boolean }): Promise<TabImage>;
|
||||
beginFrameSubscription(onlyDirty: boolean, callback: (image: TabImage) => void): void;
|
||||
endFrameSubscription(): void;
|
||||
invalidate(): void;
|
||||
getBackgroundThrottling(): boolean;
|
||||
setBackgroundThrottling(allowed: boolean): void;
|
||||
@@ -130,7 +130,7 @@ export function adaptWebContents(contents: BrowserAutomationWebContents): TabCon
|
||||
goBack: () => contents.goBack(),
|
||||
goForward: () => contents.goForward(),
|
||||
reload: () => contents.reload(),
|
||||
capturePage: (captureOptions) => contents.capturePage(undefined, captureOptions),
|
||||
captureFrame: (signal) => captureViewportFrame(contents, signal),
|
||||
invalidate: () => contents.invalidate(),
|
||||
withFrameProduction: async (capture) => {
|
||||
const previous = contents.getBackgroundThrottling();
|
||||
@@ -154,6 +154,35 @@ export function adaptWebContents(contents: BrowserAutomationWebContents): TabCon
|
||||
};
|
||||
}
|
||||
|
||||
function captureViewportFrame(
|
||||
contents: BrowserAutomationWebContents,
|
||||
signal: AbortSignal,
|
||||
): Promise<TabImage> {
|
||||
signal.throwIfAborted();
|
||||
return new Promise((resolve, reject) => {
|
||||
const stop = () => {
|
||||
signal.removeEventListener("abort", abort);
|
||||
if (!contents.isDestroyed()) contents.endFrameSubscription();
|
||||
};
|
||||
const abort = () => {
|
||||
stop();
|
||||
reject(signal.reason);
|
||||
};
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
try {
|
||||
// A resized resident guest can paint while capturePage's surface-copy
|
||||
// request remains pending. Subscribe to its rendered frames instead.
|
||||
contents.beginFrameSubscription(false, (image) => {
|
||||
stop();
|
||||
resolve(image);
|
||||
});
|
||||
} catch (error) {
|
||||
stop();
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getCdpQueue(contentsId: number): CdpSessionQueue {
|
||||
const existing = cdpQueuesByContentsId.get(contentsId);
|
||||
if (existing) {
|
||||
|
||||
@@ -39,7 +39,7 @@ class FakeTab implements TabContents {
|
||||
public readonly loadedUrls: string[] = [];
|
||||
public readonly scripts: string[] = [];
|
||||
public readonly actions: string[] = [];
|
||||
public readonly capturedViewports: Array<{ stayHidden?: boolean }> = [];
|
||||
public readonly capturedViewports: AbortSignal[] = [];
|
||||
public readonly debugCommands: Array<{ command: string; params?: Record<string, unknown> }> = [];
|
||||
public readonly inputEvents: IsolatedKeyboardInputEvent[] = [];
|
||||
private readonly captureStartWaiters: Array<() => void> = [];
|
||||
@@ -71,7 +71,6 @@ class FakeTab implements TabContents {
|
||||
public captureNeverPaints = false;
|
||||
public captureThrows = false;
|
||||
public captureErrorMessage = "capture failed";
|
||||
public viewportCaptureFailuresBeforeSuccess = 0;
|
||||
public deferCaptures = false;
|
||||
public fullPageScreenshotThrows = false;
|
||||
public fullPageScreenshotErrorMessage = "UnknownVizError";
|
||||
@@ -168,14 +167,10 @@ class FakeTab implements TabContents {
|
||||
this.actions.push("reload");
|
||||
}
|
||||
|
||||
public async capturePage(options?: { stayHidden?: boolean }): Promise<TabImage> {
|
||||
this.capturedViewports.push(options ?? {});
|
||||
public async captureFrame(signal: AbortSignal): Promise<TabImage> {
|
||||
this.capturedViewports.push(signal);
|
||||
this.actions.push("capture");
|
||||
this.resolveCaptureStartWaiters();
|
||||
if (this.viewportCaptureFailuresBeforeSuccess > 0) {
|
||||
this.viewportCaptureFailuresBeforeSuccess -= 1;
|
||||
throw new Error(this.captureErrorMessage);
|
||||
}
|
||||
if (this.captureThrows) {
|
||||
throw new Error(this.captureErrorMessage);
|
||||
}
|
||||
@@ -1564,7 +1559,8 @@ describe("executeAutomationCommand", () => {
|
||||
height: 480,
|
||||
},
|
||||
});
|
||||
expect(browser.tab.capturedViewports).toEqual([{ stayHidden: false }]);
|
||||
expect(browser.tab.capturedViewports).toHaveLength(1);
|
||||
expect(browser.tab.capturedViewports[0].aborted).toBe(true);
|
||||
expect(browser.tab.actions).toEqual(["invalidate", "capture"]);
|
||||
});
|
||||
|
||||
@@ -1590,6 +1586,7 @@ describe("executeAutomationCommand", () => {
|
||||
},
|
||||
});
|
||||
expect(browser.tab.actions).toEqual(["invalidate", "capture"]);
|
||||
expect(browser.tab.capturedViewports[0].aborted).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
@@ -1634,76 +1631,6 @@ describe("executeAutomationCommand", () => {
|
||||
expect(browser.tab.actions).toEqual(["invalidate", "capture"]);
|
||||
});
|
||||
|
||||
test("screenshot retries UnknownVizError until the first viewport frame appears", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const browser = new BrowserAutomationHarness();
|
||||
browser.tab.captureErrorMessage = "UnknownVizError";
|
||||
browser.tab.viewportCaptureFailuresBeforeSuccess = 2;
|
||||
|
||||
const resultPromise = browser.execute({
|
||||
command: "screenshot",
|
||||
args: { browserId: BROWSER_A },
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({
|
||||
requestId: "req-screenshot",
|
||||
ok: true,
|
||||
result: {
|
||||
command: "screenshot",
|
||||
browserId: BROWSER_A,
|
||||
mimeType: "image/png",
|
||||
dataBase64: "iVBORwECAw==",
|
||||
width: 640,
|
||||
height: 480,
|
||||
},
|
||||
});
|
||||
expect(browser.tab.capturedViewports).toHaveLength(3);
|
||||
expect(browser.tab.actions).toEqual([
|
||||
"invalidate",
|
||||
"capture",
|
||||
"invalidate",
|
||||
"capture",
|
||||
"invalidate",
|
||||
"capture",
|
||||
]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("screenshot returns no-frame after UnknownVizError spends the retry budget", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const browser = new BrowserAutomationHarness();
|
||||
browser.tab.captureThrows = true;
|
||||
browser.tab.captureErrorMessage = "UnknownVizError";
|
||||
|
||||
const resultPromise = browser.execute({
|
||||
command: "screenshot",
|
||||
args: { browserId: BROWSER_A },
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({
|
||||
requestId: "req-screenshot",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "screenshot_no_frame",
|
||||
message: "The tab has not painted yet. Retry the screenshot.",
|
||||
retryable: true,
|
||||
},
|
||||
});
|
||||
expect(browser.tab.capturedViewports.length).toBeGreaterThan(1);
|
||||
expect(browser.tab.actions.filter((action) => action === "invalidate")).toHaveLength(
|
||||
browser.tab.capturedViewports.length,
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("overlapping screenshots serialize through the shared capture queue", async () => {
|
||||
const browser = new BrowserAutomationHarness();
|
||||
browser.tab.deferCaptures = true;
|
||||
|
||||
@@ -35,7 +35,7 @@ export interface TabContents {
|
||||
goBack(): void;
|
||||
goForward(): void;
|
||||
reload(): void;
|
||||
capturePage(options?: TabCapturePageOptions): Promise<TabImage>;
|
||||
captureFrame(signal: AbortSignal): Promise<TabImage>;
|
||||
invalidate(): void;
|
||||
withFrameProduction<T>(capture: () => Promise<T>): Promise<T>;
|
||||
sendInputEvent(event: IsolatedKeyboardInputEvent): void;
|
||||
@@ -51,10 +51,6 @@ export interface TabImage {
|
||||
getSize(): { width: number; height: number };
|
||||
}
|
||||
|
||||
export interface TabCapturePageOptions {
|
||||
stayHidden?: boolean;
|
||||
}
|
||||
|
||||
export interface BrowserRegistry {
|
||||
listRegisteredBrowserIds(): string[];
|
||||
listRegisteredBrowserIdsForWorkspace(workspaceId: string): string[];
|
||||
@@ -215,7 +211,22 @@ async function runPaintedPixelCapture<T>(
|
||||
}
|
||||
|
||||
async function capturePaintedViewport(contents: TabContents): Promise<TabImage> {
|
||||
return runPaintedPixelCapture(contents, () => contents.capturePage({ stayHidden: false }));
|
||||
return runSerializedPixelCapture(() =>
|
||||
contents.withFrameProduction(async () => {
|
||||
const deadline = Date.now() + PIXEL_CAPTURE_TIMEOUT_MS;
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
await waitForPaint(contents, deadline);
|
||||
contents.invalidate();
|
||||
return await withPixelCaptureTimeout(
|
||||
contents.captureFrame(controller.signal),
|
||||
deadline - Date.now(),
|
||||
);
|
||||
} finally {
|
||||
controller.abort();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function tabInfoFromContents(
|
||||
|
||||
Reference in New Issue
Block a user