Drives a Flutter web build in a persistent headless chromium (systemd-run unit, own profile, CDP port) through the semantics tree: tree, tap by label, text, key, scroll, wait, shot, console. Never touches the live display; attaches only to HeadlessChrome. The MCP server wraps the same Driver. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZ4sULGfyav7EEwQ6vhbfg
5.2 KiB
flutter_web_drive
Headless, LLM-friendly driver for Flutter web apps. An agent can navigate and
test a desktop app (a Flutter web build in a Chrome --app window, the way
kartka and todo ship) without touching the real screen, mouse or keyboard.
- A long-lived headless chromium runs as a
systemd-run --userunit with a persistent profile. It has to be a unit becausesetsidchildren die with the calling Bash scope. - Each CLI verb attaches over CDP, does one thing and exits. The exit code is the verdict. On failure it prints one stderr line naming the step that failed.
- Widgets are addressed through Flutter's semantics tree. Flutter paints
to a canvas, so the accessibility mirror (
[id^=flt-semantic-node-]) is the only addressable view. The driver switches it on by clickingflt-semantics-placeholder. The app'slib/needs no changes.
Spike result (2026-10-24, chromium 155, Flutter 3.47.8 CanvasKit)
--headless=new + CDP Page.captureScreenshot renders CanvasKit frames
correctly. (The old --headless --screenshot path was blank.) No Xvfb
is needed, and none is used.
Other findings from the spike:
- Semantics enabled once stay enabled across later CDP connections.
- Console messages and exceptions are replayed on
Runtime.enable/Log.enable, soconsoleworks from a fresh attach. - The viewport is smaller than
--window-size: 1400x1000 gives 1400x879.startreports the real viewport.
Safety
- Every attach reads
/json/version. It refuses any browser that is notHeadlessChrome(exit 4). For example, Steam's CEF listens on 9222 on this PC. Never point the driver at 9222. - The unit unsets
DISPLAY/WAYLAND_DISPLAY.--password-store=basickeeps chromium away from the desktop keyring, which could prompt on :0.--disable-extensionsdrops the Claude extension that chromium-flags.conf loads. startrefuses a CDP port that some other browser already answers on.- Secrets go through
text --stdin/text --file PATH(MCP:from_file), never argv.treeshows password fields as*.
CLI
export PYTHONPATH=~/src/utils
D="python3 -m flutter_web_drive --app myapp" # --app namespaces state/artefacts
$D start --url http://127.0.0.1:47392/ --profile ~/.local/share/myapp-agent/profile \
--cdp-port 9342 [--unit fwd-myapp] [--size 1400x1000]
$D tree [--filter TEXT] [--row LABEL] [--all]
$D tap "Browse" [--contains] # exact label or one line of it
$D tap-xy 40 100
$D text "hello" | $D text --stdin | $D text --file ~/.config/x/password
$D key Enter # Escape Tab Backspace Delete Space Arrow* Home End PageUp PageDown
$D scroll 0 600 [--at X Y]
$D wait "Sync now" [--timeout S<=50] [--contains] [--gone] [--flag current] [--row LABEL]
$D shot [OUT.png] [--min-colours 16] # prints "path WxH colours=N stddev=S"
$D eval "location.href"
$D console [--limit 20] [--warnings] [--fail-on-error]
$D stop
tree prints one line per node:
#5 button "Browse | Tab 2 of 4" [tap,current] @0,75 80x67
#73 textbox "Sync account email" = "me@example.com" [focused] @16,203 1368x48
[tags]:tap(tappable),current,selected,checked,mixed,pressed,expanded,disabled,focused.= valueis the field value,aria-valuenoworaria-description. For example, an ExpansionTile reads"Collapsed".--row LABELkeeps the nodes inside the row of that label, e.g. a chapter row's "Mark unread" button:wait "Mark unread" --row "Chapter 5".tapprefers tappable matches and says when there were several. When nothing matches, it lists what is tappable.
Exit codes: 0 ok, 1 the step's check failed (not found, timeout, page
exception, blank shot), 2 usage, 3 no driver browser (not started or
crashed), 4 the CDP port belongs to a non-headless browser.
Artefacts (screenshots, state.json) go to ~/data/claude-scratch/e2e/<app>/
(override with $FWD_ARTEFACTS), never to /tmp. A wait timeout saves
wait-timeout-*.png and names it in the error.
MCP server
mcp_server.py is a thin stdio server (mcp 2.x MCPServer). Its tools match
the verbs: start stop shot tree tap tap_xy text key scroll wait eval_js console, each taking app. Every tool is one Driver call, so no logic is
duplicated. Register it (user scope):
claude mcp add flutter-web-drive -s user -e PYTHONPATH=$HOME/src/utils \
-- python3 -m flutter_web_drive.mcp_server
Layout
| file | role |
|---|---|
cdp.py |
websocket session, page discovery, headless check |
semantics.py |
the JS snippets, Node, matching, row scoping, tree format |
actions.py / keys.py |
verbs on one attached page |
browser.py / state.py |
chromium argv, systemd-run unit, per-app state |
api.py |
Driver: the verbs the CLI and the MCP server share |
cli.py / mcp_server.py |
the two front ends |
Tests (tests/, 100 % branch, no network or browser) use a fake CDP page.
The real-browser check is a consumer flow, e.g.
~/src/kartka/tools/agent/flows/f0_smoke.sh.
PYTHONPATH=. python -m pytest -q flutter_web_drive/tests \
--cov=flutter_web_drive --cov-branch --cov-fail-under=100
ruff check flutter_web_drive && ruff format --check flutter_web_drive
pip install --user -r flutter_web_drive/requirements.txt