#![allow(missing_docs)]
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
#[derive(Debug, thiserror::Error)]
pub enum BrowserError {
#[error("navigation failed: {0}")]
Navigation(String),
#[error("element not found: {0}")]
ElementNotFound(String),
#[error("timeout: {0}")]
Timeout(String),
#[error("evaluation error: {0}")]
Evaluation(String),
#[error("screenshot failed: {0}")]
Screenshot(String),
#[error("tab closed: {0}")]
TabClosed(String),
#[error("browser error: {0}")]
Backend(String),
#[error("no active session — call 'open' first")]
NoActiveSession,
}
impl From<BrowserError> for crate::tools::ToolError {
fn from(e: BrowserError) -> Self {
e.to_string()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageContent {
pub url: String,
pub title: String,
pub status: u16,
pub markdown: String,
#[serde(default)]
pub html: String,
}
impl PageContent {
pub fn empty() -> Self {
Self {
url: String::new(),
title: String::new(),
status: 0,
markdown: String::new(),
html: String::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinkInfo {
#[allow(missing_docs)]
pub text: String,
#[allow(missing_docs)]
pub href: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ElementInfo {
#[allow(missing_docs)]
pub tag: String,
#[allow(missing_docs)]
pub text: String,
#[serde(default)]
#[allow(missing_docs)]
pub attributes: HashMap<String, String>,
}
#[async_trait]
pub trait BrowserTab: Send + Sync {
async fn goto(&self, url: &str) -> Result<PageContent, BrowserError>;
async fn click(&self, selector: &str) -> Result<(), BrowserError>;
async fn type_(&self, selector: &str, text: &str) -> Result<(), BrowserError>;
async fn fill(&self, selector: &str, value: &str) -> Result<(), BrowserError>;
async fn press(&self, combo: &str) -> Result<(), BrowserError>;
async fn wait_for(&self, selector: &str, timeout_ms: u64) -> Result<(), BrowserError>;
async fn content(&self) -> Result<PageContent, BrowserError>;
async fn query_all(&self, selector: &str) -> Result<Vec<String>, BrowserError>;
async fn evaluate(&self, js: &str) -> Result<Value, BrowserError>;
async fn screenshot(&self, width: u32) -> Result<Vec<u8>, BrowserError>;
async fn close(&self) -> Result<(), BrowserError>;
async fn select_option(&self, selector: &str, value: &str) -> Result<(), BrowserError>;
async fn check(&self, selector: &str) -> Result<(), BrowserError>;
async fn uncheck(&self, selector: &str) -> Result<(), BrowserError>;
async fn clear(&self, selector: &str) -> Result<(), BrowserError> {
self.fill(selector, "").await
}
async fn hover(&self, selector: &str) -> Result<(), BrowserError> {
let sel = serde_json::to_string(selector).unwrap_or_default();
let js = format!(
r#"(function() {{ var el = document.querySelector({sel}); if (!el) return null; el.dispatchEvent(new MouseEvent('mouseover', {{bubbles:true}})); return el.tagName; }})()"#
);
self.evaluate(&js).await.map(|_| ())
}
async fn double_click(&self, selector: &str) -> Result<(), BrowserError> {
let sel = serde_json::to_string(selector).unwrap_or_default();
let js = format!(
r#"(function() {{ var el = document.querySelector({sel}); if (!el) return null; el.dispatchEvent(new MouseEvent('dblclick', {{bubbles:true}})); return el.tagName; }})()"#
);
self.evaluate(&js).await.map(|_| ())
}
async fn right_click(&self, selector: &str) -> Result<(), BrowserError> {
let sel = serde_json::to_string(selector).unwrap_or_default();
let js = format!(
r#"(function() {{ var el = document.querySelector({sel}); if (!el) return null; el.dispatchEvent(new MouseEvent('contextmenu', {{bubbles:true, button:2}})); return el.tagName; }})()"#
);
self.evaluate(&js).await.map(|_| ())
}
async fn scroll(&self, delta_x: f64, delta_y: f64) -> Result<(), BrowserError> {
let js = format!("window.scrollBy({}, {})", delta_x, delta_y);
self.evaluate(&js).await.map(|_| ())
}
async fn scroll_into_view(&self, selector: &str) -> Result<(), BrowserError> {
let sel = serde_json::to_string(selector).unwrap_or_default();
let js = format!(
r#"(function() {{ var el = document.querySelector({sel}); if (!el) return null; el.scrollIntoView(); return el.tagName; }})()"#
);
self.evaluate(&js).await.map(|_| ())
}
async fn drag(&self, from_selector: &str, to_selector: &str) -> Result<(), BrowserError> {
let from_sel = serde_json::to_string(from_selector).unwrap_or_default();
let to_sel = serde_json::to_string(to_selector).unwrap_or_default();
let js = format!(
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'; }})()"#
);
self.evaluate(&js).await.map(|_| ())
}
async fn upload_file(&self, selector: &str, path: &str) -> Result<(), BrowserError> {
let sel = serde_json::to_string(selector).unwrap_or_default();
let p = serde_json::to_string(path).unwrap_or_default();
let js = format!(
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; }})()"#
);
self.evaluate(&js).await.map(|_| ())
}
async fn get_value(&self, selector: &str) -> Result<String, BrowserError> {
let sel = serde_json::to_string(selector).unwrap_or_default();
let js = format!(
r#"(function() {{ var el = document.querySelector({sel}); if (!el) return null; return (el.value !== undefined ? el.value : el.textContent) || ''; }})()"#
);
let val = self.evaluate(&js).await?;
Ok(val.as_str().unwrap_or("").to_string())
}
async fn evaluate_await(&self, js: &str) -> Result<Value, BrowserError> {
self.evaluate(js).await
}
fn is_closed(&self) -> bool {
false
}
}
#[async_trait]
pub trait BrowserEngine: Send + Sync {
async fn fetch(&self, url: &str) -> Result<PageContent, BrowserError> {
let tab = self.new_tab().await?;
let content = tab.goto(url).await;
let _ = tab.close().await;
content
}
async fn new_tab(&self) -> Result<Box<dyn BrowserTab>, BrowserError>;
async fn close(&self) -> Result<(), BrowserError>;
async fn is_alive(&self) -> bool;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn page_content_empty() {
let p = PageContent::empty();
assert!(p.url.is_empty());
assert_eq!(p.status, 0);
}
#[test]
fn browser_error_display() {
let e = BrowserError::Navigation("connection refused".into());
assert!(e.to_string().contains("navigation failed"));
}
#[test]
fn link_info_serde() {
let link = LinkInfo {
text: "Example".into(),
href: "https://example.com".into(),
};
let json = serde_json::to_string(&link).unwrap();
let restored: LinkInfo = serde_json::from_str(&json).unwrap();
assert_eq!(restored.text, "Example");
assert_eq!(restored.href, "https://example.com");
}
#[test]
fn element_info_serde() {
let elem = ElementInfo {
tag: "DIV".into(),
text: "Hello".into(),
attributes: [("class".into(), "item".into())].into(),
};
let json = serde_json::to_string(&elem).unwrap();
assert!(json.contains("DIV"));
assert!(json.contains("Hello"));
}
#[test]
fn browser_error_no_active_session() {
let e = BrowserError::NoActiveSession;
assert!(e.to_string().contains("no active session"));
}
}