Verifying Tauri UIs Headlessly (CDP + IPC mock)
Verifying Tauri UIs Headlessly (CDP + IPC mock)
How to drive and screenshot a Tauri 2 + vite frontend end-to-end when the
dev machine's desktop is in active use (popping real windows fights the
user for focus and they'll close them mid-test). Worked out on AssettoCar
(2026-07); recipe lives in AssettoCar/.claude/skills/verify/SKILL.md.
Core idea
Run only the vite dev server, load it in headless Chrome, and inject a
window.__TAURI_INTERNALS__ mock before page load via CDP
Page.addScriptToEvaluateOnNewDocument. Feed the mock real payloads
dumped from the Rust backend (an #[ignore]d test that serializes probe
results to JSON) so the UI renders real data, not hand-typed fixtures.
The mock needs three members:
invoke(cmd, args)— switch on command name; routeplugin:dialog|opento a canned path (bypasses native dialogs entirely), record mutation commands' args onwindowfor assertions (e.g. whatgeneratewas sent).transformCallback(cb)— assignwindow['_' + id] = cb, return id (Tauri's event system delivers via these globals).metadata: { currentWebview: {label:'main'}, currentWindow: {label:'main'} }— required bygetCurrentWebview()/onDragDropEvent.
Simulate backend events (progress/log) by calling registered
plugin:event|listen handlers from the mock.
Driving
CDP over WebSocket (npm i ws --no-save, Node 20 lacks global WebSocket):
Runtime.evaluate (with awaitPromise) for state assertions,
Input.dispatchMouseEvent for trusted clicks (needed for autoplay/user
activation), Page.captureScreenshot for evidence — works fully
offscreen, no focus stolen.
Gotchas
- React StrictMode double-mounts effects; if the mock's
plugin:event|unlistenis a no-op, every event renders twice. Mock artifact, not an app bug — real unlisten removes the first listener. - A harness/background-task-owned
npm run tauri devdies when the task ends;Start-Process(detached) if the app must survive tool calls. - Automating the real app's native file dialogs (UIA/SendKeys) is flaky
and lands on the user's desktop — avoid; stub at the
plugin:dialog|openboundary instead. - WebView2 accepts
WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=9223for CDP against the real app when the desktop IS free; same driver works, but native dialogs still pop real windows. cargo run --examplelinking a tauri lib crate can fail with STATUS_ENTRYPOINT_NOT_FOUND on Windows; put dev utilities in#[ignore]d integration tests instead.