1use async_trait::async_trait;
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
12#[serde(rename_all = "lowercase")]
13pub enum EngineType {
14 Chromiumoxide,
16 Fantoccini,
18}
19
20impl std::fmt::Display for EngineType {
21 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22 match self {
23 EngineType::Chromiumoxide => write!(f, "chromiumoxide"),
24 EngineType::Fantoccini => write!(f, "fantoccini"),
25 }
26 }
27}
28
29impl std::str::FromStr for EngineType {
30 type Err = EngineError;
31
32 fn from_str(s: &str) -> Result<Self, Self::Err> {
33 match s.to_lowercase().as_str() {
34 "chromiumoxide" | "cdp" | "puppeteer" => Ok(EngineType::Chromiumoxide),
35 "fantoccini" | "webdriver" | "playwright" => Ok(EngineType::Fantoccini),
36 _ => Err(EngineError::InvalidEngine(s.to_string())),
37 }
38 }
39}
40
41#[derive(Debug, Error)]
43pub enum EngineError {
44 #[error("Invalid engine: {0}. Expected 'chromiumoxide' or 'fantoccini'")]
46 InvalidEngine(String),
47
48 #[error("Element not found: {0}")]
50 ElementNotFound(String),
51
52 #[error("Operation timed out: {0}")]
54 Timeout(String),
55
56 #[error("Navigation error: {0}")]
58 Navigation(String),
59
60 #[error("JavaScript evaluation error: {0}")]
62 Evaluation(String),
63
64 #[error("Browser error: {0}")]
66 Browser(String),
67}
68
69#[derive(Debug, Clone)]
71pub struct ElementInfo {
72 pub tag_name: String,
74 pub text_content: Option<String>,
76 pub is_visible: bool,
78 pub is_enabled: bool,
80 pub bounding_box: Option<(f64, f64, f64, f64)>,
82}
83
84#[derive(Debug, Clone)]
86pub struct ClickVerificationResult {
87 pub verified: bool,
89 pub reason: String,
91 pub navigation_error: bool,
93}
94
95#[derive(Debug, Clone)]
97pub struct ScrollVerificationResult {
98 pub verified: bool,
100 pub in_viewport: bool,
102 pub attempts: u32,
104}
105
106#[derive(Debug, Clone)]
108pub struct FillVerificationResult {
109 pub verified: bool,
111 pub actual_value: String,
113 pub attempts: u32,
115}
116
117#[derive(Debug, Clone, Default)]
119pub struct PdfOptions {
120 pub format: Option<String>,
122 pub print_background: bool,
124 pub margin_top: Option<String>,
126 pub margin_right: Option<String>,
127 pub margin_bottom: Option<String>,
128 pub margin_left: Option<String>,
129 pub scale: Option<f64>,
131 pub path: Option<String>,
133}
134
135#[derive(Debug, Clone, Default)]
137pub struct PreClickState {
138 pub disabled: Option<bool>,
140 pub aria_pressed: Option<String>,
142 pub aria_expanded: Option<String>,
144 pub aria_selected: Option<String>,
146 pub checked: Option<bool>,
148 pub class_name: Option<String>,
150 pub is_connected: bool,
152}
153
154#[async_trait]
159pub trait EngineAdapter: Send + Sync {
160 fn engine_type(&self) -> EngineType;
162
163 async fn url(&self) -> Result<String, EngineError>;
165
166 async fn goto(&self, url: &str) -> Result<(), EngineError>;
168
169 async fn query_selector(&self, selector: &str) -> Result<Option<ElementInfo>, EngineError>;
171
172 async fn query_selector_all(&self, selector: &str) -> Result<Vec<ElementInfo>, EngineError>;
174
175 async fn count(&self, selector: &str) -> Result<usize, EngineError>;
177
178 async fn click(&self, selector: &str) -> Result<(), EngineError>;
180
181 async fn fill(&self, selector: &str, text: &str) -> Result<(), EngineError>;
183
184 async fn type_text(&self, selector: &str, text: &str) -> Result<(), EngineError>;
186
187 async fn text_content(&self, selector: &str) -> Result<Option<String>, EngineError>;
189
190 async fn input_value(&self, selector: &str) -> Result<Option<String>, EngineError>;
192
193 async fn get_attribute(
195 &self,
196 selector: &str,
197 attribute: &str,
198 ) -> Result<Option<String>, EngineError>;
199
200 async fn is_visible(&self, selector: &str) -> Result<bool, EngineError>;
202
203 async fn is_enabled(&self, selector: &str) -> Result<bool, EngineError>;
205
206 async fn wait_for_selector(&self, selector: &str, timeout_ms: u64) -> Result<(), EngineError>;
208
209 async fn scroll_into_view(&self, selector: &str) -> Result<(), EngineError>;
211
212 async fn evaluate(&self, script: &str) -> Result<serde_json::Value, EngineError>;
214
215 async fn screenshot(&self) -> Result<Vec<u8>, EngineError>;
217
218 async fn pdf(&self, options: PdfOptions) -> Result<Vec<u8>, EngineError>;
223
224 async fn bring_to_front(&self) -> Result<(), EngineError>;
226
227 async fn wait_for_navigation(&self, timeout_ms: u64) -> Result<(), EngineError>;
229
230 async fn keyboard_press(&self, key: &str) -> Result<(), EngineError>;
238
239 async fn keyboard_type(&self, text: &str) -> Result<(), EngineError>;
241
242 async fn keyboard_down(&self, key: &str) -> Result<(), EngineError>;
244
245 async fn keyboard_up(&self, key: &str) -> Result<(), EngineError>;
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 #[test]
254 fn engine_type_display() {
255 assert_eq!(EngineType::Chromiumoxide.to_string(), "chromiumoxide");
256 assert_eq!(EngineType::Fantoccini.to_string(), "fantoccini");
257 }
258
259 #[test]
260 fn engine_type_from_str() {
261 assert_eq!(
262 "chromiumoxide".parse::<EngineType>().unwrap(),
263 EngineType::Chromiumoxide
264 );
265 assert_eq!(
266 "cdp".parse::<EngineType>().unwrap(),
267 EngineType::Chromiumoxide
268 );
269 assert_eq!(
270 "puppeteer".parse::<EngineType>().unwrap(),
271 EngineType::Chromiumoxide
272 );
273 assert_eq!(
274 "fantoccini".parse::<EngineType>().unwrap(),
275 EngineType::Fantoccini
276 );
277 assert_eq!(
278 "webdriver".parse::<EngineType>().unwrap(),
279 EngineType::Fantoccini
280 );
281 assert_eq!(
282 "playwright".parse::<EngineType>().unwrap(),
283 EngineType::Fantoccini
284 );
285 }
286
287 #[test]
288 fn engine_type_from_str_case_insensitive() {
289 assert_eq!(
290 "CHROMIUMOXIDE".parse::<EngineType>().unwrap(),
291 EngineType::Chromiumoxide
292 );
293 assert_eq!(
294 "Fantoccini".parse::<EngineType>().unwrap(),
295 EngineType::Fantoccini
296 );
297 }
298
299 #[test]
300 fn engine_type_from_str_invalid() {
301 let result = "invalid".parse::<EngineType>();
302 assert!(result.is_err());
303 if let Err(EngineError::InvalidEngine(name)) = result {
304 assert_eq!(name, "invalid");
305 } else {
306 panic!("Expected InvalidEngine error");
307 }
308 }
309
310 #[test]
311 fn pdf_options_default() {
312 let opts = PdfOptions::default();
313 assert!(opts.format.is_none());
314 assert!(!opts.print_background);
315 assert!(opts.margin_top.is_none());
316 assert!(opts.path.is_none());
317 assert!(opts.scale.is_none());
318 }
319
320 #[test]
321 fn pdf_options_can_be_constructed() {
322 let opts = PdfOptions {
323 format: Some("A4".to_string()),
324 print_background: true,
325 margin_top: Some("1cm".to_string()),
326 margin_right: Some("1cm".to_string()),
327 margin_bottom: Some("1cm".to_string()),
328 margin_left: Some("1cm".to_string()),
329 scale: Some(1.0),
330 path: None,
331 };
332 assert_eq!(opts.format.as_deref(), Some("A4"));
333 assert!(opts.print_background);
334 assert_eq!(opts.margin_top.as_deref(), Some("1cm"));
335 assert_eq!(opts.scale, Some(1.0));
336 }
337
338 #[test]
339 fn pre_click_state_default() {
340 let state = PreClickState::default();
341 assert!(state.disabled.is_none());
342 assert!(state.aria_pressed.is_none());
343 assert!(!state.is_connected);
344 }
345
346 #[test]
347 fn click_verification_result_creation() {
348 let result = ClickVerificationResult {
349 verified: true,
350 reason: "element state changed".to_string(),
351 navigation_error: false,
352 };
353 assert!(result.verified);
354 assert!(!result.navigation_error);
355 }
356}