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 let discovery_token = token_for_file
168 .as_deref()
169 .map_or_else(crate::auth::generate_token, String::from);
170 write_token_file(&discovery_token);
171
172 tracing::info!("Victauri MCP server listening on 127.0.0.1:{actual_port}");
173
174 let drain_state = state.clone();
175 let drain_bridge = bridge;
176 let drain_shutdown = state.shutdown_tx.subscribe();
177 let drain_finished = state.task_tracker.track("event_drain_loop");
178 tokio::spawn(async move {
179 event_drain_loop(drain_state, drain_bridge, drain_shutdown).await;
180 drain_finished.store(true, std::sync::atomic::Ordering::Relaxed);
181 });
182
183 let mut shutdown_rx2 = shutdown_rx.clone();
184 let server = axum::serve(listener, app).with_graceful_shutdown(async move {
185 let _ = shutdown_rx.wait_for(|&v| v).await;
186 remove_port_file();
187 tracing::info!("Victauri MCP server shutting down gracefully");
188 });
189
190 tokio::select! {
191 result = server => {
192 if let Err(e) = result {
193 tracing::error!("Victauri MCP server error: {e}");
194 }
195 }
196 _ = async {
197 let _ = shutdown_rx2.wait_for(|&v| v).await;
198 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
199 } => {
200 tracing::warn!("Victauri MCP server shutdown timeout — forcing exit");
201 }
202 }
203 Ok(())
204}
205
206async fn try_bind(preferred: u16) -> anyhow::Result<(tokio::net::TcpListener, u16)> {
207 if let Ok(listener) = tokio::net::TcpListener::bind(format!("127.0.0.1:{preferred}")).await {
208 return Ok((listener, preferred));
209 }
210
211 for offset in 1..=PORT_FALLBACK_RANGE {
212 let port = preferred + offset;
213 if let Ok(listener) = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}")).await {
214 return Ok((listener, port));
215 }
216 }
217
218 anyhow::bail!(
219 "could not bind to any port in range {preferred}-{}",
220 preferred + PORT_FALLBACK_RANGE
221 )
222}
223
224fn discovery_dir() -> std::path::PathBuf {
225 std::env::temp_dir()
226 .join("victauri")
227 .join(std::process::id().to_string())
228}
229
230#[cfg(windows)]
232fn restrict_to_current_user(path: &std::path::Path) {
233 let Ok(username) = std::env::var("USERNAME") else {
234 return;
235 };
236 let path_str = path.to_string_lossy();
237 let _ = std::process::Command::new("icacls")
238 .args([
239 &*path_str,
240 "/inheritance:r",
241 "/grant:r",
242 &format!("{username}:F"),
243 "/q",
244 ])
245 .stdin(std::process::Stdio::null())
246 .stdout(std::process::Stdio::null())
247 .stderr(std::process::Stdio::null())
248 .status();
249}
250
251fn write_port_file(port: u16) {
252 let dir = discovery_dir();
253 let _ = std::fs::create_dir_all(&dir);
254 #[cfg(unix)]
255 {
256 use std::os::unix::fs::PermissionsExt;
257 let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
258 }
259 #[cfg(windows)]
260 restrict_to_current_user(&dir);
261
262 let port_path = dir.join("port");
263 if let Err(e) = std::fs::write(&port_path, port.to_string()) {
264 tracing::debug!("could not write port file: {e}");
265 }
266 #[cfg(unix)]
267 {
268 use std::os::unix::fs::PermissionsExt;
269 let _ = std::fs::set_permissions(&port_path, std::fs::Permissions::from_mode(0o600));
270 }
271 let metadata = serde_json::json!({
273 "pid": std::process::id(),
274 "port": port,
275 "started_at": chrono::Utc::now().to_rfc3339(),
276 "version": env!("CARGO_PKG_VERSION"),
277 });
278 let meta_path = dir.join("metadata.json");
279 let _ = std::fs::write(&meta_path, metadata.to_string());
280 #[cfg(unix)]
281 {
282 use std::os::unix::fs::PermissionsExt;
283 let _ = std::fs::set_permissions(&meta_path, std::fs::Permissions::from_mode(0o600));
284 }
285}
286
287fn write_token_file(token: &str) {
288 let dir = discovery_dir();
289 let _ = std::fs::create_dir_all(&dir);
290 #[cfg(unix)]
291 {
292 use std::os::unix::fs::PermissionsExt;
293 let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
294 }
295 #[cfg(windows)]
296 restrict_to_current_user(&dir);
297
298 let token_path = dir.join("token");
299 if let Err(e) = std::fs::write(&token_path, token) {
300 tracing::debug!("could not write token file: {e}");
301 }
302 #[cfg(unix)]
303 {
304 use std::os::unix::fs::PermissionsExt;
305 let _ = std::fs::set_permissions(&token_path, std::fs::Permissions::from_mode(0o600));
306 }
307 #[cfg(windows)]
308 restrict_to_current_user(&token_path);
309}
310
311fn remove_port_file() {
312 let _ = std::fs::remove_dir_all(discovery_dir());
313}
314
315#[must_use]
319pub fn parse_bridge_event(ev: &serde_json::Value) -> Option<victauri_core::AppEvent> {
320 use chrono::Utc;
321 use victauri_core::AppEvent;
322
323 let event_type = ev.get("type").and_then(|t| t.as_str()).unwrap_or("");
324 let now = Utc::now();
325
326 let app_event = match event_type {
327 "console" => AppEvent::Console {
328 level: ev
329 .get("level")
330 .and_then(|l| l.as_str())
331 .unwrap_or("log")
332 .to_string(),
333 message: ev
334 .get("message")
335 .and_then(|m| m.as_str())
336 .unwrap_or("")
337 .to_string(),
338 timestamp: now,
339 },
340 "dom_mutation" => AppEvent::DomMutation {
341 webview_label: DEFAULT_WEBVIEW_LABEL.to_string(),
342 timestamp: now,
343 mutation_count: ev
344 .get("count")
345 .and_then(serde_json::Value::as_u64)
346 .unwrap_or(0) as u32,
347 },
348 "ipc" => {
349 let cmd = ev
350 .get("command")
351 .and_then(|c| c.as_str())
352 .unwrap_or("unknown");
353 AppEvent::Ipc(victauri_core::IpcCall {
354 id: uuid::Uuid::new_v4().to_string(),
355 command: cmd.to_string(),
356 timestamp: now,
357 result: match ev.get("status").and_then(|s| s.as_str()) {
358 Some("ok") => victauri_core::IpcResult::Ok(serde_json::Value::Null),
359 Some("error") => victauri_core::IpcResult::Err("error".to_string()),
360 _ => victauri_core::IpcResult::Pending,
361 },
362 duration_ms: ev
363 .get("duration_ms")
364 .and_then(serde_json::Value::as_f64)
365 .map(|d| d as u64),
366 arg_size_bytes: 0,
367 webview_label: DEFAULT_WEBVIEW_LABEL.to_string(),
368 })
369 }
370 "network" => AppEvent::StateChange {
371 key: format!(
372 "network.{}",
373 ev.get("method").and_then(|m| m.as_str()).unwrap_or("GET")
374 ),
375 timestamp: now,
376 caused_by: ev
377 .get("url")
378 .and_then(|u| u.as_str())
379 .map(std::string::ToString::to_string),
380 },
381 "navigation" => AppEvent::WindowEvent {
382 label: DEFAULT_WEBVIEW_LABEL.to_string(),
383 event: format!(
384 "navigation.{}",
385 ev.get("nav_type")
386 .and_then(|n| n.as_str())
387 .unwrap_or("unknown")
388 ),
389 timestamp: now,
390 },
391 "dom_interaction" => {
392 let action_str = ev.get("action").and_then(|a| a.as_str()).unwrap_or("click");
393 let action = match action_str {
394 "click" => victauri_core::InteractionKind::Click,
395 "double_click" => victauri_core::InteractionKind::DoubleClick,
396 "fill" => victauri_core::InteractionKind::Fill,
397 "key_press" => victauri_core::InteractionKind::KeyPress,
398 "select" => victauri_core::InteractionKind::Select,
399 "navigate" => victauri_core::InteractionKind::Navigate,
400 "scroll" => victauri_core::InteractionKind::Scroll,
401 _ => victauri_core::InteractionKind::Click,
402 };
403 AppEvent::DomInteraction {
404 action,
405 selector: ev
406 .get("selector")
407 .and_then(|s| s.as_str())
408 .unwrap_or("body")
409 .to_string(),
410 value: ev
411 .get("value")
412 .and_then(|v| v.as_str())
413 .map(std::string::ToString::to_string),
414 timestamp: now,
415 webview_label: DEFAULT_WEBVIEW_LABEL.to_string(),
416 }
417 }
418 _ => return None,
419 };
420
421 Some(app_event)
422}
423
424async fn event_drain_loop(
425 state: Arc<VictauriState>,
426 bridge: Arc<dyn WebviewBridge>,
427 mut shutdown: tokio::sync::watch::Receiver<bool>,
428) {
429 let mut last_drain_ts: f64 = 0.0;
430
431 loop {
432 tokio::select! {
433 _ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {}
434 _ = shutdown.changed() => break,
435 }
436
437 let code = format!("return window.__VICTAURI__?.getEventStream({last_drain_ts})");
438 let id = uuid::Uuid::new_v4().to_string();
439 let (tx, rx) = tokio::sync::oneshot::channel();
440
441 {
442 let mut pending = state.pending_evals.lock().await;
443 if pending.len() >= MAX_PENDING_EVALS {
444 continue;
445 }
446 pending.insert(id.clone(), tx);
447 }
448
449 let id_js = super::helpers::js_string(&id);
450 let inject = format!(
451 r"
452 (async () => {{
453 try {{
454 const __result = await (async () => {{ {code} }})();
455 await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
456 id: {id_js},
457 result: JSON.stringify(__result)
458 }});
459 }} catch (e) {{
460 await window.__TAURI_INTERNALS__.invoke('plugin:victauri|victauri_eval_callback', {{
461 id: {id_js},
462 result: JSON.stringify({{ __error: e.message }})
463 }});
464 }}
465 }})();
466 "
467 );
468
469 if bridge.eval_webview(None, &inject).is_err() {
470 state.pending_evals.lock().await.remove(&id);
471 continue;
472 }
473
474 let Ok(Ok(result)) = tokio::time::timeout(std::time::Duration::from_secs(5), rx).await
475 else {
476 state.pending_evals.lock().await.remove(&id);
477 continue;
478 };
479
480 let events: Vec<serde_json::Value> = match serde_json::from_str(&result) {
481 Ok(v) => v,
482 Err(_) => continue,
483 };
484
485 for ev in &events {
486 let ts = ev
487 .get("timestamp")
488 .and_then(serde_json::Value::as_f64)
489 .unwrap_or(0.0);
490 if ts > last_drain_ts {
491 last_drain_ts = ts;
492 }
493
494 if let Some(app_event) = parse_bridge_event(ev) {
495 state.event_log.push(app_event.clone());
496 if state.recorder.is_recording() {
497 state.recorder.record_event(app_event);
498 }
499 }
500 }
501 }
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507 use victauri_core::{AppEvent, InteractionKind, IpcResult};
508
509 #[tokio::test]
510 async fn try_bind_preferred_port_available() {
511 let (listener, port) = try_bind(0).await.unwrap();
512 let addr = listener.local_addr().unwrap();
513 assert_eq!(port, 0);
514 assert_ne!(addr.port(), 0); }
516
517 #[tokio::test]
518 async fn try_bind_falls_back_when_taken() {
519 let blocker = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
520 let blocked_port = blocker.local_addr().unwrap().port();
521
522 let (_, actual) = try_bind(blocked_port).await.unwrap();
523 assert_ne!(actual, blocked_port);
524 assert!(actual > blocked_port);
525 assert!(actual <= blocked_port + PORT_FALLBACK_RANGE);
526 }
527
528 #[test]
529 fn port_file_roundtrip() {
530 write_port_file(7777);
531 let dir = discovery_dir();
532 let content = std::fs::read_to_string(dir.join("port")).unwrap();
533 assert_eq!(content, "7777");
534 let meta: serde_json::Value =
536 serde_json::from_str(&std::fs::read_to_string(dir.join("metadata.json")).unwrap())
537 .unwrap();
538 assert_eq!(meta["port"], 7777);
539 assert_eq!(meta["pid"], std::process::id());
540 remove_port_file();
541 assert!(!dir.exists());
542 }
543
544 #[test]
547 fn parse_dom_interaction_click() {
548 let ev = serde_json::json!({
549 "type": "dom_interaction",
550 "action": "click",
551 "selector": "#submit-btn",
552 });
553 let result = parse_bridge_event(&ev).expect("should produce an event");
554 match result {
555 AppEvent::DomInteraction {
556 action,
557 selector,
558 value,
559 webview_label,
560 ..
561 } => {
562 assert_eq!(action, InteractionKind::Click);
563 assert_eq!(selector, "#submit-btn");
564 assert!(value.is_none());
565 assert_eq!(webview_label, "main");
566 }
567 other => panic!("expected DomInteraction, got {other:?}"),
568 }
569 }
570
571 #[test]
572 fn parse_dom_interaction_fill_with_value() {
573 let ev = serde_json::json!({
574 "type": "dom_interaction",
575 "action": "fill",
576 "selector": "input[name=email]",
577 "value": "test@example.com",
578 });
579 let result = parse_bridge_event(&ev).expect("should produce an event");
580 match result {
581 AppEvent::DomInteraction {
582 action,
583 selector,
584 value,
585 ..
586 } => {
587 assert_eq!(action, InteractionKind::Fill);
588 assert_eq!(selector, "input[name=email]");
589 assert_eq!(value.as_deref(), Some("test@example.com"));
590 }
591 other => panic!("expected DomInteraction, got {other:?}"),
592 }
593 }
594
595 #[test]
596 fn parse_dom_interaction_key_press() {
597 let ev = serde_json::json!({
598 "type": "dom_interaction",
599 "action": "key_press",
600 "selector": "body",
601 "value": "Enter",
602 });
603 let result = parse_bridge_event(&ev).expect("should produce an event");
604 match result {
605 AppEvent::DomInteraction { action, value, .. } => {
606 assert_eq!(action, InteractionKind::KeyPress);
607 assert_eq!(value.as_deref(), Some("Enter"));
608 }
609 other => panic!("expected DomInteraction, got {other:?}"),
610 }
611 }
612
613 #[test]
614 fn parse_dom_interaction_unknown_action_defaults_to_click() {
615 let ev = serde_json::json!({
616 "type": "dom_interaction",
617 "action": "swipe_left",
618 "selector": ".card",
619 });
620 let result = parse_bridge_event(&ev).expect("should produce an event");
621 match result {
622 AppEvent::DomInteraction { action, .. } => {
623 assert_eq!(action, InteractionKind::Click);
624 }
625 other => panic!("expected DomInteraction, got {other:?}"),
626 }
627 }
628
629 #[test]
630 fn parse_dom_interaction_missing_action_defaults_to_click() {
631 let ev = serde_json::json!({
632 "type": "dom_interaction",
633 "selector": "button",
634 });
635 let result = parse_bridge_event(&ev).expect("should produce an event");
636 match result {
637 AppEvent::DomInteraction { action, .. } => {
638 assert_eq!(action, InteractionKind::Click);
639 }
640 other => panic!("expected DomInteraction, got {other:?}"),
641 }
642 }
643
644 #[test]
645 fn parse_dom_interaction_missing_selector_defaults_to_body() {
646 let ev = serde_json::json!({
647 "type": "dom_interaction",
648 "action": "scroll",
649 });
650 let result = parse_bridge_event(&ev).expect("should produce an event");
651 match result {
652 AppEvent::DomInteraction {
653 action, selector, ..
654 } => {
655 assert_eq!(action, InteractionKind::Scroll);
656 assert_eq!(selector, "body");
657 }
658 other => panic!("expected DomInteraction, got {other:?}"),
659 }
660 }
661
662 #[test]
663 fn parse_dom_interaction_all_action_kinds() {
664 let cases = [
665 ("click", InteractionKind::Click),
666 ("double_click", InteractionKind::DoubleClick),
667 ("fill", InteractionKind::Fill),
668 ("key_press", InteractionKind::KeyPress),
669 ("select", InteractionKind::Select),
670 ("navigate", InteractionKind::Navigate),
671 ("scroll", InteractionKind::Scroll),
672 ];
673 for (action_str, expected_kind) in cases {
674 let ev = serde_json::json!({
675 "type": "dom_interaction",
676 "action": action_str,
677 "selector": "body",
678 });
679 let result = parse_bridge_event(&ev)
680 .unwrap_or_else(|| panic!("should produce event for action {action_str}"));
681 match result {
682 AppEvent::DomInteraction { action, .. } => {
683 assert_eq!(action, expected_kind, "mismatch for action {action_str}");
684 }
685 other => panic!("expected DomInteraction for {action_str}, got {other:?}"),
686 }
687 }
688 }
689
690 #[test]
693 fn parse_ipc_status_ok() {
694 let ev = serde_json::json!({
695 "type": "ipc",
696 "command": "greet",
697 "status": "ok",
698 "duration_ms": 42.0,
699 });
700 let result = parse_bridge_event(&ev).expect("should produce an event");
701 match result {
702 AppEvent::Ipc(call) => {
703 assert_eq!(call.command, "greet");
704 assert_eq!(call.result, IpcResult::Ok(serde_json::Value::Null));
705 assert_eq!(call.duration_ms, Some(42));
706 assert_eq!(call.webview_label, "main");
707 }
708 other => panic!("expected Ipc, got {other:?}"),
709 }
710 }
711
712 #[test]
713 fn parse_ipc_status_error() {
714 let ev = serde_json::json!({
715 "type": "ipc",
716 "command": "save_file",
717 "status": "error",
718 });
719 let result = parse_bridge_event(&ev).expect("should produce an event");
720 match result {
721 AppEvent::Ipc(call) => {
722 assert_eq!(call.command, "save_file");
723 assert_eq!(call.result, IpcResult::Err("error".to_string()));
724 }
725 other => panic!("expected Ipc, got {other:?}"),
726 }
727 }
728
729 #[test]
730 fn parse_ipc_status_pending() {
731 let ev = serde_json::json!({
732 "type": "ipc",
733 "command": "long_task",
734 });
735 let result = parse_bridge_event(&ev).expect("should produce an event");
736 match result {
737 AppEvent::Ipc(call) => {
738 assert_eq!(call.result, IpcResult::Pending);
739 assert!(call.duration_ms.is_none());
740 }
741 other => panic!("expected Ipc, got {other:?}"),
742 }
743 }
744
745 #[test]
748 fn parse_console_event() {
749 let ev = serde_json::json!({
750 "type": "console",
751 "level": "warn",
752 "message": "deprecated API usage",
753 });
754 let result = parse_bridge_event(&ev).expect("should produce an event");
755 match result {
756 AppEvent::Console { level, message, .. } => {
757 assert_eq!(level, "warn");
758 assert_eq!(message, "deprecated API usage");
759 }
760 other => panic!("expected Console, got {other:?}"),
761 }
762 }
763
764 #[test]
765 fn parse_console_default_level() {
766 let ev = serde_json::json!({
767 "type": "console",
768 "message": "hello",
769 });
770 let result = parse_bridge_event(&ev).expect("should produce an event");
771 match result {
772 AppEvent::Console { level, message, .. } => {
773 assert_eq!(level, "log");
774 assert_eq!(message, "hello");
775 }
776 other => panic!("expected Console, got {other:?}"),
777 }
778 }
779
780 #[test]
783 fn parse_navigation_event() {
784 let ev = serde_json::json!({
785 "type": "navigation",
786 "nav_type": "push",
787 });
788 let result = parse_bridge_event(&ev).expect("should produce an event");
789 match result {
790 AppEvent::WindowEvent { label, event, .. } => {
791 assert_eq!(label, "main");
792 assert_eq!(event, "navigation.push");
793 }
794 other => panic!("expected WindowEvent, got {other:?}"),
795 }
796 }
797
798 #[test]
799 fn parse_navigation_default_nav_type() {
800 let ev = serde_json::json!({ "type": "navigation" });
801 let result = parse_bridge_event(&ev).expect("should produce an event");
802 match result {
803 AppEvent::WindowEvent { event, .. } => {
804 assert_eq!(event, "navigation.unknown");
805 }
806 other => panic!("expected WindowEvent, got {other:?}"),
807 }
808 }
809
810 #[test]
813 fn parse_dom_mutation_event() {
814 let ev = serde_json::json!({
815 "type": "dom_mutation",
816 "count": 15,
817 });
818 let result = parse_bridge_event(&ev).expect("should produce an event");
819 match result {
820 AppEvent::DomMutation {
821 webview_label,
822 mutation_count,
823 ..
824 } => {
825 assert_eq!(webview_label, "main");
826 assert_eq!(mutation_count, 15);
827 }
828 other => panic!("expected DomMutation, got {other:?}"),
829 }
830 }
831
832 #[test]
835 fn parse_network_event() {
836 let ev = serde_json::json!({
837 "type": "network",
838 "method": "POST",
839 "url": "https://api.example.com/data",
840 });
841 let result = parse_bridge_event(&ev).expect("should produce an event");
842 match result {
843 AppEvent::StateChange { key, caused_by, .. } => {
844 assert_eq!(key, "network.POST");
845 assert_eq!(caused_by.as_deref(), Some("https://api.example.com/data"));
846 }
847 other => panic!("expected StateChange, got {other:?}"),
848 }
849 }
850
851 #[test]
854 fn parse_unknown_type_returns_none() {
855 let ev = serde_json::json!({
856 "type": "custom_telemetry",
857 "payload": 42,
858 });
859 assert!(parse_bridge_event(&ev).is_none());
860 }
861
862 #[test]
863 fn parse_missing_type_field_returns_none() {
864 let ev = serde_json::json!({ "data": "no type here" });
865 assert!(parse_bridge_event(&ev).is_none());
866 }
867
868 #[test]
869 fn parse_empty_object_returns_none() {
870 let ev = serde_json::json!({});
871 assert!(parse_bridge_event(&ev).is_none());
872 }
873}