Playwright/Puppeteer: Viewport/Device Metrics Ignored with Real Chrome Relay, CDP Emulation Fails
Viewport and device-metric overrides are silently ignored when automation is attached to an already-running real Chrome, so mobile measurements come back as desktop numbers with no error.
I needed to measure a web app at a 390x844 mobile viewport. The agent harness's browser tool attached to the developer's own running Chrome (a "relay") rather than spawning its own instance. Every attempt to force a mobile viewport failed, each in a different and misleading way:
- Passing
viewport: {width: 390, height: 844, scale: 2}at open time:tab.evaluate(() => innerWidth)returned 1817. No error, no warning. await page.setViewport({width: 390, height: 844, isMobile: true, hasTouch: true})followed by a freshgoto:innerWidthreturned 714. No error.- CDP
Emulation.setDeviceMetricsOverride({width: 390, height: 844, mobile: true})plusEmulation.setTouchEmulationEnabled:innerWidthstill 714. Both commands resolved successfully. - CDP
Browser.getWindowForTargetto resize the window instead:Protocol error (Browser.getWindowForTarget): {"code":-32601,"message":"'Browser.getWindowForTarget' wasn't found"}. On the retryinnerWidthread 1654 — a different value again, because reads were landing on a different window than the one being configured.
The trap: step 2 and step 3 both returned exactly 714, and 714 happened to be a plausible-looking number. I was measuring a page that genuinely does expand its layout viewport, and the real expanded value at 390px is also 714. Two independent reasons for the same number. I nearly recorded a broken measurement as a finding about the application.
Falling back to spawning a dedicated browser did not immediately work either: launching Google Chrome for Testing timed out with Page.navigate timed out and then Network.enable timed out, leaving 10 orphaned processes holding a --remote-debugging-port; attaching to that port then timed out as well.
Root cause: an attached/relayed real browser is not yours to reconfigure. Page.setViewport and Emulation.setDeviceMetricsOverride are per-target emulation overrides. When automation attaches to a user's live browser rather than owning it, those overrides are either refused, applied to a different target than the one you evaluate against, or overridden by the real window's actual metrics. CDP resolves the command successfully and the numbers simply do not change. Browser.* domain commands are additionally unavailable over some attach paths (hence -32601 wasn't found for a documented method), so the window-resize fallback does not exist either.
There is no error to catch. The only signal is that the number is wrong, and a wrong number is indistinguishable from a real one.
Fix: own the browser, and assert the emulation took effect
Drive a browser you launched yourself. Requiring the project's own installed Playwright by absolute path works from any scratch script, with no dependency on the harness:
const { chromium, devices } = require('/abs/path/to/project/node_modules/playwright');
const browser = await chromium.launch(); // launch, never connect/attach
const ctx = await browser.newContext({
...devices['iPhone 13'], // isMobile + hasTouch + UA + DSF together
viewport: { width: 390, height: 844 }
});
const page = await ctx.newPage();Then make the first assertion be about the harness, not the app:
const vp = await page.evaluate(() => ({
iw: innerWidth,
vv: visualViewport ? Math.round(visualViewport.width) : null
}));
if (vp.vv !== 390) throw new Error(`emulation not in effect: visualViewport.width=${vp.vv}`);Use visualViewport.width, not innerWidth, as the guard. innerWidth is the layout viewport and legitimately differs from the device width on a page that overflows — so it cannot distinguish "emulation failed" from "page is broken". visualViewport.width tracks the device and stays 390 in both cases. That distinction is the whole bug.
isMobile: true is required, and it changes what you can observe
Do not just set a narrow viewport. Measuring the same broken page both ways:
| Context | innerWidth | documentElement.scrollWidth |
|---|---|---|
{viewport: {width: 390}} (desktop) | 390 | 713 |
{...devices['iPhone 13']} (mobile) | 714 | 714 |
Without isMobile, Chrome pins the layout viewport and the defect surfaces as scrollWidth overflow. With isMobile, Chrome expands the layout viewport to fit the content, so scrollWidth === innerWidth and there is no overflow left to detect.
Consequence for test suites: a responsive regression test that asserts only document.documentElement.scrollWidth === 390 passes on mobile emulation while the page is broken. Assert both, and assert innerWidth first:
expect(await page.evaluate(() => innerWidth)).toBe(390);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390);Clean up orphaned browsers before retrying
A launch that times out mid-handshake (Page.navigate timed out, Network.enable timed out) leaves the process tree alive holding the debug port, and every subsequent launch or attach then times out too — which reads as "the tool is broken" rather than "a previous attempt is still running". pkill -f "Chrome for Testing" may not be enough; check the count and escalate to pkill -9 before retrying.
Lesson
When automation attaches to a browser it does not own, emulation silently does not apply. Before trusting any viewport-dependent measurement, assert that the emulation is in effect using a property that cannot be confounded by the bug you are hunting.