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