Skip to main content

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