Skip to content

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.

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

The CLI picks the socket in this order and stops at the first match:

  1. --socket <path>, the explicit flag. On Windows this is a Named Pipe path such as \\.\pipe\tauri-pilot-{identifier}.
  2. $TAURI_PILOT_SOCKET, the environment variable.
  3. Linux and macOS: the newest tauri-pilot-*.sock in $XDG_RUNTIME_DIR, then the newest in /tmp when $XDG_RUNTIME_DIR is unset, empty or holds no live socket you own.
  4. Windows: the newest entry in %LOCALAPPDATA%\tauri-pilot\instances\*.json whose 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).

The --window <label> option (or TAURI_PILOT_WINDOW env var) selects which window all commands operate on:

Terminal window
tauri-pilot --window settings snapshot # snapshot the settings window
tauri-pilot click @e3 --window main # click in the main window
TAURI_PILOT_WINDOW=settings tauri-pilot snapshot

If --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.

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 every snapshot call. 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) or wait --selector e12.


Health check. Verifies the plugin server is reachable and responding.

Terminal window
tauri-pilot ping

Example:

Terminal window
$ tauri-pilot ping
✓ ok

A 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.

Terminal window
tauri-pilot mcp

Configuration:

{
"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.


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.

Terminal window
tauri-pilot windows

Example:

Terminal window
$ tauri-pilot windows
main http://localhost:1420/dashboard PR Dashboard
settings http://localhost:1420/settings Settings
about http://localhost:1420/about About

JSON-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"}
]}}

Capture the current accessibility tree of the WebView and assign stable element refs (e1, e2, …).

Terminal window
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:

Terminal window
$ tauri-pilot snapshot --interactive
e1 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.

Terminal window
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:

Terminal window
+ button "Submit" [ref=e8] # added
- button "Loading..." [ref=e3] # removed
~ textbox "Search PRs" [ref=e2] value: "" → "workspace" # changed

Example:

Terminal window
# 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 -i
No 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:

Terminal window
$ tauri-pilot snapshot --save full.snap
$ tauri-pilot diff -i --ref full.snap
Error: 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"]}]
}}

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.

Terminal window
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:

Terminal window
# 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 @e4
FAIL: 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"
✓ ok

Note: Element refs (@e1, @e2, …) require a prior snapshot call. 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:

Terminal window
$ 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.

Terminal window
tauri-pilot click <target>

Example:

Terminal window
tauri-pilot click @e3
tauri-pilot click "#submit-btn"
tauri-pilot click 100,200

Clear 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.

Terminal window
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:

Terminal window
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.

Terminal window
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:

Terminal window
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.

Terminal window
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:

Terminal window
tauri-pilot press Enter
tauri-pilot press Tab
tauri-pilot press Control+1
tauri-pilot press Ctrl+Shift+P

Before 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 options in a <select>. Each value matches an option’s value, then its visible label.

Terminal window
tauri-pilot select <target> <value>...
tauri-pilot select <target> --clear

On 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:

Terminal window
tauri-pilot select "#status-filter" "open"
tauri-pilot select @e5 "closed"
tauri-pilot select 'select[name=skills]' rust go
tauri-pilot select 'select[name=skills]' --clear

Toggle 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.

Terminal window
tauri-pilot check <target>

Example:

Terminal window
tauri-pilot check "#remember-me"
tauri-pilot check @e7

Scroll the page or a specific element.

Terminal window
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:

Terminal window
tauri-pilot scroll down 500
tauri-pilot scroll up --target @e4
tauri-pilot scroll down 50 --target "#log"
tauri-pilot scroll top
tauri-pilot scroll bottom --ref @e4

Drag 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 repeated mousemove/pointermove events — usually behind a small distance threshold — and commit on mouseup. So drag presses, 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.

Terminal window
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:

Terminal window
# 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 offset
tauri-pilot drag "#slider-thumb" --offset "150,0"
# Drag to coordinates
tauri-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.

Terminal window
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:

Terminal window
# Drop a single file
tauri-pilot drop "#file-zone" --file ./photo.png
# Drop multiple files
tauri-pilot drop @e3 --file ./doc.pdf --file ./data.csv

JSON-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.

Terminal window
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:

Terminal window
# Wait for any DOM change
tauri-pilot watch
# Watch a specific subtree
tauri-pilot watch --selector "#results-list"
# Short timeout
tauri-pilot watch --timeout 3000
# Wait for DOM to settle after animations
tauri-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 2000

JSON-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.

Terminal window
tauri-pilot text <target>

Example:

Terminal window
$ tauri-pilot text @e1
PR Dashboard

Get the innerHTML of an element, or the full document HTML if no target is given.

Terminal window
tauri-pilot html [target]

Example:

Terminal window
tauri-pilot html @e1
tauri-pilot html "#main-content"
tauri-pilot html

Get 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.

Terminal window
tauri-pilot value <target>

Example:

Terminal window
$ tauri-pilot value "#search"
my-feature-branch
$ tauri-pilot value 'select[name=skills]'
rust, js

Get all HTML attributes of an element as key-value pairs.

Terminal window
tauri-pilot attrs <target>

Example:

Terminal window
$ tauri-pilot attrs @e3
id = submit-btn
class = btn btn-primary
disabled = false
type = button

Execute arbitrary JavaScript in the WebView context and return the result.

Terminal window
tauri-pilot eval [script|-]

Example:

Terminal window
$ tauri-pilot eval "document.title"
PR Dashboard
$ tauri-pilot eval "window.location.pathname"
/dashboard

Use - to read JavaScript from stdin. This is useful for complex selectors, quotes, Panda CSS class names, or multi-line scripts:

Terminal window
$ tauri-pilot eval - <<'EOF'
document.querySelector('[data-id="main"]').textContent
EOF
# (output depends on your app)
Main content
$ echo 'document.title' | tauri-pilot eval -
# (output depends on your app)
PR Dashboard

Prefer <<'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:

Terminal window
$ tauri-pilot eval "const els = document.querySelectorAll('button'); els.length"
7
$ tauri-pilot eval "function pick(n){return n*2;} pick(21)"
42

If 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:

Terminal window
$ tauri-pilot eval "document.querySelector('a')?.click()" && tauri-pilot state
# click fires, then state prints — exit 0

Top-level await is auto-wrapped in an async IIFE, so the natural shape works:

Terminal window
$ 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)'
2

An 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:

Terminal window
$ tauri-pilot eval 'const x = 1; (async () => { if (x) { await 0; } return "done"; })()'
done

For multi-statement scripts (e.g. const followed by a value to surface), use an explicit return since the wrapper has no completion-value semantics:

Terminal window
$ tauri-pilot eval 'const r = await fetch("/api"); return r.status'
200

Without 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.

Terminal window
tauri-pilot ipc <command> [OPTIONS]

Options:

Option Description
--args <json> JSON object of arguments to pass to the command

Example:

Terminal window
tauri-pilot ipc get_prs
tauri-pilot ipc create_pr --args '{"title":"Fix bug","branch":"fix/issue-42"}'

Capture the full page as a PNG using the injected html-to-image bridge.

Terminal window
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:

Terminal window
tauri-pilot screenshot ./dashboard.png
tauri-pilot screenshot --selector "#main-panel" ./panel.png
tauri-pilot screenshot # prints base64 PNG to stdout

Change the WebView URL.

Terminal window
tauri-pilot navigate <url>

Example:

Terminal window
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.

Terminal window
tauri-pilot url

The 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:

Terminal window
$ tauri-pilot url
http://localhost:1420/dashboard

Get the current page title.

Terminal window
tauri-pilot title

Example:

Terminal window
$ tauri-pilot title
PR Dashboard

Get the current page state: URL, title, viewport dimensions, and scroll position.

Terminal window
tauri-pilot state

Example:

Terminal window
$ tauri-pilot state
url http://localhost:1420/dashboard
title PR Dashboard
viewport 1280x800
scroll 0,0

Wait for an element to appear (or disappear) in the DOM.

Terminal window
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:

  • @e3 or e3 — snapshot ref (resolved via idMap); 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:

Terminal window
tauri-pilot wait "#trigger-deferred" # CSS selector via positional
tauri-pilot wait "@e3" # snapshot ref
tauri-pilot wait --selector "#spinner" --gone
tauri-pilot wait --selector ".toast-success" --timeout 5000

Display 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.

Terminal window
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:

Terminal window
# 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
✓ cleared

JSON 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"}

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.

Terminal window
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:

Terminal window
# 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
✓ cleared

JSON 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"}

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.

Terminal window
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:

Terminal window
# 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 list
auth_token = eyJhbGciOiJIUzI1NiJ9...
theme = dark
locale = 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.

Terminal window
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 --json mode)
  • snapshot and diff mask password inputs the same way
  • Output is limited to 100 forms and 500 fields per form; a truncation warning appears if exceeded
  • The --selector must match a <form> element; other elements are rejected with an error

Examples:

Terminal window
# Dump all forms on the page
$ tauri-pilot forms
# Target a specific form
$ tauri-pilot forms --selector "#login-form"
# JSON output
$ tauri-pilot forms --json

JSON-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 user interactions for later replay.

Start recording interactions. All subsequent actions (click, fill, type, etc.) will be captured.

Terminal window
tauri-pilot record start

A 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 a value attribute, 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 and record stop reports it;
  • an expect fingerprint: 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.

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.

Terminal window
tauri-pilot record stop --output test.json

When 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.json
These 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 e7

With --json the same list is under unstable ([{"step": 3, "action": "click", "ref": "e7"}]).

Option Description
--output, -o Output file path (JSON format, required)

Check if recording is currently active.

Terminal window
tauri-pilot record status

Replay a previously recorded session.

Terminal window
tauri-pilot replay test.json

Export as a shell script instead of replaying. The export only reads the file, so it works with no app running:

Terminal window
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 → ✗ SKIP

replay 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.

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.

// 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.

Terminal window
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:

Terminal window
tauri-pilot run docs/examples/login-flow.toml
tauri-pilot run scenario.toml --no-fail-fast --junit results.xml
tauri-pilot run scenario.toml --json --screenshots-dir /tmp/shots

The same scenarios run over MCP through pilot.run, which returns the JSON report instead of JUnit XML — see mcp.


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:

Terminal window
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"}}