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