1#![deny(missing_docs)]
2pub mod bridge;
41#[cfg(feature = "sqlite")]
43pub mod database;
44pub mod error;
45pub mod js_bridge;
47pub mod mcp;
49mod memory;
50pub mod privacy;
52
53pub mod redaction;
55pub mod screencast;
56pub(crate) mod screenshot;
57mod tools;
58
59pub mod auth;
61pub mod introspection;
63
64use std::collections::{HashMap, HashSet};
65use std::sync::Arc;
66use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU64};
67use tauri::plugin::{Builder, TauriPlugin};
68use tauri::{Listener, Manager, RunEvent, Runtime};
69use tokio::sync::{Mutex, oneshot, watch};
70use victauri_core::{CommandRegistry, EventLog, EventRecorder};
71
72pub use error::BuilderError;
73pub use privacy::PrivacyProfile;
74
75pub use victauri_core::CommandInfo;
76pub use victauri_macros::inspectable;
77
78#[macro_export]
99macro_rules! register_commands {
100 ($app:expr, $($schema_call:expr),+ $(,)?) => {{
101 let state = $app.state::<std::sync::Arc<$crate::VictauriState>>();
102 $(
103 state.registry.register($schema_call);
104 )+
105 }};
106}
107
108const DEFAULT_PORT: u16 = 7373;
109const DEFAULT_EVENT_CAPACITY: usize = 10_000;
110const DEFAULT_RECORDER_CAPACITY: usize = 50_000;
111const DEFAULT_EVAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
112const MAX_EVENT_CAPACITY: usize = 1_000_000;
113const MAX_RECORDER_CAPACITY: usize = 1_000_000;
114const MAX_EVAL_TIMEOUT_SECS: u64 = 300;
115
116pub type PendingCallbacks = Arc<Mutex<HashMap<String, oneshot::Sender<String>>>>;
119
120pub struct VictauriState {
122 pub event_log: EventLog,
124 pub registry: CommandRegistry,
126 pub port: AtomicU16,
128 pub pending_evals: PendingCallbacks,
130 pub recorder: EventRecorder,
132 pub privacy: privacy::PrivacyConfig,
134 pub eval_timeout: std::time::Duration,
136 pub shutdown_tx: watch::Sender<bool>,
138 pub started_at: std::time::Instant,
140 pub tool_invocations: AtomicU64,
142 pub allow_file_navigation: bool,
146 pub command_timings: introspection::CommandTimings,
148 pub fault_registry: introspection::FaultRegistry,
150 pub contract_store: introspection::ContractStore,
152 pub startup_timeline: introspection::StartupTimeline,
154 pub event_bus: introspection::EventBusMonitor,
156 pub task_tracker: introspection::TaskTracker,
158 pub bridge_ready: AtomicBool,
160 pub bridge_notify: tokio::sync::Notify,
162 pub screencast: Arc<screencast::Screencast>,
164 pub db_search_paths: Vec<std::path::PathBuf>,
171}
172
173pub struct VictauriBuilder {
185 port: Option<u16>,
186 event_capacity: usize,
187 recorder_capacity: usize,
188 eval_timeout: std::time::Duration,
189 auth_token: Option<String>,
190 auth_explicitly_enabled: bool,
191 auth_explicitly_disabled: bool,
192 disabled_tools: Vec<String>,
193 command_allowlist: Option<Vec<String>>,
194 command_blocklist: Vec<String>,
195 redaction_patterns: Vec<String>,
196 redaction_enabled: bool,
197 strict_privacy: bool,
198 privacy_profile: Option<privacy::PrivacyProfile>,
199 bridge_capacities: js_bridge::BridgeCapacities,
200 on_ready: Option<Box<dyn FnOnce(u16) + Send + 'static>>,
201 commands: Vec<victauri_core::CommandInfo>,
202 allow_file_navigation: bool,
203 listen_events: Vec<String>,
204 db_search_paths: Vec<std::path::PathBuf>,
205}
206
207impl Default for VictauriBuilder {
208 fn default() -> Self {
209 Self {
210 port: None,
211 event_capacity: DEFAULT_EVENT_CAPACITY,
212 recorder_capacity: DEFAULT_RECORDER_CAPACITY,
213 eval_timeout: DEFAULT_EVAL_TIMEOUT,
214 auth_token: None,
215 auth_explicitly_enabled: false,
216 auth_explicitly_disabled: false,
217 disabled_tools: Vec::new(),
218 command_allowlist: None,
219 command_blocklist: Vec::new(),
220 redaction_patterns: Vec::new(),
221 redaction_enabled: false,
222 strict_privacy: false,
223 privacy_profile: None,
224 bridge_capacities: js_bridge::BridgeCapacities::default(),
225 on_ready: None,
226 commands: Vec::new(),
227 allow_file_navigation: false,
228 listen_events: Vec::new(),
229 db_search_paths: Vec::new(),
230 }
231 }
232}
233
234impl VictauriBuilder {
235 #[must_use]
237 pub fn new() -> Self {
238 Self::default()
239 }
240
241 #[must_use]
243 pub fn port(mut self, port: u16) -> Self {
244 self.port = Some(port);
245 self
246 }
247
248 #[must_use]
250 pub fn event_capacity(mut self, capacity: usize) -> Self {
251 self.event_capacity = capacity;
252 self
253 }
254
255 #[must_use]
257 pub fn recorder_capacity(mut self, capacity: usize) -> Self {
258 self.recorder_capacity = capacity;
259 self
260 }
261
262 #[must_use]
264 pub fn eval_timeout(mut self, timeout: std::time::Duration) -> Self {
265 self.eval_timeout = timeout;
266 self
267 }
268
269 #[must_use]
273 pub fn auth_token(mut self, token: impl Into<String>) -> Self {
274 self.auth_token = Some(token.into());
275 self
276 }
277
278 #[must_use]
288 pub fn auth_enabled(mut self) -> Self {
289 self.auth_explicitly_enabled = true;
290 self
291 }
292
293 #[must_use]
295 pub fn generate_auth_token(mut self) -> Self {
296 self.auth_explicitly_enabled = true;
297 self
298 }
299
300 #[must_use]
310 pub fn auth_disabled(mut self) -> Self {
311 self.auth_explicitly_disabled = true;
312 self
313 }
314
315 #[must_use]
317 pub fn disable_tools(mut self, tools: &[&str]) -> Self {
318 self.disabled_tools = tools.iter().map(std::string::ToString::to_string).collect();
319 self
320 }
321
322 #[must_use]
324 pub fn command_allowlist(mut self, commands: &[&str]) -> Self {
325 self.command_allowlist = Some(
326 commands
327 .iter()
328 .map(std::string::ToString::to_string)
329 .collect(),
330 );
331 self
332 }
333
334 #[must_use]
336 pub fn command_blocklist(mut self, commands: &[&str]) -> Self {
337 self.command_blocklist = commands
338 .iter()
339 .map(std::string::ToString::to_string)
340 .collect();
341 self
342 }
343
344 #[must_use]
346 pub fn add_redaction_pattern(mut self, pattern: impl Into<String>) -> Self {
347 self.redaction_patterns.push(pattern.into());
348 self
349 }
350
351 #[must_use]
353 pub fn enable_redaction(mut self) -> Self {
354 self.redaction_enabled = true;
355 self
356 }
357
358 #[must_use]
365 pub fn strict_privacy_mode(mut self) -> Self {
366 self.strict_privacy = true;
367 self.privacy_profile = Some(privacy::PrivacyProfile::Observe);
368 self
369 }
370
371 #[must_use]
380 pub fn privacy_profile(mut self, profile: privacy::PrivacyProfile) -> Self {
381 self.privacy_profile = Some(profile);
382 if matches!(
383 profile,
384 privacy::PrivacyProfile::Observe | privacy::PrivacyProfile::Test
385 ) {
386 self.redaction_enabled = true;
387 }
388 self
389 }
390
391 #[must_use]
393 pub fn console_log_capacity(mut self, capacity: usize) -> Self {
394 self.bridge_capacities.console_logs = capacity;
395 self
396 }
397
398 #[must_use]
400 pub fn network_log_capacity(mut self, capacity: usize) -> Self {
401 self.bridge_capacities.network_log = capacity;
402 self
403 }
404
405 #[must_use]
407 pub fn navigation_log_capacity(mut self, capacity: usize) -> Self {
408 self.bridge_capacities.navigation_log = capacity;
409 self
410 }
411
412 #[must_use]
427 pub fn commands(mut self, schemas: &[victauri_core::CommandInfo]) -> Self {
428 self.commands = schemas.to_vec();
429 self
430 }
431
432 #[must_use]
448 pub fn register_command_names(mut self, names: &[&str]) -> Self {
449 self.commands
450 .extend(names.iter().map(|n| victauri_core::CommandInfo::new(*n)));
451 self
452 }
453
454 #[must_use]
470 pub fn auto_discover(mut self) -> Self {
471 self.commands
472 .extend(victauri_core::auto_discovered_commands());
473 self
474 }
475
476 #[must_use]
487 pub fn listen_events(mut self, events: &[&str]) -> Self {
488 self.listen_events = events
489 .iter()
490 .map(std::string::ToString::to_string)
491 .collect();
492 self
493 }
494
495 #[must_use]
504 pub fn allow_file_navigation(mut self) -> Self {
505 self.allow_file_navigation = true;
506 self
507 }
508
509 #[must_use]
522 pub fn db_search_paths<I, P>(mut self, paths: I) -> Self
523 where
524 I: IntoIterator<Item = P>,
525 P: Into<std::path::PathBuf>,
526 {
527 self.db_search_paths
528 .extend(paths.into_iter().map(Into::into));
529 self
530 }
531
532 #[must_use]
535 pub fn on_ready(mut self, f: impl FnOnce(u16) + Send + 'static) -> Self {
536 self.on_ready = Some(Box::new(f));
537 self
538 }
539
540 fn resolve_port(&self) -> u16 {
541 self.port
542 .or_else(|| std::env::var("VICTAURI_PORT").ok()?.parse().ok())
543 .unwrap_or(DEFAULT_PORT)
544 }
545
546 fn resolve_auth_token(&self) -> Option<String> {
547 if self.auth_explicitly_disabled {
548 return None;
549 }
550 if let Some(ref token) = self.auth_token {
551 return Some(token.clone());
552 }
553 if let Ok(token) = std::env::var("VICTAURI_AUTH_TOKEN") {
554 return Some(token);
555 }
556 Some(auth::generate_token())
557 }
558
559 fn resolve_eval_timeout(&self) -> std::time::Duration {
560 std::env::var("VICTAURI_EVAL_TIMEOUT")
561 .ok()
562 .and_then(|s| s.parse::<u64>().ok())
563 .map_or(self.eval_timeout, std::time::Duration::from_secs)
564 }
565
566 fn build_privacy_config(&self) -> privacy::PrivacyConfig {
567 let profile = self
568 .privacy_profile
569 .unwrap_or(privacy::PrivacyProfile::FullControl);
570
571 let redaction_enabled = self.redaction_enabled
572 || self.strict_privacy
573 || matches!(
574 profile,
575 privacy::PrivacyProfile::Observe | privacy::PrivacyProfile::Test
576 );
577
578 privacy::PrivacyConfig {
579 profile,
580 command_allowlist: self
581 .command_allowlist
582 .as_ref()
583 .map(|v| v.iter().cloned().collect::<HashSet<String>>()),
584 command_blocklist: self.command_blocklist.iter().cloned().collect(),
585 disabled_tools: self.disabled_tools.iter().cloned().collect(),
586 redactor: redaction::Redactor::new(&self.redaction_patterns),
587 redaction_enabled,
588 }
589 }
590
591 fn validate(&self) -> Result<(), BuilderError> {
592 let port = self.resolve_port();
593 if port == 0 {
594 return Err(BuilderError::InvalidPort {
595 port,
596 reason: "port 0 is reserved".to_string(),
597 });
598 }
599
600 if self.event_capacity == 0 || self.event_capacity > MAX_EVENT_CAPACITY {
601 return Err(BuilderError::InvalidEventCapacity {
602 capacity: self.event_capacity,
603 reason: format!("must be between 1 and {MAX_EVENT_CAPACITY}"),
604 });
605 }
606
607 if self.recorder_capacity == 0 || self.recorder_capacity > MAX_RECORDER_CAPACITY {
608 return Err(BuilderError::InvalidRecorderCapacity {
609 capacity: self.recorder_capacity,
610 reason: format!("must be between 1 and {MAX_RECORDER_CAPACITY}"),
611 });
612 }
613
614 let timeout = self.resolve_eval_timeout();
615 if timeout.as_secs() == 0 || timeout.as_secs() > MAX_EVAL_TIMEOUT_SECS {
616 return Err(BuilderError::InvalidEvalTimeout {
617 timeout_secs: timeout.as_secs(),
618 reason: format!("must be between 1 and {MAX_EVAL_TIMEOUT_SECS} seconds"),
619 });
620 }
621
622 Ok(())
623 }
624
625 pub fn build<R: Runtime>(self) -> Result<TauriPlugin<R>, BuilderError> {
635 #[cfg(not(debug_assertions))]
636 {
637 Ok(Builder::new("victauri").build())
638 }
639
640 #[cfg(debug_assertions)]
641 {
642 self.validate()?;
643
644 let port = self.resolve_port();
645 let event_capacity = self.event_capacity;
646 let recorder_capacity = self.recorder_capacity;
647 let eval_timeout = self.resolve_eval_timeout();
648 let auth_token = self.resolve_auth_token();
649 let privacy_config = self.build_privacy_config();
650 let allow_file_navigation = self.allow_file_navigation;
651 let db_search_paths = self.db_search_paths;
652 let on_ready = self.on_ready;
653 let commands = self.commands;
654 let listen_events = self.listen_events;
655 let js_init = js_bridge::init_script(&self.bridge_capacities);
656
657 Ok(Builder::new("victauri")
658 .setup(move |app, _api| {
659 let startup_timeline = introspection::StartupTimeline::new();
660 let event_log = EventLog::new(event_capacity);
661 startup_timeline.mark("event_log_created");
662 let registry = CommandRegistry::new();
663 startup_timeline.mark("registry_created");
664 let (shutdown_tx, shutdown_rx) = watch::channel(false);
665
666 let state = Arc::new(VictauriState {
667 event_log,
668 registry,
669 port: AtomicU16::new(port),
670 pending_evals: Arc::new(Mutex::new(HashMap::new())),
671 recorder: EventRecorder::new(recorder_capacity),
672 privacy: privacy_config,
673 eval_timeout,
674 shutdown_tx,
675 started_at: std::time::Instant::now(),
676 tool_invocations: AtomicU64::new(0),
677 allow_file_navigation,
678 command_timings: introspection::CommandTimings::new(),
679 fault_registry: introspection::FaultRegistry::new(),
680 contract_store: introspection::ContractStore::new(),
681 startup_timeline,
682 event_bus: introspection::EventBusMonitor::default(),
683 task_tracker: introspection::TaskTracker::new(),
684 bridge_ready: AtomicBool::new(false),
685 bridge_notify: tokio::sync::Notify::new(),
686 screencast: Arc::new(screencast::Screencast::default()),
687 db_search_paths,
688 });
689 state.startup_timeline.mark("state_created");
690
691 app.manage(state.clone());
692
693 for cmd in commands {
694 state.registry.register(cmd);
695 }
696 state.startup_timeline.mark("commands_registered");
697
698 for event_name in &listen_events {
700 let bus = state.event_bus.clone();
701 let name = event_name.clone();
702 app.listen_any(event_name.clone(), move |event| {
703 let payload =
704 serde_json::from_str::<serde_json::Value>(event.payload())
705 .map_or_else(
706 |_| event.payload().to_string(),
707 |v| v.to_string(),
708 );
709 bus.push(introspection::CapturedTauriEvent {
710 name: name.clone(),
711 payload,
712 timestamp: chrono::Utc::now()
713 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
714 });
715 });
716 }
717 state
718 .startup_timeline
719 .mark("event_bus_listeners_registered");
720
721 if let Some(ref token) = auth_token {
722 let prefix_len = token.len().min(8);
723 let suffix_start = token.len().saturating_sub(4);
724 tracing::info!(
725 "Victauri MCP server auth enabled — token: {}…{}",
726 &token[..prefix_len],
727 &token[suffix_start..]
728 );
729 } else {
730 tracing::warn!(
731 "Victauri MCP server running WITHOUT auth — any localhost process can \
732 access all tools. Use VictauriBuilder::auth_enabled() or set \
733 VICTAURI_AUTH_TOKEN for shared/CI environments."
734 );
735 }
736
737 state.startup_timeline.mark("server_spawning");
738 let app_handle = app.clone();
739 let ready_state = state.clone();
740 let server_finished = state.task_tracker.track("mcp_server");
741 tauri::async_runtime::spawn(async move {
742 match mcp::start_server_with_options(
743 app_handle,
744 state,
745 port,
746 auth_token,
747 shutdown_rx,
748 )
749 .await
750 {
751 Ok(()) => {
752 tracing::info!("Victauri MCP server stopped");
753 }
754 Err(e) => {
755 tracing::error!("Victauri MCP server failed: {e}");
756 }
757 }
758 server_finished.store(true, std::sync::atomic::Ordering::Relaxed);
759 });
760
761 if let Some(cb) = on_ready {
762 let ready_finished = ready_state.task_tracker.track("on_ready_probe");
763 tauri::async_runtime::spawn(async move {
764 for _ in 0..50 {
765 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
766 let actual_port =
767 ready_state.port.load(std::sync::atomic::Ordering::Relaxed);
768 if tokio::net::TcpStream::connect(format!(
769 "127.0.0.1:{actual_port}"
770 ))
771 .await
772 .is_ok()
773 {
774 cb(actual_port);
775 ready_finished
776 .store(true, std::sync::atomic::Ordering::Relaxed);
777 return;
778 }
779 }
780 let actual_port =
781 ready_state.port.load(std::sync::atomic::Ordering::Relaxed);
782 tracing::warn!(
783 "Victauri on_ready: server did not become ready within 5s"
784 );
785 cb(actual_port);
786 ready_finished.store(true, std::sync::atomic::Ordering::Relaxed);
787 });
788 }
789
790 tracing::info!("Victauri plugin initialized — MCP server on port {port}");
791 Ok(())
792 })
793 .on_event(|app, event| {
794 let Some(state) = app.try_state::<Arc<VictauriState>>() else {
795 return;
796 };
797 match event {
798 RunEvent::Exit => {
799 let _ = state.shutdown_tx.send(true);
800 tracing::info!("Victauri shutdown signal sent");
801 }
802 RunEvent::ExitRequested { .. } => {
803 state.event_bus.push(introspection::CapturedTauriEvent {
804 name: "tauri://exit-requested".to_string(),
805 payload: String::new(),
806 timestamp: chrono::Utc::now()
807 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
808 });
809 }
810 RunEvent::WindowEvent {
811 label,
812 event: win_event,
813 ..
814 } => {
815 let (name, payload) = format_window_event(label, win_event);
816 state.event_bus.push(introspection::CapturedTauriEvent {
817 name,
818 payload,
819 timestamp: chrono::Utc::now()
820 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
821 });
822 }
823 _ => {}
824 }
825 })
826 .js_init_script(js_init)
827 .invoke_handler(tauri::generate_handler![
828 tools::victauri_eval_js,
829 tools::victauri_eval_callback,
830 tools::victauri_get_window_state,
831 tools::victauri_list_windows,
832 tools::victauri_get_ipc_log,
833 tools::victauri_get_registry,
834 tools::victauri_get_memory_stats,
835 tools::victauri_dom_snapshot,
836 tools::victauri_verify_state,
837 tools::victauri_detect_ghost_commands,
838 tools::victauri_check_ipc_integrity,
839 ])
840 .build())
841 }
842 }
843}
844
845#[cfg(debug_assertions)]
846fn format_window_event(label: &str, event: &tauri::WindowEvent) -> (String, String) {
847 match event {
848 tauri::WindowEvent::Resized(size) => (
849 format!("window:{label}:resized"),
850 serde_json::json!({"width": size.width, "height": size.height}).to_string(),
851 ),
852 tauri::WindowEvent::Moved(pos) => (
853 format!("window:{label}:moved"),
854 serde_json::json!({"x": pos.x, "y": pos.y}).to_string(),
855 ),
856 tauri::WindowEvent::CloseRequested { .. } => {
857 (format!("window:{label}:close-requested"), String::new())
858 }
859 tauri::WindowEvent::Destroyed => (format!("window:{label}:destroyed"), String::new()),
860 tauri::WindowEvent::Focused(focused) => (
861 format!("window:{label}:focused"),
862 serde_json::json!({"focused": focused}).to_string(),
863 ),
864 tauri::WindowEvent::ScaleFactorChanged { scale_factor, .. } => (
865 format!("window:{label}:scale-factor-changed"),
866 serde_json::json!({"scale_factor": scale_factor}).to_string(),
867 ),
868 tauri::WindowEvent::ThemeChanged(theme) => (
869 format!("window:{label}:theme-changed"),
870 serde_json::json!({"theme": format!("{theme:?}")}).to_string(),
871 ),
872 tauri::WindowEvent::DragDrop(drag_event) => (
873 format!("window:{label}:drag-drop"),
874 format!("{drag_event:?}"),
875 ),
876 _ => (format!("window:{label}:other"), format!("{event:?}")),
877 }
878}
879
880#[must_use]
895pub fn init<R: Runtime>() -> TauriPlugin<R> {
896 VictauriBuilder::new()
897 .build()
898 .expect("default Victauri configuration is always valid")
899}
900
901#[must_use]
910pub fn init_auto_discover<R: Runtime>() -> TauriPlugin<R> {
911 VictauriBuilder::new()
912 .auto_discover()
913 .build()
914 .expect("default Victauri configuration is always valid")
915}
916
917#[cfg(test)]
918mod tests {
919 use super::*;
920
921 #[test]
922 fn builder_default_values() {
923 let builder = VictauriBuilder::new();
924 assert_eq!(builder.event_capacity, DEFAULT_EVENT_CAPACITY);
925 assert_eq!(builder.recorder_capacity, DEFAULT_RECORDER_CAPACITY);
926 assert!(builder.auth_token.is_none());
927 assert!(!builder.auth_explicitly_enabled);
928 assert!(!builder.auth_explicitly_disabled);
929 let resolved = builder.resolve_auth_token();
930 assert!(
931 resolved.is_some(),
932 "auth should be enabled by default (auto-generated token)"
933 );
934 assert_eq!(
935 resolved.unwrap().len(),
936 36,
937 "auto-generated token should be UUID v4"
938 );
939 assert!(builder.disabled_tools.is_empty());
940 assert!(builder.command_allowlist.is_none());
941 assert!(builder.command_blocklist.is_empty());
942 assert!(!builder.redaction_enabled);
943 assert!(!builder.strict_privacy);
944 }
945
946 #[test]
947 fn builder_port_override() {
948 let builder = VictauriBuilder::new().port(9090);
949 assert_eq!(builder.resolve_port(), 9090);
950 }
951
952 #[test]
953 #[allow(unsafe_code)]
954 fn builder_default_port() {
955 let builder = VictauriBuilder::new();
956 unsafe { std::env::remove_var("VICTAURI_PORT") };
958 assert_eq!(builder.resolve_port(), DEFAULT_PORT);
959 }
960
961 #[test]
962 fn builder_auth_token_explicit() {
963 let builder = VictauriBuilder::new().auth_token("my-secret");
964 assert_eq!(builder.resolve_auth_token(), Some("my-secret".to_string()));
965 }
966
967 #[test]
968 fn builder_auth_enabled() {
969 let builder = VictauriBuilder::new().auth_enabled();
970 assert!(builder.auth_explicitly_enabled);
971 let token = builder.resolve_auth_token().unwrap();
972 assert_eq!(token.len(), 36, "auto-generated token should be a UUID");
973 }
974
975 #[test]
976 fn builder_auth_generate_token() {
977 let builder = VictauriBuilder::new().generate_auth_token();
978 let token = builder.resolve_auth_token().unwrap();
979 assert_eq!(token.len(), 36);
980 }
981
982 #[test]
983 fn builder_auth_disabled_is_noop() {
984 let builder = VictauriBuilder::new().auth_disabled();
985 assert!(
986 builder.resolve_auth_token().is_none(),
987 "auth_disabled is a no-op, auth stays off by default"
988 );
989 }
990
991 #[test]
992 fn builder_auth_disabled_returns_none() {
993 let builder = VictauriBuilder::new().auth_disabled();
994 assert!(
995 builder.resolve_auth_token().is_none(),
996 "auth_disabled should suppress auto-generated token"
997 );
998 }
999
1000 #[test]
1001 fn builder_auth_disabled_overrides_explicit_token() {
1002 let builder = VictauriBuilder::new()
1003 .auth_token("my-secret")
1004 .auth_disabled();
1005 assert!(
1006 builder.resolve_auth_token().is_none(),
1007 "auth_disabled should override explicit token"
1008 );
1009 }
1010
1011 #[test]
1012 fn builder_capacities() {
1013 let builder = VictauriBuilder::new()
1014 .event_capacity(500)
1015 .recorder_capacity(2000);
1016 assert_eq!(builder.event_capacity, 500);
1017 assert_eq!(builder.recorder_capacity, 2000);
1018 }
1019
1020 #[test]
1021 fn builder_disable_tools() {
1022 let builder = VictauriBuilder::new().disable_tools(&["eval_js", "screenshot"]);
1023 assert_eq!(builder.disabled_tools.len(), 2);
1024 assert!(builder.disabled_tools.contains(&"eval_js".to_string()));
1025 }
1026
1027 #[test]
1028 fn builder_command_allowlist() {
1029 let builder = VictauriBuilder::new().command_allowlist(&["greet", "increment"]);
1030 assert!(builder.command_allowlist.is_some());
1031 assert_eq!(builder.command_allowlist.as_ref().unwrap().len(), 2);
1032 }
1033
1034 #[test]
1035 fn builder_command_blocklist() {
1036 let builder = VictauriBuilder::new().command_blocklist(&["dangerous_cmd"]);
1037 assert_eq!(builder.command_blocklist.len(), 1);
1038 }
1039
1040 #[test]
1041 fn builder_redaction() {
1042 let builder = VictauriBuilder::new()
1043 .add_redaction_pattern(r"SECRET_\w+")
1044 .enable_redaction();
1045 assert!(builder.redaction_enabled);
1046 assert_eq!(builder.redaction_patterns.len(), 1);
1047 }
1048
1049 #[test]
1050 fn builder_strict_privacy_config() {
1051 let builder = VictauriBuilder::new().strict_privacy_mode();
1052 let config = builder.build_privacy_config();
1053 assert!(config.redaction_enabled);
1054 assert_eq!(config.profile, crate::privacy::PrivacyProfile::Observe);
1055 assert!(!config.is_tool_enabled("eval_js"));
1056 assert!(!config.is_tool_enabled("screenshot"));
1057 assert!(!config.is_tool_enabled("interact"));
1058 assert!(config.is_tool_enabled("dom_snapshot"));
1059 }
1060
1061 #[test]
1062 fn builder_normal_privacy_config() {
1063 let builder = VictauriBuilder::new()
1064 .command_blocklist(&["secret_cmd"])
1065 .disable_tools(&["eval_js"]);
1066 let config = builder.build_privacy_config();
1067 assert!(config.command_blocklist.contains("secret_cmd"));
1068 assert!(!config.is_tool_enabled("eval_js"));
1069 assert!(!config.redaction_enabled);
1070 }
1071
1072 #[test]
1073 fn builder_strict_with_extra_blocklist() {
1074 let builder = VictauriBuilder::new()
1075 .strict_privacy_mode()
1076 .command_blocklist(&["extra_dangerous"]);
1077 let config = builder.build_privacy_config();
1078 assert!(config.command_blocklist.contains("extra_dangerous"));
1079 assert!(!config.is_tool_enabled("eval_js"));
1080 }
1081
1082 #[test]
1083 fn builder_test_profile() {
1084 let builder = VictauriBuilder::new().privacy_profile(crate::privacy::PrivacyProfile::Test);
1085 let config = builder.build_privacy_config();
1086 assert_eq!(config.profile, crate::privacy::PrivacyProfile::Test);
1087 assert!(config.redaction_enabled);
1088 assert!(config.is_tool_enabled("interact"));
1089 assert!(config.is_tool_enabled("fill"));
1090 assert!(config.is_tool_enabled("recording"));
1091 assert!(!config.is_tool_enabled("eval_js"));
1092 assert!(!config.is_tool_enabled("screenshot"));
1093 assert!(!config.is_tool_enabled("navigate"));
1094 }
1095
1096 #[test]
1097 fn builder_profile_with_extra_disables() {
1098 let builder = VictauriBuilder::new()
1099 .privacy_profile(crate::privacy::PrivacyProfile::Test)
1100 .disable_tools(&["interact"]);
1101 let config = builder.build_privacy_config();
1102 assert!(!config.is_tool_enabled("interact"));
1103 assert!(config.is_tool_enabled("fill"));
1104 }
1105
1106 #[test]
1107 fn builder_bridge_capacities() {
1108 let builder = VictauriBuilder::new()
1109 .console_log_capacity(5000)
1110 .network_log_capacity(2000)
1111 .navigation_log_capacity(500);
1112 assert_eq!(builder.bridge_capacities.console_logs, 5000);
1113 assert_eq!(builder.bridge_capacities.network_log, 2000);
1114 assert_eq!(builder.bridge_capacities.navigation_log, 500);
1115 assert_eq!(builder.bridge_capacities.mutation_log, 500);
1116 assert_eq!(builder.bridge_capacities.dialog_log, 100);
1117 }
1118
1119 #[test]
1120 fn builder_on_ready_sets_callback() {
1121 let builder = VictauriBuilder::new().on_ready(|_port| {});
1122 assert!(builder.on_ready.is_some());
1123 }
1124
1125 #[test]
1126 fn builder_file_navigation_disabled_by_default() {
1127 let builder = VictauriBuilder::new();
1128 assert!(
1129 !builder.allow_file_navigation,
1130 "file navigation should be disabled by default"
1131 );
1132 }
1133
1134 #[test]
1135 fn builder_allow_file_navigation() {
1136 let builder = VictauriBuilder::new().allow_file_navigation();
1137 assert!(builder.allow_file_navigation);
1138 }
1139
1140 #[test]
1141 fn builder_listen_events() {
1142 let builder =
1143 VictauriBuilder::new().listen_events(&["notification-added", "settings-changed"]);
1144 assert_eq!(builder.listen_events.len(), 2);
1145 assert!(
1146 builder
1147 .listen_events
1148 .contains(&"notification-added".to_string())
1149 );
1150 assert!(
1151 builder
1152 .listen_events
1153 .contains(&"settings-changed".to_string())
1154 );
1155 }
1156
1157 #[test]
1158 fn builder_listen_events_empty_by_default() {
1159 let builder = VictauriBuilder::new();
1160 assert!(builder.listen_events.is_empty());
1161 }
1162
1163 #[test]
1164 fn init_script_contains_custom_capacities() {
1165 let caps = js_bridge::BridgeCapacities {
1166 console_logs: 3000,
1167 mutation_log: 750,
1168 network_log: 5000,
1169 navigation_log: 400,
1170 dialog_log: 250,
1171 long_tasks: 200,
1172 };
1173 let script = js_bridge::init_script(&caps);
1174 assert!(script.contains("CAP_CONSOLE = 3000"));
1175 assert!(script.contains("CAP_MUTATION = 750"));
1176 assert!(script.contains("CAP_NETWORK = 5000"));
1177 assert!(script.contains("CAP_NAVIGATION = 400"));
1178 assert!(script.contains("CAP_DIALOG = 250"));
1179 assert!(script.contains("CAP_LONG_TASKS = 200"));
1180 }
1181
1182 #[test]
1183 fn init_script_default_contains_standard_capacities() {
1184 let caps = js_bridge::BridgeCapacities::default();
1185 let script = js_bridge::init_script(&caps);
1186 assert!(script.contains("CAP_CONSOLE = 1000"));
1187 assert!(script.contains("CAP_NETWORK = 1000"));
1188 assert!(script.contains("window.__VICTAURI__"));
1189 }
1190
1191 #[test]
1192 fn builder_validates_defaults() {
1193 let builder = VictauriBuilder::new();
1194 assert!(builder.validate().is_ok());
1195 }
1196
1197 #[test]
1198 fn builder_rejects_zero_port() {
1199 let builder = VictauriBuilder::new().port(0);
1200 let err = builder.validate().unwrap_err();
1201 assert!(matches!(err, BuilderError::InvalidPort { port: 0, .. }));
1202 }
1203
1204 #[test]
1205 fn builder_rejects_zero_event_capacity() {
1206 let builder = VictauriBuilder::new().event_capacity(0);
1207 let err = builder.validate().unwrap_err();
1208 assert!(matches!(
1209 err,
1210 BuilderError::InvalidEventCapacity { capacity: 0, .. }
1211 ));
1212 }
1213
1214 #[test]
1215 fn builder_rejects_excessive_event_capacity() {
1216 let builder = VictauriBuilder::new().event_capacity(2_000_000);
1217 assert!(builder.validate().is_err());
1218 }
1219
1220 #[test]
1221 fn builder_rejects_zero_recorder_capacity() {
1222 let builder = VictauriBuilder::new().recorder_capacity(0);
1223 assert!(builder.validate().is_err());
1224 }
1225
1226 #[test]
1227 fn builder_rejects_zero_eval_timeout() {
1228 let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(0));
1229 assert!(builder.validate().is_err());
1230 }
1231
1232 #[test]
1233 fn builder_rejects_excessive_eval_timeout() {
1234 let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(600));
1235 assert!(builder.validate().is_err());
1236 }
1237
1238 #[test]
1239 fn builder_accepts_edge_values() {
1240 let builder = VictauriBuilder::new()
1241 .port(1)
1242 .event_capacity(1)
1243 .recorder_capacity(1)
1244 .eval_timeout(std::time::Duration::from_secs(1));
1245 assert!(builder.validate().is_ok());
1246
1247 let builder = VictauriBuilder::new()
1248 .port(65535)
1249 .event_capacity(MAX_EVENT_CAPACITY)
1250 .recorder_capacity(MAX_RECORDER_CAPACITY)
1251 .eval_timeout(std::time::Duration::from_secs(MAX_EVAL_TIMEOUT_SECS));
1252 assert!(builder.validate().is_ok());
1253 }
1254}