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}
41
42impl From<BrowserError> for crate::tools::ToolError {
43 fn from(e: BrowserError) -> Self {
44 e.to_string()
45 }
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct PageContent {
51 pub url: String,
53 pub title: String,
55 pub status: u16,
57 pub markdown: String,
59 #[serde(default)]
61 pub html: String,
62}
63
64impl PageContent {
65 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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case")]
107pub enum BrowseWaitCondition {
108 Visible(String),
110 NetworkIdle,
114 DomContentLoaded,
116 Load,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct ObservedElement {
129 pub ref_id: String,
131 pub role: String,
133 pub name: String,
135 pub tag: String,
137 pub selector: String,
139 pub visible: bool,
141 pub interactive: bool,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct Observation {
162 pub url: String,
164 pub title: String,
166 pub elements: Vec<ObservedElement>,
168}
169
170#[async_trait]
183pub trait BrowserTab: Send + Sync {
184 async fn goto(&self, url: &str) -> Result<PageContent, BrowserError>;
186
187 async fn click(&self, selector: &str) -> Result<(), BrowserError>;
189
190 async fn type_(&self, selector: &str, text: &str) -> Result<(), BrowserError>;
192
193 async fn fill(&self, selector: &str, value: &str) -> Result<(), BrowserError>;
195
196 async fn press(&self, combo: &str) -> Result<(), BrowserError>;
198
199 async fn wait_for(&self, selector: &str, timeout_ms: u64) -> Result<(), BrowserError>;
201 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 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 async fn content(&self) -> Result<PageContent, BrowserError>;
239
240 async fn query_all(&self, selector: &str) -> Result<Vec<String>, BrowserError>;
242
243 async fn evaluate(&self, js: &str) -> Result<Value, BrowserError>;
245
246 async fn screenshot(&self, width: u32) -> Result<Vec<u8>, BrowserError>;
248
249 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 async fn close(&self) -> Result<(), BrowserError>;
261
262 async fn back(&self) -> Result<PageContent, BrowserError>;
264
265 async fn forward(&self) -> Result<PageContent, BrowserError>;
267
268 async fn reload(&self) -> Result<PageContent, BrowserError>;
270
271 async fn select_option(&self, selector: &str, value: &str) -> Result<(), BrowserError>;
273
274 async fn check(&self, selector: &str) -> Result<(), BrowserError>;
276
277 async fn uncheck(&self, selector: &str) -> Result<(), BrowserError>;
279
280 async fn clear(&self, selector: &str) -> Result<(), BrowserError> {
284 self.fill(selector, "").await
285 }
286
287 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 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 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 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 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 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 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 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 async fn evaluate_await(&self, js: &str) -> Result<Value, BrowserError> {
361 self.evaluate(js).await
362 }
363
364 fn is_closed(&self) -> bool {
366 false
367 }
368
369 fn tab_id(&self) -> uuid::Uuid {
372 uuid::Uuid::nil()
373 }
374
375 fn as_any(&self) -> &dyn std::any::Any {
377 &std::marker::PhantomData::<()>
379 }
380
381 fn clear_progress_callback(&self) {}
384
385 fn set_browse_progress_callback(&self, _cb: BrowseProgressCallback) {}
388}
389
390#[async_trait]
397pub trait BrowserEngine: Send + Sync {
398 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 async fn new_tab(&self) -> Result<Box<dyn BrowserTab>, BrowserError>;
408
409 async fn close(&self) -> Result<(), BrowserError>;
411
412 async fn is_alive(&self) -> bool;
414
415 fn callback_registry(&self) -> Arc<TabCallbackRegistry> {
425 Arc::new(TabCallbackRegistry::new())
426 }
427}
428
429#[derive(Debug, Clone, Serialize, Deserialize)]
441#[serde(tag = "kind", rename_all = "snake_case")]
442#[non_exhaustive]
443pub enum BrowseProgress {
444 NavigationStarted {
446 url: String,
448 },
449
450 WaitingForSelector {
452 selector: String,
454 timeout_ms: u64,
456 },
457
458 DocumentReady {
461 url: String,
463 title: String,
465 status: u16,
467 bytes: u64,
469 duration_ms: u64,
471 },
472
473 ScreenshotCaptured {
475 bytes: usize,
477 width: u32,
479 duration_ms: u64,
481 },
482
483 PdfExported {
485 bytes: usize,
487 width: u32,
489 duration_ms: u64,
491 },
492
493 NavigationFailed {
495 url: String,
497 error: String,
499 },
500}
501
502pub type BrowseProgressCallback = Arc<dyn Fn(BrowseProgress) + Send + Sync>;
506
507#[derive(Default)]
513struct TabCallbacks {
514 progress: Option<crate::tools::ProgressCallback>,
516 browse: Option<BrowseProgressCallback>,
518}
519
520pub 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 pub fn new() -> Self {
543 Self {
544 entries: Mutex::new(HashMap::new()),
545 }
546 }
547
548 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 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 pub fn clear(&self, tab_id: &uuid::Uuid) {
560 self.entries.lock().remove(tab_id);
561 }
562
563 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 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 pub fn is_set(&self, tab_id: &uuid::Uuid) -> bool {
583 self.entries.lock().contains_key(tab_id)
584 }
585
586 pub fn len(&self) -> usize {
588 self.entries.lock().len()
589 }
590
591 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 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 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 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 #[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 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 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}