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 resident automation `<webview>` starts in the production parking state;
|
||||||
- the parked guest has no copyable viewport frame;
|
- the parked guest has no copyable viewport frame;
|
||||||
- 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
|
||||||
|
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;
|
- 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.
|
||||||
|
|
||||||
@@ -21,10 +23,11 @@ The harness writes PNG evidence and `results.json` to:
|
|||||||
packages/desktop/capture-harness/out/
|
packages/desktop/capture-harness/out/
|
||||||
```
|
```
|
||||||
|
|
||||||
A passing run prints `PASS` lines for guest sizing, the expected parked-capture failure,
|
A passing run prints `PASS` lines for both guest sizes, the expected parked-capture
|
||||||
five viewport prep captures, five full-page prep captures, and final completion. The PNG
|
failure, the legacy stacked-below-the-clip failure for the second webview, five viewport
|
||||||
sizes may be device-pixel scaled; on a Retina display the 1280x800 logical viewport is
|
prep captures and five full-page prep captures for each of the first two webviews, and
|
||||||
usually saved as 2560x1600.
|
final completion. The PNG sizes may be device-pixel scaled; on a Retina display the
|
||||||
|
1280x800 logical viewport is usually saved as 2560x1600.
|
||||||
|
|
||||||
## Mechanism
|
## 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:
|
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
|
`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
|
`overflow:hidden`, with the full-size 1280x800 webview inside. Resident webviews are
|
||||||
the renderer acknowledges two animation frames plus a `getBoundingClientRect()` read, and
|
parked absolutely at `0,0` inside that host because a second webview stacked below the
|
||||||
the renderer restores parking in a `finally`.
|
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.display).toBe("inline-flex");
|
||||||
expect(webview.style.width).toBe("1280px");
|
expect(webview.style.width).toBe("1280px");
|
||||||
expect(webview.style.height).toBe("800px");
|
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");
|
const reused = takeResidentBrowserWebview("browser-a");
|
||||||
|
|
||||||
expect(reused).toBe(webview);
|
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();
|
expect(takeResidentBrowserWebview("browser-a")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,6 +95,10 @@ describe("resident browser webviews", () => {
|
|||||||
expect(webview.style.display).toBe("inline-flex");
|
expect(webview.style.display).toBe("inline-flex");
|
||||||
expect(webview.style.width).toBe("1280px");
|
expect(webview.style.width).toBe("1280px");
|
||||||
expect(webview.style.height).toBe("800px");
|
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);
|
await restoreResidentBrowserWebviewAfterPixelCapture(preparation);
|
||||||
|
|
||||||
@@ -94,6 +106,42 @@ describe("resident browser webviews", () => {
|
|||||||
expect(host?.style.width).toBe("1280px");
|
expect(host?.style.width).toBe("1280px");
|
||||||
expect(host?.style.height).toBe("800px");
|
expect(host?.style.height).toBe("800px");
|
||||||
expect(host?.style.opacity).toBe("0");
|
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 () => {
|
it("keeps the resident host paintable until every capture token is restored", async () => {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface ActiveCapturePreparation {
|
|||||||
browserId: string;
|
browserId: string;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
preparesResidentHost: boolean;
|
preparesResidentHost: boolean;
|
||||||
|
webview: HTMLElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
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.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
|
||||||
webview.style.border = "0";
|
webview.style.border = "0";
|
||||||
webview.style.background = "transparent";
|
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 {
|
function clearResidentWebviewParkingStyle(webview: HTMLElement): void {
|
||||||
for (const preparation of activeCapturePreparations.values()) {
|
webview.style.position = "";
|
||||||
if (preparation.preparesResidentHost) {
|
webview.style.left = "";
|
||||||
return true;
|
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> {
|
function nextAnimationFrame(): Promise<void> {
|
||||||
@@ -143,13 +166,32 @@ function activeCaptureTokenFor(input: { token?: string; requestId?: string }): s
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parkResidentHostIfIdle(): void {
|
function latestActiveResidentHostPreparation(): ActiveCapturePreparation | null {
|
||||||
if (hasActiveResidentHostPreparation()) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const host = readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID);
|
|
||||||
if (host instanceof HTMLElement) {
|
const latest = latestActiveResidentHostPreparation();
|
||||||
applyResidentHostParkingStyle(host);
|
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);
|
activeCapturePreparations.delete(token);
|
||||||
if (preparation.preparesResidentHost) {
|
if (preparation.preparesResidentHost) {
|
||||||
parkResidentHostIfIdle();
|
applyResidentWebviewStyle(preparation.webview);
|
||||||
|
syncResidentHostCaptureState();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,12 +213,12 @@ function releaseCapturePreparationsForBrowser(browserId: string): void {
|
|||||||
activeCapturePreparations.delete(token);
|
activeCapturePreparations.delete(token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
parkResidentHostIfIdle();
|
syncResidentHostCaptureState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function releaseAllCapturePreparations(): void {
|
function releaseAllCapturePreparations(): void {
|
||||||
activeCapturePreparations.clear();
|
activeCapturePreparations.clear();
|
||||||
parkResidentHostIfIdle();
|
syncResidentHostCaptureState();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function prepareBrowserWebview(
|
export function prepareBrowserWebview(
|
||||||
@@ -233,6 +276,7 @@ export function takeResidentBrowserWebview(browserId: string): HTMLElement | nul
|
|||||||
}
|
}
|
||||||
|
|
||||||
residentWebviewsByBrowserId.delete(normalizedBrowserId);
|
residentWebviewsByBrowserId.delete(normalizedBrowserId);
|
||||||
|
clearResidentWebviewParkingStyle(webview);
|
||||||
return webview;
|
return webview;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,11 +322,12 @@ export async function prepareResidentBrowserWebviewForPixelCapture(input: {
|
|||||||
browserId,
|
browserId,
|
||||||
...(requestId ? { requestId } : {}),
|
...(requestId ? { requestId } : {}),
|
||||||
preparesResidentHost,
|
preparesResidentHost,
|
||||||
|
webview,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
if (preparesResidentHost) {
|
if (preparesResidentHost) {
|
||||||
applyResidentHostCaptureStyle(host);
|
applyResidentHostCaptureStyle(host);
|
||||||
applyResidentWebviewStyle(webview);
|
raiseResidentWebviewForCapture(host, webview);
|
||||||
}
|
}
|
||||||
await waitForCapturePaint(webview);
|
await waitForCapturePaint(webview);
|
||||||
if (!activeCapturePreparations.has(token)) {
|
if (!activeCapturePreparations.has(token)) {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#target-webview {
|
.capture-harness-webview {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 1280px;
|
width: 1280px;
|
||||||
@@ -43,20 +43,26 @@
|
|||||||
const RESIDENT_VIEWPORT_HEIGHT = 800;
|
const RESIDENT_VIEWPORT_HEIGHT = 800;
|
||||||
const host = document.getElementById("paseo-browser-resident-webviews");
|
const host = document.getElementById("paseo-browser-resident-webviews");
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const webviewCount = Math.max(2, Number(params.get("webviewCount")) || 2);
|
||||||
|
const webviews = [];
|
||||||
const activeTokens = new Set();
|
const activeTokens = new Set();
|
||||||
let nextTokenId = 0;
|
let nextTokenId = 0;
|
||||||
|
|
||||||
const webview = document.createElement("webview");
|
for (let index = 0; index < webviewCount; index += 1) {
|
||||||
webview.id = "target-webview";
|
const webview = document.createElement("webview");
|
||||||
webview.setAttribute("data-paseo-browser-id", "capture-harness");
|
webview.id = `target-webview-${index + 1}`;
|
||||||
webview.setAttribute("partition", "persist:paseo-capture-harness");
|
webview.className = "capture-harness-webview";
|
||||||
webview.setAttribute("allowpopups", "true");
|
webview.setAttribute("data-paseo-browser-id", `capture-harness-${index + 1}`);
|
||||||
webview.setAttribute("spellcheck", "false");
|
webview.setAttribute("partition", `persist:paseo-capture-harness-${index + 1}`);
|
||||||
webview.setAttribute("autosize", "on");
|
webview.setAttribute("allowpopups", "true");
|
||||||
webview.src = params.get("targetUrl") || "bright.html";
|
webview.setAttribute("spellcheck", "false");
|
||||||
host.appendChild(webview);
|
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.position = "fixed";
|
||||||
host.style.left = "-20000px";
|
host.style.left = "-20000px";
|
||||||
host.style.top = "0";
|
host.style.top = "0";
|
||||||
@@ -69,6 +75,9 @@
|
|||||||
host.style.clipPath = "";
|
host.style.clipPath = "";
|
||||||
host.style.visibility = "";
|
host.style.visibility = "";
|
||||||
host.style.transform = "";
|
host.style.transform = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWebviewBaseStyle(webview) {
|
||||||
webview.style.display = "inline-flex";
|
webview.style.display = "inline-flex";
|
||||||
webview.style.flex = "0 0 auto";
|
webview.style.flex = "0 0 auto";
|
||||||
webview.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
|
webview.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
|
||||||
@@ -77,6 +86,38 @@
|
|||||||
webview.style.background = "transparent";
|
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() {
|
function applyCapturePrep() {
|
||||||
host.style.position = "fixed";
|
host.style.position = "fixed";
|
||||||
host.style.left = "0";
|
host.style.left = "0";
|
||||||
@@ -92,6 +133,12 @@
|
|||||||
host.style.transform = "";
|
host.style.transform = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyTargetStacking(targetIndex) {
|
||||||
|
webviews.forEach((webview, index) => {
|
||||||
|
applyStackedWebviewStyle(webview, index === targetIndex ? "2" : "0");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function waitFrames(count) {
|
function waitFrames(count) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
function step(remaining) {
|
function step(remaining) {
|
||||||
@@ -105,9 +152,10 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function state() {
|
function state(targetIndex = 0) {
|
||||||
const hostRect = host.getBoundingClientRect();
|
const hostRect = host.getBoundingClientRect();
|
||||||
const webviewRect = webview.getBoundingClientRect();
|
const targetWebview = webviews[targetIndex] || webviews[0];
|
||||||
|
const webviewRect = targetWebview.getBoundingClientRect();
|
||||||
return {
|
return {
|
||||||
hostStyle: {
|
hostStyle: {
|
||||||
left: host.style.left,
|
left: host.style.left,
|
||||||
@@ -130,6 +178,18 @@
|
|||||||
width: webviewRect.width,
|
width: webviewRect.width,
|
||||||
height: webviewRect.height,
|
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);
|
await waitFrames(count);
|
||||||
return state();
|
return state();
|
||||||
},
|
},
|
||||||
async prepareForPixelCapture() {
|
webContentsIds() {
|
||||||
|
return webviews.map((webview) =>
|
||||||
|
typeof webview.getWebContentsId === "function" ? webview.getWebContentsId() : null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async prepareForPixelCapture(targetIndex = 0) {
|
||||||
const token = `capture-${++nextTokenId}`;
|
const token = `capture-${++nextTokenId}`;
|
||||||
activeTokens.add(token);
|
activeTokens.add(token);
|
||||||
applyCapturePrep();
|
applyCapturePrep();
|
||||||
|
applyTargetStacking(targetIndex);
|
||||||
await waitFrames(2);
|
await waitFrames(2);
|
||||||
webview.getBoundingClientRect();
|
webviews[targetIndex].getBoundingClientRect();
|
||||||
return { token, state: state() };
|
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) {
|
async restorePixelCapture(token) {
|
||||||
activeTokens.delete(token);
|
activeTokens.delete(token);
|
||||||
@@ -154,6 +229,12 @@
|
|||||||
await waitFrames(2);
|
await waitFrames(2);
|
||||||
return state();
|
return state();
|
||||||
},
|
},
|
||||||
|
async restoreLegacyVerticalParking(token) {
|
||||||
|
activeTokens.delete(token);
|
||||||
|
applyLegacyVerticalParking();
|
||||||
|
await waitFrames(2);
|
||||||
|
return state();
|
||||||
|
},
|
||||||
async restoreParking() {
|
async restoreParking() {
|
||||||
activeTokens.clear();
|
activeTokens.clear();
|
||||||
applyParking();
|
applyParking();
|
||||||
|
|||||||
@@ -228,9 +228,15 @@ async function captureFullPage(contents) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function captureWithPrep({ win, contents, mode, repeatIndex, guestMetrics }) {
|
async function captureWithPrep({ win, contents, mode, repeatIndex, targetIndex, guestMetrics }) {
|
||||||
const preparation = await renderer(win, "window.captureHarness.prepareForPixelCapture()");
|
const preparation = await renderer(
|
||||||
const outputPath = path.join(OUT_DIR, `${mode}-prep-${repeatIndex}.png`);
|
win,
|
||||||
|
`window.captureHarness.prepareForPixelCapture(${JSON.stringify(targetIndex)})`,
|
||||||
|
);
|
||||||
|
const outputPath = path.join(
|
||||||
|
OUT_DIR,
|
||||||
|
`${mode}-webview-${targetIndex + 1}-prep-${repeatIndex}.png`,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
const image =
|
const image =
|
||||||
mode === "viewport" ? await capturePageSequence(contents) : await captureFullPage(contents);
|
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);
|
const bright = analysis.brightRatio.toFixed(4);
|
||||||
if (!analysis.pass) {
|
if (!analysis.pass) {
|
||||||
fail(
|
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(
|
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;
|
return analysis;
|
||||||
} finally {
|
} 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() {
|
async function main() {
|
||||||
ensureDirSync(OUT_DIR);
|
ensureDirSync(OUT_DIR);
|
||||||
|
|
||||||
let resolveGuest;
|
const attachedGuests = [];
|
||||||
const guestPromise = new Promise((resolve) => {
|
let resolveGuests;
|
||||||
resolveGuest = resolve;
|
const guestsPromise = new Promise((resolve) => {
|
||||||
|
resolveGuests = resolve;
|
||||||
});
|
});
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: 1000,
|
width: 1000,
|
||||||
@@ -289,33 +334,46 @@ async function main() {
|
|||||||
webPreferences.contextIsolation = true;
|
webPreferences.contextIsolation = true;
|
||||||
});
|
});
|
||||||
win.webContents.on("did-attach-webview", (_event, contents) => {
|
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"), {
|
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 withTimeout(guestsPromise, "did-attach-webview");
|
||||||
await waitForGuestLoad(guest);
|
await Promise.all(attachedGuests.map((guest) => waitForGuestLoad(guest)));
|
||||||
await renderer(win, "window.captureHarness.waitForFrames(2)");
|
await renderer(win, "window.captureHarness.waitForFrames(2)");
|
||||||
const guestMetrics = await readGuestMetrics(guest);
|
const webContentsIds = await renderer(win, "window.captureHarness.webContentsIds()");
|
||||||
|
const guestsById = new Map(attachedGuests.map((guest) => [guest.id, guest]));
|
||||||
if (guestMetrics.innerWidth !== VIEWPORT_WIDTH || guestMetrics.innerHeight !== VIEWPORT_HEIGHT) {
|
const guests = webContentsIds.map((id) => guestsById.get(id));
|
||||||
|
if (guests.some((guest) => !guest)) {
|
||||||
fail(
|
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(
|
const guestMetrics = await Promise.all(guests.map((guest) => readGuestMetrics(guest)));
|
||||||
`guest viewport sizing inner=${guestMetrics.innerWidth}x${guestMetrics.innerHeight} dpr=${guestMetrics.devicePixelRatio}`,
|
|
||||||
);
|
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()");
|
await renderer(win, "window.captureHarness.restoreParking()");
|
||||||
try {
|
try {
|
||||||
const image = await capturePageSequence(guest);
|
const image = await capturePageSequence(guests[0]);
|
||||||
const analysis = analyzeImage(
|
const analysis = analyzeImage(
|
||||||
image,
|
image,
|
||||||
{ width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT, minBrightRatio: 0.65 },
|
{ width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT, minBrightRatio: 0.65 },
|
||||||
guestMetrics,
|
guestMetrics[0],
|
||||||
);
|
);
|
||||||
fail(
|
fail(
|
||||||
`parked webview unexpectedly captured size=${analysis.width}x${analysis.height} bright=${analysis.brightRatio.toFixed(4)}`,
|
`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}`);
|
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 = [];
|
const results = [];
|
||||||
for (let index = 1; index <= REPEAT_COUNT; index += 1) {
|
for (const targetIndex of [0, 1]) {
|
||||||
results.push(
|
for (let index = 1; index <= REPEAT_COUNT; index += 1) {
|
||||||
await captureWithPrep({
|
results.push(
|
||||||
win,
|
await captureWithPrep({
|
||||||
contents: guest,
|
win,
|
||||||
mode: "viewport",
|
contents: guests[targetIndex],
|
||||||
repeatIndex: index,
|
mode: "viewport",
|
||||||
guestMetrics,
|
repeatIndex: index,
|
||||||
}),
|
targetIndex,
|
||||||
);
|
guestMetrics: guestMetrics[targetIndex],
|
||||||
}
|
}),
|
||||||
for (let index = 1; index <= REPEAT_COUNT; index += 1) {
|
);
|
||||||
results.push(
|
}
|
||||||
await captureWithPrep({
|
for (let index = 1; index <= REPEAT_COUNT; index += 1) {
|
||||||
win,
|
results.push(
|
||||||
contents: guest,
|
await captureWithPrep({
|
||||||
mode: "full-page",
|
win,
|
||||||
repeatIndex: index,
|
contents: guests[targetIndex],
|
||||||
guestMetrics,
|
mode: "full-page",
|
||||||
}),
|
repeatIndex: index,
|
||||||
);
|
targetIndex,
|
||||||
|
guestMetrics: guestMetrics[targetIndex],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await fsp.writeFile(
|
await fsp.writeFile(
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ class FakeTab implements TabContents {
|
|||||||
public captureThrows = false;
|
public captureThrows = false;
|
||||||
public deferCaptures = false;
|
public deferCaptures = false;
|
||||||
public prepareNeverAcks = false;
|
public prepareNeverAcks = false;
|
||||||
|
public fullPageScreenshotThrows = false;
|
||||||
public layoutMetrics = {
|
public layoutMetrics = {
|
||||||
cssLayoutViewport: { clientWidth: 390, clientHeight: 844 },
|
cssLayoutViewport: { clientWidth: 390, clientHeight: 844 },
|
||||||
cssContentSize: { width: 390, height: 1200 },
|
cssContentSize: { width: 390, height: 1200 },
|
||||||
@@ -184,6 +185,9 @@ class FakeTab implements TabContents {
|
|||||||
return this.layoutMetrics;
|
return this.layoutMetrics;
|
||||||
}
|
}
|
||||||
if (command === "Page.captureScreenshot") {
|
if (command === "Page.captureScreenshot") {
|
||||||
|
if (this.fullPageScreenshotThrows) {
|
||||||
|
throw new Error("UnknownVizError");
|
||||||
|
}
|
||||||
return { data: this.fullPageScreenshotData };
|
return { data: this.fullPageScreenshotData };
|
||||||
}
|
}
|
||||||
if (command === "DOM.getDocument") {
|
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();
|
const browser = new BrowserAutomationHarness();
|
||||||
browser.tab.captureThrows = true;
|
browser.tab.captureThrows = true;
|
||||||
|
|
||||||
@@ -968,7 +972,15 @@ describe("executeAutomationCommand", () => {
|
|||||||
command: "screenshot",
|
command: "screenshot",
|
||||||
args: { browserId: BROWSER_A },
|
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([
|
expect(browser.tab.actions).toEqual([
|
||||||
"prepare",
|
"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 () => {
|
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 = [
|
||||||
|
|||||||
@@ -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>(
|
async function runPreparedPixelCapture<T>(
|
||||||
contents: TabContents,
|
contents: TabContents,
|
||||||
capture: () => Promise<T>,
|
capture: () => Promise<T>,
|
||||||
@@ -163,7 +174,7 @@ async function runPreparedPixelCapture<T>(
|
|||||||
preparation = await prepareForPixelCapture(contents);
|
preparation = await prepareForPixelCapture(contents);
|
||||||
contents.setBackgroundThrottling(false);
|
contents.setBackgroundThrottling(false);
|
||||||
contents.invalidate();
|
contents.invalidate();
|
||||||
return await withPixelCaptureTimeout(capture());
|
return await capturePreparedPixelFrame(capture);
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
if (preparation) {
|
if (preparation) {
|
||||||
|
|||||||
Reference in New Issue
Block a user