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