All articles

Testing 9 min read AgiCAD studio

Real Mobile Screenshots From a Server, Not a Resized Desktop Window

Resizing a desktop browser window reported success while the page never actually reflowed. Here is a headless Chromium script that renders a genuine mobile viewport and audits it.

A robot photographer at a server rack taking a picture of a mobile page inside a phone frame, beside an awkward squashed desktop window

We spent an afternoon auditing the mobile layout of one of our products and came away with a folder of screenshots that were all lies. The tooling we were driving reported every window resize as a success. The tab's window.innerWidth never changed once.

The symptom

The workflow looked reasonable: open the page in a headless browser, resize the window to a phone width, screenshot, move on. The resize call returned success. The screenshots came back looking like a phone-shaped crop of a desktop page - because that is exactly what they were.

The proof took one line. After each resize we read window.innerWidth and window.outerWidth from inside the page. Both were still the desktop values. The command had succeeded at changing something, but not the thing that makes a page reflow. The same failure came back in a later session on a different site, which is how we know it was the approach and not a bad afternoon.

This matters because the whole point of a mobile audit is the reflow. A media query that never fires, a flex row that never wraps, a nav that never collapses - none of those show up in a cropped desktop render. You get screenshots that look plausible and tell you nothing.

The fix: a real mobile context

Headless Chromium driven by Playwright can create a browsing context that genuinely is a phone, rather than a desktop window squeezed into one. The distinction is the context options: a viewport, a device scale factor, the mobile and touch flags, and an honest user agent. Set those at context creation and the page renders at window.innerWidth === 390, media queries fire, and @media (hover: hover) rules correctly do not.

Two more things belong in the same script, because once you have a real mobile render you may as well ask the page about itself. One page.evaluate() after load collects the entire audit in a single round trip: the overflow signal, every interactive element smaller than a 44-pixel tap target, and every position: fixed or sticky element with its rectangle so you can see what is going to overlap what.

On a server that already has Chrome installed and no Playwright browser download, point chromium.launch() at the system binary with executablePath rather than pulling down a second Chromium.

#!/usr/bin/env node
/**
 * Real mobile rendering audit: screenshots + layout report from headless Chromium.
 *
 *   node mobile-audit.js https://example.com/ [outdir]
 *
 * Exits 1 when the document actually scrolls sideways.
 */
const fs = require('fs');
const path = require('path');
const { chromium } = require('playwright');

const IPHONE_UA =
  'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 ' +
  '(KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1';

const DEVICE = {
  name: 'iphone-14-pro',
  viewport: { width: 390, height: 844 },
  deviceScaleFactor: 3,
  isMobile: true,
  hasTouch: true,
  userAgent: IPHONE_UA,
};

// Runs inside the page. Everything the audit needs, in one round trip.
function collect() {
  const doc = document.documentElement;
  const vw = window.innerWidth;

  const small = [];
  const selector = 'a, button, input, select, textarea, [role="button"], [onclick]';
  for (const el of document.querySelectorAll(selector)) {
    const r = el.getBoundingClientRect();
    if (r.width === 0 && r.height === 0) continue;       // not rendered
    if (r.width >= 44 && r.height >= 44) continue;
    small.push({
      tag: el.tagName.toLowerCase(),
      text: (el.textContent || '').trim().slice(0, 40),
      width: Math.round(r.width),
      height: Math.round(r.height),
    });
  }

  const pinned = [];
  for (const el of document.querySelectorAll('body *')) {
    const pos = getComputedStyle(el).position;
    if (pos !== 'fixed' && pos !== 'sticky') continue;
    const r = el.getBoundingClientRect();
    if (r.width === 0 && r.height === 0) continue;
    pinned.push({
      tag: el.tagName.toLowerCase(),
      cls: (el.className && el.className.toString
        ? el.className.toString() : '').slice(0, 60),
      position: pos,
      top: Math.round(r.top),
      height: Math.round(r.height),
    });
  }

  // Elements sticking out sideways, with the context needed to dismiss the
  // false positives: a clipping ancestor means the page does not scroll.
  const wide = [];
  for (const el of document.querySelectorAll('body *')) {
    const r = el.getBoundingClientRect();
    if (r.right <= vw + 1 && r.left >= -1) continue;
    if (r.width === 0 || r.height === 0) continue;
    let clipped = false;
    for (let p = el.parentElement; p; p = p.parentElement) {
      const s = getComputedStyle(p);
      if (s.overflowX === 'hidden' || s.overflowX === 'clip' ||
          s.overflow === 'hidden' || s.overflow === 'clip') { clipped = true; break; }
    }
    wide.push({
      tag: el.tagName.toLowerCase(),
      cls: (el.className && el.className.toString
        ? el.className.toString() : '').slice(0, 60),
      left: Math.round(r.left),
      right: Math.round(r.right),
      clippedByAncestor: clipped,
    });
  }

  return {
    url: location.href,
    innerWidth: vw,
    scrollWidth: doc.scrollWidth,
    // The only signal that means "this page scrolls sideways".
    horizontalOverflow: doc.scrollWidth > vw + 1,
    overflowBy: doc.scrollWidth - vw,
    tapTargetsUnder44: small,
    fixedOrSticky: pinned,
    elementsOutsideViewport: wide,
  };
}

async function main() {
  const url = process.argv[2];
  const outdir = process.argv[3] || 'mobile-audit';
  if (!url) {
    console.error('usage: node mobile-audit.js <url> [outdir]');
    process.exit(2);
  }
  fs.mkdirSync(outdir, { recursive: true });

  const launch = { headless: true };
  // Use the system Chrome when Playwright's bundled browser was never
  // downloaded -- common on a server that already has Chrome installed.
  if (fs.existsSync('/usr/bin/google-chrome')) {
    launch.executablePath = '/usr/bin/google-chrome';
  }

  let browser;
  let report;
  try {
    browser = await chromium.launch(launch);
    const context = await browser.newContext(DEVICE);
    const page = await context.newPage();
    await page.goto(url, { waitUntil: 'networkidle', timeout: 45000 });

    report = await page.evaluate(collect);
    report.device = DEVICE.name;

    const base = path.join(outdir, DEVICE.name);
    await page.screenshot({ path: base + '-viewport.png' });
    await page.screenshot({ path: base + '-fullpage.png', fullPage: true });
    report.screenshots = [base + '-viewport.png', base + '-fullpage.png'];

    fs.writeFileSync(path.join(outdir, 'report.json'),
      JSON.stringify(report, null, 2));
    console.log(JSON.stringify(report, null, 2));
  } finally {
    // Not optional. A browser left running keeps the stdout pipe open and a
    // supervising script piping through `tee` waits for an EOF that never comes.
    if (browser) await browser.close();
  }

  process.exit(report && report.horizontalOverflow ? 1 : 0);
}

main().catch((err) => { console.error(err); process.exit(2); });

The signal, and the false positives that drown it

The single most useful number in that report is document.documentElement.scrollWidth compared against window.innerWidth. If scrollWidth is larger, the page scrolls sideways on a phone, which users notice immediately and hate. Everything else is advisory; that one is a bug.

The tempting alternative - walk every element and flag any whose getBoundingClientRect() pokes outside the viewport - produces a report nobody reads. On our products almost every hit was one of two things. The first is a carousel: the "next slide" siblings sit off-canvas by design, inside a clipping ancestor. The second is an anti-bot honeypot field, deliberately parked at left: -9999px so a naive form filler finds it and a human never does.

Neither is a bug, and both are invisible to the user, because an ancestor with overflow: hidden clips them. So the script reports the out-of-viewport elements but annotates each with whether a clipping ancestor exists, and only horizontalOverflow sets the exit code. Here is the contact page of this site, which has exactly that honeypot:

$ node mobile-audit.js https://agicad.com/contact/ out-contact
  "innerWidth": 390,
  "scrollWidth": 390,
  "horizontalOverflow": false,
  "elementsOutsideViewport": [
    { "tag": "input", "cls": "", "left": -9978, "right": -9757,
      "clippedByAncestor": true }
  ]

An element 9,978 pixels to the left of the viewport, and the document still does not scroll sideways. That is the whole argument for using the document-level comparison as the signal.

And here is the real run against the site's home page, trimmed for length:

$ node mobile-audit.js https://agicad.com/ out
{
  "url": "https://agicad.com/",
  "innerWidth": 390,
  "scrollWidth": 390,
  "horizontalOverflow": false,
  "overflowBy": 0,
  "tapTargetsUnder44": [
    { "tag": "a",      "text": "Cookie Policy", "width": 90,  "height": 16 },
    { "tag": "button", "text": "Accept all",    "width": 344, "height": 42 },
    { "tag": "a",      "text": "About",         "width": 39,  "height": 17 }
  ],
  "fixedOrSticky": [
    { "tag": "div",    "cls": "",                "position": "fixed", "top": 493, "height": 343 },
    { "tag": "header", "cls": "nav",             "position": "fixed", "top": 0,   "height": 69 },
    { "tag": "div",    "cls": "scroll-progress", "position": "fixed", "top": 0,   "height": 2 }
  ],
  "elementsOutsideViewport": [],
  "device": "iphone-14-pro",
  "screenshots": ["out/iphone-14-pro-viewport.png", "out/iphone-14-pro-fullpage.png"]
}
$ echo $?
0

Note the tap targets. A 42-pixel-tall cookie button is two pixels short of the guideline and we can live with it; a 17-pixel-tall footer link in a dense list is a different conversation. The script's job is to hand us the list, not to decide.

Take both screenshots

Viewport-only and fullPage: true answer different questions. The full-page image is what you scroll through looking for broken sections. The viewport-only image is what actually decides whether a visitor sees your hero, or a cookie banner with a sticky header on top of it, in the first second - and that is the frame that matters on a landing page. Save both; they cost one extra call.

Two operational gotchas

A batch of ten or more page loads will outrun a short tool or CI step timeout, and a half-finished batch is worse than none because you cannot tell which pages were audited. Run the batch detached, write to a log, and poll the log. Do not wait synchronously on something that takes minutes.

The second one cost us an hour on a different project. A long-lived headless-browser helper that is not closed in a finally block survives the script, gets re-parented to PID 1, and keeps the inherited stdout pipe open. A supervising shell script piping through tee then hangs forever waiting for an EOF that never arrives - long after the command itself returned 0. The symptom is "the script finished but the terminal is stuck". Always close the browser in finally.

When not to do this

If you already have a real-device lab or a visual-regression service, use it. Headless Chromium at 390 pixels is a faithful rendering of a phone viewport; it is not a phone. It does not have the real Safari engine, the real touch keyboard shoving the viewport around, or the real network.

And if what you are chasing is a device-specific browser bug - something that only happens in Safari on iOS, or only with a particular Android WebView - this will not reproduce it, and an afternoon spent making it reproduce is an afternoon wasted. Borrow the device.

For everything else, which is most things, a scripted mobile render that runs on the server in twenty seconds beats a manual pass through devtools that nobody repeats. If you want this wired into a build for a web application of yours, tell us the URL. Start a project and we will tell you what it finds.

We ship and run our own products. We build yours the same way.

Start a project

Join the article list

We are collecting addresses now and will email new articles once the newsletter starts. No spam, unsubscribe any time.

Hour Blocks from $180reply within two business days Start a project