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