Skip to main content

victauri_plugin/
lib.rs

1#![deny(missing_docs)]
2// In RELEASE builds the entire MCP server is gated out via `#[cfg(debug_assertions)]`
3// (`init()` is a zero-cost no-op), so the imports/constants/helpers it uses are
4// intentionally dead. Don't fail a `-Dwarnings` release build on that expected
5// dead code — without this, `RUSTFLAGS=-Dwarnings cargo build --release` errors.
6#![cfg_attr(not(debug_assertions), allow(unused_imports, dead_code))]
7//! Victauri — full-stack introspection for Tauri apps via an embedded MCP server.
8//!
9//! Add this plugin to your Tauri app for AI-agent-driven testing and debugging:
10//! DOM snapshots, IPC tracing, cross-boundary verification, and more tools —
11//! all accessible over the Model Context Protocol.
12//!
13//! # Quick Start
14//!
15//! ```ignore
16//! tauri::Builder::default()
17//!     .plugin(victauri_plugin::init())
18//!     .run(tauri::generate_context!())
19//!     .unwrap();
20//! ```
21//!
22//! In debug builds this starts an MCP server on port 7373. In release builds
23//! the plugin is a no-op with zero overhead.
24//!
25//! # Configuration
26//!
27//! Authentication is **enabled by default** with an auto-generated Bearer token
28//! written to `<temp>/victauri/<pid>/token` for client auto-discovery. The MCP
29//! server listens on `127.0.0.1` only and is gated behind `#[cfg(debug_assertions)]`.
30//! Use `.auth_token("...")` for a fixed token, or `.auth_disabled()` to opt out.
31//!
32//! ```ignore
33//! tauri::Builder::default()
34//!     .plugin(
35//!         victauri_plugin::VictauriBuilder::new()
36//!             .port(8080)
37//!             .strict_privacy_mode()
38//!             .build(),
39//!     )
40//!     .run(tauri::generate_context!())
41//!     .unwrap();
42//! ```
43
44/// Runtime-erased webview bridge trait and its Tauri implementation.
45pub mod bridge;
46/// Backend database access (`SQLite` read-only queries).
47#[cfg(feature = "sqlite")]
48pub mod database;
49pub mod error;
50/// JS bridge script generation for webview injection.
51pub mod js_bridge;
52/// MCP server, tool handler, and parameter types.
53pub mod mcp;
54mod memory;
55/// Privacy controls: command allowlists, blocklists, and tool disabling.
56pub mod privacy;
57
58/// Compose captured frames into a single contact-sheet PNG ("filmstrip").
59pub mod filmstrip;
60/// Output redaction for API keys, tokens, emails, and sensitive JSON keys.
61pub mod redaction;
62pub mod screencast;
63pub(crate) mod screenshot;
64mod tools;
65
66/// Bearer-token authentication, rate limiting, and security middlewares.
67pub mod auth;
68/// Backend introspection and chaos engineering (profiling, fault injection, contracts).
69pub mod introspection;
70
71use std::collections::{HashMap, HashSet};
72use std::sync::Arc;
73use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU64};
74use tauri::plugin::{Builder, TauriPlugin};
75use tauri::{Listener, Manager, RunEvent, Runtime};
76use tokio::sync::{Mutex, oneshot, watch};
77use victauri_core::{CommandRegistry, EventLog, EventRecorder};
78
79pub use error::BuilderError;
80pub use privacy::PrivacyProfile;
81
82pub use victauri_core::CommandInfo;
83pub use victauri_macros::inspectable;
84
85/// Register command schemas with the Victauri plugin at app setup time.
86///
87/// Pass the `__schema()` function calls generated by `#[inspectable]`.
88/// This populates the `CommandRegistry` so that `get_registry`, `resolve_command`,
89/// and `detect_ghost_commands` return real results.
90///
91/// # Example
92///
93/// ```rust,ignore
94/// use victauri_plugin::register_commands;
95///
96/// .setup(|app| {
97///     register_commands!(app,
98///         greet__schema(),
99///         increment__schema(),
100///         add_todo__schema(),
101///     );
102///     Ok(())
103/// })
104/// ```
105#[macro_export]
106macro_rules! register_commands {
107    ($app:expr, $($schema_call:expr),+ $(,)?) => {{
108        let state = $app.state::<std::sync::Arc<$crate::VictauriState>>();
109        $(
110            state.registry.register($schema_call);
111        )+
112    }};
113}
114
115const DEFAULT_PORT: u16 = 7373;
116const DEFAULT_EVENT_CAPACITY: usize = 10_000;
117const DEFAULT_RECORDER_CAPACITY: usize = 50_000;
118const DEFAULT_EVAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
119const MAX_EVENT_CAPACITY: usize = 1_000_000;
120const MAX_RECORDER_CAPACITY: usize = 1_000_000;
121const MAX_EVAL_TIMEOUT_SECS: u64 = 300;
122
123/// Map of pending JavaScript eval callbacks, keyed by request ID.
124/// Each entry holds a oneshot sender that resolves when the webview returns a result.
125pub type PendingCallbacks = Arc<Mutex<HashMap<String, oneshot::Sender<String>>>>;
126
127/// Runtime state shared between the MCP server and all tool handlers.
128pub struct VictauriState {
129    /// Ring-buffer event log for IPC calls, state changes, and DOM mutations.
130    pub event_log: EventLog,
131    /// Registry of all discovered Tauri commands with metadata.
132    pub registry: CommandRegistry,
133    /// TCP port the MCP server listens on (may differ from configured port if fallback was used).
134    pub port: AtomicU16,
135    /// Pending JavaScript eval callbacks awaiting webview responses.
136    pub pending_evals: PendingCallbacks,
137    /// Session recorder for time-travel debugging.
138    pub recorder: EventRecorder,
139    /// Privacy configuration (tool disabling, command filtering, output redaction).
140    pub privacy: privacy::PrivacyConfig,
141    /// Timeout for JavaScript eval operations.
142    pub eval_timeout: std::time::Duration,
143    /// Sends `true` to signal graceful MCP server shutdown.
144    pub shutdown_tx: watch::Sender<bool>,
145    /// Instant the plugin was initialized, for uptime tracking.
146    pub started_at: std::time::Instant,
147    /// Total number of MCP tool invocations since startup.
148    pub tool_invocations: AtomicU64,
149    /// Whether `file:` URLs are allowed in the `navigate` tool's `go_to` action.
150    /// Defaults to `false` — only `http` and `https` are permitted unless opted in
151    /// via [`VictauriBuilder::allow_file_navigation`].
152    pub allow_file_navigation: bool,
153    /// Per-command execution timing for Rust-side profiling.
154    pub command_timings: introspection::CommandTimings,
155    /// Active fault injection rules for chaos engineering.
156    pub fault_registry: introspection::FaultRegistry,
157    /// Recorded IPC contract baselines for schema drift detection.
158    pub contract_store: introspection::ContractStore,
159    /// Plugin startup phase timestamps for performance analysis.
160    pub startup_timeline: introspection::StartupTimeline,
161    /// Captured Tauri event bus events (from `listen_any`).
162    pub event_bus: introspection::EventBusMonitor,
163    /// Tracker for Victauri's own spawned async tasks.
164    pub task_tracker: introspection::TaskTracker,
165    /// Set to `true` when any webview's JS bridge sends its ready signal.
166    pub bridge_ready: AtomicBool,
167    /// Notifies waiters when the JS bridge ready signal arrives.
168    pub bridge_notify: tokio::sync::Notify,
169    /// Screencast state for the `trace` tool (background frame capture buffer).
170    pub screencast: Arc<screencast::Screencast>,
171    /// Extra absolute directories searched by `query_db` / `introspect db_health`
172    /// in addition to the OS app directories. Lets Victauri reach databases an
173    /// app stores outside the Tauri app-data dir (e.g. a project or working
174    /// directory). Opt in via [`VictauriBuilder::db_search_paths`]. Absolute
175    /// `query_db` paths are permitted only when they resolve within one of these
176    /// roots (or an app directory).
177    pub db_search_paths: Vec<std::path::PathBuf>,
178    /// Application-defined state probes surfaced via the `app_state` tool.
179    /// Registered through [`VictauriBuilder::probe`].
180    pub probes: introspection::AppStateProbes,
181}
182
183/// Builder for configuring the Victauri plugin before adding it to a Tauri app.
184///
185/// Supports port selection, authentication, privacy controls, output redaction,
186/// and capacity tuning. All settings have sensible defaults and can be overridden
187/// via environment variables.
188///
189/// **Authentication is enabled by default** with an auto-generated token written to
190/// the discovery directory (`<temp>/victauri/<pid>/token`). MCP clients that read
191/// this file get seamless access. Set `VICTAURI_AUTH_TOKEN` for a fixed token, or
192/// call [`auth_disabled()`](VictauriBuilder::auth_disabled) to opt out for local-only
193/// single-user development.
194pub struct VictauriBuilder {
195    port: Option<u16>,
196    event_capacity: usize,
197    recorder_capacity: usize,
198    eval_timeout: std::time::Duration,
199    auth_token: Option<String>,
200    auth_explicitly_enabled: bool,
201    auth_explicitly_disabled: bool,
202    disabled_tools: Vec<String>,
203    command_allowlist: Option<Vec<String>>,
204    command_blocklist: Vec<String>,
205    storage_key_blocklist: Vec<String>,
206    redaction_patterns: Vec<String>,
207    redaction_enabled: bool,
208    strict_privacy: bool,
209    privacy_profile: Option<privacy::PrivacyProfile>,
210    bridge_capacities: js_bridge::BridgeCapacities,
211    on_ready: Option<Box<dyn FnOnce(u16) + Send + 'static>>,
212    commands: Vec<victauri_core::CommandInfo>,
213    allow_file_navigation: bool,
214    listen_events: Vec<String>,
215    db_search_paths: Vec<std::path::PathBuf>,
216    probes: Vec<(String, std::sync::Arc<introspection::ProbeFn>)>,
217}
218
219impl Default for VictauriBuilder {
220    fn default() -> Self {
221        Self {
222            port: None,
223            event_capacity: DEFAULT_EVENT_CAPACITY,
224            recorder_capacity: DEFAULT_RECORDER_CAPACITY,
225            eval_timeout: DEFAULT_EVAL_TIMEOUT,
226            auth_token: None,
227            auth_explicitly_enabled: false,
228            auth_explicitly_disabled: false,
229            disabled_tools: Vec::new(),
230            command_allowlist: None,
231            command_blocklist: Vec::new(),
232            storage_key_blocklist: Vec::new(),
233            redaction_patterns: Vec::new(),
234            redaction_enabled: false,
235            strict_privacy: false,
236            privacy_profile: None,
237            bridge_capacities: js_bridge::BridgeCapacities::default(),
238            on_ready: None,
239            commands: Vec::new(),
240            allow_file_navigation: false,
241            listen_events: Vec::new(),
242            db_search_paths: Vec::new(),
243            probes: Vec::new(),
244        }
245    }
246}
247
248impl VictauriBuilder {
249    /// Create a new builder with default settings.
250    #[must_use]
251    pub fn new() -> Self {
252        Self::default()
253    }
254
255    /// Set the TCP port for the MCP server (default: 7373, env: `VICTAURI_PORT`).
256    #[must_use]
257    pub fn port(mut self, port: u16) -> Self {
258        self.port = Some(port);
259        self
260    }
261
262    /// Set the maximum number of events in the ring-buffer log (default: 10,000).
263    #[must_use]
264    pub fn event_capacity(mut self, capacity: usize) -> Self {
265        self.event_capacity = capacity;
266        self
267    }
268
269    /// Set the maximum events kept during session recording (default: 50,000).
270    #[must_use]
271    pub fn recorder_capacity(mut self, capacity: usize) -> Self {
272        self.recorder_capacity = capacity;
273        self
274    }
275
276    /// Set the timeout for JavaScript eval operations (default: 30s, env: `VICTAURI_EVAL_TIMEOUT`).
277    #[must_use]
278    pub fn eval_timeout(mut self, timeout: std::time::Duration) -> Self {
279        self.eval_timeout = timeout;
280        self
281    }
282
283    /// Set an explicit auth token for the MCP server (env: `VICTAURI_AUTH_TOKEN`).
284    ///
285    /// Setting a token implicitly enables authentication.
286    #[must_use]
287    pub fn auth_token(mut self, token: impl Into<String>) -> Self {
288        self.auth_token = Some(token.into());
289        self
290    }
291
292    /// Explicitly enable authentication with an auto-generated `UUID` v4 token.
293    ///
294    /// **Authentication is already enabled by default** (see [`Self::auth_disabled`]),
295    /// so this call is redundant in normal use and is kept for explicitness and
296    /// backward compatibility. The token is printed to the log on startup and written
297    /// to the discovery directory (`<temp>/victauri/<pid>/token`) for client
298    /// auto-discovery.
299    #[must_use]
300    pub fn auth_enabled(mut self) -> Self {
301        self.auth_explicitly_enabled = true;
302        self
303    }
304
305    /// Generate a random `UUID` v4 auth token (alias for [`auth_enabled`](Self::auth_enabled)).
306    #[must_use]
307    pub fn generate_auth_token(mut self) -> Self {
308        self.auth_explicitly_enabled = true;
309        self
310    }
311
312    /// Disable authentication entirely.
313    ///
314    /// By default, Victauri auto-generates a Bearer token and writes it to the
315    /// discovery directory. Call this method to opt out of authentication for
316    /// single-user local development where convenience outweighs the risk.
317    ///
318    /// **Warning:** Without auth, any process on localhost can invoke all MCP
319    /// tools including `eval_js` and `invoke_command`. Only disable auth on
320    /// machines where you trust every running process.
321    #[must_use]
322    pub fn auth_disabled(mut self) -> Self {
323        self.auth_explicitly_disabled = true;
324        self
325    }
326
327    /// Disable specific MCP tools by name (e.g., `["eval_js", "screenshot"]`).
328    #[must_use]
329    pub fn disable_tools(mut self, tools: &[&str]) -> Self {
330        self.disabled_tools = tools.iter().map(std::string::ToString::to_string).collect();
331        self
332    }
333
334    /// Only allow these Tauri commands to be invoked via MCP (positive allowlist).
335    #[must_use]
336    pub fn command_allowlist(mut self, commands: &[&str]) -> Self {
337        self.command_allowlist = Some(
338            commands
339                .iter()
340                .map(std::string::ToString::to_string)
341                .collect(),
342        );
343        self
344    }
345
346    /// Block specific Tauri commands from being invoked via MCP.
347    #[must_use]
348    pub fn command_blocklist(mut self, commands: &[&str]) -> Self {
349        self.command_blocklist = commands
350            .iter()
351            .map(std::string::ToString::to_string)
352            .collect();
353        self
354    }
355
356    /// Block `storage.set` from writing these `localStorage`/`sessionStorage` keys.
357    /// Use it to protect keys your app trusts for auth/role/tier/feature decisions
358    /// so an agent cannot escalate privilege by poisoning them (audit #33). Default
359    /// is empty (no keys blocked), preserving existing behaviour.
360    #[must_use]
361    pub fn storage_key_blocklist(mut self, keys: &[&str]) -> Self {
362        self.storage_key_blocklist = keys.iter().map(std::string::ToString::to_string).collect();
363        self
364    }
365
366    /// Add a regex pattern for output redaction (e.g., `r"SECRET_\w+"`).
367    #[must_use]
368    pub fn add_redaction_pattern(mut self, pattern: impl Into<String>) -> Self {
369        self.redaction_patterns.push(pattern.into());
370        self
371    }
372
373    /// Enable output redaction with built-in patterns (API keys, emails, tokens).
374    #[must_use]
375    pub fn enable_redaction(mut self) -> Self {
376        self.redaction_enabled = true;
377        self
378    }
379
380    /// Enable strict privacy mode (equivalent to [`PrivacyProfile::Observe`]).
381    ///
382    /// Disables all mutation tools (`eval_js`, screenshot, interactions, input,
383    /// storage writes, navigation, CSS injection) and enables output redaction.
384    ///
385    /// Prefer [`privacy_profile()`](Self::privacy_profile) for finer control.
386    #[must_use]
387    pub fn strict_privacy_mode(mut self) -> Self {
388        self.strict_privacy = true;
389        self.privacy_profile = Some(privacy::PrivacyProfile::Observe);
390        self
391    }
392
393    /// Set the privacy profile tier.
394    ///
395    /// - [`Observe`](privacy::PrivacyProfile::Observe) — read-only (snapshots, logs, registry)
396    /// - [`Test`](privacy::PrivacyProfile::Test) — observe + interactions + input + storage writes + recording
397    /// - [`FullControl`](privacy::PrivacyProfile::FullControl) — everything (default)
398    ///
399    /// Profiles automatically enable redaction for `Observe` and `Test`.
400    /// Use [`disable_tools()`](Self::disable_tools) to apply overrides on top of a profile.
401    #[must_use]
402    pub fn privacy_profile(mut self, profile: privacy::PrivacyProfile) -> Self {
403        self.privacy_profile = Some(profile);
404        if matches!(
405            profile,
406            privacy::PrivacyProfile::Observe | privacy::PrivacyProfile::Test
407        ) {
408            self.redaction_enabled = true;
409        }
410        self
411    }
412
413    /// Set the maximum console log entries kept in the JS bridge (default: 1000).
414    #[must_use]
415    pub fn console_log_capacity(mut self, capacity: usize) -> Self {
416        self.bridge_capacities.console_logs = capacity;
417        self
418    }
419
420    /// Set the maximum network log entries kept in the JS bridge (default: 1000).
421    #[must_use]
422    pub fn network_log_capacity(mut self, capacity: usize) -> Self {
423        self.bridge_capacities.network_log = capacity;
424        self
425    }
426
427    /// Set the maximum navigation log entries kept in the JS bridge (default: 200).
428    #[must_use]
429    pub fn navigation_log_capacity(mut self, capacity: usize) -> Self {
430        self.bridge_capacities.navigation_log = capacity;
431        self
432    }
433
434    /// Pre-register command schemas so `get_registry`, `resolve_command`, and
435    /// `detect_ghost_commands` return real results from the moment the server starts.
436    ///
437    /// Pass the `__schema()` functions generated by `#[inspectable]`.
438    ///
439    /// ```rust,ignore
440    /// VictauriBuilder::new()
441    ///     .commands(&[
442    ///         greet__schema(),
443    ///         increment__schema(),
444    ///         add_todo__schema(),
445    ///     ])
446    ///     .build()
447    /// ```
448    #[must_use]
449    pub fn commands(mut self, schemas: &[victauri_core::CommandInfo]) -> Self {
450        self.commands = schemas.to_vec();
451        self
452    }
453
454    /// Register command names without full schemas.
455    ///
456    /// Use this when your commands are decorated with `#[tauri::command]` but not
457    /// `#[inspectable]`. Ghost detection will recognize these commands as registered,
458    /// eliminating false positives.
459    ///
460    /// ```rust,ignore
461    /// VictauriBuilder::new()
462    ///     .register_command_names(&[
463    ///         "get_settings",
464    ///         "save_settings",
465    ///         "run_analysis",
466    ///     ])
467    ///     .build()
468    /// ```
469    #[must_use]
470    pub fn register_command_names(mut self, names: &[&str]) -> Self {
471        self.commands
472            .extend(names.iter().map(|n| victauri_core::CommandInfo::new(*n)));
473        self
474    }
475
476    /// Auto-discover all `#[inspectable]` commands in the binary.
477    ///
478    /// Uses `inventory` to collect every command marked with `#[inspectable]`
479    /// at link time — no manual listing required. This replaces both
480    /// `.commands(&[...])` and `register_commands!()`.
481    ///
482    /// ```rust,ignore
483    /// tauri::Builder::default()
484    ///     .plugin(
485    ///         VictauriBuilder::new()
486    ///             .auto_discover()
487    ///             .build()
488    ///             .unwrap(),
489    ///     )
490    /// ```
491    #[must_use]
492    pub fn auto_discover(mut self) -> Self {
493        self.commands
494            .extend(victauri_core::auto_discovered_commands());
495        self
496    }
497
498    /// Register custom Tauri event names to capture in the event bus.
499    ///
500    /// Window lifecycle events (focus, blur, close, resize, move, theme change) are
501    /// captured automatically. Use this for app-specific events emitted via `app.emit()`.
502    ///
503    /// ```rust,ignore
504    /// VictauriBuilder::new()
505    ///     .listen_events(&["notification-added", "settings-changed", "sync-complete"])
506    ///     .build()
507    /// ```
508    #[must_use]
509    pub fn listen_events(mut self, events: &[&str]) -> Self {
510        self.listen_events = events
511            .iter()
512            .map(std::string::ToString::to_string)
513            .collect();
514        self
515    }
516
517    /// Allow `file:` URLs in the `navigate` tool's `go_to` action.
518    ///
519    /// By default, only `http` and `https` schemes are permitted. Calling this
520    /// method opts in to `file:` navigation, which grants the MCP client access
521    /// to local filesystem paths via the webview.
522    ///
523    /// **Warning:** Enabling this in untrusted environments exposes local files
524    /// to any process that can reach the MCP server.
525    #[must_use]
526    pub fn allow_file_navigation(mut self) -> Self {
527        self.allow_file_navigation = true;
528        self
529    }
530
531    /// Add extra directories for `query_db` and `introspect db_health` to search
532    /// for `SQLite` databases, beyond the OS app directories.
533    ///
534    /// Many apps store their database outside the Tauri app-data directory (in a
535    /// project/working directory, a user-chosen location, or a configured path).
536    /// By default Victauri only searches the OS app directories, so those
537    /// databases are unreachable. Registering their containing directory here
538    /// makes them discoverable by relative name and permits absolute `query_db`
539    /// paths that resolve within these roots.
540    ///
541    /// Access remains read-only and path-traversal-guarded. Paths are added
542    /// as-is; relative paths are resolved against the process working directory.
543    #[must_use]
544    pub fn db_search_paths<I, P>(mut self, paths: I) -> Self
545    where
546        I: IntoIterator<Item = P>,
547        P: Into<std::path::PathBuf>,
548    {
549        self.db_search_paths
550            .extend(paths.into_iter().map(Into::into));
551        self
552    }
553
554    /// Register an application state probe surfaced through the `app_state` tool.
555    ///
556    /// A probe is a named closure returning a JSON snapshot of domain-specific
557    /// backend state. It gives an agent first-class, discoverable access to state
558    /// that would otherwise only be reachable by `query_db` + grepping logs —
559    /// e.g. a scoring pipeline's version and queue depth, a cache's hit rate, or
560    /// a scheduler's next-run time. The closure runs in the Rust process with no
561    /// IPC round-trip, so it can read backend state the frontend never sees.
562    ///
563    /// The idiomatic pattern is to build your shared state as an `Arc` first, then
564    /// clone it into both Tauri's `.manage()` and the probe:
565    ///
566    /// ```no_run
567    /// # use std::sync::Arc;
568    /// # use serde_json::json;
569    /// # struct Scoring; impl Scoring { fn version(&self) -> u32 { 5 } fn stale(&self) -> u64 { 0 } }
570    /// let scoring = Arc::new(Scoring);
571    /// let probe_state = Arc::clone(&scoring);
572    /// let builder = victauri_plugin::VictauriBuilder::new().probe("scoring", move || {
573    ///     json!({ "pipeline_version": probe_state.version(), "stale_items": probe_state.stale() })
574    /// });
575    /// # let _ = builder;
576    /// ```
577    #[must_use]
578    pub fn probe<F>(mut self, name: impl Into<String>, probe: F) -> Self
579    where
580        F: Fn() -> serde_json::Value + Send + Sync + 'static,
581    {
582        self.probes.push((name.into(), std::sync::Arc::new(probe)));
583        self
584    }
585
586    /// Register a callback invoked once the MCP server is listening.
587    /// The callback receives the port number.
588    #[must_use]
589    pub fn on_ready(mut self, f: impl FnOnce(u16) + Send + 'static) -> Self {
590        self.on_ready = Some(Box::new(f));
591        self
592    }
593
594    fn resolve_port(&self) -> u16 {
595        self.port
596            .or_else(|| std::env::var("VICTAURI_PORT").ok()?.parse().ok())
597            .unwrap_or(DEFAULT_PORT)
598    }
599
600    fn resolve_auth_token(&self) -> Option<String> {
601        if self.auth_explicitly_disabled {
602            return None;
603        }
604        if let Some(ref token) = self.auth_token {
605            return Some(token.clone());
606        }
607        if let Ok(token) = std::env::var("VICTAURI_AUTH_TOKEN") {
608            return Some(token);
609        }
610        Some(auth::generate_token())
611    }
612
613    fn resolve_eval_timeout(&self) -> std::time::Duration {
614        std::env::var("VICTAURI_EVAL_TIMEOUT")
615            .ok()
616            .and_then(|s| s.parse::<u64>().ok())
617            .map_or(self.eval_timeout, std::time::Duration::from_secs)
618    }
619
620    fn build_privacy_config(&self) -> privacy::PrivacyConfig {
621        let profile = self
622            .privacy_profile
623            .unwrap_or(privacy::PrivacyProfile::FullControl);
624
625        let redaction_enabled = self.redaction_enabled
626            || self.strict_privacy
627            || matches!(
628                profile,
629                privacy::PrivacyProfile::Observe | privacy::PrivacyProfile::Test
630            );
631
632        privacy::PrivacyConfig {
633            profile,
634            command_allowlist: self
635                .command_allowlist
636                .as_ref()
637                .map(|v| v.iter().cloned().collect::<HashSet<String>>()),
638            command_blocklist: self.command_blocklist.iter().cloned().collect(),
639            disabled_tools: self.disabled_tools.iter().cloned().collect(),
640            storage_key_blocklist: self.storage_key_blocklist.iter().cloned().collect(),
641            redactor: redaction::Redactor::new(&self.redaction_patterns),
642            redaction_enabled,
643        }
644    }
645
646    fn validate(&self) -> Result<(), BuilderError> {
647        let port = self.resolve_port();
648        if port == 0 {
649            return Err(BuilderError::InvalidPort {
650                port,
651                reason: "port 0 is reserved".to_string(),
652            });
653        }
654
655        if self.event_capacity == 0 || self.event_capacity > MAX_EVENT_CAPACITY {
656            return Err(BuilderError::InvalidEventCapacity {
657                capacity: self.event_capacity,
658                reason: format!("must be between 1 and {MAX_EVENT_CAPACITY}"),
659            });
660        }
661
662        if self.recorder_capacity == 0 || self.recorder_capacity > MAX_RECORDER_CAPACITY {
663            return Err(BuilderError::InvalidRecorderCapacity {
664                capacity: self.recorder_capacity,
665                reason: format!("must be between 1 and {MAX_RECORDER_CAPACITY}"),
666            });
667        }
668
669        let timeout = self.resolve_eval_timeout();
670        if timeout.as_secs() == 0 || timeout.as_secs() > MAX_EVAL_TIMEOUT_SECS {
671            return Err(BuilderError::InvalidEvalTimeout {
672                timeout_secs: timeout.as_secs(),
673                reason: format!("must be between 1 and {MAX_EVAL_TIMEOUT_SECS} seconds"),
674            });
675        }
676
677        Ok(())
678    }
679
680    /// Consume the builder and produce a Tauri plugin.
681    ///
682    /// In release builds this always succeeds. In debug builds the builder configuration is
683    /// validated first.
684    ///
685    /// # Errors
686    ///
687    /// Returns [`BuilderError`] if the port, event capacity, recorder capacity, or eval
688    /// timeout are outside their valid ranges (debug builds only).
689    pub fn build<R: Runtime>(self) -> Result<TauriPlugin<R>, BuilderError> {
690        #[cfg(not(debug_assertions))]
691        {
692            Ok(Builder::new("victauri").build())
693        }
694
695        #[cfg(debug_assertions)]
696        {
697            // Hard kill-switch: lets a team force the no-op plugin even in a debug
698            // build (e.g. a release profile compiled with `debug-assertions = true`,
699            // or a shared environment where the introspection server must never bind).
700            if env_truthy("VICTAURI_DISABLE") {
701                tracing::info!("Victauri disabled via VICTAURI_DISABLE — returning no-op plugin");
702                return Ok(Builder::new("victauri").build());
703            }
704
705            self.validate()?;
706
707            let port = self.resolve_port();
708            let event_capacity = self.event_capacity;
709            let recorder_capacity = self.recorder_capacity;
710            let eval_timeout = self.resolve_eval_timeout();
711            let auth_token = self.resolve_auth_token();
712            let privacy_config = self.build_privacy_config();
713            let allow_file_navigation = self.allow_file_navigation;
714            let db_search_paths = self.db_search_paths;
715            let on_ready = self.on_ready;
716            let commands = self.commands;
717            let listen_events = self.listen_events;
718            let probes = self.probes;
719            let js_init = js_bridge::init_script(&self.bridge_capacities);
720
721            Ok(Builder::new("victauri")
722                .setup(move |app, _api| {
723                    let startup_timeline = introspection::StartupTimeline::new();
724                    let event_log = EventLog::new(event_capacity);
725                    startup_timeline.mark("event_log_created");
726                    let registry = CommandRegistry::new();
727                    startup_timeline.mark("registry_created");
728                    let (shutdown_tx, shutdown_rx) = watch::channel(false);
729
730                    let state = Arc::new(VictauriState {
731                        event_log,
732                        registry,
733                        port: AtomicU16::new(port),
734                        pending_evals: Arc::new(Mutex::new(HashMap::new())),
735                        recorder: EventRecorder::new(recorder_capacity),
736                        privacy: privacy_config,
737                        eval_timeout,
738                        shutdown_tx,
739                        started_at: std::time::Instant::now(),
740                        tool_invocations: AtomicU64::new(0),
741                        allow_file_navigation,
742                        command_timings: introspection::CommandTimings::new(),
743                        fault_registry: introspection::FaultRegistry::new(),
744                        contract_store: introspection::ContractStore::new(),
745                        startup_timeline,
746                        event_bus: introspection::EventBusMonitor::default(),
747                        task_tracker: introspection::TaskTracker::new(),
748                        bridge_ready: AtomicBool::new(false),
749                        bridge_notify: tokio::sync::Notify::new(),
750                        screencast: Arc::new(screencast::Screencast::default()),
751                        db_search_paths,
752                        probes: introspection::AppStateProbes::default(),
753                    });
754                    state.startup_timeline.mark("state_created");
755
756                    for (name, probe) in probes {
757                        state.probes.register(name, probe);
758                    }
759
760                    app.manage(state.clone());
761
762                    for cmd in commands {
763                        state.registry.register(cmd);
764                    }
765                    state.startup_timeline.mark("commands_registered");
766
767                    // Register listeners for custom event names specified via builder.
768                    for event_name in &listen_events {
769                        let bus = state.event_bus.clone();
770                        let name = event_name.clone();
771                        app.listen_any(event_name.clone(), move |event| {
772                            let payload =
773                                serde_json::from_str::<serde_json::Value>(event.payload())
774                                    .map_or_else(
775                                        |_| event.payload().to_string(),
776                                        |v| v.to_string(),
777                                    );
778                            bus.push(introspection::CapturedTauriEvent {
779                                name: name.clone(),
780                                payload,
781                                timestamp: chrono::Utc::now()
782                                    .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
783                            });
784                        });
785                    }
786                    state
787                        .startup_timeline
788                        .mark("event_bus_listeners_registered");
789
790                    if let Some(ref token) = auth_token {
791                        let prefix_len = token.len().min(8);
792                        let suffix_start = token.len().saturating_sub(4);
793                        tracing::info!(
794                            "Victauri MCP server auth enabled — token: {}…{}",
795                            &token[..prefix_len],
796                            &token[suffix_start..]
797                        );
798                    } else {
799                        tracing::warn!(
800                            "Victauri MCP server running WITHOUT auth — any localhost process can \
801                             access all tools. Use VictauriBuilder::auth_enabled() or set \
802                             VICTAURI_AUTH_TOKEN for shared/CI environments."
803                        );
804                    }
805
806                    state.startup_timeline.mark("server_spawning");
807                    let app_handle = app.clone();
808                    let ready_state = state.clone();
809                    let server_finished = state.task_tracker.track("mcp_server");
810                    tauri::async_runtime::spawn(async move {
811                        match mcp::start_server_with_options(
812                            app_handle,
813                            state,
814                            port,
815                            auth_token,
816                            shutdown_rx,
817                        )
818                        .await
819                        {
820                            Ok(()) => {
821                                tracing::info!("Victauri MCP server stopped");
822                            }
823                            Err(e) => {
824                                tracing::error!("Victauri MCP server failed: {e}");
825                            }
826                        }
827                        server_finished.store(true, std::sync::atomic::Ordering::Relaxed);
828                    });
829
830                    if let Some(cb) = on_ready {
831                        let ready_finished = ready_state.task_tracker.track("on_ready_probe");
832                        tauri::async_runtime::spawn(async move {
833                            for _ in 0..50 {
834                                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
835                                let actual_port =
836                                    ready_state.port.load(std::sync::atomic::Ordering::Relaxed);
837                                if tokio::net::TcpStream::connect(format!(
838                                    "127.0.0.1:{actual_port}"
839                                ))
840                                .await
841                                .is_ok()
842                                {
843                                    cb(actual_port);
844                                    ready_finished
845                                        .store(true, std::sync::atomic::Ordering::Relaxed);
846                                    return;
847                                }
848                            }
849                            let actual_port =
850                                ready_state.port.load(std::sync::atomic::Ordering::Relaxed);
851                            tracing::warn!(
852                                "Victauri on_ready: server did not become ready within 5s"
853                            );
854                            cb(actual_port);
855                            ready_finished.store(true, std::sync::atomic::Ordering::Relaxed);
856                        });
857                    }
858
859                    emit_security_banner(port);
860                    Ok(())
861                })
862                .on_event(|app, event| {
863                    let Some(state) = app.try_state::<Arc<VictauriState>>() else {
864                        return;
865                    };
866                    match event {
867                        RunEvent::Exit => {
868                            let _ = state.shutdown_tx.send(true);
869                            tracing::info!("Victauri shutdown signal sent");
870                        }
871                        RunEvent::ExitRequested { .. } => {
872                            state.event_bus.push(introspection::CapturedTauriEvent {
873                                name: "tauri://exit-requested".to_string(),
874                                payload: String::new(),
875                                timestamp: chrono::Utc::now()
876                                    .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
877                            });
878                        }
879                        RunEvent::WindowEvent {
880                            label,
881                            event: win_event,
882                            ..
883                        } => {
884                            let (name, payload) = format_window_event(label, win_event);
885                            state.event_bus.push(introspection::CapturedTauriEvent {
886                                name,
887                                payload,
888                                timestamp: chrono::Utc::now()
889                                    .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
890                            });
891                        }
892                        _ => {}
893                    }
894                })
895                .js_init_script(js_init)
896                .invoke_handler(tauri::generate_handler![
897                    tools::victauri_eval_js,
898                    tools::victauri_eval_callback,
899                    tools::victauri_get_window_state,
900                    tools::victauri_list_windows,
901                    tools::victauri_get_ipc_log,
902                    tools::victauri_get_registry,
903                    tools::victauri_get_memory_stats,
904                    tools::victauri_dom_snapshot,
905                    tools::victauri_verify_state,
906                    tools::victauri_detect_ghost_commands,
907                    tools::victauri_check_ipc_integrity,
908                ])
909                .build())
910        }
911    }
912}
913
914#[cfg(debug_assertions)]
915fn format_window_event(label: &str, event: &tauri::WindowEvent) -> (String, String) {
916    match event {
917        tauri::WindowEvent::Resized(size) => (
918            format!("window:{label}:resized"),
919            serde_json::json!({"width": size.width, "height": size.height}).to_string(),
920        ),
921        tauri::WindowEvent::Moved(pos) => (
922            format!("window:{label}:moved"),
923            serde_json::json!({"x": pos.x, "y": pos.y}).to_string(),
924        ),
925        tauri::WindowEvent::CloseRequested { .. } => {
926            (format!("window:{label}:close-requested"), String::new())
927        }
928        tauri::WindowEvent::Destroyed => (format!("window:{label}:destroyed"), String::new()),
929        tauri::WindowEvent::Focused(focused) => (
930            format!("window:{label}:focused"),
931            serde_json::json!({"focused": focused}).to_string(),
932        ),
933        tauri::WindowEvent::ScaleFactorChanged { scale_factor, .. } => (
934            format!("window:{label}:scale-factor-changed"),
935            serde_json::json!({"scale_factor": scale_factor}).to_string(),
936        ),
937        tauri::WindowEvent::ThemeChanged(theme) => (
938            format!("window:{label}:theme-changed"),
939            serde_json::json!({"theme": format!("{theme:?}")}).to_string(),
940        ),
941        tauri::WindowEvent::DragDrop(drag_event) => (
942            format!("window:{label}:drag-drop"),
943            format!("{drag_event:?}"),
944        ),
945        _ => (format!("window:{label}:other"), format!("{event:?}")),
946    }
947}
948
949/// Returns `true` if the named environment variable is set to a truthy value
950/// (`1`, `true`, `yes`, or `on`, case-insensitive, surrounding whitespace ignored).
951/// Unset, empty, or any other value is `false`.
952#[cfg(debug_assertions)]
953fn env_truthy(name: &str) -> bool {
954    std::env::var(name).is_ok_and(|v| {
955        matches!(
956            v.trim().to_ascii_lowercase().as_str(),
957            "1" | "true" | "yes" | "on"
958        )
959    })
960}
961
962/// Emits an unmissable startup banner whenever the introspection server activates.
963///
964/// Victauri is a debug-only tool that exposes JS eval, IPC, the filesystem, and
965/// `SQLite` over localhost. Logging this loudly on every start guarantees it can
966/// never run *silently* — most importantly catching the dangerous case of a
967/// release build accidentally compiled with `debug-assertions = true`, where the
968/// `#[cfg(debug_assertions)]` gate would otherwise let the full server bind with
969/// no visible signal.
970#[cfg(debug_assertions)]
971fn emit_security_banner(port: u16) {
972    tracing::warn!(
973        "┌─ VICTAURI INTROSPECTION SERVER ACTIVE ─────────────────────────────\n\
974         │ Listening on http://127.0.0.1:{port} — exposes JS eval, IPC, the\n\
975         │ filesystem, and SQLite to local clients. DEBUG-ONLY developer tool;\n\
976         │ it must never reach end users.\n\
977         │ Seeing this in a shipped/release build means your release profile has\n\
978         │ `debug-assertions = true`. Turn that off, or hard-disable Victauri\n\
979         │ with the VICTAURI_DISABLE=1 environment variable.\n\
980         └────────────────────────────────────────────────────────────────────"
981    );
982}
983
984/// Initialize the Victauri plugin with default settings (port 7373 or `VICTAURI_PORT` env var).
985///
986/// In debug builds: starts the embedded MCP server with **authentication enabled**
987/// (auto-generated token written to `<temp>/victauri/<pid>/token`), injects the JS
988/// bridge, and registers all Tauri command handlers.
989///
990/// In release builds: returns a no-op plugin. The MCP server, JS bridge, and
991/// all introspection tools are completely stripped — zero overhead, zero attack surface.
992///
993/// Setting the `VICTAURI_DISABLE=1` environment variable forces the no-op plugin
994/// even in a debug build — a hard kill-switch for shared environments or a release
995/// profile compiled with `debug-assertions = true`. When the server does activate
996/// it always logs a prominent startup banner, so it can never run silently.
997///
998/// For custom configuration, use `VictauriBuilder::new().port(8080).build()`.
999///
1000/// # Panics
1001///
1002/// Panics if the default builder configuration is invalid (this is a bug).
1003#[must_use]
1004pub fn init<R: Runtime>() -> TauriPlugin<R> {
1005    VictauriBuilder::new()
1006        .build()
1007        .expect("default Victauri configuration is always valid")
1008}
1009
1010/// Initialize the Victauri plugin with auto-discovery of all `#[inspectable]` commands.
1011///
1012/// Equivalent to `VictauriBuilder::new().auto_discover().build()` — all commands
1013/// marked with `#[inspectable]` are registered automatically without manual listing.
1014///
1015/// # Panics
1016///
1017/// Panics if the default builder configuration is invalid (this is a bug).
1018#[must_use]
1019pub fn init_auto_discover<R: Runtime>() -> TauriPlugin<R> {
1020    VictauriBuilder::new()
1021        .auto_discover()
1022        .build()
1023        .expect("default Victauri configuration is always valid")
1024}
1025
1026#[cfg(test)]
1027mod tests {
1028    use super::*;
1029
1030    #[test]
1031    fn builder_default_values() {
1032        let builder = VictauriBuilder::new();
1033        assert_eq!(builder.event_capacity, DEFAULT_EVENT_CAPACITY);
1034        assert_eq!(builder.recorder_capacity, DEFAULT_RECORDER_CAPACITY);
1035        assert!(builder.auth_token.is_none());
1036        assert!(!builder.auth_explicitly_enabled);
1037        assert!(!builder.auth_explicitly_disabled);
1038        let resolved = builder.resolve_auth_token();
1039        assert!(
1040            resolved.is_some(),
1041            "auth should be enabled by default (auto-generated token)"
1042        );
1043        assert_eq!(
1044            resolved.unwrap().len(),
1045            36,
1046            "auto-generated token should be UUID v4"
1047        );
1048        assert!(builder.disabled_tools.is_empty());
1049        assert!(builder.command_allowlist.is_none());
1050        assert!(builder.command_blocklist.is_empty());
1051        assert!(!builder.redaction_enabled);
1052        assert!(!builder.strict_privacy);
1053    }
1054
1055    #[test]
1056    fn builder_port_override() {
1057        let builder = VictauriBuilder::new().port(9090);
1058        assert_eq!(builder.resolve_port(), 9090);
1059    }
1060
1061    #[test]
1062    #[allow(unsafe_code)]
1063    fn builder_default_port() {
1064        let builder = VictauriBuilder::new();
1065        // SAFETY: test-only — no concurrent env reads in this test binary.
1066        unsafe { std::env::remove_var("VICTAURI_PORT") };
1067        assert_eq!(builder.resolve_port(), DEFAULT_PORT);
1068    }
1069
1070    #[test]
1071    fn builder_auth_token_explicit() {
1072        let builder = VictauriBuilder::new().auth_token("my-secret");
1073        assert_eq!(builder.resolve_auth_token(), Some("my-secret".to_string()));
1074    }
1075
1076    // `env_truthy` is part of the debug-only kill-switch path, so this test can
1077    // only reference it in debug builds (otherwise `cargo test --release` fails to
1078    // compile — a config our CI doesn't normally run).
1079    #[cfg(debug_assertions)]
1080    #[test]
1081    #[allow(unsafe_code)]
1082    fn env_truthy_recognizes_kill_switch_values() {
1083        // Unique var name so this never races with other env-touching tests.
1084        let key = "VICTAURI_TEST_KILL_SWITCH";
1085        // SAFETY: test-only — this var is unique to this test, no concurrent readers.
1086        unsafe { std::env::remove_var(key) };
1087        assert!(!env_truthy(key), "unset should be false");
1088
1089        for v in ["1", "true", "TRUE", " yes ", "On"] {
1090            // SAFETY: test-only — unique var, no concurrent readers.
1091            unsafe { std::env::set_var(key, v) };
1092            assert!(env_truthy(key), "{v:?} should be truthy");
1093        }
1094        for v in ["0", "false", "", "nope"] {
1095            // SAFETY: test-only — unique var, no concurrent readers.
1096            unsafe { std::env::set_var(key, v) };
1097            assert!(!env_truthy(key), "{v:?} should be falsy");
1098        }
1099        // SAFETY: test-only — restore clean state.
1100        unsafe { std::env::remove_var(key) };
1101    }
1102
1103    #[test]
1104    fn builder_auth_enabled() {
1105        let builder = VictauriBuilder::new().auth_enabled();
1106        assert!(builder.auth_explicitly_enabled);
1107        let token = builder.resolve_auth_token().unwrap();
1108        assert_eq!(token.len(), 36, "auto-generated token should be a UUID");
1109    }
1110
1111    #[test]
1112    fn builder_auth_generate_token() {
1113        let builder = VictauriBuilder::new().generate_auth_token();
1114        let token = builder.resolve_auth_token().unwrap();
1115        assert_eq!(token.len(), 36);
1116    }
1117
1118    #[test]
1119    fn builder_auth_disabled_suppresses_default_token() {
1120        let builder = VictauriBuilder::new().auth_disabled();
1121        assert!(
1122            builder.resolve_auth_token().is_none(),
1123            "auth_disabled must suppress the default auto-generated token (auth is ON by default)"
1124        );
1125    }
1126
1127    #[test]
1128    fn builder_auth_disabled_returns_none() {
1129        let builder = VictauriBuilder::new().auth_disabled();
1130        assert!(
1131            builder.resolve_auth_token().is_none(),
1132            "auth_disabled should suppress auto-generated token"
1133        );
1134    }
1135
1136    #[test]
1137    fn builder_auth_disabled_overrides_explicit_token() {
1138        let builder = VictauriBuilder::new()
1139            .auth_token("my-secret")
1140            .auth_disabled();
1141        assert!(
1142            builder.resolve_auth_token().is_none(),
1143            "auth_disabled should override explicit token"
1144        );
1145    }
1146
1147    #[test]
1148    fn builder_capacities() {
1149        let builder = VictauriBuilder::new()
1150            .event_capacity(500)
1151            .recorder_capacity(2000);
1152        assert_eq!(builder.event_capacity, 500);
1153        assert_eq!(builder.recorder_capacity, 2000);
1154    }
1155
1156    #[test]
1157    fn builder_disable_tools() {
1158        let builder = VictauriBuilder::new().disable_tools(&["eval_js", "screenshot"]);
1159        assert_eq!(builder.disabled_tools.len(), 2);
1160        assert!(builder.disabled_tools.contains(&"eval_js".to_string()));
1161    }
1162
1163    #[test]
1164    fn builder_command_allowlist() {
1165        let builder = VictauriBuilder::new().command_allowlist(&["greet", "increment"]);
1166        assert!(builder.command_allowlist.is_some());
1167        assert_eq!(builder.command_allowlist.as_ref().unwrap().len(), 2);
1168    }
1169
1170    #[test]
1171    fn builder_command_blocklist() {
1172        let builder = VictauriBuilder::new().command_blocklist(&["dangerous_cmd"]);
1173        assert_eq!(builder.command_blocklist.len(), 1);
1174    }
1175
1176    #[test]
1177    fn builder_redaction() {
1178        let builder = VictauriBuilder::new()
1179            .add_redaction_pattern(r"SECRET_\w+")
1180            .enable_redaction();
1181        assert!(builder.redaction_enabled);
1182        assert_eq!(builder.redaction_patterns.len(), 1);
1183    }
1184
1185    #[test]
1186    fn builder_strict_privacy_config() {
1187        let builder = VictauriBuilder::new().strict_privacy_mode();
1188        let config = builder.build_privacy_config();
1189        assert!(config.redaction_enabled);
1190        assert_eq!(config.profile, crate::privacy::PrivacyProfile::Observe);
1191        assert!(!config.is_tool_enabled("eval_js"));
1192        assert!(!config.is_tool_enabled("screenshot"));
1193        assert!(!config.is_tool_enabled("interact"));
1194        assert!(config.is_tool_enabled("dom_snapshot"));
1195    }
1196
1197    #[test]
1198    fn builder_normal_privacy_config() {
1199        let builder = VictauriBuilder::new()
1200            .command_blocklist(&["secret_cmd"])
1201            .disable_tools(&["eval_js"]);
1202        let config = builder.build_privacy_config();
1203        assert!(config.command_blocklist.contains("secret_cmd"));
1204        assert!(!config.is_tool_enabled("eval_js"));
1205        assert!(!config.redaction_enabled);
1206    }
1207
1208    #[test]
1209    fn builder_strict_with_extra_blocklist() {
1210        let builder = VictauriBuilder::new()
1211            .strict_privacy_mode()
1212            .command_blocklist(&["extra_dangerous"]);
1213        let config = builder.build_privacy_config();
1214        assert!(config.command_blocklist.contains("extra_dangerous"));
1215        assert!(!config.is_tool_enabled("eval_js"));
1216    }
1217
1218    #[test]
1219    fn builder_test_profile() {
1220        let builder = VictauriBuilder::new().privacy_profile(crate::privacy::PrivacyProfile::Test);
1221        let config = builder.build_privacy_config();
1222        assert_eq!(config.profile, crate::privacy::PrivacyProfile::Test);
1223        assert!(config.redaction_enabled);
1224        assert!(config.is_tool_enabled("interact"));
1225        assert!(config.is_tool_enabled("fill"));
1226        assert!(config.is_tool_enabled("recording"));
1227        assert!(!config.is_tool_enabled("eval_js"));
1228        assert!(!config.is_tool_enabled("screenshot"));
1229        assert!(!config.is_tool_enabled("navigate"));
1230    }
1231
1232    #[test]
1233    fn builder_profile_with_extra_disables() {
1234        let builder = VictauriBuilder::new()
1235            .privacy_profile(crate::privacy::PrivacyProfile::Test)
1236            .disable_tools(&["interact"]);
1237        let config = builder.build_privacy_config();
1238        assert!(!config.is_tool_enabled("interact"));
1239        assert!(config.is_tool_enabled("fill"));
1240    }
1241
1242    #[test]
1243    fn builder_bridge_capacities() {
1244        let builder = VictauriBuilder::new()
1245            .console_log_capacity(5000)
1246            .network_log_capacity(2000)
1247            .navigation_log_capacity(500);
1248        assert_eq!(builder.bridge_capacities.console_logs, 5000);
1249        assert_eq!(builder.bridge_capacities.network_log, 2000);
1250        assert_eq!(builder.bridge_capacities.navigation_log, 500);
1251        assert_eq!(builder.bridge_capacities.mutation_log, 500);
1252        assert_eq!(builder.bridge_capacities.dialog_log, 100);
1253    }
1254
1255    #[test]
1256    fn builder_on_ready_sets_callback() {
1257        let builder = VictauriBuilder::new().on_ready(|_port| {});
1258        assert!(builder.on_ready.is_some());
1259    }
1260
1261    #[test]
1262    fn builder_file_navigation_disabled_by_default() {
1263        let builder = VictauriBuilder::new();
1264        assert!(
1265            !builder.allow_file_navigation,
1266            "file navigation should be disabled by default"
1267        );
1268    }
1269
1270    #[test]
1271    fn builder_allow_file_navigation() {
1272        let builder = VictauriBuilder::new().allow_file_navigation();
1273        assert!(builder.allow_file_navigation);
1274    }
1275
1276    #[test]
1277    fn builder_listen_events() {
1278        let builder =
1279            VictauriBuilder::new().listen_events(&["notification-added", "settings-changed"]);
1280        assert_eq!(builder.listen_events.len(), 2);
1281        assert!(
1282            builder
1283                .listen_events
1284                .contains(&"notification-added".to_string())
1285        );
1286        assert!(
1287            builder
1288                .listen_events
1289                .contains(&"settings-changed".to_string())
1290        );
1291    }
1292
1293    #[test]
1294    fn builder_listen_events_empty_by_default() {
1295        let builder = VictauriBuilder::new();
1296        assert!(builder.listen_events.is_empty());
1297    }
1298
1299    #[test]
1300    fn init_script_contains_custom_capacities() {
1301        let caps = js_bridge::BridgeCapacities {
1302            console_logs: 3000,
1303            mutation_log: 750,
1304            network_log: 5000,
1305            navigation_log: 400,
1306            dialog_log: 250,
1307            long_tasks: 200,
1308        };
1309        let script = js_bridge::init_script(&caps);
1310        assert!(script.contains("CAP_CONSOLE = 3000"));
1311        assert!(script.contains("CAP_MUTATION = 750"));
1312        assert!(script.contains("CAP_NETWORK = 5000"));
1313        assert!(script.contains("CAP_NAVIGATION = 400"));
1314        assert!(script.contains("CAP_DIALOG = 250"));
1315        assert!(script.contains("CAP_LONG_TASKS = 200"));
1316    }
1317
1318    #[test]
1319    fn init_script_default_contains_standard_capacities() {
1320        let caps = js_bridge::BridgeCapacities::default();
1321        let script = js_bridge::init_script(&caps);
1322        assert!(script.contains("CAP_CONSOLE = 1000"));
1323        assert!(script.contains("CAP_NETWORK = 1000"));
1324        assert!(script.contains("window.__VICTAURI__"));
1325    }
1326
1327    #[test]
1328    fn builder_validates_defaults() {
1329        let builder = VictauriBuilder::new();
1330        assert!(builder.validate().is_ok());
1331    }
1332
1333    #[test]
1334    fn builder_rejects_zero_port() {
1335        let builder = VictauriBuilder::new().port(0);
1336        let err = builder.validate().unwrap_err();
1337        assert!(matches!(err, BuilderError::InvalidPort { port: 0, .. }));
1338    }
1339
1340    #[test]
1341    fn builder_rejects_zero_event_capacity() {
1342        let builder = VictauriBuilder::new().event_capacity(0);
1343        let err = builder.validate().unwrap_err();
1344        assert!(matches!(
1345            err,
1346            BuilderError::InvalidEventCapacity { capacity: 0, .. }
1347        ));
1348    }
1349
1350    #[test]
1351    fn builder_rejects_excessive_event_capacity() {
1352        let builder = VictauriBuilder::new().event_capacity(2_000_000);
1353        assert!(builder.validate().is_err());
1354    }
1355
1356    #[test]
1357    fn builder_rejects_zero_recorder_capacity() {
1358        let builder = VictauriBuilder::new().recorder_capacity(0);
1359        assert!(builder.validate().is_err());
1360    }
1361
1362    #[test]
1363    fn builder_rejects_zero_eval_timeout() {
1364        let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(0));
1365        assert!(builder.validate().is_err());
1366    }
1367
1368    #[test]
1369    fn builder_rejects_excessive_eval_timeout() {
1370        let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(600));
1371        assert!(builder.validate().is_err());
1372    }
1373
1374    #[test]
1375    fn builder_accepts_edge_values() {
1376        let builder = VictauriBuilder::new()
1377            .port(1)
1378            .event_capacity(1)
1379            .recorder_capacity(1)
1380            .eval_timeout(std::time::Duration::from_secs(1));
1381        assert!(builder.validate().is_ok());
1382
1383        let builder = VictauriBuilder::new()
1384            .port(65535)
1385            .event_capacity(MAX_EVENT_CAPACITY)
1386            .recorder_capacity(MAX_RECORDER_CAPACITY)
1387            .eval_timeout(std::time::Duration::from_secs(MAX_EVAL_TIMEOUT_SECS));
1388        assert!(builder.validate().is_ok());
1389    }
1390}