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 futures::stream::BoxStream;
8
9use crate::interactions::click_result::{ClickEffect, Evidence};
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13/// The type of browser automation engine being used.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum EngineType {
17    /// Chrome DevTools Protocol based engine (similar to Puppeteer)
18    Chromiumoxide,
19    /// WebDriver-based engine (similar to Playwright's approach)
20    Fantoccini,
21    /// Playwright driven through the Node.js package as a CLI bridge.
22    Playwright,
23    /// Puppeteer driven through the Node.js package as a CLI bridge.
24    Puppeteer,
25}
26
27impl std::fmt::Display for EngineType {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        match self {
30            EngineType::Chromiumoxide => write!(f, "chromiumoxide"),
31            EngineType::Fantoccini => write!(f, "fantoccini"),
32            EngineType::Playwright => write!(f, "playwright"),
33            EngineType::Puppeteer => write!(f, "puppeteer"),
34        }
35    }
36}
37
38impl std::str::FromStr for EngineType {
39    type Err = EngineError;
40
41    fn from_str(s: &str) -> Result<Self, Self::Err> {
42        match s.to_lowercase().as_str() {
43            "chromiumoxide" | "cdp" => Ok(EngineType::Chromiumoxide),
44            "fantoccini" | "webdriver" => Ok(EngineType::Fantoccini),
45            "playwright" => Ok(EngineType::Playwright),
46            "puppeteer" => Ok(EngineType::Puppeteer),
47            _ => Err(EngineError::InvalidEngine(s.to_string())),
48        }
49    }
50}
51
52/// Errors related to browser engine operations.
53#[derive(Debug, Error)]
54pub enum EngineError {
55    /// Invalid engine type specified.
56    #[error(
57        "Invalid engine: {0}. Expected 'chromiumoxide', 'fantoccini', 'playwright', or 'puppeteer'"
58    )]
59    InvalidEngine(String),
60
61    /// Element not found.
62    #[error("Element not found: {0}")]
63    ElementNotFound(String),
64
65    /// Operation timed out.
66    #[error("Operation timed out: {0}")]
67    Timeout(String),
68
69    /// Navigation error.
70    #[error("Navigation error: {0}")]
71    Navigation(String),
72
73    /// JavaScript evaluation error.
74    #[error("JavaScript evaluation error: {0}")]
75    Evaluation(String),
76
77    /// Generic browser error.
78    #[error("Browser error: {0}")]
79    Browser(String),
80}
81
82/// Result of an element query.
83#[derive(Debug, Clone)]
84pub struct ElementInfo {
85    /// The element's tag name.
86    pub tag_name: String,
87    /// The element's text content.
88    pub text_content: Option<String>,
89    /// Whether the element is visible.
90    pub is_visible: bool,
91    /// Whether the element is enabled (for form elements).
92    pub is_enabled: bool,
93    /// The element's bounding box (x, y, width, height).
94    pub bounding_box: Option<(f64, f64, f64, f64)>,
95}
96
97/// Result of a click verification.
98///
99/// `verified` is a legacy alias for "the effect was confirmed". The verdict
100/// itself lives in [`ClickVerificationResult::effect`], which can also say
101/// "nothing was observed" - a distinct answer from "the click failed".
102#[derive(Debug, Clone)]
103pub struct ClickVerificationResult {
104    /// Whether the click was verified as successful.
105    pub verified: bool,
106    /// The reason for the verification result.
107    pub reason: String,
108    /// Whether a navigation error occurred during verification.
109    pub navigation_error: bool,
110    /// What the page was observed to do.
111    pub effect: ClickEffect,
112    /// The observations behind the verdict.
113    pub evidence: Vec<Evidence>,
114}
115
116impl ClickVerificationResult {
117    /// Build a verdict from an observed effect.
118    pub fn new(effect: ClickEffect, reason: impl Into<String>, evidence: Vec<Evidence>) -> Self {
119        Self {
120            verified: effect == ClickEffect::Confirmed,
121            reason: reason.into(),
122            navigation_error: false,
123            effect,
124            evidence,
125        }
126    }
127
128    /// Build a verdict confirming that the click had an effect.
129    pub fn confirmed(reason: impl Into<String>, evidence: Vec<Evidence>) -> Self {
130        Self::new(ClickEffect::Confirmed, reason, evidence)
131    }
132
133    /// Build a verdict that observed nothing either way.
134    pub fn not_observed(reason: impl Into<String>, evidence: Vec<Evidence>) -> Self {
135        Self::new(ClickEffect::NotObserved, reason, evidence)
136    }
137
138    /// Mark this verdict as one reached while the page was navigating away.
139    #[must_use]
140    pub fn with_navigation_error(mut self, navigation_error: bool) -> Self {
141        self.navigation_error = navigation_error;
142        self
143    }
144}
145
146/// Result of a scroll verification.
147#[derive(Debug, Clone)]
148pub struct ScrollVerificationResult {
149    /// Whether the scroll was verified as successful.
150    pub verified: bool,
151    /// Whether the element is in the viewport.
152    pub in_viewport: bool,
153    /// Number of verification attempts.
154    pub attempts: u32,
155}
156
157/// Result of a fill verification.
158#[derive(Debug, Clone)]
159pub struct FillVerificationResult {
160    /// Whether the fill was verified as successful.
161    pub verified: bool,
162    /// The actual value in the element after filling.
163    pub actual_value: String,
164    /// Number of verification attempts.
165    pub attempts: u32,
166}
167
168/// Options for PDF generation.
169#[derive(Debug, Clone, Default)]
170pub struct PdfOptions {
171    /// Paper format (e.g. "A4", "Letter").
172    pub format: Option<String>,
173    /// Print background graphics.
174    pub print_background: bool,
175    /// Page margins as CSS strings (e.g. "1cm").
176    pub margin_top: Option<String>,
177    pub margin_right: Option<String>,
178    pub margin_bottom: Option<String>,
179    pub margin_left: Option<String>,
180    /// Scale of the webpage rendering (0.1–2.0).
181    pub scale: Option<f64>,
182    /// Optional file path to save the PDF.
183    pub path: Option<String>,
184}
185
186/// Pre-click state captured for verification.
187#[derive(Debug, Clone, Default)]
188pub struct PreClickState {
189    /// Whether the element was disabled.
190    pub disabled: Option<bool>,
191    /// The aria-pressed attribute value.
192    pub aria_pressed: Option<String>,
193    /// The aria-expanded attribute value.
194    pub aria_expanded: Option<String>,
195    /// The aria-selected attribute value.
196    pub aria_selected: Option<String>,
197    /// Whether the element was checked (for checkboxes).
198    pub checked: Option<bool>,
199    /// The element's class name.
200    pub class_name: Option<String>,
201    /// Whether the element is connected to the DOM.
202    pub is_connected: bool,
203}
204
205/// Something the browser did that a trace records (issue #108).
206///
207/// Engines that can report page activity hand these to a recorder through
208/// [`EngineAdapter::trace_events`]; each maps to one `js/src/traces/observers.js`
209/// record.
210#[derive(Debug, Clone, PartialEq)]
211pub enum TraceEngineEvent {
212    /// A frame committed a navigation.
213    Navigated {
214        /// Whether it was the top-level frame.
215        main_frame: bool,
216        /// The URL it navigated to.
217        url: String,
218    },
219    /// The page wrote to its console.
220    Console {
221        /// `log`, `warning`, `error`, ...
222        level: String,
223        /// The message text.
224        text: String,
225    },
226    /// An uncaught exception.
227    PageError {
228        /// The error message.
229        message: String,
230        /// The stack, when the engine reports one.
231        stack: Option<String>,
232    },
233    /// A request failed.
234    RequestFailed {
235        /// The request URL.
236        url: String,
237        /// The request method.
238        method: String,
239        /// Why it failed.
240        error_text: Option<String>,
241    },
242    /// A dialog opened.
243    Dialog {
244        /// `alert`, `confirm`, `prompt` or `beforeunload`.
245        dialog_type: String,
246        /// The dialog message.
247        message: String,
248    },
249    /// A download changed state.
250    Download {
251        /// `started`, `completed`, `failed`, ...
252        phase: String,
253        /// The engine's identifier for the download.
254        id: Option<String>,
255        /// The file name the page suggested.
256        suggested_filename: Option<String>,
257        /// Where it was saved.
258        path: Option<String>,
259        /// A checksum of the saved file.
260        checksum: Option<String>,
261        /// Bytes saved.
262        bytes: Option<u64>,
263        /// The download URL.
264        url: Option<String>,
265        /// Why it failed.
266        failure: Option<String>,
267    },
268}
269
270/// Trait for browser engine adapters.
271///
272/// This trait provides a unified interface for different browser automation
273/// engines, allowing the library to work with multiple backends.
274#[async_trait]
275pub trait EngineAdapter: Send + Sync {
276    /// Get the engine type.
277    fn engine_type(&self) -> EngineType;
278
279    /// Get the current page URL.
280    async fn url(&self) -> Result<String, EngineError>;
281
282    /// Navigate to a URL.
283    async fn goto(&self, url: &str) -> Result<(), EngineError>;
284
285    /// Query for a single element.
286    async fn query_selector(&self, selector: &str) -> Result<Option<ElementInfo>, EngineError>;
287
288    /// Query for all matching elements.
289    async fn query_selector_all(&self, selector: &str) -> Result<Vec<ElementInfo>, EngineError>;
290
291    /// Count matching elements.
292    async fn count(&self, selector: &str) -> Result<usize, EngineError>;
293
294    /// Click an element.
295    async fn click(&self, selector: &str) -> Result<(), EngineError>;
296
297    /// Click at viewport coordinates, without scrolling anything into view.
298    ///
299    /// This is what `ClickScroll::None` needs: the engine's element click
300    /// scrolls the target into view first, so it cannot honor a "do not scroll"
301    /// request. The default implementation reports that the engine has no such
302    /// API, so the constraint is refused rather than silently ignored.
303    ///
304    /// # Arguments
305    ///
306    /// * `x` - Viewport x coordinate
307    /// * `y` - Viewport y coordinate
308    ///
309    /// # Returns
310    ///
311    /// Nothing on success, or [`EngineError::Browser`] when unsupported.
312    async fn mouse_click(&self, x: f64, y: f64) -> Result<(), EngineError> {
313        let _ = (x, y);
314        Err(EngineError::Browser(
315            "this engine does not expose viewport-coordinate pointer input".to_string(),
316        ))
317    }
318
319    /// Fill an input element with text.
320    async fn fill(&self, selector: &str, text: &str) -> Result<(), EngineError>;
321
322    /// Type text into an element (simulating key presses).
323    async fn type_text(&self, selector: &str, text: &str) -> Result<(), EngineError>;
324
325    /// Get the text content of an element.
326    async fn text_content(&self, selector: &str) -> Result<Option<String>, EngineError>;
327
328    /// Get the value of an input element.
329    async fn input_value(&self, selector: &str) -> Result<Option<String>, EngineError>;
330
331    /// Get an attribute value from an element.
332    async fn get_attribute(
333        &self,
334        selector: &str,
335        attribute: &str,
336    ) -> Result<Option<String>, EngineError>;
337
338    /// Check if an element is visible.
339    async fn is_visible(&self, selector: &str) -> Result<bool, EngineError>;
340
341    /// Check if an element is enabled.
342    async fn is_enabled(&self, selector: &str) -> Result<bool, EngineError>;
343
344    /// Wait for a selector to appear.
345    async fn wait_for_selector(&self, selector: &str, timeout_ms: u64) -> Result<(), EngineError>;
346
347    /// Scroll an element into view.
348    async fn scroll_into_view(&self, selector: &str) -> Result<(), EngineError>;
349
350    /// Evaluate JavaScript in the page context.
351    async fn evaluate(&self, script: &str) -> Result<serde_json::Value, EngineError>;
352
353    /// Read `chrome://version` in a fresh tab without changing the current page.
354    async fn read_browser_version_page(&self) -> Result<serde_json::Value, EngineError> {
355        Err(EngineError::Browser(format!(
356            "browser version metadata is unavailable for the {} engine",
357            self.engine_type()
358        )))
359    }
360
361    /// Restore portable cookies and origin-scoped localStorage before navigation.
362    async fn restore_storage_state(&self, _state: serde_json::Value) -> Result<(), EngineError> {
363        Err(EngineError::Browser(format!(
364            "portable storage state is unavailable for the {} engine",
365            self.engine_type()
366        )))
367    }
368
369    /// Export portable cookies and the current page's localStorage.
370    async fn export_storage_state(&self) -> Result<serde_json::Value, EngineError> {
371        Err(EngineError::Browser(format!(
372            "portable storage state is unavailable for the {} engine",
373            self.engine_type()
374        )))
375    }
376
377    /// Take a screenshot.
378    async fn screenshot(&self) -> Result<Vec<u8>, EngineError>;
379
380    /// Generate a PDF of the current page.
381    ///
382    /// Only supported by Chromium-based engines (chromiumoxide).
383    /// Returns an error for engines that do not support PDF generation.
384    async fn pdf(&self, options: PdfOptions) -> Result<Vec<u8>, EngineError>;
385
386    /// Bring the page to front.
387    async fn bring_to_front(&self) -> Result<(), EngineError>;
388
389    /// Wait for navigation to complete.
390    async fn wait_for_navigation(&self, timeout_ms: u64) -> Result<(), EngineError>;
391
392    // =========================================================================
393    // Page-level Keyboard Operations
394    // =========================================================================
395
396    /// Press a key at the page level (e.g. "Escape", "Enter", "Tab").
397    ///
398    /// Key names follow the Playwright/Puppeteer convention.
399    async fn keyboard_press(&self, key: &str) -> Result<(), EngineError>;
400
401    /// Type text at the page level (dispatches key events for each character).
402    async fn keyboard_type(&self, text: &str) -> Result<(), EngineError>;
403
404    /// Hold a key down at the page level. Must be paired with `keyboard_up`.
405    async fn keyboard_down(&self, key: &str) -> Result<(), EngineError>;
406
407    /// Release a held key at the page level.
408    async fn keyboard_up(&self, key: &str) -> Result<(), EngineError>;
409
410    // =========================================================================
411    // Trace recording (issue #108)
412    // =========================================================================
413
414    /// Run `script` in every document the page loads from now on; an
415    /// identifier for [`EngineAdapter::remove_init_script`], or `None` when the
416    /// engine cannot (the default).
417    async fn add_init_script(&self, _script: &str) -> Result<Option<String>, EngineError> {
418        Ok(None)
419    }
420
421    /// Stop running a script added with [`EngineAdapter::add_init_script`].
422    async fn remove_init_script(&self, _identifier: &str) -> Result<(), EngineError> {
423        Ok(())
424    }
425
426    /// A stream of page activity for a trace recorder, or `None` when the
427    /// engine does not report any (the default).
428    async fn trace_events(&self) -> Option<BoxStream<'static, TraceEngineEvent>> {
429        None
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    #[test]
438    fn engine_type_display() {
439        assert_eq!(EngineType::Chromiumoxide.to_string(), "chromiumoxide");
440        assert_eq!(EngineType::Fantoccini.to_string(), "fantoccini");
441        assert_eq!(EngineType::Playwright.to_string(), "playwright");
442        assert_eq!(EngineType::Puppeteer.to_string(), "puppeteer");
443    }
444
445    #[test]
446    fn engine_type_from_str() {
447        assert_eq!(
448            "chromiumoxide".parse::<EngineType>().unwrap(),
449            EngineType::Chromiumoxide
450        );
451        assert_eq!(
452            "cdp".parse::<EngineType>().unwrap(),
453            EngineType::Chromiumoxide
454        );
455        assert_eq!(
456            "puppeteer".parse::<EngineType>().unwrap(),
457            EngineType::Puppeteer
458        );
459        assert_eq!(
460            "puppeteer".parse::<EngineType>().unwrap().to_string(),
461            "puppeteer"
462        );
463        assert_eq!(
464            "fantoccini".parse::<EngineType>().unwrap(),
465            EngineType::Fantoccini
466        );
467        assert_eq!(
468            "webdriver".parse::<EngineType>().unwrap(),
469            EngineType::Fantoccini
470        );
471        assert_eq!(
472            "playwright".parse::<EngineType>().unwrap(),
473            EngineType::Playwright
474        );
475        assert_eq!(
476            "playwright".parse::<EngineType>().unwrap().to_string(),
477            "playwright"
478        );
479    }
480
481    #[test]
482    fn engine_type_from_str_case_insensitive() {
483        assert_eq!(
484            "CHROMIUMOXIDE".parse::<EngineType>().unwrap(),
485            EngineType::Chromiumoxide
486        );
487        assert_eq!(
488            "Fantoccini".parse::<EngineType>().unwrap(),
489            EngineType::Fantoccini
490        );
491        assert_eq!(
492            "Playwright".parse::<EngineType>().unwrap(),
493            EngineType::Playwright
494        );
495        assert_eq!(
496            "Puppeteer".parse::<EngineType>().unwrap(),
497            EngineType::Puppeteer
498        );
499    }
500
501    #[test]
502    fn engine_type_from_str_invalid() {
503        let result = "invalid".parse::<EngineType>();
504        assert!(result.is_err());
505        if let Err(EngineError::InvalidEngine(name)) = result {
506            assert_eq!(name, "invalid");
507        } else {
508            panic!("Expected InvalidEngine error");
509        }
510    }
511
512    #[test]
513    fn pdf_options_default() {
514        let opts = PdfOptions::default();
515        assert!(opts.format.is_none());
516        assert!(!opts.print_background);
517        assert!(opts.margin_top.is_none());
518        assert!(opts.path.is_none());
519        assert!(opts.scale.is_none());
520    }
521
522    #[test]
523    fn pdf_options_can_be_constructed() {
524        let opts = PdfOptions {
525            format: Some("A4".to_string()),
526            print_background: true,
527            margin_top: Some("1cm".to_string()),
528            margin_right: Some("1cm".to_string()),
529            margin_bottom: Some("1cm".to_string()),
530            margin_left: Some("1cm".to_string()),
531            scale: Some(1.0),
532            path: None,
533        };
534        assert_eq!(opts.format.as_deref(), Some("A4"));
535        assert!(opts.print_background);
536        assert_eq!(opts.margin_top.as_deref(), Some("1cm"));
537        assert_eq!(opts.scale, Some(1.0));
538    }
539
540    #[test]
541    fn pre_click_state_default() {
542        let state = PreClickState::default();
543        assert!(state.disabled.is_none());
544        assert!(state.aria_pressed.is_none());
545        assert!(!state.is_connected);
546    }
547
548    #[test]
549    fn click_verification_result_creation() {
550        let result = ClickVerificationResult::confirmed(
551            "element state changed",
552            vec![Evidence::message("element-state", "className changed")],
553        );
554        assert!(result.verified);
555        assert!(!result.navigation_error);
556        assert_eq!(result.effect, ClickEffect::Confirmed);
557        assert_eq!(result.evidence.len(), 1);
558
559        // Regression test for issue #89: "nothing was observed" must never
560        // present itself as a verified click.
561        let unobserved = ClickVerificationResult::not_observed("no observable change", vec![]);
562        assert!(!unobserved.verified);
563        assert_eq!(unobserved.effect, ClickEffect::NotObserved);
564    }
565}