Skip to main content

victauri_plugin/mcp/
server.rs

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
17// ── Server startup ───────────────────────────────────────────────────────────
18
19/// Build an Axum router for the MCP server with default options (no auth token).
20pub fn build_app(state: Arc<VictauriState>, bridge: Arc<dyn WebviewBridge>) -> axum::Router {
21    build_app_with_options(state, bridge, None)
22}
23
24/// Build an Axum router for the MCP server with an optional auth token and rate limiter.
25pub 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
33/// Build an Axum router with full control over auth token and rate limiter.
34pub 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    /// Expose memory stats for integration tests.
113    #[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
121/// Start the MCP server on the given port with default options (no auth token).
122///
123/// # Errors
124///
125/// Returns an error if the server fails to bind to the requested port (or any port in the
126/// fallback range), or if the server exits unexpectedly.
127pub 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
136/// Start the MCP server on the given port with an optional auth token.
137///
138/// # Errors
139///
140/// Returns an error if the server fails to bind to the requested port (or any port in the
141/// fallback range), or if the server exits unexpectedly.
142pub 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    // Write metadata for multi-server discovery
237    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/// Parse a single bridge event JSON value into an [`AppEvent`](victauri_core::AppEvent).
276///
277/// Returns `None` for unrecognised event types, allowing callers to skip them.
278#[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); // OS assigned a real port
473    }
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        // Metadata file written
493        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    // ── parse_bridge_event: dom_interaction ────────────────────────────────
503
504    #[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    // ── parse_bridge_event: ipc ────────────────────────────────────────────
649
650    #[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    // ── parse_bridge_event: console ────────────────────────────────────────
704
705    #[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    // ── parse_bridge_event: navigation ─────────────────────────────────────
738
739    #[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    // ── parse_bridge_event: dom_mutation ───────────────────────────────────
768
769    #[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    // ── parse_bridge_event: network ────────────────────────────────────────
790
791    #[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    // ── parse_bridge_event: unknown type ───────────────────────────────────
809
810    #[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}