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 #[error("{feature} is unsupported on {browser}")]
84 Unsupported { browser: String, feature: String },
85}
86
87#[derive(Debug, Clone)]
89pub struct ElementInfo {
90 pub tag_name: String,
92 pub text_content: Option<String>,
94 pub is_visible: bool,
96 pub is_enabled: bool,
98 pub bounding_box: Option<(f64, f64, f64, f64)>,
100}
101
102#[derive(Debug, Clone)]
108pub struct ClickVerificationResult {
109 pub verified: bool,
111 pub reason: String,
113 pub navigation_error: bool,
115 pub effect: ClickEffect,
117 pub evidence: Vec<Evidence>,
119}
120
121impl ClickVerificationResult {
122 pub fn new(effect: ClickEffect, reason: impl Into<String>, evidence: Vec<Evidence>) -> Self {
124 Self {
125 verified: effect == ClickEffect::Confirmed,
126 reason: reason.into(),
127 navigation_error: false,
128 effect,
129 evidence,
130 }
131 }
132
133 pub fn confirmed(reason: impl Into<String>, evidence: Vec<Evidence>) -> Self {
135 Self::new(ClickEffect::Confirmed, reason, evidence)
136 }
137
138 pub fn not_observed(reason: impl Into<String>, evidence: Vec<Evidence>) -> Self {
140 Self::new(ClickEffect::NotObserved, reason, evidence)
141 }
142
143 #[must_use]
145 pub fn with_navigation_error(mut self, navigation_error: bool) -> Self {
146 self.navigation_error = navigation_error;
147 self
148 }
149}
150
151#[derive(Debug, Clone)]
153pub struct ScrollVerificationResult {
154 pub verified: bool,
156 pub in_viewport: bool,
158 pub attempts: u32,
160}
161
162#[derive(Debug, Clone)]
164pub struct FillVerificationResult {
165 pub verified: bool,
167 pub actual_value: String,
169 pub attempts: u32,
171}
172
173#[derive(Debug, Clone, Default)]
175pub struct PdfOptions {
176 pub format: Option<String>,
178 pub print_background: bool,
180 pub margin_top: Option<String>,
182 pub margin_right: Option<String>,
183 pub margin_bottom: Option<String>,
184 pub margin_left: Option<String>,
185 pub scale: Option<f64>,
187 pub path: Option<String>,
189}
190
191#[derive(Debug, Clone, Default)]
193pub struct PreClickState {
194 pub disabled: Option<bool>,
196 pub aria_pressed: Option<String>,
198 pub aria_expanded: Option<String>,
200 pub aria_selected: Option<String>,
202 pub checked: Option<bool>,
204 pub class_name: Option<String>,
206 pub is_connected: bool,
208}
209
210#[derive(Debug, Clone, PartialEq)]
216pub enum TraceEngineEvent {
217 Navigated {
219 main_frame: bool,
221 url: String,
223 },
224 Console {
226 level: String,
228 text: String,
230 },
231 PageError {
233 message: String,
235 stack: Option<String>,
237 },
238 RequestFailed {
240 url: String,
242 method: String,
244 error_text: Option<String>,
246 },
247 Dialog {
249 dialog_type: String,
251 message: String,
253 },
254 Download {
256 phase: String,
258 id: Option<String>,
260 suggested_filename: Option<String>,
262 path: Option<String>,
264 checksum: Option<String>,
266 bytes: Option<u64>,
268 url: Option<String>,
270 failure: Option<String>,
272 },
273}
274
275#[async_trait]
280pub trait EngineAdapter: Send + Sync {
281 fn require_feature(&self, _feature: &str) -> Result<(), EngineError> {
283 Ok(())
284 }
285 fn engine_type(&self) -> EngineType;
287
288 async fn url(&self) -> Result<String, EngineError>;
290
291 async fn goto(&self, url: &str) -> Result<(), EngineError>;
293
294 async fn query_selector(&self, selector: &str) -> Result<Option<ElementInfo>, EngineError>;
296
297 async fn query_selector_all(&self, selector: &str) -> Result<Vec<ElementInfo>, EngineError>;
299
300 async fn count(&self, selector: &str) -> Result<usize, EngineError>;
302
303 async fn click(&self, selector: &str) -> Result<(), EngineError>;
305
306 async fn mouse_click(&self, x: f64, y: f64) -> Result<(), EngineError> {
322 let _ = (x, y);
323 Err(EngineError::Browser(
324 "this engine does not expose viewport-coordinate pointer input".to_string(),
325 ))
326 }
327
328 async fn fill(&self, selector: &str, text: &str) -> Result<(), EngineError>;
330
331 async fn type_text(&self, selector: &str, text: &str) -> Result<(), EngineError>;
333
334 async fn text_content(&self, selector: &str) -> Result<Option<String>, EngineError>;
336
337 async fn input_value(&self, selector: &str) -> Result<Option<String>, EngineError>;
339
340 async fn get_attribute(
342 &self,
343 selector: &str,
344 attribute: &str,
345 ) -> Result<Option<String>, EngineError>;
346
347 async fn is_visible(&self, selector: &str) -> Result<bool, EngineError>;
349
350 async fn is_enabled(&self, selector: &str) -> Result<bool, EngineError>;
352
353 async fn wait_for_selector(&self, selector: &str, timeout_ms: u64) -> Result<(), EngineError>;
355
356 async fn scroll_into_view(&self, selector: &str) -> Result<(), EngineError>;
358
359 async fn evaluate(&self, script: &str) -> Result<serde_json::Value, EngineError>;
361
362 async fn read_browser_version_page(&self) -> Result<serde_json::Value, EngineError> {
364 Err(EngineError::Browser(format!(
365 "browser version metadata is unavailable for the {} engine",
366 self.engine_type()
367 )))
368 }
369
370 async fn restore_storage_state(&self, _state: serde_json::Value) -> Result<(), 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 export_storage_state(&self) -> Result<serde_json::Value, EngineError> {
380 Err(EngineError::Browser(format!(
381 "portable storage state is unavailable for the {} engine",
382 self.engine_type()
383 )))
384 }
385
386 async fn screenshot(&self) -> Result<Vec<u8>, EngineError>;
388
389 async fn pdf(&self, options: PdfOptions) -> Result<Vec<u8>, EngineError>;
394
395 async fn bring_to_front(&self) -> Result<(), EngineError>;
397
398 async fn wait_for_navigation(&self, timeout_ms: u64) -> Result<(), EngineError>;
400
401 async fn keyboard_press(&self, key: &str) -> Result<(), EngineError>;
409
410 async fn keyboard_type(&self, text: &str) -> Result<(), EngineError>;
412
413 async fn keyboard_down(&self, key: &str) -> Result<(), EngineError>;
415
416 async fn keyboard_up(&self, key: &str) -> Result<(), EngineError>;
418
419 async fn add_init_script(&self, _script: &str) -> Result<Option<String>, EngineError> {
427 Ok(None)
428 }
429
430 async fn remove_init_script(&self, _identifier: &str) -> Result<(), EngineError> {
432 Ok(())
433 }
434
435 async fn trace_events(&self) -> Option<BoxStream<'static, TraceEngineEvent>> {
438 None
439 }
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445
446 #[test]
447 fn engine_type_display() {
448 assert_eq!(EngineType::Chromiumoxide.to_string(), "chromiumoxide");
449 assert_eq!(EngineType::Fantoccini.to_string(), "fantoccini");
450 assert_eq!(EngineType::Playwright.to_string(), "playwright");
451 assert_eq!(EngineType::Puppeteer.to_string(), "puppeteer");
452 }
453
454 #[test]
455 fn engine_type_from_str() {
456 assert_eq!(
457 "chromiumoxide".parse::<EngineType>().unwrap(),
458 EngineType::Chromiumoxide
459 );
460 assert_eq!(
461 "cdp".parse::<EngineType>().unwrap(),
462 EngineType::Chromiumoxide
463 );
464 assert_eq!(
465 "puppeteer".parse::<EngineType>().unwrap(),
466 EngineType::Puppeteer
467 );
468 assert_eq!(
469 "puppeteer".parse::<EngineType>().unwrap().to_string(),
470 "puppeteer"
471 );
472 assert_eq!(
473 "fantoccini".parse::<EngineType>().unwrap(),
474 EngineType::Fantoccini
475 );
476 assert_eq!(
477 "webdriver".parse::<EngineType>().unwrap(),
478 EngineType::Fantoccini
479 );
480 assert_eq!(
481 "playwright".parse::<EngineType>().unwrap(),
482 EngineType::Playwright
483 );
484 assert_eq!(
485 "playwright".parse::<EngineType>().unwrap().to_string(),
486 "playwright"
487 );
488 }
489
490 #[test]
491 fn engine_type_from_str_case_insensitive() {
492 assert_eq!(
493 "CHROMIUMOXIDE".parse::<EngineType>().unwrap(),
494 EngineType::Chromiumoxide
495 );
496 assert_eq!(
497 "Fantoccini".parse::<EngineType>().unwrap(),
498 EngineType::Fantoccini
499 );
500 assert_eq!(
501 "Playwright".parse::<EngineType>().unwrap(),
502 EngineType::Playwright
503 );
504 assert_eq!(
505 "Puppeteer".parse::<EngineType>().unwrap(),
506 EngineType::Puppeteer
507 );
508 }
509
510 #[test]
511 fn engine_type_from_str_invalid() {
512 let result = "invalid".parse::<EngineType>();
513 assert!(result.is_err());
514 if let Err(EngineError::InvalidEngine(name)) = result {
515 assert_eq!(name, "invalid");
516 } else {
517 panic!("Expected InvalidEngine error");
518 }
519 }
520
521 #[test]
523 fn selenium_alias_selects_native_webdriver() {
524 for name in ["selenium", "SELENIUM", "webdriver", "fantoccini"] {
525 assert_eq!(name.parse::<EngineType>().unwrap(), EngineType::Fantoccini);
526 }
527 assert_eq!(
528 serde_json::from_str::<EngineType>("\"selenium\"").unwrap(),
529 EngineType::Fantoccini
530 );
531 let options =
532 crate::browser::launcher::LaunchOptions::default().engine("selenium".parse().unwrap());
533 assert_eq!(options.launch, crate::browser::launcher::LaunchMode::Engine);
534 }
535
536 #[test]
537 fn pdf_options_default() {
538 let opts = PdfOptions::default();
539 assert!(opts.format.is_none());
540 assert!(!opts.print_background);
541 assert!(opts.margin_top.is_none());
542 assert!(opts.path.is_none());
543 assert!(opts.scale.is_none());
544 }
545
546 #[test]
547 fn pdf_options_can_be_constructed() {
548 let opts = PdfOptions {
549 format: Some("A4".to_string()),
550 print_background: true,
551 margin_top: Some("1cm".to_string()),
552 margin_right: Some("1cm".to_string()),
553 margin_bottom: Some("1cm".to_string()),
554 margin_left: Some("1cm".to_string()),
555 scale: Some(1.0),
556 path: None,
557 };
558 assert_eq!(opts.format.as_deref(), Some("A4"));
559 assert!(opts.print_background);
560 assert_eq!(opts.margin_top.as_deref(), Some("1cm"));
561 assert_eq!(opts.scale, Some(1.0));
562 }
563
564 #[test]
565 fn pre_click_state_default() {
566 let state = PreClickState::default();
567 assert!(state.disabled.is_none());
568 assert!(state.aria_pressed.is_none());
569 assert!(!state.is_connected);
570 }
571
572 #[test]
573 fn click_verification_result_creation() {
574 let result = ClickVerificationResult::confirmed(
575 "element state changed",
576 vec![Evidence::message("element-state", "className changed")],
577 );
578 assert!(result.verified);
579 assert!(!result.navigation_error);
580 assert_eq!(result.effect, ClickEffect::Confirmed);
581 assert_eq!(result.evidence.len(), 1);
582
583 let unobserved = ClickVerificationResult::not_observed("no observable change", vec![]);
586 assert!(!unobserved.verified);
587 assert_eq!(unobserved.effect, ClickEffect::NotObserved);
588 }
589}