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:
click=<selector>clicks the first match with a real pointer event.hover=<selector>moves the pointer over the first match and leaves it there, so a:hoverrule is in effect.type=<selector>:<text>focuses the first match and types the text.wait=<selector>waits until a match is on the page and visible.sleep=<duration>waits a fixed time, for a transition the DOM does not signal.
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
, 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.