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