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. The agent then shows 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, browse needs
no Node, driver, or library.
Why
CI checks that a CSS class is used and that a view renders. It does not check how a page appears.
An agent in my terminal can inspect a PNG, but it cannot capture a
signed-in page of a local app into an image. browse handles that step
without app-specific code.
Actions
Actions run in order after the page loads and before the screenshot:
click=<selector>clicks the first match.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.
The first colon in a type argument divides the selector from the
text. A colon in a pseudo-class is written \:, as in
'type=li\:nth-child(2):hello'.
Only wait waits. click, hover, and type act on the element
that is there now, and browse retries no action. One wait has a
limit of -wait, five seconds by default, and the whole run has
-timeout. The app's wrapper passes -wait through.
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. browse sends a click as a
pointer event through the DevTools Input domain rather than
element.click():
func (p *page) click(ctx context.Context, selector string) error {
pt, err := p.center(ctx, selector)
if err != nil {
return err
}
if err := p.mouse(ctx, "mouseMoved", pt, ""); err != nil {
return err
}
if err := p.mouse(ctx, "mousePressed", pt, "left"); err != nil {
return err
}
return p.mouse(ctx, "mouseReleased", pt, "left")
}
A handler that reads coordinates or listens for mousedown sees what a
person's click produces.
There is no scroll action. click and hover scroll their target to
the center of the viewport before they act, which reaches scroll boxes
inside the page.
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 and seals it the way the app's server
does. Then it 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 the wrapper seals a token out of
a copy of production only under a local secret, and the cookie 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. A change to the stylesheet has to be looked at on each. The wrapper names the three, so nobody has to remember 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.
-phone sets two device pixels per CSS pixel, touch support, and an
iPhone user agent through the DevTools Emulation domain.
The loop with an agent
The rule in the app's ui/AGENTS.md is one paragraph. It applies to
a change to the stylesheet or a template that CI cannot see. The agent
runs cmd/browse on each page the change touches, at all three
widths, and reads the PNGs. It puts the ones that show the change in
the reply and names the pages in the change description. A screenshot
shows only the pages and widths that the agent captured. So the agent
names them, and a reviewer knows what it did not see.
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 agent attaches the PNGs to a review comment on the change:
soc comment --image tmp/browse/help-phone.png --image tmp/browse/help.png < review.txt
The agent posts the comment before it removes the worktree, because
the PNGs live in the worktree's tmp/.
The agent starts the app's dev server in its own worktree in the background, and stops it after the screenshots. It stops only the process whose working directory is its worktree, so the server in my main checkout stays up.
Why the headless shell
Standard Google Chrome on macOS prompts to become the default browser
on launch. chrome-headless-shell has no browser UI, so it avoids
system dialogs and default browser prompts.
The download is about 100 MB. browse moves the extracted tree into
place with one rename so an interrupted download leaves nothing
half-installed. The pinned binary also ensures two machines render
pages identically.
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. browse uses a pipe rather than a
port, so two runs at once do not race for a port and nothing listens
on the machine:
toChromeR, toChromeW, err := os.Pipe()
fromChromeR, fromChromeW, err := os.Pipe()
cmd := exec.CommandContext(ctx, path,
"--headless",
"--remote-debugging-pipe",
"--user-data-dir="+dataDir,
"--no-first-run",
"--disable-background-networking",
"--hide-scrollbars",
fmt.Sprintf("--window-size=%d,%d", width, height),
"about:blank",
)
cmd.ExtraFiles = []*os.File{toChromeR, fromChromeW}
browse implements sixteen protocol methods directly. A library
such as chromedp adds generated bindings for the full protocol,
which adds tens of megabytes to the module cache and 15 MB to the
binary. Capturing a screenshot requires one call to
Page.captureScreenshot:
func (p *page) screenshot(ctx context.Context, full bool) ([]byte, error) {
params := map[string]any{"format": "png"}
var res struct {
Data string `json:"data"`
}
if err := p.call(ctx, "Page.captureScreenshot", params, &res); err != nil {
return nil, err
}
return base64.StdEncoding.DecodeString(res.Data)
}
The test drives the real browser against an httptest server. It
checks that the cookie and header arrive and that a click reveals a
box. It also checks 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 sockeye and mirror main to
GitHub, which go install resolves.