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