Skip to content

Commit eb45dc8

Browse files
ScottN-PVclaude
andcommitted
fix(server): read a desktop-drawn page's size where page script cannot reach
A page can reassign devicePixelRatio, innerWidth, and innerHeight in its own world. The snapshot read them there, so a page could send a negative clip scale, which Chromium does not answer and which would hold the tab's capture lock, or ask for an arbitrarily tall capture. Read them in a named isolated world instead, where they keep the browser's values. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
1 parent 2a0ebb0 commit eb45dc8

4 files changed

Lines changed: 71 additions & 19 deletions

File tree

‎apps/server/src/preview/ServerBrowser.test.ts‎

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,17 +1037,17 @@ const reportPage = (
10371037
},
10381038
) => {
10391039
const { zoom = 1, ...reported } = page;
1040-
const evaluate = context.page.evaluate.getMockImplementation()!;
1041-
context.page.evaluate.mockImplementation(((script?: unknown) =>
1042-
typeof script === "string" && script.includes("devicePixelRatio")
1043-
? Promise.resolve(reported)
1044-
: evaluate()) as typeof evaluate);
1040+
const replies: Record<string, Record<string, unknown>> = {
1041+
"Page.getFrameTree": { frameTree: { frame: { id: "main" } } },
1042+
"Page.createIsolatedWorld": { executionContextId: 7 },
1043+
"Page.getLayoutMetrics": { cssVisualViewport: { pageX: 0, pageY: 0, zoom } },
1044+
};
10451045
for (const session of context.sessions) {
10461046
const send = session.send.getMockImplementation()!;
10471047
session.send.mockImplementation(async (method, input) =>
1048-
method === "Page.getLayoutMetrics"
1049-
? { cssVisualViewport: { pageX: 0, pageY: 0, zoom } }
1050-
: send(method, input),
1048+
method === "Runtime.evaluate" && (input as { contextId?: number })?.contextId === 7
1049+
? { result: { value: reported } }
1050+
: (replies[method] ?? send(method, input)),
10511051
);
10521052
}
10531053
};
@@ -1178,9 +1178,11 @@ it.live("keeps a headless tab's snapshot at its own render scale", () =>
11781178
input: {},
11791179
});
11801180
expect(captureClips(contexts[0]!)).toEqual([{ width: 1280, height: 800, scale: 0.5 }]);
1181-
expect(contexts[0]!.page.evaluate).not.toHaveBeenCalledWith(
1182-
expect.stringContaining("devicePixelRatio"),
1183-
);
1181+
expect(
1182+
contexts[0]!.sessions.flatMap((session) =>
1183+
session.send.mock.calls.map(([method]) => method),
1184+
),
1185+
).not.toContain("Page.createIsolatedWorld");
11841186
expect(snapshot.screenshot).toMatchObject({ width: 1280, height: 800 });
11851187
}),
11861188
).pipe(Effect.provide(layer)),

‎apps/server/src/preview/ServerBrowser.ts‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1279,16 +1279,12 @@ const make = Effect.gen(function* () {
12791279
*/
12801280
const snapshotRendering = async (tab: ServerTab) => {
12811281
if (!tab.desktop) return { renderScale: RENDER_SCALE };
1282-
const page = (await tab.page.evaluate(
1283-
"({ ratio: devicePixelRatio, width: innerWidth, height: innerHeight })",
1284-
)) as { readonly ratio: number; readonly width: number; readonly height: number };
1285-
const { cssVisualViewport } = await tab.cdp.send("Page.getLayoutMetrics");
1286-
const zoom = cssVisualViewport.zoom ?? 1;
1282+
const page = await ServerBrowserPage.readPageMetrics(tab.cdp);
12871283
return {
1288-
renderScale: page.ratio / zoom,
1284+
renderScale: page.ratio / page.zoom,
12891285
viewport: {
1290-
width: Math.round(page.width * zoom),
1291-
height: Math.round(page.height * zoom),
1286+
width: Math.round(page.width * page.zoom),
1287+
height: Math.round(page.height * page.zoom),
12921288
},
12931289
};
12941290
};

‎apps/server/src/preview/ServerBrowserPage.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,32 @@ describe("server browser snapshot size", () => {
291291
},
292292
);
293293

294+
it("reads the page's own ratio and viewport after page script reassigns them", async () => {
295+
const browser = await chromium.launch({ headless: true });
296+
try {
297+
const context = await browser.newContext({
298+
viewport: { width: 1280, height: 800 },
299+
deviceScaleFactor: 1.5,
300+
});
301+
const page = await context.newPage();
302+
const cdp = await context.newCDPSession(page);
303+
await page.setContent(
304+
"<script>devicePixelRatio = -1.5; innerHeight = 1e7; innerWidth = 99999;</script>",
305+
);
306+
expect(await page.evaluate("[devicePixelRatio, innerWidth, innerHeight]")).toEqual([
307+
-1.5, 99999, 1e7,
308+
]);
309+
expect(await ServerBrowserPage.readPageMetrics(cdp)).toEqual({
310+
ratio: 1.5,
311+
width: 1280,
312+
height: 800,
313+
zoom: 1,
314+
});
315+
} finally {
316+
await browser.close();
317+
}
318+
});
319+
294320
it("reports the captured image's size when the given viewport is a pixel off", async () => {
295321
const browser = await chromium.launch({
296322
headless: true,

‎apps/server/src/preview/ServerBrowserPage.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,34 @@ const pngSize = (base64: string) => {
164164
/** A page's viewport in device-independent pixels, the units a capture clip is given in. */
165165
type Viewport = { readonly width: number; readonly height: number };
166166

167+
/**
168+
* A page's display ratio and CSS viewport, and its zoom. The ratio and viewport are read in an
169+
* isolated world, since page script can reassign `devicePixelRatio`, `innerWidth`, and
170+
* `innerHeight` in its own.
171+
*/
172+
export const readPageMetrics = async (cdp: CDPSession) => {
173+
const { frameTree } = await cdp.send("Page.getFrameTree");
174+
const { executionContextId } = await cdp.send("Page.createIsolatedWorld", {
175+
frameId: frameTree.frame.id,
176+
// Chromium reuses a named world; an unnamed one is created again on each call.
177+
worldName: "t3-preview-metrics",
178+
});
179+
const { result } = await cdp.send("Runtime.evaluate", {
180+
expression: "({ ratio: devicePixelRatio, width: innerWidth, height: innerHeight })",
181+
contextId: executionContextId,
182+
returnByValue: true,
183+
});
184+
const { cssVisualViewport } = await cdp.send("Page.getLayoutMetrics");
185+
return {
186+
...(result.value as {
187+
readonly ratio: number;
188+
readonly width: number;
189+
readonly height: number;
190+
}),
191+
zoom: cssVisualViewport.zoom ?? 1,
192+
};
193+
};
194+
167195
// Scaled captures repaint live screencasts, so callers pause them. Clips use document offsets.
168196
export const captureViewport = async (
169197
page: Page,

0 commit comments

Comments
 (0)