CLI Reference
tauri-pilot is a command-line client that communicates with the tauri-plugin-pilot server running inside your Tauri application over a Unix socket.
Global Options
Section titled “Global Options”These options can be used with any command.
| Option | Description |
|---|---|
--socket <path> |
Explicit path to the Unix socket. Auto-detected if omitted. Env: TAURI_PILOT_SOCKET |
--window <label> |
Target a specific window by label. Env: TAURI_PILOT_WINDOW. Default: main, falls back to the first window by label |
--json |
Output JSON instead of human-readable text |
--rpc-timeout <secs> |
Seconds to wait for the app to answer before giving up (default 35). wait and watch add their own --timeout on top. Env: TAURI_PILOT_RPC_TIMEOUT |
Socket Auto-Detection
Section titled “Socket Auto-Detection”The CLI picks the socket in this order and stops at the first match:
--socket <path>, the explicit flag. On Windows this is a Named Pipe path such as\\.\pipe\tauri-pilot-{identifier}.$TAURI_PILOT_SOCKET, the environment variable.- Linux and macOS: the newest
tauri-pilot-*.sockin$XDG_RUNTIME_DIR, then the newest in/tmpwhen$XDG_RUNTIME_DIRis unset, empty or holds no live socket you own. - Windows: the newest entry in
%LOCALAPPDATA%\tauri-pilot\instances\*.jsonwhose process is still running. Each entry names the app’s Named Pipe.
On Linux and macOS, “newest” means the most recent modification time among the sockets you own that accept a connection, so a socket left behind by a crashed app is skipped. When neither directory has one, the command fails with No tauri-pilot socket found. Is a Tauri app running?. On Windows, “newest” means the latest created_at in the instance file, and an entry whose process has exited is skipped.
The plugin puts its socket at $XDG_RUNTIME_DIR/tauri-pilot-{identifier}.sock when that directory is private, and in /tmp otherwise. See Socket path. Android apps listen on an abstract socket that auto-detection cannot see; forward it with ADB and set TAURI_PILOT_SOCKET (see Android via ADB).
Window Targeting
Section titled “Window Targeting”The --window <label> option (or TAURI_PILOT_WINDOW env var) selects which window all commands operate on:
tauri-pilot --window settings snapshot # snapshot the settings windowtauri-pilot click @e3 --window main # click in the main windowTAURI_PILOT_WINDOW=settings tauri-pilot snapshotIf --window is not specified, the CLI targets the main window and falls back to the first window by label. If the specified window label does not exist, the command exits with an error that lists the labels the app does have, so a typo does not cost a windows call.
The two failures carry different codes in error.data.error:
| Code | JSON-RPC code | When | Retry? |
|---|---|---|---|
WINDOW_NOT_FOUND |
-32602 |
--window names no window; available_windows lists the real labels |
No, fix the label |
NO_WEBVIEW |
-32603 |
No --window, and the app still had no window after waiting 3 s for its first one |
Yes, the app may still be starting |
A command never waits for a window named by --window: an unknown label fails at once.
Target Syntax
Section titled “Target Syntax”Many commands accept a <target> argument that identifies a DOM element. Three formats are supported:
| Format | Example | Description |
|---|---|---|
| Element ref | @e1 or e1 |
Reference from the last snapshot call; the @ is optional |
| CSS selector | #submit-btn or .class |
Standard CSS selector |
| Coordinates | 100,200 |
Raw x,y screen coordinates |
Note: Element refs (
@e1,@e2, …) are reset on everysnapshotcall. Always take a fresh snapshot before using refs.
Note: A bare
e<digits>is always read as a ref, never as a CSS type selector. A page that really has an unknown<e12>tag must write that selector another way, e.g.:is(e12)orwait --selector e12.
Commands
Section titled “Commands”Health check. Verifies the plugin server is reachable and responding.
tauri-pilot pingExample:
$ tauri-pilot ping✓ okA successful ping means the socket answers, not that a window is ready. The
plugin starts listening during setup, before Tauri creates the app’s first
window, so for about a second at startup ping and windows succeed while no
window exists yet. A command without --window sent in that gap waits up to
3 s for the first window, then fails with error.data.error = "NO_WEBVIEW"
(see Window Targeting).
Start a Model Context Protocol server over stdio. MCP-compatible agents can use this server to call tauri-pilot tools natively instead of spawning a CLI process for each interaction.
tauri-pilot mcpConfiguration:
{ "mcpServers": { "tauri-pilot": { "command": "tauri-pilot", "args": ["mcp"] } }}Use global flags before mcp to pin the server to a socket or default window:
{ "mcpServers": { "tauri-pilot": { "command": "tauri-pilot", "args": ["--socket", "/run/user/1000/tauri-pilot-com.myapp.dev.sock", "--window", "main", "mcp"] } }}The path is the default on a Linux desktop: replace 1000 with your user ID (id -u) and com.myapp.dev with your app’s identifier. On macOS, where $XDG_RUNTIME_DIR is usually unset, use /tmp/tauri-pilot-com.myapp.dev.sock. On Windows, use the Named Pipe, with each backslash doubled in JSON: "\\\\.\\pipe\\tauri-pilot-com.myapp.dev".
The MCP server exposes tools for the CLI’s app-facing commands, including
snapshot, diff, click, fill, type, press, select, check, scroll,
drag, drop, text, html, value, attrs, eval, ipc, screenshot,
navigate, url, title, wait, watch, logs, network, storage_*
(storage_get, storage_set, storage_list, storage_delete,
storage_clear),
forms, assert_*, record_*, replay, and run.
pilot.screenshot returns the PNG as one MCP image content block
(mimeType: "image/png", base64 data without the data: prefix) and no
structured content; errors keep the usual error fields. On long pages pass
selector: a full-page capture can exceed a client’s image size limit.
pilot.run executes a declarative TOML scenario. Pass path to a .toml file
or inline content (not both), optionally set fail_fast to override the file,
and read the JSON report (ok, counts, summary, steps). A failed step also
carries the absolute failure screenshot as screenshot, or the reason it could
not be written as screenshot_error. Unless screenshots_dir says otherwise,
screenshots go to an owner-only (0700) tauri-pilot-failures-<uid> directory
under $XDG_RUNTIME_DIR, or under the system temp directory when
$XDG_RUNTIME_DIR is unset or not private; on Windows temp_dir() is already
per-user, so the plain tauri-pilot-failures name is used there. The MCP
server’s working directory belongs to whichever client spawned it, so it is not
a useful default, and a shared /tmp path would hand every other user on the
host whatever the screenshots happen to show. A finished run
including failed steps is a successful tool result with ok false; only parse,
step-key, I/O, connect, and timeout failures are tool errors. Step keys are
checked against the table under run, and drop and ipc are not scenario
actions, so they fail there as unknown actions. The tool also returns
INVALID_PARAMS for an empty [[step]] list, eval steps unless
TAURI_PILOT_MCP_ENABLE_DANGEROUS_TOOLS is set, javascript: navigate URLs,
and screenshot steps that set path. The CLI example
docs/examples/login-flow.toml includes a screenshot path and cannot be run
over MCP as written. JUnit XML is not written; the JSON report is the output.
The snapshot, diff and forms tools return the app’s JSON as --json
does: password values are raw, flagged "sensitive": true on snapshot
elements, and land in the agent’s transcript. Only the CLI’s text output masks
them.
The server starts even if no Tauri app is currently running. Each tool call
resolves and connects to the tauri-pilot Unix socket lazily, using --socket,
TAURI_PILOT_SOCKET, or the normal socket auto-detection rules.
windows
Section titled “windows”List all open windows with their label, URL, and title. A window with no
native title, which is every window on Android and iOS, has no title field;
tauri-pilot title reads the page’s document.title.
tauri-pilot windowsExample:
$ tauri-pilot windowsmain http://localhost:1420/dashboard PR Dashboardsettings http://localhost:1420/settings Settingsabout http://localhost:1420/about AboutJSON-RPC example:
// Request{"jsonrpc":"2.0","id":1,"method":"windows.list","params":{}}
// Response{"jsonrpc":"2.0","id":1,"result":{"windows":[ {"label":"main","url":"http://localhost:1420/dashboard","title":"PR Dashboard"}, {"label":"settings","url":"http://localhost:1420/settings","title":"Settings"}, {"label":"about","url":"http://localhost:1420/about","title":"About"}]}}snapshot
Section titled “snapshot”Capture the current accessibility tree of the WebView and assign stable element refs (e1, e2, …).
tauri-pilot snapshot [OPTIONS]Options:
| Option | Description |
|---|---|
-i, --interactive |
Only include interactive elements (native controls plus draggable, contenteditable, onclick, and tabindex hosts) |
-s, --selector <sel> |
Scope the snapshot to the subtree matching this CSS selector |
-d, --depth <n> |
Maximum tree depth to traverse |
--save <file> |
Save the snapshot to a JSON file for later comparison with diff --ref |
Note on --save with --json: the saved file holds the unmodified RPC payload ({"elements":[…],"options":{…}}) so it can be fed straight back into diff --ref. The --json payload printed to stdout additionally embeds a "path" field ({"elements":[…],"options":{…},"path":"<file>"}) so callers piping into jq / python -c 'json.load(sys.stdin)' can recover the saved location without parsing stderr. The two shapes are intentionally different.
Example:
$ tauri-pilot snapshot --interactivee1 heading "PR Dashboard"e2 textbox "Search PRs" value=""e3 button "Refresh"JSON-RPC example:
// Request{"jsonrpc":"2.0","id":1,"method":"snapshot","params":{"interactive":true}}
// Response{"jsonrpc":"2.0","id":1,"result":{"elements":[ {"ref":"e1","role":"heading","name":"PR Dashboard","depth":0}, {"ref":"e2","role":"textbox","name":"Search PRs","depth":1,"value":""}, {"ref":"e3","role":"button","name":"Refresh","depth":1}],"options":{"interactive":true,"selector":null,"depth":null}}}options records the capture options so diff can refuse a reference taken with different ones.
An element’s name comes from, in order: aria-label, aria-labelledby, alt on images, its <label> (wrapping or for=, without the text of any nested <select> or <textarea> or of hidden parts: hidden, aria-hidden="true", display: none, visibility: hidden; a labelled button adds its own text after the label’s), title then placeholder on text inputs and textareas, placeholder on <select>, its text content (never for <select>, whose text is its options, or for a textbox, whose text is its value: <textarea>, a contenteditable host, role="textbox" or role="searchbox"), then title. A label that is only a visual sibling, with no for, does not name the control.
A textbox that is not a form control (a contenteditable host, role="textbox" or role="searchbox") reports its text, whitespace collapsed, as its value, so a rich-text editor’s content shows up in the snapshot and in diff. The human-readable tree cuts a value after 50 characters, like a name, and ends it with …; --json, --save files, MCP results, diff, value and assert value keep the full value.
Form controls with their own disabled attribute carry disabled, and readonly fields carry readonly ("disabled": true and "readonly": true in --json): a readonly <input> or <textarea>, or an element whose role supports aria-readonly (textbox, searchbox, combobox and the like) with aria-readonly="true". A control disabled only by a parent <fieldset> or by aria-disabled carries no flag, though actions refuse it. An input type on which HTML ignores readonly (checkbox, radio, range, color, file, and the button types) carries no readonly flag. diff reports either flag changing.
Password inputs display value=[redacted] in human-readable output, like forms. The raw value stays in --json mode, in --save files and in MCP results, where the element carries "sensitive": true. value @ref still reads it.
The sensitive flag is set by the app’s bridge, so masking needs both the CLI and the app’s tauri-plugin-pilot at this version or later; they need not match each other. Against an app built with an older plugin, snapshot prints the password in clear.
Compare the current page state with a previous snapshot and show only the differences. Massive token savings for AI agents that currently re-read the entire tree after each interaction.
tauri-pilot diff [OPTIONS]Options:
| Option | Description |
|---|---|
--ref <file> |
Diff against a saved snapshot file instead of the last in-memory snapshot |
-i, --interactive |
Only include interactive elements. Must match the reference snapshot, or diff refuses |
-s, --selector <sel> |
Scope to a CSS selector. Must match the reference snapshot, or diff refuses |
-d, --depth <n> |
Maximum tree depth to traverse. Must match the reference snapshot, or diff refuses |
A --ref file saved by 0.7.3 or earlier records no options. diff compares against it anyway and prints a warning.
diff compares password values raw but prints [redacted] on both sides of a change, as snapshot does. --json keeps the raw values.
A --ref file saved before password masking carries no sensitive flag. A password input that has since left the page shows up as a - line with its value in clear. Re-save the reference with the current version.
Output format:
+ button "Submit" [ref=e8] # added- button "Loading..." [ref=e3] # removed~ textbox "Search PRs" [ref=e2] value: "" → "workspace" # changedExample:
# Take a snapshot, interact, then diff$ tauri-pilot snapshot -i$ tauri-pilot fill @e2 "workspace"$ tauri-pilot click @e3$ tauri-pilot diff -i~ textbox "Search PRs" [ref=e2] value: "" → "workspace"
# Save and diff against a file$ tauri-pilot snapshot -i --save before.snap$ tauri-pilot fill @e2 "workspace"$ tauri-pilot diff -i --ref before.snap
# No changes$ tauri-pilot diff -iNo changes detected.How matching works:
Elements are matched between snapshots by (role, name, depth) — not by ref ID, since refs reset on every snapshot. For duplicate elements sharing the same identity, position order is used as a tiebreaker.
Capture options must match:
diff compares --interactive, --selector and --depth with the options recorded in the reference (the last snapshot, or the --ref file) before taking the new snapshot. On a mismatch it fails instead of reporting every filtered element as added or removed:
$ tauri-pilot snapshot --save full.snap$ tauri-pilot diff -i --ref full.snapError: RPC error (-32602): Reference snapshot was captured with other options: interactive (reference: false, current: true). ...The JSON-RPC error (-32602) carries both option sets in data ({"reference":{…},"current":{…}}). A reference file saved by 0.7.3 or earlier has no options: it is still diffed, and the result gets a warning field (printed on stderr by the CLI).
JSON-RPC example:
// Request (diff vs last snapshot){"jsonrpc":"2.0","id":1,"method":"diff","params":{"interactive":true}}
// Request (diff vs saved reference){"jsonrpc":"2.0","id":1,"method":"diff","params":{"interactive":true,"reference":{"elements":[...],"options":{"interactive":true,"selector":null,"depth":null}}}}
// Response{"jsonrpc":"2.0","id":1,"result":{ "added": [{"ref":"e8","role":"button","name":"Submit","depth":1}], "removed": [{"ref":"e3","role":"button","name":"Loading...","depth":1}], "changed": [{"old":{"ref":"e2","role":"textbox","name":"Search PRs","value":"","depth":1}, "new":{"ref":"e2","role":"textbox","name":"Search PRs","value":"workspace","depth":1}, "changes":["value"]}]}}assert
Section titled “assert”One-step verification of element state, text, or URL. Returns exit code 0 with ok on success, exit code 1 with a clear error message on failure. Designed to reduce AI agent round-trips and token usage.
tauri-pilot assert <subcommand> [args...]Subcommands:
| Subcommand | Arguments | Description |
|---|---|---|
text |
<target> <expected> |
Assert exact text content match |
visible |
<target> |
Assert element is visible |
hidden |
<target> |
Assert element is hidden; a selector that matches nothing passes |
value |
<target> <expected> |
Assert input/textarea/select value, or a contenteditable / textbox host’s text with whitespace collapsed |
count |
<selector> <expected> |
Assert number of elements matching CSS selector |
checked |
<target> |
Assert checkbox/radio is checked |
unchecked |
<target> |
Assert checkbox/radio is not checked |
contains |
<target> <expected> |
Assert text contains substring |
url |
<expected> |
Assert current URL contains substring |
Examples:
# Take a snapshot first (refs reset each time)$ tauri-pilot snapshot -i
# Exact text match$ tauri-pilot assert text @e1 "Dashboard"✓ ok
# Element visibility$ tauri-pilot assert visible @e3✓ ok
# A removed element counts as hidden: a selector that matches nothing passes.# An unknown ref (@e5) still fails, since it usually means a stale snapshot.$ tauri-pilot assert hidden '#modal'✓ ok
# Check input value$ tauri-pilot assert value @e2 "workspace"FAIL: expected value "workspace", got ""
# Count elements by CSS selector$ tauri-pilot assert count ".list-item" 5✓ ok
# Checkbox state$ tauri-pilot assert checked @e4FAIL: element is not checked
$ tauri-pilot assert unchecked @e4✓ ok
# Partial text match$ tauri-pilot assert contains @e1 "Dash"✓ ok
# URL check$ tauri-pilot assert url "/dashboard"✓ okNote: Element refs (
@e1,@e2, …) require a priorsnapshotcall. Always take a fresh snapshot before using refs in assertions.
Exit codes:
| Code | Meaning |
|---|---|
0 |
Assertion passed |
1 |
Assertion failed — FAIL: <message> on stderr, or a JSON object on stdout with --json |
With --json, a failed assertion prints ok: false, the message, and the
compared values for text, value, count, contains and url:
$ tauri-pilot --json assert text @e1 "nope"{ "ok": false, "message": "expected text \"nope\", got \"Log in\"", "expected": "nope", "actual": "Log in"}For contains and url, expected is the substring that was searched for.
visible, hidden and checked failures carry ok and message only.
Simulate a realistic click on an element (dispatches focus → mousedown → mouseup → click events).
Fails with click: target is disabled, firing no event, when the target is
disabled: a control with disabled, one inside a disabled <fieldset>, an
element inside a disabled <button> or other form control (such as an icon
in a disabled button, whether reached by selector or by --x/--y), or an
element whose role supports aria-disabled (button, link, checkbox,
menuitem, tab, textbox and the like; any token of a role list counts) with
aria-disabled="true" on itself or an ancestor, the nearest explicit value
winning, as in Playwright. The
browser drops a user’s click on a disabled control, but not a synthetic one,
so without this check a disabled button’s onclick would run.
tauri-pilot click <target>Example:
tauri-pilot click @e3tauri-pilot click "#submit-btn"tauri-pilot click 100,200Clear an <input>, <textarea>, <select>, or contenteditable element and
set a new value. Form controls use the native value setter so React and similar
frameworks see the change. On <select>, the value is matched like select
(option value, then visible label) and fill throws if nothing matches.
Contenteditable hosts (Tiptap, ProseMirror, and the like) are filled by
selecting the target’s contents and calling insertText so the editor
document updates. If insertText is unavailable, fill assigns textContent
instead, which does not update those editors. Read the result with text /
assert text, or with value / assert value, which read the host’s text
with whitespace collapsed.
Throws if the target cannot take a value (for example a plain <div>). A
reported ok means the value was written.
Fails with fill: target is disabled on a disabled target (same rules as
click) and fill: target is readonly on a readonly <input> or
<textarea> (not on input types where HTML ignores readonly, such as
range), or on an element whose role supports aria-readonly (textbox,
searchbox, combobox and the like) with aria-readonly="true", such as a
rich-text editor in read mode. On a <select>, a disabled option (or one
inside a disabled <optgroup>) fails with
fill: option "<value>" is disabled. The target keeps its value and no
event fires: the options are checked before fill focuses it.
tauri-pilot fill <target> <value>A value that starts with - (such as -5) reads as a flag; put -- before
it: tauri-pilot fill "#qty" -- -5. Scripts written by replay --export sh
always do this.
Example:
tauri-pilot fill @e2 "my-feature-branch"tauri-pilot fill "#search" "open issues"tauri-pilot fill ".ProseMirror" "hello from the editor"Type text into an <input>, <textarea>, or contenteditable element without
clearing existing content first. Same target rules as fill, except
<select> is rejected — use fill or select. Contenteditable typing also
tries insertText first and falls back to textContent; read the result
with text / assert text, or value / assert value (text with whitespace
collapsed). Fails with type: target is disabled or
type: target is readonly, like fill.
tauri-pilot type <target> <text>Text that starts with - reads as a flag; put -- before it:
tauri-pilot type @e2 -- -x. Scripts written by replay --export sh always
do this.
Example:
tauri-pilot type @e2 " additional text"Inject keyboard events at the OS level via enigo.
Events are isTrusted=true and reach DOM listeners and Tauri accelerators on
supported desktop platforms. Android and iOS have no native press backend:
there press fails with error.data.error = "UNSUPPORTED_PLATFORM", the same
code screenshot_native returns off macOS. Use fill or type for text input.
tauri-pilot press <key><key> accepts modifier-prefixed combos in the form Modifier+...+Key. Only
+ is a separator — - is treated as the literal minus key, so Shift+-
means Shift + minus. A trailing + is the + key itself (e.g. Control++
is Control + plus).
- Modifiers (case-insensitive):
Control/Ctrl,Shift,Alt/Option,Meta/Cmd/Super/Win - Common keys:
Enter,Tab,Escape,ArrowUp/ArrowDown/ArrowLeft/ArrowRight,Backspace,Delete,Home,End,PageUp,PageDown,Space,F1–F12, or any single character
Example:
tauri-pilot press Entertauri-pilot press Tabtauri-pilot press Control+1tauri-pilot press Ctrl+Shift+PBefore pressing, the plugin requests focus on the target window and checks
that the window actually received it. If the window manager refuses the
request (common on X11 with focus-stealing prevention), press returns an
error instead of injecting into whichever application currently has focus.
The press takes effect on whatever element holds focus inside that window —
call click first if you need to focus a specific input.
select
Section titled “select”Select options in a <select>. Each value matches an option’s value,
then its visible label.
tauri-pilot select <target> <value>...tauri-pilot select <target> --clearOn a <select multiple>, exactly the listed options end up selected and
every other option is deselected, so one value leaves only that option. A
single select takes one value; several are an error. An unknown value fails
the command, naming every value that matched no option, and leaves the
selection as it was. On success, select fires input then change, once
each.
A disabled <select> (same rules as click) fails with
select: target is disabled. A disabled option, or one inside a disabled
<optgroup>, fails with select: option "<value>" is disabled (several:
select: options "b", "c" are disabled), and the selection stays as it was.
--clear deselects every option of a <select multiple> and fires input
then change once, even when nothing was selected. It takes no value, and a
single select rejects it, since a user cannot leave one empty. Without
--clear, at least one value is required, so a forgotten value fails instead
of clearing the selection. The JSON-RPC method, the MCP tool and the TOML
step take an empty list (value = []) for the same thing; recordings keep
it, and replay --export sh writes it back as --clear.
A value that starts with - (such as -1) reads as a flag; put -- before
the values to pass it: tauri-pilot select "#size" -- -1. Scripts written by
replay --export sh always do this.
Example:
tauri-pilot select "#status-filter" "open"tauri-pilot select @e5 "closed"tauri-pilot select 'select[name=skills]' rust gotauri-pilot select 'select[name=skills]' --clearToggle an <input type="checkbox"> (check if unchecked, uncheck if
checked). For <input type="radio">, select it and leave it selected —
an already-selected radio stays selected, matching a real click.
Throws on any other element. A disabled checkbox or radio (same rules as
click) fails with check: target is disabled before clicking.
tauri-pilot check <target>Example:
tauri-pilot check "#remember-me"tauri-pilot check @e7scroll
Section titled “scroll”Scroll the page or a specific element.
tauri-pilot scroll <direction> [amount] [OPTIONS]Arguments:
| Argument | Description |
|---|---|
<direction> |
up, down, left, right, top, or bottom |
[amount] |
Scroll distance in pixels (ignored for top/bottom, default: 300) |
Options:
| Option | Description |
|---|---|
--target <target> |
Element to scroll: @ref (or bare e3), CSS selector, or x,y. Defaults to the page. --ref is an alias. |
top jumps to scrollY = 0; bottom jumps to the maximum scroll position (scrollHeight - innerHeight for the page, scrollHeight - clientHeight for an element). Unknown directions raise an error instead of silently no-op.
Example:
tauri-pilot scroll down 500tauri-pilot scroll up --target @e4tauri-pilot scroll down 50 --target "#log"tauri-pilot scroll toptauri-pilot scroll bottom --ref @e4Drag an element to another element or by a pixel offset.
Two unrelated kinds of drag implementation exist, and they listen for different
things, so drag emits both:
- HTML5 native drag (
draggable="true") reacts to the drag event sequence:dragstart→dragleave→dragenter→dragover→drop→dragend. - JS drag libraries (dnd-kit, sortable.js, interact.js, react-dnd’s mouse
backend) never see those events. They activate on
mousedown, then track repeatedmousemove/pointermoveevents — usually behind a small distance threshold — and commit onmouseup. Sodragpresses, streams interpolated moves from the source to the drop point, and releases. Each pointer event is followed by its compatibility mouse event, the order a browser produces.
The press targets the deepest node under the start point (elementFromPoint)
rather than the resolved element, because library listeners are commonly attached
to an inner handle or card and events only bubble upward. That node must be the
source or inside it — when an overlay, toast or backdrop covers the start point,
drag presses the resolved source instead of the thing on top of it. Moves are
hit-tested the same way at each step, so listeners on elements between the pressed
node and the document see them too.
tauri-pilot drag <source> [target] [OPTIONS]Arguments:
| Argument | Description |
|---|---|
<source> |
Element to drag (ref, selector, or coordinates) |
[target] |
Element to drop onto (mutually exclusive with --offset) |
Options:
| Option | Description |
|---|---|
--offset <X,Y> |
Drag by pixel offset instead of to an element (mutually exclusive with [target]) |
With --offset, the drop point is resolved with elementFromPoint, which only hits elements inside the visible viewport. If the source element’s center — the drag start point — is outside the viewport, the bridge scrolls the element into view (centered) before computing coordinates. The offset itself must still land inside the viewport — an offset larger than the visible area fails with a Drop point ... is outside the viewport error.
The gesture’s shape is tunable over JSON-RPC and MCP (the CLI uses the defaults):
steps (default 12, clamped to 1–60) is how many move events are emitted,
stepDelayMs (default 16) is the pause between them, and settleMs (default 250)
is how long the bridge waits after the release so an async state update, request,
or re-render can land before you assert. The plugin sizes its eval timeout from
these, so a long gesture is not cut short by the default 10-second budget.
ok reports that the gesture was delivered, not that the app reacted to it —
nothing observable from outside can prove a library handled a drop. Always assert
the expected effect afterwards. The one signal the bridge can observe is
html5DropHandled, which is true when a handler called preventDefault() on the
drop event. The result also echoes the from/to points and steps used.
A disabled source fails with drag: source is disabled and a disabled drop
target with drag: target is disabled (same rules as click), before any
drag event is dispatched (--offset may still scroll the source into view
first). A plain <div> drop zone counts as disabled only inside a disabled
control, or with a widget role and aria-disabled="true". With --offset,
the node under the drop point is refused inside such an aria-disabled
widget too. In either mode, the node drag presses inside the source (a
disabled button in a card) fails as the source.
Examples:
# Drag a card to a column (kanban board)tauri-pilot drag "#card-1" "#col-done"
# Drag by ref (after snapshot)tauri-pilot drag @e5 @e8
# Drag a slider thumb by pixel offsettauri-pilot drag "#slider-thumb" --offset "150,0"
# Drag to coordinatestauri-pilot drag @e5 "400,200"A coordinate or offset that starts with - reads as a flag: write the offset
as --offset=-50,0 and put -- before coordinate arguments, as in
tauri-pilot drag -- -10,20 "#col". Scripts written by replay --export sh
always do this.
JSON-RPC example:
// Element-to-element drag{"jsonrpc":"2.0","id":1,"method":"drag","params":{"source":{"ref":"e5"},"target":{"ref":"e8"}}}
// Offset drag{"jsonrpc":"2.0","id":1,"method":"drag","params":{"source":{"selector":"#thumb"},"offset":{"x":150,"y":0}}}Simulate a file drop on an element. Reads files from disk, base64-encodes them, and creates DataTransfer + File objects in the WebView. Useful for testing file upload zones, import features, and drag-from-OS scenarios.
tauri-pilot drop <target> --file <path> [--file <path>...]Arguments:
| Argument | Description |
|---|---|
<target> |
Element to drop files onto (ref, selector, or coordinates) |
Options:
| Option | Description |
|---|---|
--file <path> |
File to drop (required, can be repeated for multiple files) |
Limits: one request is at most 1 MiB and files are sent base64-encoded, so the files in one drop can total a little under 768 KiB.
A disabled target (same rules as click), such as a disabled
<input type="file">, fails with drop: target is disabled and fires no event.
Examples:
# Drop a single filetauri-pilot drop "#file-zone" --file ./photo.png
# Drop multiple filestauri-pilot drop @e3 --file ./doc.pdf --file ./data.csvJSON-RPC example:
{"jsonrpc":"2.0","id":1,"method":"drop","params":{ "selector":"#file-zone", "files":[{"name":"photo.png","type":"image/png","data":"iVBORw0KGgo..."}]}}Watch for DOM mutations using MutationObserver. By default, it resolves once the DOM stays quiet for --stable ms and returns a summary of what changed — the change set can be empty on idle pages. Pass --require-mutation to reject on timeout when nothing mutated. Useful for waiting on async UI updates without polling snapshots.
tauri-pilot watch [OPTIONS]Options:
| Option | Description |
|---|---|
--selector <sel> |
Scope observation to a subtree matching this CSS selector |
--timeout <ms> |
Maximum wait time in milliseconds (default: 10000) |
--stable <ms> |
Wait until DOM is stable (no new mutations) for N ms (default: 300) |
--require-mutation |
Defer the stability timer until at least one mutation shows up in the summary (whitespace-only text and comment nodes don’t count). Rejects on timeout if nothing changed. |
Examples:
# Wait for any DOM changetauri-pilot watch
# Watch a specific subtreetauri-pilot watch --selector "#results-list"
# Short timeouttauri-pilot watch --timeout 3000
# Wait for DOM to settle after animationstauri-pilot watch --stable 500
# Wait for an async re-render after an IPC call (e.g. React state update)tauri-pilot ipc settings_update --args '{"theme":"dark"}'tauri-pilot watch --require-mutation --stable 300 --timeout 2000JSON-RPC example:
{"jsonrpc":"2.0","id":1,"method":"watch","params":{"selector":"#results","timeout":5000,"stable":300,"requireMutation":true}}Get the text content of an element.
tauri-pilot text <target>Example:
$ tauri-pilot text @e1PR DashboardGet the innerHTML of an element, or the full document HTML if no target is given.
tauri-pilot html [target]Example:
tauri-pilot html @e1tauri-pilot html "#main-content"tauri-pilot htmlGet the current value of an input, textarea, or select element.
A contenteditable host or a role="textbox" / role="searchbox" widget
reports its text with whitespace collapsed (text returns the raw
textContent). Its paragraphs and <br> line breaks become one space, so
<p>Hello</p><p>World</p> reads Hello World. An element inside a
contenteditable host (a paragraph fill accepts) reads its own text the same
way. The full value is returned, never cut.
For a <select multiple>, every selected option is joined with ", "
(the same display forms uses). A single-select is unchanged.
tauri-pilot value <target>Example:
$ tauri-pilot value "#search"my-feature-branch
$ tauri-pilot value 'select[name=skills]'rust, jsGet all HTML attributes of an element as key-value pairs.
tauri-pilot attrs <target>Example:
$ tauri-pilot attrs @e3id = submit-btnclass = btn btn-primarydisabled = falsetype = buttonExecute arbitrary JavaScript in the WebView context and return the result.
tauri-pilot eval [script|-]Example:
$ tauri-pilot eval "document.title"PR Dashboard
$ tauri-pilot eval "window.location.pathname"/dashboardUse - to read JavaScript from stdin. This is useful for complex selectors,
quotes, Panda CSS class names, or multi-line scripts:
$ tauri-pilot eval - <<'EOF'document.querySelector('[data-id="main"]').textContentEOF# (output depends on your app)Main content
$ echo 'document.title' | tauri-pilot eval -# (output depends on your app)PR DashboardPrefer <<'EOF' with quotes around the heredoc delimiter. It disables shell
variable and command expansion, so $ and backticks inside the script do not
need escaping.
The plugin reads at most 1 MiB (1,048,576 bytes) per request. The limit counts
the JSON-RPC request the CLI sends, where quotes, backslashes and newlines in
the script are escaped, so a script just under 1 MiB can still be too large. A
larger request fails before it is sent, with
eval request is N bytes; the plugin accepts at most 1048576 bytes.
To inject a library, use its minified build rather than the development one.
Otherwise split the script into calls that each parse on their own, and pass
state between them through window: let, const and class bindings do
not carry over to the next call.
Statements are supported alongside bare expressions — const, let, var,
function declarations and blocks all work, and the completion value of the last
expression is returned:
$ tauri-pilot eval "const els = document.querySelectorAll('button'); els.length"7
$ tauri-pilot eval "function pick(n){return n*2;} pick(21)"42If the final statement is a Promise, it is awaited before the value is
serialized. Scripts that end on a declaration (const x = 1;) instead of an
expression return null — append the bare identifier (; x) to read the value
back.
Expressions that return undefined (e.g. element.click(), console.log(...),
any void function) print nothing and exit with status 0, so they compose
cleanly with && and set -e:
$ tauri-pilot eval "document.querySelector('a')?.click()" && tauri-pilot state# click fires, then state prints — exit 0Top-level await is auto-wrapped in an async IIFE, so the natural shape works:
$ tauri-pilot eval 'await Promise.resolve("hello")'hello
$ tauri-pilot eval 'await fetch("/api/items").then(r => r.json())'[…]
$ tauri-pilot eval 'await (1 + 1)'2An await inside a nested function (such as an async IIFE), or in the text
of a string, a template literal (not inside ${…}) or a regex literal is not
top-level, so the script does not go to the async-statement wrapper: it runs
as an expression or as statements like any other script, and a Promise it
ends on is awaited:
$ tauri-pilot eval 'const x = 1; (async () => { if (x) { await 0; } return "done"; })()'doneFor multi-statement scripts (e.g. const followed by a value to surface), use
an explicit return since the wrapper has no completion-value semantics:
$ tauri-pilot eval 'const r = await fetch("/api"); return r.status'200Without return, the result is null. If the auto-wrap can’t compile the
script (rare; usually a syntax error), the error message points back here.
Call a Tauri IPC command (registered with tauri::Builder) and return the response.
tauri-pilot ipc <command> [OPTIONS]Options:
| Option | Description |
|---|---|
--args <json> |
JSON object of arguments to pass to the command |
Example:
tauri-pilot ipc get_prstauri-pilot ipc create_pr --args '{"title":"Fix bug","branch":"fix/issue-42"}'screenshot
Section titled “screenshot”Capture the full page as a PNG using the injected html-to-image bridge.
tauri-pilot screenshot [path] [OPTIONS]Without --selector, the capture covers the whole document height, not just
the visible viewport. The bridge serializes the DOM to render it, so the
current scroll position is not reflected in the image — what you get is the
entire page from the top. Use --selector to capture a single element
(works for elements below the fold too). For a pixel-exact capture of the
native window, use screenshot_native — macOS only, it returns
UNSUPPORTED_PLATFORM on Linux, Windows, Android and iOS. A Simulator build is an
iOS build even though the host is a Mac, so use screenshot there.
The bridge also copies only an allowlist of painted CSS properties onto the
clone it renders (a full computed-style copy wedges WebKit for minutes on
style-variable-heavy pages). A page that paints through a property outside
that list renders without it, with no error, so if the PNG does not match
what you see in the app that is the first thing to check. Standard CSS
scrollbar properties (scrollbar-color, scrollbar-width,
scrollbar-gutter) are on the list. ::-webkit-scrollbar and other
scrollbar pseudo-element rules are not: html-to-image copies computed
styles on the element, not on pseudo-elements, so a WebKit-styled
scrollbar is still missing from the PNG. On macOS, screenshot_native
sidesteps the allowlist entirely.
Arguments:
| Argument | Description |
|---|---|
[path] |
Output file path. Prints base64 to stdout if omitted |
Options:
| Option | Description |
|---|---|
--selector <sel> |
Capture only the element matching this CSS selector |
Example:
tauri-pilot screenshot ./dashboard.pngtauri-pilot screenshot --selector "#main-panel" ./panel.pngtauri-pilot screenshot # prints base64 PNG to stdoutnavigate
Section titled “navigate”Change the WebView URL.
tauri-pilot navigate <url>Example:
tauri-pilot navigate "http://localhost:1420/settings"tauri-pilot navigate "/"Bridge commands only work on origins allowed to call the plugin back: the app
origin, plus any origin listed in a capability’s remote.urls that also
grants pilot:default. They pin to the origin they checked. The wrapper
compares location fields (not location.origin) and does not run the
command if the document is elsewhere. If the URL already moved after eval,
the RPC fails immediately; a later timeout is reclassified the same way.
navigate is not pinned. It assigns the absolute destination resolved from
the URL at check time, so a relative path cannot resolve against a page that
already left, and it still loads any URL. It returns once the new document’s
bridge says hello in the target window (a hello from another window on the
same origin does not count), so the next command runs on the destination, not on the
page being left. A javascript: URL or a change of #fragment alone keeps
the document and returns on the page’s answer. A navigate sent right after
the app starts, before any page said hello, gives the page up to 10 seconds
to answer, then waits for the destination’s hello as below: up to 10
seconds when the destination origin has said hello by then, up to 3 seconds
when it has not, so up to about 20 seconds. If no hello at all was
recorded by the time the page answers (the hello is still in flight or was
dropped), it returns on the
answer. In all other cases, the page being left
gets up to 3 seconds to answer first. If
the destination origin has never said hello, navigate then waits up to 3
seconds for one and fails without it; otherwise it waits up to 10 seconds, so
the worst case is up to about 13 seconds. A navigate also accepts a
hello from another bridged origin the window was redirected to.
For a same-origin navigate, the error says whether the
window never left the page (the navigation was cancelled or blocked) or
loaded a document whose bridge stayed silent. A cross-origin destination
that has not loaded when the wait ends (for example, a server that refuses
the connection) fails with “did not load in time” and names the page the
window still shows; if that page has no bridge, the error also names the
origins that work. A start page that only changed its URL during the wait
(history.pushState) on its own bridged origin counts as never left. A
page that loads without a bridge fails with a “no pilot bridge answered
there” error that names it. The “did not load” verdict is what the window
showed when the wait ended: a slow destination may still load after it. An
about:, data: or blob: destination is built from memory and cannot be
slow, so once a bridge hello has been recorded, a window still on its page
fails with “did not happen” (the navigation was cancelled or blocked)
instead. A file: destination keeps “did not load in time”: it can sit on
a network share. On a local page (about:, data:, blob:, file:), the
“no pilot bridge” error only says to navigate
back to one of the listed origins whose bridge said hello: remote.urls
cannot allow local pages. A
slow page allowed by remote.urls can miss that window on its first visit;
later commands succeed once the hello arrives. On an origin that cannot
call back,
later commands fail at once with an error that names the page and the origins
that work. Run tauri-pilot navigate with an app URL to get the session
back.
Get the current page URL.
tauri-pilot urlThe plugin answers this one from the webview itself, not from the injected
bridge, so it still works on a page the bridge cannot drive — a foreign origin
missing from remote.urls, where the bridge commands fail. windows,
navigate and press do not go through the bridge either and keep working
there; navigate to an app URL is the way back.
Example:
$ tauri-pilot urlhttp://localhost:1420/dashboardGet the current page title.
tauri-pilot titleExample:
$ tauri-pilot titlePR DashboardGet the current page state: URL, title, viewport dimensions, and scroll position.
tauri-pilot stateExample:
$ tauri-pilot stateurl http://localhost:1420/dashboardtitle PR Dashboardviewport 1280x800scroll 0,0Wait for an element to appear (or disappear) in the DOM.
tauri-pilot wait [target] [OPTIONS]Positional [target] is parsed the same way as for click, text, value, etc., except that wait only accepts a snapshot ref or a CSS selector — coordinate targets (x,y) are not supported here, since waiting for a position has no meaning:
@e3ore3— snapshot ref (resolved viaidMap); the@is optional- anything else that is not
e<digits>— CSS selector (e.g.#loading-spinner,.toast-success,[data-test=foo])
--selector takes precedence when both are provided.
Options:
| Option | Description |
|---|---|
--selector <sel> |
CSS selector to wait for (overrides positional [target]) |
--gone |
Wait for the element to disappear instead of appear |
--timeout <ms> |
Maximum wait time in milliseconds (default: 10000) |
Example:
tauri-pilot wait "#trigger-deferred" # CSS selector via positionaltauri-pilot wait "@e3" # snapshot reftauri-pilot wait --selector "#spinner" --gonetauri-pilot wait --selector ".toast-success" --timeout 5000Display or stream captured console logs (console.log, console.warn, console.error, console.info), plus uncaught exceptions and unhandled promise rejections (prefixed Unhandled rejection: ) at level error. Failed resource loads (<img> / <script> 404) are not recorded.
The JS bridge monkey-patches the browser console methods, listens for error and unhandledrejection, and stores entries in a 500-entry ring buffer with timestamp, level, serialized arguments, and source location. The human-readable renderer prints source after the arguments when it is set. A console call or unhandled rejection raised directly by a script sent with eval reports tauri-pilot-eval as its source. A call inside an app function the script invokes keeps the app frame, and a callback the script schedules (setTimeout, .then) has no source; on WebKit (Linux, macOS) neither does a call made after an await in the script.
Each console argument is copied when it is logged, so logs shows the value at call time even if the object changes afterwards. Strings, finite numbers, booleans and null are kept as they are. An Error becomes "Name: message" (without its stack), or {"__type":"Error","message":"Name: message",...} with its own fields (code, status) and cause when it has any; a DOM element a tag description such as "<button#save.primary>" (other nodes their node name, e.g. "#text"), undefined the string "undefined", NaN and Infinity their names, a BigInt its digits with an n suffix ("10n"), a symbol its description ("Symbol(x)"), and a function "[Function name]". A Map becomes {"__type":"Map","size":N,"entries":[[key,value],...]}, a Set {"__type":"Set","size":N,"values":[...]} and a typed array {"__type":"Uint8Array","length":N,"values":[...]}. A RegExp becomes its source ("/ab+c/gi"), and another built-in with no keys of its own its type name ("[Promise]", "[WeakMap]", "[ArrayBuffer]"). Objects and arrays are copied with the same rules applied to nested values, and an array hole is null; an object with its own toJSON (such as a Date) keeps its JSON form instead. A cycle is cut as "[Circular]", a property whose getter throws is "[unreadable]", nesting stops at 8 levels ("[Object]", "[Array]", "[Map]", "[Set]"), and one argument copies at most 1000 objects in all. Each container keeps its first 100 items: an array ends with "... N more items", an object gets a "..." key, and a Map, Set or typed array a truncated count. A string inside an object or array is cut after 10,000 characters; a string passed directly to console.* is kept whole.
Console capture is an accessor on each method, so a page that assigns its own console.log is chained behind the bridge instead of replacing it: the page’s function still runs, and reading console.log back returns the bridge’s entry point rather than that function. Two cases drop capture until the next logs call restores it, and lines logged in between are lost: redefining the property outright (React’s dev build does while it builds a component stack), and assigning to a method that cannot be redefined, where the bridge could only install itself by plain assignment.
tauri-pilot logs [OPTIONS]Options:
| Option | Description |
|---|---|
--level <level> |
Filter by log level: log, info, warn, error |
--last <n> |
Show only the last N entries |
-f, --follow |
Continuously poll for new logs (500ms interval) |
--clear |
Flush the ring buffer |
Examples:
# Show all captured logs$ tauri-pilot logs[14:32:01.123Z] log App initialized[14:32:01.456Z] warn Deprecated API call[14:32:02.789Z] ✗ error Failed to fetch: NetworkError
# Filter by level$ tauri-pilot logs --level error[14:32:02.789Z] ✗ error Failed to fetch: NetworkError
# Last 5 entries$ tauri-pilot logs --last 5
# Stream logs in real-time$ tauri-pilot logs --follow
# Stream errors as NDJSON (one JSON object per line, compatible with jq)$ tauri-pilot logs --follow --level error --json
# Clear the buffer$ tauri-pilot logs --clear✓ clearedJSON output format:
[ { "id": 1, "timestamp": 1712073600000, "level": "error", "args": ["Failed to fetch:", "NetworkError: 500"], "source": "app.js:42" }]JSON-RPC examples:
// Get logs filtered by level{"jsonrpc":"2.0","id":1,"method":"console.getLogs","params":{"level":"error","last":10}}
// Clear buffer{"jsonrpc":"2.0","id":2,"method":"console.clear"}network
Section titled “network”Display or stream captured network requests (fetch and XMLHttpRequest).
The JS bridge monkey-patches fetch and XMLHttpRequest and stores entries in a 200-entry ring buffer with timestamp, method, URL, status code, duration, and error details. The plugin’s own __callback IPC (ipc://localhost/plugin:pilot|__callback and the Windows http(s)://ipc.localhost/... form) is not recorded.
tauri-pilot network [OPTIONS]Options:
| Option | Description |
|---|---|
--filter <pattern> |
Filter by URL substring match |
--failed |
Show only failed requests (4xx/5xx and network errors) |
--last <n> |
Show only the last N entries |
-f, --follow |
Continuously poll for new requests (500ms interval) |
--clear |
Flush the ring buffer |
Examples:
# Show all captured requests$ tauri-pilot network[14:32:01.123Z] GET https://api.github.com/repos 200 125ms[14:32:02.456Z] POST https://api.github.com/graphql 200 340ms[14:32:03.789Z] GET https://api.github.com/rate_limit 403 12ms
# Filter by URL$ tauri-pilot network --filter graphql
# Show only failures$ tauri-pilot network --failed
# Stream requests in real-time$ tauri-pilot network --follow
# Stream as NDJSON (compatible with jq)$ tauri-pilot network --follow --json
# Clear the buffer$ tauri-pilot network --clear✓ clearedJSON output format:
[ { "id": 1, "timestamp": 1712073600000, "method": "GET", "url": "https://api.github.com/repos", "status": 200, "duration_ms": 125, "error": null, "request_size": 0, "response_size": 1024 }]request_size and response_size are in bytes, for text bodies as well as
binary ones. response_size is number | null: null means the size is
unknown, not zero.
A fetch response reports its Content-Length, the size on the wire, so a
compressed response reports its compressed size. An XHR response is measured
from the body the browser hands back, after it removed compression and any
byte-order mark: a text body counts its UTF-8 bytes, an arraybuffer or
blob body its byte length. XHR falls back to Content-Length for json
and document responses and for text whose Content-Type names a charset
other than UTF-8. A charset forced with overrideMimeType() is not seen, so
that text still counts as UTF-8.
A response carries no size when the bridge falls back to a Content-Length
the response does not have (tauri:// never sends one), or when no response
arrived at all (network error, timeout, abort). An explicit
Content-Length: 0 reports 0.
JSON-RPC examples:
// Get requests filtered by URL{"jsonrpc":"2.0","id":1,"method":"network.getRequests","params":{"filter":"graphql","last":10}}
// Clear buffer{"jsonrpc":"2.0","id":2,"method":"network.clear"}storage
Section titled “storage”Read and write browser storage (localStorage or sessionStorage) from the CLI. Useful for AI agents inspecting persisted state, auth tokens, or modifying app configuration during testing.
tauri-pilot storage <subcommand> [OPTIONS]Subcommands:
| Subcommand | Arguments | Description |
|---|---|---|
get |
<key> |
Read a single key |
set |
<key> <value> |
Write a key-value pair |
list |
Dump all key-value pairs | |
delete |
<key> |
Remove one key. A missing key still succeeds |
clear |
Clear all storage |
Options:
| Option | Description |
|---|---|
--session |
Use sessionStorage instead of localStorage. Accepted before or after the subcommand |
Examples:
# Read a key from localStorage$ tauri-pilot storage get "auth_token"eyJhbGciOiJIUzI1NiJ9...
# A missing key prints "(not found)" on stderr and exits 1.# Connection and RPC errors exit 1 too, with "Error: ..." on stderr.$ tauri-pilot storage get "missing" || echo absent(not found)absent
# With --json a missing key prints {"found": false} and still exits 1$ tauri-pilot storage get "missing" --json{ "found": false}
# Write a key$ tauri-pilot storage set "theme" "dark"✓ ok
# List all localStorage entries$ tauri-pilot storage listauth_token = eyJhbGciOiJIUzI1NiJ9...theme = darklocale = en
# Remove one key; a missing key also prints "✓ ok" and exits 0$ tauri-pilot storage delete "auth_token"✓ ok
# With --json, "deleted" says whether the key existed$ tauri-pilot storage delete "auth_token" --json{ "deleted": false}
# Clear localStorage$ tauri-pilot storage clear✓ cleared
# Use sessionStorage instead# (--session works before or after the subcommand)$ tauri-pilot storage list --session$ tauri-pilot storage get "csrf_token" --session$ tauri-pilot storage --session list$ tauri-pilot storage --session delete "tab_id"
# JSON output$ tauri-pilot storage list --json[{"key":"auth_token","value":"eyJ..."},{"key":"theme","value":"dark"}]JSON-RPC examples:
// Get a key{"jsonrpc":"2.0","id":1,"method":"storage.get","params":{"key":"auth_token","session":false}}
// Set a key{"jsonrpc":"2.0","id":2,"method":"storage.set","params":{"key":"theme","value":"dark","session":false}}
// List all{"jsonrpc":"2.0","id":3,"method":"storage.list","params":{"session":false}}
// Delete a key (result: {"deleted": true|false}){"jsonrpc":"2.0","id":4,"method":"storage.delete","params":{"key":"auth_token","session":false}}
// Clear{"jsonrpc":"2.0","id":5,"method":"storage.clear","params":{"session":false}}Dump all form fields on the page in a single command. Useful for AI agents inspecting form state, pre-filled values, or verifying form structure without calling value on each input individually.
tauri-pilot forms [OPTIONS]Options:
| Option | Description |
|---|---|
--selector <css> |
Target a specific form by CSS selector |
Notes:
- Password fields display
[redacted]in human-readable output (raw values are available in--jsonmode) snapshotanddiffmask password inputs the same way- Output is limited to 100 forms and 500 fields per form; a truncation warning appears if exceeded
- The
--selectormust match a<form>element; other elements are rejected with an error
Examples:
# Dump all forms on the page$ tauri-pilot forms
# Target a specific form$ tauri-pilot forms --selector "#login-form"
# JSON output$ tauri-pilot forms --jsonJSON-RPC:
// Dump all forms{"jsonrpc":"2.0","id":1,"method":"forms.dump"}
// Dump specific form{"jsonrpc":"2.0","id":2,"method":"forms.dump","params":{"selector":"#login-form"}}record
Section titled “record”Record user interactions for later replay.
record start
Section titled “record start”Start recording interactions. All subsequent actions (click, fill, type, etc.) will be captured.
tauri-pilot record startA snapshot ref (@e9) only means something in the snapshot that numbered
it: the next snapshot renumbers the page, and a fresh page knows no refs. So
while recording, each step that targets a ref (and the source/target of
drag) is resolved, before the action runs, to:
- a
selector: the first of a unique#id,[data-testid="..."],tag[name="..."],tag[name="..."][value="..."](for a radio or checkbox with avalueattribute, in a group sharing one name), or a short CSS path with:nth-of-type, anchored on the nearest ancestor with a unique id. A candidate counts only when it matches this element and no other one. The CSS path is positional, so it is used only when no other element it could reach (inside its unique-id anchor, or the whole page) has the same tag, role and name: for repeated rows with identical buttons (“Delete” on every row), a shift would land on another row and still pass the fingerprint check, so such a step gets no selector andrecord stopreports it; - an
expectfingerprint: the element’s tag, and the role and name a snapshot shows for it.
The ref stays in the entry for information. This costs one extra bridge
call per ref step, only while recording. That call waits at most 2 seconds;
when it gets no answer the step is saved with its ref only, and record stop
reports it.
record stop
Section titled “record stop”Stop recording and save captured interactions to a JSON file. Without a
recording in progress it fails with No recording in progress, exits 1, and
writes no file.
tauri-pilot record stop --output test.jsonWhen no selector could single out the element of a ref step, the step is saved
with its ref and fingerprint only, and record stop names it:
✓ Recording saved — 4 actions → test.jsonThese steps replay only against the snapshot that numbered their ref; give the elements an id or data-testid and re-record: step 3 (click): no stable locator for ref e7With --json the same list is under unstable
([{"step": 3, "action": "click", "ref": "e7"}]).
| Option | Description |
|---|---|
--output, -o |
Output file path (JSON format, required) |
record status
Section titled “record status”Check if recording is currently active.
tauri-pilot record statusreplay
Section titled “replay”Replay a previously recorded session.
tauri-pilot replay test.jsonExport as a shell script instead of replaying. The export only reads the file, so it works with no app running:
tauri-pilot replay test.json --export sh| Option | Description |
|---|---|
--export |
Export format instead of replaying (supported: sh) |
--window |
Run every step in this window instead of the one it was recorded in (TAURI_PILOT_WINDOW too) |
Each step runs in the window it was recorded in (its window field), or in
the default window when it has none. A step with a recorded selector must
find exactly one element, with the recorded fingerprint. Otherwise the step
fails, and replay does not fall back to the ref:
[3/6] check → ✗ FAIL: RPC error (-32603): Eval error: JavaScript error: Recorded selector body > fieldset > input:nth-of-type(2) found <input role="radio" name="Team">, recorded <input role="radio" name="Pro">The name is the one a snapshot shows: the label, or the first 50 characters
of the element’s text. An element whose text changes between runs (a counter,
a timestamp) therefore fails the fingerprint check; give it an aria-label.
A step with a ref and no selector, from a recording made before stable
locators or one record stop reported, still replays on the ref, with a
warning, since the ref only exists in the snapshot that numbered it:
[1/6] fill relies on snapshot ref e5, which only exists in the snapshot that numbered it; re-record for a stable replay--export sh writes the selector instead of the ref and adds
--window <label> to steps recorded in a window. The CLI has no command that
checks a fingerprint, so an exported script acts on the first element each
selector matches; the script says so in a comment at its top. Steps that still
rely on a ref get a comment above them.
Each step prints ok, SKIP for an action that is not replayed, or FAIL
with the error that caused it:
[1/3] fill → ✗ FAIL: RPC error (-32603): Eval error: JavaScript error: Unknown ref: e5[2/3] click → ✓ ok[3/3] assert → ✗ SKIPreplay exits 1 when any replayed step fails, 0 otherwise. With --json the
result lists every step:
{ "status": "failed", "total": 3, "passed": 1, "skipped": 1, "failed": 1, "steps": [ {"action": "fill", "status": "failed", "message": "RPC error (-32603): Eval error: JavaScript error: Unknown ref: e5"}, {"action": "click", "status": "passed"}, {"action": "assert", "status": "skipped"} ]}A failed step carries its error in message, the key run uses. A step that
relies on a snapshot ref also carries the warning above in warning. Over MCP,
pilot.replay returns the same object. A finished replay including
failed steps is a successful tool result with status "failed"; only read,
parse, and connect failures are tool errors.
Output format
Section titled “Output format”Recordings are stored as JSON arrays:
[ { "action": "check", "timestamp": 0, "window": "main", "ref": "e9", "selector": "#plan-pro", "expect": {"tag": "input", "role": "radio", "name": "Pro"} }, {"action": "click", "selector": "#trigger-deferred", "timestamp": 640}, { "action": "drag", "timestamp": 1200, "source": {"ref": "e12", "selector": "[data-testid=\"card-1\"]", "expect": {"tag": "div", "role": "generic", "name": "Card 1"}}, "target": {"selector": "#col-done"} }]selector and expect appear on steps recorded on a ref; window when the
step named one. Recordings made before these fields existed hold only ref
and replay with a warning; re-record them for stable replays.
JSON-RPC examples
Section titled “JSON-RPC examples”// Start recording{"jsonrpc":"2.0","id":1,"method":"record.start","params":{}}
// Stop recording{"jsonrpc":"2.0","id":2,"method":"record.stop","params":{}}
// Check recording status{"jsonrpc":"2.0","id":3,"method":"record.status","params":{}}
// Add an explicit entry (e.g., assertion from CLI){"jsonrpc":"2.0","id":4,"method":"record.add","params":{"action":"click","ref":"e3","timestamp":0}}Execute a declarative TOML scenario — steps with actions, assertions and timeouts. Exits 0 when every step passes, 1 on any failure.
tauri-pilot run <scenario.toml> [OPTIONS]Arguments:
| Argument | Description |
|---|---|
<scenario> |
Path to the scenario TOML file |
Options:
| Option | Description |
|---|---|
--junit <FILE> |
Write a JUnit XML report to this path |
--no-fail-fast |
Keep running the remaining steps after a failure |
--screenshots-dir <DIR> |
Directory for failure screenshots. Default ./tauri-pilot-failures, resolved against the working directory |
--json |
Print the JSON report on stdout. The text summary goes to stderr either way (global flag) |
Step keys:
Every [[step]] takes action, plus optional name and timeout_ms. The
other keys depend on the action:
| Action | Required | Optional |
|---|---|---|
click, check |
target |
|
fill |
target |
value |
select |
target |
value (a string, or a list such as ["rust", "go"] for a <select multiple>; [] clears the <select multiple>) |
type |
target |
text |
press |
key |
|
scroll |
target, direction, amount |
|
navigate |
url |
|
wait |
target or selector, not both |
gone |
watch |
selector, stable, require_mutation |
|
eval |
script |
|
screenshot |
path, selector |
|
assert-exists, assert-visible, assert-hidden |
target |
|
assert-checked, assert-unchecked |
target |
|
assert-text, assert-value, assert-contains |
target, expected |
|
assert-count |
selector, expected |
|
assert-url |
expected |
|
storage-get |
key |
session, expected |
storage-set |
key, value |
session |
storage-delete |
key |
session |
assert-hidden passes when a selector matches nothing, like assert hidden.
Every assert step but assert-exists runs the same check as the matching
assert command (assert-text as assert text, assert-count as
assert count, and so on) and fails the step with the same message. A
response without the field the check reads fails the step too.
assert-count takes an integer: expected = 3, not expected = "3"; every
other expected is a string.
The storage steps read and write localStorage, or sessionStorage with
session = true. storage-get fails when the key is missing; with
expected, it also fails when the value differs, naming both.
storage-set fails unless the app answers {"ok": true}.
run checks every step against this table before it connects. An unknown
action, a missing required key, or a key the action does not read fails the
whole file, and no step runs:
Error: Failed to load scenario: sel.toml
Caused by: 0: Invalid scenario 1: step 1: step 'assert-exists' does not accept 'selector'; use 'target'A failed step captures a screenshot and reports where it landed as
screenshot, or why it could not be written as screenshot_error — in
run --json, and as <system-out> in the JUnit XML. The reported path is
always absolute, so a CI job can upload it from any directory. A step the
CLI gave up on before the app answered gets no screenshot: --rpc-timeout ran
out, or its timeout_ms did on any action but wait and watch, which pass
it to the app instead. The app may still answer that abandoned request on the
connection, so screenshot_error says the connection is out of sync. With
--no-fail-fast, the next step opens a fresh connection.
Example:
tauri-pilot run docs/examples/login-flow.tomltauri-pilot run scenario.toml --no-fail-fast --junit results.xmltauri-pilot run scenario.toml --json --screenshots-dir /tmp/shotsThe same scenarios run over MCP through pilot.run, which returns the JSON
report instead of JUnit XML — see mcp.
JSON-RPC Protocol
Section titled “JSON-RPC Protocol”The CLI communicates with the plugin over a Unix socket using a hand-rolled JSON-RPC 2.0 protocol with newline-delimited framing (\n).
Every request is one line of at most 1 MiB (1,048,576 bytes), newline
included. The plugin answers a longer line with a -32700 error whose id is
null, then closes the connection. The CLI refuses such a request before
sending it, with
<method> request is N bytes; the plugin accepts at most 1048576 bytes.
You can interact directly with the socket using socat or nc for debugging:
echo '{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}' | socat - UNIX-CONNECT:"$XDG_RUNTIME_DIR/tauri-pilot-com.myapp.dev.sock"Use /tmp/tauri-pilot-com.myapp.dev.sock instead when the plugin fell back to /tmp. On Windows the plugin listens on a Named Pipe, which socat cannot open.
Request structure:
{"jsonrpc":"2.0","id":1,"method":"<method>","params":{...}}Success response:
{"jsonrpc":"2.0","id":1,"result":{...}}Error response:
{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found"}}