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;
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10/// The type of browser automation engine being used.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
12#[serde(rename_all = "lowercase")]
13pub enum EngineType {
14    /// Chrome DevTools Protocol based engine (similar to Puppeteer)
15    Chromiumoxide,
16    /// WebDriver-based engine (similar to Playwright's approach)
17    Fantoccini,
18    /// Playwright driven through the Node.js package as a CLI bridge.
19    Playwright,
20    /// Puppeteer driven through the Node.js package as a CLI bridge.
21    Puppeteer,
22}
23
24impl std::fmt::Display for EngineType {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        match self {
27            EngineType::Chromiumoxide => write!(f, "chromiumoxide"),
28            EngineType::Fantoccini => write!(f, "fantoccini"),
29            EngineType::Playwright => write!(f, "playwright"),
30            EngineType::Puppeteer => write!(f, "puppeteer"),
31        }
32    }
33}
34
35impl std::str::FromStr for EngineType {
36    type Err = EngineError;
37
38    fn from_str(s: &str) -> Result<Self, Self::Err> {
39        match s.to_lowercase().as_str() {
40            "chromiumoxide" | "cdp" => Ok(EngineType::Chromiumoxide),
41            "fantoccini" | "webdriver" => Ok(EngineType::Fantoccini),
42            "playwright" => Ok(EngineType::Playwright),
43            "puppeteer" => Ok(EngineType::Puppeteer),
44            _ => Err(EngineError::InvalidEngine(s.to_string())),
45        }
46    }
47}
48
49/// Errors related to browser engine operations.
50#[derive(Debug, Error)]
51pub enum EngineError {
52    /// Invalid engine type specified.
53    #[error(
54        "Invalid engine: {0}. Expected 'chromiumoxide', 'fantoccini', 'playwright', or 'puppeteer'"
55    )]
56    InvalidEngine(String),
57
58    /// Element not found.
59    #[error("Element not found: {0}")]
60    ElementNotFound(String),
61
62    /// Operation timed out.
63    #[error("Operation timed out: {0}")]
64    Timeout(String),
65
66    /// Navigation error.
67    #[error("Navigation error: {0}")]
68    Navigation(String),
69
70    /// JavaScript evaluation error.
71    #[error("JavaScript evaluation error: {0}")]
72    Evaluation(String),
73
74    /// Generic browser error.
75    #[error("Browser error: {0}")]
76    Browser(String),
77}
78
79/// Result of an element query.
80#[derive(Debug, Clone)]
81pub struct ElementInfo {
82    /// The element's tag name.
83    pub tag_name: String,
84    /// The element's text content.
85    pub text_content: Option<String>,
86    /// Whether the element is visible.
87    pub is_visible: bool,
88    /// Whether the element is enabled (for form elements).
89    pub is_enabled: bool,
90    /// The element's bounding box (x, y, width, height).
91    pub bounding_box: Option<(f64, f64, f64, f64)>,
92}
93
94/// Result of a click verification.
95#[derive(Debug, Clone)]
96pub struct ClickVerificationResult {
97    /// Whether the click was verified as successful.
98    pub verified: bool,
99    /// The reason for the verification result.
100    pub reason: String,
101    /// Whether a navigation error occurred during verification.
102    pub navigation_error: bool,
103}
104
105/// Result of a scroll verification.
106#[derive(Debug, Clone)]
107pub struct ScrollVerificationResult {
108    /// Whether the scroll was verified as successful.
109    pub verified: bool,
110    /// Whether the element is in the viewport.
111    pub in_viewport: bool,
112    /// Number of verification attempts.
113    pub attempts: u32,
114}
115
116/// Result of a fill verification.
117#[derive(Debug, Clone)]
118pub struct FillVerificationResult {
119    /// Whether the fill was verified as successful.
120    pub verified: bool,
121    /// The actual value in the element after filling.
122    pub actual_value: String,
123    /// Number of verification attempts.
124    pub attempts: u32,
125}
126
127/// Options for PDF generation.
128#[derive(Debug, Clone, Default)]
129pub struct PdfOptions {
130    /// Paper format (e.g. "A4", "Letter").
131    pub format: Option<String>,
132    /// Print background graphics.
133    pub print_background: bool,
134    /// Page margins as CSS strings (e.g. "1cm").
135    pub margin_top: Option<String>,
136    pub margin_right: Option<String>,
137    pub margin_bottom: Option<String>,
138    pub margin_left: Option<String>,
139    /// Scale of the webpage rendering (0.1–2.0).
140    pub scale: Option<f64>,
141    /// Optional file path to save the PDF.
142    pub path: Option<String>,
143}
144
145/// Pre-click state captured for verification.
146#[derive(Debug, Clone, Default)]
147pub struct PreClickState {
148    /// Whether the element was disabled.
149    pub disabled: Option<bool>,
150    /// The aria-pressed attribute value.
151    pub aria_pressed: Option<String>,
152    /// The aria-expanded attribute value.
153    pub aria_expanded: Option<String>,
154    /// The aria-selected attribute value.
155    pub aria_selected: Option<String>,
156    /// Whether the element was checked (for checkboxes).
157    pub checked: Option<bool>,
158    /// The element's class name.
159    pub class_name: Option<String>,
160    /// Whether the element is connected to the DOM.
161    pub is_connected: bool,
162}
163
164/// Trait for browser engine adapters.
165///
166/// This trait provides a unified interface for different browser automation
167/// engines, allowing the library to work with multiple backends.
168#[async_trait]
169pub trait EngineAdapter: Send + Sync {
170    /// Get the engine type.
171    fn engine_type(&self) -> EngineType;
172
173    /// Get the current page URL.
174    async fn url(&self) -> Result<String, EngineError>;
175
176    /// Navigate to a URL.
177    async fn goto(&self, url: &str) -> Result<(), EngineError>;
178
179    /// Query for a single element.
180    async fn query_selector(&self, selector: &str) -> Result<Option<ElementInfo>, EngineError>;
181
182    /// Query for all matching elements.
183    async fn query_selector_all(&self, selector: &str) -> Result<Vec<ElementInfo>, EngineError>;
184
185    /// Count matching elements.
186    async fn count(&self, selector: &str) -> Result<usize, EngineError>;
187
188    /// Click an element.
189    async fn click(&self, selector: &str) -> Result<(), EngineError>;
190
191    /// Fill an input element with text.
192    async fn fill(&self, selector: &str, text: &str) -> Result<(), EngineError>;
193
194    /// Type text into an element (simulating key presses).
195    async fn type_text(&self, selector: &str, text: &str) -> Result<(), EngineError>;
196
197    /// Get the text content of an element.
198    async fn text_content(&self, selector: &str) -> Result<Option<String>, EngineError>;
199
200    /// Get the value of an input element.
201    async fn input_value(&self, selector: &str) -> Result<Option<String>, EngineError>;
202
203    /// Get an attribute value from an element.
204    async fn get_attribute(
205        &self,
206        selector: &str,
207        attribute: &str,
208    ) -> Result<Option<String>, EngineError>;
209
210    /// Check if an element is visible.
211    async fn is_visible(&self, selector: &str) -> Result<bool, EngineError>;
212
213    /// Check if an element is enabled.
214    async fn is_enabled(&self, selector: &str) -> Result<bool, EngineError>;
215
216    /// Wait for a selector to appear.
217    async fn wait_for_selector(&self, selector: &str, timeout_ms: u64) -> Result<(), EngineError>;
218
219    /// Scroll an element into view.
220    async fn scroll_into_view(&self, selector: &str) -> Result<(), EngineError>;
221
222    /// Evaluate JavaScript in the page context.
223    async fn evaluate(&self, script: &str) -> Result<serde_json::Value, EngineError>;
224
225    /// Take a screenshot.
226    async fn screenshot(&self) -> Result<Vec<u8>, EngineError>;
227
228    /// Generate a PDF of the current page.
229    ///
230    /// Only supported by Chromium-based engines (chromiumoxide).
231    /// Returns an error for engines that do not support PDF generation.
232    async fn pdf(&self, options: PdfOptions) -> Result<Vec<u8>, EngineError>;
233
234    /// Bring the page to front.
235    async fn bring_to_front(&self) -> Result<(), EngineError>;
236
237    /// Wait for navigation to complete.
238    async fn wait_for_navigation(&self, timeout_ms: u64) -> Result<(), EngineError>;
239
240    // =========================================================================
241    // Page-level Keyboard Operations
242    // =========================================================================
243
244    /// Press a key at the page level (e.g. "Escape", "Enter", "Tab").
245    ///
246    /// Key names follow the Playwright/Puppeteer convention.
247    async fn keyboard_press(&self, key: &str) -> Result<(), EngineError>;
248
249    /// Type text at the page level (dispatches key events for each character).
250    async fn keyboard_type(&self, text: &str) -> Result<(), EngineError>;
251
252    /// Hold a key down at the page level. Must be paired with `keyboard_up`.
253    async fn keyboard_down(&self, key: &str) -> Result<(), EngineError>;
254
255    /// Release a held key at the page level.
256    async fn keyboard_up(&self, key: &str) -> Result<(), EngineError>;
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn engine_type_display() {
265        assert_eq!(EngineType::Chromiumoxide.to_string(), "chromiumoxide");
266        assert_eq!(EngineType::Fantoccini.to_string(), "fantoccini");
267        assert_eq!(EngineType::Playwright.to_string(), "playwright");
268        assert_eq!(EngineType::Puppeteer.to_string(), "puppeteer");
269    }
270
271    #[test]
272    fn engine_type_from_str() {
273        assert_eq!(
274            "chromiumoxide".parse::<EngineType>().unwrap(),
275            EngineType::Chromiumoxide
276        );
277        assert_eq!(
278            "cdp".parse::<EngineType>().unwrap(),
279            EngineType::Chromiumoxide
280        );
281        assert_eq!(
282            "puppeteer".parse::<EngineType>().unwrap(),
283            EngineType::Puppeteer
284        );
285        assert_eq!(
286            "puppeteer".parse::<EngineType>().unwrap().to_string(),
287            "puppeteer"
288        );
289        assert_eq!(
290            "fantoccini".parse::<EngineType>().unwrap(),
291            EngineType::Fantoccini
292        );
293        assert_eq!(
294            "webdriver".parse::<EngineType>().unwrap(),
295            EngineType::Fantoccini
296        );
297        assert_eq!(
298            "playwright".parse::<EngineType>().unwrap(),
299            EngineType::Playwright
300        );
301        assert_eq!(
302            "playwright".parse::<EngineType>().unwrap().to_string(),
303            "playwright"
304        );
305    }
306
307    #[test]
308    fn engine_type_from_str_case_insensitive() {
309        assert_eq!(
310            "CHROMIUMOXIDE".parse::<EngineType>().unwrap(),
311            EngineType::Chromiumoxide
312        );
313        assert_eq!(
314            "Fantoccini".parse::<EngineType>().unwrap(),
315            EngineType::Fantoccini
316        );
317        assert_eq!(
318            "Playwright".parse::<EngineType>().unwrap(),
319            EngineType::Playwright
320        );
321        assert_eq!(
322            "Puppeteer".parse::<EngineType>().unwrap(),
323            EngineType::Puppeteer
324        );
325    }
326
327    #[test]
328    fn engine_type_from_str_invalid() {
329        let result = "invalid".parse::<EngineType>();
330        assert!(result.is_err());
331        if let Err(EngineError::InvalidEngine(name)) = result {
332            assert_eq!(name, "invalid");
333        } else {
334            panic!("Expected InvalidEngine error");
335        }
336    }
337
338    #[test]
339    fn pdf_options_default() {
340        let opts = PdfOptions::default();
341        assert!(opts.format.is_none());
342        assert!(!opts.print_background);
343        assert!(opts.margin_top.is_none());
344        assert!(opts.path.is_none());
345        assert!(opts.scale.is_none());
346    }
347
348    #[test]
349    fn pdf_options_can_be_constructed() {
350        let opts = PdfOptions {
351            format: Some("A4".to_string()),
352            print_background: true,
353            margin_top: Some("1cm".to_string()),
354            margin_right: Some("1cm".to_string()),
355            margin_bottom: Some("1cm".to_string()),
356            margin_left: Some("1cm".to_string()),
357            scale: Some(1.0),
358            path: None,
359        };
360        assert_eq!(opts.format.as_deref(), Some("A4"));
361        assert!(opts.print_background);
362        assert_eq!(opts.margin_top.as_deref(), Some("1cm"));
363        assert_eq!(opts.scale, Some(1.0));
364    }
365
366    #[test]
367    fn pre_click_state_default() {
368        let state = PreClickState::default();
369        assert!(state.disabled.is_none());
370        assert!(state.aria_pressed.is_none());
371        assert!(!state.is_connected);
372    }
373
374    #[test]
375    fn click_verification_result_creation() {
376        let result = ClickVerificationResult {
377            verified: true,
378            reason: "element state changed".to_string(),
379            navigation_error: false,
380        };
381        assert!(result.verified);
382        assert!(!result.navigation_error);
383    }
384}