fix(browser): prevent cross-origin images from disappearing in CDP screenshots (#54358)

fromSurface: true + captureBeyondViewport: true triggers a Chromium compositor
bug where cross-origin image textures are lost when extending the capture
surface. Switch to fromSurface: false to use the software rendering path.

For full-page captures, temporarily expand the viewport via
Emulation.setDeviceMetricsOverride, preserving the current mobile/DPR/screen
state during capture and restoring it afterward so pre-existing device
emulation is not lost.

Made-with: Cursor

Co-authored-by: hakunaliu <hakunaliu@tencent.com>
This commit is contained in:
FMLS
2026-03-31 18:55:25 +08:00
committed by GitHub
parent 57700d716f
commit 44caf1ee3d
3 changed files with 291 additions and 16 deletions

View File

@@ -0,0 +1,201 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ResolvedBrowserProfile } from "./config.js";
import { shouldUsePlaywrightForScreenshot } from "./profile-capabilities.js";
const sentMessages = vi.hoisted(() => {
const msgs: Array<{ method: string; params?: Record<string, unknown> }> = [];
return msgs;
});
// Tracks whether emulation has been cleared so post-clear Runtime.evaluate
// can return different values for the "emulated tab" vs "non-emulated tab" tests.
const mockState = vi.hoisted(() => ({
emulationCleared: false,
emulatedTab: true,
viewport: { w: 800, h: 600, dpr: 2, sw: 800, sh: 600 } as Record<string, unknown>,
naturalViewport: { w: 1920, h: 1080, dpr: 1 },
}));
vi.mock("./cdp.helpers.js", () => ({
withCdpSocket: vi.fn(async (_wsUrl: string, fn: (send: unknown) => Promise<unknown>) => {
const send = (method: string, params?: Record<string, unknown>) => {
sentMessages.push({ method, params });
if (method === "Page.captureScreenshot") {
return Promise.resolve({ data: "AAAA" });
}
if (method === "Page.getLayoutMetrics") {
return Promise.resolve({
cssContentSize: { width: 1200, height: 3000 },
contentSize: { width: 1200, height: 3000 },
});
}
if (method === "Emulation.clearDeviceMetricsOverride") {
mockState.emulationCleared = true;
return Promise.resolve({});
}
if (method === "Emulation.setDeviceMetricsOverride") {
mockState.emulationCleared = false;
return Promise.resolve({});
}
if (method === "Runtime.evaluate") {
if (mockState.emulationCleared && mockState.emulatedTab) {
return Promise.resolve({
result: {
value: mockState.naturalViewport,
},
});
}
return Promise.resolve({
result: {
value: mockState.viewport,
},
});
}
return Promise.resolve({});
};
return fn(send);
}),
appendCdpPath: vi.fn(),
fetchJson: vi.fn(),
isLoopbackHost: vi.fn(),
isWebSocketUrl: vi.fn(),
}));
vi.mock("./navigation-guard.js", () => ({
assertBrowserNavigationAllowed: vi.fn(),
withBrowserNavigationPolicy: vi.fn(() => ({})),
}));
const localProfile: ResolvedBrowserProfile = {
name: "openclaw",
cdpUrl: "http://127.0.0.1:18800",
cdpPort: 18800,
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
color: "#FF4500",
driver: "openclaw",
attachOnly: false,
};
let captureScreenshot: typeof import("./cdp.js").captureScreenshot;
beforeEach(async () => {
sentMessages.length = 0;
mockState.emulationCleared = false;
mockState.emulatedTab = true;
mockState.viewport = { w: 800, h: 600, dpr: 2, sw: 800, sh: 600 };
mockState.naturalViewport = { w: 1920, h: 1080, dpr: 1 };
vi.resetModules();
({ captureScreenshot } = await import("./cdp.js"));
});
describe("CDP screenshot params", () => {
it("viewport screenshot uses fromSurface: false without clip or emulation override", async () => {
await captureScreenshot({ wsUrl: "ws://localhost:9222/devtools/page/X", format: "png" });
const call = sentMessages.find((m) => m.method === "Page.captureScreenshot");
expect(call).toBeDefined();
expect(call!.params).toMatchObject({
format: "png",
fromSurface: false,
captureBeyondViewport: true,
});
expect(call!.params).not.toHaveProperty("clip");
const emulationCalls = sentMessages.filter(
(m) => m.method === "Emulation.setDeviceMetricsOverride",
);
expect(emulationCalls).toHaveLength(0);
});
it("fullPage on emulated tab: clears, detects drift, re-applies saved emulation", async () => {
mockState.emulatedTab = true;
await captureScreenshot({
wsUrl: "ws://localhost:9222/devtools/page/X",
format: "png",
fullPage: true,
});
const setCalls = sentMessages.filter((m) => m.method === "Emulation.setDeviceMetricsOverride");
expect(setCalls.length).toBe(2);
// Expand: uses saved DPR, mobile defaults to false
expect(setCalls[0]!.params).toMatchObject({
width: 1200,
height: 3000,
deviceScaleFactor: 2,
mobile: false,
});
// Clear is called first in the finally block
const clearCall = sentMessages.find((m) => m.method === "Emulation.clearDeviceMetricsOverride");
expect(clearCall).toBeDefined();
// Viewport drifted after clear → re-apply saved dimensions
expect(setCalls[1]!.params).toMatchObject({
width: 800,
height: 600,
deviceScaleFactor: 2,
mobile: false,
screenWidth: 800,
screenHeight: 600,
});
});
it("fullPage on non-emulated tab: clears and does NOT re-apply emulation", async () => {
mockState.emulatedTab = false;
mockState.viewport = { w: 1920, h: 1080, dpr: 1, sw: 1920, sh: 1080 };
mockState.naturalViewport = { w: 1920, h: 1080, dpr: 1 };
await captureScreenshot({
wsUrl: "ws://localhost:9222/devtools/page/X",
format: "png",
fullPage: true,
});
const setCalls = sentMessages.filter((m) => m.method === "Emulation.setDeviceMetricsOverride");
// Only the expand call — no re-apply after clear
expect(setCalls).toHaveLength(1);
const clearCall = sentMessages.find((m) => m.method === "Emulation.clearDeviceMetricsOverride");
expect(clearCall).toBeDefined();
});
it("fullPage viewport dimensions never shrink below current innerWidth/Height", async () => {
await captureScreenshot({ wsUrl: "ws://localhost:9222/devtools/page/X", fullPage: true });
const expandCall = sentMessages.find((m) => m.method === "Emulation.setDeviceMetricsOverride");
expect(expandCall).toBeDefined();
expect(Number(expandCall!.params!.width)).toBeGreaterThanOrEqual(800);
expect(Number(expandCall!.params!.height)).toBeGreaterThanOrEqual(600);
});
});
describe("shouldUsePlaywrightForScreenshot routing", () => {
it("returns false for a normal viewport screenshot with wsUrl", () => {
expect(shouldUsePlaywrightForScreenshot({ profile: localProfile, wsUrl: "ws://x" })).toBe(
false,
);
});
it("returns true when wsUrl is missing", () => {
expect(shouldUsePlaywrightForScreenshot({ profile: localProfile })).toBe(true);
});
it("returns true when ref is specified", () => {
expect(
shouldUsePlaywrightForScreenshot({ profile: localProfile, wsUrl: "ws://x", ref: "btn-1" }),
).toBe(true);
});
it("returns true when element is specified", () => {
expect(
shouldUsePlaywrightForScreenshot({
profile: localProfile,
wsUrl: "ws://x",
element: "#submit",
}),
).toBe(true);
});
});

View File

@@ -66,17 +66,50 @@ export async function captureScreenshot(opts: {
return await withCdpSocket(opts.wsUrl, async (send) => {
await send("Page.enable");
let clip: { x: number; y: number; width: number; height: number; scale: number } | undefined;
// For full-page captures, temporarily expand the viewport to the content
// size so the entire page is within the viewport bounds. We save the
// current viewport state and restore it after capture so pre-existing
// device emulation (mobile width, DPR, touch) is not lost.
let savedVp: { w: number; h: number; dpr: number; sw: number; sh: number } | undefined;
if (opts.fullPage) {
const metrics = (await send("Page.getLayoutMetrics")) as {
cssContentSize?: { width?: number; height?: number };
contentSize?: { width?: number; height?: number };
};
const size = metrics?.cssContentSize ?? metrics?.contentSize;
const width = Number(size?.width ?? 0);
const height = Number(size?.height ?? 0);
if (width > 0 && height > 0) {
clip = { x: 0, y: 0, width, height, scale: 1 };
const contentWidth = Number(size?.width ?? 0);
const contentHeight = Number(size?.height ?? 0);
if (contentWidth > 0 && contentHeight > 0) {
const vpResult = (await send("Runtime.evaluate", {
expression:
"({ w: window.innerWidth, h: window.innerHeight, dpr: window.devicePixelRatio, sw: screen.width, sh: screen.height })",
returnByValue: true,
})) as {
result?: {
value?: { w?: number; h?: number; dpr?: number; sw?: number; sh?: number };
};
};
const v = vpResult?.result?.value;
const currentW = Number(v?.w ?? 0);
const currentH = Number(v?.h ?? 0);
savedVp = {
w: currentW,
h: currentH,
dpr: Number(v?.dpr ?? 1),
sw: Number(v?.sw ?? currentW),
sh: Number(v?.sh ?? currentH),
};
// mobile: false is the safe default — CDP provides no way to query
// the active mobile flag, and inferring from navigator.maxTouchPoints
// would false-positive on touch-enabled desktops.
await send("Emulation.setDeviceMetricsOverride", {
width: Math.ceil(Math.max(currentW, contentWidth)),
height: Math.ceil(Math.max(currentH, contentHeight)),
deviceScaleFactor: savedVp.dpr,
mobile: false,
screenWidth: savedVp.sw,
screenHeight: savedVp.sh,
});
}
}
@@ -84,19 +117,55 @@ export async function captureScreenshot(opts: {
const quality =
format === "jpeg" ? Math.max(0, Math.min(100, Math.round(opts.quality ?? 85))) : undefined;
const result = (await send("Page.captureScreenshot", {
format,
...(quality !== undefined ? { quality } : {}),
fromSurface: true,
captureBeyondViewport: true,
...(clip ? { clip } : {}),
})) as { data?: string };
try {
// fromSurface: false avoids a Chromium compositor bug where cross-origin
// image textures are lost when fromSurface: true + captureBeyondViewport: true
// extends the capture surface (see https://issues.chromium.org/40760789).
const result = (await send("Page.captureScreenshot", {
format,
...(quality !== undefined ? { quality } : {}),
fromSurface: false,
captureBeyondViewport: true,
})) as { data?: string };
const base64 = result?.data;
if (!base64) {
throw new Error("Screenshot failed: missing data");
const base64 = result?.data;
if (!base64) {
throw new Error("Screenshot failed: missing data");
}
return Buffer.from(base64, "base64");
} finally {
if (savedVp) {
// Clear the temporary viewport expansion first. If the tab had
// prior device emulation the clear will change the viewport back to
// the browser's natural dimensions — detect that and re-apply the
// saved emulation so the tab's original state is preserved.
await send("Emulation.clearDeviceMetricsOverride").catch(() => {});
try {
const postResult = (await send("Runtime.evaluate", {
expression:
"({ w: window.innerWidth, h: window.innerHeight, dpr: window.devicePixelRatio })",
returnByValue: true,
})) as { result?: { value?: { w?: number; h?: number; dpr?: number } } };
const p = postResult?.result?.value;
if (
Number(p?.w) !== savedVp.w ||
Number(p?.h) !== savedVp.h ||
Number(p?.dpr) !== savedVp.dpr
) {
await send("Emulation.setDeviceMetricsOverride", {
width: savedVp.w,
height: savedVp.h,
deviceScaleFactor: savedVp.dpr,
mobile: false,
screenWidth: savedVp.sw,
screenHeight: savedVp.sh,
});
}
} catch {
// Best-effort restoration; ignore failures in the cleanup path.
}
}
}
return Buffer.from(base64, "base64");
});
}