mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(browser): capture the full visual viewport (#5621)
* fix(browser): capture the full visual viewport * fix(browser): preserve surface geometry and high-DPI screenshot bounds * fix(browser): reject stale screenshot captures
This commit is contained in:
@@ -55,6 +55,13 @@ export async function startBrowserVerifierSite() {
|
||||
response.once("close", () => clearTimeout(timer));
|
||||
return;
|
||||
}
|
||||
if (request.url === "/screenshot-viewport") {
|
||||
response.setHeader("content-type", "text/html");
|
||||
response.end(
|
||||
`<!doctype html><html style="overflow:hidden"><body style="margin:0;background:#eee"><div style="position:absolute;top:650px;left:0;width:200px;height:50px;background:#c00"></div></body></html>`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (request.url === "/screenshot-noise") {
|
||||
response.setHeader("content-type", "text/html");
|
||||
response.end(`<!doctype html><html><body style="margin:0"><canvas width="2048" height="1536"></canvas><script>
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright-core";
|
||||
import { CdpConnection } from "../../dist/browser/CdpConnection.js";
|
||||
import { discoverCdpEndpoint } from "../../dist/browser/CdpEndpoint.js";
|
||||
import { captureCdpScreenshot } from "../../dist/browser/CdpScreenshot.js";
|
||||
import { comparePngScreenshots } from "../../dist/browser/PngVisualDiff.js";
|
||||
import { createWebScreenshotArtifact } from "../../dist/domain/webScreenshot.js";
|
||||
|
||||
/** Exercise the production capture boundary on a real, undersized surface. */
|
||||
export async function verifyViewportScreenshotE2e(endpoint, origin) {
|
||||
const browser = await chromium.connectOverCDP(endpoint);
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 800, height: 400 },
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
let connection;
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${origin}/screenshot-viewport`);
|
||||
const discovery = await discoverCdpEndpoint(
|
||||
endpoint,
|
||||
"capture_web_screenshot",
|
||||
);
|
||||
const target = discovery.targets.find(({ url }) => url === page.url());
|
||||
assert.ok(
|
||||
target?.webSocket,
|
||||
"Owned viewport fixture needs a page CDP endpoint",
|
||||
);
|
||||
connection = await CdpConnection.connect(
|
||||
target.webSocket.url,
|
||||
"capture_web_screenshot",
|
||||
);
|
||||
const emulation = await context.newCDPSession(page);
|
||||
const cases = [];
|
||||
for (const scale of [1, 2]) {
|
||||
await connection.send("Emulation.setDeviceMetricsOverride", {
|
||||
width: 800,
|
||||
height: 400,
|
||||
deviceScaleFactor: 0,
|
||||
mobile: false,
|
||||
});
|
||||
// A capture on another CDP session can restore its own device metrics.
|
||||
// Establish the fixture independently for each page-scale case.
|
||||
await emulation.send("Emulation.clearDeviceMetricsOverride");
|
||||
await emulation.send("Emulation.setDeviceMetricsOverride", {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false,
|
||||
dontSetVisibleSize: true,
|
||||
});
|
||||
await emulation.send("Emulation.setPageScaleFactor", {
|
||||
pageScaleFactor: scale,
|
||||
});
|
||||
const { visualViewport, cssVisualViewport } = await connection.send(
|
||||
"Page.getLayoutMetrics",
|
||||
);
|
||||
assert.equal(
|
||||
cssVisualViewport.clientWidth * cssVisualViewport.scale,
|
||||
1280,
|
||||
JSON.stringify({ scale, visualViewport }),
|
||||
);
|
||||
assert.equal(
|
||||
cssVisualViewport.clientHeight * cssVisualViewport.scale,
|
||||
720,
|
||||
JSON.stringify({ scale, visualViewport }),
|
||||
);
|
||||
const captures = [];
|
||||
const send = connection.send.bind(connection);
|
||||
connection.send = (...args) => {
|
||||
const result = send(...args);
|
||||
if (args[0] !== "Page.captureScreenshot") return result;
|
||||
return result.then((reply) => {
|
||||
captures.push({ params: args[1], data: reply.data });
|
||||
return reply;
|
||||
});
|
||||
};
|
||||
try {
|
||||
await assert.rejects(
|
||||
captureCdpScreenshot({
|
||||
connection,
|
||||
discovery,
|
||||
target,
|
||||
input: {
|
||||
cdp_endpoint: endpoint,
|
||||
target_id: target.id,
|
||||
allowed_origins: [origin],
|
||||
},
|
||||
}),
|
||||
(error) => error.reason === "target_changed",
|
||||
"Recovery must reject a viewport changed by another session",
|
||||
);
|
||||
} finally {
|
||||
connection.send = send;
|
||||
}
|
||||
assert.equal(
|
||||
captures.length,
|
||||
2,
|
||||
"Fixture must exercise cropped recovery",
|
||||
);
|
||||
assert.equal(captures[0].params.captureBeyondViewport, false);
|
||||
assert.equal(captures[0].params.clip, undefined);
|
||||
assert.equal(captures[1].params.captureBeyondViewport, true);
|
||||
assert.ok(captures[1].params.clip, "Recovery must use an explicit clip");
|
||||
const expectedViewport = {
|
||||
width: Math.round(visualViewport.clientWidth * visualViewport.scale),
|
||||
height: Math.round(visualViewport.clientHeight * visualViewport.scale),
|
||||
};
|
||||
const initialBytes = Buffer.from(captures[0].data, "base64");
|
||||
assert.ok(
|
||||
initialBytes.readUInt32BE(16) < expectedViewport.width ||
|
||||
initialBytes.readUInt32BE(20) < expectedViewport.height,
|
||||
"First production capture must actually be undersized",
|
||||
);
|
||||
const recovered = createWebScreenshotArtifact(
|
||||
Buffer.from(captures[1].data, "base64"),
|
||||
);
|
||||
const control = await connection.send("Page.captureScreenshot", {
|
||||
format: "png",
|
||||
fromSurface: true,
|
||||
captureBeyondViewport: true,
|
||||
clip: {
|
||||
x: cssVisualViewport.pageX,
|
||||
y: cssVisualViewport.pageY,
|
||||
width: cssVisualViewport.clientWidth,
|
||||
height: cssVisualViewport.clientHeight,
|
||||
scale: cssVisualViewport.scale,
|
||||
},
|
||||
});
|
||||
const compared = comparePngScreenshots({
|
||||
before: recovered,
|
||||
after: createWebScreenshotArtifact(Buffer.from(control.data, "base64")),
|
||||
channel_threshold: 0,
|
||||
});
|
||||
assert.equal(
|
||||
compared.status,
|
||||
"identical",
|
||||
`Viewport capture must retain every control pixel: ${JSON.stringify({ scale, compared })}`,
|
||||
);
|
||||
// A stable, same-session viewport must still return a normal screenshot.
|
||||
await connection.send("Emulation.setDeviceMetricsOverride", {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
deviceScaleFactor: 0,
|
||||
mobile: false,
|
||||
});
|
||||
await connection.send("Emulation.setPageScaleFactor", {
|
||||
pageScaleFactor: scale,
|
||||
});
|
||||
const screenshot = await captureCdpScreenshot({
|
||||
connection,
|
||||
discovery,
|
||||
target,
|
||||
input: {
|
||||
cdp_endpoint: endpoint,
|
||||
target_id: target.id,
|
||||
allowed_origins: [origin],
|
||||
},
|
||||
});
|
||||
assert.deepEqual(screenshot.viewport, expectedViewport);
|
||||
assert.equal(
|
||||
comparePngScreenshots({
|
||||
before: screenshot.artifact,
|
||||
after: recovered,
|
||||
channel_threshold: 0,
|
||||
}).status,
|
||||
"identical",
|
||||
);
|
||||
cases.push({
|
||||
page_scale: scale,
|
||||
viewport: screenshot.viewport,
|
||||
pixel_control: "identical",
|
||||
clipped_recovery: true,
|
||||
changed_viewport_rejected: true,
|
||||
stable_viewport_accepted: true,
|
||||
});
|
||||
}
|
||||
return {
|
||||
mocked: false,
|
||||
boundary: "captureCdpScreenshot",
|
||||
external_session_emulation: true,
|
||||
cases,
|
||||
};
|
||||
} finally {
|
||||
try {
|
||||
if (connection !== undefined) await connection.close();
|
||||
} finally {
|
||||
try {
|
||||
await context.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
||||
import { closeVerifierResources } from "./lib/verifier-cleanup.mjs";
|
||||
import { requireOrdinaryWindowsVerifierCaller } from "./lib/windows-ordinary-user-verifier.mjs";
|
||||
import { verifyLargeScreenshotE2e } from "./lib/browser-screenshot-e2e.mjs";
|
||||
import { verifyViewportScreenshotE2e } from "./lib/browser-viewport-screenshot-e2e.mjs";
|
||||
import { verifyPopupEventCoverage } from "./lib/browser-popup-e2e.mjs";
|
||||
import { verifyBrowserNetworkEvidence } from "./lib/browser-network-e2e.mjs";
|
||||
import { verifyBrowserScriptExport } from "./lib/browser-script-export-e2e.mjs";
|
||||
@@ -372,6 +373,11 @@ try {
|
||||
|
||||
process.stderr.write("Browser verifier: large screenshot\n");
|
||||
const largeScreenshot = await verifyLargeScreenshotE2e(endpoint, site.origin);
|
||||
process.stderr.write("Browser verifier: visual viewport screenshot\n");
|
||||
const viewportScreenshot = await verifyViewportScreenshotE2e(
|
||||
endpoint,
|
||||
site.origin,
|
||||
);
|
||||
process.stderr.write("Browser verifier: popup coverage\n");
|
||||
const popupEvents = await verifyPopupEventCoverage(executable);
|
||||
const networkEvidence = await verifyBrowserNetworkEvidence(
|
||||
@@ -423,6 +429,7 @@ try {
|
||||
scenarioStorage,
|
||||
scenarioStorageFingerprints,
|
||||
scenarioServiceWorkers,
|
||||
viewportScreenshot,
|
||||
verified: true,
|
||||
};
|
||||
} catch (cause) {
|
||||
|
||||
+153
-17
@@ -12,6 +12,7 @@ import {
|
||||
allowedSanitizedUrl,
|
||||
recordValue,
|
||||
cdpStringValue,
|
||||
numberValue,
|
||||
} from "./CdpCaptureValues.js";
|
||||
import { mainFrameUrl } from "./CdpCaptureDocuments.js";
|
||||
import { captureFrames } from "./CdpCaptureDocuments.js";
|
||||
@@ -73,17 +74,45 @@ export const captureCdpScreenshot = async (
|
||||
if (event.sessionId !== context.sessionId) return;
|
||||
observeScreenshotNavigation(event, origins, navigation);
|
||||
});
|
||||
let result: ReturnType<typeof recordValue>;
|
||||
const assertCurrentDocument = () => {
|
||||
if (navigation.invalidIdentity)
|
||||
throw new BrowserObservationError("inspect_web_page", "protocol_error");
|
||||
if (navigation.leftScope)
|
||||
throw new BrowserObservationError(
|
||||
"inspect_web_page",
|
||||
"target_not_allowed",
|
||||
);
|
||||
if (navigation.changed)
|
||||
throw new BrowserObservationError("inspect_web_page", "target_changed");
|
||||
};
|
||||
let screenshot: Awaited<ReturnType<typeof captureScreenshotImage>>;
|
||||
let after: unknown;
|
||||
try {
|
||||
result = recordValue(
|
||||
await context.connection.send(
|
||||
"Page.captureScreenshot",
|
||||
{ format: "png", fromSurface: true, captureBeyondViewport: false },
|
||||
context.sessionId,
|
||||
context.signal,
|
||||
),
|
||||
);
|
||||
const viewport = await readScreenshotViewport(context);
|
||||
assertCurrentDocument();
|
||||
screenshot = await captureScreenshotImage(context);
|
||||
assertCurrentDocument();
|
||||
const currentViewport = await readScreenshotViewport(context);
|
||||
assertCurrentDocument();
|
||||
assertSameViewport(viewport, currentViewport);
|
||||
if (!coversViewport(screenshot, currentViewport)) {
|
||||
// Refresh geometry before clipping, and reject a moving viewport rather
|
||||
// than capture a region that is no longer visible.
|
||||
screenshot = await captureScreenshotImage(context, currentViewport.clip);
|
||||
assertCurrentDocument();
|
||||
const recoveredViewport = await readScreenshotViewport(context);
|
||||
assertCurrentDocument();
|
||||
assertSameViewport(currentViewport, recoveredViewport);
|
||||
if (!coversViewport(screenshot, recoveredViewport))
|
||||
throw new BrowserObservationError(
|
||||
"inspect_web_page",
|
||||
"protocol_error",
|
||||
{
|
||||
detail:
|
||||
"The screenshot does not cover the reported visual viewport.",
|
||||
},
|
||||
);
|
||||
}
|
||||
after = await context.connection.send(
|
||||
"Page.getFrameTree",
|
||||
{},
|
||||
@@ -93,16 +122,9 @@ export const captureCdpScreenshot = async (
|
||||
} finally {
|
||||
removeListener();
|
||||
}
|
||||
const screenshot = decodeScreenshot(result);
|
||||
if (screenshot === undefined)
|
||||
throw new BrowserObservationError("inspect_web_page", "protocol_error");
|
||||
if (navigation.invalidIdentity)
|
||||
throw new BrowserObservationError("inspect_web_page", "protocol_error");
|
||||
if (navigation.leftScope)
|
||||
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
|
||||
assertCurrentDocument();
|
||||
const afterUrl = mainFrameUrl(after);
|
||||
if (
|
||||
navigation.changed ||
|
||||
afterUrl !== beforeUrl ||
|
||||
allowedSanitizedUrl(afterUrl, origins) === undefined
|
||||
)
|
||||
@@ -132,6 +154,120 @@ export const captureCdpScreenshot = async (
|
||||
};
|
||||
};
|
||||
|
||||
/** Parse CSS clip geometry and device-pixel coverage at their owning boundary. */
|
||||
const readScreenshotViewport = async (context: ScreenshotContext) => {
|
||||
const metrics = recordValue(
|
||||
await context.connection.send(
|
||||
"Page.getLayoutMetrics",
|
||||
{},
|
||||
context.sessionId,
|
||||
context.signal,
|
||||
),
|
||||
);
|
||||
const visualViewport = recordValue(metrics?.visualViewport);
|
||||
const cssViewport = recordValue(metrics?.cssVisualViewport);
|
||||
const pageX = numberValue(cssViewport?.pageX);
|
||||
const pageY = numberValue(cssViewport?.pageY);
|
||||
const width = numberValue(cssViewport?.clientWidth);
|
||||
const height = numberValue(cssViewport?.clientHeight);
|
||||
const scale = numberValue(cssViewport?.scale);
|
||||
const pixelWidth = numberValue(visualViewport?.clientWidth);
|
||||
const pixelHeight = numberValue(visualViewport?.clientHeight);
|
||||
const pixelScale = numberValue(visualViewport?.scale);
|
||||
if (
|
||||
pageX === undefined ||
|
||||
pageY === undefined ||
|
||||
width === undefined ||
|
||||
height === undefined ||
|
||||
scale === undefined ||
|
||||
pixelWidth === undefined ||
|
||||
pixelHeight === undefined ||
|
||||
pixelScale !== scale ||
|
||||
width <= 0 ||
|
||||
height <= 0 ||
|
||||
scale <= 0 ||
|
||||
pixelWidth <= 0 ||
|
||||
pixelHeight <= 0
|
||||
)
|
||||
throw new BrowserObservationError("inspect_web_page", "protocol_error");
|
||||
// Keep ordinary surface captures, including their scrollbars and pixel
|
||||
// density. The visual viewport excludes scrollbars and supplies only a
|
||||
// lower bound on the expected image size, allowing subpixel rounding.
|
||||
const minimumWidth = Math.max(1, Math.floor(pixelWidth * scale));
|
||||
const minimumHeight = Math.max(1, Math.floor(pixelHeight * scale));
|
||||
if (
|
||||
!Number.isSafeInteger(minimumWidth) ||
|
||||
!Number.isSafeInteger(minimumHeight)
|
||||
)
|
||||
throw new BrowserObservationError("inspect_web_page", "protocol_error");
|
||||
return {
|
||||
clip: { x: pageX, y: pageY, width, height, scale },
|
||||
pixelWidth,
|
||||
pixelHeight,
|
||||
minimumWidth,
|
||||
minimumHeight,
|
||||
};
|
||||
};
|
||||
|
||||
type ScreenshotViewport = Awaited<ReturnType<typeof readScreenshotViewport>>;
|
||||
|
||||
const coversViewport = (
|
||||
image: Awaited<ReturnType<typeof captureScreenshotImage>>,
|
||||
viewport: ScreenshotViewport,
|
||||
) =>
|
||||
image.dimensions.width >= viewport.minimumWidth &&
|
||||
image.dimensions.height >= viewport.minimumHeight;
|
||||
|
||||
const assertSameViewport = (
|
||||
before: ScreenshotViewport,
|
||||
after: ScreenshotViewport,
|
||||
): void => {
|
||||
if (
|
||||
before.clip.x !== after.clip.x ||
|
||||
before.clip.y !== after.clip.y ||
|
||||
before.clip.width !== after.clip.width ||
|
||||
before.clip.height !== after.clip.height ||
|
||||
before.clip.scale !== after.clip.scale ||
|
||||
before.pixelWidth !== after.pixelWidth ||
|
||||
before.pixelHeight !== after.pixelHeight
|
||||
)
|
||||
throw new BrowserObservationError("inspect_web_page", "target_changed", {
|
||||
detail: "The visual viewport changed during screenshot capture.",
|
||||
});
|
||||
};
|
||||
|
||||
interface ScreenshotClip {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly scale: number;
|
||||
}
|
||||
|
||||
/** Decode every capture at the same boundary, including a clipped recovery. */
|
||||
const captureScreenshotImage = async (
|
||||
context: ScreenshotContext,
|
||||
clip?: ScreenshotClip,
|
||||
) => {
|
||||
const result = recordValue(
|
||||
await context.connection.send(
|
||||
"Page.captureScreenshot",
|
||||
{
|
||||
format: "png",
|
||||
fromSurface: true,
|
||||
captureBeyondViewport: clip !== undefined,
|
||||
...(clip === undefined ? {} : { clip }),
|
||||
},
|
||||
context.sessionId,
|
||||
context.signal,
|
||||
),
|
||||
);
|
||||
const screenshot = decodeScreenshot(result);
|
||||
if (screenshot === undefined)
|
||||
throw new BrowserObservationError("inspect_web_page", "protocol_error");
|
||||
return screenshot;
|
||||
};
|
||||
|
||||
const decodeScreenshot = (
|
||||
result: ReturnType<typeof recordValue>,
|
||||
):
|
||||
|
||||
@@ -19,6 +19,537 @@ const captureWithFake = async (options: FakeOptions) => {
|
||||
);
|
||||
return { browser, result };
|
||||
};
|
||||
|
||||
// These wire tests validate PNG headers; actual rendering has its own browser lane.
|
||||
const screenshotData = (width: number, height: number) => {
|
||||
const bytes = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
bytes.writeUInt32BE(width, 16);
|
||||
bytes.writeUInt32BE(height, 20);
|
||||
return { data: bytes.toString("base64") };
|
||||
};
|
||||
|
||||
const withCssViewport = (metrics: Readonly<Record<string, unknown>>) => ({
|
||||
...metrics,
|
||||
cssVisualViewport: metrics.visualViewport,
|
||||
});
|
||||
|
||||
describe("screenshot authorization during metrics reads", () => {
|
||||
it.each([
|
||||
{ stage: 1, expectedCaptures: 0 },
|
||||
{ stage: 2, expectedCaptures: 1 },
|
||||
])(
|
||||
"stops capture after scope loss during metrics read $stage",
|
||||
async ({ stage, expectedCaptures }) => {
|
||||
let reads = 0;
|
||||
const { browser, result } = await captureWithFake({
|
||||
eventsBeforeReply: ["Page.getLayoutMetrics"],
|
||||
commandResult: ({ method }) =>
|
||||
method === "Page.getLayoutMetrics"
|
||||
? withCssViewport({
|
||||
visualViewport: {
|
||||
pageX: 0,
|
||||
pageY: 0,
|
||||
clientWidth: 2,
|
||||
clientHeight: 2,
|
||||
scale: 1,
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
commandEvents: (command) =>
|
||||
command.method === "Page.getLayoutMetrics" && ++reads === stage
|
||||
? [
|
||||
frameNavigated(command, {
|
||||
id: "frame-main",
|
||||
loaderId: "outside-loader",
|
||||
url: "https://outside.test/",
|
||||
}),
|
||||
]
|
||||
: undefined,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { reason: "target_not_allowed" },
|
||||
});
|
||||
expect(
|
||||
browser.commands.filter(
|
||||
({ method }) => method === "Page.captureScreenshot",
|
||||
),
|
||||
).toHaveLength(expectedCaptures);
|
||||
await expect.poll(() => browser.activeConnectionCount()).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["navigation", "uncertain identity"])(
|
||||
"stops before the first screenshot after %s during metrics",
|
||||
async (change) => {
|
||||
const { browser, result } = await captureWithFake({
|
||||
eventsBeforeReply: ["Page.getLayoutMetrics"],
|
||||
commandEvents: (command, origin) =>
|
||||
command.method === "Page.getLayoutMetrics"
|
||||
? [
|
||||
frameNavigated(
|
||||
command,
|
||||
change === "navigation"
|
||||
? {
|
||||
id: "frame-main",
|
||||
loaderId: "new-loader",
|
||||
url: `${origin}/app`,
|
||||
}
|
||||
: {
|
||||
id: "candidate-root",
|
||||
parentId: "candidate-root",
|
||||
url: `${origin}/app`,
|
||||
},
|
||||
),
|
||||
]
|
||||
: undefined,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
reason: change === "navigation" ? "target_changed" : "protocol_error",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
browser.commands.some(
|
||||
({ method }) => method === "Page.captureScreenshot",
|
||||
),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("screenshot viewport freshness", () => {
|
||||
it.each([
|
||||
{ phase: "normal capture", stage: 2, imageSize: 4, captures: 1 },
|
||||
{ phase: "before recovery", stage: 2, imageSize: 1, captures: 1 },
|
||||
{ phase: "during recovery", stage: 3, imageSize: 1, captures: 2 },
|
||||
])(
|
||||
"rejects viewport changes $phase",
|
||||
async ({ stage, imageSize, captures }) => {
|
||||
let reads = 0;
|
||||
const { browser, result } = await captureWithFake({
|
||||
commandResult: ({ method }) =>
|
||||
method === "Page.getLayoutMetrics"
|
||||
? withCssViewport({
|
||||
visualViewport: {
|
||||
pageX: 0,
|
||||
pageY: ++reads >= stage ? 50 : 0,
|
||||
clientWidth: 4,
|
||||
clientHeight: 4,
|
||||
scale: 1,
|
||||
},
|
||||
})
|
||||
: method === "Page.captureScreenshot"
|
||||
? screenshotData(imageSize, imageSize)
|
||||
: undefined,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { reason: "target_changed" },
|
||||
});
|
||||
expect(
|
||||
browser.commands.filter(
|
||||
({ method }) => method === "Page.captureScreenshot",
|
||||
),
|
||||
).toHaveLength(captures);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ label: "resize", css: { clientWidth: 8 }, pixel: { clientWidth: 8 } },
|
||||
{ label: "zoom", css: { scale: 2 }, pixel: { scale: 2 } },
|
||||
{
|
||||
label: "device pixel density",
|
||||
css: {},
|
||||
pixel: { clientWidth: 8, clientHeight: 8 },
|
||||
},
|
||||
])(
|
||||
"rejects $label during recovery even when the image is large enough",
|
||||
async ({ css, pixel }) => {
|
||||
let reads = 0;
|
||||
let captures = 0;
|
||||
const base = {
|
||||
pageX: 0,
|
||||
pageY: 0,
|
||||
clientWidth: 4,
|
||||
clientHeight: 4,
|
||||
scale: 1,
|
||||
};
|
||||
const { result } = await captureWithFake({
|
||||
commandResult: ({ method }) => {
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
const changed = ++reads === 3;
|
||||
return {
|
||||
cssVisualViewport: { ...base, ...(changed ? css : {}) },
|
||||
visualViewport: { ...base, ...(changed ? pixel : {}) },
|
||||
};
|
||||
}
|
||||
return method === "Page.captureScreenshot"
|
||||
? ++captures === 1
|
||||
? screenshotData(1, 1)
|
||||
: screenshotData(16, 16)
|
||||
: undefined;
|
||||
},
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { reason: "target_changed" },
|
||||
});
|
||||
expect(captures).toBe(2);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([2, 3])("validates refreshed metrics read %s", async (stage) => {
|
||||
let reads = 0;
|
||||
const { browser, result } = await captureWithFake({
|
||||
commandResult: ({ method }) =>
|
||||
method === "Page.getLayoutMetrics"
|
||||
? ++reads === stage
|
||||
? {}
|
||||
: withCssViewport({
|
||||
visualViewport: {
|
||||
pageX: 0,
|
||||
pageY: 0,
|
||||
clientWidth: 2,
|
||||
clientHeight: 2,
|
||||
scale: 1,
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { reason: "protocol_error" },
|
||||
});
|
||||
expect(
|
||||
browser.commands.filter(
|
||||
({ method }) => method === "Page.captureScreenshot",
|
||||
),
|
||||
).toHaveLength(stage - 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("screenshot visual viewport capture", () => {
|
||||
it("recovers a cropped surface using the reported viewport clip and page scale", async () => {
|
||||
let captures = 0;
|
||||
const browser = await startFakeCdpBrowser({
|
||||
commandResult: ({ method }) =>
|
||||
method === "Page.getLayoutMetrics"
|
||||
? withCssViewport({
|
||||
visualViewport: {
|
||||
pageX: 12,
|
||||
pageY: 34,
|
||||
clientWidth: 640,
|
||||
clientHeight: 360,
|
||||
scale: 2,
|
||||
},
|
||||
})
|
||||
: method === "Page.captureScreenshot"
|
||||
? ++captures === 1
|
||||
? screenshotData(320, 180)
|
||||
: screenshotData(1280, 720)
|
||||
: undefined,
|
||||
});
|
||||
onTestFinished(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
const result = await new CdpBrowserProvider().captureScreenshot(
|
||||
captureWebScreenshotInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
}),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
const layoutMetrics = browser.commands.findIndex(
|
||||
({ method }) => method === "Page.getLayoutMetrics",
|
||||
);
|
||||
const screenshotIndex = browser.commands.findIndex(
|
||||
({ method }) => method === "Page.captureScreenshot",
|
||||
);
|
||||
expect(layoutMetrics).toBeGreaterThanOrEqual(0);
|
||||
expect(screenshotIndex).toBeGreaterThan(layoutMetrics);
|
||||
const screenshots = browser.commands.filter(
|
||||
({ method }) => method === "Page.captureScreenshot",
|
||||
);
|
||||
expect(screenshots).toHaveLength(2);
|
||||
expect(screenshots[0]?.params).toMatchObject({
|
||||
captureBeyondViewport: false,
|
||||
});
|
||||
expect(screenshots[0]?.params).not.toHaveProperty("clip");
|
||||
expect(screenshots[1]?.params).toMatchObject({
|
||||
fromSurface: true,
|
||||
captureBeyondViewport: true,
|
||||
clip: { x: 12, y: 34, width: 640, height: 360, scale: 2 },
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.viewport).toEqual({ width: 1280, height: 720 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("screenshot CSS and device-pixel coordinates", () => {
|
||||
it("uses CSS clip coordinates when the reported viewport has twice as many device pixels", async () => {
|
||||
let captures = 0;
|
||||
const browser = await startFakeCdpBrowser({
|
||||
commandResult: ({ method }) =>
|
||||
method === "Page.getLayoutMetrics"
|
||||
? {
|
||||
cssVisualViewport: {
|
||||
pageX: 12,
|
||||
pageY: 34,
|
||||
clientWidth: 640,
|
||||
clientHeight: 360,
|
||||
scale: 1,
|
||||
},
|
||||
visualViewport: {
|
||||
pageX: 24,
|
||||
pageY: 68,
|
||||
clientWidth: 1280,
|
||||
clientHeight: 720,
|
||||
scale: 1,
|
||||
},
|
||||
}
|
||||
: method === "Page.captureScreenshot"
|
||||
? ++captures === 1
|
||||
? screenshotData(800, 400)
|
||||
: screenshotData(1280, 720)
|
||||
: undefined,
|
||||
});
|
||||
onTestFinished(() => browser.close());
|
||||
const result = await new CdpBrowserProvider().captureScreenshot(
|
||||
captureWebScreenshotInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
}),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
const screenshots = browser.commands.filter(
|
||||
({ method }) => method === "Page.captureScreenshot",
|
||||
);
|
||||
expect(screenshots).toHaveLength(2);
|
||||
expect(screenshots[1]?.params.clip).toEqual({
|
||||
x: 12,
|
||||
y: 34,
|
||||
width: 640,
|
||||
height: 360,
|
||||
scale: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("screenshot surface compatibility", () => {
|
||||
it.each([
|
||||
{
|
||||
label: "scrollbars",
|
||||
width: 2033,
|
||||
height: 1521,
|
||||
imageWidth: 2048,
|
||||
imageHeight: 1536,
|
||||
},
|
||||
{
|
||||
label: "higher pixel density",
|
||||
width: 640,
|
||||
height: 360,
|
||||
imageWidth: 1280,
|
||||
imageHeight: 720,
|
||||
},
|
||||
])(
|
||||
"preserves the original surface with $label",
|
||||
async ({ width, height, imageWidth, imageHeight }) => {
|
||||
const image = screenshotData(imageWidth, imageHeight);
|
||||
const browser = await startFakeCdpBrowser({
|
||||
commandResult: ({ method }) =>
|
||||
method === "Page.getLayoutMetrics"
|
||||
? withCssViewport({
|
||||
visualViewport: {
|
||||
pageX: 0,
|
||||
pageY: 0,
|
||||
clientWidth: width,
|
||||
clientHeight: height,
|
||||
scale: 1,
|
||||
},
|
||||
})
|
||||
: method === "Page.captureScreenshot"
|
||||
? image
|
||||
: undefined,
|
||||
});
|
||||
onTestFinished(() => browser.close());
|
||||
const result = await new CdpBrowserProvider().captureScreenshot(
|
||||
captureWebScreenshotInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
}),
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.viewport).toEqual({
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
});
|
||||
expect(result.value.artifact.data_base64).toBe(image.data);
|
||||
expect(
|
||||
browser.commands.filter(
|
||||
({ method }) => method === "Page.captureScreenshot",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses a clipped recovery that still omits visible content", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
commandResult: ({ method }) =>
|
||||
method === "Page.getLayoutMetrics"
|
||||
? withCssViewport({
|
||||
visualViewport: {
|
||||
pageX: 0,
|
||||
pageY: 0,
|
||||
clientWidth: 2,
|
||||
clientHeight: 2,
|
||||
scale: 1,
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
});
|
||||
onTestFinished(() => browser.close());
|
||||
const result = await new CdpBrowserProvider().captureScreenshot(
|
||||
captureWebScreenshotInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
}),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { reason: "protocol_error" },
|
||||
});
|
||||
expect(
|
||||
browser.commands.filter(
|
||||
({ method }) => method === "Page.captureScreenshot",
|
||||
),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not retry a cropped surface after the target leaves scope", async () => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
navigateDuringScreenshotUrl: "https://outside.test/",
|
||||
commandResult: ({ method }) =>
|
||||
method === "Page.getLayoutMetrics"
|
||||
? withCssViewport({
|
||||
visualViewport: {
|
||||
pageX: 0,
|
||||
pageY: 0,
|
||||
clientWidth: 2,
|
||||
clientHeight: 2,
|
||||
scale: 1,
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
});
|
||||
onTestFinished(() => browser.close());
|
||||
const result = await new CdpBrowserProvider().captureScreenshot(
|
||||
captureWebScreenshotInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
}),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { reason: "target_not_allowed" },
|
||||
});
|
||||
expect(
|
||||
browser.commands.filter(
|
||||
({ method }) => method === "Page.captureScreenshot",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("screenshot viewport protocol validation", () => {
|
||||
it.each([
|
||||
["missing metrics", {}],
|
||||
[
|
||||
"non-numeric coordinates",
|
||||
{
|
||||
visualViewport: {
|
||||
pageX: "unknown",
|
||||
pageY: 0,
|
||||
clientWidth: 10,
|
||||
clientHeight: 10,
|
||||
scale: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"non-finite coordinates",
|
||||
{
|
||||
visualViewport: {
|
||||
pageX: Number.POSITIVE_INFINITY,
|
||||
pageY: 0,
|
||||
clientWidth: 10,
|
||||
clientHeight: 10,
|
||||
scale: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"non-positive dimensions",
|
||||
{
|
||||
visualViewport: {
|
||||
pageX: 0,
|
||||
pageY: 0,
|
||||
clientWidth: 0,
|
||||
clientHeight: 10,
|
||||
scale: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"non-positive scale",
|
||||
{
|
||||
visualViewport: {
|
||||
pageX: 0,
|
||||
pageY: 0,
|
||||
clientWidth: 10,
|
||||
clientHeight: 10,
|
||||
scale: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
])("rejects %s before screenshot capture", async (_label, metrics) => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
commandResult: ({ method }) =>
|
||||
method === "Page.getLayoutMetrics"
|
||||
? withCssViewport(metrics)
|
||||
: undefined,
|
||||
});
|
||||
onTestFinished(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
const result = await new CdpBrowserProvider().captureScreenshot(
|
||||
captureWebScreenshotInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
}),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { reason: "protocol_error" },
|
||||
});
|
||||
expect(
|
||||
browser.commands.some(
|
||||
({ method }) => method === "Page.captureScreenshot",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("screenshot committed document identity", () => {
|
||||
it.each([
|
||||
["missing screenshot data", {}],
|
||||
@@ -93,6 +624,42 @@ describe("screenshot committed document identity", () => {
|
||||
});
|
||||
|
||||
describe("screenshot frame identity transitions", () => {
|
||||
it("does not recover a cropped image after uncertain root identity", async () => {
|
||||
const { browser, result } = await captureWithFake({
|
||||
commandResult: ({ method }) =>
|
||||
method === "Page.getLayoutMetrics"
|
||||
? withCssViewport({
|
||||
visualViewport: {
|
||||
pageX: 0,
|
||||
pageY: 0,
|
||||
clientWidth: 2,
|
||||
clientHeight: 2,
|
||||
scale: 1,
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
commandEvents: (command, origin) =>
|
||||
command.method === "Page.captureScreenshot"
|
||||
? [
|
||||
frameNavigated(command, {
|
||||
id: "candidate-root",
|
||||
parentId: "candidate-root",
|
||||
url: `${origin}/app`,
|
||||
}),
|
||||
]
|
||||
: undefined,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { reason: "protocol_error" },
|
||||
});
|
||||
expect(
|
||||
browser.commands.filter(
|
||||
({ method }) => method === "Page.captureScreenshot",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects a self-parented root candidate as malformed identity", async () => {
|
||||
const { browser, result } = await captureWithFake({
|
||||
commandEvents: (command, origin) =>
|
||||
|
||||
+17
@@ -10,6 +10,23 @@ export const resultFor = (
|
||||
switch (command.method) {
|
||||
case "Target.attachToTarget":
|
||||
return attachToTargetResult(options);
|
||||
case "Page.getLayoutMetrics":
|
||||
return {
|
||||
cssVisualViewport: {
|
||||
pageX: 0,
|
||||
pageY: 0,
|
||||
clientWidth: 1,
|
||||
clientHeight: 1,
|
||||
scale: 1,
|
||||
},
|
||||
visualViewport: {
|
||||
pageX: 0,
|
||||
pageY: 0,
|
||||
clientWidth: 1,
|
||||
clientHeight: 1,
|
||||
scale: 1,
|
||||
},
|
||||
};
|
||||
case "Page.getFrameTree":
|
||||
return frameTreeResult(port, options, frameTreeReads);
|
||||
case "Page.getResourceTree":
|
||||
|
||||
Reference in New Issue
Block a user