Skip to main content

oxi_agent/tools/browse/
engine.rs

1//! Browser engine abstraction layer.
2
3#![allow(missing_docs)]
4//!
5//! Defines the core traits (`BrowserEngine`, `BrowserTab`) and shared
6//! types that all browser tools depend on. These traits are always compiled
7//! (no feature gates) so tools can use them regardless of the backend.
8//!
9//! Actual backend implementations (e.g. oxibrowser-core) are behind
10//! `#[cfg(feature = "native-browser")]` in `oxibrowser_backend.rs`.
11
12use async_trait::async_trait;
13use parking_lot::Mutex;
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16use std::collections::HashMap;
17use std::sync::Arc;
18
19/// Errors that can occur during browser operations.
20#[derive(Debug, thiserror::Error)]
21pub enum BrowserError {
22    #[error("navigation failed: {0}")]
23    Navigation(String),
24    #[error("element not found: {0}")]
25    ElementNotFound(String),
26    #[error("timeout: {0}")]
27    Timeout(String),
28    #[error("evaluation error: {0}")]
29    Evaluation(String),
30    #[error("screenshot failed: {0}")]
31    Screenshot(String),
32    #[error("tab closed: {0}")]
33    TabClosed(String),
34    #[error("browser error: {0}")]
35    Backend(String),
36    #[error("no active session — call 'open' first")]
37    NoActiveSession,
38}
39
40impl From<BrowserError> for crate::tools::ToolError {
41    fn from(e: BrowserError) -> Self {
42        e.to_string()
43    }
44}
45
46/// Shared page content returned by `goto` and `content` methods.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct PageContent {
49    /// Final URL after redirects.
50    pub url: String,
51    /// Page title.
52    pub title: String,
53    /// HTTP status code.
54    pub status: u16,
55    /// Rendered page content as markdown.
56    pub markdown: String,
57    /// Raw HTML body.
58    #[serde(default)]
59    pub html: String,
60}
61
62impl PageContent {
63    /// Create an empty page (for mock / fallback).
64    pub fn empty() -> Self {
65        Self {
66            url: String::new(),
67            title: String::new(),
68            status: 0,
69            markdown: String::new(),
70            html: String::new(),
71        }
72    }
73}
74
75/// A single link on a page.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct LinkInfo {
78    #[allow(missing_docs)]
79    pub text: String,
80    #[allow(missing_docs)]
81    pub href: String,
82}
83
84/// A single element matched by a CSS selector.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct ElementInfo {
87    #[allow(missing_docs)]
88    pub tag: String,
89    #[allow(missing_docs)]
90    pub text: String,
91    #[serde(default)]
92    #[allow(missing_docs)]
93    pub attributes: HashMap<String, String>,
94}
95
96// ── BrowserTab trait ──────────────────────────────────────────────────────────
97
98/// Operations available on a single browser tab.
99///
100/// Implementors handle their own async runtime; this trait only
101/// defines the interface contract.
102#[async_trait]
103pub trait BrowserTab: Send + Sync {
104    /// Navigate to `url` and return page content.
105    async fn goto(&self, url: &str) -> Result<PageContent, BrowserError>;
106
107    /// Click an element matching `selector`.
108    async fn click(&self, selector: &str) -> Result<(), BrowserError>;
109
110    /// Type text into an element matching `selector`.
111    async fn type_(&self, selector: &str, text: &str) -> Result<(), BrowserError>;
112
113    /// Fill (set value of) an element matching `selector`.
114    async fn fill(&self, selector: &str, value: &str) -> Result<(), BrowserError>;
115
116    /// Press a keyboard combo (e.g. `"Enter"`, `"Control+c"`).
117    async fn press(&self, combo: &str) -> Result<(), BrowserError>;
118
119    /// Wait for an element matching `selector` to appear.
120    async fn wait_for(&self, selector: &str, timeout_ms: u64) -> Result<(), BrowserError>;
121
122    /// Get the current page content (markdown + html).
123    async fn content(&self) -> Result<PageContent, BrowserError>;
124
125    /// Get text content of all elements matching `selector`.
126    async fn query_all(&self, selector: &str) -> Result<Vec<String>, BrowserError>;
127
128    /// Evaluate a JavaScript expression and return the JSON result.
129    async fn evaluate(&self, js: &str) -> Result<Value, BrowserError>;
130
131    /// Capture a screenshot and return PNG bytes.
132    async fn screenshot(&self, width: u32) -> Result<Vec<u8>, BrowserError>;
133
134    /// Close this tab.
135    async fn close(&self) -> Result<(), BrowserError>;
136
137    /// Navigate back in history. Returns the rendered page content.
138    async fn back(&self) -> Result<PageContent, BrowserError>;
139
140    /// Navigate forward in history. Returns the rendered page content.
141    async fn forward(&self) -> Result<PageContent, BrowserError>;
142
143    /// Reload the current page. Returns the rendered page content.
144    async fn reload(&self) -> Result<PageContent, BrowserError>;
145
146    /// Select an option in a `<select>` element.
147    async fn select_option(&self, selector: &str, value: &str) -> Result<(), BrowserError>;
148
149    /// Check a checkbox or radio input.
150    async fn check(&self, selector: &str) -> Result<(), BrowserError>;
151
152    /// Uncheck a checkbox or radio input.
153    async fn uncheck(&self, selector: &str) -> Result<(), BrowserError>;
154
155    // ── Advanced interaction ───────────────────────────────────
156
157    /// Clear the value of an input element.
158    async fn clear(&self, selector: &str) -> Result<(), BrowserError> {
159        self.fill(selector, "").await
160    }
161
162    /// Hover over an element.
163    async fn hover(&self, selector: &str) -> Result<(), BrowserError> {
164        let sel = serde_json::to_string(selector).unwrap_or_default();
165        let js = format!(
166            r#"(function() {{ var el = document.querySelector({sel}); if (!el) return null; el.dispatchEvent(new MouseEvent('mouseover', {{bubbles:true}})); return el.tagName; }})()"#
167        );
168        self.evaluate(&js).await.map(|_| ())
169    }
170
171    /// Double-click an element.
172    async fn double_click(&self, selector: &str) -> Result<(), BrowserError> {
173        let sel = serde_json::to_string(selector).unwrap_or_default();
174        let js = format!(
175            r#"(function() {{ var el = document.querySelector({sel}); if (!el) return null; el.dispatchEvent(new MouseEvent('dblclick', {{bubbles:true}})); return el.tagName; }})()"#
176        );
177        self.evaluate(&js).await.map(|_| ())
178    }
179
180    /// Right-click (context menu) an element.
181    async fn right_click(&self, selector: &str) -> Result<(), BrowserError> {
182        let sel = serde_json::to_string(selector).unwrap_or_default();
183        let js = format!(
184            r#"(function() {{ var el = document.querySelector({sel}); if (!el) return null; el.dispatchEvent(new MouseEvent('contextmenu', {{bubbles:true, button:2}})); return el.tagName; }})()"#
185        );
186        self.evaluate(&js).await.map(|_| ())
187    }
188
189    /// Scroll the page by delta pixels.
190    async fn scroll(&self, delta_x: f64, delta_y: f64) -> Result<(), BrowserError> {
191        let js = format!("window.scrollBy({}, {})", delta_x, delta_y);
192        self.evaluate(&js).await.map(|_| ())
193    }
194
195    /// Scroll an element into view.
196    async fn scroll_into_view(&self, selector: &str) -> Result<(), BrowserError> {
197        let sel = serde_json::to_string(selector).unwrap_or_default();
198        let js = format!(
199            r#"(function() {{ var el = document.querySelector({sel}); if (!el) return null; el.scrollIntoView(); return el.tagName; }})()"#
200        );
201        self.evaluate(&js).await.map(|_| ())
202    }
203
204    /// Drag from one element to another.
205    async fn drag(&self, from_selector: &str, to_selector: &str) -> Result<(), BrowserError> {
206        let from_sel = serde_json::to_string(from_selector).unwrap_or_default();
207        let to_sel = serde_json::to_string(to_selector).unwrap_or_default();
208        let js = format!(
209            r#"(function() {{ var src = document.querySelector({from_sel}); var dst = document.querySelector({to_sel}); if (!src || !dst) return null; src.dispatchEvent(new DragEvent('dragstart', {{bubbles:true}})); dst.dispatchEvent(new DragEvent('drop', {{bubbles:true}})); src.dispatchEvent(new DragEvent('dragend', {{bubbles:true}})); return 'ok'; }})()"#
210        );
211        self.evaluate(&js).await.map(|_| ())
212    }
213
214    /// Upload a file to a file input element.
215    async fn upload_file(&self, selector: &str, path: &str) -> Result<(), BrowserError> {
216        let sel = serde_json::to_string(selector).unwrap_or_default();
217        let p = serde_json::to_string(path).unwrap_or_default();
218        let js = format!(
219            r#"(function() {{ var el = document.querySelector({sel}); if (!el || el.type !== 'file') return null; if (typeof DataTransfer === 'undefined') return null; var dt = new DataTransfer(); var f = new File([], {p}.split('/').pop()); dt.items.add(f); el.files = dt.files; el.dispatchEvent(new Event('change', {{bubbles:true}})); return el.tagName; }})()"#
220        );
221        self.evaluate(&js).await.map(|_| ())
222    }
223
224    /// Get the value or text content of an element.
225    async fn get_value(&self, selector: &str) -> Result<String, BrowserError> {
226        let sel = serde_json::to_string(selector).unwrap_or_default();
227        let js = format!(
228            r#"(function() {{ var el = document.querySelector({sel}); if (!el) return null; return (el.value !== undefined ? el.value : el.textContent) || ''; }})()"#
229        );
230        let val = self.evaluate(&js).await?;
231        Ok(val.as_str().unwrap_or("").to_string())
232    }
233
234    /// Evaluate JS that may return a promise; awaits by default.
235    async fn evaluate_await(&self, js: &str) -> Result<Value, BrowserError> {
236        self.evaluate(js).await
237    }
238
239    /// Returns `true` if this tab has been closed.
240    fn is_closed(&self) -> bool {
241        false
242    }
243
244    /// Return this tab's unique ID, if the backend supports it.
245    /// Defaults to `Uuid::nil()` for backends that don't track tab identity.
246    fn tab_id(&self) -> uuid::Uuid {
247        uuid::Uuid::nil()
248    }
249
250    /// Support downcasting for backend-specific access.
251    fn as_any(&self) -> &dyn std::any::Any {
252        // Default: no concrete type info.
253        &std::marker::PhantomData::<()>
254    }
255
256    /// Clear any registered progress callback for this tab.
257    /// Defaults to no-op — only backends with callback registries override.
258    fn clear_progress_callback(&self) {}
259
260    /// Register a structured browse progress callback for this tab.
261    /// Defaults to no-op — only backends with browse callback support override.
262    fn set_browse_progress_callback(&self, _cb: BrowseProgressCallback) {}
263}
264
265// ── BrowserEngine trait ───────────────────────────────────────────────────────
266
267/// Factory for opening and managing browser tabs.
268///
269/// This trait is implemented by backends (e.g. oxibrowser-core) and
270/// consumed by the tool layer via `Arc<dyn BrowserEngine>`.
271#[async_trait]
272pub trait BrowserEngine: Send + Sync {
273    /// Fetch a URL and return page content (no tab management).
274    async fn fetch(&self, url: &str) -> Result<PageContent, BrowserError> {
275        let tab = self.new_tab().await?;
276        let content = tab.goto(url).await;
277        let _ = tab.close().await;
278        content
279    }
280
281    /// Open a new browser tab and return it.
282    async fn new_tab(&self) -> Result<Box<dyn BrowserTab>, BrowserError>;
283
284    /// Close all open tabs and shut down the browser instance.
285    async fn close(&self) -> Result<(), BrowserError>;
286
287    /// Returns `true` if the browser is still alive.
288    async fn is_alive(&self) -> bool;
289
290    /// Access the engine's per-tab callback registry.
291    ///
292    /// Tools (e.g. `BrowseTool`) register per-tab callbacks keyed by
293    /// `tab_id`. The backend's background event-drain task extracts
294    /// `tab_id` from each `BrowserEvent` and routes it to the correct
295    /// callback. Backends without event streaming return an empty
296    /// registry — `set`/`invoke` become no-ops.
297    ///
298    /// Default implementation returns a fresh empty registry.
299    fn callback_registry(&self) -> Arc<TabCallbackRegistry> {
300        Arc::new(TabCallbackRegistry::new())
301    }
302}
303
304// ── BrowseProgress ──────────────────────────────────────────────────────
305
306/// Structured progress event for browser tool execution.
307///
308/// Converted from `oxibrowser_core::BrowserEvent` in the backend's drain
309/// task. Carries structured data that would be lost if flattened to a string
310/// via `short_label()`. The agent loop's browse callback receives these and
311/// enriches `ToolCallContext` with the result fields.
312///
313/// Defined here (not in `oxibrowser_backend.rs`) so the type is always
314/// available — no feature gate needed.
315#[derive(Debug, Clone, Serialize, Deserialize)]
316#[serde(tag = "kind", rename_all = "snake_case")]
317#[non_exhaustive]
318pub enum BrowseProgress {
319    /// A navigation has begun.
320    NavigationStarted {
321        /// URL being navigated to (pre-redirect).
322        url: String,
323    },
324
325    /// Waiting for a CSS selector to appear.
326    WaitingForSelector {
327        /// CSS selector being awaited.
328        selector: String,
329        /// Maximum wait time in milliseconds.
330        timeout_ms: u64,
331    },
332
333    /// Page has finished loading and JS has executed.
334    /// This is the key event — carries rich structured data.
335    DocumentReady {
336        /// Final URL after redirects.
337        url: String,
338        /// Page `<title>`.
339        title: String,
340        /// HTTP status code.
341        status: u16,
342        /// Size of the HTML body in bytes.
343        bytes: u64,
344        /// Wall-clock duration of the page load, in milliseconds.
345        duration_ms: u64,
346    },
347
348    /// A screenshot has been captured.
349    ScreenshotCaptured {
350        /// Size of the PNG payload in bytes.
351        bytes: usize,
352        /// Viewport width the screenshot was rendered at.
353        width: u32,
354        /// Render duration in milliseconds.
355        duration_ms: u64,
356    },
357
358    /// Navigation failed.
359    NavigationFailed {
360        /// URL that failed.
361        url: String,
362        /// Error description.
363        error: String,
364    },
365}
366
367// ── BrowseProgressCallback ──────────────────────────────────────────────
368
369/// Callback type for structured browse progress events.
370pub type BrowseProgressCallback = Arc<dyn Fn(BrowseProgress) + Send + Sync>;
371
372// ── TabCallbackRegistry ──────────────────────────────────────────────────
373
374/// Per-`tab_id` callback entry. Groups the string progress callback
375/// and the structured browse callback for a single tab. Both share
376/// the same lifecycle — `clear` removes both at once.
377#[derive(Default)]
378struct TabCallbacks {
379    /// String progress callback (`partial_result` text).
380    progress: Option<crate::tools::ProgressCallback>,
381    /// Structured browse progress callback (context enrichment).
382    browse: Option<BrowseProgressCallback>,
383}
384
385/// Per-`tab_id` callback registry for browser event routing.
386///
387/// Each `BrowseTool` invocation opens its own tab and registers a callback
388/// keyed by the tab's `tab_id`. The engine's background event-drain task
389/// extracts `tab_id` from each `BrowserEvent` and routes it to the correct
390/// callback. Multiple tabs can be active concurrently — each receives only
391/// its own events.
392///
393/// Tabs that have no registered callback (e.g. opened outside of a tool
394/// call) are silently ignored — `invoke` is a no-op for unknown tab IDs.
395pub struct TabCallbackRegistry {
396    entries: Mutex<HashMap<uuid::Uuid, TabCallbacks>>,
397}
398
399impl Default for TabCallbackRegistry {
400    fn default() -> Self {
401        Self::new()
402    }
403}
404
405impl TabCallbackRegistry {
406    /// Create an empty registry.
407    pub fn new() -> Self {
408        Self {
409            entries: Mutex::new(HashMap::new()),
410        }
411    }
412
413    /// Register a string progress callback for the given `tab_id`.
414    pub fn set(&self, tab_id: uuid::Uuid, cb: crate::tools::ProgressCallback) {
415        self.entries
416            .lock()
417            .entry(tab_id)
418            .or_default()
419            .progress = Some(cb);
420    }
421
422    /// Register a structured browse progress callback for the given tab.
423    pub fn set_browse(&self, tab_id: uuid::Uuid, cb: BrowseProgressCallback) {
424        self.entries
425            .lock()
426            .entry(tab_id)
427            .or_default()
428            .browse = Some(cb);
429    }
430
431    /// Remove **all** callbacks for `tab_id`. Called when the tab is closed.
432    pub fn clear(&self, tab_id: &uuid::Uuid) {
433        self.entries.lock().remove(tab_id);
434    }
435
436    /// Invoke the string progress callback for `tab_id`, if registered.
437    pub fn invoke(&self, tab_id: &uuid::Uuid, msg: String) {
438        if let Some(entry) = self.entries.lock().get(tab_id) {
439            if let Some(ref cb) = entry.progress {
440                cb(msg);
441            }
442        }
443    }
444
445    /// Invoke the browse progress callback for `tab_id`, if registered.
446    pub fn invoke_browse(&self, tab_id: &uuid::Uuid, progress: BrowseProgress) {
447        if let Some(entry) = self.entries.lock().get(tab_id) {
448            if let Some(ref cb) = entry.browse {
449                cb(progress);
450            }
451        }
452    }
453
454    /// Whether a string callback is registered for the given `tab_id`.
455    pub fn is_set(&self, tab_id: &uuid::Uuid) -> bool {
456        self.entries.lock().contains_key(tab_id)
457    }
458
459    /// Number of currently registered tabs.
460    pub fn len(&self) -> usize {
461        self.entries.lock().len()
462    }
463
464    /// Returns `true` if no tabs have registered callbacks.
465    pub fn is_empty(&self) -> bool {
466        self.entries.lock().is_empty()
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use std::sync::atomic::{AtomicUsize, Ordering};
474
475    #[test]
476    fn tab_callback_registry_default_is_empty() {
477        let reg = TabCallbackRegistry::new();
478        assert!(reg.is_empty());
479        assert_eq!(reg.len(), 0);
480        // invoke on empty registry is a silent no-op
481        let nil = uuid::Uuid::nil();
482        reg.invoke(&nil, "should be dropped".into());
483    }
484
485    #[test]
486    fn tab_callback_registry_set_and_invoke() {
487        let reg = TabCallbackRegistry::new();
488        let tab_a = uuid::Uuid::new_v4();
489        let tab_b = uuid::Uuid::new_v4();
490        let count = Arc::new(AtomicUsize::new(0));
491        let count_clone = Arc::clone(&count);
492        reg.set(
493            tab_a,
494            oxi_ai::progress_callback(move |msg: String| {
495                assert_eq!(msg, "hello");
496                count_clone.fetch_add(1, Ordering::SeqCst);
497            }),
498        );
499        assert!(reg.is_set(&tab_a));
500        assert!(!reg.is_set(&tab_b));
501
502        reg.invoke(&tab_a, "hello".into());
503        reg.invoke(&tab_a, "hello".into());
504        // invoke for unregistered tab_b is a no-op
505        reg.invoke(&tab_b, "hello".into());
506        assert_eq!(count.load(Ordering::SeqCst), 2);
507    }
508
509    #[test]
510    fn tab_callback_registry_set_per_tab_isolation() {
511        let reg = TabCallbackRegistry::new();
512        let tab_a = uuid::Uuid::new_v4();
513        let tab_b = uuid::Uuid::new_v4();
514        let count_a = Arc::new(AtomicUsize::new(0));
515        let count_b = Arc::new(AtomicUsize::new(0));
516
517        let ca = Arc::clone(&count_a);
518        reg.set(
519            tab_a,
520            oxi_ai::progress_callback(move |_| {
521                ca.fetch_add(1, Ordering::SeqCst);
522            }),
523        );
524        let cb_clone = Arc::clone(&count_b);
525        reg.set(
526            tab_b,
527            oxi_ai::progress_callback(move |_| {
528                cb_clone.fetch_add(1, Ordering::SeqCst);
529            }),
530        );
531
532        reg.invoke(&tab_a, "event".into());
533        assert_eq!(count_a.load(Ordering::SeqCst), 1);
534        assert_eq!(count_b.load(Ordering::SeqCst), 0);
535
536        reg.invoke(&tab_b, "event".into());
537        assert_eq!(count_a.load(Ordering::SeqCst), 1);
538        assert_eq!(count_b.load(Ordering::SeqCst), 1);
539    }
540
541    #[test]
542    fn tab_callback_registry_clear() {
543        let reg = TabCallbackRegistry::new();
544        let tab_a = uuid::Uuid::new_v4();
545        let count = Arc::new(AtomicUsize::new(0));
546        let c = Arc::clone(&count);
547        reg.set(
548            tab_a,
549            oxi_ai::progress_callback(move |_| {
550                c.fetch_add(1, Ordering::SeqCst);
551            }),
552        );
553        reg.invoke(&tab_a, "x".into());
554        assert_eq!(count.load(Ordering::SeqCst), 1);
555
556        reg.clear(&tab_a);
557        assert!(!reg.is_set(&tab_a));
558        reg.invoke(&tab_a, "y".into());
559        assert_eq!(
560            count.load(Ordering::SeqCst),
561            1,
562            "invoke after clear is no-op"
563        );
564    }
565
566    #[test]
567    fn page_content_empty() {
568        let p = PageContent::empty();
569        assert!(p.url.is_empty());
570        assert_eq!(p.status, 0);
571    }
572
573    #[test]
574    fn browser_error_display() {
575        let e = BrowserError::Navigation("connection refused".into());
576        assert!(e.to_string().contains("navigation failed"));
577    }
578
579    #[test]
580    fn link_info_serde() {
581        let link = LinkInfo {
582            text: "Example".into(),
583            href: "https://example.com".into(),
584        };
585        let json = serde_json::to_string(&link).unwrap();
586        let restored: LinkInfo = serde_json::from_str(&json).unwrap();
587        assert_eq!(restored.text, "Example");
588        assert_eq!(restored.href, "https://example.com");
589    }
590
591    #[test]
592    fn element_info_serde() {
593        let elem = ElementInfo {
594            tag: "DIV".into(),
595            text: "Hello".into(),
596            attributes: [("class".into(), "item".into())].into(),
597        };
598        let json = serde_json::to_string(&elem).unwrap();
599        assert!(json.contains("DIV"));
600        assert!(json.contains("Hello"));
601    }
602
603    #[test]
604    fn browser_error_no_active_session() {
605        let e = BrowserError::NoActiveSession;
606        assert!(e.to_string().contains("no active session"));
607    }
608
609    // ── Browse progress callback tests ──────────────────────────
610
611    #[test]
612    fn tab_callback_registry_browse_set_and_invoke() {
613        let reg = TabCallbackRegistry::new();
614        let tab = uuid::Uuid::new_v4();
615        let received: Arc<std::sync::Mutex<Vec<BrowseProgress>>> =
616            Arc::new(std::sync::Mutex::new(Vec::new()));
617        let r = Arc::clone(&received);
618        reg.set_browse(
619            tab,
620            Arc::new(move |bp: BrowseProgress| {
621                r.lock().unwrap().push(bp);
622            }),
623        );
624
625        let progress = BrowseProgress::DocumentReady {
626            url: "https://example.com".into(),
627            title: "Example".into(),
628            status: 200,
629            bytes: 1024,
630            duration_ms: 500,
631        };
632        reg.invoke_browse(&tab, progress.clone());
633
634        let events = received.lock().unwrap();
635        assert_eq!(events.len(), 1);
636        assert!(matches!(&events[0], BrowseProgress::DocumentReady { status: 200, .. }));
637    }
638
639    #[test]
640    fn tab_callback_registry_browse_clear_removes_both() {
641        let reg = TabCallbackRegistry::new();
642        let tab = uuid::Uuid::new_v4();
643
644        // Register both types
645        reg.set(
646            tab,
647            oxi_ai::progress_callback(move |_| {}),
648        );
649        reg.set_browse(
650            tab,
651            Arc::new(move |_: BrowseProgress| {}),
652        );
653        assert!(reg.is_set(&tab));
654
655        // clear removes both
656        reg.clear(&tab);
657        assert!(!reg.is_set(&tab));
658        assert!(reg.is_empty());
659    }
660
661    #[test]
662    fn tab_callback_registry_browse_isolation_per_tab() {
663        let reg = TabCallbackRegistry::new();
664        let tab_a = uuid::Uuid::new_v4();
665        let tab_b = uuid::Uuid::new_v4();
666
667        let count_a = Arc::new(AtomicUsize::new(0));
668        let count_b = Arc::new(AtomicUsize::new(0));
669
670        let ca = Arc::clone(&count_a);
671        reg.set_browse(tab_a, Arc::new(move |_: BrowseProgress| {
672            ca.fetch_add(1, Ordering::SeqCst);
673        }));
674        let cb2 = Arc::clone(&count_b);
675        reg.set_browse(tab_b, Arc::new(move |_: BrowseProgress| {
676            cb2.fetch_add(1, Ordering::SeqCst);
677        }));
678
679        let doc_ready = BrowseProgress::DocumentReady {
680            url: "https://example.com".into(),
681            title: "Example".into(),
682            status: 200,
683            bytes: 1024,
684            duration_ms: 100,
685        };
686        reg.invoke_browse(&tab_a, doc_ready.clone());
687        assert_eq!(count_a.load(Ordering::SeqCst), 1);
688        assert_eq!(count_b.load(Ordering::SeqCst), 0);
689
690        reg.invoke_browse(&tab_b, doc_ready);
691        assert_eq!(count_a.load(Ordering::SeqCst), 1);
692        assert_eq!(count_b.load(Ordering::SeqCst), 1);
693    }
694
695    #[test]
696    fn browse_progress_serde_roundtrip() {
697        let variants = vec![
698            BrowseProgress::NavigationStarted {
699                url: "https://example.com".into(),
700            },
701            BrowseProgress::WaitingForSelector {
702                selector: ".content".into(),
703                timeout_ms: 5000,
704            },
705            BrowseProgress::DocumentReady {
706                url: "https://example.com/page".into(),
707                title: "Test Page".into(),
708                status: 200,
709                bytes: 4096,
710                duration_ms: 1234,
711            },
712            BrowseProgress::ScreenshotCaptured {
713                bytes: 8192,
714                width: 1280,
715                duration_ms: 200,
716            },
717            BrowseProgress::NavigationFailed {
718                url: "https://fail.example.com".into(),
719                error: "connection refused".into(),
720            },
721        ];
722
723        for bp in &variants {
724            let json = serde_json::to_string(bp).unwrap();
725            let restored: BrowseProgress = serde_json::from_str(&json).unwrap();
726            let json2 = serde_json::to_string(&restored).unwrap();
727            assert_eq!(json, json2, "roundtrip failed for {:?}", bp);
728        }
729    }
730}