Fix fresh browser screenshot prep race

This commit is contained in:
Mohamed Boudra
2026-07-03 07:18:45 +02:00
parent f22865df62
commit cda1ba43cc
9 changed files with 497 additions and 44 deletions
+6 -3
View File
@@ -8,6 +8,8 @@ It validates the compositor behavior that unit tests cannot see:
- 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, and the capture
target is raised above its sibling webviews before capture; target is raised above its sibling webviews before capture;
- a newly attached resident webview can be captured immediately after load, without
an extra settle delay;
- 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; - 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;
- restore returns the host to offscreen parking after every capture. - restore returns the host to offscreen parking after every capture.
@@ -25,9 +27,10 @@ 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 both guest sizes, the expected parked-capture
failure, the legacy stacked-below-the-clip failure for the second webview, five viewport failure, the legacy stacked-below-the-clip failure for the second webview, five viewport
prep captures and five full-page prep captures for each of the first two webviews, and prep captures and five full-page prep captures for each of the first two webviews, a
final completion. The PNG sizes may be device-pixel scaled; on a Retina display the fresh-immediate viewport and full-page capture for a newly attached webview, and final
1280x800 logical viewport is usually saved as 2560x1600. completion. The PNG sizes may be device-pixel scaled; on a Retina display the 1280x800
logical viewport is usually saved as 2560x1600.
## Mechanism ## Mechanism
@@ -144,6 +144,34 @@ describe("resident browser webviews", () => {
expect(secondWebview.style.zIndex).toBe("0"); expect(secondWebview.style.zIndex).toBe("0");
}); });
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 () => { it("keeps the resident host paintable until every capture token is restored", async () => {
ensureResidentBrowserWebview({ ensureResidentBrowserWebview({
browserId: "browser-overlap", browserId: "browser-overlap",
@@ -202,7 +202,10 @@ function releaseCapturePreparationToken(token: string): void {
} }
activeCapturePreparations.delete(token); activeCapturePreparations.delete(token);
if (preparation.preparesResidentHost) { if (preparation.preparesResidentHost) {
applyResidentWebviewStyle(preparation.webview); const host = readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID);
if (host instanceof HTMLElement && preparation.webview.parentElement === host) {
applyResidentWebviewStyle(preparation.webview);
}
syncResidentHostCaptureState(); syncResidentHostCaptureState();
} }
} }
@@ -276,6 +279,7 @@ export function takeResidentBrowserWebview(browserId: string): HTMLElement | nul
} }
residentWebviewsByBrowserId.delete(normalizedBrowserId); residentWebviewsByBrowserId.delete(normalizedBrowserId);
releaseCapturePreparationsForBrowser(normalizedBrowserId);
clearResidentWebviewParkingStyle(webview); clearResidentWebviewParkingStyle(webview);
return webview; return webview;
} }
+12 -2
View File
@@ -48,7 +48,8 @@
const activeTokens = new Set(); const activeTokens = new Set();
let nextTokenId = 0; let nextTokenId = 0;
for (let index = 0; index < webviewCount; index += 1) { function appendHarnessWebview(sourceUrl) {
const index = webviews.length;
const webview = document.createElement("webview"); const webview = document.createElement("webview");
webview.id = `target-webview-${index + 1}`; webview.id = `target-webview-${index + 1}`;
webview.className = "capture-harness-webview"; webview.className = "capture-harness-webview";
@@ -57,9 +58,15 @@
webview.setAttribute("allowpopups", "true"); webview.setAttribute("allowpopups", "true");
webview.setAttribute("spellcheck", "false"); webview.setAttribute("spellcheck", "false");
webview.setAttribute("autosize", "on"); webview.setAttribute("autosize", "on");
webview.src = params.get("targetUrl") || "bright.html"; webview.src = sourceUrl;
applyStackedWebviewStyle(webview);
host.appendChild(webview); host.appendChild(webview);
webviews.push(webview); webviews.push(webview);
return index;
}
for (let index = 0; index < webviewCount; index += 1) {
appendHarnessWebview(params.get("targetUrl") || "bright.html");
} }
function applyHostParking() { function applyHostParking() {
@@ -203,6 +210,9 @@
typeof webview.getWebContentsId === "function" ? webview.getWebContentsId() : null, typeof webview.getWebContentsId === "function" ? webview.getWebContentsId() : null,
); );
}, },
addWebview(sourceUrl) {
return appendHarnessWebview(sourceUrl || params.get("targetUrl") || "bright.html");
},
async prepareForPixelCapture(targetIndex = 0) { async prepareForPixelCapture(targetIndex = 0) {
const token = `capture-${++nextTokenId}`; const token = `capture-${++nextTokenId}`;
activeTokens.add(token); activeTokens.add(token);
+69 -6
View File
@@ -143,7 +143,7 @@ async function saveImage(image, outputPath) {
await fsp.writeFile(outputPath, image.toPNG()); await fsp.writeFile(outputPath, image.toPNG());
} }
async function waitForGuestLoad(contents) { async function waitForGuestLoad(contents, input = {}) {
await new Promise((resolve) => { await new Promise((resolve) => {
if (!contents.isLoading()) { if (!contents.isLoading()) {
resolve(); resolve();
@@ -152,7 +152,10 @@ async function waitForGuestLoad(contents) {
contents.once("did-finish-load", resolve); contents.once("did-finish-load", resolve);
contents.once("did-fail-load", resolve); contents.once("did-fail-load", resolve);
}); });
await new Promise((resolve) => setTimeout(resolve, 500)); const settleMs = input.settleMs ?? 500;
if (settleMs > 0) {
await new Promise((resolve) => setTimeout(resolve, settleMs));
}
} }
async function renderer(win, expression) { async function renderer(win, expression) {
@@ -228,14 +231,23 @@ async function captureFullPage(contents) {
} }
} }
async function captureWithPrep({ win, contents, mode, repeatIndex, targetIndex, guestMetrics }) { async function captureWithPrep({
win,
contents,
mode,
repeatIndex,
targetIndex,
guestMetrics,
repeatTotal = REPEAT_COUNT,
label = "prep",
}) {
const preparation = await renderer( const preparation = await renderer(
win, win,
`window.captureHarness.prepareForPixelCapture(${JSON.stringify(targetIndex)})`, `window.captureHarness.prepareForPixelCapture(${JSON.stringify(targetIndex)})`,
); );
const outputPath = path.join( const outputPath = path.join(
OUT_DIR, OUT_DIR,
`${mode}-webview-${targetIndex + 1}-prep-${repeatIndex}.png`, `${mode}-webview-${targetIndex + 1}-${label}-${repeatIndex}.png`,
); );
try { try {
const image = const image =
@@ -251,11 +263,11 @@ async function captureWithPrep({ win, contents, mode, repeatIndex, targetIndex,
const bright = analysis.brightRatio.toFixed(4); const bright = analysis.brightRatio.toFixed(4);
if (!analysis.pass) { if (!analysis.pass) {
fail( fail(
`${mode} webview ${targetIndex + 1} prep ${repeatIndex}/${REPEAT_COUNT} size=${size} logical=${logicalSize} bright=${bright} text=${analysis.textNonUniform} file=${outputPath}`, `${mode} webview ${targetIndex + 1} ${label} ${repeatIndex}/${repeatTotal} size=${size} logical=${logicalSize} bright=${bright} text=${analysis.textNonUniform} file=${outputPath}`,
); );
} }
pass( pass(
`${mode} webview ${targetIndex + 1} prep ${repeatIndex}/${REPEAT_COUNT} size=${size} logical=${logicalSize} bright=${bright} text=${analysis.textNonUniform} file=${outputPath}`, `${mode} webview ${targetIndex + 1} ${label} ${repeatIndex}/${repeatTotal} size=${size} logical=${logicalSize} bright=${bright} text=${analysis.textNonUniform} file=${outputPath}`,
); );
return analysis; return analysis;
} finally { } finally {
@@ -308,14 +320,58 @@ async function expectLegacySecondWebviewFailure({ win, contents, mode, guestMetr
} }
} }
async function captureFreshWebviewImmediately({ win, waitForNextAttachedGuest }) {
const freshGuestPromise = waitForNextAttachedGuest();
const targetIndex = await renderer(
win,
`window.captureHarness.addWebview(${JSON.stringify(fileUrl(path.join(ROOT, "bright.html")))})`,
);
const guest = await withTimeout(freshGuestPromise, "fresh did-attach-webview");
await waitForGuestLoad(guest, { settleMs: 0 });
const guestMetrics = await readGuestMetrics(guest);
if (guestMetrics.innerWidth !== VIEWPORT_WIDTH || guestMetrics.innerHeight !== VIEWPORT_HEIGHT) {
fail(
`fresh guest viewport sizing webview ${targetIndex + 1} inner=${guestMetrics.innerWidth}x${guestMetrics.innerHeight} expected=${VIEWPORT_WIDTH}x${VIEWPORT_HEIGHT}`,
);
}
pass(
`fresh guest viewport sizing webview ${targetIndex + 1} inner=${guestMetrics.innerWidth}x${guestMetrics.innerHeight} dpr=${guestMetrics.devicePixelRatio}`,
);
await captureWithPrep({
win,
contents: guest,
mode: "viewport",
repeatIndex: 1,
targetIndex,
guestMetrics,
repeatTotal: 1,
label: "fresh-immediate",
});
await captureWithPrep({
win,
contents: guest,
mode: "full-page",
repeatIndex: 1,
targetIndex,
guestMetrics,
repeatTotal: 1,
label: "fresh-immediate",
});
}
async function main() { async function main() {
ensureDirSync(OUT_DIR); ensureDirSync(OUT_DIR);
const attachedGuests = []; const attachedGuests = [];
const freshGuestWaiters = [];
let resolveGuests; let resolveGuests;
const guestsPromise = new Promise((resolve) => { const guestsPromise = new Promise((resolve) => {
resolveGuests = resolve; resolveGuests = resolve;
}); });
const waitForNextAttachedGuest = () =>
new Promise((resolve) => {
freshGuestWaiters.push(resolve);
});
const win = new BrowserWindow({ const win = new BrowserWindow({
width: 1000, width: 1000,
height: 700, height: 700,
@@ -335,6 +391,10 @@ async function main() {
}); });
win.webContents.on("did-attach-webview", (_event, contents) => { win.webContents.on("did-attach-webview", (_event, contents) => {
attachedGuests.push(contents); attachedGuests.push(contents);
const waiter = freshGuestWaiters.shift();
if (waiter) {
waiter(contents);
}
if (attachedGuests.length >= 2) { if (attachedGuests.length >= 2) {
resolveGuests(attachedGuests); resolveGuests(attachedGuests);
} }
@@ -398,6 +458,9 @@ async function main() {
await renderer(win, "window.captureHarness.restoreParking()"); await renderer(win, "window.captureHarness.restoreParking()");
await captureFreshWebviewImmediately({ win, waitForNextAttachedGuest });
await renderer(win, "window.captureHarness.restoreParking()");
const results = []; const results = [];
for (const targetIndex of [0, 1]) { for (const targetIndex of [0, 1]) {
for (let index = 1; index <= REPEAT_COUNT; index += 1) { for (let index = 1; index <= REPEAT_COUNT; index += 1) {
@@ -299,6 +299,101 @@ describe("browser automation IPC adapter", () => {
expect(ipc.listenerCount("paseo:browser:capture-prepared")).toBe(0); 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 () => { test("prepareForPixelCapture ignores matching responses from the wrong sender", async () => {
const host = new FakeHostWebContents(10); const host = new FakeHostWebContents(10);
const contents = new FakeWebContents(20, host); const contents = new FakeWebContents(20, host);
@@ -364,12 +459,28 @@ describe("browser automation IPC adapter", () => {
} }
}); });
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 () => { test("prepareForPixelCapture rejects when the guest has no embedder renderer", async () => {
const contents = new FakeWebContents(20, null); const contents = new FakeWebContents(20, null);
const tab = adaptWebContents(contents, "browser-a"); const tab = adaptWebContents(contents, "browser-a", { timeoutMs: 1 });
await expect(tab.prepareForPixelCapture()).rejects.toThrow( await expect(tab.prepareForPixelCapture()).rejects.toThrow("prep_unavailable");
"Browser host renderer is not available.",
);
}); });
}); });
@@ -14,6 +14,8 @@ import {
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_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; let nextPixelCaptureBridgeRequest = 0;
@@ -121,12 +123,11 @@ export function adaptWebContents(
reload: () => contents.reload(), reload: () => contents.reload(),
capturePage: (captureOptions) => contents.capturePage(undefined, captureOptions), capturePage: (captureOptions) => contents.capturePage(undefined, captureOptions),
prepareForPixelCapture: async () => { prepareForPixelCapture: async () => {
const host = getPixelCaptureHost(contents); const result = await preparePixelCaptureBridgeWithRetry({ contents, browserId, options });
const result = await requestPixelCaptureBridge({ host, browserId, kind: "prepare", options });
if (!result.token) { if (!result.token) {
throw new Error("Browser pixel capture preparation did not return a token."); throw new Error("Browser pixel capture preparation did not return a token.");
} }
preparedPixelCapturesByToken.set(result.token, { browserId, host }); preparedPixelCapturesByToken.set(result.token, { browserId, host: result.host });
return { token: result.token }; return { token: result.token };
}, },
restorePixelCapture: async (preparation) => { restorePixelCapture: async (preparation) => {
@@ -159,14 +160,83 @@ export function adaptWebContents(
}; };
} }
function getPixelCaptureHost(contents: BrowserAutomationWebContents): HostWebContents { function getPixelCaptureHost(contents: BrowserAutomationWebContents): HostWebContents | null {
const host = contents.hostWebContents; const host = contents.hostWebContents;
if (!host || host.isDestroyed()) { if (!host || host.isDestroyed()) {
throw new Error("Browser host renderer is not available."); return null;
} }
return host; 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: { function requestPixelCaptureBridge(input: {
host: HostWebContents; host: HostWebContents;
browserId: string; browserId: string;
@@ -53,9 +53,12 @@ class FakeTab implements TabContents {
public consoleMessages: BrowserAutomationConsoleLogEntry[] = []; public consoleMessages: BrowserAutomationConsoleLogEntry[] = [];
public captureNeverPaints = false; public captureNeverPaints = false;
public captureThrows = false; public captureThrows = false;
public captureErrorMessage = "capture failed";
public deferCaptures = false; public deferCaptures = false;
public prepareNeverAcks = false; public prepareNeverAcks = false;
public prepareErrorMessage: string | null = null;
public fullPageScreenshotThrows = false; public fullPageScreenshotThrows = false;
public fullPageScreenshotErrorMessage = "UnknownVizError";
public layoutMetrics = { public layoutMetrics = {
cssLayoutViewport: { clientWidth: 390, clientHeight: 844 }, cssLayoutViewport: { clientWidth: 390, clientHeight: 844 },
cssContentSize: { width: 390, height: 1200 }, cssContentSize: { width: 390, height: 1200 },
@@ -131,7 +134,7 @@ class FakeTab implements TabContents {
this.actions.push("capture"); this.actions.push("capture");
this.resolveCaptureStartWaiters(); this.resolveCaptureStartWaiters();
if (this.captureThrows) { if (this.captureThrows) {
throw new Error("capture failed"); throw new Error(this.captureErrorMessage);
} }
if (this.captureNeverPaints) { if (this.captureNeverPaints) {
return new Promise<never>(() => {}); return new Promise<never>(() => {});
@@ -146,6 +149,9 @@ class FakeTab implements TabContents {
public async prepareForPixelCapture(): Promise<TabPixelCapturePreparation> { public async prepareForPixelCapture(): Promise<TabPixelCapturePreparation> {
this.actions.push("prepare"); this.actions.push("prepare");
if (this.prepareErrorMessage) {
throw new Error(this.prepareErrorMessage);
}
if (this.prepareNeverAcks) { if (this.prepareNeverAcks) {
return new Promise<never>(() => {}); return new Promise<never>(() => {});
} }
@@ -186,7 +192,7 @@ class FakeTab implements TabContents {
} }
if (command === "Page.captureScreenshot") { if (command === "Page.captureScreenshot") {
if (this.fullPageScreenshotThrows) { if (this.fullPageScreenshotThrows) {
throw new Error("UnknownVizError"); throw new Error(this.fullPageScreenshotErrorMessage);
} }
return { data: this.fullPageScreenshotData }; return { data: this.fullPageScreenshotData };
} }
@@ -963,10 +969,32 @@ describe("executeAutomationCommand", () => {
} }
}); });
test("screenshot returns no-frame and restores capture preparation when viewport capture throws", async () => { test("screenshot restores capture preparation when viewport capture fails with an ordinary error", async () => {
const browser = new BrowserAutomationHarness(); const browser = new BrowserAutomationHarness();
browser.tab.captureThrows = true; browser.tab.captureThrows = true;
await expect(
browser.execute({
command: "screenshot",
args: { browserId: BROWSER_A },
}),
).rejects.toThrow("capture failed");
expect(browser.tab.actions).toEqual([
"prepare",
"background:false",
"invalidate",
"capture",
"restore:capture-1",
"background:true",
]);
});
test("screenshot returns no-frame and restores capture preparation when viewport capture throws UnknownVizError", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.captureThrows = true;
browser.tab.captureErrorMessage = "UnknownVizError";
await expect( await expect(
browser.execute({ browser.execute({
command: "screenshot", command: "screenshot",
@@ -1010,7 +1038,7 @@ describe("executeAutomationCommand", () => {
error: { error: {
code: "screenshot_no_frame", code: "screenshot_no_frame",
message: message:
"The browser tab has no painted frame. Focus the tab in the app, then try again.", "Browser screenshot prep_unavailable: the app renderer did not acknowledge capture preparation before the timeout.",
retryable: false, retryable: false,
}, },
}); });
@@ -1020,6 +1048,28 @@ describe("executeAutomationCommand", () => {
} }
}); });
test("screenshot returns prep_unavailable and does not capture when preparation cannot be honored", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.prepareErrorMessage =
"Browser screenshot prep_unavailable: Browser host renderer is not available.";
const result = await browser.execute({
command: "screenshot",
args: { browserId: BROWSER_A },
});
expect(result).toEqual({
requestId: "req-screenshot",
ok: false,
error: {
code: "screenshot_no_frame",
message: "Browser screenshot prep_unavailable: Browser host renderer is not available.",
retryable: false,
},
});
expect(browser.tab.actions).toEqual(["prepare", "background:true"]);
});
test("overlapping screenshots serialize capture preparation and restore", async () => { test("overlapping screenshots serialize capture preparation and restore", async () => {
const browser = new BrowserAutomationHarness(); const browser = new BrowserAutomationHarness();
browser.tab.deferCaptures = true; browser.tab.deferCaptures = true;
@@ -1076,6 +1126,65 @@ describe("executeAutomationCommand", () => {
]); ]);
}); });
test("overlapping screenshots across browser tabs serialize capture preparation and restore", async () => {
const registry = new FakeRegistry();
const firstTab = new FakeTab(1, "https://a.test", "A");
const secondTab = new FakeTab(2, "https://b.test", "B");
firstTab.deferCaptures = true;
secondTab.deferCaptures = true;
registry.register(BROWSER_A, WORKSPACE_A, firstTab);
registry.register(BROWSER_B, WORKSPACE_A, secondTab);
const first = executeAutomationCommand(
automationRequest({
command: "screenshot",
args: { browserId: BROWSER_A },
}),
registry,
);
await firstTab.waitForCaptureStart(1);
const second = executeAutomationCommand(
automationRequest(
{
command: "screenshot",
args: { browserId: BROWSER_B },
},
{ requestId: "req-screenshot-2" },
),
registry,
);
await Promise.resolve();
expect(firstTab.actions).toEqual(["prepare", "background:false", "invalidate", "capture"]);
expect(secondTab.actions).toEqual([]);
firstTab.finishNextCapture();
await secondTab.waitForCaptureStart(1);
expect(firstTab.actions).toEqual([
"prepare",
"background:false",
"invalidate",
"capture",
"restore:capture-1",
"background:true",
]);
expect(secondTab.actions).toEqual(["prepare", "background:false", "invalidate", "capture"]);
secondTab.finishNextCapture();
await expect(first).resolves.toMatchObject({ requestId: "req-screenshot", ok: true });
await expect(second).resolves.toMatchObject({ requestId: "req-screenshot-2", ok: true });
expect(secondTab.actions).toEqual([
"prepare",
"background:false",
"invalidate",
"capture",
"restore:capture-1",
"background:true",
]);
});
test("screenshot with fullPage captures the page content area through CDP", async () => { test("screenshot with fullPage captures the page content area through CDP", async () => {
const browser = new BrowserAutomationHarness(); const browser = new BrowserAutomationHarness();
@@ -1176,6 +1285,28 @@ describe("executeAutomationCommand", () => {
]); ]);
}); });
test("screenshot with fullPage restores capture preparation when CDP capture fails with an ordinary error", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.fullPageScreenshotThrows = true;
browser.tab.fullPageScreenshotErrorMessage = "Debugger detached";
await expect(
browser.execute({
command: "screenshot",
args: { browserId: BROWSER_A, fullPage: true },
}),
).rejects.toThrow("Debugger detached");
expect(browser.tab.actions).toEqual([
"prepare",
"background:false",
"invalidate",
"debug:Page.getLayoutMetrics",
"debug:Page.captureScreenshot",
"restore:capture-1",
"background:true",
]);
});
test("upload resolves workspace files before setting them on the file input", async () => { test("upload resolves workspace files before setting them on the file input", async () => {
const browser = new BrowserAutomationHarness(); const browser = new BrowserAutomationHarness();
browser.tab.snapshotElements = [ browser.tab.snapshotElements = [
@@ -63,8 +63,9 @@ const WAIT_POLL_INTERVAL_MS = 25;
const PIXEL_CAPTURE_TIMEOUT_MS = 5_000; const PIXEL_CAPTURE_TIMEOUT_MS = 5_000;
const SCREENSHOT_NO_FRAME_MESSAGE = const SCREENSHOT_NO_FRAME_MESSAGE =
"The browser tab has no painted frame. Focus the tab in the app, then try again."; "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:"]);
const pixelCaptureQueuesByContentsId = new Map<number, Promise<void>>(); let pixelCaptureQueue: Promise<void> = Promise.resolve();
function fail( function fail(
requestId: string, requestId: string,
@@ -76,8 +77,8 @@ function fail(
} }
class ScreenshotNoFrameError extends Error { class ScreenshotNoFrameError extends Error {
public constructor() { public constructor(message = SCREENSHOT_NO_FRAME_MESSAGE) {
super(SCREENSHOT_NO_FRAME_MESSAGE); super(message);
this.name = "ScreenshotNoFrameError"; this.name = "ScreenshotNoFrameError";
} }
} }
@@ -86,8 +87,11 @@ function isScreenshotNoFrameError(error: unknown): error is ScreenshotNoFrameErr
return error instanceof ScreenshotNoFrameError; return error instanceof ScreenshotNoFrameError;
} }
function screenshotNoFrameFailure(requestId: string): FailurePayload { function screenshotNoFrameFailure(
return fail(requestId, "screenshot_no_frame", SCREENSHOT_NO_FRAME_MESSAGE); requestId: string,
error: ScreenshotNoFrameError,
): FailurePayload {
return fail(requestId, "screenshot_no_frame", error.message);
} }
async function withPixelCaptureTimeout<T>(capture: Promise<T>): Promise<T> { async function withPixelCaptureTimeout<T>(capture: Promise<T>): Promise<T> {
@@ -107,25 +111,22 @@ async function withPixelCaptureTimeout<T>(capture: Promise<T>): Promise<T> {
} }
} }
async function runSerializedPixelCapture<T>( async function runSerializedPixelCapture<T>(capture: () => Promise<T>): Promise<T> {
contents: TabContents, const previous = pixelCaptureQueue;
capture: () => Promise<T>,
): Promise<T> {
const previous = pixelCaptureQueuesByContentsId.get(contents.id) ?? Promise.resolve();
let releaseCurrent = () => {}; let releaseCurrent = () => {};
const current = new Promise<void>((resolve) => { const current = new Promise<void>((resolve) => {
releaseCurrent = resolve; releaseCurrent = resolve;
}); });
const tail = previous.catch(() => {}).then(() => current); const tail = previous.catch(() => {}).then(() => current);
pixelCaptureQueuesByContentsId.set(contents.id, tail); pixelCaptureQueue = tail;
await previous.catch(() => {}); await previous.catch(() => {});
try { try {
return await capture(); return await capture();
} finally { } finally {
releaseCurrent(); releaseCurrent();
if (pixelCaptureQueuesByContentsId.get(contents.id) === tail) { if (pixelCaptureQueue === tail) {
pixelCaptureQueuesByContentsId.delete(contents.id); pixelCaptureQueue = Promise.resolve();
} }
} }
} }
@@ -133,8 +134,8 @@ async function runSerializedPixelCapture<T>(
async function prepareForPixelCapture(contents: TabContents): Promise<TabPixelCapturePreparation> { async function prepareForPixelCapture(contents: TabContents): Promise<TabPixelCapturePreparation> {
try { try {
return await withPixelCaptureTimeout(contents.prepareForPixelCapture()); return await withPixelCaptureTimeout(contents.prepareForPixelCapture());
} catch { } catch (error) {
throw new ScreenshotNoFrameError(); throw screenshotPreparationError(error);
} }
} }
@@ -156,15 +157,47 @@ async function capturePreparedPixelFrame<T>(capture: () => Promise<T>): Promise<
if (isScreenshotNoFrameError(error)) { if (isScreenshotNoFrameError(error)) {
throw error; throw error;
} }
throw new ScreenshotNoFrameError(); if (isKnownNoFrameCaptureError(error)) {
throw new ScreenshotNoFrameError();
}
throw error;
} }
} }
function screenshotPreparationError(error: unknown): ScreenshotNoFrameError {
const message = error instanceof Error ? error.message : String(error);
if (isScreenshotNoFrameError(error)) {
return new ScreenshotNoFrameError(
`${SCREENSHOT_PREP_UNAVAILABLE_PREFIX} the app renderer did not acknowledge capture preparation before the timeout.`,
);
}
if (message.includes(SCREENSHOT_PREP_UNAVAILABLE_PREFIX)) {
return new ScreenshotNoFrameError(message);
}
if (
message.includes("Browser pixel capture preparation is unavailable.") ||
message.includes("Browser host renderer is not available.") ||
message.includes("is not mounted.")
) {
return new ScreenshotNoFrameError(`${SCREENSHOT_PREP_UNAVAILABLE_PREFIX} ${message}`);
}
return new ScreenshotNoFrameError(`${SCREENSHOT_PREP_UNAVAILABLE_PREFIX} ${message}`);
}
function isKnownNoFrameCaptureError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes("UnknownVizError") ||
message.includes("No frame") ||
message.includes("no painted frame")
);
}
async function runPreparedPixelCapture<T>( async function runPreparedPixelCapture<T>(
contents: TabContents, contents: TabContents,
capture: () => Promise<T>, capture: () => Promise<T>,
): Promise<T> { ): Promise<T> {
return runSerializedPixelCapture(contents, async () => { return runSerializedPixelCapture(async () => {
const previousBackgroundThrottling = contents.isBackgroundThrottlingAllowed(); const previousBackgroundThrottling = contents.isBackgroundThrottlingAllowed();
let preparation: TabPixelCapturePreparation | null = null; let preparation: TabPixelCapturePreparation | null = null;
try { try {
@@ -839,7 +872,7 @@ async function executeScreenshot(
image = await capturePaintedViewport(target.contents); image = await capturePaintedViewport(target.contents);
} catch (error) { } catch (error) {
if (isScreenshotNoFrameError(error)) { if (isScreenshotNoFrameError(error)) {
return screenshotNoFrameFailure(requestId); return screenshotNoFrameFailure(requestId, error);
} }
throw error; throw error;
} }
@@ -931,7 +964,7 @@ async function executeFullPageScreenshot(
}); });
} catch (error) { } catch (error) {
if (isScreenshotNoFrameError(error)) { if (isScreenshotNoFrameError(error)) {
return screenshotNoFrameFailure(requestId); return screenshotNoFrameFailure(requestId, error);
} }
throw error; throw error;
} }