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