From a7e37127ce213f90b6a7b5e5334e10f0e33f8960 Mon Sep 17 00:00:00 2001 From: Thomas Bachem Date: Thu, 16 Jul 2026 18:13:31 +0200 Subject: [PATCH] fix: downscale viewport screenshots when no viewport is emulated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--screenshot-max-width`/`--screenshot-max-height` never applied to a plain viewport screenshot. `getSourceBox()` reads `page.viewport()` and bails when it is null, so no clip was computed and the capture came back at full size. `page.viewport()` is null in normal usage: the connect and launch paths both pass `defaultViewport: null`, and nothing sets a viewport unless `emulate` is called with one – `--viewport` sets the window bounds, and `resize_page` moves the window. Full page and element screenshots were unaffected because they take the other `getSourceBox()` branches. Fall back to the window's own `innerWidth`/`innerHeight`, which is the area a viewport screenshot already captures, so the clip only applies `scale` and the framing is unchanged. The existing coverage missed this because it calls `page.setViewport()` first, which is the one condition under which the flags worked. --- src/tools/screenshot.ts | 14 +++++++++++-- tests/tools/screenshot.test.ts | 37 ++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/tools/screenshot.ts b/src/tools/screenshot.ts index 9edaa6f01..9523126be 100644 --- a/src/tools/screenshot.ts +++ b/src/tools/screenshot.ts @@ -43,10 +43,20 @@ async function getSourceBox( return {x: 0, y: 0, width: dims.width, height: dims.height}; } const viewport = page.viewport(); - if (!viewport) { + if (viewport) { + return {x: 0, y: 0, width: viewport.width, height: viewport.height}; + } + // The browser is launched and connected with `defaultViewport: null`, so + // `page.viewport()` stays null until something emulates one. Fall back to the + // window's own dimensions, which is the area a viewport screenshot captures. + const dims = await page.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight, + })); + if (dims.width <= 0 || dims.height <= 0) { return undefined; } - return {x: 0, y: 0, width: viewport.width, height: viewport.height}; + return {x: 0, y: 0, width: dims.width, height: dims.height}; } function computeDownscaleClip( diff --git a/tests/tools/screenshot.test.ts b/tests/tools/screenshot.test.ts index a55e13a63..89ab098de 100644 --- a/tests/tools/screenshot.test.ts +++ b/tests/tools/screenshot.test.ts @@ -345,6 +345,43 @@ describe('screenshot', () => { }); }); + it('downscales viewport screenshot when no viewport is emulated', async () => { + const tool = screenshot({ + screenshotMaxWidth: 100, + } as ParsedArguments); + await withMcpContext(async (response, context) => { + const page = context.getSelectedMcpPage().pptrPage; + // No setViewport call here: the browser is launched and connected with + // `defaultViewport: null`, so this is what a page looks like unless the + // emulate tool has set a viewport. + assert.equal(page.viewport(), null); + await page.setContent( + html`
`, + ); + const source = await page.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight, + })); + + await tool.handler( + {params: {format: 'png'}, page: context.getSelectedMcpPage()}, + response, + context, + ); + + assert.equal(response.images.length, 1); + const buf = Buffer.from(response.images[0].data, 'base64'); + assert.equal(pngWidth(buf), 100); + // The window size comes from the environment rather than an emulated + // viewport, so allow a pixel of rounding slack on the derived height. + const expectedHeight = Math.round(source.height * (100 / source.width)); + assert.ok( + Math.abs(pngHeight(buf) - expectedHeight) <= 1, + `expected height ~${expectedHeight}, got ${pngHeight(buf)}`, + ); + }); + }); + it('downscales using the smaller scale when both max-width and max-height are set', async () => { const tool = screenshot({ screenshotMaxWidth: 400,