Introduction
Victauri — Verified Introspection & Control for Tauri Applications.
Victauri gives AI agents X-ray vision and hands inside Tauri apps. Unlike browser automation tools like Playwright (which only see the browser glass), Victauri provides simultaneous access to the webview DOM, the Rust backend, the IPC layer, the database, and native window state — all through a single MCP interface.
Who Is This For?
- AI agent developers who need to test, debug, or drive Tauri applications
- Tauri app developers who want automated testing with full-stack visibility
- QA engineers looking for deeper inspection than DOM-only tools provide
Key Value Proposition
One plugin, one line of code, full-stack access:
| Layer | What You Get |
|---|---|
| WebView | DOM snapshots, element interaction, JS evaluation, CSS inspection |
| IPC | Command registry, invoke commands, intercept and log IPC traffic |
| Backend | State reading, memory tracking, process diagnostics |
| Windows | Multi-window management, screenshots, positioning |
| Time-Travel | Record sessions, checkpoint state, replay events |
All of this is exposed over the Model Context Protocol (MCP), the standard that AI agents already speak. Connect Claude Code, VS Code Copilot, or any MCP client — and your agent has complete control of the running Tauri application.
Design Principles
-
Same-process — The MCP server runs inside the Tauri app process, not as a separate sidecar. This gives sub-millisecond tool response times and direct
AppHandleaccess. -
Zero-cost in release — Everything is gated behind
#[cfg(debug_assertions)]. In release builds, the plugin is a complete no-op with zero binary size overhead. -
Full-stack — WebView + IPC + Backend + DB, not just DOM. Cross-boundary verification catches state drift between frontend and backend.
-
MCP-native — Speaks the protocol AI agents already understand. No custom SDKs or adapters needed.
-
Cross-platform — Works identically on Windows, macOS, and Linux. No CDP dependency.
-
Plugin, not framework — One line in
Cargo.tomlto add, one line to remove. Your app architecture stays unchanged.
Project Structure
Victauri is a Rust workspace with 7 crates:
victauri/
├── crates/
│ ├── victauri-browser/ # Chrome extension native host: MCP for any website
│ ├── victauri-cli/ # CLI: init, check, test, record, watch, coverage
│ ├── victauri-core/ # Shared types: events, registry, snapshots
│ ├── victauri-macros/ # Proc macros: #[inspectable]
│ ├── victauri-plugin/ # Tauri plugin: embedded MCP server + JS bridge
│ ├── victauri-test/ # Test client + assertion helpers
│ └── victauri-watchdog/ # Crash-recovery health monitor
├── extensions/
│ ├── chrome/ # Chrome/Edge/Brave extension (MV3)
│ ├── firefox/ # Firefox extension (MV3)
│ └── npm/ # victauri-browser npm package
├── editors/
│ └── vscode/ # VS Code extension
└── examples/
└── demo-app/ # Reference Tauri app with full test suite
Current Status
All 7 crates are published to crates.io. 1976 tests pass (1813 Rust + 163 JavaScript). Tested against 5 real-world open-source Tauri apps (96.9% pass rate across 895 tests) with zero Victauri bugs found. Supports Tauri 2.0+ with rmcp 1.5.0.
Getting Started
Get Victauri running in your Tauri app in under 5 minutes.
Prerequisites
- A Tauri 2.0+ application
- Rust toolchain (stable)
- An MCP client (Claude Code, VS Code, or any MCP-compatible tool)
Step 1: Add the Dependency
Add victauri-plugin to your app’s src-tauri/Cargo.toml:
[dependencies]
victauri-plugin = "0.3"
The plugin must be a regular dependency (not [dev-dependencies]) because it runs inside your app process. In release builds, init() returns a no-op plugin with zero overhead — no feature flags needed.
Step 2: Initialize the Plugin
Add victauri::init() to your Tauri builder in src-tauri/src/main.rs:
fn main() {
tauri::Builder::default()
.plugin(victauri_plugin::init())
// ... your other plugins and setup
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
That’s it. In debug builds, this starts an MCP server on 127.0.0.1:7373. In release builds, it’s a no-op.
Step 3: Add Capabilities
Add the victauri:default capability to your app’s capabilities file. Create or edit src-tauri/capabilities/default.json:
{
"identifier": "default",
"windows": ["*"],
"permissions": [
"core:default",
"victauri:default"
]
}
Without this capability, the Tauri permission system silently blocks IPC callbacks and the plugin cannot interact with your webviews.
Step 4: Connect via MCP
Once your app is running in debug mode, the MCP server is available at:
http://127.0.0.1:7373/mcp
Claude Code Connection
Create a .mcp.json file in your project root:
{
"mcpServers": {
"victauri": {
"url": "http://127.0.0.1:7373/mcp"
}
}
}
Claude Code will automatically discover and connect to your running app.
With Authentication
By default, Victauri generates a random auth token and prints it to the console on startup. To use a fixed token:
#![allow(unused)]
fn main() {
tauri::Builder::default()
.plugin(
victauri_plugin::VictauriBuilder::new()
.auth_token("my-secret-token")
.build(),
)
.run(tauri::generate_context!())
.unwrap();
}
Then include it in your .mcp.json:
{
"mcpServers": {
"victauri": {
"url": "http://127.0.0.1:7373/mcp",
"headers": {
"Authorization": "Bearer my-secret-token"
}
}
}
}
Or disable auth entirely for local development:
#![allow(unused)]
fn main() {
.plugin(
victauri_plugin::VictauriBuilder::new()
.auth_disabled()
.build(),
)
}
Step 5: Verify It Works
With your app running, check the health endpoint:
curl http://127.0.0.1:7373/health
# Returns: ok
curl http://127.0.0.1:7373/info
# Returns: {"name":"victauri","port":7373,"protocol":"mcp","version":"0.3.0",...}
Or use the Victauri CLI:
cargo install victauri-cli
victauri check
Optional: Register Commands
To enable command discovery and ghost command detection, annotate your Tauri commands with #[inspectable] and register them:
use victauri_plugin::inspectable;
#[inspectable(description = "Greet a user", intent = "greeting")]
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
fn main() {
tauri::Builder::default()
.plugin(victauri_plugin::init())
.invoke_handler(tauri::generate_handler![greet])
.setup(|app| {
victauri_plugin::register_commands!(app, greet__schema());
Ok(())
})
.run(tauri::generate_context!())
.unwrap();
}
Optional: REST API
All 28 tools are also available via a REST API without MCP session overhead:
# List available tools
curl http://127.0.0.1:7373/api/tools
# Execute a tool directly
curl -X POST http://127.0.0.1:7373/api/tools/eval_js \
-H "Content-Type: application/json" \
-d '{"expression": "document.title"}'
Next Steps
- Architecture — Understand how Victauri works under the hood
- Tools Reference — Complete list of all 28 tools
- Configuration — Customize port, auth, privacy, and more
- Testing — Write automated tests with the victauri-test crate
Architecture
Victauri embeds a full MCP server inside your running Tauri application. This page explains the design decisions and how the pieces fit together.
The Three Layers
Victauri provides access to three distinct layers of a Tauri application:
┌─────────────────────────────────────────────────┐
│ MCP Client │
│ (Claude Code, VS Code, etc.) │
└─────────────────┬───────────────────────────────┘
│ HTTP/SSE (localhost:7373)
┌─────────────────▼───────────────────────────────┐
│ Victauri Plugin │
│ (axum server + tool handlers) │
├─────────────────┬────────────┬──────────────────┤
│ WebView │ IPC │ Backend │
│ (JS Bridge) │ (Intercept)│ (AppHandle) │
└────────┬────────┴─────┬──────┴────────┬─────────┘
│ │ │
DOM/Events Command Flow Rust State
WebView Layer
The JS bridge is injected into every webview via js_init_script() (persistent across navigations). It provides:
- DOM snapshots — Full accessible tree with ARIA roles, names, and ref handles
- Element interaction — Click, hover, fill, type, press keys with Playwright-grade actionability checks
- JS evaluation — Run arbitrary JavaScript with async/await support
- CSS inspection — Computed styles, bounding boxes with box model
- Console/mutation logs — Captured in-bridge with configurable capacity
- Network interception — Fetch and XMLHttpRequest monitoring
- Navigation tracking — pushState, replaceState, popstate, hashchange
IPC Layer
Tauri 2.0 sends all IPC via fetch() to http://ipc.localhost/<command>. Victauri intercepts this at the network level:
- Command registry — Discover all available commands with metadata
- IPC log — Full history of command invocations with timing
- Ghost command detection — Find frontend-invoked commands not in the registry
- Integrity checking — Detect stale, pending, or errored IPC calls
Backend Layer
Since the plugin runs in the same process, it has direct access to:
- AppHandle — Manage windows, invoke commands, read state
- Memory stats — Real OS process memory (working set, page faults)
- Diagnostics — Plugin uptime, tool invocation counts, configuration
Same-Process Embedded Design
Unlike external automation tools that communicate over DevTools Protocol or WebSocket bridges, Victauri runs inside the application process:
External approach: Victauri approach:
Agent ──HTTP──► Proxy Agent ──HTTP──► Tauri App
│ (contains MCP server)
CDP Direct AppHandle access
│ Sub-ms response times
Browser No state drift
Benefits:
- No state drift — The MCP server reads the same memory as the application
- Sub-millisecond responses — No IPC hop to an external process
- Full access — Can read Rust state, invoke commands, access the database directly
- Single dependency — No separate process to manage or keep alive
The JS Bridge
The bridge (window.__VICTAURI__) is injected as an init script so it survives page navigations:
// Available methods on window.__VICTAURI__:
__VICTAURI__.version // Bridge version string
__VICTAURI__.snapshot() // Full DOM tree with refs
__VICTAURI__.getRef(id) // Get element by ref handle
__VICTAURI__.click(ref) // Click with actionability checks
__VICTAURI__.fill(ref, val) // Set input value
__VICTAURI__.type(ref, text) // Type character-by-character
__VICTAURI__.pressKey(key) // Dispatch keyboard event
__VICTAURI__.getConsoleLogs() // Captured console entries
__VICTAURI__.getStyles(ref) // Computed CSS properties
// ... 20+ methods total
Ref Handles
Following Playwright MCP’s pattern, elements are identified by short-lived ref handles rather than CSS selectors:
- Refs are derived from the accessible tree (ARIA roles and names)
- They are short strings like
"e3"or"e47" - They survive DOM restructuring within a single snapshot
- A new
dom_snapshotgenerates fresh refs
This avoids brittle CSS selectors and gives agents a semantic view of the UI.
Actionability Checks
Before interactions (click, fill, type, hover), the bridge performs Playwright-grade checks:
- Element exists in DOM
- Element is visible (not
display:noneorvisibility:hidden) - Element is enabled (not
disabledattribute) - Element has non-zero size
- Element is not covered by another element (hit-test)
- Element does not have
pointer-events:none - Element is in viewport (with auto-scroll)
- Element is stable (not animating)
- Element is attached to DOM
- Element is actionable for the specific operation
Dual Protocol: MCP + REST
Victauri serves both protocols on the same port:
| Endpoint | Protocol | Use Case |
|---|---|---|
/mcp | MCP Streamable HTTP + SSE | AI agents (Claude Code, etc.) |
/api/tools | REST (plain JSON) | Scripts, CI, curl, custom integrations |
/health | GET (no auth) | Health checks, watchdog |
/info | GET | Server metadata |
The REST API uses the same tool dispatch, auth, rate limiting, and privacy enforcement as MCP. It simply removes the session/handshake overhead.
MCP Resources
Three subscribable resources provide real-time state:
victauri://state— Plugin state (commands registered, events captured, memory, port)victauri://windows— All window states (position, size, visibility, URL)victauri://ipc-log— Recent IPC call history
Port Fallback
If port 7373 is already in use (e.g., another Tauri app running Victauri), the server tries ports 7374 through 7383. The actual bound port is written to a temp file (<temp>/victauri.port) for client discovery and cleaned up on shutdown.
Release Safety
The entire plugin is gated:
#![allow(unused)]
fn main() {
pub fn init<R: Runtime>() -> TauriPlugin<R> {
#[cfg(debug_assertions)]
{ /* full MCP server setup */ }
#[cfg(not(debug_assertions))]
{ /* no-op plugin with zero overhead */ }
}
}
In release builds:
- No axum server is started
- No JS bridge is injected
- No memory is allocated for event logs
- The binary size increase is zero
Tools Reference
Victauri exposes 28 MCP tools organized into standalone tools (one action per call) and compound tools (multiple actions via an action parameter).
All tools are accessible via MCP at /mcp or REST at POST /api/tools/{tool_name}.
Backend Tools
These tools access the Rust backend directly — no webview proxy, no JavaScript evaluation.
app_info
Get application configuration, directory paths, environment, discovered databases, and process info.
Parameters: None required.
Returns: {config, paths, databases, process, environment}
list_app_dir
Browse files in app backend directories (data, config, log, local_data).
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
dir_type | string | no | One of: data, config, log, local_data (default: data) |
subpath | string | no | Subdirectory to list within the chosen directory |
Returns: {path, entries: [{name, size, is_dir, modified}]}
read_app_file
Read a file from one of the app’s backend directories.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
path | string | yes | File path relative to the directory root |
dir_type | string | no | One of: data, config, log, local_data (default: data) |
Returns: {content, encoding, size} — UTF-8 text or base64-encoded binary.
query_db
Execute a read-only SQL query against a SQLite database in the app’s data directory.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
sql | string | yes | SQL query (SELECT only) |
db_path | string | no | Path to database file (auto-discovers if omitted) |
params | array | no | Bind parameters for the query |
Examples:
{"sql": "SELECT * FROM users WHERE active = ?", "params": [true]}
{"sql": "SELECT count(*) FROM items", "db_path": "app.db"}
Returns: {columns, rows, row_count}
Webview & IPC Tools
eval_js
Evaluate JavaScript in the webview and return the result.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
expression | string | yes | JavaScript expression or statements to evaluate |
webview_label | string | no | Target webview (defaults to “main” or first visible) |
Examples:
{"expression": "document.title"}
{"expression": "document.querySelectorAll('button').length"}
{"expression": "await fetch('/api/data').then(r => r.json())"}
Bare expressions are auto-wrapped with return. Multi-statement code and async/await are supported.
dom_snapshot
Capture a full accessible DOM tree with ref handles for every element.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
webview_label | string | no | Target webview |
Returns: Tree of elements with ref, role, name, children, and bounding box data.
find_elements
Search for elements by CSS selector or text content.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
selector | string | no | CSS selector (alias: css) |
text | string | no | Text content to search for |
role | string | no | ARIA role to filter by |
webview_label | string | no | Target webview |
Examples:
{"selector": "button.primary"}
{"text": "Submit"}
{"role": "heading"}
invoke_command
Invoke a Tauri command from the backend.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
command | string | yes | Command name |
args | object | no | Arguments to pass |
Example:
{"command": "get_settings", "args": {}}
{"command": "search_context", "args": {"query": "hello"}}
screenshot
Capture a PNG screenshot of the application window.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
window_label | string | no | Target window (defaults to main) |
Returns: Base64-encoded PNG image data.
verify_state
Compare frontend and backend state to detect drift.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
frontend_expr | string | no | JS expression for frontend state |
backend_state | object | no | Expected backend state to compare |
Example:
{
"frontend_expr": "document.title",
"backend_state": {"title": "My App"}
}
detect_ghost_commands
Find commands invoked by the frontend that are not registered in the backend registry.
Parameters: None required.
Returns: List of ghost commands with invocation counts.
check_ipc_integrity
Verify the health of IPC communication.
Parameters: None required.
Returns: {healthy, total_calls, pending, stale, errored}
wait_for
Wait for a condition to become true, polling until timeout.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
condition | string | yes | One of: selector, selector_gone, text, text_gone, url |
value | string | yes | The selector, text, or URL pattern to match |
timeout_ms | number | no | Max wait time in ms (default: 5000) |
Example:
{"condition": "selector", "value": ".modal.open", "timeout_ms": 3000}
{"condition": "url", "value": "/dashboard"}
assert_semantic
Assert a condition about the application state using JS expressions.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
expression | string | yes | JS expression to evaluate |
condition | string | yes | One of: equals, not_equals, contains, greater_than, less_than, truthy, falsy |
expected | any | no | Expected value (not needed for truthy/falsy) |
Example:
{
"expression": "document.title",
"condition": "equals",
"expected": "My App"
}
resolve_command
Resolve a natural language description to registered commands.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
query | string | yes | Natural language description |
Example:
{"query": "show settings"}
get_registry
List all registered commands with their metadata.
Parameters: None.
get_memory_stats
Get real OS process memory usage.
Parameters: None.
Returns: {working_set_bytes, peak_working_set_bytes, page_fault_count, page_file_bytes}
get_plugin_info
Get plugin version, uptime, configuration, and capabilities.
Parameters: None.
get_diagnostics
Get detailed diagnostic information about the plugin state.
Parameters: None.
Compound Tools
Compound tools use an action parameter to select the specific operation.
interact
Element interactions with actionability checks.
| Action | Parameters | Description |
|---|---|---|
click | ref_id | Click an element |
hover | ref_id | Hover over an element |
focus | ref_id | Focus an element |
scroll_into_view | ref_id | Scroll element into viewport |
select | ref_id, value | Select an option |
Example:
{"action": "click", "ref_id": "e3"}
{"action": "hover", "ref_id": "e12"}
input
Text input and keyboard operations.
| Action | Parameters | Description |
|---|---|---|
fill | ref_id, value | Set input value directly |
type | ref_id, text | Type character-by-character |
press_key | key | Press a keyboard key |
Example:
{"action": "fill", "ref_id": "e5", "value": "hello@example.com"}
{"action": "type", "ref_id": "e5", "text": "Hello"}
{"action": "press_key", "key": "Enter"}
Supported keys: Tab, Escape, Enter, ArrowUp, ArrowDown, ArrowLeft, ArrowRight, F1-F12, and any single character.
window
Window management operations.
| Action | Parameters | Description |
|---|---|---|
get_state | label | Get window state (position, size, visibility) |
list | — | List all window labels |
manage | label, operation | minimize/unminimize/maximize/unmaximize/close |
resize | label, width, height | Resize a window |
move_to | label, x, y | Move a window |
set_title | label, title | Change window title |
Example:
{"action": "list"}
{"action": "get_state", "label": "main"}
{"action": "resize", "label": "main", "width": 1200, "height": 800}
storage
Browser storage operations.
| Action | Parameters | Description |
|---|---|---|
get | key | Get localStorage value |
set | key, value | Set localStorage value |
delete | key | Delete localStorage key |
cookies | — | Get all cookies |
Example:
{"action": "set", "key": "theme", "value": "dark"}
{"action": "get", "key": "theme"}
navigate
Navigation and history operations.
| Action | Parameters | Description |
|---|---|---|
go_to | url | Navigate to a URL (http/https only) |
back | — | Go back in history |
history | — | Get navigation history log |
dialogs | — | Get dialog log (alerts, confirms, prompts) |
Example:
{"action": "go_to", "url": "https://example.com"}
{"action": "history"}
recording
Time-travel recording for session capture and replay.
| Action | Parameters | Description |
|---|---|---|
start | — | Start recording events |
stop | — | Stop recording and return session |
checkpoint | label | Create a named checkpoint |
events | since, limit | Get recorded events |
export | — | Export full session data |
import | session | Import a session for replay |
Example:
{"action": "start"}
{"action": "checkpoint", "label": "after-login"}
{"action": "stop"}
inspect
CSS inspection, accessibility, and performance profiling.
| Action | Parameters | Description |
|---|---|---|
styles | ref_id, properties | Get computed CSS styles |
bounds | ref_ids | Get bounding boxes with box model |
highlight | ref_id, color, label | Draw debug overlay on element |
accessibility | — | Run WCAG accessibility audit |
performance | — | Get performance metrics |
Example:
{"action": "styles", "ref_id": "e3", "properties": ["color", "font-size"]}
{"action": "bounds", "ref_ids": ["e1", "e2", "e3"]}
{"action": "accessibility"}
{"action": "performance"}
The accessibility audit checks: missing alt text, unlabeled form inputs, empty buttons/links, heading hierarchy, color contrast (WCAG AA), ARIA role validity, positive tabindex, and missing document language/title.
Performance metrics include: navigation timing, resource summary, paint timing (FP/FCP), JS heap usage, long task count, and DOM statistics.
css
CSS injection for debugging and prototyping.
| Action | Parameters | Description |
|---|---|---|
inject | css | Inject custom CSS (replaces previous) |
remove | — | Remove injected CSS |
Example:
{"action": "inject", "css": "* { outline: 1px solid red; }"}
{"action": "remove"}
logs
Access all captured logs from the application.
| Action | Parameters | Description |
|---|---|---|
console | since, level | Console log entries |
network | since | Network request log |
ipc | since, limit | IPC command log |
navigation | — | Navigation history |
dialogs | — | Dialog interactions |
events | since | Event stream |
slow_ipc | threshold_ms | IPC calls slower than threshold |
Example:
{"action": "console", "level": "error"}
{"action": "network"}
{"action": "slow_ipc", "threshold_ms": 100}
Testing
Victauri provides a complete testing toolkit: a typed HTTP client, a Locator API with auto-waiting, assertion helpers, a fluent verification builder, visual regression testing, IPC coverage tracking, and a CLI for running tests from the terminal.
Quick Start
Add the test crate to your dev dependencies:
[dev-dependencies]
victauri-test = "0.3"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
Write a test:
#![allow(unused)]
fn main() {
use victauri_test::{e2e_test, VictauriClient};
e2e_test!(greet_flow, |client| async move {
client.fill_by_id("name-input", "World").await.unwrap();
client.click_by_id("greet-btn").await.unwrap();
client.expect_text("Hello, World!").await.unwrap();
});
}
Run it:
pnpm tauri dev # start your app
VICTAURI_E2E=1 cargo test --test smoke # run tests
Test Client
VictauriClient
The VictauriClient is a typed HTTP client that handles MCP session lifecycle automatically:
#![allow(unused)]
fn main() {
use victauri_test::VictauriClient;
#[tokio::test]
async fn test_my_app() {
let client = VictauriClient::connect(7373).await.unwrap();
let title = client.eval_js("document.title").await.unwrap();
assert!(title.contains("My App"));
client.click("e3").await.unwrap();
client.fill("e5", "hello@example.com").await.unwrap();
}
}
Auto-Discovery
discover() reads the port and auth token from temp files written by the plugin:
#![allow(unused)]
fn main() {
let mut client = VictauriClient::discover().await.unwrap();
}
With Authentication
#![allow(unused)]
fn main() {
let client = VictauriClient::connect_with_token(7373, "my-secret-token")
.await
.unwrap();
}
Direct Client Methods
High-level methods that find elements by text or ID — no ref handles or selectors needed:
| Method | What it does |
|---|---|
click_by_text("Submit") | Find element by visible text, click it |
click_by_id("save-btn") | Find element by HTML id, click it |
fill_by_id("email", "a@b.com") | Find input by id, fill value |
type_by_id("search", "query") | Find input by id, type char-by-char |
select_by_id("theme", "dark") | Find select by id, choose option |
expect_text("Success!") | Poll until text appears (5s timeout) |
expect_no_text("Error") | Poll until text disappears (3s timeout) |
text_by_id("counter") | Get text content of element by id |
double_click_by_id("item") | Find element by id, double-click it |
hover("e3") | Hover over element by ref |
scroll_to_by_id("footer") | Scroll element into viewport |
Low-Level Client Methods
For direct MCP tool access using ref handles:
| Method | Description |
|---|---|
eval_js(expr) | Evaluate JavaScript |
dom_snapshot() | Get full DOM tree |
find_elements(selector) | Find elements by CSS |
click(ref_id) | Click element |
fill(ref_id, value) | Fill input |
type_text(ref_id, text) | Type characters |
press_key(key) | Press keyboard key |
screenshot(label) | Capture PNG |
get_window_state(label) | Window position/size |
list_windows() | All window labels |
invoke_command(name, args) | Call Tauri command |
get_ipc_log(limit) | IPC call history |
get_registry() | Registered commands |
get_memory_stats() | Process memory |
verify_state(frontend, backend) | Cross-boundary check |
detect_ghost_commands() | Unregistered commands |
check_ipc_integrity() | IPC health |
assert_semantic(expr, cond, expected) | Semantic assertion |
wait_for(condition, value, timeout) | Wait for condition |
start_recording() | Begin time-travel |
stop_recording() | End recording |
checkpoint(label) | Create checkpoint |
get_console_logs(since) | Console entries |
audit_accessibility() | WCAG audit |
get_performance_metrics() | Navigation timing, heap, resources |
query_db(sql, db_path, params) | SQLite query |
app_info() | App config and paths |
Locator API
For complex queries, Victauri provides composable locators with auto-waiting expectations.
Factory Methods
Create locators by different strategies:
| Factory | Example | Finds by |
|---|---|---|
Locator::role("button") | ARIA role | role="button" |
Locator::text("Submit") | Visible text (substring) | textContent |
Locator::text_exact("OK") | Visible text (exact match) | textContent |
Locator::test_id("login-btn") | Test ID attribute | data-testid |
Locator::css(".nav > a") | CSS selector | CSS query |
Locator::label("Email") | Associated label text | <label> + for |
Locator::placeholder("Search...") | Placeholder attribute | placeholder |
Locator::alt_text("Logo") | Alt text (images) | alt |
Locator::title("Close") | Title attribute | title |
Refinement
Narrow results with chained filters:
#![allow(unused)]
fn main() {
// Button with role "button" AND text containing "Save"
let save = Locator::role("button").and_text("Save");
// Third item in a list
let third = Locator::role("listitem").nth(2);
// Input with specific tag
let textarea = Locator::label("Description").and_tag("textarea");
}
Actions
Interact with resolved elements:
#![allow(unused)]
fn main() {
locator.click(&mut client).await?;
locator.double_click(&mut client).await?;
locator.fill(&mut client, "value").await?;
locator.type_text(&mut client, "typed").await?;
locator.press_key(&mut client, "Enter").await?;
locator.press_key(&mut client, "Control+a").await?; // keyboard combos
locator.hover(&mut client).await?;
locator.focus(&mut client).await?;
locator.scroll_into_view(&mut client).await?;
locator.select_option(&mut client, &["dark"]).await?;
locator.check(&mut client).await?; // checkbox
locator.uncheck(&mut client).await?;
}
Queries
Read element state:
#![allow(unused)]
fn main() {
let text = locator.text_content(&mut client).await?;
let value = locator.input_value(&mut client).await?;
let visible = locator.is_visible(&mut client).await?;
let enabled = locator.is_enabled(&mut client).await?;
let checked = locator.is_checked(&mut client).await?;
let focused = locator.is_focused(&mut client).await?;
let count = locator.count(&mut client).await?;
let bounds = locator.bounding_box(&mut client).await?;
let attr = locator.get_attribute(&mut client, "href").await?;
let all = locator.all(&mut client).await?; // all matches
let texts = locator.all_text_contents(&mut client).await?;
}
Expectations
Auto-waiting assertions with configurable timeout:
#![allow(unused)]
fn main() {
// Wait up to 5s (default) for element to become visible
locator.expect(&mut client).to_be_visible().await?;
// Custom timeout and polling
locator.expect(&mut client)
.timeout_ms(10_000)
.poll_ms(100)
.to_have_text("Complete")
.await?;
// Negation — wait until condition is NOT true
locator.expect(&mut client).not().to_be_visible().await?;
}
| Expectation | Waits until |
|---|---|
.to_be_visible() | Element is visible |
.to_be_hidden() | Element is hidden |
.to_be_enabled() | Element is enabled |
.to_be_disabled() | Element is disabled |
.to_be_focused() | Element has focus |
.to_have_text("exact") | Text content equals value |
.to_contain_text("partial") | Text content contains value |
.to_have_value("input-val") | Input value equals value |
.to_have_attribute("href", "/home") | Attribute equals value |
.to_have_count(3) | Exactly N elements match |
.to_be_checked() | Checkbox/radio is checked |
.to_be_unchecked() | Checkbox/radio is unchecked |
.to_be_attached() | Element exists in DOM |
.to_be_detached() | Element removed from DOM |
Full Locator Example
#![allow(unused)]
fn main() {
use victauri_test::prelude::*;
#[tokio::test]
async fn settings_flow() {
if !is_e2e() { return; }
let mut client = VictauriClient::discover().await.unwrap();
let save_btn = Locator::role("button").and_text("Save");
let email = Locator::label("Email address");
let toast = Locator::test_id("toast-message");
email.fill(&mut client, "user@example.com").await.unwrap();
save_btn.click(&mut client).await.unwrap();
toast.expect(&mut client)
.to_contain_text("Settings saved")
.await
.unwrap();
toast.expect(&mut client)
.timeout_ms(10_000)
.not()
.to_be_visible()
.await
.unwrap();
}
}
Zero-Boilerplate Tests
The e2e_test! macro handles skip-when-no-server and auto-connect:
#![allow(unused)]
fn main() {
use victauri_test::{e2e_test, VictauriClient};
e2e_test!(greet_flow, |client| async move {
client.fill_by_id("name-input", "World").await.unwrap();
client.click_by_id("greet-btn").await.unwrap();
client.expect_text("Hello, World!").await.unwrap();
});
}
Managed App Lifecycle
TestApp starts your app, waits for the server, and cleans up on drop:
#![allow(unused)]
fn main() {
use victauri_test::TestApp;
#[tokio::test]
async fn full_lifecycle() {
let app = TestApp::spawn("cargo run -p my-app").await.unwrap();
let mut client = app.client().await.unwrap();
client.click_by_text("Start").await.unwrap();
client.expect_text("Running").await.unwrap();
// app process is killed when `app` is dropped
}
}
IPC Verification
Assert IPC Calls Happened
#![allow(unused)]
fn main() {
use victauri_test::{assert_ipc_called, assert_ipc_called_with, assert_ipc_not_called};
client.click_by_id("save-btn").await?;
let log = client.get_ipc_log(None).await?;
assert_ipc_called(&log, "save_settings");
assert_ipc_called_with(&log, "save_settings", &json!({"theme": "dark"}));
assert_ipc_not_called(&log, "delete_account");
}
IPC Checkpoints
Isolate assertions to a specific user action:
#![allow(unused)]
fn main() {
let checkpoint = client.create_ipc_checkpoint().await?;
client.click_by_id("save-btn").await?;
let calls = client.get_ipc_calls_since(checkpoint).await?;
assert_eq!(calls.len(), 1);
assert_eq!(calls[0]["command"], "save_settings");
}
Cross-Boundary Verification
Detect when the frontend and backend disagree:
#![allow(unused)]
fn main() {
let result = client.verify_state(
"document.querySelector('.theme-label').textContent",
json!({"theme": "dark"})
).await?;
assert!(result["divergences"].as_array().unwrap().is_empty());
}
Ghost Command Detection
Find orphaned commands — called in the frontend but missing from the backend:
#![allow(unused)]
fn main() {
let ghosts = client.detect_ghost_commands().await?;
assert!(ghosts["ghost_commands"].as_array().unwrap().is_empty(),
"Found ghost commands: {ghosts}");
}
IPC Health Check
Detect stuck, stale, or errored IPC calls:
#![allow(unused)]
fn main() {
let health = client.check_ipc_integrity().await?;
assert!(health["healthy"].as_bool().unwrap());
}
Backend Access
Victauri provides direct access to the Rust backend — no webview proxy needed.
Query SQLite Databases
#![allow(unused)]
fn main() {
let result = client.query_db(
"SELECT * FROM users WHERE active = ?",
None, // auto-discover database
Some(vec![json!(true)]), // bind parameters
).await?;
println!("{} rows", result["row_count"]);
for row in result["rows"].as_array().unwrap() {
println!(" {} ({})", row["name"], row["email"]);
}
}
Inspect App Configuration
#![allow(unused)]
fn main() {
let info = client.app_info().await?;
println!("App: {}", info["config"]["product_name"]);
println!("Data dir: {}", info["paths"]["data"]);
println!("Databases found: {:?}", info["databases"]);
}
Browse and Read Backend Files
#![allow(unused)]
fn main() {
let files = client.list_app_dir(Some("data"), None).await?;
for entry in files["entries"].as_array().unwrap() {
println!(" {} ({} bytes)", entry["name"], entry["size"]);
}
let content = client.read_app_file("settings.json", Some("config")).await?;
println!("{}", content["content"]);
}
End-to-End: UI Action to Database Verification
#![allow(unused)]
fn main() {
client.click_by_id("save-btn").await?;
let log = client.get_ipc_log(None).await?;
assert_ipc_called(&log, "save_settings");
let result = client.query_db(
"SELECT value FROM settings WHERE key = 'theme'",
None, None,
).await?;
assert_eq!(result["rows"][0]["value"], "dark");
}
Fluent Verification
Check multiple conditions at once — DOM, IPC, accessibility, errors — with a single report:
#![allow(unused)]
fn main() {
let report = client.verify()
.has_text("Settings saved")
.has_no_text("Error")
.ipc_was_called("save_settings")
.ipc_was_called_with("save_settings", json!({"theme": "dark"}))
.ipc_was_not_called("delete_account")
.no_console_errors()
.no_ghost_commands()
.ipc_healthy()
.coverage_above(80.0)
.run()
.await?;
report.assert_all_passed();
for failure in report.failures() {
eprintln!("FAILED: {} — {}", failure.description, failure.detail);
}
}
Visual Regression Testing
Compare screenshots against stored baselines with pixel-level diffing:
#![allow(unused)]
fn main() {
use victauri_test::visual::{VisualOptions, ThresholdPreset, MaskRegion};
let opts = VisualOptions {
snapshot_dir: "tests/snapshots".into(),
..VisualOptions::from_preset(ThresholdPreset::Standard)
};
let diff = client.screenshot_visual("dashboard", &opts).await?;
assert!(diff.is_match, "visual regression: {:.2}% pixels differ", diff.diff_percentage);
}
On first run, the screenshot is saved as the baseline. Subsequent runs compare and generate a red-overlay diff image when mismatched.
Threshold Presets
| Preset | Tolerance | Threshold | Use case |
|---|---|---|---|
Strict | 0 | 0.0% | Pixel-perfect, no variation |
Standard | 2 | 0.1% | Most apps, minor anti-aliasing OK |
AntiAlias | 5 | 0.5% | Cross-browser font rendering |
Relaxed | 10 | 2.0% | Cross-platform CI |
Mask Regions
Exclude dynamic content from comparison:
#![allow(unused)]
fn main() {
let opts = VisualOptions {
snapshot_dir: "tests/snapshots".into(),
masks: vec![
MaskRegion::new(0, 0, 200, 50), // timestamp header
],
..VisualOptions::from_preset(ThresholdPreset::Standard)
};
}
Save Screenshots to Files
#![allow(unused)]
fn main() {
client.screenshot_to_file("debug.png").await?;
client.screenshot_to_file_for("main", "main-window.png").await?;
}
IPC Coverage
Track which registered Tauri commands your tests actually exercise:
#![allow(unused)]
fn main() {
use victauri_test::coverage::coverage_report;
let report = coverage_report(&mut client).await?;
println!("{}", report.to_summary());
assert!(report.meets_threshold(80.0),
"Coverage {:.1}% below 80% threshold", report.coverage_percentage);
}
Or inline with the fluent builder:
#![allow(unused)]
fn main() {
client.verify()
.has_text("Welcome")
.coverage_above(80.0)
.run().await?.assert_all_passed();
}
From the CLI:
victauri coverage --threshold 80
Prerequisite: Commands must use #[inspectable] to be tracked. See Command Instrumentation.
Accessibility Auditing
Run WCAG-based accessibility checks against your running app:
#![allow(unused)]
fn main() {
let audit = client.audit_accessibility().await?;
let violations = audit["summary"]["violations"].as_u64().unwrap_or(0);
assert_eq!(violations, 0, "Accessibility violations found: {audit}");
}
Checks include: images without alt text, unlabeled form inputs, empty buttons/links, heading hierarchy, color contrast (WCAG AA), ARIA role validity, positive tabindex, missing document language and title.
Use the assertion helper for a one-liner:
#![allow(unused)]
fn main() {
use victauri_test::assert_no_a11y_violations;
let audit = client.audit_accessibility().await?;
assert_no_a11y_violations(&audit);
}
Performance Monitoring
Track navigation timing, memory usage, and resource loading:
#![allow(unused)]
fn main() {
let metrics = client.get_performance_metrics().await?;
let heap_mb = metrics["heap"]["usedJSHeapSize"]
.as_f64().unwrap_or(0.0) / 1_048_576.0;
assert!(heap_mb < 256.0, "Heap usage too high: {heap_mb:.1} MB");
let load_ms = metrics["navigation"]["loadEventEnd"]
.as_f64().unwrap_or(0.0);
assert!(load_ms < 3000.0, "Page load too slow: {load_ms:.0}ms");
}
Or use the assertion helper with a budget:
#![allow(unused)]
fn main() {
use victauri_test::assert_performance_budget;
let metrics = client.get_performance_metrics().await?;
assert_performance_budget(&metrics, 5000.0, 512.0); // max load ms, max heap MB
}
Metrics include: DNS lookup time, TTFB, DOM interactive/complete, load event, resource summary (count, transfer size, by type, 5 slowest), paint timing (FP, FCP), JS heap usage, long task count, DOM stats.
Time-Travel Recording
Record interactions, create checkpoints, and generate test files.
Record from the CLI
victauri record --output tests/login_flow.rs --test-name login_flow
# Interact with your app...
# Press Ctrl+C to stop and generate the test file
Record Programmatically
#![allow(unused)]
fn main() {
client.start_recording(Some("my-session")).await?;
client.checkpoint("before-login").await?;
client.fill_by_id("email", "user@example.com").await?;
client.click_by_id("login-btn").await?;
client.checkpoint("after-login").await?;
let events = client.events_between("before-login", "after-login").await?;
let session = client.stop_recording().await?;
}
Command Instrumentation
Mark your Tauri commands with #[inspectable] for coverage tracking, ghost detection, and natural language resolution:
#![allow(unused)]
fn main() {
use victauri_macros::inspectable;
#[tauri::command]
#[inspectable(
description = "Save user preferences",
intent = "persist settings",
category = "settings",
example = "save the user's theme preference"
)]
async fn save_settings(settings: Settings) -> Result<(), AppError> {
// your code
}
}
This generates a command schema at compile time — zero runtime cost. Commands become discoverable through get_registry and natural language via resolve_command.
To auto-discover all instrumented commands:
#![allow(unused)]
fn main() {
tauri::Builder::default()
.plugin(
victauri_plugin::VictauriBuilder::new()
.auto_discover()
.build()
.unwrap(),
)
// ...
}
Assertion Helpers
Standalone Functions
#![allow(unused)]
fn main() {
use victauri_test::{
assert_json_eq,
assert_json_truthy,
assert_no_a11y_violations,
assert_performance_budget,
assert_ipc_healthy,
assert_state_matches,
};
assert_json_eq(&client, "document.title", "My App").await;
assert_json_truthy(&client, "document.querySelector('nav')").await;
assert_no_a11y_violations(&client).await;
assert_performance_budget(&client, 100.0, 50.0).await;
assert_ipc_healthy(&client).await;
assert_state_matches(&client, "document.title", json!({"title": "My App"})).await;
}
Client Assertion Methods
#![allow(unused)]
fn main() {
client.assert_eval_works().await;
client.assert_dom_snapshot_valid().await;
client.assert_screenshot_ok().await;
client.assert_windows_exist(&["main"]).await;
client.assert_ipc_integrity_ok().await;
client.assert_accessible().await;
client.assert_dom_complete_under(5000).await;
client.assert_heap_under_mb(200.0).await;
client.assert_no_uncaught_errors().await;
client.assert_recording_lifecycle().await;
client.assert_health_hardened().await;
}
Smoke Test Suite
Run the built-in 11-check smoke test programmatically:
#![allow(unused)]
fn main() {
use victauri_test::{VictauriClient, SmokeConfig};
#[tokio::test]
async fn smoke() {
let client = VictauriClient::connect(7373).await.unwrap();
let report = client.smoke_test(SmokeConfig::default()).await;
println!("Passed: {}/{}", report.passed, report.total);
assert!(report.all_passed());
// Custom thresholds
let config = SmokeConfig {
max_load_ms: 3000,
max_heap_mb: 150.0,
..Default::default()
};
let report = client.smoke_test(config).await;
}
}
The 11 checks: health endpoint, eval, DOM snapshot, screenshot, window state, IPC integrity, memory, accessibility (violations), accessibility (warnings), performance, and health endpoint hardening.
Reports include timing data and can export to JUnit XML for CI integration.
Common Patterns
Test a Form Submission End-to-End
#![allow(unused)]
fn main() {
#[tokio::test]
async fn submit_contact_form() {
if !is_e2e() { return; }
let mut client = VictauriClient::discover().await.unwrap();
let email = Locator::label("Email");
let message = Locator::label("Message");
let submit = Locator::role("button").and_text("Send");
email.fill(&mut client, "user@example.com").await.unwrap();
message.fill(&mut client, "Hello!").await.unwrap();
submit.click(&mut client).await.unwrap();
Locator::text("Message sent")
.expect(&mut client)
.to_be_visible()
.await
.unwrap();
let log = client.get_ipc_log(Some(1)).await.unwrap();
assert_ipc_called_with(&log, "send_message", &json!({
"email": "user@example.com",
"body": "Hello!"
}));
}
}
Test Navigation Between Pages
#![allow(unused)]
fn main() {
#[tokio::test]
async fn navigation_flow() {
if !is_e2e() { return; }
let mut client = VictauriClient::discover().await.unwrap();
Locator::text("Settings").click(&mut client).await.unwrap();
Locator::role("heading").and_text("Settings")
.expect(&mut client)
.to_be_visible()
.await
.unwrap();
let url = client.eval_js("window.location.hash").await.unwrap();
assert_eq!(url.as_str().unwrap(), "#/settings");
}
}
Test Multi-Window Behavior
#![allow(unused)]
fn main() {
#[tokio::test]
async fn notification_window() {
if !is_e2e() { return; }
let mut client = VictauriClient::discover().await.unwrap();
let windows = client.list_windows().await.unwrap();
let labels: Vec<&str> = windows.as_array().unwrap()
.iter().filter_map(|w| w.as_str()).collect();
assert!(labels.contains(&"main"));
let state = client.get_window_state(Some("main")).await.unwrap();
assert!(state["visible"].as_bool().unwrap());
}
}
Verify State Consistency After Interaction
#![allow(unused)]
fn main() {
#[tokio::test]
async fn counter_state_sync() {
if !is_e2e() { return; }
let mut client = VictauriClient::discover().await.unwrap();
for _ in 0..3 {
client.click_by_id("increment-btn").await.unwrap();
}
client.expect_text("3").await.unwrap();
let result = client.invoke_command("get_counter", None).await.unwrap();
assert_eq!(result.as_i64().unwrap(), 3);
}
}
Full Verification Report in CI
#![allow(unused)]
fn main() {
#[tokio::test]
async fn ci_health_check() {
if !is_e2e() { return; }
let mut client = VictauriClient::discover().await.unwrap();
client.verify()
.has_text("Welcome")
.no_console_errors()
.ipc_healthy()
.no_ghost_commands()
.coverage_above(75.0)
.run().await.unwrap()
.assert_all_passed();
}
}
CLI Reference
Install with cargo install victauri-cli, then:
victauri init # Scaffold test directory with starter tests
victauri check # Connect to running app, report health
victauri check --junit report.xml # Same, with JUnit XML output
victauri test # Run 11 built-in smoke checks
victauri test --max-load-ms 5000 --max-heap-mb 256 # With custom thresholds
victauri record --output tests/flow.rs # Record interactions → generate Rust test
victauri coverage --threshold 80 # Report IPC coverage, fail if below 80%
victauri watch # Re-run tests on file changes
victauri watch --filter smoke # Only re-run specific test file
victauri test — Smoke Suite
Runs 11 built-in checks against your running app:
- Server connectivity
- JavaScript evaluation
- DOM snapshot validity
- Screenshot capture
- Window enumeration
- IPC integrity
- Accessibility audit (violations)
- Accessibility audit (warnings)
- DOM load performance
- Heap memory usage
- Health endpoint hardening
Exit code 0 if all pass, 1 if any fail. Ideal for CI gates.
CI Integration
Victauri tests run in CI without special infrastructure. Pick the approach that fits:
Option A: GitHub Action (recommended)
# .github/workflows/test.yml
- name: Start app
run: xvfb-run --auto-servernum cargo run -p my-app &
- uses: runyourempire/victauri@main
with:
max-load-ms: 5000
max-heap-mb: 256
coverage: true
coverage-threshold: 80
junit-path: results.xml
One step. Installs the CLI, waits for the server, runs smoke tests, and optionally gates on IPC coverage.
Option B: Managed Lifecycle with TestApp
# .github/workflows/test.yml
- name: Run Victauri tests
run: cargo test -p my-app --test integration
TestApp::spawn handles starting the app, waiting for the server, and cleanup. Nothing else needed.
Option C: Manual Server Lifecycle
- name: Build app
run: cargo build -p my-app
- name: Start app
run: xvfb-run --auto-servernum cargo run -p my-app &
- name: Wait for server
run: |
for i in $(seq 1 30); do
curl -sf http://127.0.0.1:7373/health && break
sleep 1
done
- name: Run tests
run: cargo test -p my-app --test integration -- --test-threads=1
env:
VICTAURI_E2E: "1"
VICTAURI_PORT: "7373"
Option D: Use the CLI Directly
- name: Start app
run: xvfb-run --auto-servernum cargo run -p my-app &
- name: Smoke test
run: victauri test --junit results.xml --max-load-ms 5000
- name: Coverage gate
run: victauri coverage --threshold 80 --junit coverage.xml
Platform Notes
| Platform | Notes |
|---|---|
| Linux | Requires xvfb-run --auto-servernum for headless display |
| macOS | Works out of the box — no WebDriver/CDP needed |
| Windows | Works out of the box — uses native PrintWindow for screenshots |
Testing Tauri Apps: The Complete Guide
A practical guide to testing Tauri 2.x applications — covering every approach from unit tests to full-stack integration testing.
The Testing Problem
Tauri apps have three distinct layers that need testing:
- Frontend (HTML/CSS/JS in a webview) — UI rendering, user interactions, client-side state
- Backend (Rust) — business logic, database access, system operations
- IPC (Tauri commands) — the bridge between frontend and backend
Most testing tools only see one layer. Frontend testing tools (Vitest, Playwright) can interact with the DOM but can’t verify that the Rust handler ran correctly. Rust testing tools (cargo test) can test business logic but can’t click a button. The IPC layer — where most Tauri bugs live — falls through the cracks.
This guide covers every approach and when to use each one.
Approach 1: Unit Tests (Rust)
Best for: Business logic, data transformations, pure functions.
Standard cargo test works perfectly for Rust code that doesn’t depend on AppHandle or Tauri runtime:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_email() {
assert!(is_valid_email("alice@example.com"));
assert!(!is_valid_email("not-an-email"));
}
#[test]
fn calculates_total() {
let items = vec![Item { price: 10.0, qty: 2 }, Item { price: 5.0, qty: 1 }];
assert_eq!(calculate_total(&items), 25.0);
}
}
}
Limitation: Can’t test anything that touches the Tauri runtime, webview, or IPC layer. If your command handler calls app.emit() or reads window state, unit tests won’t cover it.
Approach 2: Frontend Tests (Vitest / Jest)
Best for: Component rendering, UI logic, client-side state management.
Mock the Tauri IPC layer and test your frontend in isolation:
// __mocks__/@tauri-apps/api/core.ts
export const invoke = vi.fn();
// components/Counter.test.ts
import { invoke } from '@tauri-apps/api/core';
import { render, fireEvent } from '@testing-library/svelte';
import Counter from './Counter.svelte';
test('increment calls backend', async () => {
invoke.mockResolvedValue(1);
const { getByText } = render(Counter);
await fireEvent.click(getByText('+'));
expect(invoke).toHaveBeenCalledWith('increment');
});
Limitation: You’re testing against mocks, not the real backend. The mock says increment returns 1, but the real handler might return an error, use a different type, or have been renamed. Mock drift is the #1 source of false-passing Tauri tests.
Approach 3: WebDriver (Selenium / WebdriverIO)
Best for: Teams already invested in WebDriver infrastructure, cross-browser testing.
Tauri supports WebDriver via tauri-driver, which wraps the platform’s native WebDriver:
// wdio.conf.js
exports.config = {
capabilities: [{
'tauri:options': {
application: '../src-tauri/target/debug/my-app',
},
}],
};
// test.js
describe('counter', () => {
it('increments', async () => {
await $('[data-testid="increment-btn"]').click();
const value = await $('[data-testid="counter-value"]').getText();
expect(value).toBe('1');
});
});
Limitations:
- Requires
tauri-driverbinary and platform-specific WebDriver (msedgedriveron Windows,safaridriveron macOS,geckodriveron Linux) - macOS requires enabling Develop menu and “Allow Remote Automation” in Safari
- Linux requires WebKitGTK WebDriver, which isn’t always available
- Can only interact with the DOM — no backend state verification, no IPC inspection
- Slow startup (seconds per test due to WebDriver protocol overhead)
Approach 4: Playwright
Best for: Teams familiar with Playwright, visual regression testing.
Playwright doesn’t officially support Tauri, but community approaches exist:
import { _electron as electron } from 'playwright';
// This only works for Electron apps, not Tauri.
// For Tauri, you'd need to connect to the webview's DevTools port,
// which requires CDP support that varies by platform.
Limitations:
- No official Tauri support — community workarounds only
- CDP (Chrome DevTools Protocol) availability varies: Windows (WebView2 supports CDP), macOS (WKWebView does not), Linux (WebKitGTK has partial support)
- Cross-platform testing becomes platform-specific
- Same DOM-only limitation as WebDriver
Approach 5: Full-Stack Testing with Victauri
Best for: Testing all three layers together — frontend, IPC, and backend — from one test.
Victauri embeds an MCP server inside your Tauri process, giving tests direct access to the DOM, IPC layer, Rust backend, and native windows simultaneously.
Setup
cargo install victauri-cli
victauri init
Add the plugin to your Tauri app:
#![allow(unused)]
fn main() {
tauri::Builder::default()
.plugin(victauri_plugin::init()) // compiles away in release builds
.invoke_handler(tauri::generate_handler![greet, increment, list_todos])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Instrument your commands for full introspection:
#![allow(unused)]
fn main() {
use victauri_macros::inspectable;
#[inspectable(description = "Greet a user by name", category = "ui")]
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {name}!")
}
}
Writing Tests
#![allow(unused)]
fn main() {
use victauri_test::{e2e_test, locator::Locator};
use serde_json::json;
// Basic interaction test
e2e_test!(greet_flow, |client| async move {
// Fill the input
client.fill_by_id("name-input", "Alice").await.unwrap();
// Click the button
client.click_by_id("greet-btn").await.unwrap();
// Verify the UI updated
client.expect_text("Hello, Alice!").await.unwrap();
});
}
The Locator API
Composable element queries inspired by Playwright:
#![allow(unused)]
fn main() {
e2e_test!(locator_example, |client| async move {
Locator::test_id("name-input")
.fill(&mut client, "Bob")
.await
.unwrap();
Locator::text("Greet")
.click(&mut client)
.await
.unwrap();
Locator::test_id("greet-result")
.expect(&mut client)
.to_contain_text("Hello, Bob!")
.await
.unwrap();
});
}
Cross-Boundary Verification
Test that the DOM and backend agree — the pattern that catches state drift:
#![allow(unused)]
fn main() {
e2e_test!(counter_state_sync, |client| async move {
client.invoke_command("reset_counter", None).await.unwrap();
// Interact via UI
client.click_by_id("increment-btn").await.unwrap();
client.click_by_id("increment-btn").await.unwrap();
// Verify both layers agree
let report = client.verify()
.state_matches(
"parseInt(document.getElementById('counter-value').textContent)",
json!({"counter": 2}),
)
.ipc_was_called("increment")
.no_console_errors()
.run()
.await
.unwrap();
report.assert_all_passed();
});
}
IPC Testing
Verify that commands exist, were called, and return the right data:
#![allow(unused)]
fn main() {
e2e_test!(ipc_verification, |client| async move {
// Check IPC layer health
let report = client.check_ipc_integrity().await.unwrap();
assert!(report["healthy"].as_bool().unwrap());
// Invoke a command directly and check the result
let todo: serde_json::Value = client
.invoke_command("add_todo", Some(json!({"title": "Write tests"})))
.await
.unwrap();
assert!(todo["id"].is_number());
// Find ghost commands — frontend calls with no backend handler
let ghosts = client.detect_ghost_commands().await.unwrap();
assert!(ghosts["ghosts"].as_array().unwrap().is_empty(),
"found ghost commands: {:?}", ghosts);
// Check command registry
let registry = client.get_registry().await.unwrap();
let names: Vec<&str> = registry.as_array().unwrap()
.iter()
.filter_map(|c| c["name"].as_str())
.collect();
assert!(names.contains(&"add_todo"));
});
}
Accessibility Auditing
WCAG checks built in — no external tools needed:
#![allow(unused)]
fn main() {
e2e_test!(accessibility_check, |client| async move {
let audit = client.audit_accessibility().await.unwrap();
let violations = audit["summary"]["violations"].as_u64().unwrap_or(0);
assert!(violations == 0,
"a11y violations found: {}",
serde_json::to_string_pretty(&audit["violations"]).unwrap_or_default()
);
});
}
Performance Budgets
Enforce performance limits in CI:
#![allow(unused)]
fn main() {
e2e_test!(performance_budget, |client| async move {
let perf = client.get_performance_metrics().await.unwrap();
// DOM interactive under 3 seconds
if let Some(ms) = perf["navigation"]["domInteractive"].as_f64() {
assert!(ms < 3000.0, "DOM interactive: {ms}ms");
}
// JS heap under 100MB
if let Some(mb) = perf["jsHeap"]["usedMB"].as_f64() {
assert!(mb < 100.0, "JS heap: {mb}MB");
}
// Under 500 DOM elements
if let Some(count) = perf["dom"]["elementCount"].as_u64() {
assert!(count < 500, "DOM elements: {count}");
}
});
}
Multi-Window Testing
Test apps with multiple windows:
#![allow(unused)]
fn main() {
e2e_test!(multi_window, |client| async move {
let windows = client.list_windows().await.unwrap();
let labels: Vec<&str> = windows.as_array().unwrap()
.iter()
.filter_map(|w| w.as_str())
.collect();
assert!(labels.contains(&"main"));
// Check state of specific window
let state = client.get_window_state(Some("main")).await.unwrap();
assert!(state["visible"].as_bool().unwrap());
assert!(state["width"].as_f64().unwrap() > 0.0);
});
}
Time-Travel Recording
Record interactions and replay them:
#![allow(unused)]
fn main() {
e2e_test!(recording, |client| async move {
client.start_recording(None).await.unwrap();
// Do some actions
client.invoke_command("increment", None).await.unwrap();
client.invoke_command("increment", None).await.unwrap();
let session = client.stop_recording().await.unwrap();
let events = session["events"].as_array().unwrap();
assert!(!events.is_empty());
});
}
The Smoke Suite
Built-in checks that run in seconds:
# CLI — 11 checks, pass/fail, JUnit XML
victauri test --max-load-ms 5000 --max-heap-mb 256 --junit results.xml
# From code
e2e_test!(smoke, |client| async move {
let report = client.smoke_test().await.unwrap();
assert!(report.all_passed(),
"{}/{} passed", report.passed_count(), report.total_count());
});
IPC Coverage
Know which commands your tests exercise:
victauri coverage --threshold 80
Output:
IPC Command Coverage Report
────────────────────────────
greet ✓ covered
increment ✓ covered
add_todo ✓ covered
delete_todo ✗ NOT covered
update_settings ✗ NOT covered
Coverage: 3/5 commands (60.0%)
✗ Below threshold of 80%
Comparison
| Unit tests | Frontend mocks | WebDriver | Playwright | Victauri | |
|---|---|---|---|---|---|
| DOM interaction | - | Yes | Yes | Yes | Yes |
| Backend verification | Yes | - | - | - | Yes |
| IPC inspection | - | Mocked | - | - | Real |
| Cross-boundary | - | - | - | - | Yes |
| Ghost detection | - | - | - | - | Yes |
| A11y auditing | - | Via lib | - | Yes | Yes |
| Perf profiling | - | - | - | Yes | Yes |
| Screenshots | - | - | Yes | Yes | Yes |
| Setup complexity | None | Low | High | Medium | Low |
| Cross-platform | Yes | Yes | Varies | Varies | Yes |
| Release overhead | None | None | None | None | None |
| AI agent support | - | - | - | - | MCP + REST |
Recommended Strategy
Use all the approaches where they shine:
- Unit tests for pure business logic (no Tauri runtime needed)
- Frontend tests for component-level rendering (mock only when intentional)
- Victauri for integration tests that verify frontend + IPC + backend work together
- Victauri CLI in CI as a smoke gate before merge
Unit tests ─────────────────── cargo test (fast, Rust-only)
│
Frontend tests ─────────────── vitest / jest (component rendering)
│
Integration tests ──────────── victauri e2e_test! (full-stack)
│
CI smoke gate ──────────────── victauri test (11 checks, seconds)
│
Coverage gate ──────────────── victauri coverage --threshold 80
CI Integration
GitHub Action
- name: Start app
run: xvfb-run --auto-servernum cargo run -p my-app &
- uses: runyourempire/victauri@main
with:
max-load-ms: 5000
coverage: true
coverage-threshold: 80
junit-path: results.xml
Manual
- name: Start app
run: xvfb-run --auto-servernum cargo run -p my-app &
- name: Wait for server
run: |
for i in $(seq 1 30); do
curl -sf http://127.0.0.1:7373/health && break
sleep 1
done
- name: Test
run: victauri test --junit results.xml
- name: Coverage
run: victauri coverage --threshold 80
Platform Notes
| Platform | Display server | Screenshot method |
|---|---|---|
| Linux | xvfb-run --auto-servernum | X11 GetImage / grim |
| macOS | None needed | CGWindowListCreateImage |
| Windows | None needed | PrintWindow + GetDIBits |
REST API
Every Victauri tool is also accessible via plain HTTP — useful for scripts, CI pipelines, or any language:
# List tools
curl http://127.0.0.1:7373/api/tools
# Evaluate JS
curl -X POST http://127.0.0.1:7373/api/tools/eval_js \
-H "Content-Type: application/json" \
-d '{"code": "document.title"}'
# Get memory stats
curl -X POST http://127.0.0.1:7373/api/tools/get_memory_stats
# Take screenshot
curl -X POST http://127.0.0.1:7373/api/tools/screenshot -d '{}'
Further Reading
- Victauri README — full tool reference, architecture, quick start
- Demo app tests — 20 integration tests demonstrating every pattern
- Agent session example — real AI agent session transcript
- Tauri testing docs — official Tauri testing guidance
- MCP protocol — the protocol Victauri speaks
Tauri App Compatibility
Victauri works with any Tauri 2.x application. This page documents compatibility considerations discovered through research against real-world open-source Tauri apps and platform-level investigation.
Content Security Policy (CSP)
Short answer: CSP does not block Victauri on any platform.
Victauri’s JS bridge is injected via Tauri’s js_init_script() API, and all tool calls use Tauri’s webview.eval() which delegates to native platform APIs:
| Platform | Native API | CSP bypass |
|---|---|---|
| Windows | ICoreWebView2.ExecuteScriptAsync() | Yes (Chromium CDP allowUnsafeEvalBlockedByCSP defaults to true) |
| macOS | WKWebView.evaluateJavaScript() | Yes (privileged bridge execution) |
| Linux | webkit_web_view_run_javascript() | Yes (WebKitGTK docs confirm explicit bypass) |
The bridge code itself never uses eval(), new Function(), setTimeout(string), or any other CSP-sensitive pattern. All JavaScript is direct DOM API calls and function closures.
Why this works: Native webview eval is a host-application privilege, not a page-level script execution. It operates outside the web security model, similar to how browser DevTools can evaluate code regardless of CSP.
Even apps with strict CSP like "script-src 'self'" (Spacedrive) should work with Victauri. Use get_diagnostics to verify.
Known Edge Cases
Shadow DOM (closed mode)
Victauri traverses open shadow roots automatically (element.shadowRoot returns the shadow tree). However, closed shadow roots ({ mode: "closed" }) return null — their contents are invisible to dom_snapshot, find_elements, and audit_accessibility.
Affected components: Shoelace, some Ionic components. Lit and Stencil default to open mode.
Detection: get_diagnostics reports custom elements that may have closed shadow roots.
Workaround: None possible — this is a browser security boundary. If you control the component, switch to mode: "open" in debug builds.
iframes
Tauri’s js_init_script does not run inside iframes (tauri-apps/tauri#13577). The Victauri bridge will be absent inside any <iframe> element. Tools like dom_snapshot will show the <iframe> element but not its contents.
Detection: get_diagnostics reports iframe count and sources.
Workaround: None — this is a Tauri limitation. Tauri recommends using multi-webview (WebviewWindow) instead of iframes for security.
Service Workers
Service workers can intercept fetch() calls, including calls to http://ipc.localhost/ which Victauri uses to capture IPC traffic. An active service worker may cause:
- Missing entries in
get_ipc_log - False negatives in
detect_ghost_commandsandcheck_ipc_integrity
Additionally, tauri-apps/tauri#12673 documents that service workers break invoke() and emit() on second app launch.
Detection: get_diagnostics warns when navigator.serviceWorker.controller is active.
Risk level: Low — service workers require https:// or http://localhost origin, so they only affect apps using tauri-plugin-localhost. Most Tauri apps use the tauri:// protocol which doesn’t support service workers.
Alternative IPC Transports
rspc / tauri-specta
Apps like Spacedrive route all RPC through a single Tauri command (e.g., daemon_request). Victauri’s IPC log will show the wrapper command but not the inner procedure names. get_registry returns empty unless commands are also registered with #[inspectable].
tauri-invoke-http
tauri-invoke-http replaces Tauri’s IPC transport entirely with a localhost HTTP server, bypassing ipc.localhost. Victauri’s IPC interception will miss all calls. This plugin is very niche.
Sidecar processes
Apps using Node.js sidecars (Yaak) or daemon processes (Spacedrive) for backend logic — Victauri only sees the Tauri IPC boundary. Sidecar/gRPC traffic is invisible.
Large DOM
Victauri walks the entire DOM tree for dom_snapshot. Performance scales linearly:
| Elements | Approximate time |
|---|---|
| 1,000 | ~10ms |
| 5,000 | ~50ms |
| 10,000 | ~100ms |
| 50,000 | ~500ms |
Apps using virtualized lists (react-window, tanstack-virtual) only render visible items, so the actual DOM count is smaller than the data set.
Detection: get_diagnostics warns when DOM exceeds 5,000 elements.
Plugin Port Conflicts
tauri-plugin-localhost also binds a localhost HTTP server. Victauri’s port fallback (7373 → 7374 → … → 7383) and automatic port file discovery handle this. No action needed.
Custom Invoke Handlers
Tauri 2 supports only one invoke_handler per app (tauri-apps/tauri#11447). Victauri intercepts IPC via fetch monitoring, not by wrapping the invoke handler, so custom handlers do not affect Victauri.
Platform-Specific Notes
| Platform | Requirement | Notes |
|---|---|---|
| Windows | WebView2 runtime | Pre-installed on Windows 11; auto-installed on Windows 10. Evergreen (auto-updates). |
| macOS | macOS 10.15+ | WKWebView ships with the OS. No additional runtime. |
| Linux | WebKitGTK 2.36+ (webkit2gtk-4.1) | Ubuntu 22.04+. Tauri 2 won’t compile on older versions, so not a Victauri concern. |
Tauri Version Compatibility
Victauri is tested with Tauri 2.0 through 2.11. Key compatibility facts:
js_init_script()API is stable across all 2.x releases- The
fetch()→ipc.localhostIPC transport is unchanged since Tauri 2.0 - Plugin init scripts run in IIFE isolation since April 2024 — no global scope conflicts
- The
plugin:victauri|namespace prefix is automatically excluded from IPC logs
Diagnostics Tool
Call get_diagnostics (MCP or REST) to check for all known edge cases at runtime:
curl -X POST http://127.0.0.1:7373/api/tools/get_diagnostics -d '{}'
Returns:
{
"result": {
"warnings": [
{
"id": "closed-shadow-dom",
"severity": "medium",
"message": "3 custom element(s) may use closed shadow DOM",
"details": { "count": 3 }
}
],
"info": {
"bridge_version": "0.3.0",
"dom_elements": 847,
"open_shadow_roots": 12,
"event_listeners": 234,
"protocol": "tauri:",
"url": "tauri://localhost/",
"user_agent": "..."
}
}
}
Warning IDs: service-worker-active, closed-shadow-dom, iframes-present, large-dom.
Multi-Window Apps
Victauri handles multi-window apps automatically. Default window selection: "main" → first visible → any. Use webview_label to target specific windows:
#![allow(unused)]
fn main() {
client.eval_js_in("settings", "document.title").await?;
client.dom_snapshot_for("notification").await?;
}
Apps with many dynamic windows (Spacedrive’s 15+ types, Seelen-UI’s desktop environment) should target windows by label explicitly.
Tested Apps
| App | Stars | Tauri | Frontend | Windows | Commands | Victauri Fit |
|---|---|---|---|---|---|---|
| Vibe | 6.1k | 2.x | TypeScript | 1 | 41 | Excellent |
| Bokuchi | 68 | 2.x | React | 1 | 15 | Excellent |
| Yaak | 18.6k | 2.11 | React 19 | 1 | ~30-60 | Good (gRPC sidecar invisible) |
| Whispering | 4.5k | 2.x | Svelte 5 | 1 | ~10-20 | Good (framework diversity) |
| Wealthfolio | 7.4k | 2.10 | React | 1 | Standard | Good (single-instance plugin) |
| Clash Nyanpasu | 13k | 2.4 | React 19 | Dynamic | Standard | Good (dynamic windows, specta IPC) |
| Spacedrive | 38k | 2.1 | React + rspc | 15+ | rspc-routed | Partial (rspc opaque, multi-window) |
| Seelen-UI | 16.8k | 2.10 | Svelte 5 | Many | Standard | Hard (desktop environment) |
| Hoppscotch Agent | 79k | 2.9 | Minimal | 1 | 2 | Too minimal |
Framework Coverage
Victauri’s DOM snapshot uses the accessible tree (ARIA roles and names), which is framework-agnostic. Confirmed working with:
- React (demo-app, 4DA)
- Vue (Hoppscotch web client)
- Svelte (Whispering, Seelen-UI)
- Vanilla HTML/JS
Configuration
Victauri is configured via the VictauriBuilder API in Rust code and/or environment variables.
Quick Reference
| Setting | Builder Method | Environment Variable | Default |
|---|---|---|---|
| Port | .port(7373) | VICTAURI_PORT | 7373 |
| Auth token | .auth_token("...") | VICTAURI_AUTH_TOKEN | Auto-generated UUID |
| Disable auth | .auth_disabled() | — | Auth enabled |
| Eval timeout | .eval_timeout(Duration) | VICTAURI_EVAL_TIMEOUT | 30s |
| Event capacity | .event_capacity(10000) | — | 10,000 |
| Recorder capacity | .recorder_capacity(50000) | — | 50,000 |
| Console log cap | .console_log_capacity(1000) | — | 1,000 |
| Network log cap | .network_log_capacity(1000) | — | 1,000 |
| Navigation log cap | .navigation_log_capacity(200) | — | 200 |
VictauriBuilder API
Basic Setup
#![allow(unused)]
fn main() {
use victauri_plugin::VictauriBuilder;
tauri::Builder::default()
.plugin(
VictauriBuilder::new()
.port(8080)
.auth_token("my-fixed-token")
.build(),
)
.run(tauri::generate_context!())
.unwrap();
}
Port Configuration
#![allow(unused)]
fn main() {
VictauriBuilder::new()
.port(9000) // Preferred port
.build()
}
If the preferred port is busy, Victauri tries the next 10 ports (9001-9010). The actual port is:
- Printed to the log on startup
- Written to
<temp_dir>/victauri.port - Available via the
/infoendpoint - Stored in
VictauriState.port(AtomicU16)
Authentication
Authentication is enabled by default. Three modes:
#![allow(unused)]
fn main() {
// 1. Auto-generated token (default — token printed to console)
VictauriBuilder::new().build()
// 2. Fixed token
VictauriBuilder::new()
.auth_token("my-secret-token")
.build()
// 3. Random UUID token (explicit)
VictauriBuilder::new()
.generate_auth_token()
.build()
// 4. No authentication (use only in trusted environments)
VictauriBuilder::new()
.auth_disabled()
.build()
}
The VICTAURI_AUTH_TOKEN environment variable overrides any programmatic token.
Privacy Controls
Privacy Profiles
Three tiers of access control:
#![allow(unused)]
fn main() {
use victauri_plugin::PrivacyProfile;
// Read-only: snapshots, logs, registry only. No mutations.
VictauriBuilder::new()
.privacy_profile(PrivacyProfile::Observe)
.build()
// Testing: observe + interactions + input + storage + recording
VictauriBuilder::new()
.privacy_profile(PrivacyProfile::Test)
.build()
// Full control: everything enabled (default)
VictauriBuilder::new()
.privacy_profile(PrivacyProfile::FullControl)
.build()
}
Observe and Test profiles automatically enable output redaction.
Strict Privacy Mode
Shorthand for Observe profile:
#![allow(unused)]
fn main() {
VictauriBuilder::new()
.strict_privacy_mode()
.build()
}
Tool Disabling
Disable specific tools by name:
#![allow(unused)]
fn main() {
VictauriBuilder::new()
.disable_tools(&["eval_js", "screenshot", "invoke_command"])
.build()
}
Disabled tools return an error when called and are not listed in tool discovery.
Command Allowlists and Blocklists
Control which Tauri commands can be invoked via MCP:
#![allow(unused)]
fn main() {
// Only allow these commands (positive allowlist)
VictauriBuilder::new()
.command_allowlist(&["get_settings", "get_status", "search"])
.build()
// Block specific commands (negative blocklist)
VictauriBuilder::new()
.command_blocklist(&["delete_user", "reset_database", "admin_override"])
.build()
}
The allowlist takes priority: if set, only listed commands are permitted regardless of the blocklist.
Output Redaction
Automatically redact sensitive data from tool responses:
#![allow(unused)]
fn main() {
VictauriBuilder::new()
.enable_redaction() // Built-in patterns: API keys, emails, tokens
.add_redaction_pattern(r"SECRET_\w+") // Custom regex
.add_redaction_pattern(r"sk-[a-zA-Z0-9]+") // OpenAI keys
.build()
}
Built-in patterns match:
- API keys (
api_key,apikey,api-keyin JSON) - Bearer tokens
- Email addresses
- Common secret patterns
Capacity Tuning
#![allow(unused)]
fn main() {
VictauriBuilder::new()
.event_capacity(50_000) // Ring buffer for event log (max: 1,000,000)
.recorder_capacity(100_000) // Time-travel recording buffer (max: 1,000,000)
.eval_timeout(std::time::Duration::from_secs(60)) // JS eval timeout (max: 300s)
.console_log_capacity(2000) // JS bridge console buffer
.network_log_capacity(2000) // JS bridge network buffer
.navigation_log_capacity(500) // JS bridge navigation buffer
.build()
}
File Navigation
By default, the navigate tool only allows http:// and https:// URLs. To allow file:// URLs:
#![allow(unused)]
fn main() {
VictauriBuilder::new()
.allow_file_navigation()
.build()
}
Ready Callback
Get notified when the server is bound and ready:
#![allow(unused)]
fn main() {
VictauriBuilder::new()
.on_ready(|port| {
println!("Victauri ready on port {}", port);
})
.build()
}
Pre-registering Commands
Register #[inspectable] command schemas at build time:
#![allow(unused)]
fn main() {
VictauriBuilder::new()
.register_command(greet__schema())
.register_command(increment__schema())
.build()
}
Environment Variables
| Variable | Description |
|---|---|
VICTAURI_PORT | Override the MCP server port |
VICTAURI_AUTH_TOKEN | Set the authentication token |
VICTAURI_EVAL_TIMEOUT | Eval timeout in seconds |
Environment variables take priority over builder settings.
Watchdog Configuration
The victauri-watchdog binary is configured entirely via environment variables:
| Variable | Default | Description |
|---|---|---|
VICTAURI_PORT | 7373 | Port to monitor |
VICTAURI_INTERVAL | 5 | Health check interval in seconds |
VICTAURI_MAX_FAILURES | 3 | Consecutive failures before recovery action |
VICTAURI_ON_FAILURE | (none) | Shell command to execute on failure |
VICTAURI_PORT=7373 VICTAURI_MAX_FAILURES=5 VICTAURI_ON_FAILURE="notify-send 'App crashed'" victauri-watchdog
Full Example
#![allow(unused)]
fn main() {
use std::time::Duration;
use victauri_plugin::{VictauriBuilder, PrivacyProfile};
tauri::Builder::default()
.plugin(
VictauriBuilder::new()
// Network
.port(7373)
.eval_timeout(Duration::from_secs(30))
// Auth
.auth_token("dev-token-123")
// Privacy
.privacy_profile(PrivacyProfile::Test)
.command_blocklist(&["dangerous_command"])
.disable_tools(&["screenshot"])
// Redaction
.enable_redaction()
.add_redaction_pattern(r"password=\w+")
// Capacity
.event_capacity(20_000)
.recorder_capacity(100_000)
.console_log_capacity(2000)
// Commands
.register_command(greet__schema())
.register_command(increment__schema())
// Callback
.on_ready(|port| println!("MCP server on :{}", port))
.build(),
)
.invoke_handler(tauri::generate_handler![greet, increment])
.run(tauri::generate_context!())
.unwrap();
}
Security
Victauri provides multiple layers of security to ensure that only authorized agents can access your application during development.
Debug-Only Gate
The most fundamental security measure: Victauri does not exist in release builds.
#![allow(unused)]
fn main() {
pub fn init<R: Runtime>() -> TauriPlugin<R> {
#[cfg(debug_assertions)]
{ /* Full MCP server, JS bridge, everything */ }
#[cfg(not(debug_assertions))]
{ /* Empty no-op plugin — zero binary overhead */ }
}
}
This means:
- No MCP server is started in production
- No JS bridge is injected
- No HTTP endpoints are exposed
- No memory is allocated for logs or state
- The compiled binary has zero overhead from Victauri
You cannot accidentally ship Victauri to users.
Bearer Token Authentication
Authentication is enabled by default. Every request to the MCP server (except /health) must include a valid Bearer token.
How It Works
- On startup, if no explicit token is configured, Victauri generates a random UUID v4 token
- The token is printed to the application log
- Clients must include
Authorization: Bearer <token>in every request - Token comparison uses constant-time equality to prevent timing attacks
Configuration
#![allow(unused)]
fn main() {
// Auto-generated token (logged on startup)
VictauriBuilder::new().build()
// Fixed token
VictauriBuilder::new()
.auth_token("my-secret-token")
.build()
// Environment variable (takes priority)
// VICTAURI_AUTH_TOKEN=my-token
// Disable auth (only for fully trusted environments)
VictauriBuilder::new()
.auth_disabled()
.build()
}
What Is Protected
| Endpoint | Auth Required |
|---|---|
/health | No |
/mcp | Yes |
/api/tools | Yes |
/api/tools/{name} | Yes |
/info | Yes |
The /health endpoint is unauthenticated so that the watchdog and load balancers can check liveness without credentials.
Rate Limiting
A token-bucket rate limiter prevents abuse, even from authenticated clients:
- Default rate: 1000 requests per second
- Implementation: Lock-free
AtomicU64counter - Bucket refill: Continuous (not windowed)
- Response on limit: HTTP 429 Too Many Requests
This protects against runaway agents or scripts that flood the server with requests.
Privacy Layer
Fine-grained control over what agents can see and do.
Privacy Profiles
#![allow(unused)]
fn main() {
use victauri_plugin::PrivacyProfile;
// Read-only: agent can observe but not mutate
VictauriBuilder::new()
.privacy_profile(PrivacyProfile::Observe)
.build()
// Testing: can interact and record, but no arbitrary code execution
VictauriBuilder::new()
.privacy_profile(PrivacyProfile::Test)
.build()
// Full control (default)
VictauriBuilder::new()
.privacy_profile(PrivacyProfile::FullControl)
.build()
}
Observe Profile Disables:
eval_js(arbitrary code execution)screenshot(visual data exfiltration)- All interaction tools (click, fill, type)
- All input tools
- Storage writes
- Navigation
- CSS injection
- Recording (state capture)
Test Profile Disables:
eval_js(arbitrary code execution)screenshot- CSS injection
Command Filtering
Control which Tauri commands can be invoked:
#![allow(unused)]
fn main() {
// Allowlist: only these commands can be called
VictauriBuilder::new()
.command_allowlist(&["get_settings", "get_status"])
.build()
// Blocklist: these commands are forbidden
VictauriBuilder::new()
.command_blocklist(&["delete_data", "admin_reset"])
.build()
}
Tool Disabling
Disable individual MCP tools:
#![allow(unused)]
fn main() {
VictauriBuilder::new()
.disable_tools(&["eval_js", "invoke_command", "screenshot"])
.build()
}
Disabled tools:
- Return an error if called directly
- Are omitted from tool discovery listings
- Cannot be re-enabled at runtime
Output Redaction
Automatically scrub sensitive data from all tool responses:
#![allow(unused)]
fn main() {
VictauriBuilder::new()
.enable_redaction()
.add_redaction_pattern(r"sk-[a-zA-Z0-9]{32,}") // OpenAI keys
.add_redaction_pattern(r"ghp_[a-zA-Z0-9]{36}") // GitHub tokens
.build()
}
Built-in patterns (when redaction is enabled):
- API key values in JSON (
"api_key": "..."becomes"api_key": "[REDACTED]") - Bearer tokens in strings
- Email addresses
- Common secret key formats
Redaction is applied as a post-processing step to all tool output, regardless of which tool generated it.
Origin Guard
The MCP server only accepts connections from localhost (127.0.0.1 / ::1). The axum server binds exclusively to 127.0.0.1, meaning:
- No remote network access is possible
- Other machines on the LAN cannot connect
- Only processes on the same machine can reach the server
For the Chrome extension (victauri-browser), an additional origin guard rejects requests with non-localhost Origin headers, preventing web pages from connecting to the native host.
Security Headers
All HTTP responses include security headers:
X-Content-Type-Options: nosniffX-Frame-Options: DENYCache-Control: no-store
Threat Model
What Victauri Protects Against
| Threat | Mitigation |
|---|---|
| Production exposure | #[cfg(debug_assertions)] gate |
| Unauthorized local access | Bearer token auth (enabled by default) |
| Timing attacks on auth | Constant-time comparison |
| Request flooding | Token-bucket rate limiter |
| Remote network access | Localhost-only binding |
| Data exfiltration | Privacy profiles + output redaction |
| Dangerous mutations | Tool disabling + command allowlists |
| Cross-origin attacks | Origin header validation |
What Is Out of Scope
- Malicious code on the same machine with the auth token — If an attacker has the token and localhost access, they have the same privileges as the legitimate agent. This is inherent to any localhost-based development tool.
- Memory inspection of the process — A sufficiently privileged attacker on the same machine could read process memory directly. Victauri does not add encryption at rest for in-process data.
Recommendations
For typical development:
#![allow(unused)]
fn main() {
// Default: auto-generated token, printed to console
VictauriBuilder::new().build()
}
For CI/automated testing:
#![allow(unused)]
fn main() {
// Fixed token from environment
VictauriBuilder::new()
.auth_token(std::env::var("CI_VICTAURI_TOKEN").unwrap())
.build()
}
For shared development environments:
#![allow(unused)]
fn main() {
// Restrictive: read-only with redaction
VictauriBuilder::new()
.privacy_profile(PrivacyProfile::Observe)
.command_blocklist(&["dangerous_admin_command"])
.build()
}
For quick local prototyping (trusted machine, single user):
#![allow(unused)]
fn main() {
VictauriBuilder::new()
.auth_disabled()
.build()
}
Chrome Extension
The victauri-browser crate provides MCP access to any website running in Chrome, Edge, Brave, or Arc — not just Tauri applications.
What It Does
The Chrome extension + native messaging host extends Victauri’s inspection capabilities to regular web pages. An AI agent can connect via MCP on localhost:7474 and get DOM snapshots, interact with elements, evaluate JavaScript, inspect styles, and more — all on arbitrary websites.
This is useful for:
- Web scraping with semantic understanding
- Cross-site testing workflows
- Automating web tasks that span both Tauri apps and web services
- General browser automation via MCP
Installation
1. Install the Native Host Binary
cargo install victauri-browser
Or build from source:
cargo build -p victauri-browser --release
2. Register the Native Messaging Host
victauri-browser install
This registers the native messaging host manifest with your browser (Chrome, Edge, Brave, or Arc are auto-detected). The manifest tells the browser how to launch the native host when the extension requests it.
To uninstall:
victauri-browser uninstall
3. Load the Chrome Extension
- Open your browser’s extension management page (
chrome://extensions) - Enable “Developer mode”
- Click “Load unpacked” and select the
extensions/chrome/directory from the Victauri repo
4. Connect via MCP
The native host starts an HTTP server on localhost:7474 (with fallback to 7475-7484 if the port is busy).
{
"mcpServers": {
"victauri-browser": {
"url": "http://127.0.0.1:7474/mcp"
}
}
}
Architecture
The communication flow:
MCP Client (Claude Code)
│
│ HTTP (localhost:7474)
▼
Native Host Binary (victauri-browser)
│
│ Chrome Native Messaging (stdio)
│ 32-bit LE length prefix + UTF-8 JSON
▼
Extension Service Worker (MV3)
│
│ chrome.tabs.sendMessage()
▼
Content Script (ISOLATED world)
│
│ CustomEvent (__victauri_command / __victauri_response)
▼
JS Bridge (MAIN world)
│
│ Direct DOM access
▼
Web Page
Components
Native Host Binary (victauri-browser)
- Dual role: HTTP server for MCP clients AND native messaging host for Chrome
- axum router serves
/mcp(MCP protocol),/api/tools(REST),/health,/info - Reads/writes Chrome native messaging format on stdio
BridgeDispatchsends UUID-tagged commands and resolves responses via oneshot channels
Service Worker (MV3 background script)
- Manages native messaging connection lifecycle
- Routes commands to the correct tab’s content script
- Handles tab lifecycle (creation, removal, navigation)
- Manages CDP sessions for advanced features
Content Script (ISOLATED world)
- Relay between service worker and MAIN world bridge
- Uses
CustomEventpattern to cross the world boundary - Injected into all pages matching the extension’s permissions
JS Bridge (MAIN world, 1700+ lines)
- Full DOM inspection, interactions, accessibility, performance
- Same Playwright-grade actionability checks as the Tauri plugin bridge
- CSS inspection, recording, element finding, scroll, hover, click
Available Tools (20)
| Tool | Description |
|---|---|
get_plugin_info | Extension version and status (handled locally) |
tabs.list | List open browser tabs (handled locally) |
dom_snapshot | Full accessible DOM tree of active tab |
find_elements | Search by CSS selector, text, or role |
eval_js | Evaluate JavaScript in page context |
click | Click an element by ref |
fill | Set input value |
type_text | Type characters one-by-one |
press_key | Dispatch keyboard event |
hover | Hover over element |
scroll_into_view | Scroll element into viewport |
get_styles | Computed CSS for an element |
get_bounding_boxes | Element dimensions and box model |
highlight_element | Draw debug overlay |
clear_highlights | Remove all overlays |
screenshot | Capture visible tab as PNG |
navigate | Go to URL |
get_cookies | Get cookies for current domain |
get_console_logs | Captured console entries |
get_network_log | Fetch/XHR request history |
Authentication
The native host supports Bearer token authentication:
# Set via environment variable
VICTAURI_AUTH_TOKEN=my-token victauri-browser serve
Security features:
- Constant-time token comparison
- Token-bucket rate limiter
- Security headers on all responses
- Origin guard (blocks non-localhost origins)
Port Behavior
Default port: 7474. If busy, tries 7475 through 7484. The victauri-browser serve command prints the actual port on startup.
Tab Management
The extension tracks tab state (URL, title, bridge readiness). Commands are sent to the active tab by default, or you can target a specific tab by ID using the tab_id parameter where supported.
Special behaviors:
- Navigation uses
chrome.tabs.update()(not content scriptwindow.location) for reliability - Cookies use
chrome.cookies.getAll()for httpOnly access - Screenshots use Chrome’s
chrome.tabs.captureVisibleTab()API
FAQ
General
What Tauri versions are supported?
Victauri supports Tauri 2.0 and later. It is not compatible with Tauri 1.x due to fundamental differences in the plugin system and IPC architecture.
Does Victauri work with any frontend framework?
Yes. Victauri is framework-agnostic. It has been tested with:
- React (18, 19)
- Vue 3 / Nuxt
- Svelte / SvelteKit
- Any framework that renders to the DOM
The JS bridge operates at the DOM level and does not depend on any framework internals.
Can I use Victauri in production?
No, by design. The entire plugin is gated behind #[cfg(debug_assertions)] and compiles to a no-op in release builds. This is intentional — Victauri provides full introspection and control capabilities that should never be available in production.
Is there any performance impact?
In debug builds: The JS bridge adds a small overhead for network/console/navigation interception (hooks into fetch, console.*, and history APIs). The MCP server itself uses negligible resources when idle.
In release builds: Zero overhead. The plugin is completely compiled out.
How is this different from Playwright?
Playwright sees only the browser glass (DOM). Victauri gives agents simultaneous access to:
- The DOM (like Playwright)
- The Rust backend state
- The IPC layer between frontend and backend
- Native window state
- Process memory and diagnostics
Playwright requires an external process and CDP. Victauri runs inside the app with direct access.
How is this different from Tauri’s built-in testing?
Tauri’s testing utilities (tauri-driver, WebDriver) focus on end-to-end automation. Victauri provides:
- MCP protocol for AI agent integration
- Cross-boundary state verification (frontend vs backend)
- Time-travel recording and replay
- Ghost command detection
- Accessibility auditing
- All accessible through a standard protocol any MCP client can use
Setup
Why do I need victauri:default in capabilities?
Tauri 2.0’s permission system blocks IPC calls that don’t have matching capability grants. Without victauri:default, the plugin’s webview callbacks are silently dropped by Tauri’s security layer — no error is shown, things just don’t work.
The MCP server doesn’t start — what’s wrong?
Check that:
- You’re running a debug build (
cargo run, notcargo run --release) - The port isn’t already in use (check the logs for port fallback messages)
- The plugin is initialized before
.run():.plugin(victauri_plugin::init())
How do I find the actual port?
If the default port (7373) is busy, Victauri tries 7374-7383. The actual port is:
- Printed to stdout/logs on startup
- Written to
<temp_dir>/victauri.port - Available via
GET /infoon the bound port - Discoverable by the
victauri checkCLI command
My frontend uses CSP — will eval work?
Yes. The JS bridge uses init scripts (injected before page load) and direct function invocation patterns that work within standard CSP policies. The eval_js tool evaluates code through the Tauri webview’s eval() mechanism, which operates outside the page’s CSP sandbox.
Tools
Why do refs change between snapshots?
Refs are short-lived handles tied to the DOM state at snapshot time. If the DOM changes (user interaction, framework re-render, dynamic content), refs from a previous snapshot may no longer be valid. Always take a fresh dom_snapshot before interacting with elements.
Why does click/fill fail with “element not actionable”?
Victauri performs Playwright-grade actionability checks before interactions:
- Element must be visible (
displaynotnone,visibilitynothidden) - Element must be enabled (no
disabledattribute) - Element must have non-zero size
- Element must not be covered by another element (overlays, modals)
- Element must not have
pointer-events: none
This prevents flaky tests that interact with hidden or covered elements. If you’re seeing this error, check your UI state — another element (modal backdrop, loading overlay, tooltip) may be covering the target.
How does invoke_command work?
It calls window.__TAURI_INTERNALS__.invoke(command, args) in the webview, which triggers the standard Tauri IPC flow. The command must be registered in your invoke_handler. If the command requires specific Tauri permissions/capabilities, those must also be configured.
What’s the eval timeout?
Default: 30 seconds. This is long enough to support wait_for polling and async operations. Configurable via VictauriBuilder::eval_timeout() up to 300 seconds.
Can I invoke commands with complex arguments?
Yes. Pass arguments as a JSON object:
{
"command": "create_todo",
"args": {
"title": "Buy groceries",
"priority": 3,
"tags": ["shopping", "urgent"]
}
}
Architecture
Why not use Chrome DevTools Protocol?
CDP requires an external debugger connection and only works with Chromium-based webviews. Victauri’s embedded approach:
- Works on all platforms identically (no CDP dependency)
- Has access to the Rust backend (CDP can’t see that)
- Doesn’t require debug flags or remote debugging ports
- Responds in sub-milliseconds (no network hop)
Why HTTP and not stdio for MCP transport?
Tauri apps are GUI processes — stdin/stdout aren’t available for MCP communication. HTTP/SSE on localhost is the correct transport for a server embedded in an already-running graphical application.
Why are all tools in one impl block?
The rmcp crate’s #[tool_router] and #[tool_handler] macros require all tool methods to be in a single impl block. The handler is split across parameter modules for organization, but the dispatch stays monolithic due to this constraint.
Can multiple agents connect simultaneously?
Yes. The MCP server handles concurrent connections. Each connection gets its own MCP session. State (event log, recorder, bridge) is shared across sessions via Arc and thread-safe primitives.
Browser Extension
Does the Chrome extension work without the Tauri plugin?
Yes. The Chrome extension (victauri-browser) is completely standalone. It provides MCP access to any website — no Tauri app required. It’s a separate crate with its own binary.
Can I use both the plugin and extension together?
Yes. They run on different ports (plugin on 7373, extension on 7474) and serve different purposes:
- Plugin: inspects your Tauri app’s webview + backend
- Extension: inspects arbitrary web pages in the browser
Which browsers are supported?
Chrome, Edge, Brave, and Arc (all Chromium-based browsers that support Manifest V3 and native messaging).
Troubleshooting
“Bridge not found” or __VICTAURI__ is undefined
The JS bridge may not have loaded yet. This can happen if:
- The page is still loading (wait for DOMContentLoaded)
- The webview was created after plugin init (the bridge uses
js_init_scriptwhich only applies to webviews created after the script is registered) - CSP blocks inline scripts (unlikely with init scripts, but check console errors)
IPC log is empty
IPC logging works by intercepting fetch() calls to http://ipc.localhost/. If your IPC log is empty:
- Verify the app has actually made IPC calls (check network tab in dev tools)
- The bridge’s fetch interceptor must load before the first IPC call
plugin:victauri|*calls are intentionally excluded from the log
Recording captures no events
The auto-event recording loop polls getEventStream() every 1 second. If your recording appears empty:
- Ensure you called
startbefore the actions you want to capture - Wait at least 1 second after actions before
stop - Check that the events you expect (console, mutation, network) are actually occurring
Tests pass locally but fail in CI
Common CI issues:
- No display server (use
xvfb-runon Linux for Tauri apps) - Port conflicts (use a unique port or let the fallback mechanism work)
- Timing (CI machines may be slower — increase timeouts)
- Frontend not built (debug builds embed
frontendDistat compile time — runnpm run buildfirst)