mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 02:10:24 +00:00
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:
201
extensions/browser/src/browser/cdp.screenshot-params.test.ts
Normal file
201
extensions/browser/src/browser/cdp.screenshot-params.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user