fix(lighthouse): restore the emulated viewport after a navigation audit (#3189)

Fixes #2968

After a `lighthouse_audit` in `navigation` mode, the page keeps the
emulated width and height but loses `devicePixelRatio`, the screen size
and the screen orientation, while every response still prints `Emulating
viewport: {...}`. Calling `emulate` again with the same viewport does
not repair it.

Cause: `restoreEmulation()` re-sent the same viewport. Lighthouse uses
its own CDP session, which replaces the page's device metrics override
and clears it when it detaches. Chrome ignores an override that is
identical to the last one the same session sent, so puppeteer's second
`setViewport` with the same values does nothing.

Change: when a viewport is configured, `restoreEmulation()` first sends
it with `width + 1`, then applies the real one through `emulate()`.
`isMobile` and `hasTouch` are unchanged between the two calls, so
puppeteer does not reload the page (`setViewport(null)` first would, I
checked).

How I checked it (GitHub Actions on a scratch repo, Chrome with the
AppArmor userns restriction off, base `6606011`):

- Probe on the real browser, mobile viewport 390x844x3, `navigation`
audit: before the audit `dpr 3, screen.width 390, portrait-primary`;
after the audit without the change `dpr 1, screen.width 3840 (host),
landscape-primary`; plain `restoreEmulation()` again: unchanged. With
the width + 1 step first: `dpr 3, screen.width 390, portrait-primary`
and a marker set on `window` survives, so no reload. `setViewport(null)`
first also repairs it but reloads the page (marker lost). `snapshot`
mode was not affected.
- New test `keeps the emulated viewport after a navigation audit` (real
browser) and an updated `restoreEmulation()` unit test, plus a test that
no viewport call is added when none was configured. Without the change 2
tests fail; with it `tests/McpPage.test.ts` and
`tests/tools/lighthouse.test.ts` pass (84 of 84).
- `eslint` and `prettier --check` on the three changed files: clean.

Not done: the full test suite; Windows and macOS. The `width + 1` step
briefly applies a viewport one pixel wider, which is invisible to the
agent but could trigger a `resize` event in the page.

---------

Co-authored-by: Musa Toktaş <mtoktas38@gmail.com>
This commit is contained in:
Musa ToktasandMusa Toktaş authored and GitHub committed 2026-10-16 11:48:51 +00:00
1 parent 382f4418ac
commit c777ece9a8
3 files changed
+64 -3

No files matched your search

+13
View File
@@ -1115,6 +1115,19 @@ export class McpPage implements ContextPage {
async restoreEmulation(): Promise<void> {
const currentSetting = this.emulationSettings;
const {viewport} = currentSetting;
if (viewport) {
// Lighthouse's CDP session replaces the device metrics override and
// clears it on detach, and Chrome ignores an override identical to the
// last one this session sent. A different width first makes the real
// call take effect without a reload (`isMobile` and `hasTouch` stay the
// same). This also runs after snapshot audits and fires a resize event,
// so do not remove it as redundant.
await this.pptrPage.setViewport({
...viewport,
width: Math.max(1, viewport.width - 1),
});
}
await this.emulate(currentSetting);
}
+21 -1
View File
@@ -777,8 +777,18 @@ describe('McpPage', () => {
await mcpPage.restoreEmulation();
sinon.assert.calledTwice(pptrPage.setViewport);
// A different width first, because Chrome ignores an override that is
// identical to the last one of this session (see restoreEmulation).
sinon.assert.calledThrice(pptrPage.setViewport);
sinon.assert.calledWithExactly(pptrPage.setViewport.secondCall, {
width: 399,
height: 400,
deviceScaleFactor: 1,
isMobile: false,
hasTouch: false,
isLandscape: false,
});
sinon.assert.calledWithExactly(pptrPage.setViewport.thirdCall, {
width: 400,
height: 400,
deviceScaleFactor: 1,
@@ -788,6 +798,16 @@ describe('McpPage', () => {
});
});
it('does not send a scratch viewport when none was configured', async () => {
const {mcpPage, pptrPage} = await createMcpPage();
await mcpPage.emulate({cpuThrottlingRate: 4});
pptrPage.setViewport.resetHistory();
await mcpPage.restoreEmulation();
sinon.assert.calledOnceWithExactly(pptrPage.setViewport, null);
});
it('re-applies previously configured network and cpu throttling emulation', async () => {
const {mcpPage, pptrPage} = await createMcpPage();
await mcpPage.emulate({
+30 -2
View File
@@ -28,8 +28,32 @@ describe('lighthouse', () => {
server.addHtmlRoute('/test', html`<div>Test</div>`);
await withMcpContext(async (response, context, args) => {
const page = context.getSelectedMcpPage().pptrPage;
const mcpPage = context.getSelectedMcpPage();
const page = mcpPage.pptrPage;
await page.goto(server.getRoute('/test'));
await mcpPage.emulate({
viewport: {
width: 390,
height: 844,
deviceScaleFactor: 3,
isMobile: true,
hasTouch: true,
isLandscape: false,
},
});
const readViewport = () =>
page.evaluate(() => ({
width: window.innerWidth,
height: window.innerHeight,
deviceScaleFactor: window.devicePixelRatio,
screenWidth: screen.width,
orientation: screen.orientation.type,
maxTouchPoints: navigator.maxTouchPoints,
}));
const emulated = await readViewport();
assert.equal(emulated.width, 390);
assert.equal(emulated.deviceScaleFactor, 3);
assert.ok(emulated.maxTouchPoints > 0);
await lighthouseAudit(args).handler(
{
@@ -37,12 +61,16 @@ describe('lighthouse', () => {
mode: 'navigation',
device: 'desktop',
},
page: context.getSelectedMcpPage(),
page: mcpPage,
},
response,
context,
);
// Lighthouse replaces and then clears the device metrics override, the
// emulated viewport must be back afterwards.
assert.deepEqual(await readViewport(), emulated);
const data = response.attachedLighthouseResult;
assert.ok(data);