Fix multi-webview browser capture prep

This commit is contained in:
Mohamed Boudra
2026-07-03 07:00:06 +02:00
parent 33ffeb4d42
commit f22865df62
7 changed files with 392 additions and 82 deletions

View File

@@ -6,6 +6,8 @@ It validates the compositor behavior that unit tests cannot see:
- the resident automation `<webview>` starts in the production parking state;
- the parked guest has no copyable viewport frame;
- the resident webview guest is sized to 1280x800 logical pixels;
- multiple resident webviews are parked as an overlapping stack, and the capture
target is raised above its sibling webviews before capture;
- 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.
@@ -21,10 +23,11 @@ The harness writes PNG evidence and `results.json` to:
packages/desktop/capture-harness/out/
```
A passing run prints `PASS` lines for guest sizing, the expected parked-capture failure,
five viewport prep captures, five full-page prep captures, and final completion. The PNG
sizes may be device-pixel scaled; on a Retina display the 1280x800 logical viewport is
usually saved as 2560x1600.
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
prep captures and five full-page prep captures for each of the first two webviews, 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
@@ -34,6 +37,10 @@ webview parked at `left:-20000px` and `opacity:0` does not have a copyable surfa
Before pixel capture, the app renderer temporarily makes the resident host paintable:
`left:0`, `top:0`, `opacity:1`, `pointer-events:none`, host size `1x1`, and
`overflow:hidden`, with the full-size 1280x800 webview inside. Main captures only after
the renderer acknowledges two animation frames plus a `getBoundingClientRect()` read, and
the renderer restores parking in a `finally`.
`overflow:hidden`, with the full-size 1280x800 webview inside. Resident webviews are
parked absolutely at `0,0` inside that host because a second webview stacked below the
1px clip still has no copyable surface. During capture, the renderer raises the target
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`.

View File

@@ -28,10 +28,18 @@ describe("resident browser webviews", () => {
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("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();
});
@@ -87,6 +95,10 @@ describe("resident browser webviews", () => {
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);
@@ -94,6 +106,42 @@ describe("resident browser webviews", () => {
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("keeps the resident host paintable until every capture token is restored", async () => {

View File

@@ -20,6 +20,7 @@ interface ActiveCapturePreparation {
browserId: string;
requestId?: string;
preparesResidentHost: boolean;
webview: HTMLElement;
}
function trimNonEmpty(value: string | null | undefined): string | null {
@@ -98,15 +99,37 @@ function applyResidentWebviewStyle(webview: HTMLElement): void {
webview.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
webview.style.border = "0";
webview.style.background = "transparent";
webview.style.position = "absolute";
webview.style.left = "0";
webview.style.top = "0";
webview.style.marginTop = "0";
webview.style.zIndex = "0";
}
function hasActiveResidentHostPreparation(): boolean {
for (const preparation of activeCapturePreparations.values()) {
if (preparation.preparesResidentHost) {
return true;
function clearResidentWebviewParkingStyle(webview: HTMLElement): void {
webview.style.position = "";
webview.style.left = "";
webview.style.top = "";
webview.style.marginTop = "";
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 false;
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> {
@@ -143,13 +166,32 @@ function activeCaptureTokenFor(input: { token?: string; requestId?: string }): s
return null;
}
function parkResidentHostIfIdle(): void {
if (hasActiveResidentHostPreparation()) {
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 host = readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID);
if (host instanceof HTMLElement) {
applyResidentHostParkingStyle(host);
const latest = latestActiveResidentHostPreparation();
if (latest) {
applyResidentHostCaptureStyle(host);
raiseResidentWebviewForCapture(host, latest.webview);
return;
}
applyResidentHostParkingStyle(host);
for (const webview of residentWebviewChildren(host)) {
applyResidentWebviewStyle(webview);
}
}
@@ -160,7 +202,8 @@ function releaseCapturePreparationToken(token: string): void {
}
activeCapturePreparations.delete(token);
if (preparation.preparesResidentHost) {
parkResidentHostIfIdle();
applyResidentWebviewStyle(preparation.webview);
syncResidentHostCaptureState();
}
}
@@ -170,12 +213,12 @@ function releaseCapturePreparationsForBrowser(browserId: string): void {
activeCapturePreparations.delete(token);
}
}
parkResidentHostIfIdle();
syncResidentHostCaptureState();
}
function releaseAllCapturePreparations(): void {
activeCapturePreparations.clear();
parkResidentHostIfIdle();
syncResidentHostCaptureState();
}
export function prepareBrowserWebview(
@@ -233,6 +276,7 @@ export function takeResidentBrowserWebview(browserId: string): HTMLElement | nul
}
residentWebviewsByBrowserId.delete(normalizedBrowserId);
clearResidentWebviewParkingStyle(webview);
return webview;
}
@@ -278,11 +322,12 @@ export async function prepareResidentBrowserWebviewForPixelCapture(input: {
browserId,
...(requestId ? { requestId } : {}),
preparesResidentHost,
webview,
});
try {
if (preparesResidentHost) {
applyResidentHostCaptureStyle(host);
applyResidentWebviewStyle(webview);
raiseResidentWebviewForCapture(host, webview);
}
await waitForCapturePaint(webview);
if (!activeCapturePreparations.has(token)) {

View File

@@ -25,7 +25,7 @@
pointer-events: none;
}
#target-webview {
.capture-harness-webview {
display: inline-flex;
flex: 0 0 auto;
width: 1280px;
@@ -43,20 +43,26 @@
const RESIDENT_VIEWPORT_HEIGHT = 800;
const host = document.getElementById("paseo-browser-resident-webviews");
const params = new URLSearchParams(window.location.search);
const webviewCount = Math.max(2, Number(params.get("webviewCount")) || 2);
const webviews = [];
const activeTokens = new Set();
let nextTokenId = 0;
const webview = document.createElement("webview");
webview.id = "target-webview";
webview.setAttribute("data-paseo-browser-id", "capture-harness");
webview.setAttribute("partition", "persist:paseo-capture-harness");
webview.setAttribute("allowpopups", "true");
webview.setAttribute("spellcheck", "false");
webview.setAttribute("autosize", "on");
webview.src = params.get("targetUrl") || "bright.html";
host.appendChild(webview);
for (let index = 0; index < webviewCount; index += 1) {
const webview = document.createElement("webview");
webview.id = `target-webview-${index + 1}`;
webview.className = "capture-harness-webview";
webview.setAttribute("data-paseo-browser-id", `capture-harness-${index + 1}`);
webview.setAttribute("partition", `persist:paseo-capture-harness-${index + 1}`);
webview.setAttribute("allowpopups", "true");
webview.setAttribute("spellcheck", "false");
webview.setAttribute("autosize", "on");
webview.src = params.get("targetUrl") || "bright.html";
host.appendChild(webview);
webviews.push(webview);
}
function applyParking() {
function applyHostParking() {
host.style.position = "fixed";
host.style.left = "-20000px";
host.style.top = "0";
@@ -69,6 +75,9 @@
host.style.clipPath = "";
host.style.visibility = "";
host.style.transform = "";
}
function applyWebviewBaseStyle(webview) {
webview.style.display = "inline-flex";
webview.style.flex = "0 0 auto";
webview.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
@@ -77,6 +86,38 @@
webview.style.background = "transparent";
}
function applyLegacyVerticalWebviewStyle(webview, index) {
applyWebviewBaseStyle(webview);
webview.style.position = "";
webview.style.left = "";
webview.style.top = "";
webview.style.marginTop = index === 0 ? "0" : "4px";
webview.style.zIndex = "";
}
function applyStackedWebviewStyle(webview, zIndex = "0") {
applyWebviewBaseStyle(webview);
webview.style.position = "absolute";
webview.style.left = "0";
webview.style.top = "0";
webview.style.marginTop = "0";
webview.style.zIndex = zIndex;
}
function applyParking() {
applyHostParking();
webviews.forEach((webview) => {
applyStackedWebviewStyle(webview);
});
}
function applyLegacyVerticalParking() {
applyHostParking();
webviews.forEach((webview, index) => {
applyLegacyVerticalWebviewStyle(webview, index);
});
}
function applyCapturePrep() {
host.style.position = "fixed";
host.style.left = "0";
@@ -92,6 +133,12 @@
host.style.transform = "";
}
function applyTargetStacking(targetIndex) {
webviews.forEach((webview, index) => {
applyStackedWebviewStyle(webview, index === targetIndex ? "2" : "0");
});
}
function waitFrames(count) {
return new Promise((resolve) => {
function step(remaining) {
@@ -105,9 +152,10 @@
});
}
function state() {
function state(targetIndex = 0) {
const hostRect = host.getBoundingClientRect();
const webviewRect = webview.getBoundingClientRect();
const targetWebview = webviews[targetIndex] || webviews[0];
const webviewRect = targetWebview.getBoundingClientRect();
return {
hostStyle: {
left: host.style.left,
@@ -130,6 +178,18 @@
width: webviewRect.width,
height: webviewRect.height,
},
webviews: webviews.map((webview) => {
const rect = webview.getBoundingClientRect();
return {
id: webview.id,
zIndex: webview.style.zIndex,
position: webview.style.position,
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
};
}),
};
}
@@ -138,13 +198,28 @@
await waitFrames(count);
return state();
},
async prepareForPixelCapture() {
webContentsIds() {
return webviews.map((webview) =>
typeof webview.getWebContentsId === "function" ? webview.getWebContentsId() : null,
);
},
async prepareForPixelCapture(targetIndex = 0) {
const token = `capture-${++nextTokenId}`;
activeTokens.add(token);
applyCapturePrep();
applyTargetStacking(targetIndex);
await waitFrames(2);
webview.getBoundingClientRect();
return { token, state: state() };
webviews[targetIndex].getBoundingClientRect();
return { token, state: state(targetIndex) };
},
async prepareLegacyVerticalPixelCapture(targetIndex = 0) {
const token = `legacy-capture-${++nextTokenId}`;
activeTokens.add(token);
applyLegacyVerticalParking();
applyCapturePrep();
await waitFrames(2);
webviews[targetIndex].getBoundingClientRect();
return { token, state: state(targetIndex) };
},
async restorePixelCapture(token) {
activeTokens.delete(token);
@@ -154,6 +229,12 @@
await waitFrames(2);
return state();
},
async restoreLegacyVerticalParking(token) {
activeTokens.delete(token);
applyLegacyVerticalParking();
await waitFrames(2);
return state();
},
async restoreParking() {
activeTokens.clear();
applyParking();

View File

@@ -228,9 +228,15 @@ async function captureFullPage(contents) {
}
}
async function captureWithPrep({ win, contents, mode, repeatIndex, guestMetrics }) {
const preparation = await renderer(win, "window.captureHarness.prepareForPixelCapture()");
const outputPath = path.join(OUT_DIR, `${mode}-prep-${repeatIndex}.png`);
async function captureWithPrep({ win, contents, mode, repeatIndex, targetIndex, guestMetrics }) {
const preparation = await renderer(
win,
`window.captureHarness.prepareForPixelCapture(${JSON.stringify(targetIndex)})`,
);
const outputPath = path.join(
OUT_DIR,
`${mode}-webview-${targetIndex + 1}-prep-${repeatIndex}.png`,
);
try {
const image =
mode === "viewport" ? await capturePageSequence(contents) : await captureFullPage(contents);
@@ -245,11 +251,11 @@ async function captureWithPrep({ win, contents, mode, repeatIndex, guestMetrics
const bright = analysis.brightRatio.toFixed(4);
if (!analysis.pass) {
fail(
`${mode} prep ${repeatIndex}/${REPEAT_COUNT} size=${size} logical=${logicalSize} bright=${bright} text=${analysis.textNonUniform} file=${outputPath}`,
`${mode} webview ${targetIndex + 1} prep ${repeatIndex}/${REPEAT_COUNT} size=${size} logical=${logicalSize} bright=${bright} text=${analysis.textNonUniform} file=${outputPath}`,
);
}
pass(
`${mode} prep ${repeatIndex}/${REPEAT_COUNT} size=${size} logical=${logicalSize} bright=${bright} text=${analysis.textNonUniform} file=${outputPath}`,
`${mode} webview ${targetIndex + 1} prep ${repeatIndex}/${REPEAT_COUNT} size=${size} logical=${logicalSize} bright=${bright} text=${analysis.textNonUniform} file=${outputPath}`,
);
return analysis;
} finally {
@@ -264,12 +270,51 @@ async function captureWithPrep({ win, contents, mode, repeatIndex, guestMetrics
}
}
async function expectLegacySecondWebviewFailure({ win, contents, mode, guestMetrics }) {
const targetIndex = 1;
const preparation = await renderer(
win,
`window.captureHarness.prepareLegacyVerticalPixelCapture(${JSON.stringify(targetIndex)})`,
);
const outputPath = path.join(OUT_DIR, `${mode}-legacy-webview-${targetIndex + 1}.png`);
try {
const image =
mode === "viewport" ? await capturePageSequence(contents) : await captureFullPage(contents);
await saveImage(image, outputPath);
const expected =
mode === "viewport"
? { width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT, minBrightRatio: 0.65 }
: { width: VIEWPORT_WIDTH, height: FULL_PAGE_HEIGHT, minBrightRatio: 0.55 };
const analysis = analyzeImage(image, expected, guestMetrics);
const size = `${analysis.width}x${analysis.height}`;
const logicalSize = `${analysis.logicalWidthAtDpr}x${analysis.logicalHeightAtDpr}`;
const bright = analysis.brightRatio.toFixed(4);
if (analysis.pass) {
fail(
`${mode} legacy webview ${targetIndex + 1} unexpectedly captured size=${size} logical=${logicalSize} bright=${bright} text=${analysis.textNonUniform} file=${outputPath}`,
);
}
pass(
`${mode} legacy webview ${targetIndex + 1} reproduces no-frame size=${size} logical=${logicalSize} bright=${bright} text=${analysis.textNonUniform} file=${outputPath}`,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
pass(`${mode} legacy webview ${targetIndex + 1} reproduces no-frame error=${message}`);
} finally {
await renderer(
win,
`window.captureHarness.restoreLegacyVerticalParking(${JSON.stringify(preparation.token)})`,
);
}
}
async function main() {
ensureDirSync(OUT_DIR);
let resolveGuest;
const guestPromise = new Promise((resolve) => {
resolveGuest = resolve;
const attachedGuests = [];
let resolveGuests;
const guestsPromise = new Promise((resolve) => {
resolveGuests = resolve;
});
const win = new BrowserWindow({
width: 1000,
@@ -289,33 +334,46 @@ async function main() {
webPreferences.contextIsolation = true;
});
win.webContents.on("did-attach-webview", (_event, contents) => {
resolveGuest(contents);
attachedGuests.push(contents);
if (attachedGuests.length >= 2) {
resolveGuests(attachedGuests);
}
});
await win.loadFile(path.join(ROOT, "index.html"), {
query: { targetUrl: fileUrl(path.join(ROOT, "bright.html")) },
query: { targetUrl: fileUrl(path.join(ROOT, "bright.html")), webviewCount: "2" },
});
const guest = await withTimeout(guestPromise, "did-attach-webview");
await waitForGuestLoad(guest);
await withTimeout(guestsPromise, "did-attach-webview");
await Promise.all(attachedGuests.map((guest) => waitForGuestLoad(guest)));
await renderer(win, "window.captureHarness.waitForFrames(2)");
const guestMetrics = await readGuestMetrics(guest);
if (guestMetrics.innerWidth !== VIEWPORT_WIDTH || guestMetrics.innerHeight !== VIEWPORT_HEIGHT) {
const webContentsIds = await renderer(win, "window.captureHarness.webContentsIds()");
const guestsById = new Map(attachedGuests.map((guest) => [guest.id, guest]));
const guests = webContentsIds.map((id) => guestsById.get(id));
if (guests.some((guest) => !guest)) {
fail(
`guest viewport sizing inner=${guestMetrics.innerWidth}x${guestMetrics.innerHeight} expected=${VIEWPORT_WIDTH}x${VIEWPORT_HEIGHT}`,
`could not map webviews to guest contents ids=${JSON.stringify(webContentsIds)} attached=${attachedGuests.map((guest) => guest.id).join(",")}`,
);
}
pass(
`guest viewport sizing inner=${guestMetrics.innerWidth}x${guestMetrics.innerHeight} dpr=${guestMetrics.devicePixelRatio}`,
);
const guestMetrics = await Promise.all(guests.map((guest) => readGuestMetrics(guest)));
guestMetrics.forEach((metrics, index) => {
if (metrics.innerWidth !== VIEWPORT_WIDTH || metrics.innerHeight !== VIEWPORT_HEIGHT) {
fail(
`guest viewport sizing webview ${index + 1} inner=${metrics.innerWidth}x${metrics.innerHeight} expected=${VIEWPORT_WIDTH}x${VIEWPORT_HEIGHT}`,
);
}
pass(
`guest viewport sizing webview ${index + 1} inner=${metrics.innerWidth}x${metrics.innerHeight} dpr=${metrics.devicePixelRatio}`,
);
});
await renderer(win, "window.captureHarness.restoreParking()");
try {
const image = await capturePageSequence(guest);
const image = await capturePageSequence(guests[0]);
const analysis = analyzeImage(
image,
{ width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT, minBrightRatio: 0.65 },
guestMetrics,
guestMetrics[0],
);
fail(
`parked webview unexpectedly captured size=${analysis.width}x${analysis.height} bright=${analysis.brightRatio.toFixed(4)}`,
@@ -325,28 +383,47 @@ async function main() {
pass(`parked webview has no copyable viewport frame error=${message}`);
}
await expectLegacySecondWebviewFailure({
win,
contents: guests[1],
mode: "viewport",
guestMetrics: guestMetrics[1],
});
await expectLegacySecondWebviewFailure({
win,
contents: guests[1],
mode: "full-page",
guestMetrics: guestMetrics[1],
});
await renderer(win, "window.captureHarness.restoreParking()");
const results = [];
for (let index = 1; index <= REPEAT_COUNT; index += 1) {
results.push(
await captureWithPrep({
win,
contents: guest,
mode: "viewport",
repeatIndex: index,
guestMetrics,
}),
);
}
for (let index = 1; index <= REPEAT_COUNT; index += 1) {
results.push(
await captureWithPrep({
win,
contents: guest,
mode: "full-page",
repeatIndex: index,
guestMetrics,
}),
);
for (const targetIndex of [0, 1]) {
for (let index = 1; index <= REPEAT_COUNT; index += 1) {
results.push(
await captureWithPrep({
win,
contents: guests[targetIndex],
mode: "viewport",
repeatIndex: index,
targetIndex,
guestMetrics: guestMetrics[targetIndex],
}),
);
}
for (let index = 1; index <= REPEAT_COUNT; index += 1) {
results.push(
await captureWithPrep({
win,
contents: guests[targetIndex],
mode: "full-page",
repeatIndex: index,
targetIndex,
guestMetrics: guestMetrics[targetIndex],
}),
);
}
}
await fsp.writeFile(

View File

@@ -55,6 +55,7 @@ class FakeTab implements TabContents {
public captureThrows = false;
public deferCaptures = false;
public prepareNeverAcks = false;
public fullPageScreenshotThrows = false;
public layoutMetrics = {
cssLayoutViewport: { clientWidth: 390, clientHeight: 844 },
cssContentSize: { width: 390, height: 1200 },
@@ -184,6 +185,9 @@ class FakeTab implements TabContents {
return this.layoutMetrics;
}
if (command === "Page.captureScreenshot") {
if (this.fullPageScreenshotThrows) {
throw new Error("UnknownVizError");
}
return { data: this.fullPageScreenshotData };
}
if (command === "DOM.getDocument") {
@@ -959,7 +963,7 @@ describe("executeAutomationCommand", () => {
}
});
test("screenshot restores capture preparation when viewport capture fails", async () => {
test("screenshot returns no-frame and restores capture preparation when viewport capture throws", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.captureThrows = true;
@@ -968,7 +972,15 @@ describe("executeAutomationCommand", () => {
command: "screenshot",
args: { browserId: BROWSER_A },
}),
).rejects.toThrow("capture failed");
).resolves.toEqual({
requestId: "req-screenshot",
ok: false,
error: {
code: "screenshot_no_frame",
message: "The browser tab has no painted frame. Focus the tab in the app, then try again.",
retryable: false,
},
});
expect(browser.tab.actions).toEqual([
"prepare",
@@ -1135,6 +1147,35 @@ describe("executeAutomationCommand", () => {
]);
});
test("screenshot with fullPage returns no-frame when CDP capture throws", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.fullPageScreenshotThrows = true;
const result = await browser.execute({
command: "screenshot",
args: { browserId: BROWSER_A, fullPage: true },
});
expect(result).toEqual({
requestId: "req-screenshot",
ok: false,
error: {
code: "screenshot_no_frame",
message: "The browser tab has no painted frame. Focus the tab in the app, then try again.",
retryable: false,
},
});
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 () => {
const browser = new BrowserAutomationHarness();
browser.tab.snapshotElements = [

View File

@@ -149,6 +149,17 @@ async function restorePixelCapture(
}
}
async function capturePreparedPixelFrame<T>(capture: () => Promise<T>): Promise<T> {
try {
return await withPixelCaptureTimeout(capture());
} catch (error) {
if (isScreenshotNoFrameError(error)) {
throw error;
}
throw new ScreenshotNoFrameError();
}
}
async function runPreparedPixelCapture<T>(
contents: TabContents,
capture: () => Promise<T>,
@@ -163,7 +174,7 @@ async function runPreparedPixelCapture<T>(
preparation = await prepareForPixelCapture(contents);
contents.setBackgroundThrottling(false);
contents.invalidate();
return await withPixelCaptureTimeout(capture());
return await capturePreparedPixelFrame(capture);
} finally {
try {
if (preparation) {