Open a page in headless Chrome, run a few actions, write a PNG. For an agent or a person who wants to see a page of a local app without a browser window.
go install github.com/croaky/browse@main
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
browse -full -out tmp/index.png http://localhost:3000/
browse -steps -video tmp/search.gif http://localhost:3000/ 'type=#q:acme' wait=.resultsThe URL is an absolute http or https URL. browse refuses any
other scheme before Chrome starts, so file://localhost/etc/passwd
cannot put a local file into the PNG.
On success it prints each path it wrote, one per line, with the main
PNG last, and nothing else. On failure it prints why and exits 1.
The default path is <slug>.png in the
current directory, from the URL path: /help is help.png,
/companies/12 is companies-12.png, and / is index.png.
The first run downloads one pinned build of chrome-headless-shell
(about 100 MB) into the user cache directory. After that there is
nothing else: no Node, no driver, no library.
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.select=<selector>:<value>picks the option of the first match whose value, or else whose label, is the text, and firesinputandchange. Aclickcannot do it: the headless shell draws no option popup.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 or select argument divides the selector
from the text. A selector holds a colon in a pseudo-class, so write
that colon as \::
browse http://localhost:3000/ 'type=li\:nth-child(2):hello'The text needs no escape, because only the first colon divides:
type=#at:12:30 types 12:30.
Only wait waits. click, hover, type, and select act on the
element that is there now, and no match is an error at once. A step
that needs an element the page has still to render takes a wait
before it.
browse retries no action: a click that needs a retry needs a wait.
There is no scroll action. click and hover scroll their
target to the center of the viewport before they act, and scroll a
scroll box inside the page to reach it. So a capture of a page or a
box scrolled part way hovers an element that far down:
browse http://localhost:3000/report 'hover=tbody tr:nth-child(40)'One wait has -wait, five seconds by default, and the whole run has
-timeout. The budget is per wait, so one selector that never shows
stops that wait and leaves the run its time.
An action that fails names itself and the selector:
click=#missing: no element matches "#missing". A wait that fails
says how long it waited: wait=#box: no element matches "#box" and is visible after 5s.
An error can hold text from the page: a JavaScript exception, a tag
name, an option label, or a line of Chrome's stderr, where the
headless shell writes the page's console messages. browse escapes
each control character in that text as \x1b or \n and cuts it at
200 bytes. So a page cannot move the terminal cursor or write a false
browse: line on stderr. A run past -timeout reports the timeout,
not Chrome's stderr.
-width,-height: the viewport in CSS pixels, default 1280 by 900.-phone: 390 by 844 at two device pixels per CSS pixel, a touch screen, and an iPhone user agent, so a page built for a phone renders and behaves as it does on one.-widthor-heightbeside it changes one dimension.-full: capture the whole document, not the viewport.-out <path>: where to write the PNG.-steps: also write a PNG after each action, beside the main PNG.help.pngwith two actions giveshelp-1-click.pngandhelp-2-wait.png. An agent that checks a flow can read each step.-video <path.gif>: also write an animated GIF of the run, from the first paint to the last action, at real speed. It is for a person: an agent reads images, not motion.-chrome <path>: a Chrome binary, instead of the pinned headless shell.-wait <duration>: a limit for onewaitaction, default 5s.-timeout <duration>: a limit for the whole run, default 30s.
Most pages worth a screenshot are behind a login. browse takes
cookies 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_COOKIE is the shape of a Cookie header: name=value pairs
separated by semicolons, set for the URL's host. Chrome sends each
cookie to that host and no other.
There is no header variable. Chrome sends an extra header to every
host the page contacts: each third-party script, font, image, and
redirect target. So a bearer token in a header leaks to all of them.
A run with BROWSE_HEADER set stops and says to use BROWSE_COOKIE.
An app that wants an agent to see its pages writes a small wrapper
that mints a session for a development user, puts it in
BROWSE_COOKIE, and calls browse. This tool knows no app.
browse runs chrome-headless-shell from
Chrome for Testing,
at the version pinned in shell.go. The first run downloads it to
~/Library/Caches/browse/ on macOS or ~/.cache/browse/ on Linux and
says so on stderr. A pinned version means two laptops that install the
same browse render a page the same way. -chrome wins, then
BROWSE_CHROME, for a run that needs another binary.
Why not the Google Chrome that is installed: 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. --no-default-browser-check,
the default-browser feature flags, and a seeded profile do not stop
it. The headless shell has no browser UI and does not ask.
Each run starts the browser with a fresh temporary profile and removes it at exit. The run reads no cookie, history, or extension of the user's own profile and writes nothing to it.
The download has no checksum. Chrome for Testing publishes none, so a checksum here is one a person computes for each platform at each version, and a stale entry blocks a bump. The transfer is HTTPS from Google's bucket, a short read fails in the unzip, and the extracted tree moves into place in one rename. So an interrupted download leaves nothing for the next run to find.
The pin holds until a Chrome release breaks something this tool uses,
or until a page needs a newer renderer. TestBrowse is the guard: it
drives the real browser through a cookie, a pointer event, phone
emulation, and a PNG decode. A bump is its own commit, with a laptop
run of that test in the description.
The headless shell needs shared libraries that a server image often leaves out. On Ubuntu 24.04:
sudo apt-get install -y libatk1.0-0t64 libatk-bridge2.0-0t64 libxcomposite1 \
libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2t64 libatspi2.0-0t64Chrome's sandbox needs unprivileged user namespaces. Ubuntu 23.10 and
later let AppArmor restrict them, so the shell stops at start with
No usable sandbox!, and browse prints that line. Do not pass
--no-sandbox, and do not turn the restriction off for the whole
machine. Install the shell where only root can write, and allow user
namespaces for that path only:
v=153.0.8010.47 # shellVersion in shell.go
sudo mkdir -p /opt/chrome-headless-shell
curl -fsSLO "https://storage.googleapis.com/chrome-for-testing-public/$v/linux64/chrome-headless-shell-linux64.zip"
sudo unzip -q chrome-headless-shell-linux64.zip -d /opt/chrome-headless-shell
sudo mv /opt/chrome-headless-shell/chrome-headless-shell-linux64 /opt/chrome-headless-shell/$v
sudo tee /etc/apparmor.d/chrome-headless-shell >/dev/null <<'EOF'
abi <abi/4.0>,
include <tunables/global>
profile chrome-headless-shell /opt/chrome-headless-shell/*/chrome-headless-shell flags=(unconfined) {
userns,
}
EOF
sudo apparmor_parser -r /etc/apparmor.d/chrome-headless-shell
export BROWSE_CHROME=/opt/chrome-headless-shell/$v/chrome-headless-shellRoot extracts the zip inside the root-owned
/opt/chrome-headless-shell, so no other user can make a path that
the steps use. Do not extract into /tmp: another user can make the
directory there first and then own the shell.
The profile names the root-owned path, not the user cache. A profile for a path the user can write lets any program the user puts there use user namespaces. Chromium explains the trade-off in AppArmor user namespace restrictions.
-video uses Chrome's screencast. Chrome sends a PNG each time the
page paints, with a timestamp, and browse acks each frame. Each
frame shows until the next paint, so a page that does not change is
one frame held, and a sleep after a transition shows the transition
at its real speed. The first frame is the blank tab before the load.
An action returns before Chrome paints its result, so the screencast alone can stop before the last action shows. The last frame is a screenshot after the last action instead, which waits for a new paint as the PNG does. It holds one second before the GIF plays again. The GIF is the size of the PNG, and a screencast frame of another size scales to that width.
Each GIF frame has its own palette of 256 colors, with no dithering. Half are the colors the frame uses most, so the page's background, text, and borders keep their exact colors. The other half are the means of groups of similar colors among the rest of the pixels, so a small photo such as an avatar keeps its hue. A JPEG frame or a fixed palette such as Plan 9 puts a colored halo around text. Dithering adds noise that changes from frame to frame. A gradient or a large photo shows bands.
Headless Chrome draws no pointer, so a click or a hover shows only by what it changes on the page.
The DevTools Protocol client is about 250 lines in cdp.go. 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 nineteen 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. Nineteen methods by hand is smaller than the import, and
there is nothing to keep current.
Development happens on sockeye, a
self-hosted review and CI server, which holds in progress branches.
GitHub receives main so go install works.
MIT