Skip to main content

victauri_plugin/mcp/
server.rs

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