use std::time::Duration;
use fantoccini::ClientBuilder;
use rstest::*;
use serde_json::json;
pub use fantoccini::{Client as FantocciniClient, Locator};
const DEFAULT_CONNECT_RETRIES: u32 = 3;
const BASE_RETRY_DELAY_MS: u64 = 500;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BrowserType {
Chrome,
Firefox,
}
impl BrowserType {
pub fn from_str_lossy(s: &str) -> Self {
match s.to_lowercase().as_str() {
"firefox" | "gecko" | "geckodriver" => Self::Firefox,
_ => Self::Chrome,
}
}
}
#[derive(Clone, Debug)]
pub struct BrowserConfig {
pub webdriver_url: String,
pub headless: bool,
pub browser_type: BrowserType,
pub wait_timeout: Duration,
}
impl BrowserConfig {
pub fn from_env() -> Self {
let webdriver_url =
std::env::var("WEBDRIVER_URL").unwrap_or_else(|_| "http://localhost:4444".to_string());
let headless = std::env::var("BROWSER_HEADLESS")
.map(|v| !matches!(v.to_lowercase().trim(), "false" | "0" | "no" | "off"))
.unwrap_or(true);
let browser_type = std::env::var("BROWSER_TYPE")
.map(|v| BrowserType::from_str_lossy(&v))
.unwrap_or(BrowserType::Chrome);
let wait_timeout = std::env::var("BROWSER_WAIT_TIMEOUT")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or_else(|| Duration::from_secs(10));
Self {
webdriver_url,
headless,
browser_type,
wait_timeout,
}
}
}
impl Default for BrowserConfig {
fn default() -> Self {
Self::from_env()
}
}
pub struct BrowserClient {
client: fantoccini::Client,
config: BrowserConfig,
}
impl BrowserClient {
fn build_capabilities(config: &BrowserConfig) -> serde_json::Map<String, serde_json::Value> {
let mut caps = serde_json::Map::new();
match config.browser_type {
BrowserType::Chrome => {
caps.insert("browserName".into(), json!("chrome"));
let mut chrome_opts = serde_json::Map::new();
let mut args = vec![
"--no-sandbox".to_string(),
"--disable-dev-shm-usage".to_string(),
];
if config.headless {
args.push("--headless=new".to_string());
}
chrome_opts.insert("args".into(), json!(args));
caps.insert("goog:chromeOptions".into(), json!(chrome_opts));
}
BrowserType::Firefox => {
caps.insert("browserName".into(), json!("firefox"));
let mut firefox_opts = serde_json::Map::new();
if config.headless {
firefox_opts.insert("args".into(), json!(["-headless"]));
}
caps.insert("moz:firefoxOptions".into(), json!(firefox_opts));
}
}
caps
}
pub async fn connect(
config: BrowserConfig,
) -> Result<Self, fantoccini::error::NewSessionError> {
let caps = Self::build_capabilities(&config);
let client = ClientBuilder::native()
.capabilities(caps)
.connect(&config.webdriver_url)
.await?;
Ok(Self { client, config })
}
pub async fn connect_with_retries(
config: BrowserConfig,
retries: u32,
) -> Result<Self, fantoccini::error::NewSessionError> {
let base_caps = Self::build_capabilities(&config);
let mut last_error = None;
let total_attempts = retries + 1;
for attempt in 0..total_attempts {
let caps = base_caps.clone();
match ClientBuilder::native()
.capabilities(caps)
.connect(&config.webdriver_url)
.await
{
Ok(client) => return Ok(Self { client, config }),
Err(e) => {
if attempt + 1 < total_attempts {
let delay = Duration::from_millis(
BASE_RETRY_DELAY_MS
.saturating_mul(2u64.saturating_pow(attempt))
.min(8_000),
);
eprintln!(
"[e2e] WebDriver connection attempt {}/{} failed: {}. Retrying in {:?}...",
attempt + 1,
total_attempts,
e,
delay,
);
tokio::time::sleep(delay).await;
}
last_error = Some(e);
}
}
}
Err(last_error.expect("Should have at least one error after retries"))
}
pub async fn navigate(&self, url: &str) -> Result<(), fantoccini::error::CmdError> {
self.client.goto(url).await
}
pub async fn current_url(&self) -> Result<url::Url, fantoccini::error::CmdError> {
self.client.current_url().await
}
pub async fn title(&self) -> Result<String, fantoccini::error::CmdError> {
self.client.title().await
}
pub async fn source(&self) -> Result<String, fantoccini::error::CmdError> {
self.client.source().await
}
pub async fn find(
&self,
css: &str,
) -> Result<fantoccini::elements::Element, fantoccini::error::CmdError> {
self.client.find(Locator::Css(css)).await
}
pub async fn find_all(
&self,
css: &str,
) -> Result<Vec<fantoccini::elements::Element>, fantoccini::error::CmdError> {
self.client.find_all(Locator::Css(css)).await
}
pub async fn click(&self, css: &str) -> Result<(), fantoccini::error::CmdError> {
let elem = self.find(css).await?;
elem.click().await
}
pub async fn type_into(
&self,
css: &str,
text: &str,
) -> Result<(), fantoccini::error::CmdError> {
let elem = self.find(css).await?;
elem.clear().await?;
elem.send_keys(text).await
}
pub async fn wait_for(
&self,
css: &str,
) -> Result<fantoccini::elements::Element, fantoccini::error::CmdError> {
self.client
.wait()
.at_most(self.config.wait_timeout)
.for_element(Locator::Css(css))
.await
}
pub async fn get_text(&self, css: &str) -> Result<String, fantoccini::error::CmdError> {
let elem = self.find(css).await?;
elem.text().await
}
pub async fn get_attribute(
&self,
css: &str,
attribute: &str,
) -> Result<Option<String>, fantoccini::error::CmdError> {
let elem = self.find(css).await?;
elem.attr(attribute).await
}
pub async fn select_by_text(
&self,
select_css: &str,
text: &str,
) -> Result<(), fantoccini::error::CmdError> {
let script = format!(
r#"
const select = document.querySelector({css});
if (!select) return false;
for (const opt of select.options) {{
if (opt.text === {text}) {{
select.value = opt.value;
select.dispatchEvent(new Event('change', {{ bubbles: true }}));
return true;
}}
}}
return false;
"#,
css = serde_json::to_string(select_css).expect("failed to JSON-encode CSS selector"),
text = serde_json::to_string(text).expect("failed to JSON-encode option text"),
);
let result = self.client.execute(&script, vec![]).await?;
let success = result.as_bool().unwrap_or(false);
if !success {
return Err(fantoccini::error::CmdError::NotW3C(serde_json::json!({
"error": "select_by_text failed",
"message": format!(
"Could not find option with text {:?} in select {:?}",
text, select_css
),
})));
}
Ok(())
}
pub async fn wait_for_url<F>(
&self,
predicate: F,
) -> Result<url::Url, fantoccini::error::CmdError>
where
F: Fn(&url::Url) -> bool,
{
let start = std::time::Instant::now();
let poll_interval = Duration::from_millis(100);
loop {
let current = self.current_url().await?;
if predicate(¤t) {
return Ok(current);
}
if start.elapsed() > self.config.wait_timeout {
return Err(fantoccini::error::CmdError::WaitTimeout);
}
tokio::time::sleep(poll_interval).await;
}
}
pub async fn screenshot(&self) -> Result<Vec<u8>, fantoccini::error::CmdError> {
self.client.screenshot().await
}
pub async fn execute_js(
&self,
script: &str,
args: Vec<serde_json::Value>,
) -> Result<serde_json::Value, fantoccini::error::CmdError> {
self.client.execute(script, args).await
}
pub async fn close(self) -> Result<(), fantoccini::error::CmdError> {
self.client.close().await
}
pub fn inner(&self) -> &fantoccini::Client {
&self.client
}
pub fn into_inner(self) -> fantoccini::Client {
self.client
}
pub fn config(&self) -> &BrowserConfig {
&self.config
}
}
#[fixture]
pub fn browser_config() -> BrowserConfig {
BrowserConfig::from_env()
}
#[fixture]
pub async fn browser_client(browser_config: BrowserConfig) -> BrowserClient {
BrowserClient::connect_with_retries(browser_config.clone(), DEFAULT_CONNECT_RETRIES)
.await
.unwrap_or_else(|e| {
panic!(
"Failed to connect to WebDriver server at {} (browser: {:?}, headless: {}) after {} retries: {e}",
browser_config.webdriver_url, browser_config.browser_type, browser_config.headless,
DEFAULT_CONNECT_RETRIES,
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::*;
use serial_test::serial;
#[rstest]
#[case("chrome", BrowserType::Chrome)]
#[case("Chrome", BrowserType::Chrome)]
#[case("CHROME", BrowserType::Chrome)]
#[case("firefox", BrowserType::Firefox)]
#[case("Firefox", BrowserType::Firefox)]
#[case("FIREFOX", BrowserType::Firefox)]
#[case("gecko", BrowserType::Firefox)]
#[case("geckodriver", BrowserType::Firefox)]
#[case("unknown", BrowserType::Chrome)]
#[case("", BrowserType::Chrome)]
fn test_browser_type_from_str_lossy(#[case] input: &str, #[case] expected: BrowserType) {
let result = BrowserType::from_str_lossy(input);
assert_eq!(result, expected);
}
#[rstest]
#[serial(browser_env)]
fn test_browser_config_defaults() {
unsafe {
std::env::remove_var("WEBDRIVER_URL");
std::env::remove_var("BROWSER_HEADLESS");
std::env::remove_var("BROWSER_TYPE");
std::env::remove_var("BROWSER_WAIT_TIMEOUT");
}
let config = BrowserConfig::from_env();
assert_eq!(config.webdriver_url, "http://localhost:4444");
assert!(config.headless);
assert_eq!(config.browser_type, BrowserType::Chrome);
assert_eq!(config.wait_timeout, Duration::from_secs(10));
}
#[rstest]
#[case("false", false)]
#[case("False", false)]
#[case("FALSE", false)]
#[case("0", false)]
#[case("no", false)]
#[case("off", false)]
#[case("true", true)]
#[case("1", true)]
#[case("yes", true)]
#[serial(browser_env)]
fn test_browser_headless_parsing(#[case] value: &str, #[case] expected: bool) {
unsafe {
std::env::set_var("BROWSER_HEADLESS", value);
}
let config = BrowserConfig::from_env();
assert_eq!(config.headless, expected);
unsafe {
std::env::remove_var("BROWSER_HEADLESS");
}
}
#[rstest]
#[serial(browser_env)]
fn test_browser_config_custom_values() {
unsafe {
std::env::set_var("WEBDRIVER_URL", "http://selenium:4444");
std::env::set_var("BROWSER_TYPE", "firefox");
std::env::set_var("BROWSER_WAIT_TIMEOUT", "30");
}
let config = BrowserConfig::from_env();
assert_eq!(config.webdriver_url, "http://selenium:4444");
assert_eq!(config.browser_type, BrowserType::Firefox);
assert_eq!(config.wait_timeout, Duration::from_secs(30));
unsafe {
std::env::remove_var("WEBDRIVER_URL");
std::env::remove_var("BROWSER_TYPE");
std::env::remove_var("BROWSER_WAIT_TIMEOUT");
}
}
}