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:
STDquantum
2026-10-23 05:17:03 +08:00
committed by GitHub
parent 1d5357aaad
commit 146dcf46f5
6 changed files with 946 additions and 17 deletions
@@ -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();
}
}
}
}
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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":