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