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.lock().entry(tab_id).or_default().progress = Some(cb);
416 }
417
418 pub fn set_browse(&self, tab_id: uuid::Uuid, cb: BrowseProgressCallback) {
420 self.entries.lock().entry(tab_id).or_default().browse = Some(cb);
421 }
422
423 pub fn clear(&self, tab_id: &uuid::Uuid) {
425 self.entries.lock().remove(tab_id);
426 }
427
428 pub fn invoke(&self, tab_id: &uuid::Uuid, msg: String) {
430 if let Some(entry) = self.entries.lock().get(tab_id) {
431 if let Some(ref cb) = entry.progress {
432 cb(msg);
433 }
434 }
435 }
436
437 pub fn invoke_browse(&self, tab_id: &uuid::Uuid, progress: BrowseProgress) {
439 if let Some(entry) = self.entries.lock().get(tab_id) {
440 if let Some(ref cb) = entry.browse {
441 cb(progress);
442 }
443 }
444 }
445
446 pub fn is_set(&self, tab_id: &uuid::Uuid) -> bool {
448 self.entries.lock().contains_key(tab_id)
449 }
450
451 pub fn len(&self) -> usize {
453 self.entries.lock().len()
454 }
455
456 pub fn is_empty(&self) -> bool {
458 self.entries.lock().is_empty()
459 }
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use std::sync::atomic::{AtomicUsize, Ordering};
466
467 #[test]
468 fn tab_callback_registry_default_is_empty() {
469 let reg = TabCallbackRegistry::new();
470 assert!(reg.is_empty());
471 assert_eq!(reg.len(), 0);
472 let nil = uuid::Uuid::nil();
474 reg.invoke(&nil, "should be dropped".into());
475 }
476
477 #[test]
478 fn tab_callback_registry_set_and_invoke() {
479 let reg = TabCallbackRegistry::new();
480 let tab_a = uuid::Uuid::new_v4();
481 let tab_b = uuid::Uuid::new_v4();
482 let count = Arc::new(AtomicUsize::new(0));
483 let count_clone = Arc::clone(&count);
484 reg.set(
485 tab_a,
486 oxi_ai::progress_callback(move |msg: String| {
487 assert_eq!(msg, "hello");
488 count_clone.fetch_add(1, Ordering::SeqCst);
489 }),
490 );
491 assert!(reg.is_set(&tab_a));
492 assert!(!reg.is_set(&tab_b));
493
494 reg.invoke(&tab_a, "hello".into());
495 reg.invoke(&tab_a, "hello".into());
496 reg.invoke(&tab_b, "hello".into());
498 assert_eq!(count.load(Ordering::SeqCst), 2);
499 }
500
501 #[test]
502 fn tab_callback_registry_set_per_tab_isolation() {
503 let reg = TabCallbackRegistry::new();
504 let tab_a = uuid::Uuid::new_v4();
505 let tab_b = uuid::Uuid::new_v4();
506 let count_a = Arc::new(AtomicUsize::new(0));
507 let count_b = Arc::new(AtomicUsize::new(0));
508
509 let ca = Arc::clone(&count_a);
510 reg.set(
511 tab_a,
512 oxi_ai::progress_callback(move |_| {
513 ca.fetch_add(1, Ordering::SeqCst);
514 }),
515 );
516 let cb_clone = Arc::clone(&count_b);
517 reg.set(
518 tab_b,
519 oxi_ai::progress_callback(move |_| {
520 cb_clone.fetch_add(1, Ordering::SeqCst);
521 }),
522 );
523
524 reg.invoke(&tab_a, "event".into());
525 assert_eq!(count_a.load(Ordering::SeqCst), 1);
526 assert_eq!(count_b.load(Ordering::SeqCst), 0);
527
528 reg.invoke(&tab_b, "event".into());
529 assert_eq!(count_a.load(Ordering::SeqCst), 1);
530 assert_eq!(count_b.load(Ordering::SeqCst), 1);
531 }
532
533 #[test]
534 fn tab_callback_registry_clear() {
535 let reg = TabCallbackRegistry::new();
536 let tab_a = uuid::Uuid::new_v4();
537 let count = Arc::new(AtomicUsize::new(0));
538 let c = Arc::clone(&count);
539 reg.set(
540 tab_a,
541 oxi_ai::progress_callback(move |_| {
542 c.fetch_add(1, Ordering::SeqCst);
543 }),
544 );
545 reg.invoke(&tab_a, "x".into());
546 assert_eq!(count.load(Ordering::SeqCst), 1);
547
548 reg.clear(&tab_a);
549 assert!(!reg.is_set(&tab_a));
550 reg.invoke(&tab_a, "y".into());
551 assert_eq!(
552 count.load(Ordering::SeqCst),
553 1,
554 "invoke after clear is no-op"
555 );
556 }
557
558 #[test]
559 fn page_content_empty() {
560 let p = PageContent::empty();
561 assert!(p.url.is_empty());
562 assert_eq!(p.status, 0);
563 }
564
565 #[test]
566 fn browser_error_display() {
567 let e = BrowserError::Navigation("connection refused".into());
568 assert!(e.to_string().contains("navigation failed"));
569 }
570
571 #[test]
572 fn link_info_serde() {
573 let link = LinkInfo {
574 text: "Example".into(),
575 href: "https://example.com".into(),
576 };
577 let json = serde_json::to_string(&link).unwrap();
578 let restored: LinkInfo = serde_json::from_str(&json).unwrap();
579 assert_eq!(restored.text, "Example");
580 assert_eq!(restored.href, "https://example.com");
581 }
582
583 #[test]
584 fn element_info_serde() {
585 let elem = ElementInfo {
586 tag: "DIV".into(),
587 text: "Hello".into(),
588 attributes: [("class".into(), "item".into())].into(),
589 };
590 let json = serde_json::to_string(&elem).unwrap();
591 assert!(json.contains("DIV"));
592 assert!(json.contains("Hello"));
593 }
594
595 #[test]
596 fn browser_error_no_active_session() {
597 let e = BrowserError::NoActiveSession;
598 assert!(e.to_string().contains("no active session"));
599 }
600
601 #[test]
604 fn tab_callback_registry_browse_set_and_invoke() {
605 let reg = TabCallbackRegistry::new();
606 let tab = uuid::Uuid::new_v4();
607 let received: Arc<std::sync::Mutex<Vec<BrowseProgress>>> =
608 Arc::new(std::sync::Mutex::new(Vec::new()));
609 let r = Arc::clone(&received);
610 reg.set_browse(
611 tab,
612 Arc::new(move |bp: BrowseProgress| {
613 r.lock().unwrap().push(bp);
614 }),
615 );
616
617 let progress = BrowseProgress::DocumentReady {
618 url: "https://example.com".into(),
619 title: "Example".into(),
620 status: 200,
621 bytes: 1024,
622 duration_ms: 500,
623 };
624 reg.invoke_browse(&tab, progress.clone());
625
626 let events = received.lock().unwrap();
627 assert_eq!(events.len(), 1);
628 assert!(matches!(
629 &events[0],
630 BrowseProgress::DocumentReady { status: 200, .. }
631 ));
632 }
633
634 #[test]
635 fn tab_callback_registry_browse_clear_removes_both() {
636 let reg = TabCallbackRegistry::new();
637 let tab = uuid::Uuid::new_v4();
638
639 reg.set(tab, oxi_ai::progress_callback(move |_| {}));
641 reg.set_browse(tab, Arc::new(move |_: BrowseProgress| {}));
642 assert!(reg.is_set(&tab));
643
644 reg.clear(&tab);
646 assert!(!reg.is_set(&tab));
647 assert!(reg.is_empty());
648 }
649
650 #[test]
651 fn tab_callback_registry_browse_isolation_per_tab() {
652 let reg = TabCallbackRegistry::new();
653 let tab_a = uuid::Uuid::new_v4();
654 let tab_b = uuid::Uuid::new_v4();
655
656 let count_a = Arc::new(AtomicUsize::new(0));
657 let count_b = Arc::new(AtomicUsize::new(0));
658
659 let ca = Arc::clone(&count_a);
660 reg.set_browse(
661 tab_a,
662 Arc::new(move |_: BrowseProgress| {
663 ca.fetch_add(1, Ordering::SeqCst);
664 }),
665 );
666 let cb2 = Arc::clone(&count_b);
667 reg.set_browse(
668 tab_b,
669 Arc::new(move |_: BrowseProgress| {
670 cb2.fetch_add(1, Ordering::SeqCst);
671 }),
672 );
673
674 let doc_ready = BrowseProgress::DocumentReady {
675 url: "https://example.com".into(),
676 title: "Example".into(),
677 status: 200,
678 bytes: 1024,
679 duration_ms: 100,
680 };
681 reg.invoke_browse(&tab_a, doc_ready.clone());
682 assert_eq!(count_a.load(Ordering::SeqCst), 1);
683 assert_eq!(count_b.load(Ordering::SeqCst), 0);
684
685 reg.invoke_browse(&tab_b, doc_ready);
686 assert_eq!(count_a.load(Ordering::SeqCst), 1);
687 assert_eq!(count_b.load(Ordering::SeqCst), 1);
688 }
689
690 #[test]
691 fn browse_progress_serde_roundtrip() {
692 let variants = vec![
693 BrowseProgress::NavigationStarted {
694 url: "https://example.com".into(),
695 },
696 BrowseProgress::WaitingForSelector {
697 selector: ".content".into(),
698 timeout_ms: 5000,
699 },
700 BrowseProgress::DocumentReady {
701 url: "https://example.com/page".into(),
702 title: "Test Page".into(),
703 status: 200,
704 bytes: 4096,
705 duration_ms: 1234,
706 },
707 BrowseProgress::ScreenshotCaptured {
708 bytes: 8192,
709 width: 1280,
710 duration_ms: 200,
711 },
712 BrowseProgress::NavigationFailed {
713 url: "https://fail.example.com".into(),
714 error: "connection refused".into(),
715 },
716 ];
717
718 for bp in &variants {
719 let json = serde_json::to_string(bp).unwrap();
720 let restored: BrowseProgress = serde_json::from_str(&json).unwrap();
721 let json2 = serde_json::to_string(&restored).unwrap();
722 assert_eq!(json, json2, "roundtrip failed for {:?}", bp);
723 }
724 }
725}