1#![allow(missing_docs)]
4use 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct PageContent {
49 pub url: String,
51 pub title: String,
53 pub status: u16,
55 pub markdown: String,
57 #[serde(default)]
59 pub html: String,
60}
61
62impl PageContent {
63 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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum BrowseWaitCondition {
106 Visible(String),
108 NetworkIdle,
112 DomContentLoaded,
114 Load,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct ObservedElement {
127 pub ref_id: String,
129 pub role: String,
131 pub name: String,
133 pub tag: String,
135 pub selector: String,
137 pub visible: bool,
139 pub interactive: bool,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct Observation {
160 pub url: String,
162 pub title: String,
164 pub elements: Vec<ObservedElement>,
166}
167
168#[async_trait]
181pub trait BrowserTab: Send + Sync {
182 async fn goto(&self, url: &str) -> Result<PageContent, BrowserError>;
184
185 async fn click(&self, selector: &str) -> Result<(), BrowserError>;
187
188 async fn type_(&self, selector: &str, text: &str) -> Result<(), BrowserError>;
190
191 async fn fill(&self, selector: &str, value: &str) -> Result<(), BrowserError>;
193
194 async fn press(&self, combo: &str) -> Result<(), BrowserError>;
196
197 async fn wait_for(&self, selector: &str, timeout_ms: u64) -> Result<(), BrowserError>;
199 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 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 async fn content(&self) -> Result<PageContent, BrowserError>;
237
238 async fn query_all(&self, selector: &str) -> Result<Vec<String>, BrowserError>;
240
241 async fn evaluate(&self, js: &str) -> Result<Value, BrowserError>;
243
244 async fn screenshot(&self, width: u32) -> Result<Vec<u8>, BrowserError>;
246
247 async fn close(&self) -> Result<(), BrowserError>;
249
250 async fn back(&self) -> Result<PageContent, BrowserError>;
252
253 async fn forward(&self) -> Result<PageContent, BrowserError>;
255
256 async fn reload(&self) -> Result<PageContent, BrowserError>;
258
259 async fn select_option(&self, selector: &str, value: &str) -> Result<(), BrowserError>;
261
262 async fn check(&self, selector: &str) -> Result<(), BrowserError>;
264
265 async fn uncheck(&self, selector: &str) -> Result<(), BrowserError>;
267
268 async fn clear(&self, selector: &str) -> Result<(), BrowserError> {
272 self.fill(selector, "").await
273 }
274
275 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 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 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 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 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 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 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 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 async fn evaluate_await(&self, js: &str) -> Result<Value, BrowserError> {
349 self.evaluate(js).await
350 }
351
352 fn is_closed(&self) -> bool {
354 false
355 }
356
357 fn tab_id(&self) -> uuid::Uuid {
360 uuid::Uuid::nil()
361 }
362
363 fn as_any(&self) -> &dyn std::any::Any {
365 &std::marker::PhantomData::<()>
367 }
368
369 fn clear_progress_callback(&self) {}
372
373 fn set_browse_progress_callback(&self, _cb: BrowseProgressCallback) {}
376}
377
378#[async_trait]
385pub trait BrowserEngine: Send + Sync {
386 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 async fn new_tab(&self) -> Result<Box<dyn BrowserTab>, BrowserError>;
396
397 async fn close(&self) -> Result<(), BrowserError>;
399
400 async fn is_alive(&self) -> bool;
402
403 fn callback_registry(&self) -> Arc<TabCallbackRegistry> {
413 Arc::new(TabCallbackRegistry::new())
414 }
415}
416
417#[derive(Debug, Clone, Serialize, Deserialize)]
429#[serde(tag = "kind", rename_all = "snake_case")]
430#[non_exhaustive]
431pub enum BrowseProgress {
432 NavigationStarted {
434 url: String,
436 },
437
438 WaitingForSelector {
440 selector: String,
442 timeout_ms: u64,
444 },
445
446 DocumentReady {
449 url: String,
451 title: String,
453 status: u16,
455 bytes: u64,
457 duration_ms: u64,
459 },
460
461 ScreenshotCaptured {
463 bytes: usize,
465 width: u32,
467 duration_ms: u64,
469 },
470
471 NavigationFailed {
473 url: String,
475 error: String,
477 },
478}
479
480pub type BrowseProgressCallback = Arc<dyn Fn(BrowseProgress) + Send + Sync>;
484
485#[derive(Default)]
491struct TabCallbacks {
492 progress: Option<crate::tools::ProgressCallback>,
494 browse: Option<BrowseProgressCallback>,
496}
497
498pub 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 pub fn new() -> Self {
521 Self {
522 entries: Mutex::new(HashMap::new()),
523 }
524 }
525
526 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 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 pub fn clear(&self, tab_id: &uuid::Uuid) {
538 self.entries.lock().remove(tab_id);
539 }
540
541 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 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 pub fn is_set(&self, tab_id: &uuid::Uuid) -> bool {
561 self.entries.lock().contains_key(tab_id)
562 }
563
564 pub fn len(&self) -> usize {
566 self.entries.lock().len()
567 }
568
569 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 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 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 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 #[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 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 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}