diff --git a/docs/browser-capture-harness.md b/docs/browser-capture-harness.md index 9e768c4c5..05a2c9dad 100644 --- a/docs/browser-capture-harness.md +++ b/docs/browser-capture-harness.md @@ -6,6 +6,8 @@ It validates the compositor behavior that unit tests cannot see: - the resident automation `` 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`. diff --git a/packages/app/src/components/browser-webview-resident.browser.test.ts b/packages/app/src/components/browser-webview-resident.browser.test.ts index cf5872314..aafdca080 100644 --- a/packages/app/src/components/browser-webview-resident.browser.test.ts +++ b/packages/app/src/components/browser-webview-resident.browser.test.ts @@ -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 () => { diff --git a/packages/app/src/components/browser-webview-resident.ts b/packages/app/src/components/browser-webview-resident.ts index 36b882134..1e6da6a97 100644 --- a/packages/app/src/components/browser-webview-resident.ts +++ b/packages/app/src/components/browser-webview-resident.ts @@ -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 { @@ -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)) { diff --git a/packages/desktop/capture-harness/index.html b/packages/desktop/capture-harness/index.html index ef5a96f82..e631d3497 100644 --- a/packages/desktop/capture-harness/index.html +++ b/packages/desktop/capture-harness/index.html @@ -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(); diff --git a/packages/desktop/capture-harness/main.js b/packages/desktop/capture-harness/main.js index 69067e9c6..4e7459b2b 100644 --- a/packages/desktop/capture-harness/main.js +++ b/packages/desktop/capture-harness/main.js @@ -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( diff --git a/packages/desktop/src/features/browser-automation/service.test.ts b/packages/desktop/src/features/browser-automation/service.test.ts index 66367cd20..d1c56ddcd 100644 --- a/packages/desktop/src/features/browser-automation/service.test.ts +++ b/packages/desktop/src/features/browser-automation/service.test.ts @@ -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 = [ diff --git a/packages/desktop/src/features/browser-automation/service.ts b/packages/desktop/src/features/browser-automation/service.ts index 2bccd4453..a7c16ea54 100644 --- a/packages/desktop/src/features/browser-automation/service.ts +++ b/packages/desktop/src/features/browser-automation/service.ts @@ -149,6 +149,17 @@ async function restorePixelCapture( } } +async function capturePreparedPixelFrame(capture: () => Promise): Promise { + try { + return await withPixelCaptureTimeout(capture()); + } catch (error) { + if (isScreenshotNoFrameError(error)) { + throw error; + } + throw new ScreenshotNoFrameError(); + } +} + async function runPreparedPixelCapture( contents: TabContents, capture: () => Promise, @@ -163,7 +174,7 @@ async function runPreparedPixelCapture( preparation = await prepareForPixelCapture(contents); contents.setBackgroundThrottling(false); contents.invalidate(); - return await withPixelCaptureTimeout(capture()); + return await capturePreparedPixelFrame(capture); } finally { try { if (preparation) {