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:

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 ![](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 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.

← All articles