// Browser client using Fantoccini WebDriver use crate::browser::error::{BrowserError, Result}; use crate::browser::session::{BrowserSession, BrowserType, SessionConfig, SessionManager}; use base64::{engine::general_purpose::STANDARD, Engine}; use fantoccini::elements::Element; use fantoccini::{Client, ClientBuilder, Locator}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use tokio::sync::RwLock; use uuid::Uuid; /// Main browser automation client pub struct BrowserClient { /// Active WebDriver connections connections: Arc>>, /// Session manager session_manager: SessionManager, } impl BrowserClient { pub fn new() -> Self { Self { connections: Arc::new(RwLock::new(HashMap::new())), session_manager: SessionManager::new(), } } /// Create a new browser session pub async fn create_session(&self, config: SessionConfig) -> Result { let session_id = Uuid::new_v4().to_string(); // Build WebDriver capabilities as Map let capabilities = self.build_capabilities(&config)?; // Connect to WebDriver let client = ClientBuilder::native() .capabilities(capabilities) .connect(&config.webdriver_url) .await .map_err(|e| BrowserError::ConnectionFailed(e.to_string()))?; // Store connection { let mut connections = self.connections.write().await; connections.insert(session_id.clone(), client); } // Create session record let session = BrowserSession::new(session_id.clone(), config); self.session_manager.add_session(session).await; Ok(session_id) } /// Close a browser session pub async fn close_session(&self, session_id: &str) -> Result<()> { let client = self.get_client(session_id).await?; // Close the browser client .close() .await .map_err(|e| BrowserError::ConnectionFailed(e.to_string()))?; // Remove from connections { let mut connections = self.connections.write().await; connections.remove(session_id); } // Remove session record self.session_manager.remove_session(session_id).await; Ok(()) } /// Get session information pub async fn get_session(&self, session_id: &str) -> Result { self.session_manager .get_session(session_id) .await .ok_or_else(|| BrowserError::SessionNotFound(session_id.to_string())) } /// List all sessions pub async fn list_sessions(&self) -> Vec { self.session_manager.list_sessions().await } /// Navigate to URL pub async fn navigate(&self, session_id: &str, url: &str) -> Result { let client = self.get_client(session_id).await?; client .goto(url) .await .map_err(|_| BrowserError::NavigationFailed { url: url.to_string(), })?; // Get current URL and title let current_url = client.current_url().await.ok().map(|u| u.to_string()); let title = client.title().await.ok(); // Update session self.session_manager .update_session(session_id, |s| { s.update_location(current_url.clone(), title.clone()); }) .await; Ok(NavigationResult { url: current_url, title, }) } /// Go back pub async fn back(&self, session_id: &str) -> Result<()> { let client = self.get_client(session_id).await?; client.back().await.map_err(|e| BrowserError::CommandFailed(e.to_string()))?; self.update_session_location(session_id).await; Ok(()) } /// Go forward pub async fn forward(&self, session_id: &str) -> Result<()> { let client = self.get_client(session_id).await?; client.forward().await.map_err(|e| BrowserError::CommandFailed(e.to_string()))?; self.update_session_location(session_id).await; Ok(()) } /// Refresh page pub async fn refresh(&self, session_id: &str) -> Result<()> { let client = self.get_client(session_id).await?; client.refresh().await.map_err(|e| BrowserError::CommandFailed(e.to_string()))?; Ok(()) } /// Find element by CSS selector pub async fn find_element(&self, session_id: &str, selector: &str) -> Result { let client = self.get_client(session_id).await?; let element = client .find(Locator::Css(selector)) .await .map_err(|_| BrowserError::ElementNotFound { selector: selector.to_string(), })?; self.element_to_info(&element, selector).await } /// Find multiple elements pub async fn find_elements(&self, session_id: &str, selector: &str) -> Result> { let client = self.get_client(session_id).await?; let elements = client .find_all(Locator::Css(selector)) .await .map_err(|_| BrowserError::ElementNotFound { selector: selector.to_string(), })?; let mut infos = Vec::new(); for element in elements { if let Ok(info) = self.element_to_info(&element, selector).await { infos.push(info); } } Ok(infos) } /// Click element pub async fn click(&self, session_id: &str, selector: &str) -> Result<()> { let client = self.get_client(session_id).await?; let element = client .find(Locator::Css(selector)) .await .map_err(|_| BrowserError::ElementNotFound { selector: selector.to_string(), })?; element.click().await.map_err(|e| BrowserError::CommandFailed(e.to_string()))?; self.update_session_location(session_id).await; Ok(()) } /// Type text into element pub async fn type_text(&self, session_id: &str, selector: &str, text: &str) -> Result<()> { let client = self.get_client(session_id).await?; let element = client .find(Locator::Css(selector)) .await .map_err(|_| BrowserError::ElementNotFound { selector: selector.to_string(), })?; element.send_keys(text).await.map_err(|e| BrowserError::CommandFailed(e.to_string()))?; Ok(()) } /// Clear and type text pub async fn clear_and_type(&self, session_id: &str, selector: &str, text: &str) -> Result<()> { let client = self.get_client(session_id).await?; let element = client .find(Locator::Css(selector)) .await .map_err(|_| BrowserError::ElementNotFound { selector: selector.to_string(), })?; element.clear().await.map_err(|e| BrowserError::CommandFailed(e.to_string()))?; element.send_keys(text).await.map_err(|e| BrowserError::CommandFailed(e.to_string()))?; Ok(()) } /// Get element text pub async fn get_text(&self, session_id: &str, selector: &str) -> Result { let client = self.get_client(session_id).await?; let element = client .find(Locator::Css(selector)) .await .map_err(|_| BrowserError::ElementNotFound { selector: selector.to_string(), })?; element.text().await.map_err(|e| BrowserError::CommandFailed(e.to_string())) } /// Get element attribute pub async fn get_attribute( &self, session_id: &str, selector: &str, attribute: &str, ) -> Result> { let client = self.get_client(session_id).await?; let element = client .find(Locator::Css(selector)) .await .map_err(|_| BrowserError::ElementNotFound { selector: selector.to_string(), })?; element.attr(attribute).await.map_err(|e| BrowserError::CommandFailed(e.to_string())) } /// Execute JavaScript pub async fn execute_script( &self, session_id: &str, script: &str, args: Vec, ) -> Result { let client = self.get_client(session_id).await?; client.execute(script, args).await.map_err(|e| BrowserError::ScriptError { message: e.to_string(), }) } /// Take screenshot pub async fn screenshot(&self, session_id: &str) -> Result { let client = self.get_client(session_id).await?; let screenshot = client.screenshot().await.map_err(|e| BrowserError::ScreenshotFailed { reason: e.to_string(), })?; let base64_data = STANDARD.encode(&screenshot); Ok(ScreenshotResult { data: screenshot, base64: base64_data, format: "png".to_string(), }) } /// Take element screenshot pub async fn element_screenshot( &self, session_id: &str, selector: &str, ) -> Result { let client = self.get_client(session_id).await?; let element = client .find(Locator::Css(selector)) .await .map_err(|_| BrowserError::ElementNotFound { selector: selector.to_string(), })?; let screenshot = element.screenshot().await.map_err(|e| BrowserError::ScreenshotFailed { reason: e.to_string(), })?; let base64_data = STANDARD.encode(&screenshot); Ok(ScreenshotResult { data: screenshot, base64: base64_data, format: "png".to_string(), }) } /// Wait for element with custom timeout pub async fn wait_for_element( &self, session_id: &str, selector: &str, timeout_ms: u64, ) -> Result { let client = self.get_client(session_id).await?; let locator = Locator::Css(selector); // Use the new wait().for_element() API instead of deprecated wait_for_find let element = tokio::time::timeout( Duration::from_millis(timeout_ms), client.wait().for_element(locator) ) .await .map_err(|_| BrowserError::Timeout { selector: selector.to_string(), })? .map_err(|_| BrowserError::ElementNotFound { selector: selector.to_string(), })?; self.element_to_info(&element, selector).await } /// Get page source pub async fn get_source(&self, session_id: &str) -> Result { let client = self.get_client(session_id).await?; client.source().await.map_err(|e| BrowserError::CommandFailed(e.to_string())) } /// Get current URL pub async fn get_current_url(&self, session_id: &str) -> Result { let client = self.get_client(session_id).await?; let url = client.current_url().await.map_err(|e| BrowserError::CommandFailed(e.to_string()))?; Ok(url.to_string()) } /// Get page title pub async fn get_title(&self, session_id: &str) -> Result { let client = self.get_client(session_id).await?; client.title().await.map_err(|e| BrowserError::CommandFailed(e.to_string())) } // Private helper methods async fn get_client(&self, session_id: &str) -> Result { let connections = self.connections.read().await; connections .get(session_id) .cloned() .ok_or_else(|| BrowserError::SessionNotFound(session_id.to_string())) } fn build_capabilities(&self, config: &SessionConfig) -> Result> { let browser_name = match config.browser_type { BrowserType::Chrome => "chrome", BrowserType::Firefox => "firefox", BrowserType::Edge => "MicrosoftEdge", BrowserType::Safari => "safari", }; let mut args = vec![]; if config.headless { args.push("--headless".to_string()); } if let Some((width, height)) = config.window_size { args.push(format!("--window-size={},{}", width, height)); } args.extend(config.browser_args.clone()); let mut caps = serde_json::Map::new(); caps.insert("browserName".to_string(), serde_json::json!(browser_name)); let mut chrome_options = serde_json::Map::new(); chrome_options.insert("args".to_string(), serde_json::json!(args)); chrome_options.insert("w3c".to_string(), serde_json::json!(true)); caps.insert("goog:chromeOptions".to_string(), serde_json::Value::Object(chrome_options)); Ok(caps) } async fn element_to_info(&self, element: &Element, selector: &str) -> Result { let tag_name = element.tag_name().await.ok(); let text = element.text().await.ok(); let is_displayed = element.is_displayed().await.unwrap_or(false); let is_enabled = element.is_enabled().await.unwrap_or(false); let is_selected = element.is_selected().await.unwrap_or(false); // KNOWN LIMITATION: location and size are always None. // The fantoccini Element type does not expose a synchronous bounding-box // helper; retrieving geometry requires a separate execute_script call // (e.g. element.getBoundingClientRect()). Since no current caller relies // on these fields, they are intentionally left as None rather than adding // an extra round-trip. If bounding-box data is needed in the future, // add a dedicated browser_element_rect command that calls // execute_script("return arguments[0].getBoundingClientRect()") and // deprecate the location/size fields on ElementInfo entirely. let location = None; let size = None; Ok(ElementInfo { selector: selector.to_string(), tag_name, text, is_displayed, is_enabled, is_selected, location, size, }) } async fn update_session_location(&self, session_id: &str) { if let Ok(client) = self.get_client(session_id).await { let current_url = client.current_url().await.ok().map(|u| u.to_string()); let title = client.title().await.ok(); self.session_manager .update_session(session_id, |s| { s.update_location(current_url, title); }) .await; } } } impl Default for BrowserClient { fn default() -> Self { Self::new() } } impl Clone for BrowserClient { fn clone(&self) -> Self { Self { connections: Arc::clone(&self.connections), session_manager: self.session_manager.clone(), } } } // Result types #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NavigationResult { pub url: Option, pub title: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ElementInfo { pub selector: String, pub tag_name: Option, pub text: Option, pub is_displayed: bool, pub is_enabled: bool, pub is_selected: bool, pub location: Option, pub size: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ElementLocation { pub x: i32, pub y: i32, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ElementSize { pub width: u64, pub height: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ScreenshotResult { pub data: Vec, pub base64: String, pub format: String, }