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 #[serde(alias = "selenium", alias = "webdriver")]
21 Fantoccini,
22 Playwright,
24 Puppeteer,
26}
27
28impl std::fmt::Display for EngineType {
29 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 match self {
31 EngineType::Chromiumoxide => write!(f, "chromiumoxide"),
32 EngineType::Fantoccini => write!(f, "fantoccini"),
33 EngineType::Playwright => write!(f, "playwright"),
34 EngineType::Puppeteer => write!(f, "puppeteer"),
35 }
36 }
37}
38
39impl std::str::FromStr for EngineType {
40 type Err = EngineError;
41
42 fn from_str(s: &str) -> Result<Self, Self::Err> {
43 match s.to_lowercase().as_str() {
44 "chromiumoxide" | "cdp" => Ok(EngineType::Chromiumoxide),
45 "fantoccini" | "webdriver" | "selenium" => Ok(EngineType::Fantoccini),
46 "playwright" => Ok(EngineType::Playwright),
47 "puppeteer" => Ok(EngineType::Puppeteer),
48 _ => Err(EngineError::InvalidEngine(s.to_string())),
49 }
50 }
51}
52
53#[derive(Debug, Error)]
55pub enum EngineError {
56 #[error(
58 "Invalid engine: {0}. Expected 'chromiumoxide', 'fantoccini'/'selenium'/'webdriver', 'playwright', or 'puppeteer'"
59 )]
60 InvalidEngine(String),
61
62 #[error("Element not found: {0}")]
64 ElementNotFound(String),
65
66 #[error("Operation timed out: {0}")]
68 Timeout(String),
69
70 #[error("Navigation error: {0}")]
72 Navigation(String),
73
74 #[error("JavaScript evaluation error: {0}")]
76 Evaluation(String),
77
78 #[error("Browser error: {0}")]
80 Browser(String),
81}
82
83#[derive(Debug, Clone)]
85pub struct ElementInfo {
86 pub tag_name: String,
88 pub text_content: Option<String>,
90 pub is_visible: bool,
92 pub is_enabled: bool,
94 pub bounding_box: Option<(f64, f64, f64, f64)>,
96}
97
98#[derive(Debug, Clone)]
104pub struct ClickVerificationResult {
105 pub verified: bool,
107 pub reason: String,
109 pub navigation_error: bool,
111 pub effect: ClickEffect,
113 pub evidence: Vec<Evidence>,
115}
116
117impl ClickVerificationResult {
118 pub fn new(effect: ClickEffect, reason: impl Into<String>, evidence: Vec<Evidence>) -> Self {
120 Self {
121 verified: effect == ClickEffect::Confirmed,
122 reason: reason.into(),
123 navigation_error: false,
124 effect,
125 evidence,
126 }
127 }
128
129 pub fn confirmed(reason: impl Into<String>, evidence: Vec<Evidence>) -> Self {
131 Self::new(ClickEffect::Confirmed, reason, evidence)
132 }
133
134 pub fn not_observed(reason: impl Into<String>, evidence: Vec<Evidence>) -> Self {
136 Self::new(ClickEffect::NotObserved, reason, evidence)
137 }
138
139 #[must_use]
141 pub fn with_navigation_error(mut self, navigation_error: bool) -> Self {
142 self.navigation_error = navigation_error;
143 self
144 }
145}
146
147#[derive(Debug, Clone)]
149pub struct ScrollVerificationResult {
150 pub verified: bool,
152 pub in_viewport: bool,
154 pub attempts: u32,
156}
157
158#[derive(Debug, Clone)]
160pub struct FillVerificationResult {
161 pub verified: bool,
163 pub actual_value: String,
165 pub attempts: u32,
167}
168
169#[derive(Debug, Clone, Default)]
171pub struct PdfOptions {
172 pub format: Option<String>,
174 pub print_background: bool,
176 pub margin_top: Option<String>,
178 pub margin_right: Option<String>,
179 pub margin_bottom: Option<String>,
180 pub margin_left: Option<String>,
181 pub scale: Option<f64>,
183 pub path: Option<String>,
185}
186
187#[derive(Debug, Clone, Default)]
189pub struct PreClickState {
190 pub disabled: Option<bool>,
192 pub aria_pressed: Option<String>,
194 pub aria_expanded: Option<String>,
196 pub aria_selected: Option<String>,
198 pub checked: Option<bool>,
200 pub class_name: Option<String>,
202 pub is_connected: bool,
204}
205
206#[derive(Debug, Clone, PartialEq)]
212pub enum TraceEngineEvent {
213 Navigated {
215 main_frame: bool,
217 url: String,
219 },
220 Console {
222 level: String,
224 text: String,
226 },
227 PageError {
229 message: String,
231 stack: Option<String>,
233 },
234 RequestFailed {
236 url: String,
238 method: String,
240 error_text: Option<String>,
242 },
243 Dialog {
245 dialog_type: String,
247 message: String,
249 },
250 Download {
252 phase: String,
254 id: Option<String>,
256 suggested_filename: Option<String>,
258 path: Option<String>,
260 checksum: Option<String>,
262 bytes: Option<u64>,
264 url: Option<String>,
266 failure: Option<String>,
268 },
269}
270
271#[async_trait]
276pub trait EngineAdapter: Send + Sync {
277 fn engine_type(&self) -> EngineType;
279
280 async fn url(&self) -> Result<String, EngineError>;
282
283 async fn goto(&self, url: &str) -> Result<(), EngineError>;
285
286 async fn query_selector(&self, selector: &str) -> Result<Option<ElementInfo>, EngineError>;
288
289 async fn query_selector_all(&self, selector: &str) -> Result<Vec<ElementInfo>, EngineError>;
291
292 async fn count(&self, selector: &str) -> Result<usize, EngineError>;
294
295 async fn click(&self, selector: &str) -> Result<(), EngineError>;
297
298 async fn mouse_click(&self, x: f64, y: f64) -> Result<(), EngineError> {
314 let _ = (x, y);
315 Err(EngineError::Browser(
316 "this engine does not expose viewport-coordinate pointer input".to_string(),
317 ))
318 }
319
320 async fn fill(&self, selector: &str, text: &str) -> Result<(), EngineError>;
322
323 async fn type_text(&self, selector: &str, text: &str) -> Result<(), EngineError>;
325
326 async fn text_content(&self, selector: &str) -> Result<Option<String>, EngineError>;
328
329 async fn input_value(&self, selector: &str) -> Result<Option<String>, EngineError>;
331
332 async fn get_attribute(
334 &self,
335 selector: &str,
336 attribute: &str,
337 ) -> Result<Option<String>, EngineError>;
338
339 async fn is_visible(&self, selector: &str) -> Result<bool, EngineError>;
341
342 async fn is_enabled(&self, selector: &str) -> Result<bool, EngineError>;
344
345 async fn wait_for_selector(&self, selector: &str, timeout_ms: u64) -> Result<(), EngineError>;
347
348 async fn scroll_into_view(&self, selector: &str) -> Result<(), EngineError>;
350
351 async fn evaluate(&self, script: &str) -> Result<serde_json::Value, EngineError>;
353
354 async fn read_browser_version_page(&self) -> Result<serde_json::Value, EngineError> {
356 Err(EngineError::Browser(format!(
357 "browser version metadata is unavailable for the {} engine",
358 self.engine_type()
359 )))
360 }
361
362 async fn restore_storage_state(&self, _state: serde_json::Value) -> Result<(), EngineError> {
364 Err(EngineError::Browser(format!(
365 "portable storage state is unavailable for the {} engine",
366 self.engine_type()
367 )))
368 }
369
370 async fn export_storage_state(&self) -> Result<serde_json::Value, EngineError> {
372 Err(EngineError::Browser(format!(
373 "portable storage state is unavailable for the {} engine",
374 self.engine_type()
375 )))
376 }
377
378 async fn screenshot(&self) -> Result<Vec<u8>, EngineError>;
380
381 async fn pdf(&self, options: PdfOptions) -> Result<Vec<u8>, EngineError>;
386
387 async fn bring_to_front(&self) -> Result<(), EngineError>;
389
390 async fn wait_for_navigation(&self, timeout_ms: u64) -> Result<(), EngineError>;
392
393 async fn keyboard_press(&self, key: &str) -> Result<(), EngineError>;
401
402 async fn keyboard_type(&self, text: &str) -> Result<(), EngineError>;
404
405 async fn keyboard_down(&self, key: &str) -> Result<(), EngineError>;
407
408 async fn keyboard_up(&self, key: &str) -> Result<(), EngineError>;
410
411 async fn add_init_script(&self, _script: &str) -> Result<Option<String>, EngineError> {
419 Ok(None)
420 }
421
422 async fn remove_init_script(&self, _identifier: &str) -> Result<(), EngineError> {
424 Ok(())
425 }
426
427 async fn trace_events(&self) -> Option<BoxStream<'static, TraceEngineEvent>> {
430 None
431 }
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 #[test]
439 fn engine_type_display() {
440 assert_eq!(EngineType::Chromiumoxide.to_string(), "chromiumoxide");
441 assert_eq!(EngineType::Fantoccini.to_string(), "fantoccini");
442 assert_eq!(EngineType::Playwright.to_string(), "playwright");
443 assert_eq!(EngineType::Puppeteer.to_string(), "puppeteer");
444 }
445
446 #[test]
447 fn engine_type_from_str() {
448 assert_eq!(
449 "chromiumoxide".parse::<EngineType>().unwrap(),
450 EngineType::Chromiumoxide
451 );
452 assert_eq!(
453 "cdp".parse::<EngineType>().unwrap(),
454 EngineType::Chromiumoxide
455 );
456 assert_eq!(
457 "puppeteer".parse::<EngineType>().unwrap(),
458 EngineType::Puppeteer
459 );
460 assert_eq!(
461 "puppeteer".parse::<EngineType>().unwrap().to_string(),
462 "puppeteer"
463 );
464 assert_eq!(
465 "fantoccini".parse::<EngineType>().unwrap(),
466 EngineType::Fantoccini
467 );
468 assert_eq!(
469 "webdriver".parse::<EngineType>().unwrap(),
470 EngineType::Fantoccini
471 );
472 assert_eq!(
473 "playwright".parse::<EngineType>().unwrap(),
474 EngineType::Playwright
475 );
476 assert_eq!(
477 "playwright".parse::<EngineType>().unwrap().to_string(),
478 "playwright"
479 );
480 }
481
482 #[test]
483 fn engine_type_from_str_case_insensitive() {
484 assert_eq!(
485 "CHROMIUMOXIDE".parse::<EngineType>().unwrap(),
486 EngineType::Chromiumoxide
487 );
488 assert_eq!(
489 "Fantoccini".parse::<EngineType>().unwrap(),
490 EngineType::Fantoccini
491 );
492 assert_eq!(
493 "Playwright".parse::<EngineType>().unwrap(),
494 EngineType::Playwright
495 );
496 assert_eq!(
497 "Puppeteer".parse::<EngineType>().unwrap(),
498 EngineType::Puppeteer
499 );
500 }
501
502 #[test]
503 fn engine_type_from_str_invalid() {
504 let result = "invalid".parse::<EngineType>();
505 assert!(result.is_err());
506 if let Err(EngineError::InvalidEngine(name)) = result {
507 assert_eq!(name, "invalid");
508 } else {
509 panic!("Expected InvalidEngine error");
510 }
511 }
512
513 #[test]
515 fn selenium_alias_selects_native_webdriver() {
516 for name in ["selenium", "SELENIUM", "webdriver", "fantoccini"] {
517 assert_eq!(name.parse::<EngineType>().unwrap(), EngineType::Fantoccini);
518 }
519 assert_eq!(
520 serde_json::from_str::<EngineType>("\"selenium\"").unwrap(),
521 EngineType::Fantoccini
522 );
523 let options =
524 crate::browser::launcher::LaunchOptions::default().engine("selenium".parse().unwrap());
525 assert_eq!(options.launch, crate::browser::launcher::LaunchMode::Engine);
526 }
527
528 #[test]
529 fn pdf_options_default() {
530 let opts = PdfOptions::default();
531 assert!(opts.format.is_none());
532 assert!(!opts.print_background);
533 assert!(opts.margin_top.is_none());
534 assert!(opts.path.is_none());
535 assert!(opts.scale.is_none());
536 }
537
538 #[test]
539 fn pdf_options_can_be_constructed() {
540 let opts = PdfOptions {
541 format: Some("A4".to_string()),
542 print_background: true,
543 margin_top: Some("1cm".to_string()),
544 margin_right: Some("1cm".to_string()),
545 margin_bottom: Some("1cm".to_string()),
546 margin_left: Some("1cm".to_string()),
547 scale: Some(1.0),
548 path: None,
549 };
550 assert_eq!(opts.format.as_deref(), Some("A4"));
551 assert!(opts.print_background);
552 assert_eq!(opts.margin_top.as_deref(), Some("1cm"));
553 assert_eq!(opts.scale, Some(1.0));
554 }
555
556 #[test]
557 fn pre_click_state_default() {
558 let state = PreClickState::default();
559 assert!(state.disabled.is_none());
560 assert!(state.aria_pressed.is_none());
561 assert!(!state.is_connected);
562 }
563
564 #[test]
565 fn click_verification_result_creation() {
566 let result = ClickVerificationResult::confirmed(
567 "element state changed",
568 vec![Evidence::message("element-state", "className changed")],
569 );
570 assert!(result.verified);
571 assert!(!result.navigation_error);
572 assert_eq!(result.effect, ClickEffect::Confirmed);
573 assert_eq!(result.evidence.len(), 1);
574
575 let unobserved = ClickVerificationResult::not_observed("no observable change", vec![]);
578 assert!(!unobserved.verified);
579 assert_eq!(unobserved.effect, ClickEffect::NotObserved);
580 }
581}