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#[async_trait]
103pub trait BrowserTab: Send + Sync {
104 async fn goto(&self, url: &str) -> Result<PageContent, BrowserError>;
106
107 async fn click(&self, selector: &str) -> Result<(), BrowserError>;
109
110 async fn type_(&self, selector: &str, text: &str) -> Result<(), BrowserError>;
112
113 async fn fill(&self, selector: &str, value: &str) -> Result<(), BrowserError>;
115
116 async fn press(&self, combo: &str) -> Result<(), BrowserError>;
118
119 async fn wait_for(&self, selector: &str, timeout_ms: u64) -> Result<(), BrowserError>;
121
122 async fn content(&self) -> Result<PageContent, BrowserError>;
124
125 async fn query_all(&self, selector: &str) -> Result<Vec<String>, BrowserError>;
127
128 async fn evaluate(&self, js: &str) -> Result<Value, BrowserError>;
130
131 async fn screenshot(&self, width: u32) -> Result<Vec<u8>, BrowserError>;
133
134 async fn close(&self) -> Result<(), BrowserError>;
136
137 async fn back(&self) -> Result<PageContent, BrowserError>;
139
140 async fn forward(&self) -> Result<PageContent, BrowserError>;
142
143 async fn reload(&self) -> Result<PageContent, BrowserError>;
145
146 async fn select_option(&self, selector: &str, value: &str) -> Result<(), BrowserError>;
148
149 async fn check(&self, selector: &str) -> Result<(), BrowserError>;
151
152 async fn uncheck(&self, selector: &str) -> Result<(), BrowserError>;
154
155 async fn clear(&self, selector: &str) -> Result<(), BrowserError> {
159 self.fill(selector, "").await
160 }
161
162 async fn hover(&self, selector: &str) -> Result<(), BrowserError> {
164 let sel = serde_json::to_string(selector).unwrap_or_default();
165 let js = format!(
166 r#"(function() {{ var el = document.querySelector({sel}); if (!el) return null; el.dispatchEvent(new MouseEvent('mouseover', {{bubbles:true}})); return el.tagName; }})()"#
167 );
168 self.evaluate(&js).await.map(|_| ())
169 }
170
171 async fn double_click(&self, selector: &str) -> Result<(), BrowserError> {
173 let sel = serde_json::to_string(selector).unwrap_or_default();
174 let js = format!(
175 r#"(function() {{ var el = document.querySelector({sel}); if (!el) return null; el.dispatchEvent(new MouseEvent('dblclick', {{bubbles:true}})); return el.tagName; }})()"#
176 );
177 self.evaluate(&js).await.map(|_| ())
178 }
179
180 async fn right_click(&self, selector: &str) -> Result<(), BrowserError> {
182 let sel = serde_json::to_string(selector).unwrap_or_default();
183 let js = format!(
184 r#"(function() {{ var el = document.querySelector({sel}); if (!el) return null; el.dispatchEvent(new MouseEvent('contextmenu', {{bubbles:true, button:2}})); return el.tagName; }})()"#
185 );
186 self.evaluate(&js).await.map(|_| ())
187 }
188
189 async fn scroll(&self, delta_x: f64, delta_y: f64) -> Result<(), BrowserError> {
191 let js = format!("window.scrollBy({}, {})", delta_x, delta_y);
192 self.evaluate(&js).await.map(|_| ())
193 }
194
195 async fn scroll_into_view(&self, selector: &str) -> Result<(), BrowserError> {
197 let sel = serde_json::to_string(selector).unwrap_or_default();
198 let js = format!(
199 r#"(function() {{ var el = document.querySelector({sel}); if (!el) return null; el.scrollIntoView(); return el.tagName; }})()"#
200 );
201 self.evaluate(&js).await.map(|_| ())
202 }
203
204 async fn drag(&self, from_selector: &str, to_selector: &str) -> Result<(), BrowserError> {
206 let from_sel = serde_json::to_string(from_selector).unwrap_or_default();
207 let to_sel = serde_json::to_string(to_selector).unwrap_or_default();
208 let js = format!(
209 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'; }})()"#
210 );
211 self.evaluate(&js).await.map(|_| ())
212 }
213
214 async fn upload_file(&self, selector: &str, path: &str) -> Result<(), BrowserError> {
216 let sel = serde_json::to_string(selector).unwrap_or_default();
217 let p = serde_json::to_string(path).unwrap_or_default();
218 let js = format!(
219 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; }})()"#
220 );
221 self.evaluate(&js).await.map(|_| ())
222 }
223
224 async fn get_value(&self, selector: &str) -> Result<String, BrowserError> {
226 let sel = serde_json::to_string(selector).unwrap_or_default();
227 let js = format!(
228 r#"(function() {{ var el = document.querySelector({sel}); if (!el) return null; return (el.value !== undefined ? el.value : el.textContent) || ''; }})()"#
229 );
230 let val = self.evaluate(&js).await?;
231 Ok(val.as_str().unwrap_or("").to_string())
232 }
233
234 async fn evaluate_await(&self, js: &str) -> Result<Value, BrowserError> {
236 self.evaluate(js).await
237 }
238
239 fn is_closed(&self) -> bool {
241 false
242 }
243
244 fn tab_id(&self) -> uuid::Uuid {
247 uuid::Uuid::nil()
248 }
249
250 fn as_any(&self) -> &dyn std::any::Any {
252 &std::marker::PhantomData::<()>
254 }
255
256 fn clear_progress_callback(&self) {}
259
260 fn set_browse_progress_callback(&self, _cb: BrowseProgressCallback) {}
263}
264
265#[async_trait]
272pub trait BrowserEngine: Send + Sync {
273 async fn fetch(&self, url: &str) -> Result<PageContent, BrowserError> {
275 let tab = self.new_tab().await?;
276 let content = tab.goto(url).await;
277 let _ = tab.close().await;
278 content
279 }
280
281 async fn new_tab(&self) -> Result<Box<dyn BrowserTab>, BrowserError>;
283
284 async fn close(&self) -> Result<(), BrowserError>;
286
287 async fn is_alive(&self) -> bool;
289
290 fn callback_registry(&self) -> Arc<TabCallbackRegistry> {
300 Arc::new(TabCallbackRegistry::new())
301 }
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
316#[serde(tag = "kind", rename_all = "snake_case")]
317#[non_exhaustive]
318pub enum BrowseProgress {
319 NavigationStarted {
321 url: String,
323 },
324
325 WaitingForSelector {
327 selector: String,
329 timeout_ms: u64,
331 },
332
333 DocumentReady {
336 url: String,
338 title: String,
340 status: u16,
342 bytes: u64,
344 duration_ms: u64,
346 },
347
348 ScreenshotCaptured {
350 bytes: usize,
352 width: u32,
354 duration_ms: u64,
356 },
357
358 NavigationFailed {
360 url: String,
362 error: String,
364 },
365}
366
367pub type BrowseProgressCallback = Arc<dyn Fn(BrowseProgress) + Send + Sync>;
371
372#[derive(Default)]
378struct TabCallbacks {
379 progress: Option<crate::tools::ProgressCallback>,
381 browse: Option<BrowseProgressCallback>,
383}
384
385pub struct TabCallbackRegistry {
396 entries: Mutex<HashMap<uuid::Uuid, TabCallbacks>>,
397}
398
399impl Default for TabCallbackRegistry {
400 fn default() -> Self {
401 Self::new()
402 }
403}
404
405impl TabCallbackRegistry {
406 pub fn new() -> Self {
408 Self {
409 entries: Mutex::new(HashMap::new()),
410 }
411 }
412
413 pub fn set(&self, tab_id: uuid::Uuid, cb: crate::tools::ProgressCallback) {
415 self.entries
416 .lock()
417 .entry(tab_id)
418 .or_default()
419 .progress = Some(cb);
420 }
421
422 pub fn set_browse(&self, tab_id: uuid::Uuid, cb: BrowseProgressCallback) {
424 self.entries
425 .lock()
426 .entry(tab_id)
427 .or_default()
428 .browse = Some(cb);
429 }
430
431 pub fn clear(&self, tab_id: &uuid::Uuid) {
433 self.entries.lock().remove(tab_id);
434 }
435
436 pub fn invoke(&self, tab_id: &uuid::Uuid, msg: String) {
438 if let Some(entry) = self.entries.lock().get(tab_id) {
439 if let Some(ref cb) = entry.progress {
440 cb(msg);
441 }
442 }
443 }
444
445 pub fn invoke_browse(&self, tab_id: &uuid::Uuid, progress: BrowseProgress) {
447 if let Some(entry) = self.entries.lock().get(tab_id) {
448 if let Some(ref cb) = entry.browse {
449 cb(progress);
450 }
451 }
452 }
453
454 pub fn is_set(&self, tab_id: &uuid::Uuid) -> bool {
456 self.entries.lock().contains_key(tab_id)
457 }
458
459 pub fn len(&self) -> usize {
461 self.entries.lock().len()
462 }
463
464 pub fn is_empty(&self) -> bool {
466 self.entries.lock().is_empty()
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473 use std::sync::atomic::{AtomicUsize, Ordering};
474
475 #[test]
476 fn tab_callback_registry_default_is_empty() {
477 let reg = TabCallbackRegistry::new();
478 assert!(reg.is_empty());
479 assert_eq!(reg.len(), 0);
480 let nil = uuid::Uuid::nil();
482 reg.invoke(&nil, "should be dropped".into());
483 }
484
485 #[test]
486 fn tab_callback_registry_set_and_invoke() {
487 let reg = TabCallbackRegistry::new();
488 let tab_a = uuid::Uuid::new_v4();
489 let tab_b = uuid::Uuid::new_v4();
490 let count = Arc::new(AtomicUsize::new(0));
491 let count_clone = Arc::clone(&count);
492 reg.set(
493 tab_a,
494 oxi_ai::progress_callback(move |msg: String| {
495 assert_eq!(msg, "hello");
496 count_clone.fetch_add(1, Ordering::SeqCst);
497 }),
498 );
499 assert!(reg.is_set(&tab_a));
500 assert!(!reg.is_set(&tab_b));
501
502 reg.invoke(&tab_a, "hello".into());
503 reg.invoke(&tab_a, "hello".into());
504 reg.invoke(&tab_b, "hello".into());
506 assert_eq!(count.load(Ordering::SeqCst), 2);
507 }
508
509 #[test]
510 fn tab_callback_registry_set_per_tab_isolation() {
511 let reg = TabCallbackRegistry::new();
512 let tab_a = uuid::Uuid::new_v4();
513 let tab_b = uuid::Uuid::new_v4();
514 let count_a = Arc::new(AtomicUsize::new(0));
515 let count_b = Arc::new(AtomicUsize::new(0));
516
517 let ca = Arc::clone(&count_a);
518 reg.set(
519 tab_a,
520 oxi_ai::progress_callback(move |_| {
521 ca.fetch_add(1, Ordering::SeqCst);
522 }),
523 );
524 let cb_clone = Arc::clone(&count_b);
525 reg.set(
526 tab_b,
527 oxi_ai::progress_callback(move |_| {
528 cb_clone.fetch_add(1, Ordering::SeqCst);
529 }),
530 );
531
532 reg.invoke(&tab_a, "event".into());
533 assert_eq!(count_a.load(Ordering::SeqCst), 1);
534 assert_eq!(count_b.load(Ordering::SeqCst), 0);
535
536 reg.invoke(&tab_b, "event".into());
537 assert_eq!(count_a.load(Ordering::SeqCst), 1);
538 assert_eq!(count_b.load(Ordering::SeqCst), 1);
539 }
540
541 #[test]
542 fn tab_callback_registry_clear() {
543 let reg = TabCallbackRegistry::new();
544 let tab_a = uuid::Uuid::new_v4();
545 let count = Arc::new(AtomicUsize::new(0));
546 let c = Arc::clone(&count);
547 reg.set(
548 tab_a,
549 oxi_ai::progress_callback(move |_| {
550 c.fetch_add(1, Ordering::SeqCst);
551 }),
552 );
553 reg.invoke(&tab_a, "x".into());
554 assert_eq!(count.load(Ordering::SeqCst), 1);
555
556 reg.clear(&tab_a);
557 assert!(!reg.is_set(&tab_a));
558 reg.invoke(&tab_a, "y".into());
559 assert_eq!(
560 count.load(Ordering::SeqCst),
561 1,
562 "invoke after clear is no-op"
563 );
564 }
565
566 #[test]
567 fn page_content_empty() {
568 let p = PageContent::empty();
569 assert!(p.url.is_empty());
570 assert_eq!(p.status, 0);
571 }
572
573 #[test]
574 fn browser_error_display() {
575 let e = BrowserError::Navigation("connection refused".into());
576 assert!(e.to_string().contains("navigation failed"));
577 }
578
579 #[test]
580 fn link_info_serde() {
581 let link = LinkInfo {
582 text: "Example".into(),
583 href: "https://example.com".into(),
584 };
585 let json = serde_json::to_string(&link).unwrap();
586 let restored: LinkInfo = serde_json::from_str(&json).unwrap();
587 assert_eq!(restored.text, "Example");
588 assert_eq!(restored.href, "https://example.com");
589 }
590
591 #[test]
592 fn element_info_serde() {
593 let elem = ElementInfo {
594 tag: "DIV".into(),
595 text: "Hello".into(),
596 attributes: [("class".into(), "item".into())].into(),
597 };
598 let json = serde_json::to_string(&elem).unwrap();
599 assert!(json.contains("DIV"));
600 assert!(json.contains("Hello"));
601 }
602
603 #[test]
604 fn browser_error_no_active_session() {
605 let e = BrowserError::NoActiveSession;
606 assert!(e.to_string().contains("no active session"));
607 }
608
609 #[test]
612 fn tab_callback_registry_browse_set_and_invoke() {
613 let reg = TabCallbackRegistry::new();
614 let tab = uuid::Uuid::new_v4();
615 let received: Arc<std::sync::Mutex<Vec<BrowseProgress>>> =
616 Arc::new(std::sync::Mutex::new(Vec::new()));
617 let r = Arc::clone(&received);
618 reg.set_browse(
619 tab,
620 Arc::new(move |bp: BrowseProgress| {
621 r.lock().unwrap().push(bp);
622 }),
623 );
624
625 let progress = BrowseProgress::DocumentReady {
626 url: "https://example.com".into(),
627 title: "Example".into(),
628 status: 200,
629 bytes: 1024,
630 duration_ms: 500,
631 };
632 reg.invoke_browse(&tab, progress.clone());
633
634 let events = received.lock().unwrap();
635 assert_eq!(events.len(), 1);
636 assert!(matches!(&events[0], BrowseProgress::DocumentReady { status: 200, .. }));
637 }
638
639 #[test]
640 fn tab_callback_registry_browse_clear_removes_both() {
641 let reg = TabCallbackRegistry::new();
642 let tab = uuid::Uuid::new_v4();
643
644 reg.set(
646 tab,
647 oxi_ai::progress_callback(move |_| {}),
648 );
649 reg.set_browse(
650 tab,
651 Arc::new(move |_: BrowseProgress| {}),
652 );
653 assert!(reg.is_set(&tab));
654
655 reg.clear(&tab);
657 assert!(!reg.is_set(&tab));
658 assert!(reg.is_empty());
659 }
660
661 #[test]
662 fn tab_callback_registry_browse_isolation_per_tab() {
663 let reg = TabCallbackRegistry::new();
664 let tab_a = uuid::Uuid::new_v4();
665 let tab_b = uuid::Uuid::new_v4();
666
667 let count_a = Arc::new(AtomicUsize::new(0));
668 let count_b = Arc::new(AtomicUsize::new(0));
669
670 let ca = Arc::clone(&count_a);
671 reg.set_browse(tab_a, Arc::new(move |_: BrowseProgress| {
672 ca.fetch_add(1, Ordering::SeqCst);
673 }));
674 let cb2 = Arc::clone(&count_b);
675 reg.set_browse(tab_b, Arc::new(move |_: BrowseProgress| {
676 cb2.fetch_add(1, Ordering::SeqCst);
677 }));
678
679 let doc_ready = BrowseProgress::DocumentReady {
680 url: "https://example.com".into(),
681 title: "Example".into(),
682 status: 200,
683 bytes: 1024,
684 duration_ms: 100,
685 };
686 reg.invoke_browse(&tab_a, doc_ready.clone());
687 assert_eq!(count_a.load(Ordering::SeqCst), 1);
688 assert_eq!(count_b.load(Ordering::SeqCst), 0);
689
690 reg.invoke_browse(&tab_b, doc_ready);
691 assert_eq!(count_a.load(Ordering::SeqCst), 1);
692 assert_eq!(count_b.load(Ordering::SeqCst), 1);
693 }
694
695 #[test]
696 fn browse_progress_serde_roundtrip() {
697 let variants = vec![
698 BrowseProgress::NavigationStarted {
699 url: "https://example.com".into(),
700 },
701 BrowseProgress::WaitingForSelector {
702 selector: ".content".into(),
703 timeout_ms: 5000,
704 },
705 BrowseProgress::DocumentReady {
706 url: "https://example.com/page".into(),
707 title: "Test Page".into(),
708 status: 200,
709 bytes: 4096,
710 duration_ms: 1234,
711 },
712 BrowseProgress::ScreenshotCaptured {
713 bytes: 8192,
714 width: 1280,
715 duration_ms: 200,
716 },
717 BrowseProgress::NavigationFailed {
718 url: "https://fail.example.com".into(),
719 error: "connection refused".into(),
720 },
721 ];
722
723 for bp in &variants {
724 let json = serde_json::to_string(bp).unwrap();
725 let restored: BrowseProgress = serde_json::from_str(&json).unwrap();
726 let json2 = serde_json::to_string(&restored).unwrap();
727 assert_eq!(json, json2, "roundtrip failed for {:?}", bp);
728 }
729 }
730}