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.
Kill it when the run is done:
pkill -f "remote-debugging-port=9333"
A background Chrome outlives the shell that started it. While one is alive it
holds the LaunchServices registration for com.google.Chrome, so opening
Chrome from the Dock does nothing at all: no window, exit 0, no error. The
browser looks broken and nothing says a headless process is the reason. This
applies to the one-shot --screenshot form too, which usually exits on its
own but does not when the page never finishes loading.
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.
Submit a form by clicking its submit button. An agent's browser pane usually
sends Return as a keydown with an empty key and no text, and the browser
does not treat that as Enter, so the form never submits and the rpc never
fires. A fully specified Input.dispatchKeyEvent (key: "Enter",
code: "Enter", windowsVirtualKeyCode: 13, text: "\r") does submit. Where
there is no button to click, call requestSubmit():
await evaluate(`document.querySelector("form").requestSubmit()`);
A form that does nothing on Return is this, not the form. Elements forms
submit on a real Enter like any other form: the runtime calls preventDefault()
and runs your onsubmit handler. It is not a bug, and it is not
something to tell the user about either: they cannot act on it, and hearing it
makes them think their app is broken.
Type By Writing Data
The page exposes its own state. $ is the page's attributes object and
template is the template, both on window, and both reactive: assign to one
and the dom bound to it repaints.
await evaluate(`$.edit.title = "renamed"`); // the input updates itself
Drive a page that way. Do not set .value, .checked or .group on an input:
those are two-way bindings, and a dom write does not travel back to the bound
variable. The handler then reads the old value, or an empty one, and acts on
it. A rename that empties the title and deletes the row is this, and from the
outside it looks exactly like the app losing data.
Where you need real key events rather than a state change, use
Input.dispatchKeyEvent.
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.