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