Browser
elements man browser Read as markdownLook 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.