Skip to main content

victauri_plugin/
lib.rs

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