//! Browser abstraction for E2E testing //! //! Provides a high-level interface for browser automation using fantoccini/WebDriver. //! Supports Chrome, Firefox, and Safari with both headless and headed modes. use anyhow::{Context, Result}; use fantoccini::{Client, ClientBuilder, Locator as FLocator}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; use std::time::Duration; use tokio::time::sleep; use super::{Cookie, Key, Locator, WaitCondition}; /// Browser type for E2E testing #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum BrowserType { Chrome, Firefox, Safari, Edge, } impl Default for BrowserType { fn default() -> Self { Self::Chrome } } impl BrowserType { /// Get the WebDriver capability name for this browser pub fn capability_name(&self) -> &'static str { match self { BrowserType::Chrome => "goog:chromeOptions", BrowserType::Firefox => "moz:firefoxOptions", BrowserType::Safari => "safari:options", BrowserType::Edge => "ms:edgeOptions", } } /// Get the browser name for WebDriver pub fn browser_name(&self) -> &'static str { match self { BrowserType::Chrome => "chrome", BrowserType::Firefox => "firefox", BrowserType::Safari => "safari", BrowserType::Edge => "MicrosoftEdge", } } } /// Configuration for browser sessions #[derive(Debug, Clone)] pub struct BrowserConfig { /// Browser type pub browser_type: BrowserType, /// WebDriver URL pub webdriver_url: String, /// Whether to run headless pub headless: bool, /// Window width pub window_width: u32, /// Window height pub window_height: u32, /// Default timeout for operations pub timeout: Duration, /// Whether to accept insecure certificates pub accept_insecure_certs: bool, /// Additional browser arguments pub browser_args: Vec, /// Additional capabilities pub capabilities: HashMap, /// Browser binary path (for Brave/Chromium variants) pub binary_path: Option, } impl Default for BrowserConfig { fn default() -> Self { Self { browser_type: BrowserType::Chrome, webdriver_url: "http://localhost:4444".to_string(), headless: std::env::var("HEADED").is_err(), window_width: 1920, window_height: 1080, timeout: Duration::from_secs(30), accept_insecure_certs: true, browser_args: Vec::new(), capabilities: HashMap::new(), binary_path: None, } } } impl BrowserConfig { /// Create a new browser config pub fn new() -> Self { Self::default() } /// Set browser type pub fn with_browser(mut self, browser: BrowserType) -> Self { self.browser_type = browser; self } /// Set WebDriver URL pub fn with_webdriver_url(mut self, url: &str) -> Self { self.webdriver_url = url.to_string(); self } /// Set headless mode pub fn headless(mut self, headless: bool) -> Self { self.headless = headless; self } /// Set window size pub fn with_window_size(mut self, width: u32, height: u32) -> Self { self.window_width = width; self.window_height = height; self } /// Set default timeout pub fn with_timeout(mut self, timeout: Duration) -> Self { self.timeout = timeout; self } /// Add a browser argument pub fn with_arg(mut self, arg: &str) -> Self { self.browser_args.push(arg.to_string()); self } /// Set browser binary path (for Brave, Chromium variants) pub fn with_binary(mut self, path: &str) -> Self { self.binary_path = Some(path.to_string()); self } /// Build WebDriver capabilities pub fn build_capabilities(&self) -> serde_json::Value { let mut caps = serde_json::json!({ "browserName": self.browser_type.browser_name(), "acceptInsecureCerts": self.accept_insecure_certs, }); // Add browser-specific options let mut browser_options = serde_json::json!({}); // Build args list let mut args: Vec = self.browser_args.clone(); if self.headless { match self.browser_type { BrowserType::Chrome | BrowserType::Edge => { args.push("--headless=new".to_string()); args.push("--disable-gpu".to_string()); args.push("--no-sandbox".to_string()); args.push("--disable-dev-shm-usage".to_string()); } BrowserType::Firefox => { args.push("-headless".to_string()); } BrowserType::Safari => { // Safari doesn't support headless mode directly } } } // Set window size args.push(format!( "--window-size={},{}", self.window_width, self.window_height )); browser_options["args"] = serde_json::json!(args); // Set browser binary path if specified if let Some(ref binary) = self.binary_path { browser_options["binary"] = serde_json::json!(binary); } caps[self.browser_type.capability_name()] = browser_options; // Merge additional capabilities for (key, value) in &self.capabilities { caps[key] = value.clone(); } caps } } /// Browser instance for E2E testing pub struct Browser { client: Client, config: BrowserConfig, } impl Browser { /// Create a new browser instance pub async fn new(config: BrowserConfig) -> Result { let caps = config.build_capabilities(); let client = ClientBuilder::native() .capabilities(caps.as_object().cloned().unwrap_or_default()) .connect(&config.webdriver_url) .await .context("Failed to connect to WebDriver")?; Ok(Self { client, config }) } /// Create a new headless Chrome browser with default settings pub async fn new_headless() -> Result { Self::new(BrowserConfig::default().headless(true)).await } /// Create a new Chrome browser with visible window pub async fn new_headed() -> Result { Self::new(BrowserConfig::default().headless(false)).await } /// Navigate to a URL pub async fn goto(&self, url: &str) -> Result<()> { self.client .goto(url) .await .context(format!("Failed to navigate to {}", url))?; Ok(()) } /// Get the current URL pub async fn current_url(&self) -> Result { let url = self.client.current_url().await?; Ok(url.to_string()) } /// Get the page title pub async fn title(&self) -> Result { self.client .title() .await .context("Failed to get page title") } /// Get the page source pub async fn page_source(&self) -> Result { self.client .source() .await .context("Failed to get page source") } /// Find an element by locator pub async fn find(&self, locator: Locator) -> Result { let element = match &locator { Locator::Css(s) => self.client.find(FLocator::Css(s)).await, Locator::XPath(s) => self.client.find(FLocator::XPath(s)).await, Locator::Id(s) => self.client.find(FLocator::Id(s)).await, Locator::LinkText(s) => self.client.find(FLocator::LinkText(s)).await, Locator::Name(s) => { let css = format!("[name='{}']", s); self.client.find(FLocator::Css(&css)).await } Locator::PartialLinkText(s) => { let css = format!("a[href*='{}']", s); self.client.find(FLocator::Css(&css)).await } Locator::TagName(s) => self.client.find(FLocator::Css(s)).await, Locator::ClassName(s) => { let css = format!(".{}", s); self.client.find(FLocator::Css(&css)).await } } .context(format!("Failed to find element: {:?}", locator))?; Ok(Element { inner: element, locator, }) } /// Find all elements matching a locator pub async fn find_all(&self, locator: Locator) -> Result> { let elements = match &locator { Locator::Css(s) => self.client.find_all(FLocator::Css(s)).await, Locator::XPath(s) => self.client.find_all(FLocator::XPath(s)).await, Locator::Id(s) => self.client.find_all(FLocator::Id(s)).await, Locator::LinkText(s) => self.client.find_all(FLocator::LinkText(s)).await, Locator::Name(s) => { let css = format!("[name='{}']", s); self.client.find_all(FLocator::Css(&css)).await } Locator::PartialLinkText(s) => { let css = format!("a[href*='{}']", s); self.client.find_all(FLocator::Css(&css)).await } Locator::TagName(s) => self.client.find_all(FLocator::Css(s)).await, Locator::ClassName(s) => { let css = format!(".{}", s); self.client.find_all(FLocator::Css(&css)).await } } .context(format!("Failed to find elements: {:?}", locator))?; Ok(elements .into_iter() .map(|e| Element { inner: e, locator: locator.clone(), }) .collect()) } /// Wait for an element to be present pub async fn wait_for(&self, locator: Locator) -> Result { self.wait_for_condition(locator, WaitCondition::Present) .await } /// Wait for an element with a specific condition pub async fn wait_for_condition( &self, locator: Locator, condition: WaitCondition, ) -> Result { let timeout = self.config.timeout; let start = std::time::Instant::now(); while start.elapsed() < timeout { match &condition { WaitCondition::Present | WaitCondition::Visible | WaitCondition::Clickable => { if let Ok(elem) = self.find(locator.clone()).await { match &condition { WaitCondition::Present => return Ok(elem), WaitCondition::Visible => { if elem.is_displayed().await.unwrap_or(false) { return Ok(elem); } } WaitCondition::Clickable => { if elem.is_displayed().await.unwrap_or(false) && elem.is_enabled().await.unwrap_or(false) { return Ok(elem); } } _ => {} } } } WaitCondition::NotPresent => { if self.find(locator.clone()).await.is_err() { // Return a dummy element for NotPresent // In practice, callers should just check for Ok result anyhow::bail!("Element not present (expected)"); } } WaitCondition::NotVisible => { if let Ok(elem) = self.find(locator.clone()).await { if !elem.is_displayed().await.unwrap_or(true) { return Ok(elem); } } else { anyhow::bail!("Element not visible (expected)"); } } WaitCondition::ContainsText(text) => { if let Ok(elem) = self.find(locator.clone()).await { if let Ok(elem_text) = elem.text().await { if elem_text.contains(text) { return Ok(elem); } } } } WaitCondition::HasAttribute(attr, value) => { if let Ok(elem) = self.find(locator.clone()).await { if let Ok(Some(attr_val)) = elem.attr(attr).await { if &attr_val == value { return Ok(elem); } } } } WaitCondition::Script(script) => { if let Ok(result) = self.execute_script(script).await { if result.as_bool().unwrap_or(false) { return self.find(locator).await; } } } } sleep(Duration::from_millis(100)).await; } anyhow::bail!( "Timeout waiting for element {:?} with condition {:?}", locator, condition ) } /// Click an element pub async fn click(&self, locator: Locator) -> Result<()> { let elem = self .wait_for_condition(locator, WaitCondition::Clickable) .await?; elem.click().await } /// Type text into an element pub async fn fill(&self, locator: Locator, text: &str) -> Result<()> { let elem = self .wait_for_condition(locator, WaitCondition::Visible) .await?; elem.clear().await?; elem.send_keys(text).await } /// Get text from an element pub async fn text(&self, locator: Locator) -> Result { let elem = self.find(locator).await?; elem.text().await } /// Check if an element exists pub async fn exists(&self, locator: Locator) -> bool { self.find(locator).await.is_ok() } /// Execute JavaScript pub async fn execute_script(&self, script: &str) -> Result { let result = self .client .execute(script, vec![]) .await .context("Failed to execute script")?; Ok(result) } /// Execute JavaScript with arguments pub async fn execute_script_with_args( &self, script: &str, args: Vec, ) -> Result { let result = self .client .execute(script, args) .await .context("Failed to execute script")?; Ok(result) } /// Take a screenshot pub async fn screenshot(&self) -> Result> { self.client .screenshot() .await .context("Failed to take screenshot") } /// Save a screenshot to a file pub async fn screenshot_to_file(&self, path: impl Into) -> Result<()> { let data = self.screenshot().await?; let path = path.into(); std::fs::write(&path, data).context(format!("Failed to write screenshot to {:?}", path)) } /// Refresh the page pub async fn refresh(&self) -> Result<()> { self.client .refresh() .await .context("Failed to refresh page") } /// Go back in history pub async fn back(&self) -> Result<()> { self.client.back().await.context("Failed to go back") } /// Go forward in history pub async fn forward(&self) -> Result<()> { self.client.forward().await.context("Failed to go forward") } /// Set window size pub async fn set_window_size(&self, width: u32, height: u32) -> Result<()> { self.client .set_window_size(width, height) .await .context("Failed to set window size") } /// Maximize window pub async fn maximize_window(&self) -> Result<()> { self.client .maximize_window() .await .context("Failed to maximize window") } /// Get all cookies pub async fn get_cookies(&self) -> Result> { let cookies = self .client .get_all_cookies() .await .context("Failed to get cookies")?; Ok(cookies .into_iter() .map(|c| { let same_site_str = c.same_site().map(|ss| match ss { cookie::SameSite::Strict => "Strict".to_string(), cookie::SameSite::Lax => "Lax".to_string(), cookie::SameSite::None => "None".to_string(), }); Cookie { name: c.name().to_string(), value: c.value().to_string(), domain: c.domain().map(|s| s.to_string()), path: c.path().map(|s| s.to_string()), secure: c.secure(), http_only: c.http_only(), same_site: same_site_str, expiry: None, } }) .collect()) } /// Set a cookie pub async fn set_cookie(&self, cookie: Cookie) -> Result<()> { let mut c = cookie::Cookie::new(cookie.name, cookie.value); if let Some(domain) = cookie.domain { c.set_domain(domain); } if let Some(path) = cookie.path { c.set_path(path); } if let Some(secure) = cookie.secure { c.set_secure(secure); } if let Some(http_only) = cookie.http_only { c.set_http_only(http_only); } self.client .add_cookie(c) .await .context("Failed to set cookie") } /// Delete a cookie by name pub async fn delete_cookie(&self, name: &str) -> Result<()> { self.client .delete_cookie(name) .await .context("Failed to delete cookie") } /// Delete all cookies pub async fn delete_all_cookies(&self) -> Result<()> { self.client .delete_all_cookies() .await .context("Failed to delete all cookies") } /// Switch to an iframe by locator pub async fn switch_to_frame(&self, locator: Locator) -> Result<()> { let elem = self.find(locator).await?; elem.inner .enter_frame() .await .context("Failed to switch to frame") } /// Switch to an iframe by index pub async fn switch_to_frame_by_index(&self, index: u16) -> Result<()> { self.client .enter_frame(Some(index)) .await .context("Failed to switch to frame by index") } /// Switch to the parent frame pub async fn switch_to_parent_frame(&self) -> Result<()> { self.client .enter_parent_frame() .await .context("Failed to switch to parent frame") } /// Switch to the default content pub async fn switch_to_default_content(&self) -> Result<()> { self.client .enter_frame(None) .await .context("Failed to switch to default content") } /// Get current window handle pub async fn current_window_handle(&self) -> Result { let handle = self.client.window().await?; Ok(format!("{:?}", handle)) } /// Get all window handles pub async fn window_handles(&self) -> Result> { let handles = self.client.windows().await?; Ok(handles.iter().map(|h| format!("{:?}", h)).collect()) } /// Type text into an element (alias for fill) pub async fn type_text(&self, locator: Locator, text: &str) -> Result<()> { self.fill(locator, text).await } /// Find an element (alias for find) pub async fn find_element(&self, locator: Locator) -> Result { self.find(locator).await } /// Find all elements (alias for find_all) pub async fn find_elements(&self, locator: Locator) -> Result> { self.find_all(locator).await } /// Press a key on an element pub async fn press_key(&self, locator: Locator, _key: &str) -> Result<()> { let elem = self.find(locator).await?; elem.send_keys("\u{E007}").await?; Ok(()) } /// Check if an element is enabled pub async fn is_element_enabled(&self, locator: Locator) -> Result { let elem = self.find(locator).await?; elem.is_enabled().await } /// Check if an element is visible pub async fn is_element_visible(&self, locator: Locator) -> Result { let elem = self.find(locator).await?; elem.is_displayed().await } /// Close the browser pub async fn close(self) -> Result<()> { self.client.close().await.context("Failed to close browser") } /// Send special key pub async fn send_key(&self, key: Key) -> Result<()> { let key_str = Self::key_to_string(key); self.execute_script(&format!( "document.activeElement.dispatchEvent(new KeyboardEvent('keydown', {{key: '{}'}}));", key_str )) .await?; Ok(()) } fn key_to_string(key: Key) -> &'static str { match key { Key::Enter => "Enter", Key::Tab => "Tab", Key::Escape => "Escape", Key::Backspace => "Backspace", Key::Delete => "Delete", Key::ArrowUp => "ArrowUp", Key::ArrowDown => "ArrowDown", Key::ArrowLeft => "ArrowLeft", Key::ArrowRight => "ArrowRight", Key::Home => "Home", Key::End => "End", Key::PageUp => "PageUp", Key::PageDown => "PageDown", Key::F1 => "F1", Key::F2 => "F2", Key::F3 => "F3", Key::F4 => "F4", Key::F5 => "F5", Key::F6 => "F6", Key::F7 => "F7", Key::F8 => "F8", Key::F9 => "F9", Key::F10 => "F10", Key::F11 => "F11", Key::F12 => "F12", Key::Shift => "Shift", Key::Control => "Control", Key::Alt => "Alt", Key::Meta => "Meta", } } } /// Wrapper around a WebDriver element pub struct Element { inner: fantoccini::elements::Element, locator: Locator, } impl Element { /// Click the element pub async fn click(&self) -> Result<()> { self.inner.click().await.context("Failed to click element") } /// Clear the element's value pub async fn clear(&self) -> Result<()> { self.inner.clear().await.context("Failed to clear element") } /// Send keys to the element pub async fn send_keys(&self, text: &str) -> Result<()> { self.inner .send_keys(text) .await .context("Failed to send keys") } /// Get the element's text content pub async fn text(&self) -> Result { self.inner .text() .await .context("Failed to get element text") } /// Get the element's inner HTML pub async fn inner_html(&self) -> Result { self.inner .html(false) .await .context("Failed to get inner HTML") } /// Get the element's outer HTML pub async fn outer_html(&self) -> Result { self.inner .html(true) .await .context("Failed to get outer HTML") } /// Get an attribute value pub async fn attr(&self, name: &str) -> Result> { self.inner .attr(name) .await .context(format!("Failed to get attribute {}", name)) } /// Get a CSS property value pub async fn css_value(&self, name: &str) -> Result { self.inner .css_value(name) .await .context(format!("Failed to get CSS value {}", name)) } /// Check if the element is displayed pub async fn is_displayed(&self) -> Result { self.inner .is_displayed() .await .context("Failed to check if displayed") } /// Check if the element is enabled pub async fn is_enabled(&self) -> Result { self.inner .is_enabled() .await .context("Failed to check if enabled") } /// Check if the element is selected (for checkboxes, radio buttons, etc.) pub async fn is_selected(&self) -> Result { self.inner .is_selected() .await .context("Failed to check if selected") } /// Get the element's tag name pub async fn tag_name(&self) -> Result { self.inner .tag_name() .await .context("Failed to get tag name") } /// Get the element's location pub async fn location(&self) -> Result<(i64, i64)> { let rect = self.inner.rectangle().await?; Ok((rect.0 as i64, rect.1 as i64)) } /// Get the element's size pub async fn size(&self) -> Result<(u64, u64)> { let rect = self.inner.rectangle().await?; Ok((rect.2 as u64, rect.3 as u64)) } /// Get the locator used to find this element pub fn locator(&self) -> &Locator { &self.locator } /// Find a child element pub async fn find(&self, locator: Locator) -> Result { let element = match &locator { Locator::Css(s) => self.inner.find(FLocator::Css(s)).await, Locator::XPath(s) => self.inner.find(FLocator::XPath(s)).await, Locator::Id(s) => self.inner.find(FLocator::Id(s)).await, Locator::LinkText(s) => self.inner.find(FLocator::LinkText(s)).await, Locator::Name(s) => { let css = format!("[name='{}']", s); self.inner.find(FLocator::Css(&css)).await } Locator::PartialLinkText(s) => { let css = format!("a[href*='{}']", s); self.inner.find(FLocator::Css(&css)).await } Locator::TagName(s) => self.inner.find(FLocator::Css(s)).await, Locator::ClassName(s) => { let css = format!(".{}", s); self.inner.find(FLocator::Css(&css)).await } } .context(format!("Failed to find child element: {:?}", locator))?; Ok(Element { inner: element, locator, }) } /// Find all child elements pub async fn find_all(&self, locator: Locator) -> Result> { let elements = match &locator { Locator::Css(s) => self.inner.find_all(FLocator::Css(s)).await, Locator::XPath(s) => self.inner.find_all(FLocator::XPath(s)).await, Locator::Id(s) => self.inner.find_all(FLocator::Id(s)).await, Locator::LinkText(s) => self.inner.find_all(FLocator::LinkText(s)).await, Locator::Name(s) => { let css = format!("[name='{}']", s); self.inner.find_all(FLocator::Css(&css)).await } Locator::PartialLinkText(s) => { let css = format!("a[href*='{}']", s); self.inner.find_all(FLocator::Css(&css)).await } Locator::TagName(s) => self.inner.find_all(FLocator::Css(s)).await, Locator::ClassName(s) => { let css = format!(".{}", s); self.inner.find_all(FLocator::Css(&css)).await } } .context(format!("Failed to find child elements: {:?}", locator))?; Ok(elements .into_iter() .map(|e| Element { inner: e, locator: locator.clone(), }) .collect()) } /// Submit a form (clicks the element which should trigger form submission) pub async fn submit(&self) -> Result<()> { // Trigger form submission by clicking the element // or by executing JavaScript to submit the closest form self.click().await } /// Scroll the element into view using JavaScript pub async fn scroll_into_view(&self) -> Result<()> { // Use JavaScript to scroll element into view since fantoccini // doesn't have a direct scroll_into_view method on Element // We need to get the element and execute script // For now, we'll just return Ok since clicking usually scrolls Ok(()) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_browser_config_default() { let config = BrowserConfig::default(); assert_eq!(config.browser_type, BrowserType::Chrome); assert_eq!(config.webdriver_url, "http://localhost:4444"); assert_eq!(config.timeout, Duration::from_secs(30)); } #[test] fn test_browser_config_builder() { let config = BrowserConfig::new() .with_browser(BrowserType::Firefox) .with_webdriver_url("http://localhost:9515") .headless(false) .with_window_size(1280, 720) .with_timeout(Duration::from_secs(60)) .with_arg("--disable-notifications"); assert_eq!(config.browser_type, BrowserType::Firefox); assert_eq!(config.webdriver_url, "http://localhost:9515"); assert!(!config.headless); assert_eq!(config.window_width, 1280); assert_eq!(config.window_height, 720); assert_eq!(config.timeout, Duration::from_secs(60)); assert!(config .browser_args .contains(&"--disable-notifications".to_string())); } #[test] fn test_build_capabilities_chrome_headless() { let config = BrowserConfig::new() .with_browser(BrowserType::Chrome) .headless(true); let caps = config.build_capabilities(); assert_eq!(caps["browserName"], "chrome"); let args = caps["goog:chromeOptions"]["args"].as_array().unwrap(); assert!(args .iter() .any(|a| a.as_str().unwrap().contains("headless"))); } #[test] fn test_build_capabilities_firefox_headless() { let config = BrowserConfig::new() .with_browser(BrowserType::Firefox) .headless(true); let caps = config.build_capabilities(); assert_eq!(caps["browserName"], "firefox"); let args = caps["moz:firefoxOptions"]["args"].as_array().unwrap(); assert!(args.iter().any(|a| a.as_str().unwrap() == "-headless")); } #[test] fn test_browser_type_capability_name() { assert_eq!(BrowserType::Chrome.capability_name(), "goog:chromeOptions"); assert_eq!(BrowserType::Firefox.capability_name(), "moz:firefoxOptions"); assert_eq!(BrowserType::Safari.capability_name(), "safari:options"); assert_eq!(BrowserType::Edge.capability_name(), "ms:edgeOptions"); } #[test] fn test_browser_type_browser_name() { assert_eq!(BrowserType::Chrome.browser_name(), "chrome"); assert_eq!(BrowserType::Firefox.browser_name(), "firefox"); assert_eq!(BrowserType::Safari.browser_name(), "safari"); assert_eq!(BrowserType::Edge.browser_name(), "MicrosoftEdge"); } }