Skip to main content

browser_commander/core/
engine.rs

1//! Browser engine detection and abstraction.
2//!
3//! This module provides traits and types for abstracting over different
4//! browser automation engines.
5
6use async_trait::async_trait;
7
8use crate::interactions::click_result::{ClickEffect, Evidence};
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11
12/// The type of browser automation engine being used.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum EngineType {
16    /// Chrome DevTools Protocol based engine (similar to Puppeteer)
17    Chromiumoxide,
18    /// WebDriver-based engine (similar to Playwright's approach)
19    Fantoccini,
20    /// Playwright driven through the Node.js package as a CLI bridge.
21    Playwright,
22    /// Puppeteer driven through the Node.js package as a CLI bridge.
23    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/// Errors related to browser engine operations.
52#[derive(Debug, Error)]
53pub enum EngineError {
54    /// Invalid engine type specified.
55    #[error(
56        "Invalid engine: {0}. Expected 'chromiumoxide', 'fantoccini', 'playwright', or 'puppeteer'"
57    )]
58    InvalidEngine(String),
59
60    /// Element not found.
61    #[error("Element not found: {0}")]
62    ElementNotFound(String),
63
64    /// Operation timed out.
65    #[error("Operation timed out: {0}")]
66    Timeout(String),
67
68    /// Navigation error.
69    #[error("Navigation error: {0}")]
70    Navigation(String),
71
72    /// JavaScript evaluation error.
73    #[error("JavaScript evaluation error: {0}")]
74    Evaluation(String),
75
76    /// Generic browser error.
77    #[error("Browser error: {0}")]
78    Browser(String),
79}
80
81/// Result of an element query.
82#[derive(Debug, Clone)]
83pub struct ElementInfo {
84    /// The element's tag name.
85    pub tag_name: String,
86    /// The element's text content.
87    pub text_content: Option<String>,
88    /// Whether the element is visible.
89    pub is_visible: bool,
90    /// Whether the element is enabled (for form elements).
91    pub is_enabled: bool,
92    /// The element's bounding box (x, y, width, height).
93    pub bounding_box: Option<(f64, f64, f64, f64)>,
94}
95
96/// Result of a click verification.
97///
98/// `verified` is a legacy alias for "the effect was confirmed". The verdict
99/// itself lives in [`ClickVerificationResult::effect`], which can also say
100/// "nothing was observed" - a distinct answer from "the click failed".
101#[derive(Debug, Clone)]
102pub struct ClickVerificationResult {
103    /// Whether the click was verified as successful.
104    pub verified: bool,
105    /// The reason for the verification result.
106    pub reason: String,
107    /// Whether a navigation error occurred during verification.
108    pub navigation_error: bool,
109    /// What the page was observed to do.
110    pub effect: ClickEffect,
111    /// The observations behind the verdict.
112    pub evidence: Vec<Evidence>,
113}
114
115impl ClickVerificationResult {
116    /// Build a verdict from an observed effect.
117    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    /// Build a verdict confirming that the click had an effect.
128    pub fn confirmed(reason: impl Into<String>, evidence: Vec<Evidence>) -> Self {
129        Self::new(ClickEffect::Confirmed, reason, evidence)
130    }
131
132    /// Build a verdict that observed nothing either way.
133    pub fn not_observed(reason: impl Into<String>, evidence: Vec<Evidence>) -> Self {
134        Self::new(ClickEffect::NotObserved, reason, evidence)
135    }
136
137    /// Mark this verdict as one reached while the page was navigating away.
138    #[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/// Result of a scroll verification.
146#[derive(Debug, Clone)]
147pub struct ScrollVerificationResult {
148    /// Whether the scroll was verified as successful.
149    pub verified: bool,
150    /// Whether the element is in the viewport.
151    pub in_viewport: bool,
152    /// Number of verification attempts.
153    pub attempts: u32,
154}
155
156/// Result of a fill verification.
157#[derive(Debug, Clone)]
158pub struct FillVerificationResult {
159    /// Whether the fill was verified as successful.
160    pub verified: bool,
161    /// The actual value in the element after filling.
162    pub actual_value: String,
163    /// Number of verification attempts.
164    pub attempts: u32,
165}
166
167/// Options for PDF generation.
168#[derive(Debug, Clone, Default)]
169pub struct PdfOptions {
170    /// Paper format (e.g. "A4", "Letter").
171    pub format: Option<String>,
172    /// Print background graphics.
173    pub print_background: bool,
174    /// Page margins as CSS strings (e.g. "1cm").
175    pub margin_top: Option<String>,
176    pub margin_right: Option<String>,
177    pub margin_bottom: Option<String>,
178    pub margin_left: Option<String>,
179    /// Scale of the webpage rendering (0.1–2.0).
180    pub scale: Option<f64>,
181    /// Optional file path to save the PDF.
182    pub path: Option<String>,
183}
184
185/// Pre-click state captured for verification.
186#[derive(Debug, Clone, Default)]
187pub struct PreClickState {
188    /// Whether the element was disabled.
189    pub disabled: Option<bool>,
190    /// The aria-pressed attribute value.
191    pub aria_pressed: Option<String>,
192    /// The aria-expanded attribute value.
193    pub aria_expanded: Option<String>,
194    /// The aria-selected attribute value.
195    pub aria_selected: Option<String>,
196    /// Whether the element was checked (for checkboxes).
197    pub checked: Option<bool>,
198    /// The element's class name.
199    pub class_name: Option<String>,
200    /// Whether the element is connected to the DOM.
201    pub is_connected: bool,
202}
203
204/// Trait for browser engine adapters.
205///
206/// This trait provides a unified interface for different browser automation
207/// engines, allowing the library to work with multiple backends.
208#[async_trait]
209pub trait EngineAdapter: Send + Sync {
210    /// Get the engine type.
211    fn engine_type(&self) -> EngineType;
212
213    /// Get the current page URL.
214    async fn url(&self) -> Result<String, EngineError>;
215
216    /// Navigate to a URL.
217    async fn goto(&self, url: &str) -> Result<(), EngineError>;
218
219    /// Query for a single element.
220    async fn query_selector(&self, selector: &str) -> Result<Option<ElementInfo>, EngineError>;
221
222    /// Query for all matching elements.
223    async fn query_selector_all(&self, selector: &str) -> Result<Vec<ElementInfo>, EngineError>;
224
225    /// Count matching elements.
226    async fn count(&self, selector: &str) -> Result<usize, EngineError>;
227
228    /// Click an element.
229    async fn click(&self, selector: &str) -> Result<(), EngineError>;
230
231    /// Click at viewport coordinates, without scrolling anything into view.
232    ///
233    /// This is what `ClickScroll::None` needs: the engine's element click
234    /// scrolls the target into view first, so it cannot honor a "do not scroll"
235    /// request. The default implementation reports that the engine has no such
236    /// API, so the constraint is refused rather than silently ignored.
237    ///
238    /// # Arguments
239    ///
240    /// * `x` - Viewport x coordinate
241    /// * `y` - Viewport y coordinate
242    ///
243    /// # Returns
244    ///
245    /// Nothing on success, or [`EngineError::Browser`] when unsupported.
246    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    /// Fill an input element with text.
254    async fn fill(&self, selector: &str, text: &str) -> Result<(), EngineError>;
255
256    /// Type text into an element (simulating key presses).
257    async fn type_text(&self, selector: &str, text: &str) -> Result<(), EngineError>;
258
259    /// Get the text content of an element.
260    async fn text_content(&self, selector: &str) -> Result<Option<String>, EngineError>;
261
262    /// Get the value of an input element.
263    async fn input_value(&self, selector: &str) -> Result<Option<String>, EngineError>;
264
265    /// Get an attribute value from an element.
266    async fn get_attribute(
267        &self,
268        selector: &str,
269        attribute: &str,
270    ) -> Result<Option<String>, EngineError>;
271
272    /// Check if an element is visible.
273    async fn is_visible(&self, selector: &str) -> Result<bool, EngineError>;
274
275    /// Check if an element is enabled.
276    async fn is_enabled(&self, selector: &str) -> Result<bool, EngineError>;
277
278    /// Wait for a selector to appear.
279    async fn wait_for_selector(&self, selector: &str, timeout_ms: u64) -> Result<(), EngineError>;
280
281    /// Scroll an element into view.
282    async fn scroll_into_view(&self, selector: &str) -> Result<(), EngineError>;
283
284    /// Evaluate JavaScript in the page context.
285    async fn evaluate(&self, script: &str) -> Result<serde_json::Value, EngineError>;
286
287    /// Take a screenshot.
288    async fn screenshot(&self) -> Result<Vec<u8>, EngineError>;
289
290    /// Generate a PDF of the current page.
291    ///
292    /// Only supported by Chromium-based engines (chromiumoxide).
293    /// Returns an error for engines that do not support PDF generation.
294    async fn pdf(&self, options: PdfOptions) -> Result<Vec<u8>, EngineError>;
295
296    /// Bring the page to front.
297    async fn bring_to_front(&self) -> Result<(), EngineError>;
298
299    /// Wait for navigation to complete.
300    async fn wait_for_navigation(&self, timeout_ms: u64) -> Result<(), EngineError>;
301
302    // =========================================================================
303    // Page-level Keyboard Operations
304    // =========================================================================
305
306    /// Press a key at the page level (e.g. "Escape", "Enter", "Tab").
307    ///
308    /// Key names follow the Playwright/Puppeteer convention.
309    async fn keyboard_press(&self, key: &str) -> Result<(), EngineError>;
310
311    /// Type text at the page level (dispatches key events for each character).
312    async fn keyboard_type(&self, text: &str) -> Result<(), EngineError>;
313
314    /// Hold a key down at the page level. Must be paired with `keyboard_up`.
315    async fn keyboard_down(&self, key: &str) -> Result<(), EngineError>;
316
317    /// Release a held key at the page level.
318    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        // Regression test for issue #89: "nothing was observed" must never
448        // present itself as a verified click.
449        let unobserved = ClickVerificationResult::not_observed("no observable change", vec![]);
450        assert!(!unobserved.verified);
451        assert_eq!(unobserved.effect, ClickEffect::NotObserved);
452    }
453}