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.
Add the test crate to your dev dependencies:
[dev-dependencies]
victauri-test = "0.10"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }Write a test:
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
cd src-tauri && VICTAURI_E2E=1 cargo test --test smoke # run tests (from the app crate)The VictauriClient is a typed HTTP client that handles the MCP handshake automatically (the server is stateless by default — there is no session to manage):
use victauri_test::VictauriClient;
#[tokio::test]
async fn test_my_app() {
// discover() finds the running app and its auth token (see below).
let mut client = VictauriClient::discover().await.unwrap();
let title = client.eval_js("document.title").await.unwrap();
assert!(title.to_string().contains("My App"));
client.click("e3").await.unwrap();
client.fill("e5", "hello@example.com").await.unwrap();
}discover() reads the port and auth token from the discovery files the plugin writes. With
several Victauri apps running, cargo test inside a Tauri app crate picks that crate's app (its
tauri.conf.json identifier); otherwise select one with VICTAURI_APP or discover_app(Some(…)):
let mut client = VictauriClient::discover().await.unwrap();VictauriClient::connect(7373) connects to a fixed port WITHOUT a token, so it only works
against an app built with auth_disabled().
let mut client = VictauriClient::connect_with_token(7373, Some("my-secret-token"))
.await
.unwrap();High-level methods that find elements by text or ID — no ref handles or selectors needed:
| Method | What it does |
|---|---|
click_by_text("Submit") |
Find element by visible text, click it |
click_by_id("save-btn") |
Find element by HTML id, click it |
fill_by_id("email", "a@b.com") |
Find input by id, fill value |
type_by_id("search", "query") |
Find input by id, type char-by-char |
select_by_id("theme", "dark") |
Find select by id, choose option |
expect_text("Success!") |
Poll until text appears (5s timeout) |
expect_no_text("Error") |
Poll until text disappears (3s timeout) |
text_by_id("counter") |
Get text content of element by id |
double_click_by_id("item") |
Find element by id, double-click it |
hover("e3") |
Hover over element by ref |
scroll_to_by_id("footer") |
Scroll element into viewport |
For direct MCP tool access using ref handles:
| Method | Description |
|---|---|
eval_js(expr) |
Evaluate JavaScript |
dom_snapshot() |
Get full DOM tree |
find_elements(selector) |
Find elements by CSS |
click(ref_id) |
Click element |
fill(ref_id, value) |
Fill input |
type_text(ref_id, text) |
Type characters |
press_key(key) |
Press keyboard key |
screenshot(label) |
Capture PNG |
get_window_state(label) |
Window position/size |
list_windows() |
All window labels |
invoke_command(name, args) |
Call Tauri command |
get_ipc_log(limit) |
IPC call history |
get_registry() |
Registered commands |
get_memory_stats() |
Process memory |
verify_state(frontend, backend) |
Cross-boundary check |
detect_ghost_commands() |
Unregistered commands |
check_ipc_integrity() |
IPC health |
assert_semantic(expr, label, cond, expected) |
Semantic assertion |
wait_for(condition, value, timeout) |
Wait for condition |
start_recording(session_id) |
Begin time-travel |
stop_recording() |
End recording |
logs("console", limit) |
Console / network / IPC 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 |
For complex queries, Victauri provides composable locators with auto-waiting expectations.
Create locators by different strategies:
| Factory | Example | Finds by |
|---|---|---|
Locator::role("button") |
ARIA role | role="button" |
Locator::text("Submit") |
Visible text (substring) | textContent |
Locator::text_exact("OK") |
Visible text (exact match) | textContent |
Locator::test_id("login-btn") |
Test ID attribute | data-testid |
Locator::css(".nav > a") |
CSS selector | CSS query |
Locator::label("Email") |
Associated label text | <label> + for |
Locator::placeholder("Search...") |
Placeholder attribute | placeholder |
Locator::alt_text("Logo") |
Alt text (images) | alt |
Locator::title("Close") |
Title attribute | title |
Narrow results with chained filters:
// 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");Interact with resolved elements:
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?;Read element state:
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?;Auto-waiting assertions with configurable timeout:
// Wait up to 5s (default) for element to become visible
locator.expect(&mut client).to_be_visible().await?;
// Custom timeout and polling
locator.expect(&mut client)
.timeout_ms(10_000)
.poll_ms(100)
.to_have_text("Complete")
.await?;
// Negation — wait until condition is NOT true
locator.expect(&mut client).not().to_be_visible().await?;| Expectation | Waits until |
|---|---|
.to_be_visible() |
Element is visible |
.to_be_hidden() |
Element is hidden |
.to_be_enabled() |
Element is enabled |
.to_be_disabled() |
Element is disabled |
.to_be_focused() |
Element has focus |
.to_have_text("exact") |
Text content equals value |
.to_contain_text("partial") |
Text content contains value |
.to_have_value("input-val") |
Input value equals value |
.to_have_attribute("href", "/home") |
Attribute equals value |
.to_have_count(3) |
Exactly N elements match |
.to_be_checked() |
Checkbox/radio is checked |
.to_be_unchecked() |
Checkbox/radio is unchecked |
.to_be_attached() |
Element exists in DOM |
.to_be_detached() |
Element removed from DOM |
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();
}The e2e_test! macro handles skip-when-no-server and auto-connect:
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();
});TestApp starts your app, waits for the server, and cleans up on drop:
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
}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");Isolate assertions to a specific user action:
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");Detect when the frontend and backend disagree:
let result = client.verify_state(
"document.querySelector('.theme-label').textContent",
json!({"theme": "dark"})
).await?;
assert!(result["divergences"].as_array().unwrap().is_empty());Find orphaned commands — called in the frontend but missing from the backend:
let ghosts = client.detect_ghost_commands().await?;
assert!(ghosts["confirmed_ghosts"].as_array().unwrap().is_empty(),
"Found ghost commands: {ghosts}");Detect stuck, stale, or errored IPC calls:
let health = client.check_ipc_integrity().await?;
assert!(health["healthy"].as_bool().unwrap());Victauri provides direct access to the Rust backend — no webview proxy needed.
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"]);
}let info = client.app_info().await?;
println!("App: {}", info["config"]["product_name"]);
println!("Data dir: {}", info["paths"]["data"]);
println!("Databases found: {:?}", info["databases"]);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"]);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");Check multiple conditions at once — DOM, IPC, accessibility, errors — with a single report:
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);
}Compare screenshots against stored baselines with pixel-level diffing:
use victauri_test::visual::{VisualOptions, ThresholdPreset, MaskRegion};
let opts = VisualOptions {
snapshot_dir: "tests/snapshots".into(),
..VisualOptions::default()
}
.with_preset(ThresholdPreset::Standard);
// Errors with `TestError::VisualRegression` when the diff exceeds the threshold.
let diff = client.screenshot_visual("dashboard", &opts).await?;
println!("{:.2}% of pixels match", diff.match_percentage);On first run, the screenshot is saved as the baseline. Subsequent runs compare and generate a red-overlay diff image when mismatched.
| Preset | Tolerance | Threshold | Use case |
|---|---|---|---|
Strict |
0 | 0.0% | Pixel-perfect, no variation |
Standard |
2 | 0.1% | Most apps, minor anti-aliasing OK |
AntiAlias |
5 | 0.5% | Cross-browser font rendering |
Relaxed |
10 | 2.0% | Cross-platform CI |
Exclude dynamic content from comparison:
let opts = VisualOptions {
snapshot_dir: "tests/snapshots".into(),
..VisualOptions::default()
}
.with_preset(ThresholdPreset::Standard)
.with_mask(MaskRegion::new(0, 0, 200, 50)); // timestamp headerclient.screenshot_to_file("debug.png").await?;
client.screenshot_to_file_for("main", "main-window.png").await?;Track which registered Tauri commands your tests actually exercise:
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:
client.verify()
.has_text("Welcome")
.coverage_above(80.0)
.run().await?.assert_all_passed();From the CLI:
victauri coverage --threshold 80Prerequisite: Commands must use #[inspectable] to be tracked. See Command Instrumentation.
Run WCAG-based accessibility checks against your running app:
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:
use victauri_test::assert_no_a11y_violations;
let audit = client.audit_accessibility().await?;
assert_no_a11y_violations(&audit);Track navigation timing, memory usage, and resource loading:
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:
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 MBMetrics 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.
Record interactions, create checkpoints, and generate test files.
victauri record --output tests/login_flow.rs --test-name login_flow
# Interact with your app...
# Press Ctrl+C to stop and generate the test fileclient.start_recording(Some("my-session")).await?;
client.fill_by_id("email", "user@example.com").await?;
client.click_by_id("login-btn").await?;
// `stop_recording` returns the full session (events + any checkpoints).
let session = client.stop_recording().await?;
// Mid-session checkpoints and events-between-checkpoints are available through the
// `recording` tool's actions (checkpoint / events / events_between) via call_tool,
// or through the core `EventRecorder` API when embedding directly.Mark your Tauri commands with #[inspectable] for coverage tracking, ghost detection, and natural language resolution. The generated code names victauri_core::... by path, so the crate also needs victauri-core (and victauri-macros for this import) in its own [dependencies]:
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. In debug builds the schema is registered through inventory; in a release build nothing is registered and <fn>__schema() returns only the name and the async flag. Commands become discoverable through get_registry and natural language via resolve_command.
To auto-discover all instrumented commands:
tauri::Builder::default()
.plugin(
victauri_plugin::VictauriBuilder::new()
.auto_discover()
.build()
.unwrap(),
)
// ...use victauri_test::{
assert_json_eq,
assert_json_truthy,
assert_no_a11y_violations,
assert_performance_budget,
assert_ipc_healthy,
assert_state_matches,
};
// These helpers are SYNCHRONOUS and take a `&Value` you already fetched (plus a
// JSON pointer for the *_json_ helpers), not the client. Fetch first, then assert:
let title = client.eval_js("document.title").await?;
assert_json_eq(&title, "", &json!("My App")); // pointer "" = the whole value
let integrity = client.check_ipc_integrity().await?;
assert_ipc_healthy(&integrity);Each returns Result<(), TestError> — propagate with ? (or .unwrap() in a test):
use std::time::Duration;
client.assert_eval_works().await?;
client.assert_dom_snapshot_valid().await?;
client.assert_screenshot_ok().await?;
client.assert_windows_exist().await?;
client.assert_ipc_integrity_ok().await?;
client.assert_accessible().await?;
client.assert_dom_complete_under(Duration::from_millis(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?;Run the built-in 11-check smoke test programmatically:
use victauri_test::{VictauriClient, SmokeConfig};
#[tokio::test]
async fn smoke() {
let client = VictauriClient::connect(7373).await.unwrap();
let report = client.smoke_test().await.unwrap();
println!("Passed: {}/{}", report.passed_count(), report.total_count());
assert!(report.all_passed());
// Custom thresholds
let config = SmokeConfig {
max_dom_complete_ms: 3000,
max_heap_mb: 150.0,
};
let report = client.smoke_test_with_config(config).await.unwrap();
assert!(report.all_passed());
}The 11 checks: eval_js works, DOM snapshot valid, screenshot captures an image, windows exist, IPC integrity healthy, no uncaught errors, accessibility audit, DOM complete under the load budget (--max-load-ms), JS heap under the memory budget (--max-heap-mb), recording lifecycle, and health endpoint hardened.
Reports include timing data and can export to JUnit XML for CI integration.
#[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!"
}));
}#[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");
}#[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());
}#[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);
}#[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();
}Install with cargo install victauri-cli, then:
victauri init # Scaffold test directory with starter tests
victauri init --optional # Same, Victauri behind a `victauri` Cargo feature
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 tools # List the running app's tools
victauri call query_db query="SELECT 1" # Call any tool from the shell → JSON on stdout
victauri call screenshot --out shot.png # Image results are saved to a file
victauri watch # Re-run tests on file changes
victauri watch --filter smoke # Only re-run specific test fileRuns 11 built-in checks against your running app:
- Server connectivity
- JavaScript evaluation
- DOM snapshot validity
- Screenshot capture
- Window enumeration
- IPC integrity
- Accessibility audit (violations)
- Accessibility audit (warnings)
- DOM load performance
- Heap memory usage
- Health endpoint hardening
Exit code 0 if all pass, 1 if any fail. Ideal for CI gates.
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: 4DA-Systems/victauri/.github/actions/victauri-test@v0.10.2
with:
max-load-ms: 5000
max-heap-mb: 256
coverage: true
coverage-threshold: 80
junit-path: results.xmlOne step. Installs the CLI, waits for the server, runs smoke tests, and optionally gates on IPC coverage.
# .github/workflows/test.yml
- name: Run Victauri tests
run: cargo test -p my-app --test integrationTestApp::spawn handles starting the app, waiting for the server, and cleanup. Nothing else needed.
- 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"- 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 |
|---|---|
| Linux | Requires xvfb-run --auto-servernum for headless display |
| macOS | Works out of the box — no WebDriver/CDP needed |
| Windows | Works out of the box — uses native PrintWindow for screenshots |