1use async_trait::async_trait;
7
8use crate::interactions::click_result::{ClickEffect, Evidence};
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum EngineType {
16 Chromiumoxide,
18 Fantoccini,
20 Playwright,
22 Puppeteer,
24}
25
26impl std::fmt::Display for EngineType {
27 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 match self {
29 EngineType::Chromiumoxide => write!(f, "chromiumoxide"),
30 EngineType::Fantoccini => write!(f, "fantoccini"),
31 EngineType::Playwright => write!(f, "playwright"),
32 EngineType::Puppeteer => write!(f, "puppeteer"),
33 }
34 }
35}
36
37impl std::str::FromStr for EngineType {
38 type Err = EngineError;
39
40 fn from_str(s: &str) -> Result<Self, Self::Err> {
41 match s.to_lowercase().as_str() {
42 "chromiumoxide" | "cdp" => Ok(EngineType::Chromiumoxide),
43 "fantoccini" | "webdriver" => Ok(EngineType::Fantoccini),
44 "playwright" => Ok(EngineType::Playwright),
45 "puppeteer" => Ok(EngineType::Puppeteer),
46 _ => Err(EngineError::InvalidEngine(s.to_string())),
47 }
48 }
49}
50
51#[derive(Debug, Error)]
53pub enum EngineError {
54 #[error(
56 "Invalid engine: {0}. Expected 'chromiumoxide', 'fantoccini', 'playwright', or 'puppeteer'"
57 )]
58 InvalidEngine(String),
59
60 #[error("Element not found: {0}")]
62 ElementNotFound(String),
63
64 #[error("Operation timed out: {0}")]
66 Timeout(String),
67
68 #[error("Navigation error: {0}")]
70 Navigation(String),
71
72 #[error("JavaScript evaluation error: {0}")]
74 Evaluation(String),
75
76 #[error("Browser error: {0}")]
78 Browser(String),
79}
80
81#[derive(Debug, Clone)]
83pub struct ElementInfo {
84 pub tag_name: String,
86 pub text_content: Option<String>,
88 pub is_visible: bool,
90 pub is_enabled: bool,
92 pub bounding_box: Option<(f64, f64, f64, f64)>,
94}
95
96#[derive(Debug, Clone)]
102pub struct ClickVerificationResult {
103 pub verified: bool,
105 pub reason: String,
107 pub navigation_error: bool,
109 pub effect: ClickEffect,
111 pub evidence: Vec<Evidence>,
113}
114
115impl ClickVerificationResult {
116 pub fn new(effect: ClickEffect, reason: impl Into<String>, evidence: Vec<Evidence>) -> Self {
118 Self {
119 verified: effect == ClickEffect::Confirmed,
120 reason: reason.into(),
121 navigation_error: false,
122 effect,
123 evidence,
124 }
125 }
126
127 pub fn confirmed(reason: impl Into<String>, evidence: Vec<Evidence>) -> Self {
129 Self::new(ClickEffect::Confirmed, reason, evidence)
130 }
131
132 pub fn not_observed(reason: impl Into<String>, evidence: Vec<Evidence>) -> Self {
134 Self::new(ClickEffect::NotObserved, reason, evidence)
135 }
136
137 #[must_use]
139 pub fn with_navigation_error(mut self, navigation_error: bool) -> Self {
140 self.navigation_error = navigation_error;
141 self
142 }
143}
144
145#[derive(Debug, Clone)]
147pub struct ScrollVerificationResult {
148 pub verified: bool,
150 pub in_viewport: bool,
152 pub attempts: u32,
154}
155
156#[derive(Debug, Clone)]
158pub struct FillVerificationResult {
159 pub verified: bool,
161 pub actual_value: String,
163 pub attempts: u32,
165}
166
167#[derive(Debug, Clone, Default)]
169pub struct PdfOptions {
170 pub format: Option<String>,
172 pub print_background: bool,
174 pub margin_top: Option<String>,
176 pub margin_right: Option<String>,
177 pub margin_bottom: Option<String>,
178 pub margin_left: Option<String>,
179 pub scale: Option<f64>,
181 pub path: Option<String>,
183}
184
185#[derive(Debug, Clone, Default)]
187pub struct PreClickState {
188 pub disabled: Option<bool>,
190 pub aria_pressed: Option<String>,
192 pub aria_expanded: Option<String>,
194 pub aria_selected: Option<String>,
196 pub checked: Option<bool>,
198 pub class_name: Option<String>,
200 pub is_connected: bool,
202}
203
204#[async_trait]
209pub trait EngineAdapter: Send + Sync {
210 fn engine_type(&self) -> EngineType;
212
213 async fn url(&self) -> Result<String, EngineError>;
215
216 async fn goto(&self, url: &str) -> Result<(), EngineError>;
218
219 async fn query_selector(&self, selector: &str) -> Result<Option<ElementInfo>, EngineError>;
221
222 async fn query_selector_all(&self, selector: &str) -> Result<Vec<ElementInfo>, EngineError>;
224
225 async fn count(&self, selector: &str) -> Result<usize, EngineError>;
227
228 async fn click(&self, selector: &str) -> Result<(), EngineError>;
230
231 async fn mouse_click(&self, x: f64, y: f64) -> Result<(), EngineError> {
247 let _ = (x, y);
248 Err(EngineError::Browser(
249 "this engine does not expose viewport-coordinate pointer input".to_string(),
250 ))
251 }
252
253 async fn fill(&self, selector: &str, text: &str) -> Result<(), EngineError>;
255
256 async fn type_text(&self, selector: &str, text: &str) -> Result<(), EngineError>;
258
259 async fn text_content(&self, selector: &str) -> Result<Option<String>, EngineError>;
261
262 async fn input_value(&self, selector: &str) -> Result<Option<String>, EngineError>;
264
265 async fn get_attribute(
267 &self,
268 selector: &str,
269 attribute: &str,
270 ) -> Result<Option<String>, EngineError>;
271
272 async fn is_visible(&self, selector: &str) -> Result<bool, EngineError>;
274
275 async fn is_enabled(&self, selector: &str) -> Result<bool, EngineError>;
277
278 async fn wait_for_selector(&self, selector: &str, timeout_ms: u64) -> Result<(), EngineError>;
280
281 async fn scroll_into_view(&self, selector: &str) -> Result<(), EngineError>;
283
284 async fn evaluate(&self, script: &str) -> Result<serde_json::Value, EngineError>;
286
287 async fn screenshot(&self) -> Result<Vec<u8>, EngineError>;
289
290 async fn pdf(&self, options: PdfOptions) -> Result<Vec<u8>, EngineError>;
295
296 async fn bring_to_front(&self) -> Result<(), EngineError>;
298
299 async fn wait_for_navigation(&self, timeout_ms: u64) -> Result<(), EngineError>;
301
302 async fn keyboard_press(&self, key: &str) -> Result<(), EngineError>;
310
311 async fn keyboard_type(&self, text: &str) -> Result<(), EngineError>;
313
314 async fn keyboard_down(&self, key: &str) -> Result<(), EngineError>;
316
317 async fn keyboard_up(&self, key: &str) -> Result<(), EngineError>;
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324
325 #[test]
326 fn engine_type_display() {
327 assert_eq!(EngineType::Chromiumoxide.to_string(), "chromiumoxide");
328 assert_eq!(EngineType::Fantoccini.to_string(), "fantoccini");
329 assert_eq!(EngineType::Playwright.to_string(), "playwright");
330 assert_eq!(EngineType::Puppeteer.to_string(), "puppeteer");
331 }
332
333 #[test]
334 fn engine_type_from_str() {
335 assert_eq!(
336 "chromiumoxide".parse::<EngineType>().unwrap(),
337 EngineType::Chromiumoxide
338 );
339 assert_eq!(
340 "cdp".parse::<EngineType>().unwrap(),
341 EngineType::Chromiumoxide
342 );
343 assert_eq!(
344 "puppeteer".parse::<EngineType>().unwrap(),
345 EngineType::Puppeteer
346 );
347 assert_eq!(
348 "puppeteer".parse::<EngineType>().unwrap().to_string(),
349 "puppeteer"
350 );
351 assert_eq!(
352 "fantoccini".parse::<EngineType>().unwrap(),
353 EngineType::Fantoccini
354 );
355 assert_eq!(
356 "webdriver".parse::<EngineType>().unwrap(),
357 EngineType::Fantoccini
358 );
359 assert_eq!(
360 "playwright".parse::<EngineType>().unwrap(),
361 EngineType::Playwright
362 );
363 assert_eq!(
364 "playwright".parse::<EngineType>().unwrap().to_string(),
365 "playwright"
366 );
367 }
368
369 #[test]
370 fn engine_type_from_str_case_insensitive() {
371 assert_eq!(
372 "CHROMIUMOXIDE".parse::<EngineType>().unwrap(),
373 EngineType::Chromiumoxide
374 );
375 assert_eq!(
376 "Fantoccini".parse::<EngineType>().unwrap(),
377 EngineType::Fantoccini
378 );
379 assert_eq!(
380 "Playwright".parse::<EngineType>().unwrap(),
381 EngineType::Playwright
382 );
383 assert_eq!(
384 "Puppeteer".parse::<EngineType>().unwrap(),
385 EngineType::Puppeteer
386 );
387 }
388
389 #[test]
390 fn engine_type_from_str_invalid() {
391 let result = "invalid".parse::<EngineType>();
392 assert!(result.is_err());
393 if let Err(EngineError::InvalidEngine(name)) = result {
394 assert_eq!(name, "invalid");
395 } else {
396 panic!("Expected InvalidEngine error");
397 }
398 }
399
400 #[test]
401 fn pdf_options_default() {
402 let opts = PdfOptions::default();
403 assert!(opts.format.is_none());
404 assert!(!opts.print_background);
405 assert!(opts.margin_top.is_none());
406 assert!(opts.path.is_none());
407 assert!(opts.scale.is_none());
408 }
409
410 #[test]
411 fn pdf_options_can_be_constructed() {
412 let opts = PdfOptions {
413 format: Some("A4".to_string()),
414 print_background: true,
415 margin_top: Some("1cm".to_string()),
416 margin_right: Some("1cm".to_string()),
417 margin_bottom: Some("1cm".to_string()),
418 margin_left: Some("1cm".to_string()),
419 scale: Some(1.0),
420 path: None,
421 };
422 assert_eq!(opts.format.as_deref(), Some("A4"));
423 assert!(opts.print_background);
424 assert_eq!(opts.margin_top.as_deref(), Some("1cm"));
425 assert_eq!(opts.scale, Some(1.0));
426 }
427
428 #[test]
429 fn pre_click_state_default() {
430 let state = PreClickState::default();
431 assert!(state.disabled.is_none());
432 assert!(state.aria_pressed.is_none());
433 assert!(!state.is_connected);
434 }
435
436 #[test]
437 fn click_verification_result_creation() {
438 let result = ClickVerificationResult::confirmed(
439 "element state changed",
440 vec![Evidence::message("element-state", "className changed")],
441 );
442 assert!(result.verified);
443 assert!(!result.navigation_error);
444 assert_eq!(result.effect, ClickEffect::Confirmed);
445 assert_eq!(result.evidence.len(), 1);
446
447 let unobserved = ClickVerificationResult::not_observed("no observable change", vec![]);
450 assert!(!unobserved.verified);
451 assert_eq!(unobserved.effect, ClickEffect::NotObserved);
452 }
453}