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    /// Native WebDriver engine, also selected with `selenium` or `webdriver`.
20    #[serde(alias = "selenium", alias = "webdriver")]
21    Fantoccini,
22    /// Playwright's native driver protocol, with an optional Node.js bridge.
23    Playwright,
24    /// Puppeteer driven through the Node.js package as a CLI bridge.
25    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/// Errors related to browser engine operations.
54#[derive(Debug, Error)]
55pub enum EngineError {
56    /// Invalid engine type specified.
57    #[error(
58        "Invalid engine: {0}. Expected 'chromiumoxide', 'fantoccini'/'selenium'/'webdriver', 'playwright', or 'puppeteer'"
59    )]
60    InvalidEngine(String),
61
62    /// Element not found.
63    #[error("Element not found: {0}")]
64    ElementNotFound(String),
65
66    /// Operation timed out.
67    #[error("Operation timed out: {0}")]
68    Timeout(String),
69
70    /// Navigation error.
71    #[error("Navigation error: {0}")]
72    Navigation(String),
73
74    /// JavaScript evaluation error.
75    #[error("JavaScript evaluation error: {0}")]
76    Evaluation(String),
77
78    /// Generic browser error.
79    #[error("Browser error: {0}")]
80    Browser(String),
81
82    /// A feature unavailable in the selected browser.
83    #[error("{feature} is unsupported on {browser}")]
84    Unsupported { browser: String, feature: String },
85}
86
87/// Result of an element query.
88#[derive(Debug, Clone)]
89pub struct ElementInfo {
90    /// The element's tag name.
91    pub tag_name: String,
92    /// The element's text content.
93    pub text_content: Option<String>,
94    /// Whether the element is visible.
95    pub is_visible: bool,
96    /// Whether the element is enabled (for form elements).
97    pub is_enabled: bool,
98    /// The element's bounding box (x, y, width, height).
99    pub bounding_box: Option<(f64, f64, f64, f64)>,
100}
101
102/// Result of a click verification.
103///
104/// `verified` is a legacy alias for "the effect was confirmed". The verdict
105/// itself lives in [`ClickVerificationResult::effect`], which can also say
106/// "nothing was observed" - a distinct answer from "the click failed".
107#[derive(Debug, Clone)]
108pub struct ClickVerificationResult {
109    /// Whether the click was verified as successful.
110    pub verified: bool,
111    /// The reason for the verification result.
112    pub reason: String,
113    /// Whether a navigation error occurred during verification.
114    pub navigation_error: bool,
115    /// What the page was observed to do.
116    pub effect: ClickEffect,
117    /// The observations behind the verdict.
118    pub evidence: Vec<Evidence>,
119}
120
121impl ClickVerificationResult {
122    /// Build a verdict from an observed effect.
123    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    /// Build a verdict confirming that the click had an effect.
134    pub fn confirmed(reason: impl Into<String>, evidence: Vec<Evidence>) -> Self {
135        Self::new(ClickEffect::Confirmed, reason, evidence)
136    }
137
138    /// Build a verdict that observed nothing either way.
139    pub fn not_observed(reason: impl Into<String>, evidence: Vec<Evidence>) -> Self {
140        Self::new(ClickEffect::NotObserved, reason, evidence)
141    }
142
143    /// Mark this verdict as one reached while the page was navigating away.
144    #[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/// Result of a scroll verification.
152#[derive(Debug, Clone)]
153pub struct ScrollVerificationResult {
154    /// Whether the scroll was verified as successful.
155    pub verified: bool,
156    /// Whether the element is in the viewport.
157    pub in_viewport: bool,
158    /// Number of verification attempts.
159    pub attempts: u32,
160}
161
162/// Result of a fill verification.
163#[derive(Debug, Clone)]
164pub struct FillVerificationResult {
165    /// Whether the fill was verified as successful.
166    pub verified: bool,
167    /// The actual value in the element after filling.
168    pub actual_value: String,
169    /// Number of verification attempts.
170    pub attempts: u32,
171}
172
173/// Options for PDF generation.
174#[derive(Debug, Clone, Default)]
175pub struct PdfOptions {
176    /// Paper format (e.g. "A4", "Letter").
177    pub format: Option<String>,
178    /// Print background graphics.
179    pub print_background: bool,
180    /// Page margins as CSS strings (e.g. "1cm").
181    pub margin_top: Option<String>,
182    pub margin_right: Option<String>,
183    pub margin_bottom: Option<String>,
184    pub margin_left: Option<String>,
185    /// Scale of the webpage rendering (0.1–2.0).
186    pub scale: Option<f64>,
187    /// Optional file path to save the PDF.
188    pub path: Option<String>,
189}
190
191/// Pre-click state captured for verification.
192#[derive(Debug, Clone, Default)]
193pub struct PreClickState {
194    /// Whether the element was disabled.
195    pub disabled: Option<bool>,
196    /// The aria-pressed attribute value.
197    pub aria_pressed: Option<String>,
198    /// The aria-expanded attribute value.
199    pub aria_expanded: Option<String>,
200    /// The aria-selected attribute value.
201    pub aria_selected: Option<String>,
202    /// Whether the element was checked (for checkboxes).
203    pub checked: Option<bool>,
204    /// The element's class name.
205    pub class_name: Option<String>,
206    /// Whether the element is connected to the DOM.
207    pub is_connected: bool,
208}
209
210/// Something the browser did that a trace records (issue #108).
211///
212/// Engines that can report page activity hand these to a recorder through
213/// [`EngineAdapter::trace_events`]; each maps to one `js/src/traces/observers.js`
214/// record.
215#[derive(Debug, Clone, PartialEq)]
216pub enum TraceEngineEvent {
217    /// A frame committed a navigation.
218    Navigated {
219        /// Whether it was the top-level frame.
220        main_frame: bool,
221        /// The URL it navigated to.
222        url: String,
223    },
224    /// The page wrote to its console.
225    Console {
226        /// `log`, `warning`, `error`, ...
227        level: String,
228        /// The message text.
229        text: String,
230    },
231    /// An uncaught exception.
232    PageError {
233        /// The error message.
234        message: String,
235        /// The stack, when the engine reports one.
236        stack: Option<String>,
237    },
238    /// A request failed.
239    RequestFailed {
240        /// The request URL.
241        url: String,
242        /// The request method.
243        method: String,
244        /// Why it failed.
245        error_text: Option<String>,
246    },
247    /// A dialog opened.
248    Dialog {
249        /// `alert`, `confirm`, `prompt` or `beforeunload`.
250        dialog_type: String,
251        /// The dialog message.
252        message: String,
253    },
254    /// A download changed state.
255    Download {
256        /// `started`, `completed`, `failed`, ...
257        phase: String,
258        /// The engine's identifier for the download.
259        id: Option<String>,
260        /// The file name the page suggested.
261        suggested_filename: Option<String>,
262        /// Where it was saved.
263        path: Option<String>,
264        /// A checksum of the saved file.
265        checksum: Option<String>,
266        /// Bytes saved.
267        bytes: Option<u64>,
268        /// The download URL.
269        url: Option<String>,
270        /// Why it failed.
271        failure: Option<String>,
272    },
273}
274
275/// Trait for browser engine adapters.
276///
277/// This trait provides a unified interface for different browser automation
278/// engines, allowing the library to work with multiple backends.
279#[async_trait]
280pub trait EngineAdapter: Send + Sync {
281    /// Validate a feature before starting observers or writing artifacts.
282    fn require_feature(&self, _feature: &str) -> Result<(), EngineError> {
283        Ok(())
284    }
285    /// Get the engine type.
286    fn engine_type(&self) -> EngineType;
287
288    /// Get the current page URL.
289    async fn url(&self) -> Result<String, EngineError>;
290
291    /// Navigate to a URL.
292    async fn goto(&self, url: &str) -> Result<(), EngineError>;
293
294    /// Query for a single element.
295    async fn query_selector(&self, selector: &str) -> Result<Option<ElementInfo>, EngineError>;
296
297    /// Query for all matching elements.
298    async fn query_selector_all(&self, selector: &str) -> Result<Vec<ElementInfo>, EngineError>;
299
300    /// Count matching elements.
301    async fn count(&self, selector: &str) -> Result<usize, EngineError>;
302
303    /// Click an element.
304    async fn click(&self, selector: &str) -> Result<(), EngineError>;
305
306    /// Click at viewport coordinates, without scrolling anything into view.
307    ///
308    /// This is what `ClickScroll::None` needs: the engine's element click
309    /// scrolls the target into view first, so it cannot honor a "do not scroll"
310    /// request. The default implementation reports that the engine has no such
311    /// API, so the constraint is refused rather than silently ignored.
312    ///
313    /// # Arguments
314    ///
315    /// * `x` - Viewport x coordinate
316    /// * `y` - Viewport y coordinate
317    ///
318    /// # Returns
319    ///
320    /// Nothing on success, or [`EngineError::Browser`] when unsupported.
321    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    /// Fill an input element with text.
329    async fn fill(&self, selector: &str, text: &str) -> Result<(), EngineError>;
330
331    /// Type text into an element (simulating key presses).
332    async fn type_text(&self, selector: &str, text: &str) -> Result<(), EngineError>;
333
334    /// Get the text content of an element.
335    async fn text_content(&self, selector: &str) -> Result<Option<String>, EngineError>;
336
337    /// Get the value of an input element.
338    async fn input_value(&self, selector: &str) -> Result<Option<String>, EngineError>;
339
340    /// Get an attribute value from an element.
341    async fn get_attribute(
342        &self,
343        selector: &str,
344        attribute: &str,
345    ) -> Result<Option<String>, EngineError>;
346
347    /// Check if an element is visible.
348    async fn is_visible(&self, selector: &str) -> Result<bool, EngineError>;
349
350    /// Check if an element is enabled.
351    async fn is_enabled(&self, selector: &str) -> Result<bool, EngineError>;
352
353    /// Wait for a selector to appear.
354    async fn wait_for_selector(&self, selector: &str, timeout_ms: u64) -> Result<(), EngineError>;
355
356    /// Scroll an element into view.
357    async fn scroll_into_view(&self, selector: &str) -> Result<(), EngineError>;
358
359    /// Evaluate JavaScript in the page context.
360    async fn evaluate(&self, script: &str) -> Result<serde_json::Value, EngineError>;
361
362    /// Read `chrome://version` in a fresh tab without changing the current page.
363    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    /// Restore portable cookies and origin-scoped localStorage before navigation.
371    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    /// Export portable cookies and the current page's localStorage.
379    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    /// Take a screenshot.
387    async fn screenshot(&self) -> Result<Vec<u8>, EngineError>;
388
389    /// Generate a PDF of the current page.
390    ///
391    /// Only supported by Chromium-based engines (chromiumoxide).
392    /// Returns an error for engines that do not support PDF generation.
393    async fn pdf(&self, options: PdfOptions) -> Result<Vec<u8>, EngineError>;
394
395    /// Bring the page to front.
396    async fn bring_to_front(&self) -> Result<(), EngineError>;
397
398    /// Wait for navigation to complete.
399    async fn wait_for_navigation(&self, timeout_ms: u64) -> Result<(), EngineError>;
400
401    // =========================================================================
402    // Page-level Keyboard Operations
403    // =========================================================================
404
405    /// Press a key at the page level (e.g. "Escape", "Enter", "Tab").
406    ///
407    /// Key names follow the Playwright/Puppeteer convention.
408    async fn keyboard_press(&self, key: &str) -> Result<(), EngineError>;
409
410    /// Type text at the page level (dispatches key events for each character).
411    async fn keyboard_type(&self, text: &str) -> Result<(), EngineError>;
412
413    /// Hold a key down at the page level. Must be paired with `keyboard_up`.
414    async fn keyboard_down(&self, key: &str) -> Result<(), EngineError>;
415
416    /// Release a held key at the page level.
417    async fn keyboard_up(&self, key: &str) -> Result<(), EngineError>;
418
419    // =========================================================================
420    // Trace recording (issue #108)
421    // =========================================================================
422
423    /// Run `script` in every document the page loads from now on; an
424    /// identifier for [`EngineAdapter::remove_init_script`], or `None` when the
425    /// engine cannot (the default).
426    async fn add_init_script(&self, _script: &str) -> Result<Option<String>, EngineError> {
427        Ok(None)
428    }
429
430    /// Stop running a script added with [`EngineAdapter::add_init_script`].
431    async fn remove_init_script(&self, _identifier: &str) -> Result<(), EngineError> {
432        Ok(())
433    }
434
435    /// A stream of page activity for a trace recorder, or `None` when the
436    /// engine does not report any (the default).
437    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    // feature-parity: engines.webdriver@native-typed
522    #[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        // Regression test for issue #89: "nothing was observed" must never
584        // present itself as a verified click.
585        let unobserved = ClickVerificationResult::not_observed("no observable change", vec![]);
586        assert!(!unobserved.verified);
587        assert_eq!(unobserved.effect, ClickEffect::NotObserved);
588    }
589}