Manual Browser

Browser

elements man browser Read as markdown

Look at the route you changed. A green build says the code compiles, not that the page is right, and a screenshot costs a second. Chrome, Edge or any other Chromium build already on the machine does all of it: a still of a route, a phone-sized render, a click, and a measurement read back out of the page.

Do not install Playwright, Puppeteer or chromedriver, and do not report a layout as unverified because a browser automation library is missing. Look for a Chromium binary first, and only say you could not look if there is none.

A Still Of A Route

The app must be running (elements start, serving http://localhost:4000).

# Windows: chrome.exe or msedge.exe. Linux: google-chrome.
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
  --headless --disable-gpu --hide-scrollbars \
  --window-size=1440,900 --virtual-time-budget=3000 \
  --screenshot=/tmp/page.png "http://localhost:4000/your-route"

Then open /tmp/page.png.

Window Size Is Not Viewport Size

--window-size sizes the window, and the viewport is what is left after the platform takes its cut. On macOS with Chrome 152 the numbers come out like this:

Flag Viewport Image
--window-size=1440,900 1440x813 1440x900
--window-size=500,800 500x713 500x800
--window-size=390,844 500x757 390x844

The width floor is the platform's minimum window width, and the height loses the window's own chrome. At 390 the page is laid out at 500 and the capture is cropped to 390, so the image is a narrow desktop layout with its right edge cut off. Anything against that image is wrong twice over: a control parked past x=390 reads as missing, and the phone breakpoints never fired.

So --window-size is for desktop sizes, where being off by a scrollbar does not change a layout. For a phone, emulate the device.

Phone Widths

Start Chrome once with a debugging port, then drive it over the DevTools Protocol. Emulation.setDeviceMetricsOverride sets the viewport exactly, at the pixel ratio and the mobile flag a phone has.

"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
  --headless --disable-gpu --hide-scrollbars \
  --remote-debugging-port=9333 about:blank &

Do not pass --user-data-dir. Some builds hang on a cold profile.

The client is one file with no dependencies: Node ships a global WebSocket.

// shot.mjs: node shot.mjs <url> <out.png> [width] [height]
let [url, out, width = 390, height = 844] = process.argv.slice(2);

let target = await (await fetch("http://127.0.0.1:9333/json/new?about:blank", { method: "PUT" })).json();
let ws = new WebSocket(target.webSocketDebuggerUrl);
let waiting = new Map();
let onLoad;
let id = 0;

await new Promise((ok) => (ws.onopen = ok));

ws.onmessage = (e) => {
  let m = JSON.parse(e.data);

  if (m.id && waiting.has(m.id)) {
    waiting.get(m.id)(m.result);
    waiting.delete(m.id);
  }

  if (m.method === "Page.loadEventFired") {
    onLoad?.();
  }
};

function send(method, params = {}) {
  return new Promise((ok) => {
    waiting.set(++id, ok);
    ws.send(JSON.stringify({ id, method, params }));
  });
}

await send("Page.enable");
await send("Emulation.setDeviceMetricsOverride", {
  width: +width,
  height: +height,
  deviceScaleFactor: 2,
  mobile: +width < 768,
});

let loaded = new Promise((ok) => (onLoad = ok));
await send("Page.navigate", { url });
await loaded;

let shot = await send("Page.captureScreenshot", { format: "png" });
let fs = await import("node:fs/promises");
await fs.writeFile(out, Buffer.from(shot.data, "base64"));
ws.close();

node shot.mjs http://localhost:4000/ /tmp/phone.png 390 844 writes a 780x1688 image: 390x844 at deviceScaleFactor: 2, laid out at 390 the way the phone lays it out. Check 320 as well. It is the narrowest width still worth supporting and it is where a row of buttons gives up.

mobile: true honours the page's viewport meta, and Elements puts <meta name="viewport" content="width=device-width, initial-scale=1.0"> in every rendered page, so a route renders at the width you asked for.

Measure Instead Of Reading Pixels

The same connection evaluates JavaScript in the page, which answers layout questions that a screenshot only hints at.

let evaluate = async (expression) =>
  (await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true })).result.value;

// Horizontal overflow: 0 or the page scrolls sideways on a phone.
await evaluate(`document.documentElement.scrollWidth - document.documentElement.clientWidth`);

// Did the wordmark stay on the heading's line?
await evaluate(`document.querySelector("h1 .mark").getBoundingClientRect().top
              === document.querySelector("h1").getBoundingClientRect().top`);

An element whose right edge sits past the viewport is only overflow if nothing above it clips: check for an overflow: hidden ancestor before reporting it.

Clicking And Reading State Back

Click through a flow the same way, then assert on what the page became.

await evaluate(`document.querySelector("a.button.is-primary").click()`);
await new Promise((ok) => setTimeout(ok, 500));
await evaluate(`location.pathname`);          // "/signin"

Input.dispatchMouseEvent and Input.dispatchKeyEvent drive real input events where a synthetic click() is not enough, such as a control that reacts to pointerdown or to a keystroke.

Each call to /json/new opens another tab in the same browser, so every tab shares one cookie jar and one signed-in user. Live data between two different users needs two Chrome processes, each with its own --remote-debugging-port.

Related

  • build: the build loop and what a green build means.
  • tests: the automated half of the same check.