1use std::sync::Arc;
2use std::sync::atomic::Ordering;
3
4use axum::extract::DefaultBodyLimit;
5use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
6use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};
7use tauri::Runtime;
8use tower::limit::ConcurrencyLimitLayer;
9
10use crate::VictauriState;
11use crate::bridge::WebviewBridge;
12
13use super::{MAX_PENDING_EVALS, VictauriMcpHandler};
14
15const DEFAULT_WEBVIEW_LABEL: &str = "main";
16
17pub fn build_app(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> axum::Router {
21 build_app_with_options(state, bridge, None)
22}
23
24pub fn build_app_with_options(
26 state: Arc<VictauriState>,
27 bridge: Arc<dyn WebviewBridge>,
28 auth_token: Option<String>,
29) -> axum::Router {
30 build_app_full(state, bridge, auth_token, None)
31}
32
33pub fn build_app_full(
35 state: Arc<VictauriState>,
36 bridge: Arc<dyn WebviewBridge>,
37 auth_token: Option<String>,
38 rate_limiter: Option<Arc<crate::auth::RateLimiterState>>,
39) -> axum::Router {
40 let tauri_cfg = bridge.tauri_config();
43 let app_identifier = tauri_cfg
44 .get("identifier")
45 .and_then(|v| v.as_str())
46 .map(String::from);
47 let app_product_name = tauri_cfg
48 .get("product_name")
49 .and_then(|v| v.as_str())
50 .map(String::from);
51
52 let handler = VictauriMcpHandler::new(state.clone(), bridge);
53 let rest = super::rest::router(handler.clone());
54
55 let mcp_service = StreamableHttpService::new(
56 move || Ok(handler.clone()),
57 Arc::new(LocalSessionManager::default()),
58 StreamableHttpServerConfig::default(),
59 );
60
61 let auth_state = Arc::new(crate::auth::AuthState {
62 token: auth_token.clone(),
63 });
64 let info_state = state.clone();
65 let info_auth = auth_token.is_some();
66
67 let privacy_enabled = !state.privacy.disabled_tools.is_empty()
68 || state.privacy.command_allowlist.is_some()
69 || !state.privacy.command_blocklist.is_empty()
70 || state.privacy.redaction_enabled;
71
72 let mut router = axum::Router::new()
73 .route_service("/mcp", mcp_service)
74 .nest("/api/tools", rest)
75 .route(
76 "/info",
77 axum::routing::get(move || {
78 let s = info_state.clone();
79 let app_id = app_identifier.clone();
80 let app_name = app_product_name.clone();
81 async move {
82 axum::Json(serde_json::json!({
83 "name": "victauri",
84 "description": "Full-stack Tauri app inspection: webview + IPC + Rust backend + SQLite",
85 "version": env!("CARGO_PKG_VERSION"),
86 "protocol": "mcp",
87 "app_identifier": app_id,
89 "app_product_name": app_name,
90 "capabilities": ["webview", "ipc", "backend", "database", "filesystem"],
91 "commands_registered": s.registry.count(),
92 "events_captured": s.event_log.len(),
93 "port": s.port.load(Ordering::Relaxed),
94 "auth_required": info_auth,
95 "privacy_mode": privacy_enabled,
96 }))
97 }
98 }),
99 );
100
101 if auth_token.is_some() {
102 router = router.layer(axum::middleware::from_fn_with_state(
103 auth_state,
104 crate::auth::require_auth,
105 ));
106 }
107
108 let limiter = rate_limiter.unwrap_or_else(crate::auth::default_rate_limiter);
109 router = router.layer(axum::middleware::from_fn_with_state(
110 limiter,
111 crate::auth::rate_limit,
112 ));
113
114 router
115 .route(
116 "/health",
117 axum::routing::get(|| async { axum::Json(serde_json::json!({"status": "ok"})) }),
118 )
119 .layer(DefaultBodyLimit::max(2 * 1024 * 1024))
120 .layer(ConcurrencyLimitLayer::new(64))
121 .layer(axum::middleware::from_fn(crate::auth::security_headers))
122 .layer(axum::middleware::from_fn(crate::auth::origin_guard))
123 .layer(axum::middleware::from_fn(crate::auth::dns_rebinding_guard))
124}
125
126#[doc(hidden)]
127#[allow(dead_code)]
128pub mod tests_support {
129 #[must_use]
131 pub fn get_memory_stats() -> serde_json::Value {
132 crate::memory::current_stats()
133 }
134}
135
136const PORT_FALLBACK_RANGE: u16 = 10;
137
138pub async fn start_server<R: Runtime>(
145 app_handle: tauri::AppHandle<R>,
146 state: Arc<VictauriState>,
147 port: u16,
148 shutdown_rx: tokio::sync::watch::Receiver<bool>,
149) -> anyhow::Result<()> {
150 start_server_with_options(app_handle, state, port, None, shutdown_rx).await
151}
152
153pub async fn start_server_with_options<R: Runtime>(
160 app_handle: tauri::AppHandle<R>,
161 state: Arc<VictauriState>,
162 port: u16,
163 auth_token: Option<String>,
164 mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
165) -> anyhow::Result<()> {
166 let bridge: Arc<dyn WebviewBridge> = Arc::new(app_handle);
167 let token_for_file = auth_token.clone();
168 let app = build_app_with_options(state.clone(), bridge.clone(), auth_token);
169
170 let (listener, actual_port) = try_bind(port).await?;
171
172 if actual_port != port {
173 tracing::warn!("Victauri: port {port} in use, fell back to {actual_port}");
174 }
175
176 state.port.store(actual_port, Ordering::Relaxed);
177 let cfg = bridge.tauri_config();
178 let app_identifier = cfg.get("identifier").and_then(|v| v.as_str());
179 let app_product_name = cfg.get("product_name").and_then(|v| v.as_str());
180 write_port_file(actual_port, app_identifier, app_product_name);
181 let discovery_token = token_for_file
188 .as_deref()
189 .map_or_else(crate::auth::generate_token, String::from);
190 write_token_file(&discovery_token);
191
192 tracing::info!("Victauri MCP server listening on 127.0.0.1:{actual_port}");
193
194 let drain_state = state.clone();
195 let drain_bridge = bridge;
196 let drain_shutdown = state.shutdown_tx.subscribe();
197 let drain_finished = state.task_tracker.track("event_drain_loop");
198 tokio::spawn(async move {
199 event_drain_loop(drain_state, drain_bridge, drain_shutdown).await;
200 drain_finished.store(true, std::sync::atomic::Ordering::Relaxed);
201 });
202
203 let mut shutdown_rx2 = shutdown_rx.clone();
204 let server = axum::serve(listener, app).with_graceful_shutdown(async move {
205 let _ = shutdown_rx.wait_for(|&v| v).await;
206 remove_port_file();
207 tracing::info!("Victauri MCP server shutting down gracefully");
208 });
209
210 tokio::select! {
211 result = server => {
212 if let Err(e) = result {
213 tracing::error!("Victauri MCP server error: {e}");
214 }
215 }
216 _ = async {
217 let _ = shutdown_rx2.wait_for(|&v| v).await;
218 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
219 } => {
220 tracing::warn!("Victauri MCP server shutdown timeout — forcing exit");
221 }
222 }
223 Ok(())
224}
225
226async fn try_bind(preferred: u16) -> anyhow::Result<(tokio::net::TcpListener, u16)> {
227 if let Ok(listener) = tokio::net::TcpListener::bind(format!("127.0.0.1:{preferred}")).await {
228 return Ok((listener, preferred));
229 }
230
231 for offset in 1..=PORT_FALLBACK_RANGE {
232 let port = preferred + offset;
233 if let Ok(listener) = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}")).await {
234 return Ok((listener, port));
235 }
236 }
237
238 anyhow::bail!(
239 "could not bind to any port in range {preferred}-{}",
240 preferred + PORT_FALLBACK_RANGE
241 )
242}
243
244fn discovery_dir() -> std::path::PathBuf {
245 std::env::temp_dir()
246 .join("victauri")
247 .join(std::process::id().to_string())
248}
249
250#[cfg(windows)]
252fn restrict_to_current_user(path: &std::path::Path) {
253 let Ok(username) = std::env::var("USERNAME") else {
254 return;
255 };
256 let path_str = path.to_string_lossy();
257 let _ = std::process::Command::new("icacls")
258 .args([
259 &*path_str,
260 "/inheritance:r",
261 "/grant:r",
262 &format!("{username}:F"),
263 "/q",
264 ])
265 .stdin(std::process::Stdio::null())
266 .stdout(std::process::Stdio::null())
267 .stderr(std::process::Stdio::null())
268 .status();
269}
270
271fn write_port_file(port: u16, identifier: Option<&str>, product_name: Option<&str>) {
272 let dir = discovery_dir();
273 let _ = std::fs::create_dir_all(&dir);
274 #[cfg(unix)]
275 {
276 use std::os::unix::fs::PermissionsExt;
277 let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
278 }
279 #[cfg(windows)]
280 restrict_to_current_user(&dir);
281
282 let port_path = dir.join("port");
283 if let Err(e) = std::fs::write(&port_path, port.to_string()) {
284 tracing::debug!("could not write port file: {e}");
285 }
286 #[cfg(unix)]
287 {
288 use std::os::unix::fs::PermissionsExt;
289 let _ = std::fs::set_permissions(&port_path, std::fs::Permissions::from_mode(0o600));
290 }
291 let metadata = serde_json::json!({
296 "pid": std::process::id(),
297 "port": port,
298 "identifier": identifier,
299 "product_name": product_name,
300 "started_at": chrono::Utc::now().to_rfc3339(),
301 "version": env!("CARGO_PKG_VERSION"),
302 });
303 let meta_path = dir.join("metadata.json");
304 let _ = std::fs::write(&meta_path, metadata.to_string());
305 #[cfg(unix)]
306 {
307 use std::os::unix::fs::PermissionsExt;
308 let _ = std::fs::set_permissions(&meta_path, std::fs::Permissions::from_mode(0o600));
309 }
310}
311
312fn write_token_file(token: &str) {
313 let dir = discovery_dir();
314 let _ = std::fs::create_dir_all(&dir);
315 #[cfg(unix)]
316 {
317 use std::os::unix::fs::PermissionsExt;
318 let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
319 }
320 #[cfg(windows)]
321 restrict_to_current_user(&dir);
322
323 let token_path = dir.join("token");
324 if let Err(e) = std::fs::write(&token_path, token) {
325 tracing::debug!("could not write token file: {e}");
326 }
327 #[cfg(unix)]
328 {
329 use std::os::unix::fs::PermissionsExt;
330 let _ = std::fs::set_permissions(&token_path, std::fs::Permissions::from_mode(0o600));
331 }
332 #[cfg(windows)]
333 restrict_to_current_user(&token_path);
334}
335
336fn remove_port_file() {
337 let _ = std::fs::remove_dir_all(discovery_dir());
338}
339
340#[must_use]
344pub fn parse_bridge_event(ev: &serde_json::Value) -> Option<victauri_core::AppEvent> {
345 use chrono::Utc;
346 use victauri_core::AppEvent;
347
348 let event_type = ev.get("type").and_then(|t| t.as_str()).unwrap_or("");
349 let now = Utc::now();
350
351 let app_event = match event_type {
352 "console" => AppEvent::Console {
353 level: ev
354 .get("level")
355 .and_then(|l| l.as_str())
356 .unwrap_or("log")
357 .to_string(),
358 message: ev
359 .get("message")
360 .and_then(|m| m.as_str())
361 .unwrap_or("")
362 .to_string(),
363 timestamp: now,
364 },
365 "dom_mutation" => AppEvent::DomMutation {
366 webview_label: DEFAULT_WEBVIEW_LABEL.to_string(),
367 timestamp: now,
368 mutation_count: ev
369 .get("count")
370 .and_then(serde_json::Value::as_u64)
371 .unwrap_or(0) as u32,
372 },
373 "ipc" => {
374 let cmd = ev
375 .get("command")
376 .and_then(|c| c.as_str())
377 .unwrap_or("unknown");
378 AppEvent::Ipc(victauri_core::IpcCall {
379 id: uuid::Uuid::new_v4().to_string(),
380 command: cmd.to_string(),
381 timestamp: now,
382 result: match ev.get("status").and_then(|s| s.as_str()) {
383 Some("ok") => victauri_core::IpcResult::Ok(serde_json::Value::Null),
384 Some("error") => victauri_core::IpcResult::Err("error".to_string()),
385 _ => victauri_core::IpcResult::Pending,
386 },
387 duration_ms: ev
388 .get("duration_ms")
389 .and_then(serde_json::Value::as_f64)
390 .map(|d| d as u64),
391 arg_size_bytes: 0,
392 webview_label: DEFAULT_WEBVIEW_LABEL.to_string(),
393 })
394 }
395 "network" => AppEvent::StateChange {
396 key: format!(
397 "network.{}",
398 ev.get("method").and_then(|m| m.as_str()).unwrap_or("GET")
399 ),
400 timestamp: now,
401 caused_by: ev
402 .get("url")
403 .and_then(|u| u.as_str())
404 .map(std::string::ToString::to_string),
405 },
406 "navigation" => AppEvent::WindowEvent {
407 label: DEFAULT_WEBVIEW_LABEL.to_string(),
408 event: format!(
409 "navigation.{}",
410 ev.get("nav_type")
411 .and_then(|n| n.as_str())
412 .unwrap_or("unknown")
413 ),
414 timestamp: now,
415 },
416 "dom_interaction" => {
417 let action_str = ev.get("action").and_then(|a| a.as_str()).unwrap_or("click");
418 let action = match action_str {
419 "click" => victauri_core::InteractionKind::Click,
420 "double_click" => victauri_core::InteractionKind::DoubleClick,
421 "fill" => victauri_core::InteractionKind::Fill,
422 "key_press" => victauri_core::InteractionKind::KeyPress,
423 "select" => victauri_core::InteractionKind::Select,
424 "navigate" => victauri_core::InteractionKind::Navigate,
425 "scroll" => victauri_core::InteractionKind::Scroll,
426 _ => victauri_core::InteractionKind::Click,
427 };
428 AppEvent::DomInteraction {
429 action,
430 selector: ev
431 .get("selector")
432 .and_then(|s| s.as_str())
433 .unwrap_or("body")
434 .to_string(),
435 value: ev
436 .get("value")
437 .and_then(|v| v.as_str())
438 .map(std::string::ToString::to_string),
439 timestamp: now,
440 webview_label: DEFAULT_WEBVIEW_LABEL.to_string(),
441 }
442 }
443 _ => return None,
444 };
445
446 Some(app_event)
447}
448
449async fn event_drain_loop(
450 state: Arc<VictauriState>,
451 bridge: Arc<dyn WebviewBridge>,
452 mut shutdown: tokio::sync::watch::Receiver<bool>,
453) {
454 let mut last_drain_ts: f64 = 0.0;
455
456 loop {
457 tokio::select! {
458 _ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {}
459 _ = shutdown.changed() => break,
460 }
461
462 let code = format!("return window.__VICTAURI__?.getEventStream({last_drain_ts})");
463 let id = uuid::Uuid::new_v4().to_string();
464 let (tx, rx) = tokio::sync::oneshot::channel();
465
466 {
467 let mut pending = state.pending_evals.lock().await;
468 if pending.len() >= MAX_PENDING_EVALS {
469 continue;
470 }
471 pending.insert(id.clone(), tx);
472 }
473
474 let id_js = super::helpers::js_string(&id);
475 let inject = format!(
476 r"
477 (async () => {{
478 try {{
479 const __result = await (async () => {{ {code} }})();
480 await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
481 id: {id_js},
482 result: JSON.stringify(__result)
483 }});
484 }} catch (e) {{
485 await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
486 id: {id_js},
487 result: JSON.stringify({{ __error: e.message }})
488 }});
489 }}
490 }})();
491 "
492 );
493
494 if bridge.eval_webview(None, &inject).is_err() {
495 state.pending_evals.lock().await.remove(&id);
496 continue;
497 }
498
499 let Ok(Ok(result)) = tokio::time::timeout(std::time::Duration::from_secs(5), rx).await
500 else {
501 state.pending_evals.lock().await.remove(&id);
502 continue;
503 };
504
505 let events: Vec<serde_json::Value> = match serde_json::from_str(&result) {
506 Ok(v) => v,
507 Err(_) => continue,
508 };
509
510 for ev in &events {
511 let ts = ev
512 .get("timestamp")
513 .and_then(serde_json::Value::as_f64)
514 .unwrap_or(0.0);
515 if ts > last_drain_ts {
516 last_drain_ts = ts;
517 }
518
519 if let Some(app_event) = parse_bridge_event(ev) {
520 state.event_log.push(app_event.clone());
521 if state.recorder.is_recording() {
522 state.recorder.record_event(app_event);
523 }
524 }
525 }
526 }
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532 use victauri_core::{AppEvent, InteractionKind, IpcResult};
533
534 #[tokio::test]
535 async fn try_bind_preferred_port_available() {
536 let (listener, port) = try_bind(0).await.unwrap();
537 let addr = listener.local_addr().unwrap();
538 assert_eq!(port, 0);
539 assert_ne!(addr.port(), 0); }
541
542 #[tokio::test]
543 async fn try_bind_falls_back_when_taken() {
544 let blocker = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
545 let blocked_port = blocker.local_addr().unwrap().port();
546
547 let (_, actual) = try_bind(blocked_port).await.unwrap();
548 assert_ne!(actual, blocked_port);
549 assert!(actual > blocked_port);
550 assert!(actual <= blocked_port + PORT_FALLBACK_RANGE);
551 }
552
553 #[test]
554 fn port_file_roundtrip() {
555 write_port_file(7777, Some("com.example.app"), Some("Example"));
556 let dir = discovery_dir();
557 let content = std::fs::read_to_string(dir.join("port")).unwrap();
558 assert_eq!(content, "7777");
559 let meta: serde_json::Value =
561 serde_json::from_str(&std::fs::read_to_string(dir.join("metadata.json")).unwrap())
562 .unwrap();
563 assert_eq!(meta["port"], 7777);
564 assert_eq!(meta["pid"], std::process::id());
565 assert_eq!(meta["identifier"], "com.example.app");
567 assert_eq!(meta["product_name"], "Example");
568 remove_port_file();
569 assert!(!dir.exists());
570 }
571
572 #[test]
575 fn parse_dom_interaction_click() {
576 let ev = serde_json::json!({
577 "type": "dom_interaction",
578 "action": "click",
579 "selector": "#submit-btn",
580 });
581 let result = parse_bridge_event(&ev).expect("should produce an event");
582 match result {
583 AppEvent::DomInteraction {
584 action,
585 selector,
586 value,
587 webview_label,
588 ..
589 } => {
590 assert_eq!(action, InteractionKind::Click);
591 assert_eq!(selector, "#submit-btn");
592 assert!(value.is_none());
593 assert_eq!(webview_label, "main");
594 }
595 other => panic!("expected DomInteraction, got {other:?}"),
596 }
597 }
598
599 #[test]
600 fn parse_dom_interaction_fill_with_value() {
601 let ev = serde_json::json!({
602 "type": "dom_interaction",
603 "action": "fill",
604 "selector": "input[name=email]",
605 "value": "test@example.com",
606 });
607 let result = parse_bridge_event(&ev).expect("should produce an event");
608 match result {
609 AppEvent::DomInteraction {
610 action,
611 selector,
612 value,
613 ..
614 } => {
615 assert_eq!(action, InteractionKind::Fill);
616 assert_eq!(selector, "input[name=email]");
617 assert_eq!(value.as_deref(), Some("test@example.com"));
618 }
619 other => panic!("expected DomInteraction, got {other:?}"),
620 }
621 }
622
623 #[test]
624 fn parse_dom_interaction_key_press() {
625 let ev = serde_json::json!({
626 "type": "dom_interaction",
627 "action": "key_press",
628 "selector": "body",
629 "value": "Enter",
630 });
631 let result = parse_bridge_event(&ev).expect("should produce an event");
632 match result {
633 AppEvent::DomInteraction { action, value, .. } => {
634 assert_eq!(action, InteractionKind::KeyPress);
635 assert_eq!(value.as_deref(), Some("Enter"));
636 }
637 other => panic!("expected DomInteraction, got {other:?}"),
638 }
639 }
640
641 #[test]
642 fn parse_dom_interaction_unknown_action_defaults_to_click() {
643 let ev = serde_json::json!({
644 "type": "dom_interaction",
645 "action": "swipe_left",
646 "selector": ".card",
647 });
648 let result = parse_bridge_event(&ev).expect("should produce an event");
649 match result {
650 AppEvent::DomInteraction { action, .. } => {
651 assert_eq!(action, InteractionKind::Click);
652 }
653 other => panic!("expected DomInteraction, got {other:?}"),
654 }
655 }
656
657 #[test]
658 fn parse_dom_interaction_missing_action_defaults_to_click() {
659 let ev = serde_json::json!({
660 "type": "dom_interaction",
661 "selector": "button",
662 });
663 let result = parse_bridge_event(&ev).expect("should produce an event");
664 match result {
665 AppEvent::DomInteraction { action, .. } => {
666 assert_eq!(action, InteractionKind::Click);
667 }
668 other => panic!("expected DomInteraction, got {other:?}"),
669 }
670 }
671
672 #[test]
673 fn parse_dom_interaction_missing_selector_defaults_to_body() {
674 let ev = serde_json::json!({
675 "type": "dom_interaction",
676 "action": "scroll",
677 });
678 let result = parse_bridge_event(&ev).expect("should produce an event");
679 match result {
680 AppEvent::DomInteraction {
681 action, selector, ..
682 } => {
683 assert_eq!(action, InteractionKind::Scroll);
684 assert_eq!(selector, "body");
685 }
686 other => panic!("expected DomInteraction, got {other:?}"),
687 }
688 }
689
690 #[test]
691 fn parse_dom_interaction_all_action_kinds() {
692 let cases = [
693 ("click", InteractionKind::Click),
694 ("double_click", InteractionKind::DoubleClick),
695 ("fill", InteractionKind::Fill),
696 ("key_press", InteractionKind::KeyPress),
697 ("select", InteractionKind::Select),
698 ("navigate", InteractionKind::Navigate),
699 ("scroll", InteractionKind::Scroll),
700 ];
701 for (action_str, expected_kind) in cases {
702 let ev = serde_json::json!({
703 "type": "dom_interaction",
704 "action": action_str,
705 "selector": "body",
706 });
707 let result = parse_bridge_event(&ev)
708 .unwrap_or_else(|| panic!("should produce event for action {action_str}"));
709 match result {
710 AppEvent::DomInteraction { action, .. } => {
711 assert_eq!(action, expected_kind, "mismatch for action {action_str}");
712 }
713 other => panic!("expected DomInteraction for {action_str}, got {other:?}"),
714 }
715 }
716 }
717
718 #[test]
721 fn parse_ipc_status_ok() {
722 let ev = serde_json::json!({
723 "type": "ipc",
724 "command": "greet",
725 "status": "ok",
726 "duration_ms": 42.0,
727 });
728 let result = parse_bridge_event(&ev).expect("should produce an event");
729 match result {
730 AppEvent::Ipc(call) => {
731 assert_eq!(call.command, "greet");
732 assert_eq!(call.result, IpcResult::Ok(serde_json::Value::Null));
733 assert_eq!(call.duration_ms, Some(42));
734 assert_eq!(call.webview_label, "main");
735 }
736 other => panic!("expected Ipc, got {other:?}"),
737 }
738 }
739
740 #[test]
741 fn parse_ipc_status_error() {
742 let ev = serde_json::json!({
743 "type": "ipc",
744 "command": "save_file",
745 "status": "error",
746 });
747 let result = parse_bridge_event(&ev).expect("should produce an event");
748 match result {
749 AppEvent::Ipc(call) => {
750 assert_eq!(call.command, "save_file");
751 assert_eq!(call.result, IpcResult::Err("error".to_string()));
752 }
753 other => panic!("expected Ipc, got {other:?}"),
754 }
755 }
756
757 #[test]
758 fn parse_ipc_status_pending() {
759 let ev = serde_json::json!({
760 "type": "ipc",
761 "command": "long_task",
762 });
763 let result = parse_bridge_event(&ev).expect("should produce an event");
764 match result {
765 AppEvent::Ipc(call) => {
766 assert_eq!(call.result, IpcResult::Pending);
767 assert!(call.duration_ms.is_none());
768 }
769 other => panic!("expected Ipc, got {other:?}"),
770 }
771 }
772
773 #[test]
776 fn parse_console_event() {
777 let ev = serde_json::json!({
778 "type": "console",
779 "level": "warn",
780 "message": "deprecated API usage",
781 });
782 let result = parse_bridge_event(&ev).expect("should produce an event");
783 match result {
784 AppEvent::Console { level, message, .. } => {
785 assert_eq!(level, "warn");
786 assert_eq!(message, "deprecated API usage");
787 }
788 other => panic!("expected Console, got {other:?}"),
789 }
790 }
791
792 #[test]
793 fn parse_console_default_level() {
794 let ev = serde_json::json!({
795 "type": "console",
796 "message": "hello",
797 });
798 let result = parse_bridge_event(&ev).expect("should produce an event");
799 match result {
800 AppEvent::Console { level, message, .. } => {
801 assert_eq!(level, "log");
802 assert_eq!(message, "hello");
803 }
804 other => panic!("expected Console, got {other:?}"),
805 }
806 }
807
808 #[test]
811 fn parse_navigation_event() {
812 let ev = serde_json::json!({
813 "type": "navigation",
814 "nav_type": "push",
815 });
816 let result = parse_bridge_event(&ev).expect("should produce an event");
817 match result {
818 AppEvent::WindowEvent { label, event, .. } => {
819 assert_eq!(label, "main");
820 assert_eq!(event, "navigation.push");
821 }
822 other => panic!("expected WindowEvent, got {other:?}"),
823 }
824 }
825
826 #[test]
827 fn parse_navigation_default_nav_type() {
828 let ev = serde_json::json!({ "type": "navigation" });
829 let result = parse_bridge_event(&ev).expect("should produce an event");
830 match result {
831 AppEvent::WindowEvent { event, .. } => {
832 assert_eq!(event, "navigation.unknown");
833 }
834 other => panic!("expected WindowEvent, got {other:?}"),
835 }
836 }
837
838 #[test]
841 fn parse_dom_mutation_event() {
842 let ev = serde_json::json!({
843 "type": "dom_mutation",
844 "count": 15,
845 });
846 let result = parse_bridge_event(&ev).expect("should produce an event");
847 match result {
848 AppEvent::DomMutation {
849 webview_label,
850 mutation_count,
851 ..
852 } => {
853 assert_eq!(webview_label, "main");
854 assert_eq!(mutation_count, 15);
855 }
856 other => panic!("expected DomMutation, got {other:?}"),
857 }
858 }
859
860 #[test]
863 fn parse_network_event() {
864 let ev = serde_json::json!({
865 "type": "network",
866 "method": "POST",
867 "url": "https://api.example.com/data",
868 });
869 let result = parse_bridge_event(&ev).expect("should produce an event");
870 match result {
871 AppEvent::StateChange { key, caused_by, .. } => {
872 assert_eq!(key, "network.POST");
873 assert_eq!(caused_by.as_deref(), Some("https://api.example.com/data"));
874 }
875 other => panic!("expected StateChange, got {other:?}"),
876 }
877 }
878
879 #[test]
882 fn parse_unknown_type_returns_none() {
883 let ev = serde_json::json!({
884 "type": "custom_telemetry",
885 "payload": 42,
886 });
887 assert!(parse_bridge_event(&ev).is_none());
888 }
889
890 #[test]
891 fn parse_missing_type_field_returns_none() {
892 let ev = serde_json::json!({ "data": "no type here" });
893 assert!(parse_bridge_event(&ev).is_none());
894 }
895
896 #[test]
897 fn parse_empty_object_returns_none() {
898 let ev = serde_json::json!({});
899 assert!(parse_bridge_event(&ev).is_none());
900 }
901}