Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

LayerWhat You Get
WebViewDOM snapshots, element interaction, JS evaluation, CSS inspection
IPCCommand registry, invoke commands, intercept and log IPC traffic
BackendState reading, memory tracking, process diagnostics
WindowsMulti-window management, screenshots, positioning
Time-TravelRecord 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

  1. 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 AppHandle access.

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

  3. Full-stack — WebView + IPC + Backend + DB, not just DOM. Cross-boundary verification catches state drift between frontend and backend.

  4. MCP-native — Speaks the protocol AI agents already understand. No custom SDKs or adapters needed.

  5. Cross-platform — Works identically on Windows, macOS, and Linux. No CDP dependency.

  6. Plugin, not framework — One line in Cargo.toml to 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

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_snapshot generates 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:

  1. Element exists in DOM
  2. Element is visible (not display:none or visibility:hidden)
  3. Element is enabled (not disabled attribute)
  4. Element has non-zero size
  5. Element is not covered by another element (hit-test)
  6. Element does not have pointer-events:none
  7. Element is in viewport (with auto-scroll)
  8. Element is stable (not animating)
  9. Element is attached to DOM
  10. Element is actionable for the specific operation

Dual Protocol: MCP + REST

Victauri serves both protocols on the same port:

EndpointProtocolUse Case
/mcpMCP Streamable HTTP + SSEAI agents (Claude Code, etc.)
/api/toolsREST (plain JSON)Scripts, CI, curl, custom integrations
/healthGET (no auth)Health checks, watchdog
/infoGETServer 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:

NameTypeRequiredDescription
dir_typestringnoOne of: data, config, log, local_data (default: data)
subpathstringnoSubdirectory 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:

NameTypeRequiredDescription
pathstringyesFile path relative to the directory root
dir_typestringnoOne 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:

NameTypeRequiredDescription
sqlstringyesSQL query (SELECT only)
db_pathstringnoPath to database file (auto-discovers if omitted)
paramsarraynoBind 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:

NameTypeRequiredDescription
expressionstringyesJavaScript expression or statements to evaluate
webview_labelstringnoTarget 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:

NameTypeRequiredDescription
webview_labelstringnoTarget 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:

NameTypeRequiredDescription
selectorstringnoCSS selector (alias: css)
textstringnoText content to search for
rolestringnoARIA role to filter by
webview_labelstringnoTarget webview

Examples:

{"selector": "button.primary"}
{"text": "Submit"}
{"role": "heading"}

invoke_command

Invoke a Tauri command from the backend.

Parameters:

NameTypeRequiredDescription
commandstringyesCommand name
argsobjectnoArguments to pass

Example:

{"command": "get_settings", "args": {}}
{"command": "search_context", "args": {"query": "hello"}}

screenshot

Capture a PNG screenshot of the application window.

Parameters:

NameTypeRequiredDescription
window_labelstringnoTarget window (defaults to main)

Returns: Base64-encoded PNG image data.


verify_state

Compare frontend and backend state to detect drift.

Parameters:

NameTypeRequiredDescription
frontend_exprstringnoJS expression for frontend state
backend_stateobjectnoExpected 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:

NameTypeRequiredDescription
conditionstringyesOne of: selector, selector_gone, text, text_gone, url
valuestringyesThe selector, text, or URL pattern to match
timeout_msnumbernoMax 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:

NameTypeRequiredDescription
expressionstringyesJS expression to evaluate
conditionstringyesOne of: equals, not_equals, contains, greater_than, less_than, truthy, falsy
expectedanynoExpected 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:

NameTypeRequiredDescription
querystringyesNatural 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.

ActionParametersDescription
clickref_idClick an element
hoverref_idHover over an element
focusref_idFocus an element
scroll_into_viewref_idScroll element into viewport
selectref_id, valueSelect an option

Example:

{"action": "click", "ref_id": "e3"}
{"action": "hover", "ref_id": "e12"}

input

Text input and keyboard operations.

ActionParametersDescription
fillref_id, valueSet input value directly
typeref_id, textType character-by-character
press_keykeyPress 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.

ActionParametersDescription
get_statelabelGet window state (position, size, visibility)
listList all window labels
managelabel, operationminimize/unminimize/maximize/unmaximize/close
resizelabel, width, heightResize a window
move_tolabel, x, yMove a window
set_titlelabel, titleChange window title

Example:

{"action": "list"}
{"action": "get_state", "label": "main"}
{"action": "resize", "label": "main", "width": 1200, "height": 800}

storage

Browser storage operations.

ActionParametersDescription
getkeyGet localStorage value
setkey, valueSet localStorage value
deletekeyDelete localStorage key
cookiesGet all cookies

Example:

{"action": "set", "key": "theme", "value": "dark"}
{"action": "get", "key": "theme"}

Navigation and history operations.

ActionParametersDescription
go_tourlNavigate to a URL (http/https only)
backGo back in history
historyGet navigation history log
dialogsGet dialog log (alerts, confirms, prompts)

Example:

{"action": "go_to", "url": "https://example.com"}
{"action": "history"}

recording

Time-travel recording for session capture and replay.

ActionParametersDescription
startStart recording events
stopStop recording and return session
checkpointlabelCreate a named checkpoint
eventssince, limitGet recorded events
exportExport full session data
importsessionImport a session for replay

Example:

{"action": "start"}
{"action": "checkpoint", "label": "after-login"}
{"action": "stop"}

inspect

CSS inspection, accessibility, and performance profiling.

ActionParametersDescription
stylesref_id, propertiesGet computed CSS styles
boundsref_idsGet bounding boxes with box model
highlightref_id, color, labelDraw debug overlay on element
accessibilityRun WCAG accessibility audit
performanceGet 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.

ActionParametersDescription
injectcssInject custom CSS (replaces previous)
removeRemove injected CSS

Example:

{"action": "inject", "css": "* { outline: 1px solid red; }"}
{"action": "remove"}

logs

Access all captured logs from the application.

ActionParametersDescription
consolesince, levelConsole log entries
networksinceNetwork request log
ipcsince, limitIPC command log
navigationNavigation history
dialogsDialog interactions
eventssinceEvent stream
slow_ipcthreshold_msIPC 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:

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

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

FactoryExampleFinds by
Locator::role("button")ARIA rolerole="button"
Locator::text("Submit")Visible text (substring)textContent
Locator::text_exact("OK")Visible text (exact match)textContent
Locator::test_id("login-btn")Test ID attributedata-testid
Locator::css(".nav > a")CSS selectorCSS query
Locator::label("Email")Associated label text<label> + for
Locator::placeholder("Search...")Placeholder attributeplaceholder
Locator::alt_text("Logo")Alt text (images)alt
Locator::title("Close")Title attributetitle

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?;
}
ExpectationWaits 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

PresetToleranceThresholdUse case
Strict00.0%Pixel-perfect, no variation
Standard20.1%Most apps, minor anti-aliasing OK
AntiAlias50.5%Cross-browser font rendering
Relaxed102.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:

  1. Server connectivity
  2. JavaScript evaluation
  3. DOM snapshot validity
  4. Screenshot capture
  5. Window enumeration
  6. IPC integrity
  7. Accessibility audit (violations)
  8. Accessibility audit (warnings)
  9. DOM load performance
  10. Heap memory usage
  11. 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:

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

PlatformNotes
LinuxRequires xvfb-run --auto-servernum for headless display
macOSWorks out of the box — no WebDriver/CDP needed
WindowsWorks 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:

  1. Frontend (HTML/CSS/JS in a webview) — UI rendering, user interactions, client-side state
  2. Backend (Rust) — business logic, database access, system operations
  3. 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-driver binary and platform-specific WebDriver (msedgedriver on Windows, safaridriver on macOS, geckodriver on 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 testsFrontend mocksWebDriverPlaywrightVictauri
DOM interaction-YesYesYesYes
Backend verificationYes---Yes
IPC inspection-Mocked--Real
Cross-boundary----Yes
Ghost detection----Yes
A11y auditing-Via lib-YesYes
Perf profiling---YesYes
Screenshots--YesYesYes
Setup complexityNoneLowHighMediumLow
Cross-platformYesYesVariesVariesYes
Release overheadNoneNoneNoneNoneNone
AI agent support----MCP + REST

Use all the approaches where they shine:

  1. Unit tests for pure business logic (no Tauri runtime needed)
  2. Frontend tests for component-level rendering (mock only when intentional)
  3. Victauri for integration tests that verify frontend + IPC + backend work together
  4. 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

PlatformDisplay serverScreenshot method
Linuxxvfb-run --auto-servernumX11 GetImage / grim
macOSNone neededCGWindowListCreateImage
WindowsNone neededPrintWindow + 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

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:

PlatformNative APICSP bypass
WindowsICoreWebView2.ExecuteScriptAsync()Yes (Chromium CDP allowUnsafeEvalBlockedByCSP defaults to true)
macOSWKWebView.evaluateJavaScript()Yes (privileged bridge execution)
Linuxwebkit_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_commands and check_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:

ElementsApproximate 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

PlatformRequirementNotes
WindowsWebView2 runtimePre-installed on Windows 11; auto-installed on Windows 10. Evergreen (auto-updates).
macOSmacOS 10.15+WKWebView ships with the OS. No additional runtime.
LinuxWebKitGTK 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.localhost IPC 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

AppStarsTauriFrontendWindowsCommandsVictauri Fit
Vibe6.1k2.xTypeScript141Excellent
Bokuchi682.xReact115Excellent
Yaak18.6k2.11React 191~30-60Good (gRPC sidecar invisible)
Whispering4.5k2.xSvelte 51~10-20Good (framework diversity)
Wealthfolio7.4k2.10React1StandardGood (single-instance plugin)
Clash Nyanpasu13k2.4React 19DynamicStandardGood (dynamic windows, specta IPC)
Spacedrive38k2.1React + rspc15+rspc-routedPartial (rspc opaque, multi-window)
Seelen-UI16.8k2.10Svelte 5ManyStandardHard (desktop environment)
Hoppscotch Agent79k2.9Minimal12Too 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

SettingBuilder MethodEnvironment VariableDefault
Port.port(7373)VICTAURI_PORT7373
Auth token.auth_token("...")VICTAURI_AUTH_TOKENAuto-generated UUID
Disable auth.auth_disabled()Auth enabled
Eval timeout.eval_timeout(Duration)VICTAURI_EVAL_TIMEOUT30s
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 /info endpoint
  • 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-key in 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

VariableDescription
VICTAURI_PORTOverride the MCP server port
VICTAURI_AUTH_TOKENSet the authentication token
VICTAURI_EVAL_TIMEOUTEval timeout in seconds

Environment variables take priority over builder settings.

Watchdog Configuration

The victauri-watchdog binary is configured entirely via environment variables:

VariableDefaultDescription
VICTAURI_PORT7373Port to monitor
VICTAURI_INTERVAL5Health check interval in seconds
VICTAURI_MAX_FAILURES3Consecutive 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

  1. On startup, if no explicit token is configured, Victauri generates a random UUID v4 token
  2. The token is printed to the application log
  3. Clients must include Authorization: Bearer <token> in every request
  4. 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

EndpointAuth Required
/healthNo
/mcpYes
/api/toolsYes
/api/tools/{name}Yes
/infoYes

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 AtomicU64 counter
  • 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: nosniff
  • X-Frame-Options: DENY
  • Cache-Control: no-store

Threat Model

What Victauri Protects Against

ThreatMitigation
Production exposure#[cfg(debug_assertions)] gate
Unauthorized local accessBearer token auth (enabled by default)
Timing attacks on authConstant-time comparison
Request floodingToken-bucket rate limiter
Remote network accessLocalhost-only binding
Data exfiltrationPrivacy profiles + output redaction
Dangerous mutationsTool disabling + command allowlists
Cross-origin attacksOrigin 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

  1. Open your browser’s extension management page (chrome://extensions)
  2. Enable “Developer mode”
  3. 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
  • BridgeDispatch sends 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 CustomEvent pattern 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)

ToolDescription
get_plugin_infoExtension version and status (handled locally)
tabs.listList open browser tabs (handled locally)
dom_snapshotFull accessible DOM tree of active tab
find_elementsSearch by CSS selector, text, or role
eval_jsEvaluate JavaScript in page context
clickClick an element by ref
fillSet input value
type_textType characters one-by-one
press_keyDispatch keyboard event
hoverHover over element
scroll_into_viewScroll element into viewport
get_stylesComputed CSS for an element
get_bounding_boxesElement dimensions and box model
highlight_elementDraw debug overlay
clear_highlightsRemove all overlays
screenshotCapture visible tab as PNG
navigateGo to URL
get_cookiesGet cookies for current domain
get_console_logsCaptured console entries
get_network_logFetch/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 script window.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:

  1. You’re running a debug build (cargo run, not cargo run --release)
  2. The port isn’t already in use (check the logs for port fallback messages)
  3. 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 /info on the bound port
  • Discoverable by the victauri check CLI 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 (display not none, visibility not hidden)
  • Element must be enabled (no disabled attribute)
  • 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_script which 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 start before 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-run on 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 frontendDist at compile time — run npm run build first)