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};
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};
18use crate::downloads::{attach_downloads, DownloadManager, DownloadSetting};
19use crate::fingerprint::apply::{apply_fingerprint, ApplyOptions};
20use crate::fingerprint::automation_parity::{
21    apply_automation_parity_args, parity_ignored_default_args,
22};
23use crate::fingerprint::profile::FingerprintProfile;
24
25/// Options for launching a browser.
26#[derive(Debug, Clone)]
27pub struct LaunchOptions {
28    /// The browser engine to use.
29    pub engine: EngineType,
30    /// Path to user data directory.
31    pub user_data_dir: Option<PathBuf>,
32    /// Run in headless mode.
33    pub headless: bool,
34    /// Slow down operations by this many milliseconds.
35    pub slow_mo: u64,
36    /// Enable verbose logging.
37    pub verbose: bool,
38    /// Additional Chrome arguments.
39    pub args: Vec<String>,
40    /// Additional Chrome arguments appended after the compatibility `args`.
41    pub extra_args: Vec<String>,
42    /// Browser Commander default arguments to omit.
43    pub ignore_default_args: Vec<String>,
44    /// Omit every Browser Commander and engine default argument.
45    pub ignore_all_default_args: bool,
46    /// Installed browser channel for Playwright/Puppeteer (for example, `chrome`).
47    pub channel: Option<String>,
48    /// Explicit path to a Chrome or Chromium executable.
49    pub executable_path: Option<PathBuf>,
50    /// Color scheme to emulate. `None` uses the system default.
51    pub color_scheme: Option<ColorScheme>,
52    /// Optional timeout for the browser launch handshake.
53    pub launch_timeout: Option<Duration>,
54    /// Whether to run the browser with the Chromium sandbox enabled.
55    ///
56    /// Defaults to `true`. Disable when running in environments where the
57    /// sandbox is unavailable (e.g. CI containers without the required
58    /// capabilities). This translates to the `--no-sandbox` /
59    /// `--disable-setuid-sandbox` Chromium flags.
60    pub sandbox: bool,
61    /// Node.js executable for Playwright/Puppeteer fallback engines.
62    pub node_executable: Option<PathBuf>,
63    /// Working directory used to resolve Playwright/Puppeteer Node packages.
64    pub node_working_dir: Option<PathBuf>,
65    /// Keep `navigator.webdriver` false and the command line free of switches a
66    /// hand-started Chrome does not carry.
67    ///
68    /// Defaults to `true`. Set to `false` to launch with the engine's own
69    /// defaults, which is what the parity tests use as a negative control.
70    pub automation_parity: bool,
71    /// The environment pages should see: user agent, time zone, locale, core
72    /// count, screen and the rest.
73    ///
74    /// Applied over CDP once the browser is up, so it only works for the
75    /// chromiumoxide engine; see
76    /// [`fingerprint::profile`](crate::fingerprint::profile) for the field list
77    /// and [`presets`](crate::fingerprint::presets) for ready-made machines.
78    pub fingerprint: Option<FingerprintProfile>,
79    /// Manage the browser's downloads: where they are saved, how they are
80    /// named, and whether they outlive the browser.
81    ///
82    /// Downloads are redirected over CDP, so this only works for the
83    /// chromiumoxide engine; the other engines refuse rather than accept a
84    /// setting they cannot honor. See [`downloads`](crate::downloads).
85    pub downloads: DownloadSetting,
86}
87
88impl Default for LaunchOptions {
89    fn default() -> Self {
90        Self {
91            engine: EngineType::Chromiumoxide,
92            user_data_dir: None,
93            headless: false,
94            slow_mo: 0,
95            verbose: false,
96            args: Vec::new(),
97            extra_args: Vec::new(),
98            ignore_default_args: Vec::new(),
99            ignore_all_default_args: false,
100            channel: None,
101            executable_path: None,
102            color_scheme: None,
103            launch_timeout: None,
104            sandbox: true,
105            node_executable: None,
106            node_working_dir: None,
107            automation_parity: true,
108            fingerprint: None,
109            downloads: DownloadSetting::Off,
110        }
111    }
112}
113
114impl LaunchOptions {
115    /// Set the browser automation engine.
116    pub fn engine(mut self, engine: EngineType) -> Self {
117        self.engine = engine;
118        if engine == EngineType::Playwright && self.slow_mo == 0 {
119            self.slow_mo = 150;
120        }
121        self
122    }
123
124    /// Create options for chromiumoxide engine.
125    pub fn chromiumoxide() -> Self {
126        Self {
127            engine: EngineType::Chromiumoxide,
128            ..Default::default()
129        }
130    }
131
132    /// Create options for fantoccini (WebDriver) engine.
133    pub fn fantoccini() -> Self {
134        Self {
135            engine: EngineType::Fantoccini,
136            ..Default::default()
137        }
138    }
139
140    /// Create options for Playwright through the Node.js CLI bridge.
141    pub fn playwright() -> Self {
142        Self {
143            engine: EngineType::Playwright,
144            slow_mo: 150,
145            ..Default::default()
146        }
147    }
148
149    /// Create options for Puppeteer through the Node.js CLI bridge.
150    pub fn puppeteer() -> Self {
151        Self {
152            engine: EngineType::Puppeteer,
153            ..Default::default()
154        }
155    }
156
157    /// Set headless mode.
158    pub fn headless(mut self, headless: bool) -> Self {
159        self.headless = headless;
160        self
161    }
162
163    /// Set the user data directory.
164    pub fn user_data_dir(mut self, dir: impl Into<PathBuf>) -> Self {
165        self.user_data_dir = Some(dir.into());
166        self
167    }
168
169    /// Set slow motion delay.
170    pub fn slow_mo(mut self, ms: u64) -> Self {
171        self.slow_mo = ms;
172        self
173    }
174
175    /// Enable verbose logging.
176    pub fn verbose(mut self, verbose: bool) -> Self {
177        self.verbose = verbose;
178        self
179    }
180
181    /// Add additional Chrome arguments.
182    pub fn with_args(mut self, args: Vec<String>) -> Self {
183        self.args = args;
184        self
185    }
186
187    /// Add Chrome arguments after the compatibility `args` field.
188    pub fn with_extra_args(mut self, args: Vec<String>) -> Self {
189        self.extra_args = args;
190        self
191    }
192
193    /// Omit selected Browser Commander defaults.
194    pub fn ignore_default_args(mut self, args: Vec<String>) -> Self {
195        self.ignore_default_args = args;
196        self
197    }
198
199    /// Omit every Browser Commander and engine default argument.
200    pub fn ignore_all_default_args(mut self) -> Self {
201        self.ignore_all_default_args = true;
202        self
203    }
204
205    /// Select an installed browser channel for Playwright or Puppeteer.
206    pub fn channel(mut self, channel: impl Into<String>) -> Self {
207        self.channel = Some(channel.into());
208        self
209    }
210
211    /// Select an explicit Chrome or Chromium executable.
212    pub fn executable_path(mut self, executable_path: impl Into<PathBuf>) -> Self {
213        self.executable_path = Some(executable_path.into());
214        self
215    }
216
217    /// Set the color scheme for media emulation.
218    pub fn color_scheme(mut self, color_scheme: ColorScheme) -> Self {
219        self.color_scheme = Some(color_scheme);
220        self
221    }
222
223    /// Override the browser launch timeout.
224    pub fn launch_timeout(mut self, timeout: Duration) -> Self {
225        self.launch_timeout = Some(timeout);
226        self
227    }
228
229    /// Enable or disable the Chromium sandbox for the launched browser.
230    pub fn sandbox(mut self, sandbox: bool) -> Self {
231        self.sandbox = sandbox;
232        self
233    }
234
235    /// Override the Node.js executable used by Playwright/Puppeteer engines.
236    pub fn node_executable(mut self, executable: impl Into<PathBuf>) -> Self {
237        self.node_executable = Some(executable.into());
238        self
239    }
240
241    /// Set the directory where Node resolves `playwright` or `puppeteer`.
242    pub fn node_working_dir(mut self, dir: impl Into<PathBuf>) -> Self {
243        self.node_working_dir = Some(dir.into());
244        self
245    }
246
247    /// Turn fingerprint parity with a hand-started Chrome on or off.
248    pub fn automation_parity(mut self, automation_parity: bool) -> Self {
249        self.automation_parity = automation_parity;
250        self
251    }
252
253    /// Set the environment pages should see.
254    pub fn fingerprint(mut self, fingerprint: FingerprintProfile) -> Self {
255        self.fingerprint = Some(fingerprint);
256        self
257    }
258
259    /// Manage this browser's downloads.
260    ///
261    /// # Arguments
262    ///
263    /// * `downloads` - `true` for the defaults, `false` for none, or
264    ///   [`DownloadOptions`](crate::downloads::DownloadOptions)
265    pub fn downloads(mut self, downloads: impl Into<DownloadSetting>) -> Self {
266        self.downloads = downloads.into();
267        self
268    }
269
270    /// Get all Chrome arguments (default + custom).
271    pub fn all_chrome_args(&self) -> Vec<String> {
272        let mut all_args: Vec<String> = if self.ignore_all_default_args {
273            Vec::new()
274        } else {
275            CHROME_ARGS
276                .iter()
277                .filter(|argument| {
278                    !self
279                        .ignore_default_args
280                        .iter()
281                        .any(|item| item == **argument)
282                })
283                .map(|argument| argument.to_string())
284                .collect()
285        };
286        all_args.extend(self.args.clone());
287        all_args.extend(self.extra_args.clone());
288        if self.automation_parity {
289            all_args = apply_automation_parity_args(&all_args);
290        }
291        all_args
292    }
293
294    /// Engine default switches to suppress so the command line matches a
295    /// hand-started Chrome.
296    ///
297    /// Merged with the caller's `ignore_default_args`, because a switch the
298    /// engine appends after the caller's arguments cannot be countered by
299    /// passing a different value for it.
300    pub fn all_ignored_default_args(&self) -> Vec<String> {
301        let mut ignored = if self.automation_parity {
302            parity_ignored_default_args(self.engine, self.headless)
303        } else {
304            Vec::new()
305        };
306        for argument in &self.ignore_default_args {
307            if !ignored.contains(argument) {
308                ignored.push(argument.clone());
309            }
310        }
311        ignored
312    }
313
314    /// Get the user data directory, using a default if not specified.
315    pub fn get_user_data_dir(&self) -> PathBuf {
316        if let Some(ref dir) = self.user_data_dir {
317            dir.clone()
318        } else {
319            let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
320            home.join(".browser-commander")
321                .join(format!("{}-data", self.engine))
322        }
323    }
324}
325
326/// Browser metadata returned alongside a launched page.
327#[derive(Debug, Clone)]
328pub struct Browser {
329    /// The engine type being used.
330    pub engine: EngineType,
331    /// The user data directory.
332    pub user_data_dir: PathBuf,
333    /// Whether the browser is running headless.
334    pub headless: bool,
335}
336
337/// Result of a browser launch.
338///
339/// Contains both static metadata (`browser`) and a live
340/// [`EngineAdapter`] (`page`) that can be passed to the navigation,
341/// interaction, and query helpers exposed by this crate.
342pub struct LaunchResult {
343    /// The browser metadata.
344    pub browser: Browser,
345    /// A live page/adapter tied to the launched browser.
346    ///
347    /// For `Chromiumoxide`, this is a [`ChromiumoxidePage`]
348    /// implementing [`EngineAdapter`]. Pass `launch_result.page.as_ref()` to
349    /// `goto`, `click`, `evaluate`, and other helpers.
350    pub page: Arc<dyn EngineAdapter>,
351    /// The download manager, when the caller asked for managed downloads.
352    ///
353    /// Downloads keep arriving while the browser is open, so the manager
354    /// outlives any single call: hold on to it, and call
355    /// [`DownloadManager::dispose`] before closing the browser.
356    pub downloads: Option<Arc<DownloadManager>>,
357}
358
359impl std::fmt::Debug for LaunchResult {
360    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
361        f.debug_struct("LaunchResult")
362            .field("browser", &self.browser)
363            .field("page", &"<dyn EngineAdapter>")
364            .field("downloads", &self.downloads)
365            .finish()
366    }
367}
368
369/// Launch a browser with the given options.
370///
371/// For the `Chromiumoxide` engine, this starts a Chromium process, waits for
372/// the CDP handshake, opens a blank page, and returns a [`LaunchResult`]
373/// containing both the metadata (`browser`) and a live page adapter (`page`)
374/// implementing [`EngineAdapter`].
375///
376/// For the `Playwright` and `Puppeteer` engines, this starts a local Node.js
377/// subprocess and uses the official Node package as a CLI bridge. The selected
378/// package must be available to Node module resolution, usually by running
379/// `npm install playwright` or `npm install puppeteer` in the configured
380/// `node_working_dir`.
381///
382/// The `Fantoccini` engine is not yet implemented as a managed launcher; use
383/// chromiumoxide or connect to an externally-managed WebDriver session.
384///
385/// # Arguments
386///
387/// * `options` - Launch options
388///
389/// # Returns
390///
391/// The launch result containing the browser metadata and a page adapter
392///
393/// # Errors
394///
395/// Returns an error if the browser fails to launch.
396pub async fn launch_browser(options: LaunchOptions) -> Result<LaunchResult, anyhow::Error> {
397    if options.verbose {
398        tracing::info!("Launching browser with {} engine...", options.engine);
399    }
400
401    let user_data_dir = options.get_user_data_dir();
402    std::fs::create_dir_all(&user_data_dir)?;
403
404    match options.engine {
405        EngineType::Chromiumoxide => launch_chromiumoxide(options, user_data_dir).await,
406        EngineType::Playwright | EngineType::Puppeteer => {
407            launch_node_bridge(options, user_data_dir).await
408        }
409        EngineType::Fantoccini => Err(anyhow::anyhow!(
410            "fantoccini engine launch is not yet implemented; \
411             connect to an existing WebDriver session or use EngineType::Chromiumoxide"
412        )),
413    }
414}
415
416/// A transport for engines that have none.
417///
418/// `attach_downloads` refuses an unsupported engine before it sends anything,
419/// so this exists only to satisfy the signature; being asked to send is a bug,
420/// and saying so is better than a command that silently goes nowhere.
421struct UnavailableTransport;
422
423#[async_trait::async_trait]
424impl crate::fingerprint::CdpTransport for UnavailableTransport {
425    async fn send(
426        &self,
427        method: &str,
428        _params: serde_json::Value,
429    ) -> anyhow::Result<serde_json::Value> {
430        Err(anyhow::anyhow!(
431            "this engine has no CDP transport, so {method} cannot be sent"
432        ))
433    }
434}
435
436async fn launch_node_bridge(
437    options: LaunchOptions,
438    user_data_dir: PathBuf,
439) -> Result<LaunchResult, anyhow::Error> {
440    let engine = options.engine;
441    let headless = options.headless;
442    if options.fingerprint.is_some() {
443        // Failing here is the honest answer: the node bridge speaks its own
444        // command protocol rather than CDP, so a profile handed to it would be
445        // silently dropped and the page would report the real machine.
446        return Err(anyhow::anyhow!(
447            "the {engine} engine cannot apply a fingerprint profile yet; \
448             use EngineType::Chromiumoxide, or apply the profile from the \
449             JavaScript package, which drives Playwright and Puppeteer directly"
450        ));
451    }
452    // Same reasoning as the fingerprint above: the bridge has no CDP route, so
453    // a managed download would never be seen and every capture would time out.
454    // `attach_downloads` returns `Ok(None)` when no manager was asked for, and
455    // names the engine and what to use instead when one was.
456    attach_downloads(engine, &UnavailableTransport, options.downloads.clone())
457        .await
458        .map_err(|error| anyhow::anyhow!("{error}"))?;
459    let adapter = NodeBridgePage::launch(options, user_data_dir.clone()).await?;
460
461    Ok(LaunchResult {
462        browser: Browser {
463            engine,
464            user_data_dir,
465            headless,
466        },
467        page: Arc::new(adapter),
468        downloads: None,
469    })
470}
471
472async fn launch_chromiumoxide(
473    options: LaunchOptions,
474    user_data_dir: PathBuf,
475) -> Result<LaunchResult, anyhow::Error> {
476    // chromiumoxide 0.9 stopped re-exporting `HeadlessMode`, so the mode is
477    // selected through the builder's own methods instead of the enum.
478    let builder = BrowserConfig::builder();
479    let builder = if options.headless {
480        builder.new_headless_mode()
481    } else {
482        builder.with_head()
483    };
484    let mut builder = builder
485        .user_data_dir(&user_data_dir)
486        .args(options.all_chrome_args());
487
488    // Chromiumoxide only exposes an all-or-nothing switch for its own default
489    // layer. Disable that layer whenever the caller requests an omission so an
490    // engine-provided duplicate cannot silently re-add the selected flag.
491    if options.ignore_all_default_args || !options.all_ignored_default_args().is_empty() {
492        builder = builder.disable_default_args();
493    }
494
495    if !options.sandbox {
496        builder = builder.no_sandbox();
497    }
498
499    if let Some(ref executable_path) = options.executable_path {
500        builder = builder.chrome_executable(executable_path);
501    }
502
503    if let Some(timeout) = options.launch_timeout {
504        builder = builder.launch_timeout(timeout);
505    }
506
507    let config = builder
508        .build()
509        .map_err(|e| anyhow::anyhow!("failed to build browser config: {}", e))?;
510
511    let (browser, mut handler) = CdpBrowser::launch(config)
512        .await
513        .map_err(|e| anyhow::anyhow!("failed to launch chromium: {}", e))?;
514
515    // Drain the CDP event stream on a background task. Dropping the handler
516    // causes the browser to hang, so we must keep polling it for the lifetime
517    // of the browser. Errors are logged but do not abort the task — the CDP
518    // channel naturally returns errors once the browser is closed.
519    let handler_task = tokio::spawn(async move {
520        while let Some(event) = handler.next().await {
521            if let Err(err) = event {
522                tracing::debug!(error = %err, "chromiumoxide handler event error");
523            }
524        }
525    });
526
527    let page = browser
528        .new_page("about:blank")
529        .await
530        .map_err(|e| anyhow::anyhow!("failed to open initial page: {}", e))?;
531
532    let engine = options.engine;
533    let headless = options.headless;
534    let color_scheme = options.color_scheme.clone();
535
536    let adapter = ChromiumoxidePage::new(page, browser, handler_task, user_data_dir.clone());
537
538    // The fingerprint goes on before the caller can navigate, so the first
539    // document a page loads already sees the configured environment. A failure
540    // here is fatal rather than best-effort: a half-applied profile describes a
541    // machine that does not exist, which is louder than no profile at all.
542    if let Some(ref profile) = options.fingerprint {
543        apply_fingerprint(&adapter, profile, ApplyOptions::default()).await?;
544        if options.verbose {
545            tracing::info!("Fingerprint profile applied");
546        }
547    }
548
549    // Downloads are redirected before the caller can navigate too: a download
550    // that starts on the first page must land in the managed directory like
551    // every later one, rather than in whatever folder Chromium was using.
552    let downloads = attach_downloads(engine, &adapter, options.downloads.clone())
553        .await
554        .map_err(|error| anyhow::anyhow!("{error}"))?;
555
556    // Apply color scheme emulation (best-effort).
557    if let Some(ref cs) = color_scheme {
558        if let Err(err) = adapter.set_color_scheme(Some(cs)).await {
559            if options.verbose {
560                tracing::warn!(error = %err, "could not set color scheme");
561            }
562        }
563    }
564
565    // Bring the page to front so the address bar is not focused when running
566    // headful — mirrors the JS launcher's behavior.
567    if !headless {
568        if let Err(err) = adapter.bring_to_front().await {
569            if options.verbose {
570                tracing::debug!(error = %err, "bring_to_front failed");
571            }
572        }
573    }
574
575    if options.verbose {
576        tracing::info!("Browser launched with {} engine", engine);
577    }
578
579    Ok(LaunchResult {
580        browser: Browser {
581            engine,
582            user_data_dir,
583            headless,
584        },
585        page: Arc::new(adapter),
586        downloads,
587    })
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593    use crate::fingerprint::automation_parity::{
594        AUTOMATION_CONTROLLED_OFF_ARG, PLAYWRIGHT_HEADLESS_POINTER_ARG,
595        PLAYWRIGHT_SOFTWARE_WEBGL_ARG,
596    };
597    use crate::fingerprint::presets::create_default_fingerprint_preset;
598
599    #[test]
600    fn launch_options_default() {
601        let options = LaunchOptions::default();
602        assert_eq!(options.engine, EngineType::Chromiumoxide);
603        assert!(!options.headless);
604        assert_eq!(options.slow_mo, 0);
605        assert!(!options.verbose);
606        assert!(options.args.is_empty());
607        assert!(options.extra_args.is_empty());
608        assert!(options.ignore_default_args.is_empty());
609        assert!(!options.ignore_all_default_args);
610        assert!(options.automation_parity);
611        assert!(options.channel.is_none());
612        assert!(options.executable_path.is_none());
613        assert!(options.node_executable.is_none());
614        assert!(options.node_working_dir.is_none());
615        // A launch without a profile has to leave the machine as it is, so the
616        // browser reports the real hardware rather than a half-set one.
617        assert!(options.fingerprint.is_none());
618    }
619
620    #[test]
621    fn launch_options_builder() {
622        let options = LaunchOptions::chromiumoxide()
623            .headless(true)
624            .slow_mo(100)
625            .verbose(true)
626            .with_args(vec!["--custom-arg".to_string()]);
627
628        assert_eq!(options.engine, EngineType::Chromiumoxide);
629        assert!(options.headless);
630        assert_eq!(options.slow_mo, 100);
631        assert!(options.verbose);
632        assert_eq!(options.args, vec!["--custom-arg"]);
633    }
634
635    #[test]
636    fn launch_options_fantoccini() {
637        let options = LaunchOptions::fantoccini();
638        assert_eq!(options.engine, EngineType::Fantoccini);
639    }
640
641    #[test]
642    fn launch_options_playwright() {
643        let options = LaunchOptions::playwright();
644        assert_eq!(options.engine, EngineType::Playwright);
645        assert_eq!(options.slow_mo, 150);
646    }
647
648    #[test]
649    fn launch_options_puppeteer() {
650        let options = LaunchOptions::puppeteer();
651        assert_eq!(options.engine, EngineType::Puppeteer);
652    }
653
654    #[test]
655    fn launch_options_node_bridge_configuration() {
656        let options = LaunchOptions::playwright()
657            .node_executable("/custom/node")
658            .node_working_dir("/project/js")
659            .channel("chrome-beta")
660            .executable_path("/opt/google/chrome-beta");
661
662        assert_eq!(options.node_executable, Some(PathBuf::from("/custom/node")));
663        assert_eq!(options.node_working_dir, Some(PathBuf::from("/project/js")));
664        assert_eq!(options.channel.as_deref(), Some("chrome-beta"));
665        assert_eq!(
666            options.executable_path,
667            Some(PathBuf::from("/opt/google/chrome-beta"))
668        );
669    }
670
671    #[test]
672    fn all_chrome_args_includes_defaults() {
673        let options = LaunchOptions::default();
674        let args = options.all_chrome_args();
675
676        assert!(args.contains(&"--disable-infobars".to_string()));
677        assert!(args.contains(&"--password-store=basic".to_string()));
678        assert!(args.contains(&"--no-first-run".to_string()));
679    }
680
681    #[test]
682    fn all_chrome_args_appends_extra_args_and_ignores_selected_defaults() {
683        let options = LaunchOptions::default()
684            .with_args(vec!["--legacy-arg".to_string()])
685            .with_extra_args(vec!["--lang=en-US".to_string()])
686            .ignore_default_args(vec!["--no-default-browser-check".to_string()]);
687
688        let args = options.all_chrome_args();
689        assert!(args.contains(&"--password-store=basic".to_string()));
690        assert!(!args.contains(&"--no-default-browser-check".to_string()));
691        assert_eq!(
692            &args[args.len() - 3..],
693            [
694                "--legacy-arg".to_string(),
695                "--lang=en-US".to_string(),
696                AUTOMATION_CONTROLLED_OFF_ARG.to_string()
697            ]
698        );
699    }
700
701    #[test]
702    fn all_chrome_args_can_ignore_every_default() {
703        let options = LaunchOptions::default()
704            .ignore_all_default_args()
705            .with_extra_args(vec!["--lang=en-US".to_string()]);
706
707        assert_eq!(
708            options.all_chrome_args(),
709            [
710                "--lang=en-US".to_string(),
711                AUTOMATION_CONTROLLED_OFF_ARG.to_string()
712            ]
713        );
714    }
715
716    #[test]
717    fn all_chrome_args_can_ignore_password_store_default_specifically() {
718        let options = LaunchOptions::default()
719            .ignore_default_args(vec!["--password-store=basic".to_string()]);
720
721        let args = options.all_chrome_args();
722        assert!(!args.contains(&"--password-store=basic".to_string()));
723        assert!(args.contains(&"--no-first-run".to_string()));
724    }
725
726    #[test]
727    fn all_chrome_args_includes_custom() {
728        let options = LaunchOptions::default().with_args(vec!["--custom".to_string()]);
729        let args = options.all_chrome_args();
730
731        assert!(args.contains(&"--custom".to_string()));
732    }
733
734    #[test]
735    fn all_chrome_args_disables_the_automation_controlled_feature() {
736        let args = LaunchOptions::default().all_chrome_args();
737        assert_eq!(
738            args.last().map(String::as_str),
739            Some(AUTOMATION_CONTROLLED_OFF_ARG)
740        );
741    }
742
743    #[test]
744    fn all_chrome_args_leaves_the_command_line_alone_when_parity_is_off() {
745        let args = LaunchOptions::default()
746            .automation_parity(false)
747            .all_chrome_args();
748        assert!(!args
749            .iter()
750            .any(|argument| argument == AUTOMATION_CONTROLLED_OFF_ARG));
751    }
752
753    #[test]
754    fn all_ignored_default_args_merges_parity_with_the_caller_list() {
755        let options = LaunchOptions::playwright()
756            .headless(true)
757            .ignore_default_args(vec!["--no-first-run".to_string()]);
758
759        assert_eq!(
760            options.all_ignored_default_args(),
761            [
762                "--enable-automation".to_string(),
763                PLAYWRIGHT_SOFTWARE_WEBGL_ARG.to_string(),
764                PLAYWRIGHT_HEADLESS_POINTER_ARG.to_string(),
765                "--no-first-run".to_string()
766            ]
767        );
768    }
769
770    #[test]
771    fn all_ignored_default_args_does_not_repeat_a_switch_the_caller_already_listed() {
772        let options = LaunchOptions::playwright()
773            .ignore_default_args(vec!["--enable-automation".to_string()]);
774
775        assert_eq!(
776            options.all_ignored_default_args(),
777            [
778                "--enable-automation".to_string(),
779                PLAYWRIGHT_SOFTWARE_WEBGL_ARG.to_string()
780            ]
781        );
782    }
783
784    #[test]
785    fn all_ignored_default_args_keeps_only_the_caller_list_when_parity_is_off() {
786        let options = LaunchOptions::playwright()
787            .headless(true)
788            .automation_parity(false)
789            .ignore_default_args(vec!["--no-first-run".to_string()]);
790
791        assert_eq!(
792            options.all_ignored_default_args(),
793            ["--no-first-run".to_string()]
794        );
795    }
796
797    #[test]
798    fn chromiumoxide_excludes_nothing_by_default() {
799        assert!(LaunchOptions::chromiumoxide()
800            .all_ignored_default_args()
801            .is_empty());
802    }
803
804    #[test]
805    fn get_user_data_dir_uses_custom() {
806        let options = LaunchOptions::default().user_data_dir("/custom/path");
807        assert_eq!(options.get_user_data_dir(), PathBuf::from("/custom/path"));
808    }
809
810    #[test]
811    fn get_user_data_dir_creates_default() {
812        let options = LaunchOptions::default();
813        let dir = options.get_user_data_dir();
814        assert!(dir.to_string_lossy().contains("browser-commander"));
815        assert!(dir.to_string_lossy().contains("chromiumoxide-data"));
816    }
817
818    #[tokio::test]
819    async fn launch_fantoccini_is_unimplemented() {
820        let options = LaunchOptions::fantoccini();
821        let err = launch_browser(options).await.unwrap_err();
822        assert!(err.to_string().contains("fantoccini"));
823    }
824
825    #[test]
826    fn launch_options_carry_a_fingerprint_profile() {
827        let profile = create_default_fingerprint_preset("windows-chrome").expect("preset");
828        let options = LaunchOptions::default().fingerprint(profile.clone());
829
830        assert_eq!(options.fingerprint, Some(profile));
831    }
832
833    #[tokio::test]
834    async fn launch_playwright_refuses_a_fingerprint_it_cannot_apply() {
835        // Dropping the profile silently would leave the page reporting the real
836        // machine while the caller believes it is hidden.
837        let options = LaunchOptions::playwright()
838            .headless(true)
839            .fingerprint(create_default_fingerprint_preset("windows-chrome").expect("preset"));
840
841        let err = launch_browser(options).await.unwrap_err();
842
843        assert!(err
844            .to_string()
845            .contains("cannot apply a fingerprint profile"));
846    }
847
848    #[test]
849    fn launch_options_carry_a_download_setting() {
850        assert!(matches!(
851            LaunchOptions::default().downloads,
852            DownloadSetting::Off
853        ));
854        assert!(matches!(
855            LaunchOptions::default().downloads(true).downloads,
856            DownloadSetting::On
857        ));
858
859        let configured = LaunchOptions::default()
860            .downloads(crate::downloads::DownloadOptions::default().directory("/tmp/bc-downloads"));
861        let DownloadSetting::Options(options) = configured.downloads else {
862            panic!("the caller's download options were dropped");
863        };
864        assert_eq!(options.directory.as_deref(), Some("/tmp/bc-downloads"));
865    }
866
867    #[tokio::test]
868    async fn launch_playwright_refuses_downloads_it_cannot_manage() {
869        // Accepting the setting silently would leave the caller waiting on a
870        // manager watching a directory the browser never writes into.
871        let options = LaunchOptions::playwright().headless(true).downloads(true);
872
873        let err = launch_browser(options).await.unwrap_err();
874
875        assert!(
876            err.to_string()
877                .contains("managed downloads are not supported"),
878            "unexpected message: {err}"
879        );
880    }
881
882    #[tokio::test]
883    async fn launch_playwright_reports_missing_node_executable() {
884        let options = LaunchOptions::playwright()
885            .headless(true)
886            .node_executable("browser-commander-missing-node");
887        let err = launch_browser(options).await.unwrap_err();
888        assert!(err.to_string().contains("failed to start Node.js bridge"));
889    }
890}