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