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 token (printed to logs).
23//! Use `.auth_disabled()` to opt out, or `.auth_token("...")` to set a specific token.
24//!
25//! ```ignore
26//! tauri::Builder::default()
27//!     .plugin(
28//!         victauri_plugin::VictauriBuilder::new()
29//!             .port(8080)
30//!             .strict_privacy_mode()
31//!             .build(),
32//!     )
33//!     .run(tauri::generate_context!())
34//!     .unwrap();
35//! ```
36
37/// Runtime-erased webview bridge trait and its Tauri implementation.
38pub mod bridge;
39pub mod error;
40/// JS bridge script generation for webview injection.
41pub mod js_bridge;
42/// MCP server, tool handler, and parameter types.
43pub mod mcp;
44mod memory;
45/// Privacy controls: command allowlists, blocklists, and tool disabling.
46pub mod privacy;
47/// Output redaction for API keys, tokens, emails, and sensitive JSON keys.
48pub mod redaction;
49pub(crate) mod screenshot;
50mod tools;
51
52/// Bearer-token authentication, rate limiting, and security middlewares.
53pub mod auth;
54
55use std::collections::{HashMap, HashSet};
56use std::sync::Arc;
57use std::sync::atomic::{AtomicU16, AtomicU64};
58use tauri::plugin::{Builder, TauriPlugin};
59use tauri::{Manager, RunEvent, Runtime};
60use tokio::sync::{Mutex, oneshot, watch};
61use victauri_core::{CommandRegistry, EventLog, EventRecorder};
62
63pub use error::BuilderError;
64pub use privacy::PrivacyProfile;
65
66pub use victauri_core::CommandInfo;
67pub use victauri_macros::inspectable;
68
69/// Register command schemas with the Victauri plugin at app setup time.
70///
71/// Pass the `__schema()` function calls generated by `#[inspectable]`.
72/// This populates the `CommandRegistry` so that `get_registry`, `resolve_command`,
73/// and `detect_ghost_commands` return real results.
74///
75/// # Example
76///
77/// ```rust,ignore
78/// use victauri_plugin::register_commands;
79///
80/// .setup(|app| {
81///     register_commands!(app,
82///         greet__schema(),
83///         increment__schema(),
84///         add_todo__schema(),
85///     );
86///     Ok(())
87/// })
88/// ```
89#[macro_export]
90macro_rules! register_commands {
91    ($app:expr, $($schema_call:expr),+ $(,)?) => {{
92        let state = $app.state::<std::sync::Arc<$crate::VictauriState>>();
93        $(
94            state.registry.register($schema_call);
95        )+
96    }};
97}
98
99const DEFAULT_PORT: u16 = 7373;
100const DEFAULT_EVENT_CAPACITY: usize = 10_000;
101const DEFAULT_RECORDER_CAPACITY: usize = 50_000;
102const DEFAULT_EVAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
103const MAX_EVENT_CAPACITY: usize = 1_000_000;
104const MAX_RECORDER_CAPACITY: usize = 1_000_000;
105const MAX_EVAL_TIMEOUT_SECS: u64 = 300;
106
107/// Map of pending JavaScript eval callbacks, keyed by request ID.
108/// Each entry holds a oneshot sender that resolves when the webview returns a result.
109pub type PendingCallbacks = Arc<Mutex<HashMap<String, oneshot::Sender<String>>>>;
110
111/// Runtime state shared between the MCP server and all tool handlers.
112pub struct VictauriState {
113    /// Ring-buffer event log for IPC calls, state changes, and DOM mutations.
114    pub event_log: EventLog,
115    /// Registry of all discovered Tauri commands with metadata.
116    pub registry: CommandRegistry,
117    /// TCP port the MCP server listens on (may differ from configured port if fallback was used).
118    pub port: AtomicU16,
119    /// Pending JavaScript eval callbacks awaiting webview responses.
120    pub pending_evals: PendingCallbacks,
121    /// Session recorder for time-travel debugging.
122    pub recorder: EventRecorder,
123    /// Privacy configuration (tool disabling, command filtering, output redaction).
124    pub privacy: privacy::PrivacyConfig,
125    /// Timeout for JavaScript eval operations.
126    pub eval_timeout: std::time::Duration,
127    /// Sends `true` to signal graceful MCP server shutdown.
128    pub shutdown_tx: watch::Sender<bool>,
129    /// Instant the plugin was initialized, for uptime tracking.
130    pub started_at: std::time::Instant,
131    /// Total number of MCP tool invocations since startup.
132    pub tool_invocations: AtomicU64,
133    /// Whether `file:` URLs are allowed in the `navigate` tool's `go_to` action.
134    /// Defaults to `false` — only `http` and `https` are permitted unless opted in
135    /// via [`VictauriBuilder::allow_file_navigation`].
136    pub allow_file_navigation: bool,
137}
138
139/// Builder for configuring the Victauri plugin before adding it to a Tauri app.
140///
141/// Supports port selection, authentication, privacy controls, output redaction,
142/// and capacity tuning. All settings have sensible defaults and can be overridden
143/// via environment variables.
144///
145/// **Authentication is enabled by default.** If no explicit token is set and no
146/// `VICTAURI_AUTH_TOKEN` env var exists, a random `UUID` token is auto-generated
147/// and printed to the log. Call [`auth_disabled()`](VictauriBuilder::auth_disabled)
148/// to explicitly opt out of authentication.
149pub struct VictauriBuilder {
150    port: Option<u16>,
151    event_capacity: usize,
152    recorder_capacity: usize,
153    eval_timeout: std::time::Duration,
154    auth_token: Option<String>,
155    auth_explicitly_disabled: bool,
156    disabled_tools: Vec<String>,
157    command_allowlist: Option<Vec<String>>,
158    command_blocklist: Vec<String>,
159    redaction_patterns: Vec<String>,
160    redaction_enabled: bool,
161    strict_privacy: bool,
162    privacy_profile: Option<privacy::PrivacyProfile>,
163    bridge_capacities: js_bridge::BridgeCapacities,
164    on_ready: Option<Box<dyn FnOnce(u16) + Send + 'static>>,
165    commands: Vec<victauri_core::CommandInfo>,
166    allow_file_navigation: bool,
167}
168
169impl Default for VictauriBuilder {
170    fn default() -> Self {
171        Self {
172            port: None,
173            event_capacity: DEFAULT_EVENT_CAPACITY,
174            recorder_capacity: DEFAULT_RECORDER_CAPACITY,
175            eval_timeout: DEFAULT_EVAL_TIMEOUT,
176            auth_token: None,
177            auth_explicitly_disabled: false,
178            disabled_tools: Vec::new(),
179            command_allowlist: None,
180            command_blocklist: Vec::new(),
181            redaction_patterns: Vec::new(),
182            redaction_enabled: false,
183            strict_privacy: false,
184            privacy_profile: None,
185            bridge_capacities: js_bridge::BridgeCapacities::default(),
186            on_ready: None,
187            commands: Vec::new(),
188            allow_file_navigation: false,
189        }
190    }
191}
192
193impl VictauriBuilder {
194    /// Create a new builder with default settings.
195    #[must_use]
196    pub fn new() -> Self {
197        Self::default()
198    }
199
200    /// Set the TCP port for the MCP server (default: 7373, env: `VICTAURI_PORT`).
201    #[must_use]
202    pub fn port(mut self, port: u16) -> Self {
203        self.port = Some(port);
204        self
205    }
206
207    /// Set the maximum number of events in the ring-buffer log (default: 10,000).
208    #[must_use]
209    pub fn event_capacity(mut self, capacity: usize) -> Self {
210        self.event_capacity = capacity;
211        self
212    }
213
214    /// Set the maximum events kept during session recording (default: 50,000).
215    #[must_use]
216    pub fn recorder_capacity(mut self, capacity: usize) -> Self {
217        self.recorder_capacity = capacity;
218        self
219    }
220
221    /// Set the timeout for JavaScript eval operations (default: 30s, env: `VICTAURI_EVAL_TIMEOUT`).
222    #[must_use]
223    pub fn eval_timeout(mut self, timeout: std::time::Duration) -> Self {
224        self.eval_timeout = timeout;
225        self
226    }
227
228    /// Set an explicit auth token for the MCP server (env: `VICTAURI_AUTH_TOKEN`).
229    #[must_use]
230    pub fn auth_token(mut self, token: impl Into<String>) -> Self {
231        self.auth_token = Some(token.into());
232        self
233    }
234
235    /// Generate a random `UUID` v4 auth token.
236    #[must_use]
237    pub fn generate_auth_token(mut self) -> Self {
238        self.auth_token = Some(auth::generate_token());
239        self
240    }
241
242    /// Explicitly disable authentication. By default, Victauri auto-generates a
243    /// token if none is provided. Call this method to opt out of auth entirely.
244    ///
245    /// **Warning:** Without authentication, any process on localhost can access
246    /// the MCP server. Only use this in trusted environments.
247    #[must_use]
248    pub fn auth_disabled(mut self) -> Self {
249        self.auth_explicitly_disabled = true;
250        self.auth_token = None;
251        self
252    }
253
254    /// Disable specific MCP tools by name (e.g., `["eval_js", "screenshot"]`).
255    #[must_use]
256    pub fn disable_tools(mut self, tools: &[&str]) -> Self {
257        self.disabled_tools = tools.iter().map(std::string::ToString::to_string).collect();
258        self
259    }
260
261    /// Only allow these Tauri commands to be invoked via MCP (positive allowlist).
262    #[must_use]
263    pub fn command_allowlist(mut self, commands: &[&str]) -> Self {
264        self.command_allowlist = Some(
265            commands
266                .iter()
267                .map(std::string::ToString::to_string)
268                .collect(),
269        );
270        self
271    }
272
273    /// Block specific Tauri commands from being invoked via MCP.
274    #[must_use]
275    pub fn command_blocklist(mut self, commands: &[&str]) -> Self {
276        self.command_blocklist = commands
277            .iter()
278            .map(std::string::ToString::to_string)
279            .collect();
280        self
281    }
282
283    /// Add a regex pattern for output redaction (e.g., `r"SECRET_\w+"`).
284    #[must_use]
285    pub fn add_redaction_pattern(mut self, pattern: impl Into<String>) -> Self {
286        self.redaction_patterns.push(pattern.into());
287        self
288    }
289
290    /// Enable output redaction with built-in patterns (API keys, emails, tokens).
291    #[must_use]
292    pub fn enable_redaction(mut self) -> Self {
293        self.redaction_enabled = true;
294        self
295    }
296
297    /// Enable strict privacy mode (equivalent to [`PrivacyProfile::Observe`]).
298    ///
299    /// Disables all mutation tools (`eval_js`, screenshot, interactions, input,
300    /// storage writes, navigation, CSS injection) and enables output redaction.
301    ///
302    /// Prefer [`privacy_profile()`](Self::privacy_profile) for finer control.
303    #[must_use]
304    pub fn strict_privacy_mode(mut self) -> Self {
305        self.strict_privacy = true;
306        self.privacy_profile = Some(privacy::PrivacyProfile::Observe);
307        self
308    }
309
310    /// Set the privacy profile tier.
311    ///
312    /// - [`Observe`](privacy::PrivacyProfile::Observe) — read-only (snapshots, logs, registry)
313    /// - [`Test`](privacy::PrivacyProfile::Test) — observe + interactions + input + storage writes + recording
314    /// - [`FullControl`](privacy::PrivacyProfile::FullControl) — everything (default)
315    ///
316    /// Profiles automatically enable redaction for `Observe` and `Test`.
317    /// Use [`disable_tools()`](Self::disable_tools) to apply overrides on top of a profile.
318    #[must_use]
319    pub fn privacy_profile(mut self, profile: privacy::PrivacyProfile) -> Self {
320        self.privacy_profile = Some(profile);
321        if matches!(
322            profile,
323            privacy::PrivacyProfile::Observe | privacy::PrivacyProfile::Test
324        ) {
325            self.redaction_enabled = true;
326        }
327        self
328    }
329
330    /// Set the maximum console log entries kept in the JS bridge (default: 1000).
331    #[must_use]
332    pub fn console_log_capacity(mut self, capacity: usize) -> Self {
333        self.bridge_capacities.console_logs = capacity;
334        self
335    }
336
337    /// Set the maximum network log entries kept in the JS bridge (default: 1000).
338    #[must_use]
339    pub fn network_log_capacity(mut self, capacity: usize) -> Self {
340        self.bridge_capacities.network_log = capacity;
341        self
342    }
343
344    /// Set the maximum navigation log entries kept in the JS bridge (default: 200).
345    #[must_use]
346    pub fn navigation_log_capacity(mut self, capacity: usize) -> Self {
347        self.bridge_capacities.navigation_log = capacity;
348        self
349    }
350
351    /// Pre-register command schemas so `get_registry`, `resolve_command`, and
352    /// `detect_ghost_commands` return real results from the moment the server starts.
353    ///
354    /// Pass the `__schema()` functions generated by `#[inspectable]`.
355    ///
356    /// ```rust,ignore
357    /// VictauriBuilder::new()
358    ///     .commands(&[
359    ///         greet__schema(),
360    ///         increment__schema(),
361    ///         add_todo__schema(),
362    ///     ])
363    ///     .build()
364    /// ```
365    #[must_use]
366    pub fn commands(mut self, schemas: &[victauri_core::CommandInfo]) -> Self {
367        self.commands = schemas.to_vec();
368        self
369    }
370
371    /// Auto-discover all `#[inspectable]` commands in the binary.
372    ///
373    /// Uses `inventory` to collect every command marked with `#[inspectable]`
374    /// at link time — no manual listing required. This replaces both
375    /// `.commands(&[...])` and `register_commands!()`.
376    ///
377    /// ```rust,ignore
378    /// tauri::Builder::default()
379    ///     .plugin(
380    ///         VictauriBuilder::new()
381    ///             .auto_discover()
382    ///             .build()
383    ///             .unwrap(),
384    ///     )
385    /// ```
386    #[must_use]
387    pub fn auto_discover(mut self) -> Self {
388        self.commands
389            .extend(victauri_core::auto_discovered_commands());
390        self
391    }
392
393    /// Allow `file:` URLs in the `navigate` tool's `go_to` action.
394    ///
395    /// By default, only `http` and `https` schemes are permitted. Calling this
396    /// method opts in to `file:` navigation, which grants the MCP client access
397    /// to local filesystem paths via the webview.
398    ///
399    /// **Warning:** Enabling this in untrusted environments exposes local files
400    /// to any process that can reach the MCP server.
401    #[must_use]
402    pub fn allow_file_navigation(mut self) -> Self {
403        self.allow_file_navigation = true;
404        self
405    }
406
407    /// Register a callback invoked once the MCP server is listening.
408    /// The callback receives the port number.
409    #[must_use]
410    pub fn on_ready(mut self, f: impl FnOnce(u16) + Send + 'static) -> Self {
411        self.on_ready = Some(Box::new(f));
412        self
413    }
414
415    fn resolve_port(&self) -> u16 {
416        self.port
417            .or_else(|| std::env::var("VICTAURI_PORT").ok()?.parse().ok())
418            .unwrap_or(DEFAULT_PORT)
419    }
420
421    fn resolve_auth_token(&self) -> Option<String> {
422        if self.auth_explicitly_disabled {
423            return None;
424        }
425        self.auth_token
426            .clone()
427            .or_else(|| std::env::var("VICTAURI_AUTH_TOKEN").ok())
428            .or_else(|| Some(auth::generate_token()))
429    }
430
431    fn resolve_eval_timeout(&self) -> std::time::Duration {
432        std::env::var("VICTAURI_EVAL_TIMEOUT")
433            .ok()
434            .and_then(|s| s.parse::<u64>().ok())
435            .map_or(self.eval_timeout, std::time::Duration::from_secs)
436    }
437
438    fn build_privacy_config(&self) -> privacy::PrivacyConfig {
439        let profile = self
440            .privacy_profile
441            .unwrap_or(privacy::PrivacyProfile::FullControl);
442
443        let redaction_enabled = self.redaction_enabled
444            || self.strict_privacy
445            || matches!(
446                profile,
447                privacy::PrivacyProfile::Observe | privacy::PrivacyProfile::Test
448            );
449
450        privacy::PrivacyConfig {
451            profile,
452            command_allowlist: self
453                .command_allowlist
454                .as_ref()
455                .map(|v| v.iter().cloned().collect::<HashSet<String>>()),
456            command_blocklist: self.command_blocklist.iter().cloned().collect(),
457            disabled_tools: self.disabled_tools.iter().cloned().collect(),
458            redactor: redaction::Redactor::new(&self.redaction_patterns),
459            redaction_enabled,
460        }
461    }
462
463    fn validate(&self) -> Result<(), BuilderError> {
464        let port = self.resolve_port();
465        if port == 0 {
466            return Err(BuilderError::InvalidPort {
467                port,
468                reason: "port 0 is reserved".to_string(),
469            });
470        }
471
472        if self.event_capacity == 0 || self.event_capacity > MAX_EVENT_CAPACITY {
473            return Err(BuilderError::InvalidEventCapacity {
474                capacity: self.event_capacity,
475                reason: format!("must be between 1 and {MAX_EVENT_CAPACITY}"),
476            });
477        }
478
479        if self.recorder_capacity == 0 || self.recorder_capacity > MAX_RECORDER_CAPACITY {
480            return Err(BuilderError::InvalidRecorderCapacity {
481                capacity: self.recorder_capacity,
482                reason: format!("must be between 1 and {MAX_RECORDER_CAPACITY}"),
483            });
484        }
485
486        let timeout = self.resolve_eval_timeout();
487        if timeout.as_secs() == 0 || timeout.as_secs() > MAX_EVAL_TIMEOUT_SECS {
488            return Err(BuilderError::InvalidEvalTimeout {
489                timeout_secs: timeout.as_secs(),
490                reason: format!("must be between 1 and {MAX_EVAL_TIMEOUT_SECS} seconds"),
491            });
492        }
493
494        Ok(())
495    }
496
497    /// Consume the builder and produce a Tauri plugin.
498    ///
499    /// In release builds this always succeeds. In debug builds the builder configuration is
500    /// validated first.
501    ///
502    /// # Errors
503    ///
504    /// Returns [`BuilderError`] if the port, event capacity, recorder capacity, or eval
505    /// timeout are outside their valid ranges (debug builds only).
506    pub fn build<R: Runtime>(self) -> Result<TauriPlugin<R>, BuilderError> {
507        #[cfg(not(debug_assertions))]
508        {
509            Ok(Builder::new("victauri").build())
510        }
511
512        #[cfg(debug_assertions)]
513        {
514            self.validate()?;
515
516            let port = self.resolve_port();
517            let event_capacity = self.event_capacity;
518            let recorder_capacity = self.recorder_capacity;
519            let eval_timeout = self.resolve_eval_timeout();
520            let auth_token = self.resolve_auth_token();
521            let privacy_config = self.build_privacy_config();
522            let allow_file_navigation = self.allow_file_navigation;
523            let on_ready = self.on_ready;
524            let commands = self.commands;
525            let js_init = js_bridge::init_script(&self.bridge_capacities);
526
527            Ok(Builder::new("victauri")
528                .setup(move |app, _api| {
529                    let event_log = EventLog::new(event_capacity);
530                    let registry = CommandRegistry::new();
531                    let (shutdown_tx, shutdown_rx) = watch::channel(false);
532
533                    let state = Arc::new(VictauriState {
534                        event_log,
535                        registry,
536                        port: AtomicU16::new(port),
537                        pending_evals: Arc::new(Mutex::new(HashMap::new())),
538                        recorder: EventRecorder::new(recorder_capacity),
539                        privacy: privacy_config,
540                        eval_timeout,
541                        shutdown_tx,
542                        started_at: std::time::Instant::now(),
543                        tool_invocations: AtomicU64::new(0),
544                        allow_file_navigation,
545                    });
546
547                    app.manage(state.clone());
548
549                    for cmd in commands {
550                        state.registry.register(cmd);
551                    }
552
553                    if let Some(ref token) = auth_token {
554                        tracing::info!(
555                            "Victauri MCP server auth enabled — token: {}…{}",
556                            &token[..8],
557                            &token[token.len().saturating_sub(4)..]
558                        );
559                    } else {
560                        tracing::warn!(
561                            "Victauri MCP server auth DISABLED — any localhost process can access the MCP server"
562                        );
563                    }
564
565                    let app_handle = app.clone();
566                    let ready_state = state.clone();
567                    tauri::async_runtime::spawn(async move {
568                        match mcp::start_server_with_options(
569                            app_handle, state, port, auth_token, shutdown_rx,
570                        )
571                        .await
572                        {
573                            Ok(()) => {
574                                tracing::info!("Victauri MCP server stopped");
575                            }
576                            Err(e) => {
577                                tracing::error!("Victauri MCP server failed: {e}");
578                            }
579                        }
580                    });
581
582                    if let Some(cb) = on_ready {
583                        tauri::async_runtime::spawn(async move {
584                            for _ in 0..50 {
585                                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
586                                let actual_port = ready_state.port.load(std::sync::atomic::Ordering::Relaxed);
587                                if tokio::net::TcpStream::connect(format!(
588                                    "127.0.0.1:{actual_port}"
589                                ))
590                                .await
591                                .is_ok()
592                                {
593                                    cb(actual_port);
594                                    return;
595                                }
596                            }
597                            let actual_port = ready_state.port.load(std::sync::atomic::Ordering::Relaxed);
598                            tracing::warn!("Victauri on_ready: server did not become ready within 5s");
599                            cb(actual_port);
600                        });
601                    }
602
603                    tracing::info!("Victauri plugin initialized — MCP server on port {port}");
604                    Ok(())
605                })
606                .on_event(|app, event| {
607                    if let RunEvent::Exit = event
608                        && let Some(state) = app.try_state::<Arc<VictauriState>>()
609                    {
610                        let _ = state.shutdown_tx.send(true);
611                        tracing::info!("Victauri shutdown signal sent");
612                    }
613                })
614                .js_init_script(js_init)
615                .invoke_handler(tauri::generate_handler![
616                    tools::victauri_eval_js,
617                    tools::victauri_eval_callback,
618                    tools::victauri_get_window_state,
619                    tools::victauri_list_windows,
620                    tools::victauri_get_ipc_log,
621                    tools::victauri_get_registry,
622                    tools::victauri_get_memory_stats,
623                    tools::victauri_dom_snapshot,
624                    tools::victauri_verify_state,
625                    tools::victauri_detect_ghost_commands,
626                    tools::victauri_check_ipc_integrity,
627                ])
628                .build())
629        }
630    }
631}
632
633/// Initialize the Victauri plugin with default settings (port 7373 or `VICTAURI_PORT` env var).
634///
635/// In debug builds: starts the embedded MCP server, injects the JS bridge, and
636/// registers all Tauri command handlers.
637///
638/// In release builds: returns a no-op plugin. The MCP server, JS bridge, and
639/// all introspection tools are completely stripped — zero overhead, zero attack surface.
640///
641/// For custom configuration, use `VictauriBuilder::new().port(8080).build()`.
642///
643/// # Panics
644///
645/// Panics if the default builder configuration is invalid (this is a bug).
646#[must_use]
647pub fn init<R: Runtime>() -> TauriPlugin<R> {
648    VictauriBuilder::new()
649        .build()
650        .expect("default Victauri configuration is always valid")
651}
652
653/// Initialize the Victauri plugin with auto-discovery of all `#[inspectable]` commands.
654///
655/// Equivalent to `VictauriBuilder::new().auto_discover().build()` — all commands
656/// marked with `#[inspectable]` are registered automatically without manual listing.
657///
658/// # Panics
659///
660/// Panics if the default builder configuration is invalid (this is a bug).
661#[must_use]
662pub fn init_auto_discover<R: Runtime>() -> TauriPlugin<R> {
663    VictauriBuilder::new()
664        .auto_discover()
665        .build()
666        .expect("default Victauri configuration is always valid")
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672
673    #[test]
674    fn builder_default_values() {
675        let builder = VictauriBuilder::new();
676        assert_eq!(builder.event_capacity, DEFAULT_EVENT_CAPACITY);
677        assert_eq!(builder.recorder_capacity, DEFAULT_RECORDER_CAPACITY);
678        // Raw field is None, but resolve_auth_token() auto-generates a token
679        assert!(builder.auth_token.is_none());
680        assert!(!builder.auth_explicitly_disabled);
681        let resolved = builder.resolve_auth_token();
682        assert!(resolved.is_some(), "auth should be enabled by default");
683        assert_eq!(
684            resolved.unwrap().len(),
685            36,
686            "auto-generated token should be a UUID"
687        );
688        assert!(builder.disabled_tools.is_empty());
689        assert!(builder.command_allowlist.is_none());
690        assert!(builder.command_blocklist.is_empty());
691        assert!(!builder.redaction_enabled);
692        assert!(!builder.strict_privacy);
693    }
694
695    #[test]
696    fn builder_port_override() {
697        let builder = VictauriBuilder::new().port(9090);
698        assert_eq!(builder.resolve_port(), 9090);
699    }
700
701    #[test]
702    #[allow(unsafe_code)]
703    fn builder_default_port() {
704        let builder = VictauriBuilder::new();
705        // SAFETY: test-only — no concurrent env reads in this test binary.
706        unsafe { std::env::remove_var("VICTAURI_PORT") };
707        assert_eq!(builder.resolve_port(), DEFAULT_PORT);
708    }
709
710    #[test]
711    fn builder_auth_token_explicit() {
712        let builder = VictauriBuilder::new().auth_token("my-secret");
713        assert_eq!(builder.resolve_auth_token(), Some("my-secret".to_string()));
714    }
715
716    #[test]
717    fn builder_auth_token_generated() {
718        let builder = VictauriBuilder::new().generate_auth_token();
719        let token = builder.resolve_auth_token().unwrap();
720        assert_eq!(token.len(), 36);
721    }
722
723    #[test]
724    fn builder_auth_disabled() {
725        let builder = VictauriBuilder::new().auth_disabled();
726        assert!(builder.auth_explicitly_disabled);
727        assert!(
728            builder.resolve_auth_token().is_none(),
729            "auth_disabled should opt out of auto-generated token"
730        );
731    }
732
733    #[test]
734    fn builder_auth_disabled_overrides_explicit_token() {
735        let builder = VictauriBuilder::new()
736            .auth_token("my-secret")
737            .auth_disabled();
738        assert!(
739            builder.resolve_auth_token().is_none(),
740            "auth_disabled should override explicit token"
741        );
742    }
743
744    #[test]
745    fn builder_capacities() {
746        let builder = VictauriBuilder::new()
747            .event_capacity(500)
748            .recorder_capacity(2000);
749        assert_eq!(builder.event_capacity, 500);
750        assert_eq!(builder.recorder_capacity, 2000);
751    }
752
753    #[test]
754    fn builder_disable_tools() {
755        let builder = VictauriBuilder::new().disable_tools(&["eval_js", "screenshot"]);
756        assert_eq!(builder.disabled_tools.len(), 2);
757        assert!(builder.disabled_tools.contains(&"eval_js".to_string()));
758    }
759
760    #[test]
761    fn builder_command_allowlist() {
762        let builder = VictauriBuilder::new().command_allowlist(&["greet", "increment"]);
763        assert!(builder.command_allowlist.is_some());
764        assert_eq!(builder.command_allowlist.as_ref().unwrap().len(), 2);
765    }
766
767    #[test]
768    fn builder_command_blocklist() {
769        let builder = VictauriBuilder::new().command_blocklist(&["dangerous_cmd"]);
770        assert_eq!(builder.command_blocklist.len(), 1);
771    }
772
773    #[test]
774    fn builder_redaction() {
775        let builder = VictauriBuilder::new()
776            .add_redaction_pattern(r"SECRET_\w+")
777            .enable_redaction();
778        assert!(builder.redaction_enabled);
779        assert_eq!(builder.redaction_patterns.len(), 1);
780    }
781
782    #[test]
783    fn builder_strict_privacy_config() {
784        let builder = VictauriBuilder::new().strict_privacy_mode();
785        let config = builder.build_privacy_config();
786        assert!(config.redaction_enabled);
787        assert_eq!(config.profile, crate::privacy::PrivacyProfile::Observe);
788        assert!(!config.is_tool_enabled("eval_js"));
789        assert!(!config.is_tool_enabled("screenshot"));
790        assert!(!config.is_tool_enabled("interact"));
791        assert!(config.is_tool_enabled("dom_snapshot"));
792    }
793
794    #[test]
795    fn builder_normal_privacy_config() {
796        let builder = VictauriBuilder::new()
797            .command_blocklist(&["secret_cmd"])
798            .disable_tools(&["eval_js"]);
799        let config = builder.build_privacy_config();
800        assert!(config.command_blocklist.contains("secret_cmd"));
801        assert!(!config.is_tool_enabled("eval_js"));
802        assert!(!config.redaction_enabled);
803    }
804
805    #[test]
806    fn builder_strict_with_extra_blocklist() {
807        let builder = VictauriBuilder::new()
808            .strict_privacy_mode()
809            .command_blocklist(&["extra_dangerous"]);
810        let config = builder.build_privacy_config();
811        assert!(config.command_blocklist.contains("extra_dangerous"));
812        assert!(!config.is_tool_enabled("eval_js"));
813    }
814
815    #[test]
816    fn builder_test_profile() {
817        let builder = VictauriBuilder::new().privacy_profile(crate::privacy::PrivacyProfile::Test);
818        let config = builder.build_privacy_config();
819        assert_eq!(config.profile, crate::privacy::PrivacyProfile::Test);
820        assert!(config.redaction_enabled);
821        assert!(config.is_tool_enabled("interact"));
822        assert!(config.is_tool_enabled("fill"));
823        assert!(config.is_tool_enabled("recording"));
824        assert!(!config.is_tool_enabled("eval_js"));
825        assert!(!config.is_tool_enabled("screenshot"));
826        assert!(!config.is_tool_enabled("navigate"));
827    }
828
829    #[test]
830    fn builder_profile_with_extra_disables() {
831        let builder = VictauriBuilder::new()
832            .privacy_profile(crate::privacy::PrivacyProfile::Test)
833            .disable_tools(&["interact"]);
834        let config = builder.build_privacy_config();
835        assert!(!config.is_tool_enabled("interact"));
836        assert!(config.is_tool_enabled("fill"));
837    }
838
839    #[test]
840    fn builder_bridge_capacities() {
841        let builder = VictauriBuilder::new()
842            .console_log_capacity(5000)
843            .network_log_capacity(2000)
844            .navigation_log_capacity(500);
845        assert_eq!(builder.bridge_capacities.console_logs, 5000);
846        assert_eq!(builder.bridge_capacities.network_log, 2000);
847        assert_eq!(builder.bridge_capacities.navigation_log, 500);
848        assert_eq!(builder.bridge_capacities.mutation_log, 500);
849        assert_eq!(builder.bridge_capacities.dialog_log, 100);
850    }
851
852    #[test]
853    fn builder_on_ready_sets_callback() {
854        let builder = VictauriBuilder::new().on_ready(|_port| {});
855        assert!(builder.on_ready.is_some());
856    }
857
858    #[test]
859    fn builder_file_navigation_disabled_by_default() {
860        let builder = VictauriBuilder::new();
861        assert!(
862            !builder.allow_file_navigation,
863            "file navigation should be disabled by default"
864        );
865    }
866
867    #[test]
868    fn builder_allow_file_navigation() {
869        let builder = VictauriBuilder::new().allow_file_navigation();
870        assert!(builder.allow_file_navigation);
871    }
872
873    #[test]
874    fn init_script_contains_custom_capacities() {
875        let caps = js_bridge::BridgeCapacities {
876            console_logs: 3000,
877            mutation_log: 750,
878            network_log: 5000,
879            navigation_log: 400,
880            dialog_log: 250,
881            long_tasks: 200,
882        };
883        let script = js_bridge::init_script(&caps);
884        assert!(script.contains("CAP_CONSOLE = 3000"));
885        assert!(script.contains("CAP_MUTATION = 750"));
886        assert!(script.contains("CAP_NETWORK = 5000"));
887        assert!(script.contains("CAP_NAVIGATION = 400"));
888        assert!(script.contains("CAP_DIALOG = 250"));
889        assert!(script.contains("CAP_LONG_TASKS = 200"));
890    }
891
892    #[test]
893    fn init_script_default_contains_standard_capacities() {
894        let caps = js_bridge::BridgeCapacities::default();
895        let script = js_bridge::init_script(&caps);
896        assert!(script.contains("CAP_CONSOLE = 1000"));
897        assert!(script.contains("CAP_NETWORK = 1000"));
898        assert!(script.contains("window.__VICTAURI__"));
899    }
900
901    #[test]
902    fn builder_validates_defaults() {
903        let builder = VictauriBuilder::new();
904        assert!(builder.validate().is_ok());
905    }
906
907    #[test]
908    fn builder_rejects_zero_port() {
909        let builder = VictauriBuilder::new().port(0);
910        let err = builder.validate().unwrap_err();
911        assert!(matches!(err, BuilderError::InvalidPort { port: 0, .. }));
912    }
913
914    #[test]
915    fn builder_rejects_zero_event_capacity() {
916        let builder = VictauriBuilder::new().event_capacity(0);
917        let err = builder.validate().unwrap_err();
918        assert!(matches!(
919            err,
920            BuilderError::InvalidEventCapacity { capacity: 0, .. }
921        ));
922    }
923
924    #[test]
925    fn builder_rejects_excessive_event_capacity() {
926        let builder = VictauriBuilder::new().event_capacity(2_000_000);
927        assert!(builder.validate().is_err());
928    }
929
930    #[test]
931    fn builder_rejects_zero_recorder_capacity() {
932        let builder = VictauriBuilder::new().recorder_capacity(0);
933        assert!(builder.validate().is_err());
934    }
935
936    #[test]
937    fn builder_rejects_zero_eval_timeout() {
938        let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(0));
939        assert!(builder.validate().is_err());
940    }
941
942    #[test]
943    fn builder_rejects_excessive_eval_timeout() {
944        let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(600));
945        assert!(builder.validate().is_err());
946    }
947
948    #[test]
949    fn builder_accepts_edge_values() {
950        let builder = VictauriBuilder::new()
951            .port(1)
952            .event_capacity(1)
953            .recorder_capacity(1)
954            .eval_timeout(std::time::Duration::from_secs(1));
955        assert!(builder.validate().is_ok());
956
957        let builder = VictauriBuilder::new()
958            .port(65535)
959            .event_capacity(MAX_EVENT_CAPACITY)
960            .recorder_capacity(MAX_RECORDER_CAPACITY)
961            .eval_timeout(std::time::Duration::from_secs(MAX_EVAL_TIMEOUT_SECS));
962        assert!(builder.validate().is_ok());
963    }
964}