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