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