961 lines
31 KiB
Rust
961 lines
31 KiB
Rust
//! 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<String>,
|
|
/// Additional capabilities
|
|
pub capabilities: HashMap<String, serde_json::Value>,
|
|
}
|
|
|
|
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(),
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
/// 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<String> = 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);
|
|
|
|
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<Self> {
|
|
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> {
|
|
Self::new(BrowserConfig::default().headless(true)).await
|
|
}
|
|
|
|
/// Create a new Chrome browser with visible window
|
|
pub async fn new_headed() -> Result<Self> {
|
|
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<String> {
|
|
let url = self.client.current_url().await?;
|
|
Ok(url.to_string())
|
|
}
|
|
|
|
/// Get the page title
|
|
pub async fn title(&self) -> Result<String> {
|
|
self.client
|
|
.title()
|
|
.await
|
|
.context("Failed to get page title")
|
|
}
|
|
|
|
/// Get the page source
|
|
pub async fn page_source(&self) -> Result<String> {
|
|
self.client
|
|
.source()
|
|
.await
|
|
.context("Failed to get page source")
|
|
}
|
|
|
|
/// Find an element by locator
|
|
pub async fn find(&self, locator: Locator) -> Result<Element> {
|
|
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<Vec<Element>> {
|
|
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<Element> {
|
|
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<Element> {
|
|
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<String> {
|
|
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<serde_json::Value> {
|
|
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<serde_json::Value>,
|
|
) -> Result<serde_json::Value> {
|
|
let result = self
|
|
.client
|
|
.execute(script, args)
|
|
.await
|
|
.context("Failed to execute script")?;
|
|
Ok(result)
|
|
}
|
|
|
|
/// Take a screenshot
|
|
pub async fn screenshot(&self) -> Result<Vec<u8>> {
|
|
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<PathBuf>) -> 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<Vec<Cookie>> {
|
|
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<String> {
|
|
let handle = self.client.window().await?;
|
|
Ok(format!("{:?}", handle))
|
|
}
|
|
|
|
/// Get all window handles
|
|
pub async fn window_handles(&self) -> Result<Vec<String>> {
|
|
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<Element> {
|
|
self.find(locator).await
|
|
}
|
|
|
|
/// Find all elements (alias for find_all)
|
|
pub async fn find_elements(&self, locator: Locator) -> Result<Vec<Element>> {
|
|
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<bool> {
|
|
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<bool> {
|
|
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<String> {
|
|
self.inner
|
|
.text()
|
|
.await
|
|
.context("Failed to get element text")
|
|
}
|
|
|
|
/// Get the element's inner HTML
|
|
pub async fn inner_html(&self) -> Result<String> {
|
|
self.inner
|
|
.html(false)
|
|
.await
|
|
.context("Failed to get inner HTML")
|
|
}
|
|
|
|
/// Get the element's outer HTML
|
|
pub async fn outer_html(&self) -> Result<String> {
|
|
self.inner
|
|
.html(true)
|
|
.await
|
|
.context("Failed to get outer HTML")
|
|
}
|
|
|
|
/// Get an attribute value
|
|
pub async fn attr(&self, name: &str) -> Result<Option<String>> {
|
|
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<String> {
|
|
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<bool> {
|
|
self.inner
|
|
.is_displayed()
|
|
.await
|
|
.context("Failed to check if displayed")
|
|
}
|
|
|
|
/// Check if the element is enabled
|
|
pub async fn is_enabled(&self) -> Result<bool> {
|
|
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<bool> {
|
|
self.inner
|
|
.is_selected()
|
|
.await
|
|
.context("Failed to check if selected")
|
|
}
|
|
|
|
/// Get the element's tag name
|
|
pub async fn tag_name(&self) -> Result<String> {
|
|
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<Element> {
|
|
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<Vec<Element>> {
|
|
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");
|
|
}
|
|
}
|