1#![deny(missing_docs)]
2pub mod bridge;
40#[cfg(feature = "sqlite")]
42pub mod database;
43pub mod error;
44pub mod js_bridge;
46pub mod mcp;
48mod memory;
49pub mod privacy;
51pub mod redaction;
53pub(crate) mod screenshot;
54mod tools;
55
56pub mod auth;
58pub mod introspection;
60
61use std::collections::{HashMap, HashSet};
62use std::sync::Arc;
63use std::sync::atomic::{AtomicU16, AtomicU64};
64use tauri::plugin::{Builder, TauriPlugin};
65use tauri::{Listener, Manager, RunEvent, Runtime};
66use tokio::sync::{Mutex, oneshot, watch};
67use victauri_core::{CommandRegistry, EventLog, EventRecorder};
68
69pub use error::BuilderError;
70pub use privacy::PrivacyProfile;
71
72pub use victauri_core::CommandInfo;
73pub use victauri_macros::inspectable;
74
75#[macro_export]
96macro_rules! register_commands {
97 ($app:expr, $($schema_call:expr),+ $(,)?) => {{
98 let state = $app.state::<std::sync::Arc<$crate::VictauriState>>();
99 $(
100 state.registry.register($schema_call);
101 )+
102 }};
103}
104
105const DEFAULT_PORT: u16 = 7373;
106const DEFAULT_EVENT_CAPACITY: usize = 10_000;
107const DEFAULT_RECORDER_CAPACITY: usize = 50_000;
108const DEFAULT_EVAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
109const MAX_EVENT_CAPACITY: usize = 1_000_000;
110const MAX_RECORDER_CAPACITY: usize = 1_000_000;
111const MAX_EVAL_TIMEOUT_SECS: u64 = 300;
112
113pub type PendingCallbacks = Arc<Mutex<HashMap<String, oneshot::Sender<String>>>>;
116
117pub struct VictauriState {
119 pub event_log: EventLog,
121 pub registry: CommandRegistry,
123 pub port: AtomicU16,
125 pub pending_evals: PendingCallbacks,
127 pub recorder: EventRecorder,
129 pub privacy: privacy::PrivacyConfig,
131 pub eval_timeout: std::time::Duration,
133 pub shutdown_tx: watch::Sender<bool>,
135 pub started_at: std::time::Instant,
137 pub tool_invocations: AtomicU64,
139 pub allow_file_navigation: bool,
143 pub command_timings: introspection::CommandTimings,
145 pub fault_registry: introspection::FaultRegistry,
147 pub contract_store: introspection::ContractStore,
149 pub startup_timeline: introspection::StartupTimeline,
151 pub event_bus: introspection::EventBusMonitor,
153 pub task_tracker: introspection::TaskTracker,
155}
156
157pub struct VictauriBuilder {
169 port: Option<u16>,
170 event_capacity: usize,
171 recorder_capacity: usize,
172 eval_timeout: std::time::Duration,
173 auth_token: Option<String>,
174 auth_explicitly_enabled: bool,
175 disabled_tools: Vec<String>,
176 command_allowlist: Option<Vec<String>>,
177 command_blocklist: Vec<String>,
178 redaction_patterns: Vec<String>,
179 redaction_enabled: bool,
180 strict_privacy: bool,
181 privacy_profile: Option<privacy::PrivacyProfile>,
182 bridge_capacities: js_bridge::BridgeCapacities,
183 on_ready: Option<Box<dyn FnOnce(u16) + Send + 'static>>,
184 commands: Vec<victauri_core::CommandInfo>,
185 allow_file_navigation: bool,
186 listen_events: Vec<String>,
187}
188
189impl Default for VictauriBuilder {
190 fn default() -> Self {
191 Self {
192 port: None,
193 event_capacity: DEFAULT_EVENT_CAPACITY,
194 recorder_capacity: DEFAULT_RECORDER_CAPACITY,
195 eval_timeout: DEFAULT_EVAL_TIMEOUT,
196 auth_token: None,
197 auth_explicitly_enabled: false,
198 disabled_tools: Vec::new(),
199 command_allowlist: None,
200 command_blocklist: Vec::new(),
201 redaction_patterns: Vec::new(),
202 redaction_enabled: false,
203 strict_privacy: false,
204 privacy_profile: None,
205 bridge_capacities: js_bridge::BridgeCapacities::default(),
206 on_ready: None,
207 commands: Vec::new(),
208 allow_file_navigation: false,
209 listen_events: Vec::new(),
210 }
211 }
212}
213
214impl VictauriBuilder {
215 #[must_use]
217 pub fn new() -> Self {
218 Self::default()
219 }
220
221 #[must_use]
223 pub fn port(mut self, port: u16) -> Self {
224 self.port = Some(port);
225 self
226 }
227
228 #[must_use]
230 pub fn event_capacity(mut self, capacity: usize) -> Self {
231 self.event_capacity = capacity;
232 self
233 }
234
235 #[must_use]
237 pub fn recorder_capacity(mut self, capacity: usize) -> Self {
238 self.recorder_capacity = capacity;
239 self
240 }
241
242 #[must_use]
244 pub fn eval_timeout(mut self, timeout: std::time::Duration) -> Self {
245 self.eval_timeout = timeout;
246 self
247 }
248
249 #[must_use]
253 pub fn auth_token(mut self, token: impl Into<String>) -> Self {
254 self.auth_token = Some(token.into());
255 self
256 }
257
258 #[must_use]
268 pub fn auth_enabled(mut self) -> Self {
269 self.auth_explicitly_enabled = true;
270 self
271 }
272
273 #[must_use]
275 pub fn generate_auth_token(mut self) -> Self {
276 self.auth_explicitly_enabled = true;
277 self
278 }
279
280 #[must_use]
287 pub fn auth_disabled(self) -> Self {
288 self
289 }
290
291 #[must_use]
293 pub fn disable_tools(mut self, tools: &[&str]) -> Self {
294 self.disabled_tools = tools.iter().map(std::string::ToString::to_string).collect();
295 self
296 }
297
298 #[must_use]
300 pub fn command_allowlist(mut self, commands: &[&str]) -> Self {
301 self.command_allowlist = Some(
302 commands
303 .iter()
304 .map(std::string::ToString::to_string)
305 .collect(),
306 );
307 self
308 }
309
310 #[must_use]
312 pub fn command_blocklist(mut self, commands: &[&str]) -> Self {
313 self.command_blocklist = commands
314 .iter()
315 .map(std::string::ToString::to_string)
316 .collect();
317 self
318 }
319
320 #[must_use]
322 pub fn add_redaction_pattern(mut self, pattern: impl Into<String>) -> Self {
323 self.redaction_patterns.push(pattern.into());
324 self
325 }
326
327 #[must_use]
329 pub fn enable_redaction(mut self) -> Self {
330 self.redaction_enabled = true;
331 self
332 }
333
334 #[must_use]
341 pub fn strict_privacy_mode(mut self) -> Self {
342 self.strict_privacy = true;
343 self.privacy_profile = Some(privacy::PrivacyProfile::Observe);
344 self
345 }
346
347 #[must_use]
356 pub fn privacy_profile(mut self, profile: privacy::PrivacyProfile) -> Self {
357 self.privacy_profile = Some(profile);
358 if matches!(
359 profile,
360 privacy::PrivacyProfile::Observe | privacy::PrivacyProfile::Test
361 ) {
362 self.redaction_enabled = true;
363 }
364 self
365 }
366
367 #[must_use]
369 pub fn console_log_capacity(mut self, capacity: usize) -> Self {
370 self.bridge_capacities.console_logs = capacity;
371 self
372 }
373
374 #[must_use]
376 pub fn network_log_capacity(mut self, capacity: usize) -> Self {
377 self.bridge_capacities.network_log = capacity;
378 self
379 }
380
381 #[must_use]
383 pub fn navigation_log_capacity(mut self, capacity: usize) -> Self {
384 self.bridge_capacities.navigation_log = capacity;
385 self
386 }
387
388 #[must_use]
403 pub fn commands(mut self, schemas: &[victauri_core::CommandInfo]) -> Self {
404 self.commands = schemas.to_vec();
405 self
406 }
407
408 #[must_use]
424 pub fn register_command_names(mut self, names: &[&str]) -> Self {
425 self.commands
426 .extend(names.iter().map(|n| victauri_core::CommandInfo::new(*n)));
427 self
428 }
429
430 #[must_use]
446 pub fn auto_discover(mut self) -> Self {
447 self.commands
448 .extend(victauri_core::auto_discovered_commands());
449 self
450 }
451
452 #[must_use]
463 pub fn listen_events(mut self, events: &[&str]) -> Self {
464 self.listen_events = events
465 .iter()
466 .map(std::string::ToString::to_string)
467 .collect();
468 self
469 }
470
471 #[must_use]
480 pub fn allow_file_navigation(mut self) -> Self {
481 self.allow_file_navigation = true;
482 self
483 }
484
485 #[must_use]
488 pub fn on_ready(mut self, f: impl FnOnce(u16) + Send + 'static) -> Self {
489 self.on_ready = Some(Box::new(f));
490 self
491 }
492
493 fn resolve_port(&self) -> u16 {
494 self.port
495 .or_else(|| std::env::var("VICTAURI_PORT").ok()?.parse().ok())
496 .unwrap_or(DEFAULT_PORT)
497 }
498
499 fn resolve_auth_token(&self) -> Option<String> {
500 if let Some(ref token) = self.auth_token {
501 return Some(token.clone());
502 }
503 if let Ok(token) = std::env::var("VICTAURI_AUTH_TOKEN") {
504 return Some(token);
505 }
506 if self.auth_explicitly_enabled {
507 return Some(auth::generate_token());
508 }
509 None
510 }
511
512 fn resolve_eval_timeout(&self) -> std::time::Duration {
513 std::env::var("VICTAURI_EVAL_TIMEOUT")
514 .ok()
515 .and_then(|s| s.parse::<u64>().ok())
516 .map_or(self.eval_timeout, std::time::Duration::from_secs)
517 }
518
519 fn build_privacy_config(&self) -> privacy::PrivacyConfig {
520 let profile = self
521 .privacy_profile
522 .unwrap_or(privacy::PrivacyProfile::FullControl);
523
524 let redaction_enabled = self.redaction_enabled
525 || self.strict_privacy
526 || matches!(
527 profile,
528 privacy::PrivacyProfile::Observe | privacy::PrivacyProfile::Test
529 );
530
531 privacy::PrivacyConfig {
532 profile,
533 command_allowlist: self
534 .command_allowlist
535 .as_ref()
536 .map(|v| v.iter().cloned().collect::<HashSet<String>>()),
537 command_blocklist: self.command_blocklist.iter().cloned().collect(),
538 disabled_tools: self.disabled_tools.iter().cloned().collect(),
539 redactor: redaction::Redactor::new(&self.redaction_patterns),
540 redaction_enabled,
541 }
542 }
543
544 fn validate(&self) -> Result<(), BuilderError> {
545 let port = self.resolve_port();
546 if port == 0 {
547 return Err(BuilderError::InvalidPort {
548 port,
549 reason: "port 0 is reserved".to_string(),
550 });
551 }
552
553 if self.event_capacity == 0 || self.event_capacity > MAX_EVENT_CAPACITY {
554 return Err(BuilderError::InvalidEventCapacity {
555 capacity: self.event_capacity,
556 reason: format!("must be between 1 and {MAX_EVENT_CAPACITY}"),
557 });
558 }
559
560 if self.recorder_capacity == 0 || self.recorder_capacity > MAX_RECORDER_CAPACITY {
561 return Err(BuilderError::InvalidRecorderCapacity {
562 capacity: self.recorder_capacity,
563 reason: format!("must be between 1 and {MAX_RECORDER_CAPACITY}"),
564 });
565 }
566
567 let timeout = self.resolve_eval_timeout();
568 if timeout.as_secs() == 0 || timeout.as_secs() > MAX_EVAL_TIMEOUT_SECS {
569 return Err(BuilderError::InvalidEvalTimeout {
570 timeout_secs: timeout.as_secs(),
571 reason: format!("must be between 1 and {MAX_EVAL_TIMEOUT_SECS} seconds"),
572 });
573 }
574
575 Ok(())
576 }
577
578 pub fn build<R: Runtime>(self) -> Result<TauriPlugin<R>, BuilderError> {
588 #[cfg(not(debug_assertions))]
589 {
590 Ok(Builder::new("victauri").build())
591 }
592
593 #[cfg(debug_assertions)]
594 {
595 self.validate()?;
596
597 let port = self.resolve_port();
598 let event_capacity = self.event_capacity;
599 let recorder_capacity = self.recorder_capacity;
600 let eval_timeout = self.resolve_eval_timeout();
601 let auth_token = self.resolve_auth_token();
602 let privacy_config = self.build_privacy_config();
603 let allow_file_navigation = self.allow_file_navigation;
604 let on_ready = self.on_ready;
605 let commands = self.commands;
606 let listen_events = self.listen_events;
607 let js_init = js_bridge::init_script(&self.bridge_capacities);
608
609 Ok(Builder::new("victauri")
610 .setup(move |app, _api| {
611 let startup_timeline = introspection::StartupTimeline::new();
612 let event_log = EventLog::new(event_capacity);
613 startup_timeline.mark("event_log_created");
614 let registry = CommandRegistry::new();
615 startup_timeline.mark("registry_created");
616 let (shutdown_tx, shutdown_rx) = watch::channel(false);
617
618 let state = Arc::new(VictauriState {
619 event_log,
620 registry,
621 port: AtomicU16::new(port),
622 pending_evals: Arc::new(Mutex::new(HashMap::new())),
623 recorder: EventRecorder::new(recorder_capacity),
624 privacy: privacy_config,
625 eval_timeout,
626 shutdown_tx,
627 started_at: std::time::Instant::now(),
628 tool_invocations: AtomicU64::new(0),
629 allow_file_navigation,
630 command_timings: introspection::CommandTimings::new(),
631 fault_registry: introspection::FaultRegistry::new(),
632 contract_store: introspection::ContractStore::new(),
633 startup_timeline,
634 event_bus: introspection::EventBusMonitor::default(),
635 task_tracker: introspection::TaskTracker::new(),
636 });
637 state.startup_timeline.mark("state_created");
638
639 app.manage(state.clone());
640
641 for cmd in commands {
642 state.registry.register(cmd);
643 }
644 state.startup_timeline.mark("commands_registered");
645
646 for event_name in &listen_events {
648 let bus = state.event_bus.clone();
649 let name = event_name.clone();
650 app.listen_any(event_name.clone(), move |event| {
651 let payload =
652 serde_json::from_str::<serde_json::Value>(event.payload())
653 .map_or_else(
654 |_| event.payload().to_string(),
655 |v| v.to_string(),
656 );
657 bus.push(introspection::CapturedTauriEvent {
658 name: name.clone(),
659 payload,
660 timestamp: chrono::Utc::now()
661 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
662 });
663 });
664 }
665 state
666 .startup_timeline
667 .mark("event_bus_listeners_registered");
668
669 if let Some(ref token) = auth_token {
670 tracing::info!(
671 "Victauri MCP server auth enabled — token: {}…{}",
672 &token[..8],
673 &token[token.len().saturating_sub(4)..]
674 );
675 } else {
676 tracing::info!(
677 "Victauri MCP server running without auth (localhost-only, debug build)"
678 );
679 }
680
681 state.startup_timeline.mark("server_spawning");
682 let app_handle = app.clone();
683 let ready_state = state.clone();
684 let server_finished = state.task_tracker.track("mcp_server");
685 tauri::async_runtime::spawn(async move {
686 match mcp::start_server_with_options(
687 app_handle,
688 state,
689 port,
690 auth_token,
691 shutdown_rx,
692 )
693 .await
694 {
695 Ok(()) => {
696 tracing::info!("Victauri MCP server stopped");
697 }
698 Err(e) => {
699 tracing::error!("Victauri MCP server failed: {e}");
700 }
701 }
702 server_finished.store(true, std::sync::atomic::Ordering::Relaxed);
703 });
704
705 if let Some(cb) = on_ready {
706 let ready_finished = ready_state.task_tracker.track("on_ready_probe");
707 tauri::async_runtime::spawn(async move {
708 for _ in 0..50 {
709 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
710 let actual_port =
711 ready_state.port.load(std::sync::atomic::Ordering::Relaxed);
712 if tokio::net::TcpStream::connect(format!(
713 "127.0.0.1:{actual_port}"
714 ))
715 .await
716 .is_ok()
717 {
718 cb(actual_port);
719 ready_finished
720 .store(true, std::sync::atomic::Ordering::Relaxed);
721 return;
722 }
723 }
724 let actual_port =
725 ready_state.port.load(std::sync::atomic::Ordering::Relaxed);
726 tracing::warn!(
727 "Victauri on_ready: server did not become ready within 5s"
728 );
729 cb(actual_port);
730 ready_finished.store(true, std::sync::atomic::Ordering::Relaxed);
731 });
732 }
733
734 tracing::info!("Victauri plugin initialized — MCP server on port {port}");
735 Ok(())
736 })
737 .on_event(|app, event| {
738 let Some(state) = app.try_state::<Arc<VictauriState>>() else {
739 return;
740 };
741 match event {
742 RunEvent::Exit => {
743 let _ = state.shutdown_tx.send(true);
744 tracing::info!("Victauri shutdown signal sent");
745 }
746 RunEvent::ExitRequested { .. } => {
747 state.event_bus.push(introspection::CapturedTauriEvent {
748 name: "tauri://exit-requested".to_string(),
749 payload: String::new(),
750 timestamp: chrono::Utc::now()
751 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
752 });
753 }
754 RunEvent::WindowEvent {
755 label,
756 event: win_event,
757 ..
758 } => {
759 let (name, payload) = format_window_event(label, win_event);
760 state.event_bus.push(introspection::CapturedTauriEvent {
761 name,
762 payload,
763 timestamp: chrono::Utc::now()
764 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
765 });
766 }
767 _ => {}
768 }
769 })
770 .js_init_script(js_init)
771 .invoke_handler(tauri::generate_handler![
772 tools::victauri_eval_js,
773 tools::victauri_eval_callback,
774 tools::victauri_get_window_state,
775 tools::victauri_list_windows,
776 tools::victauri_get_ipc_log,
777 tools::victauri_get_registry,
778 tools::victauri_get_memory_stats,
779 tools::victauri_dom_snapshot,
780 tools::victauri_verify_state,
781 tools::victauri_detect_ghost_commands,
782 tools::victauri_check_ipc_integrity,
783 ])
784 .build())
785 }
786 }
787}
788
789#[cfg(debug_assertions)]
790fn format_window_event(label: &str, event: &tauri::WindowEvent) -> (String, String) {
791 match event {
792 tauri::WindowEvent::Resized(size) => (
793 format!("window:{label}:resized"),
794 serde_json::json!({"width": size.width, "height": size.height}).to_string(),
795 ),
796 tauri::WindowEvent::Moved(pos) => (
797 format!("window:{label}:moved"),
798 serde_json::json!({"x": pos.x, "y": pos.y}).to_string(),
799 ),
800 tauri::WindowEvent::CloseRequested { .. } => {
801 (format!("window:{label}:close-requested"), String::new())
802 }
803 tauri::WindowEvent::Destroyed => (format!("window:{label}:destroyed"), String::new()),
804 tauri::WindowEvent::Focused(focused) => (
805 format!("window:{label}:focused"),
806 serde_json::json!({"focused": focused}).to_string(),
807 ),
808 tauri::WindowEvent::ScaleFactorChanged { scale_factor, .. } => (
809 format!("window:{label}:scale-factor-changed"),
810 serde_json::json!({"scale_factor": scale_factor}).to_string(),
811 ),
812 tauri::WindowEvent::ThemeChanged(theme) => (
813 format!("window:{label}:theme-changed"),
814 serde_json::json!({"theme": format!("{theme:?}")}).to_string(),
815 ),
816 tauri::WindowEvent::DragDrop(drag_event) => (
817 format!("window:{label}:drag-drop"),
818 format!("{drag_event:?}"),
819 ),
820 _ => (format!("window:{label}:other"), format!("{event:?}")),
821 }
822}
823
824#[must_use]
838pub fn init<R: Runtime>() -> TauriPlugin<R> {
839 VictauriBuilder::new()
840 .build()
841 .expect("default Victauri configuration is always valid")
842}
843
844#[must_use]
853pub fn init_auto_discover<R: Runtime>() -> TauriPlugin<R> {
854 VictauriBuilder::new()
855 .auto_discover()
856 .build()
857 .expect("default Victauri configuration is always valid")
858}
859
860#[cfg(test)]
861mod tests {
862 use super::*;
863
864 #[test]
865 fn builder_default_values() {
866 let builder = VictauriBuilder::new();
867 assert_eq!(builder.event_capacity, DEFAULT_EVENT_CAPACITY);
868 assert_eq!(builder.recorder_capacity, DEFAULT_RECORDER_CAPACITY);
869 assert!(builder.auth_token.is_none());
870 assert!(!builder.auth_explicitly_enabled);
871 let resolved = builder.resolve_auth_token();
872 assert!(resolved.is_none(), "auth should be disabled by default");
873 assert!(builder.disabled_tools.is_empty());
874 assert!(builder.command_allowlist.is_none());
875 assert!(builder.command_blocklist.is_empty());
876 assert!(!builder.redaction_enabled);
877 assert!(!builder.strict_privacy);
878 }
879
880 #[test]
881 fn builder_port_override() {
882 let builder = VictauriBuilder::new().port(9090);
883 assert_eq!(builder.resolve_port(), 9090);
884 }
885
886 #[test]
887 #[allow(unsafe_code)]
888 fn builder_default_port() {
889 let builder = VictauriBuilder::new();
890 unsafe { std::env::remove_var("VICTAURI_PORT") };
892 assert_eq!(builder.resolve_port(), DEFAULT_PORT);
893 }
894
895 #[test]
896 fn builder_auth_token_explicit() {
897 let builder = VictauriBuilder::new().auth_token("my-secret");
898 assert_eq!(builder.resolve_auth_token(), Some("my-secret".to_string()));
899 }
900
901 #[test]
902 fn builder_auth_enabled() {
903 let builder = VictauriBuilder::new().auth_enabled();
904 assert!(builder.auth_explicitly_enabled);
905 let token = builder.resolve_auth_token().unwrap();
906 assert_eq!(token.len(), 36, "auto-generated token should be a UUID");
907 }
908
909 #[test]
910 fn builder_auth_generate_token() {
911 let builder = VictauriBuilder::new().generate_auth_token();
912 let token = builder.resolve_auth_token().unwrap();
913 assert_eq!(token.len(), 36);
914 }
915
916 #[test]
917 fn builder_auth_disabled_is_noop() {
918 let builder = VictauriBuilder::new().auth_disabled();
919 assert!(
920 builder.resolve_auth_token().is_none(),
921 "auth_disabled is a no-op, auth stays off by default"
922 );
923 }
924
925 #[test]
926 fn builder_auth_disabled_does_not_override_explicit_token() {
927 let builder = VictauriBuilder::new()
928 .auth_token("my-secret")
929 .auth_disabled();
930 assert_eq!(
931 builder.resolve_auth_token(),
932 Some("my-secret".to_string()),
933 "auth_disabled is a no-op, explicit token should remain"
934 );
935 }
936
937 #[test]
938 fn builder_capacities() {
939 let builder = VictauriBuilder::new()
940 .event_capacity(500)
941 .recorder_capacity(2000);
942 assert_eq!(builder.event_capacity, 500);
943 assert_eq!(builder.recorder_capacity, 2000);
944 }
945
946 #[test]
947 fn builder_disable_tools() {
948 let builder = VictauriBuilder::new().disable_tools(&["eval_js", "screenshot"]);
949 assert_eq!(builder.disabled_tools.len(), 2);
950 assert!(builder.disabled_tools.contains(&"eval_js".to_string()));
951 }
952
953 #[test]
954 fn builder_command_allowlist() {
955 let builder = VictauriBuilder::new().command_allowlist(&["greet", "increment"]);
956 assert!(builder.command_allowlist.is_some());
957 assert_eq!(builder.command_allowlist.as_ref().unwrap().len(), 2);
958 }
959
960 #[test]
961 fn builder_command_blocklist() {
962 let builder = VictauriBuilder::new().command_blocklist(&["dangerous_cmd"]);
963 assert_eq!(builder.command_blocklist.len(), 1);
964 }
965
966 #[test]
967 fn builder_redaction() {
968 let builder = VictauriBuilder::new()
969 .add_redaction_pattern(r"SECRET_\w+")
970 .enable_redaction();
971 assert!(builder.redaction_enabled);
972 assert_eq!(builder.redaction_patterns.len(), 1);
973 }
974
975 #[test]
976 fn builder_strict_privacy_config() {
977 let builder = VictauriBuilder::new().strict_privacy_mode();
978 let config = builder.build_privacy_config();
979 assert!(config.redaction_enabled);
980 assert_eq!(config.profile, crate::privacy::PrivacyProfile::Observe);
981 assert!(!config.is_tool_enabled("eval_js"));
982 assert!(!config.is_tool_enabled("screenshot"));
983 assert!(!config.is_tool_enabled("interact"));
984 assert!(config.is_tool_enabled("dom_snapshot"));
985 }
986
987 #[test]
988 fn builder_normal_privacy_config() {
989 let builder = VictauriBuilder::new()
990 .command_blocklist(&["secret_cmd"])
991 .disable_tools(&["eval_js"]);
992 let config = builder.build_privacy_config();
993 assert!(config.command_blocklist.contains("secret_cmd"));
994 assert!(!config.is_tool_enabled("eval_js"));
995 assert!(!config.redaction_enabled);
996 }
997
998 #[test]
999 fn builder_strict_with_extra_blocklist() {
1000 let builder = VictauriBuilder::new()
1001 .strict_privacy_mode()
1002 .command_blocklist(&["extra_dangerous"]);
1003 let config = builder.build_privacy_config();
1004 assert!(config.command_blocklist.contains("extra_dangerous"));
1005 assert!(!config.is_tool_enabled("eval_js"));
1006 }
1007
1008 #[test]
1009 fn builder_test_profile() {
1010 let builder = VictauriBuilder::new().privacy_profile(crate::privacy::PrivacyProfile::Test);
1011 let config = builder.build_privacy_config();
1012 assert_eq!(config.profile, crate::privacy::PrivacyProfile::Test);
1013 assert!(config.redaction_enabled);
1014 assert!(config.is_tool_enabled("interact"));
1015 assert!(config.is_tool_enabled("fill"));
1016 assert!(config.is_tool_enabled("recording"));
1017 assert!(!config.is_tool_enabled("eval_js"));
1018 assert!(!config.is_tool_enabled("screenshot"));
1019 assert!(!config.is_tool_enabled("navigate"));
1020 }
1021
1022 #[test]
1023 fn builder_profile_with_extra_disables() {
1024 let builder = VictauriBuilder::new()
1025 .privacy_profile(crate::privacy::PrivacyProfile::Test)
1026 .disable_tools(&["interact"]);
1027 let config = builder.build_privacy_config();
1028 assert!(!config.is_tool_enabled("interact"));
1029 assert!(config.is_tool_enabled("fill"));
1030 }
1031
1032 #[test]
1033 fn builder_bridge_capacities() {
1034 let builder = VictauriBuilder::new()
1035 .console_log_capacity(5000)
1036 .network_log_capacity(2000)
1037 .navigation_log_capacity(500);
1038 assert_eq!(builder.bridge_capacities.console_logs, 5000);
1039 assert_eq!(builder.bridge_capacities.network_log, 2000);
1040 assert_eq!(builder.bridge_capacities.navigation_log, 500);
1041 assert_eq!(builder.bridge_capacities.mutation_log, 500);
1042 assert_eq!(builder.bridge_capacities.dialog_log, 100);
1043 }
1044
1045 #[test]
1046 fn builder_on_ready_sets_callback() {
1047 let builder = VictauriBuilder::new().on_ready(|_port| {});
1048 assert!(builder.on_ready.is_some());
1049 }
1050
1051 #[test]
1052 fn builder_file_navigation_disabled_by_default() {
1053 let builder = VictauriBuilder::new();
1054 assert!(
1055 !builder.allow_file_navigation,
1056 "file navigation should be disabled by default"
1057 );
1058 }
1059
1060 #[test]
1061 fn builder_allow_file_navigation() {
1062 let builder = VictauriBuilder::new().allow_file_navigation();
1063 assert!(builder.allow_file_navigation);
1064 }
1065
1066 #[test]
1067 fn builder_listen_events() {
1068 let builder =
1069 VictauriBuilder::new().listen_events(&["notification-added", "settings-changed"]);
1070 assert_eq!(builder.listen_events.len(), 2);
1071 assert!(
1072 builder
1073 .listen_events
1074 .contains(&"notification-added".to_string())
1075 );
1076 assert!(
1077 builder
1078 .listen_events
1079 .contains(&"settings-changed".to_string())
1080 );
1081 }
1082
1083 #[test]
1084 fn builder_listen_events_empty_by_default() {
1085 let builder = VictauriBuilder::new();
1086 assert!(builder.listen_events.is_empty());
1087 }
1088
1089 #[test]
1090 fn init_script_contains_custom_capacities() {
1091 let caps = js_bridge::BridgeCapacities {
1092 console_logs: 3000,
1093 mutation_log: 750,
1094 network_log: 5000,
1095 navigation_log: 400,
1096 dialog_log: 250,
1097 long_tasks: 200,
1098 };
1099 let script = js_bridge::init_script(&caps);
1100 assert!(script.contains("CAP_CONSOLE = 3000"));
1101 assert!(script.contains("CAP_MUTATION = 750"));
1102 assert!(script.contains("CAP_NETWORK = 5000"));
1103 assert!(script.contains("CAP_NAVIGATION = 400"));
1104 assert!(script.contains("CAP_DIALOG = 250"));
1105 assert!(script.contains("CAP_LONG_TASKS = 200"));
1106 }
1107
1108 #[test]
1109 fn init_script_default_contains_standard_capacities() {
1110 let caps = js_bridge::BridgeCapacities::default();
1111 let script = js_bridge::init_script(&caps);
1112 assert!(script.contains("CAP_CONSOLE = 1000"));
1113 assert!(script.contains("CAP_NETWORK = 1000"));
1114 assert!(script.contains("window.__VICTAURI__"));
1115 }
1116
1117 #[test]
1118 fn builder_validates_defaults() {
1119 let builder = VictauriBuilder::new();
1120 assert!(builder.validate().is_ok());
1121 }
1122
1123 #[test]
1124 fn builder_rejects_zero_port() {
1125 let builder = VictauriBuilder::new().port(0);
1126 let err = builder.validate().unwrap_err();
1127 assert!(matches!(err, BuilderError::InvalidPort { port: 0, .. }));
1128 }
1129
1130 #[test]
1131 fn builder_rejects_zero_event_capacity() {
1132 let builder = VictauriBuilder::new().event_capacity(0);
1133 let err = builder.validate().unwrap_err();
1134 assert!(matches!(
1135 err,
1136 BuilderError::InvalidEventCapacity { capacity: 0, .. }
1137 ));
1138 }
1139
1140 #[test]
1141 fn builder_rejects_excessive_event_capacity() {
1142 let builder = VictauriBuilder::new().event_capacity(2_000_000);
1143 assert!(builder.validate().is_err());
1144 }
1145
1146 #[test]
1147 fn builder_rejects_zero_recorder_capacity() {
1148 let builder = VictauriBuilder::new().recorder_capacity(0);
1149 assert!(builder.validate().is_err());
1150 }
1151
1152 #[test]
1153 fn builder_rejects_zero_eval_timeout() {
1154 let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(0));
1155 assert!(builder.validate().is_err());
1156 }
1157
1158 #[test]
1159 fn builder_rejects_excessive_eval_timeout() {
1160 let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(600));
1161 assert!(builder.validate().is_err());
1162 }
1163
1164 #[test]
1165 fn builder_accepts_edge_values() {
1166 let builder = VictauriBuilder::new()
1167 .port(1)
1168 .event_capacity(1)
1169 .recorder_capacity(1)
1170 .eval_timeout(std::time::Duration::from_secs(1));
1171 assert!(builder.validate().is_ok());
1172
1173 let builder = VictauriBuilder::new()
1174 .port(65535)
1175 .event_capacity(MAX_EVENT_CAPACITY)
1176 .recorder_capacity(MAX_RECORDER_CAPACITY)
1177 .eval_timeout(std::time::Duration::from_secs(MAX_EVAL_TIMEOUT_SECS));
1178 assert!(builder.validate().is_ok());
1179 }
1180}