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("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 NoMatch(String),
45 #[error("missing value for action: {action}")]
46 MissingValue { action: &'static str },
48 #[error("grounding parse failed: {0}")]
49 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#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct PageContent {
62 pub url: String,
64 pub title: String,
66 pub status: u16,
68 pub markdown: String,
70 #[serde(default)]
72 pub html: String,
73}
74
75impl PageContent {
76 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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
117#[serde(rename_all = "snake_case")]
118pub enum BrowseWaitCondition {
119 Visible(String),
121 NetworkIdle,
125 DomContentLoaded,
127 Load,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct ObservedElement {
140 pub ref_id: String,
142 pub role: String,
144 pub name: String,
146 pub tag: String,
148 pub selector: String,
150 pub visible: bool,
152 pub interactive: bool,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct Observation {
173 pub url: String,
175 pub title: String,
177 pub elements: Vec<ObservedElement>,
179}
180
181#[async_trait]
194pub trait BrowserTab: Send + Sync {
195 async fn goto(&self, url: &str) -> Result<PageContent, BrowserError>;
197
198 async fn click(&self, selector: &str) -> Result<(), BrowserError>;
200
201 async fn type_(&self, selector: &str, text: &str) -> Result<(), BrowserError>;
203
204 async fn fill(&self, selector: &str, value: &str) -> Result<(), BrowserError>;
206
207 async fn press(&self, combo: &str) -> Result<(), BrowserError>;
209
210 async fn wait_for(&self, selector: &str, timeout_ms: u64) -> Result<(), BrowserError>;
212 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 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 async fn content(&self) -> Result<PageContent, BrowserError>;
250
251 async fn query_all(&self, selector: &str) -> Result<Vec<String>, BrowserError>;
253
254 async fn evaluate(&self, js: &str) -> Result<Value, BrowserError>;
256
257 async fn screenshot(&self, width: u32) -> Result<Vec<u8>, BrowserError>;
259
260 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 async fn close(&self) -> Result<(), BrowserError>;
272
273 async fn back(&self) -> Result<PageContent, BrowserError>;
275
276 async fn forward(&self) -> Result<PageContent, BrowserError>;
278
279 async fn reload(&self) -> Result<PageContent, BrowserError>;
281
282 async fn select_option(&self, selector: &str, value: &str) -> Result<(), BrowserError>;
284
285 async fn check(&self, selector: &str) -> Result<(), BrowserError>;
287
288 async fn uncheck(&self, selector: &str) -> Result<(), BrowserError>;
290
291 async fn clear(&self, selector: &str) -> Result<(), BrowserError> {
295 self.fill(selector, "").await
296 }
297
298 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 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 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 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 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 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 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 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 async fn evaluate_await(&self, js: &str) -> Result<Value, BrowserError> {
372 self.evaluate(js).await
373 }
374
375 fn is_closed(&self) -> bool {
377 false
378 }
379
380 fn tab_id(&self) -> uuid::Uuid {
383 uuid::Uuid::nil()
384 }
385
386 fn as_any(&self) -> &dyn std::any::Any {
388 &std::marker::PhantomData::<()>
390 }
391
392 fn clear_progress_callback(&self) {}
395
396 fn set_browse_progress_callback(&self, _cb: BrowseProgressCallback) {}
399}
400
401#[async_trait]
408pub trait BrowserEngine: Send + Sync {
409 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 async fn new_tab(&self) -> Result<Box<dyn BrowserTab>, BrowserError>;
419
420 async fn close(&self) -> Result<(), BrowserError>;
422
423 async fn is_alive(&self) -> bool;
425
426 fn callback_registry(&self) -> Arc<TabCallbackRegistry> {
436 Arc::new(TabCallbackRegistry::new())
437 }
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize)]
452#[serde(tag = "kind", rename_all = "snake_case")]
453#[non_exhaustive]
454pub enum BrowseProgress {
455 NavigationStarted {
457 url: String,
459 },
460
461 WaitingForSelector {
463 selector: String,
465 timeout_ms: u64,
467 },
468
469 DocumentReady {
472 url: String,
474 title: String,
476 status: u16,
478 bytes: u64,
480 duration_ms: u64,
482 },
483
484 ScreenshotCaptured {
486 bytes: usize,
488 width: u32,
490 duration_ms: u64,
492 },
493
494 PdfExported {
496 bytes: usize,
498 width: u32,
500 duration_ms: u64,
502 },
503
504 NavigationFailed {
506 url: String,
508 error: String,
510 },
511}
512
513pub type BrowseProgressCallback = Arc<dyn Fn(BrowseProgress) + Send + Sync>;
517
518#[derive(Default)]
524struct TabCallbacks {
525 progress: Option<crate::tools::ProgressCallback>,
527 browse: Option<BrowseProgressCallback>,
529}
530
531pub 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 pub fn new() -> Self {
554 Self {
555 entries: Mutex::new(HashMap::new()),
556 }
557 }
558
559 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 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 pub fn clear(&self, tab_id: &uuid::Uuid) {
571 self.entries.lock().remove(tab_id);
572 }
573
574 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 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 pub fn is_set(&self, tab_id: &uuid::Uuid) -> bool {
594 self.entries.lock().contains_key(tab_id)
595 }
596
597 pub fn len(&self) -> usize {
599 self.entries.lock().len()
600 }
601
602 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 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 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 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 #[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 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 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}