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