cmd / browse

browse opens a page in headless Chrome, runs a few actions, and writes a PNG. I wrote it so an agent on my laptop can look at a page after it changes a stylesheet or a template, and show me what it saw.

go install github.com/croaky/browse@latest

browse http://localhost:3000/help
browse -phone http://localhost:3000/portfolio
browse http://localhost:3000/companies/12 click=.tab:nth-child(2) wait=.drawer.active

It prints the path it wrote and nothing else. The first run downloads one pinned build of chrome-headless-shell, Google's browser for automation, into the user cache directory. After that there is no Node, no driver, and no library.

Why

CI checks that a CSS class is used and that a view renders. Nothing in CI checks what a page looks like. A change to a stylesheet shipped on a reading of the diff, and its description said "open the pages", which nobody could check had happened.

I had seen the loop I wanted on a phone. I built sports.dancroak.com over iMessage with Instinct. I asked for a team page to be more compact. The assistant changed the templates, deployed, and sent a screenshot. I looked at the screenshot and asked whether each game needed a date header. I reviewed the visible outcome, not the code.

An agent in my terminal can read a PNG. What it could not do was get a signed-in page of a local app into one. browse is that half. It knows no app.

Actions

Actions run in order after the page loads and before the screenshot:

An action that fails names itself and the selector: click=#missing: no element matches "#missing". An agent reads that sentence and picks a different selector. A click is a pointer event through the DevTools Input domain rather than element.click(), so a handler that reads the event's coordinates or listens for mousedown sees what a person's click gives it.

Auth

Most pages worth a screenshot are behind a login. browse takes cookies and headers from the environment, not from flags, so a secret is not in a process list or a shell history:

BROWSE_COOKIE='session=abc123' browse http://localhost:3000/
BROWSE_HEADER='Authorization: Bearer abc123' browse http://localhost:3000/

Each app I want an agent to see has a small wrapper, cmd/browse, in its own repo. The wrapper reads a development user's session token from the app's local database, seals it the way the app's server does, puts the cookie in BROWSE_COOKIE, builds the URL from the checkout's port, and execs browse. The sealing function refuses any environment but development, so a token out of a copy of production is sealed only under a local secret and opens nowhere else.

The wrapper picks the user from git config user.email, so each laptop signs in as its owner with no setup. -as <initials> names another user.

Widths

The app I use this on most is read on a phone, a laptop, and a desktop monitor, and a change to the sheet has to be looked at on each. The wrapper names the three, so nobody remembers the numbers:

go run ./cmd/browse -phone /portfolio      # 390 by 844 at 2x, touch, a phone user agent
go run ./cmd/browse /portfolio             # 1280 by 900
go run ./cmd/browse -desktop /portfolio    # 1920 by 1080

The PNG is tmp/browse/<slug>[-phone|-desktop].png, so one page's three widths sit side by side and a later run replaces an earlier one.

-phone sets two device pixels per CSS pixel, a touch screen, and an iPhone user agent, through the DevTools Emulation domain. Width alone gave a page at half the text size it has on the device, and a page that branches on maxTouchPoints or (hover: none) took the desktop branch.

The loop with an agent

The rule in the app's ui/AGENTS.md is one paragraph. A change to the stylesheet or a template that CI cannot see: run cmd/browse on each page the change touches, at all three widths, read the PNGs, put the ones that show the change in the reply, and name the pages in the change description. A screenshot is a view, not a proof, so the agent says what it looked at, and a reviewer knows what was and was not seen.

Warp renders a local image an agent writes as ![](tmp/browse/help.png), so the reply reads like the iMessage thread: a sentence, a picture, my question, another picture. A shot takes about two seconds.

The first phone-width shot found a defect. In a drawer at 390 pixels, two fieldsets ran past the drawer's right edge while the inputs around them shrank to fit. The browser default for a fieldset is min-inline-size: min-content, so it never shrinks below its widest child, and a file input is wider than a phone drawer. The fix was one declaration. Nobody had opened that drawer on a phone.

Why the headless shell

The first version ran the Google Chrome installed in /Applications. On macOS, a fresh profile of it asks the system to make Chrome the default browser at every launch, and macOS shows a dialog for that. The unified log records the request as lsd: Unentitled request to set default handler for URL scheme, by Chrome's pid. --no-default-browser-check does not stop it. Disabling the five DefaultBrowser* features by name does not stop it. A seeded profile with the policy preference set to false does not stop it.

chrome-headless-shell has no browser UI, so it has no code that asks. browse pins one version and downloads it once, about 100 MB, and moves the extracted tree into place with one rename, so an interrupted download leaves nothing half-installed. The pin also means two laptops that install the same browse render a page the same way, which matters when two people compare screenshots.

Why no browser library

The DevTools Protocol client is about 250 lines. It speaks to Chrome over --remote-debugging-pipe: JSON messages on file descriptors 3 and 4, each ended by a NUL byte. A pipe rather than a port, so two runs at once do not race for a port and nothing listens on the machine.

The tool calls sixteen protocol methods. A library such as chromedp brings generated bindings for the whole protocol, tens of megabytes in the module cache and a binary about 15 MB larger, and it tracks Chrome releases. Sixteen methods by hand is smaller than the import, and there is nothing to keep current. A new method is one call with a map of parameters and a small result struct.

The test drives the real browser against an httptest server. It checks the cookie and header arrive, that a click reveals a box, that the phone run sees a touch screen and sends the phone user agent, and that the PNG has the expected size. It skips under -short, which is how CI runs it, so CI proves the parsers and a laptop proves the browser.

GitHub repo is a mirror

I develop browse on cibot and mirror main to GitHub, which go install resolves.

← All articles