1use async_trait::async_trait;
7use futures::stream::BoxStream;
8
9use crate::interactions::click_result::{ClickEffect, Evidence};
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum EngineType {
17 Chromiumoxide,
19 Fantoccini,
21 Playwright,
23 Puppeteer,
25}
26
27impl std::fmt::Display for EngineType {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 match self {
30 EngineType::Chromiumoxide => write!(f, "chromiumoxide"),
31 EngineType::Fantoccini => write!(f, "fantoccini"),
32 EngineType::Playwright => write!(f, "playwright"),
33 EngineType::Puppeteer => write!(f, "puppeteer"),
34 }
35 }
36}
37
38impl std::str::FromStr for EngineType {
39 type Err = EngineError;
40
41 fn from_str(s: &str) -> Result<Self, Self::Err> {
42 match s.to_lowercase().as_str() {
43 "chromiumoxide" | "cdp" => Ok(EngineType::Chromiumoxide),
44 "fantoccini" | "webdriver" => Ok(EngineType::Fantoccini),
45 "playwright" => Ok(EngineType::Playwright),
46 "puppeteer" => Ok(EngineType::Puppeteer),
47 _ => Err(EngineError::InvalidEngine(s.to_string())),
48 }
49 }
50}
51
52#[derive(Debug, Error)]
54pub enum EngineError {
55 #[error(
57 "Invalid engine: {0}. Expected 'chromiumoxide', 'fantoccini', 'playwright', or 'puppeteer'"
58 )]
59 InvalidEngine(String),
60
61 #[error("Element not found: {0}")]
63 ElementNotFound(String),
64
65 #[error("Operation timed out: {0}")]
67 Timeout(String),
68
69 #[error("Navigation error: {0}")]
71 Navigation(String),
72
73 #[error("JavaScript evaluation error: {0}")]
75 Evaluation(String),
76
77 #[error("Browser error: {0}")]
79 Browser(String),
80}
81
82#[derive(Debug, Clone)]
84pub struct ElementInfo {
85 pub tag_name: String,
87 pub text_content: Option<String>,
89 pub is_visible: bool,
91 pub is_enabled: bool,
93 pub bounding_box: Option<(f64, f64, f64, f64)>,
95}
96
97#[derive(Debug, Clone)]
103pub struct ClickVerificationResult {
104 pub verified: bool,
106 pub reason: String,
108 pub navigation_error: bool,
110 pub effect: ClickEffect,
112 pub evidence: Vec<Evidence>,
114}
115
116impl ClickVerificationResult {
117 pub fn new(effect: ClickEffect, reason: impl Into<String>, evidence: Vec<Evidence>) -> Self {
119 Self {
120 verified: effect == ClickEffect::Confirmed,
121 reason: reason.into(),
122 navigation_error: false,
123 effect,
124 evidence,
125 }
126 }
127
128 pub fn confirmed(reason: impl Into<String>, evidence: Vec<Evidence>) -> Self {
130 Self::new(ClickEffect::Confirmed, reason, evidence)
131 }
132
133 pub fn not_observed(reason: impl Into<String>, evidence: Vec<Evidence>) -> Self {
135 Self::new(ClickEffect::NotObserved, reason, evidence)
136 }
137
138 #[must_use]
140 pub fn with_navigation_error(mut self, navigation_error: bool) -> Self {
141 self.navigation_error = navigation_error;
142 self
143 }
144}
145
146#[derive(Debug, Clone)]
148pub struct ScrollVerificationResult {
149 pub verified: bool,
151 pub in_viewport: bool,
153 pub attempts: u32,
155}
156
157#[derive(Debug, Clone)]
159pub struct FillVerificationResult {
160 pub verified: bool,
162 pub actual_value: String,
164 pub attempts: u32,
166}
167
168#[derive(Debug, Clone, Default)]
170pub struct PdfOptions {
171 pub format: Option<String>,
173 pub print_background: bool,
175 pub margin_top: Option<String>,
177 pub margin_right: Option<String>,
178 pub margin_bottom: Option<String>,
179 pub margin_left: Option<String>,
180 pub scale: Option<f64>,
182 pub path: Option<String>,
184}
185
186#[derive(Debug, Clone, Default)]
188pub struct PreClickState {
189 pub disabled: Option<bool>,
191 pub aria_pressed: Option<String>,
193 pub aria_expanded: Option<String>,
195 pub aria_selected: Option<String>,
197 pub checked: Option<bool>,
199 pub class_name: Option<String>,
201 pub is_connected: bool,
203}
204
205#[derive(Debug, Clone, PartialEq)]
211pub enum TraceEngineEvent {
212 Navigated {
214 main_frame: bool,
216 url: String,
218 },
219 Console {
221 level: String,
223 text: String,
225 },
226 PageError {
228 message: String,
230 stack: Option<String>,
232 },
233 RequestFailed {
235 url: String,
237 method: String,
239 error_text: Option<String>,
241 },
242 Dialog {
244 dialog_type: String,
246 message: String,
248 },
249 Download {
251 phase: String,
253 id: Option<String>,
255 suggested_filename: Option<String>,
257 path: Option<String>,
259 checksum: Option<String>,
261 bytes: Option<u64>,
263 url: Option<String>,
265 failure: Option<String>,
267 },
268}
269
270#[async_trait]
275pub trait EngineAdapter: Send + Sync {
276 fn engine_type(&self) -> EngineType;
278
279 async fn url(&self) -> Result<String, EngineError>;
281
282 async fn goto(&self, url: &str) -> Result<(), EngineError>;
284
285 async fn query_selector(&self, selector: &str) -> Result<Option<ElementInfo>, EngineError>;
287
288 async fn query_selector_all(&self, selector: &str) -> Result<Vec<ElementInfo>, EngineError>;
290
291 async fn count(&self, selector: &str) -> Result<usize, EngineError>;
293
294 async fn click(&self, selector: &str) -> Result<(), EngineError>;
296
297 async fn mouse_click(&self, x: f64, y: f64) -> Result<(), EngineError> {
313 let _ = (x, y);
314 Err(EngineError::Browser(
315 "this engine does not expose viewport-coordinate pointer input".to_string(),
316 ))
317 }
318
319 async fn fill(&self, selector: &str, text: &str) -> Result<(), EngineError>;
321
322 async fn type_text(&self, selector: &str, text: &str) -> Result<(), EngineError>;
324
325 async fn text_content(&self, selector: &str) -> Result<Option<String>, EngineError>;
327
328 async fn input_value(&self, selector: &str) -> Result<Option<String>, EngineError>;
330
331 async fn get_attribute(
333 &self,
334 selector: &str,
335 attribute: &str,
336 ) -> Result<Option<String>, EngineError>;
337
338 async fn is_visible(&self, selector: &str) -> Result<bool, EngineError>;
340
341 async fn is_enabled(&self, selector: &str) -> Result<bool, EngineError>;
343
344 async fn wait_for_selector(&self, selector: &str, timeout_ms: u64) -> Result<(), EngineError>;
346
347 async fn scroll_into_view(&self, selector: &str) -> Result<(), EngineError>;
349
350 async fn evaluate(&self, script: &str) -> Result<serde_json::Value, EngineError>;
352
353 async fn read_browser_version_page(&self) -> Result<serde_json::Value, EngineError> {
355 Err(EngineError::Browser(format!(
356 "browser version metadata is unavailable for the {} engine",
357 self.engine_type()
358 )))
359 }
360
361 async fn restore_storage_state(&self, _state: serde_json::Value) -> Result<(), EngineError> {
363 Err(EngineError::Browser(format!(
364 "portable storage state is unavailable for the {} engine",
365 self.engine_type()
366 )))
367 }
368
369 async fn export_storage_state(&self) -> Result<serde_json::Value, EngineError> {
371 Err(EngineError::Browser(format!(
372 "portable storage state is unavailable for the {} engine",
373 self.engine_type()
374 )))
375 }
376
377 async fn screenshot(&self) -> Result<Vec<u8>, EngineError>;
379
380 async fn pdf(&self, options: PdfOptions) -> Result<Vec<u8>, EngineError>;
385
386 async fn bring_to_front(&self) -> Result<(), EngineError>;
388
389 async fn wait_for_navigation(&self, timeout_ms: u64) -> Result<(), EngineError>;
391
392 async fn keyboard_press(&self, key: &str) -> Result<(), EngineError>;
400
401 async fn keyboard_type(&self, text: &str) -> Result<(), EngineError>;
403
404 async fn keyboard_down(&self, key: &str) -> Result<(), EngineError>;
406
407 async fn keyboard_up(&self, key: &str) -> Result<(), EngineError>;
409
410 async fn add_init_script(&self, _script: &str) -> Result<Option<String>, EngineError> {
418 Ok(None)
419 }
420
421 async fn remove_init_script(&self, _identifier: &str) -> Result<(), EngineError> {
423 Ok(())
424 }
425
426 async fn trace_events(&self) -> Option<BoxStream<'static, TraceEngineEvent>> {
429 None
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 #[test]
438 fn engine_type_display() {
439 assert_eq!(EngineType::Chromiumoxide.to_string(), "chromiumoxide");
440 assert_eq!(EngineType::Fantoccini.to_string(), "fantoccini");
441 assert_eq!(EngineType::Playwright.to_string(), "playwright");
442 assert_eq!(EngineType::Puppeteer.to_string(), "puppeteer");
443 }
444
445 #[test]
446 fn engine_type_from_str() {
447 assert_eq!(
448 "chromiumoxide".parse::<EngineType>().unwrap(),
449 EngineType::Chromiumoxide
450 );
451 assert_eq!(
452 "cdp".parse::<EngineType>().unwrap(),
453 EngineType::Chromiumoxide
454 );
455 assert_eq!(
456 "puppeteer".parse::<EngineType>().unwrap(),
457 EngineType::Puppeteer
458 );
459 assert_eq!(
460 "puppeteer".parse::<EngineType>().unwrap().to_string(),
461 "puppeteer"
462 );
463 assert_eq!(
464 "fantoccini".parse::<EngineType>().unwrap(),
465 EngineType::Fantoccini
466 );
467 assert_eq!(
468 "webdriver".parse::<EngineType>().unwrap(),
469 EngineType::Fantoccini
470 );
471 assert_eq!(
472 "playwright".parse::<EngineType>().unwrap(),
473 EngineType::Playwright
474 );
475 assert_eq!(
476 "playwright".parse::<EngineType>().unwrap().to_string(),
477 "playwright"
478 );
479 }
480
481 #[test]
482 fn engine_type_from_str_case_insensitive() {
483 assert_eq!(
484 "CHROMIUMOXIDE".parse::<EngineType>().unwrap(),
485 EngineType::Chromiumoxide
486 );
487 assert_eq!(
488 "Fantoccini".parse::<EngineType>().unwrap(),
489 EngineType::Fantoccini
490 );
491 assert_eq!(
492 "Playwright".parse::<EngineType>().unwrap(),
493 EngineType::Playwright
494 );
495 assert_eq!(
496 "Puppeteer".parse::<EngineType>().unwrap(),
497 EngineType::Puppeteer
498 );
499 }
500
501 #[test]
502 fn engine_type_from_str_invalid() {
503 let result = "invalid".parse::<EngineType>();
504 assert!(result.is_err());
505 if let Err(EngineError::InvalidEngine(name)) = result {
506 assert_eq!(name, "invalid");
507 } else {
508 panic!("Expected InvalidEngine error");
509 }
510 }
511
512 #[test]
513 fn pdf_options_default() {
514 let opts = PdfOptions::default();
515 assert!(opts.format.is_none());
516 assert!(!opts.print_background);
517 assert!(opts.margin_top.is_none());
518 assert!(opts.path.is_none());
519 assert!(opts.scale.is_none());
520 }
521
522 #[test]
523 fn pdf_options_can_be_constructed() {
524 let opts = PdfOptions {
525 format: Some("A4".to_string()),
526 print_background: true,
527 margin_top: Some("1cm".to_string()),
528 margin_right: Some("1cm".to_string()),
529 margin_bottom: Some("1cm".to_string()),
530 margin_left: Some("1cm".to_string()),
531 scale: Some(1.0),
532 path: None,
533 };
534 assert_eq!(opts.format.as_deref(), Some("A4"));
535 assert!(opts.print_background);
536 assert_eq!(opts.margin_top.as_deref(), Some("1cm"));
537 assert_eq!(opts.scale, Some(1.0));
538 }
539
540 #[test]
541 fn pre_click_state_default() {
542 let state = PreClickState::default();
543 assert!(state.disabled.is_none());
544 assert!(state.aria_pressed.is_none());
545 assert!(!state.is_connected);
546 }
547
548 #[test]
549 fn click_verification_result_creation() {
550 let result = ClickVerificationResult::confirmed(
551 "element state changed",
552 vec![Evidence::message("element-state", "className changed")],
553 );
554 assert!(result.verified);
555 assert!(!result.navigation_error);
556 assert_eq!(result.effect, ClickEffect::Confirmed);
557 assert_eq!(result.evidence.len(), 1);
558
559 let unobserved = ClickVerificationResult::not_observed("no observable change", vec![]);
562 assert!(!unobserved.verified);
563 assert_eq!(unobserved.effect, ClickEffect::NotObserved);
564 }
565}