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