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