mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Fix multi-webview browser capture prep
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user