Automation
The automation server exposes runtime state and accepts commands via a file-based protocol. A running app publishes an accessibility snapshot — every widget with its id, role, name, bounds, and state — and accepts scripted clicks, keys, drags, and assertions against it. Use it for integration testing, CI smoke tests, inspecting running apps, and letting AI agents verify their own UI work.
Enabling automation
Build with the automation flag (native verbs forward -D flags to the underlying zig build):
native build -Dautomation=trueIn your runner, pass an automation.Server to RuntimeOptions:
const server = native_sdk.automation.Server.init(io, ".zig-cache/native-sdk-automation", "My App");
var runtime = native_sdk.Runtime.init(.{
.platform = my_platform,
.automation = server,
});The default directory is .zig-cache/native-sdk-automation.
File protocol
When the runtime publishes a snapshot, it writes these files to the automation directory:
| File | Description |
|---|---|
snapshot.txt | Runtime state: source kind, window metadata, native/WebView metadata including role, accessibility label, text, and focus state, ready=true/false, and markup_watch=armed|off in the header — whether the markup hot-reload watch is armed (only in builds where the app wired .markup with a watch_path and io, or registered compiled fragments through fragment_watch — i.e. Debug dev builds) |
accessibility.txt | Accessibility tree summary with native view roles and accessible names. An explicit label= REPLACES the visible text as the accessible name — snapshot greps and screen readers see the label, not the text, so don't label an element whose visible text your assertions grep for. |
windows.txt | Window list: window @w{id} "{title}" focused={bool} per line |
screenshot-<view-label>.png | Deterministic PNG of a gpu_surface view, rendered through the CPU reference renderer on screenshot <view-label> [scale] |
command-<n>.txt | Command queue: one entry per command, written by the CLI, consumed oldest-first by the runtime (which deletes the entry as its consumption ack) |
bridge-response.txt | JSON response from the last bridge command |
provenance.txt | Response to the last provenance command: where a live widget was authored (file, byte span, line:column, template chain, iteration keys) |
Commands
The runtime watches the command queue and processes these actions:
| Action | Description |
|---|---|
reload | Reload the WebView source |
wait | Block until the snapshot shows ready=true |
resize <width> <height> [<scale>] | Dispatch a main-window resize event and relayout native/WebView surfaces |
provenance <view-label> (<widget-id> | at <x> <y>) | Report where a live widget was authored into provenance.txt: markup file, byte span, line:column, template use-site chain, and iteration keys. Point queries hit-test view-local coordinates. |
bridge <json> | Send a bridge command with origin zero://inline |
menu-command <id> | Dispatch a menu command event for the main window |
native-command <id> [<view-label>] | Dispatch a native view command event for the main window |
widget-action <view-label> <widget-id> <action> [<value>] | Invoke a retained canvas widget action |
widget-click <view-label> <widget-id> | Dispatch pointer down/up at a retained canvas widget |
widget-hold <view-label> <widget-id> | Press-and-hold: pointer down, the reserved hold timer fired, then the suppressed release — the widget's on_hold Msg through the real gesture path |
widget-context-press <view-label> <widget-id> | Secondary click (right/ctrl-click): presents the widget's context menu, or dispatches on_hold immediately when the route has none |
widget-context-menu <view-label> <widget-id> <item-index> | Invoke a declared context-menu item by 0-based index (as the snapshot's context_menu=[...] lists them) — the selection dispatches as the same context_menu_action platform event a real pick produces; presentation is skipped because the OS menu's tracking loop cannot be driven. Refuses undeclared menus, out-of-range indices, separators, and disabled items by name |
widget-drag <view-label> <widget-id> <start-x-ratio> <end-x-ratio> [<start-y-ratio> <end-y-ratio>] | Dispatch pointer down/drag/up across a retained canvas widget |
widget-wheel <view-label> <widget-id> <delta-y> | Dispatch wheel input at a retained canvas widget |
widget-key <view-label> <key> [<text>] | Dispatch key input to the focused retained canvas widget |
shortcut <id> | Dispatch a shortcut command event for the main window |
tray-action <item-id> | Select a status-item dropdown row (ids from the snapshot's tray-item #id lines) |
focus <view-label> | Focus a native or WebView-backed view in the main window |
focus-next / focus-previous | Move focus through visible, enabled views in runtime order |
profile <on|off> | Toggle per-stage frame timing. While on, the snapshot carries a frame_profile line with rolling p50/p90/max microseconds per pipeline stage (rebuild, layout, reconcile, emit, a11y, plan, patch, encode, present, host_decode, host_draw) |
Commands queue as command-<n>.txt entries: each native automate <command> claims the next sequence number exclusively (rapid back-to-back invocations can never overwrite each other), the app consumes one entry per presented frame in strict arrival order, and deleting the entry is the consumption ack. The queue is bounded to a handful of entries — a writer finding it full retries until the app drains a slot and fails loudly (non-zero exit) if it never does. native automate <command> prints delivered <action> -> <dir> only after the app consumed its entry, so a dead or frozen app is always a loud failure, never a silently dropped command.
Widget verbs and screenshot address a gpu_surface view by its label across ALL open windows — snapshots enumerate every window's views and widgets, so a model-declared secondary window's canvas (a settings window) is drivable and capturable exactly like the main one, with no window argument.
CLI usage
The native automate subcommand interacts with the automation directory:
# Wait for the app to be ready (polls snapshot.txt for ready=true)
native automate wait
# Assert on the snapshot: each argument is a regex that must match
# (polls up to --timeout-ms, default 30000; --absent inverts)
native automate assert 'gpu_nonblank=true' 'role=button name="Reset"'
native automate assert --absent 'error event='
# List running automation-enabled apps
native automate list
# Dump the current snapshot
native automate snapshot
# Render a gpu_surface view to screenshot-main-canvas.png
native automate screenshot main-canvas
# Reload the WebView
native automate reload
# Resize the main window surface
native automate resize 900 640
# Dispatch command-source events
native automate menu-command app.refresh
native automate native-command app.refresh refresh-button
native automate shortcut app.refresh
# Drive retained canvas widgets
native automate widget-action canvas 2 press
native automate widget-click canvas 3
native automate widget-hold canvas 3 # press-and-hold (on_hold)
native automate widget-context-press canvas 3 # right-click (presents the menu)
native automate widget-context-menu canvas 3 1 # pick declared menu item 1
# Drive focus between native controls and WebViews
native automate focus refresh-button
native automate focus-next
native automate focus-previous
# Toggle per-stage frame timing; the snapshot then carries a
# frame_profile line (rolling p50/p90/max us per pipeline stage)
native automate profile on
native automate snapshot | grep -o 'frame_profile.*'
native automate profile off
# Send a bridge command and get the response
native automate bridge '{"id":"1","command":"native.ping","payload":{"source":"automation"}}'Testing with automation
The WebView and native-shell smoke build steps demonstrate full automation test flows:
- Build and start the app with
-Dautomation=true - Run
native automate waitto block until the app is ready - Run
native automate snapshotto verify window, source, and native/WebView metadata - Run
native automate resize ...,native automate bridge '...', focus traversal, or command-source actions such asnative automate native-command ... - Verify bridge responses, relayout bounds, focus state, and updated snapshots
zig build test-webview-smoke -Dplatform=macosWrite-back
Because views are data, automation can go the other way too: select a widget in the running app, jump to the markup that authored it, and write an edit back into the source file — the app's own hot-reload watch picks the change up and repaints.
The read half is provenance. Every markup-built widget's structural id maps back to its authored source, captured at view build time: the file, the node's byte span and line:column, the template instantiation chain (a widget inside a template reports both its definition site and every <use> that put it there), and the iteration keys that say which <for> row it is. Widgets built with the Zig builder report authored=zig honestly — write-back edits markup files only.
# Where does this widget come from? (id from the snapshot, or hit-test a point)
native automate provenance kanban-canvas 6624116744891006388
native automate provenance kanban-canvas at 760 30The write half is edit: typed, minimal-diff operations on the file the widget came from. An operation changes only bytes inside the target node's span — whitespace, comments, attribute order, and every other node survive byte for byte, proven by reparsing and diffing the parse trees before anything is written.
native automate edit kanban-canvas 6624116744891006388 set-text "Add task"
native automate edit kanban-canvas 6624116744891006388 set-attr variant secondary
native automate edit kanban-canvas 6624116744891006388 remove-attr variantEdits refuse rather than guess: a widget authored in Zig, a file that changed on disk since the app loaded it (the provenance response carries the loaded bytes' hash, so concurrent edits are never clobbered), or an edit that would fail markup validation all stop with a teaching error and leave the file untouched. A successful edit needs no reload command — the markup watch (MarkupOptions.watch_path, a dev/Debug feature) reloads within its poll interval, so native automate assert on the snapshot is the way to await the repaint.
zig build test-writeback-smoke (macOS) drives the whole loop against the kanban example: query provenance, flip the button label through the verb, assert the repaint, verify the byte-exact diff, and flip it back.
Custom directory
Pass a custom path to automation.Server.init():
const server = native_sdk.automation.Server.init(io, "/tmp/my-app-automation", "My App");The CLI reads from the default .zig-cache/native-sdk-automation unless you specify a directory via the automation subcommand.