Skip to main content

browser_commander/browser/
launcher.rs

1//! Browser launcher for browser automation.
2//!
3//! This module provides utilities for launching browser instances
4//! with appropriate configuration.
5
6use std::path::PathBuf;
7use std::sync::Arc;
8use std::time::Duration;
9
10use chromiumoxide::browser::{Browser as CdpBrowser, BrowserConfig, HeadlessMode};
11use futures::StreamExt;
12
13use crate::browser::chromiumoxide_adapter::ChromiumoxidePage;
14use crate::browser::media::ColorScheme;
15use crate::browser::node_bridge::NodeBridgePage;
16use crate::core::constants::CHROME_ARGS;
17use crate::core::engine::{EngineAdapter, EngineType};
18
19/// Options for launching a browser.
20#[derive(Debug, Clone)]
21pub struct LaunchOptions {
22    /// The browser engine to use.
23    pub engine: EngineType,
24    /// Path to user data directory.
25    pub user_data_dir: Option<PathBuf>,
26    /// Run in headless mode.
27    pub headless: bool,
28    /// Slow down operations by this many milliseconds.
29    pub slow_mo: u64,
30    /// Enable verbose logging.
31    pub verbose: bool,
32    /// Additional Chrome arguments.
33    pub args: Vec<String>,
34    /// Color scheme to emulate. `None` uses the system default.
35    pub color_scheme: Option<ColorScheme>,
36    /// Optional timeout for the browser launch handshake.
37    pub launch_timeout: Option<Duration>,
38    /// Whether to run the browser with the Chromium sandbox enabled.
39    ///
40    /// Defaults to `true`. Disable when running in environments where the
41    /// sandbox is unavailable (e.g. CI containers without the required
42    /// capabilities). This translates to the `--no-sandbox` /
43    /// `--disable-setuid-sandbox` Chromium flags.
44    pub sandbox: bool,
45    /// Node.js executable for Playwright/Puppeteer fallback engines.
46    pub node_executable: Option<PathBuf>,
47    /// Working directory used to resolve Playwright/Puppeteer Node packages.
48    pub node_working_dir: Option<PathBuf>,
49}
50
51impl Default for LaunchOptions {
52    fn default() -> Self {
53        Self {
54            engine: EngineType::Chromiumoxide,
55            user_data_dir: None,
56            headless: false,
57            slow_mo: 0,
58            verbose: false,
59            args: Vec::new(),
60            color_scheme: None,
61            launch_timeout: None,
62            sandbox: true,
63            node_executable: None,
64            node_working_dir: None,
65        }
66    }
67}
68
69impl LaunchOptions {
70    /// Set the browser automation engine.
71    pub fn engine(mut self, engine: EngineType) -> Self {
72        self.engine = engine;
73        if engine == EngineType::Playwright && self.slow_mo == 0 {
74            self.slow_mo = 150;
75        }
76        self
77    }
78
79    /// Create options for chromiumoxide engine.
80    pub fn chromiumoxide() -> Self {
81        Self {
82            engine: EngineType::Chromiumoxide,
83            ..Default::default()
84        }
85    }
86
87    /// Create options for fantoccini (WebDriver) engine.
88    pub fn fantoccini() -> Self {
89        Self {
90            engine: EngineType::Fantoccini,
91            ..Default::default()
92        }
93    }
94
95    /// Create options for Playwright through the Node.js CLI bridge.
96    pub fn playwright() -> Self {
97        Self {
98            engine: EngineType::Playwright,
99            slow_mo: 150,
100            ..Default::default()
101        }
102    }
103
104    /// Create options for Puppeteer through the Node.js CLI bridge.
105    pub fn puppeteer() -> Self {
106        Self {
107            engine: EngineType::Puppeteer,
108            ..Default::default()
109        }
110    }
111
112    /// Set headless mode.
113    pub fn headless(mut self, headless: bool) -> Self {
114        self.headless = headless;
115        self
116    }
117
118    /// Set the user data directory.
119    pub fn user_data_dir(mut self, dir: impl Into<PathBuf>) -> Self {
120        self.user_data_dir = Some(dir.into());
121        self
122    }
123
124    /// Set slow motion delay.
125    pub fn slow_mo(mut self, ms: u64) -> Self {
126        self.slow_mo = ms;
127        self
128    }
129
130    /// Enable verbose logging.
131    pub fn verbose(mut self, verbose: bool) -> Self {
132        self.verbose = verbose;
133        self
134    }
135
136    /// Add additional Chrome arguments.
137    pub fn with_args(mut self, args: Vec<String>) -> Self {
138        self.args = args;
139        self
140    }
141
142    /// Set the color scheme for media emulation.
143    pub fn color_scheme(mut self, color_scheme: ColorScheme) -> Self {
144        self.color_scheme = Some(color_scheme);
145        self
146    }
147
148    /// Override the browser launch timeout.
149    pub fn launch_timeout(mut self, timeout: Duration) -> Self {
150        self.launch_timeout = Some(timeout);
151        self
152    }
153
154    /// Enable or disable the Chromium sandbox for the launched browser.
155    pub fn sandbox(mut self, sandbox: bool) -> Self {
156        self.sandbox = sandbox;
157        self
158    }
159
160    /// Override the Node.js executable used by Playwright/Puppeteer engines.
161    pub fn node_executable(mut self, executable: impl Into<PathBuf>) -> Self {
162        self.node_executable = Some(executable.into());
163        self
164    }
165
166    /// Set the directory where Node resolves `playwright` or `puppeteer`.
167    pub fn node_working_dir(mut self, dir: impl Into<PathBuf>) -> Self {
168        self.node_working_dir = Some(dir.into());
169        self
170    }
171
172    /// Get all Chrome arguments (default + custom).
173    pub fn all_chrome_args(&self) -> Vec<String> {
174        let mut all_args: Vec<String> = CHROME_ARGS.iter().map(|s| s.to_string()).collect();
175        all_args.extend(self.args.clone());
176        all_args
177    }
178
179    /// Get the user data directory, using a default if not specified.
180    pub fn get_user_data_dir(&self) -> PathBuf {
181        if let Some(ref dir) = self.user_data_dir {
182            dir.clone()
183        } else {
184            let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
185            home.join(".browser-commander")
186                .join(format!("{}-data", self.engine))
187        }
188    }
189}
190
191/// Browser metadata returned alongside a launched page.
192#[derive(Debug, Clone)]
193pub struct Browser {
194    /// The engine type being used.
195    pub engine: EngineType,
196    /// The user data directory.
197    pub user_data_dir: PathBuf,
198    /// Whether the browser is running headless.
199    pub headless: bool,
200}
201
202/// Result of a browser launch.
203///
204/// Contains both static metadata (`browser`) and a live
205/// [`EngineAdapter`] (`page`) that can be passed to the navigation,
206/// interaction, and query helpers exposed by this crate.
207pub struct LaunchResult {
208    /// The browser metadata.
209    pub browser: Browser,
210    /// A live page/adapter tied to the launched browser.
211    ///
212    /// For `Chromiumoxide`, this is a [`ChromiumoxidePage`]
213    /// implementing [`EngineAdapter`]. Pass `launch_result.page.as_ref()` to
214    /// `goto`, `click`, `evaluate`, and other helpers.
215    pub page: Arc<dyn EngineAdapter>,
216}
217
218impl std::fmt::Debug for LaunchResult {
219    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220        f.debug_struct("LaunchResult")
221            .field("browser", &self.browser)
222            .field("page", &"<dyn EngineAdapter>")
223            .finish()
224    }
225}
226
227/// Launch a browser with the given options.
228///
229/// For the `Chromiumoxide` engine, this starts a Chromium process, waits for
230/// the CDP handshake, opens a blank page, and returns a [`LaunchResult`]
231/// containing both the metadata (`browser`) and a live page adapter (`page`)
232/// implementing [`EngineAdapter`].
233///
234/// For the `Playwright` and `Puppeteer` engines, this starts a local Node.js
235/// subprocess and uses the official Node package as a CLI bridge. The selected
236/// package must be available to Node module resolution, usually by running
237/// `npm install playwright` or `npm install puppeteer` in the configured
238/// `node_working_dir`.
239///
240/// The `Fantoccini` engine is not yet implemented as a managed launcher; use
241/// chromiumoxide or connect to an externally-managed WebDriver session.
242///
243/// # Arguments
244///
245/// * `options` - Launch options
246///
247/// # Returns
248///
249/// The launch result containing the browser metadata and a page adapter
250///
251/// # Errors
252///
253/// Returns an error if the browser fails to launch.
254pub async fn launch_browser(options: LaunchOptions) -> Result<LaunchResult, anyhow::Error> {
255    if options.verbose {
256        tracing::info!("Launching browser with {} engine...", options.engine);
257    }
258
259    let user_data_dir = options.get_user_data_dir();
260    std::fs::create_dir_all(&user_data_dir)?;
261
262    match options.engine {
263        EngineType::Chromiumoxide => launch_chromiumoxide(options, user_data_dir).await,
264        EngineType::Playwright | EngineType::Puppeteer => {
265            launch_node_bridge(options, user_data_dir).await
266        }
267        EngineType::Fantoccini => Err(anyhow::anyhow!(
268            "fantoccini engine launch is not yet implemented; \
269             connect to an existing WebDriver session or use EngineType::Chromiumoxide"
270        )),
271    }
272}
273
274async fn launch_node_bridge(
275    options: LaunchOptions,
276    user_data_dir: PathBuf,
277) -> Result<LaunchResult, anyhow::Error> {
278    let engine = options.engine;
279    let headless = options.headless;
280    let adapter = NodeBridgePage::launch(options, user_data_dir.clone()).await?;
281
282    Ok(LaunchResult {
283        browser: Browser {
284            engine,
285            user_data_dir,
286            headless,
287        },
288        page: Arc::new(adapter),
289    })
290}
291
292async fn launch_chromiumoxide(
293    options: LaunchOptions,
294    user_data_dir: PathBuf,
295) -> Result<LaunchResult, anyhow::Error> {
296    let headless_mode = if options.headless {
297        HeadlessMode::New
298    } else {
299        HeadlessMode::False
300    };
301
302    let mut builder = BrowserConfig::builder()
303        .user_data_dir(&user_data_dir)
304        .headless_mode(headless_mode)
305        .args(options.all_chrome_args());
306
307    if !options.sandbox {
308        builder = builder.no_sandbox();
309    }
310
311    if let Some(timeout) = options.launch_timeout {
312        builder = builder.launch_timeout(timeout);
313    }
314
315    let config = builder
316        .build()
317        .map_err(|e| anyhow::anyhow!("failed to build browser config: {}", e))?;
318
319    let (browser, mut handler) = CdpBrowser::launch(config)
320        .await
321        .map_err(|e| anyhow::anyhow!("failed to launch chromium: {}", e))?;
322
323    // Drain the CDP event stream on a background task. Dropping the handler
324    // causes the browser to hang, so we must keep polling it for the lifetime
325    // of the browser. Errors are logged but do not abort the task — the CDP
326    // channel naturally returns errors once the browser is closed.
327    let handler_task = tokio::spawn(async move {
328        while let Some(event) = handler.next().await {
329            if let Err(err) = event {
330                tracing::debug!(error = %err, "chromiumoxide handler event error");
331            }
332        }
333    });
334
335    let page = browser
336        .new_page("about:blank")
337        .await
338        .map_err(|e| anyhow::anyhow!("failed to open initial page: {}", e))?;
339
340    let engine = options.engine;
341    let headless = options.headless;
342    let color_scheme = options.color_scheme.clone();
343
344    let adapter = ChromiumoxidePage::new(page, browser, handler_task, user_data_dir.clone());
345
346    // Apply color scheme emulation (best-effort).
347    if let Some(ref cs) = color_scheme {
348        if let Err(err) = adapter.set_color_scheme(Some(cs)).await {
349            if options.verbose {
350                tracing::warn!(error = %err, "could not set color scheme");
351            }
352        }
353    }
354
355    // Bring the page to front so the address bar is not focused when running
356    // headful — mirrors the JS launcher's behavior.
357    if !headless {
358        if let Err(err) = adapter.bring_to_front().await {
359            if options.verbose {
360                tracing::debug!(error = %err, "bring_to_front failed");
361            }
362        }
363    }
364
365    if options.verbose {
366        tracing::info!("Browser launched with {} engine", engine);
367    }
368
369    Ok(LaunchResult {
370        browser: Browser {
371            engine,
372            user_data_dir,
373            headless,
374        },
375        page: Arc::new(adapter),
376    })
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    #[test]
384    fn launch_options_default() {
385        let options = LaunchOptions::default();
386        assert_eq!(options.engine, EngineType::Chromiumoxide);
387        assert!(!options.headless);
388        assert_eq!(options.slow_mo, 0);
389        assert!(!options.verbose);
390        assert!(options.args.is_empty());
391        assert!(options.node_executable.is_none());
392        assert!(options.node_working_dir.is_none());
393    }
394
395    #[test]
396    fn launch_options_builder() {
397        let options = LaunchOptions::chromiumoxide()
398            .headless(true)
399            .slow_mo(100)
400            .verbose(true)
401            .with_args(vec!["--custom-arg".to_string()]);
402
403        assert_eq!(options.engine, EngineType::Chromiumoxide);
404        assert!(options.headless);
405        assert_eq!(options.slow_mo, 100);
406        assert!(options.verbose);
407        assert_eq!(options.args, vec!["--custom-arg"]);
408    }
409
410    #[test]
411    fn launch_options_fantoccini() {
412        let options = LaunchOptions::fantoccini();
413        assert_eq!(options.engine, EngineType::Fantoccini);
414    }
415
416    #[test]
417    fn launch_options_playwright() {
418        let options = LaunchOptions::playwright();
419        assert_eq!(options.engine, EngineType::Playwright);
420        assert_eq!(options.slow_mo, 150);
421    }
422
423    #[test]
424    fn launch_options_puppeteer() {
425        let options = LaunchOptions::puppeteer();
426        assert_eq!(options.engine, EngineType::Puppeteer);
427    }
428
429    #[test]
430    fn launch_options_node_bridge_configuration() {
431        let options = LaunchOptions::playwright()
432            .node_executable("/custom/node")
433            .node_working_dir("/project/js");
434
435        assert_eq!(options.node_executable, Some(PathBuf::from("/custom/node")));
436        assert_eq!(options.node_working_dir, Some(PathBuf::from("/project/js")));
437    }
438
439    #[test]
440    fn all_chrome_args_includes_defaults() {
441        let options = LaunchOptions::default();
442        let args = options.all_chrome_args();
443
444        assert!(args.contains(&"--disable-infobars".to_string()));
445        assert!(args.contains(&"--no-first-run".to_string()));
446    }
447
448    #[test]
449    fn all_chrome_args_includes_custom() {
450        let options = LaunchOptions::default().with_args(vec!["--custom".to_string()]);
451        let args = options.all_chrome_args();
452
453        assert!(args.contains(&"--custom".to_string()));
454    }
455
456    #[test]
457    fn get_user_data_dir_uses_custom() {
458        let options = LaunchOptions::default().user_data_dir("/custom/path");
459        assert_eq!(options.get_user_data_dir(), PathBuf::from("/custom/path"));
460    }
461
462    #[test]
463    fn get_user_data_dir_creates_default() {
464        let options = LaunchOptions::default();
465        let dir = options.get_user_data_dir();
466        assert!(dir.to_string_lossy().contains("browser-commander"));
467        assert!(dir.to_string_lossy().contains("chromiumoxide-data"));
468    }
469
470    #[tokio::test]
471    async fn launch_fantoccini_is_unimplemented() {
472        let options = LaunchOptions::fantoccini();
473        let err = launch_browser(options).await.unwrap_err();
474        assert!(err.to_string().contains("fantoccini"));
475    }
476
477    #[tokio::test]
478    async fn launch_playwright_reports_missing_node_executable() {
479        let options = LaunchOptions::playwright()
480            .headless(true)
481            .node_executable("browser-commander-missing-node");
482        let err = launch_browser(options).await.unwrap_err();
483        assert!(err.to_string().contains("failed to start Node.js bridge"));
484    }
485}