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