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