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