use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EngineType {
Chromiumoxide,
Fantoccini,
Playwright,
Puppeteer,
}
impl std::fmt::Display for EngineType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EngineType::Chromiumoxide => write!(f, "chromiumoxide"),
EngineType::Fantoccini => write!(f, "fantoccini"),
EngineType::Playwright => write!(f, "playwright"),
EngineType::Puppeteer => write!(f, "puppeteer"),
}
}
}
impl std::str::FromStr for EngineType {
type Err = EngineError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"chromiumoxide" | "cdp" => Ok(EngineType::Chromiumoxide),
"fantoccini" | "webdriver" => Ok(EngineType::Fantoccini),
"playwright" => Ok(EngineType::Playwright),
"puppeteer" => Ok(EngineType::Puppeteer),
_ => Err(EngineError::InvalidEngine(s.to_string())),
}
}
}
#[derive(Debug, Error)]
pub enum EngineError {
#[error(
"Invalid engine: {0}. Expected 'chromiumoxide', 'fantoccini', 'playwright', or 'puppeteer'"
)]
InvalidEngine(String),
#[error("Element not found: {0}")]
ElementNotFound(String),
#[error("Operation timed out: {0}")]
Timeout(String),
#[error("Navigation error: {0}")]
Navigation(String),
#[error("JavaScript evaluation error: {0}")]
Evaluation(String),
#[error("Browser error: {0}")]
Browser(String),
}
#[derive(Debug, Clone)]
pub struct ElementInfo {
pub tag_name: String,
pub text_content: Option<String>,
pub is_visible: bool,
pub is_enabled: bool,
pub bounding_box: Option<(f64, f64, f64, f64)>,
}
#[derive(Debug, Clone)]
pub struct ClickVerificationResult {
pub verified: bool,
pub reason: String,
pub navigation_error: bool,
}
#[derive(Debug, Clone)]
pub struct ScrollVerificationResult {
pub verified: bool,
pub in_viewport: bool,
pub attempts: u32,
}
#[derive(Debug, Clone)]
pub struct FillVerificationResult {
pub verified: bool,
pub actual_value: String,
pub attempts: u32,
}
#[derive(Debug, Clone, Default)]
pub struct PdfOptions {
pub format: Option<String>,
pub print_background: bool,
pub margin_top: Option<String>,
pub margin_right: Option<String>,
pub margin_bottom: Option<String>,
pub margin_left: Option<String>,
pub scale: Option<f64>,
pub path: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct PreClickState {
pub disabled: Option<bool>,
pub aria_pressed: Option<String>,
pub aria_expanded: Option<String>,
pub aria_selected: Option<String>,
pub checked: Option<bool>,
pub class_name: Option<String>,
pub is_connected: bool,
}
#[async_trait]
pub trait EngineAdapter: Send + Sync {
fn engine_type(&self) -> EngineType;
async fn url(&self) -> Result<String, EngineError>;
async fn goto(&self, url: &str) -> Result<(), EngineError>;
async fn query_selector(&self, selector: &str) -> Result<Option<ElementInfo>, EngineError>;
async fn query_selector_all(&self, selector: &str) -> Result<Vec<ElementInfo>, EngineError>;
async fn count(&self, selector: &str) -> Result<usize, EngineError>;
async fn click(&self, selector: &str) -> Result<(), EngineError>;
async fn fill(&self, selector: &str, text: &str) -> Result<(), EngineError>;
async fn type_text(&self, selector: &str, text: &str) -> Result<(), EngineError>;
async fn text_content(&self, selector: &str) -> Result<Option<String>, EngineError>;
async fn input_value(&self, selector: &str) -> Result<Option<String>, EngineError>;
async fn get_attribute(
&self,
selector: &str,
attribute: &str,
) -> Result<Option<String>, EngineError>;
async fn is_visible(&self, selector: &str) -> Result<bool, EngineError>;
async fn is_enabled(&self, selector: &str) -> Result<bool, EngineError>;
async fn wait_for_selector(&self, selector: &str, timeout_ms: u64) -> Result<(), EngineError>;
async fn scroll_into_view(&self, selector: &str) -> Result<(), EngineError>;
async fn evaluate(&self, script: &str) -> Result<serde_json::Value, EngineError>;
async fn screenshot(&self) -> Result<Vec<u8>, EngineError>;
async fn pdf(&self, options: PdfOptions) -> Result<Vec<u8>, EngineError>;
async fn bring_to_front(&self) -> Result<(), EngineError>;
async fn wait_for_navigation(&self, timeout_ms: u64) -> Result<(), EngineError>;
async fn keyboard_press(&self, key: &str) -> Result<(), EngineError>;
async fn keyboard_type(&self, text: &str) -> Result<(), EngineError>;
async fn keyboard_down(&self, key: &str) -> Result<(), EngineError>;
async fn keyboard_up(&self, key: &str) -> Result<(), EngineError>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn engine_type_display() {
assert_eq!(EngineType::Chromiumoxide.to_string(), "chromiumoxide");
assert_eq!(EngineType::Fantoccini.to_string(), "fantoccini");
assert_eq!(EngineType::Playwright.to_string(), "playwright");
assert_eq!(EngineType::Puppeteer.to_string(), "puppeteer");
}
#[test]
fn engine_type_from_str() {
assert_eq!(
"chromiumoxide".parse::<EngineType>().unwrap(),
EngineType::Chromiumoxide
);
assert_eq!(
"cdp".parse::<EngineType>().unwrap(),
EngineType::Chromiumoxide
);
assert_eq!(
"puppeteer".parse::<EngineType>().unwrap(),
EngineType::Puppeteer
);
assert_eq!(
"puppeteer".parse::<EngineType>().unwrap().to_string(),
"puppeteer"
);
assert_eq!(
"fantoccini".parse::<EngineType>().unwrap(),
EngineType::Fantoccini
);
assert_eq!(
"webdriver".parse::<EngineType>().unwrap(),
EngineType::Fantoccini
);
assert_eq!(
"playwright".parse::<EngineType>().unwrap(),
EngineType::Playwright
);
assert_eq!(
"playwright".parse::<EngineType>().unwrap().to_string(),
"playwright"
);
}
#[test]
fn engine_type_from_str_case_insensitive() {
assert_eq!(
"CHROMIUMOXIDE".parse::<EngineType>().unwrap(),
EngineType::Chromiumoxide
);
assert_eq!(
"Fantoccini".parse::<EngineType>().unwrap(),
EngineType::Fantoccini
);
assert_eq!(
"Playwright".parse::<EngineType>().unwrap(),
EngineType::Playwright
);
assert_eq!(
"Puppeteer".parse::<EngineType>().unwrap(),
EngineType::Puppeteer
);
}
#[test]
fn engine_type_from_str_invalid() {
let result = "invalid".parse::<EngineType>();
assert!(result.is_err());
if let Err(EngineError::InvalidEngine(name)) = result {
assert_eq!(name, "invalid");
} else {
panic!("Expected InvalidEngine error");
}
}
#[test]
fn pdf_options_default() {
let opts = PdfOptions::default();
assert!(opts.format.is_none());
assert!(!opts.print_background);
assert!(opts.margin_top.is_none());
assert!(opts.path.is_none());
assert!(opts.scale.is_none());
}
#[test]
fn pdf_options_can_be_constructed() {
let opts = PdfOptions {
format: Some("A4".to_string()),
print_background: true,
margin_top: Some("1cm".to_string()),
margin_right: Some("1cm".to_string()),
margin_bottom: Some("1cm".to_string()),
margin_left: Some("1cm".to_string()),
scale: Some(1.0),
path: None,
};
assert_eq!(opts.format.as_deref(), Some("A4"));
assert!(opts.print_background);
assert_eq!(opts.margin_top.as_deref(), Some("1cm"));
assert_eq!(opts.scale, Some(1.0));
}
#[test]
fn pre_click_state_default() {
let state = PreClickState::default();
assert!(state.disabled.is_none());
assert!(state.aria_pressed.is_none());
assert!(!state.is_connected);
}
#[test]
fn click_verification_result_creation() {
let result = ClickVerificationResult {
verified: true,
reason: "element state changed".to_string(),
navigation_error: false,
};
assert!(result.verified);
assert!(!result.navigation_error);
}
}