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