Skip to main content

browser_automation_cli/native/cdp/
chrome.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Chrome discovery + launch option args for chromiumoxide one-shot.
3#![allow(missing_docs)]
4//!
5//! FORBIDDEN: dual spawn via Child/Command for Chrome production path.
6//! FORBIDDEN: BrowserFetcher embedded no MVP (system Chrome only).
7//! Launch ownership: `oxide::launch_with_oxide` → `Browser::launch`.
8//!
9//! # Workload (PAR-92 / PAR-101)
10//!
11//! **Subprocess + I/O:** temp profile path is allocated in [`build_chrome_args`]
12//! without `std::fs` on the async worker. Materialization uses
13//! [`crate::concurrency::create_dir_all_blocking`] from `oxide::launch_with_oxide`
14//! (async path). Tests may call [`materialize_temp_user_data_dir_sync`].
15
16use std::io::Write;
17use std::path::{Path, PathBuf};
18
19/// Options shared by CLI → BrowserManager → oxide launch.
20#[derive(Debug, Clone)]
21pub struct LaunchOptions {
22    pub headless: bool,
23    pub executable_path: Option<String>,
24    pub proxy: Option<String>,
25    pub proxy_bypass: Option<String>,
26    pub proxy_username: Option<String>,
27    pub proxy_password: Option<String>,
28    pub profile: Option<String>,
29    pub args: Vec<String>,
30    pub allow_file_access: bool,
31    pub extensions: Option<Vec<String>>,
32    pub storage_state: Option<String>,
33    pub user_agent: Option<String>,
34    pub ignore_https_errors: bool,
35    pub color_scheme: Option<String>,
36    pub download_path: Option<String>,
37    /// Hide native scrollbars in headless Chromium screenshots.
38    pub hide_scrollbars: bool,
39    /// Initial viewport for `--window-size`.
40    pub viewport_size: Option<(u32, u32)>,
41    /// When true, omit mock keychain flags (real system keychain).
42    pub use_real_keychain: bool,
43    /// Enable WebGPU (SwiftShader on Linux when needed).
44    pub webgpu: bool,
45    /// Opt-out Xvfb for headed Linux (legacy flag retained for CLI compat).
46    pub no_xvfb: bool,
47    /// Restrict WebRTC to proxied transports.
48    pub restrict_webrtc: bool,
49}
50
51impl Default for LaunchOptions {
52    fn default() -> Self {
53        Self {
54            headless: true,
55            executable_path: None,
56            proxy: None,
57            proxy_bypass: None,
58            proxy_username: None,
59            proxy_password: None,
60            profile: None,
61            args: Vec::new(),
62            allow_file_access: false,
63            extensions: None,
64            storage_state: None,
65            user_agent: None,
66            ignore_https_errors: false,
67            color_scheme: None,
68            download_path: None,
69            hide_scrollbars: true,
70            viewport_size: None,
71            use_real_keychain: false,
72            webgpu: false,
73            no_xvfb: false,
74            restrict_webrtc: false,
75        }
76    }
77}
78
79/// Resolved Chrome CLI args + user-data-dir for chromiumoxide launch.
80pub(crate) struct ChromeArgs {
81    pub args: Vec<String>,
82    pub user_data_dir: PathBuf,
83    pub temp_user_data_dir: Option<PathBuf>,
84}
85
86/// Create the temp profile dir on the **current** thread (unit tests / sync callers).
87///
88/// Production async launch uses [`crate::concurrency::create_dir_all_blocking`] instead.
89#[cfg_attr(not(test), allow(dead_code))]
90pub(crate) fn materialize_temp_user_data_dir_sync(args: &ChromeArgs) -> Result<(), String> {
91    if let Some(ref dir) = args.temp_user_data_dir {
92        std::fs::create_dir_all(dir)
93            .map_err(|e| format!("Failed to create temp profile dir: {}", e))?;
94    }
95    Ok(())
96}
97
98/// Build Chrome flags from [`LaunchOptions`] (used by oxide one-shot path).
99///
100/// Temp profile directories are **not** created here (PAR-92): callers on the
101/// async path must await disk materialization off the Tokio worker.
102pub(crate) fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
103    // Chrome only honors the last --enable-features switch.
104    let mut enable_features: Vec<String> = vec![
105        "NetworkService".to_string(),
106        "NetworkServiceInProcess".to_string(),
107    ];
108    if options.webgpu && cfg!(target_os = "linux") {
109        enable_features.push("Vulkan".to_string());
110    }
111
112    let mut user_args: Vec<String> = Vec::new();
113    for arg in &options.args {
114        if let Some(values) = arg.strip_prefix("--enable-features=") {
115            for feature in values.split(',').map(str::trim).filter(|f| !f.is_empty()) {
116                if !enable_features.iter().any(|f| f == feature) {
117                    enable_features.push(feature.to_string());
118                }
119            }
120        } else {
121            user_args.push(arg.clone());
122        }
123    }
124
125    // Chrome only honors the last --disable-features switch — keep a single list.
126    let mut disable_features: Vec<String> = vec!["Translate".to_string()];
127    let has_extensions = options
128        .extensions
129        .as_ref()
130        .is_some_and(|exts| !exts.is_empty());
131    if has_extensions {
132        // Chrome 127+ gates --load-extension behind this feature (must disable the gate).
133        disable_features.push("DisableLoadExtensionCommandLineSwitch".to_string());
134    }
135
136    let mut args = vec![
137        "--remote-debugging-port=0".to_string(),
138        "--no-first-run".to_string(),
139        "--no-default-browser-check".to_string(),
140        "--disable-background-networking".to_string(),
141        "--disable-backgrounding-occluded-windows".to_string(),
142        "--disable-component-update".to_string(),
143        "--disable-default-apps".to_string(),
144        "--disable-hang-monitor".to_string(),
145        "--disable-popup-blocking".to_string(),
146        "--disable-prompt-on-repost".to_string(),
147        "--disable-sync".to_string(),
148        format!("--disable-features={}", disable_features.join(",")),
149        format!("--enable-features={}", enable_features.join(",")),
150        // GAP-016 / PRD §5F: do not enable metrics subsystem (even "recording-only").
151        // Prefer hard-disable of metrics/crash reporter where Chromium honors flags.
152        "--disable-metrics".to_string(),
153        "--disable-metrics-reporting".to_string(),
154        "--disable-breakpad".to_string(),
155        "--disable-crash-reporter".to_string(),
156        "--disable-domain-reliability".to_string(),
157        "--disable-client-side-phishing-detection".to_string(),
158        "--disable-component-extensions-with-background-pages".to_string(),
159    ];
160
161    if options.webgpu {
162        args.push("--enable-unsafe-webgpu".to_string());
163        if cfg!(target_os = "linux") {
164            args.push("--use-angle=vulkan".to_string());
165            args.push("--use-vulkan=swiftshader".to_string());
166            args.push("--use-webgpu-adapter=swiftshader".to_string());
167            args.push("--disable-vulkan-surface".to_string());
168        }
169    }
170
171    if !options.use_real_keychain {
172        args.push("--password-store=basic".to_string());
173        args.push("--use-mock-keychain".to_string());
174    }
175
176    if options.headless && !has_extensions {
177        args.push("--headless=new".to_string());
178        if options.hide_scrollbars {
179            args.push("--hide-scrollbars".to_string());
180        }
181        args.push("--enable-unsafe-swiftshader".to_string());
182    }
183
184    if let Some(ref proxy) = options.proxy {
185        args.push(format!("--proxy-server={}", proxy));
186    }
187
188    if let Some(ref bypass) = options.proxy_bypass {
189        args.push(format!("--proxy-bypass-list={}", bypass));
190    }
191
192    let (user_data_dir, temp_user_data_dir) = if let Some(ref profile) = options.profile {
193        let expanded = expand_tilde(profile);
194        let dir = PathBuf::from(&expanded);
195        args.push(format!("--user-data-dir={}", expanded));
196        (dir, None)
197    } else {
198        // PAR-92: allocate path only — do **not** `create_dir_all` here.
199        // Async launch materializes via `create_dir_all_blocking` in oxide;
200        // sync tests use `materialize_temp_user_data_dir_sync`.
201        let dir = std::env::temp_dir().join(format!(
202            "browser-automation-cli-chrome-{}",
203            uuid::Uuid::new_v4()
204        ));
205        args.push(format!("--user-data-dir={}", dir.display()));
206        (dir.clone(), Some(dir))
207    };
208
209    if options.ignore_https_errors {
210        args.push("--ignore-certificate-errors".to_string());
211    }
212
213    if options.allow_file_access {
214        args.push("--allow-file-access-from-files".to_string());
215        args.push("--allow-file-access".to_string());
216    }
217
218    if let Some(ref exts) = options.extensions {
219        if !exts.is_empty() {
220            let ext_list = exts.join(",");
221            args.push(format!("--load-extension={}", ext_list));
222            args.push(format!("--disable-extensions-except={}", ext_list));
223        }
224    }
225
226    let has_window_size = options
227        .args
228        .iter()
229        .any(|a| a.starts_with("--start-maximized") || a.starts_with("--window-size="));
230
231    if !has_window_size && options.headless && !has_extensions {
232        let (w, h) = options.viewport_size.unwrap_or((1280, 720));
233        args.push(format!("--window-size={},{}", w, h));
234    }
235
236    args.extend(user_args);
237
238    if options.restrict_webrtc {
239        args.retain(|arg| !arg.starts_with("--force-webrtc-ip-handling-policy="));
240        args.push("--force-webrtc-ip-handling-policy=disable_non_proxied_udp".to_string());
241    }
242
243    if should_disable_sandbox(&args) {
244        args.push("--no-sandbox".to_string());
245    }
246
247    if should_disable_dev_shm(&args) {
248        args.push("--disable-dev-shm-usage".to_string());
249    }
250
251    Ok(ChromeArgs {
252        args,
253        user_data_dir,
254        temp_user_data_dir,
255    })
256}
257
258/// Locate system Chrome/Chromium for CDP (multiplatform discovery cascade).
259///
260/// Precedence (product law — **no product env vars** for settings):
261/// 1. XDG `chrome_path` when file exists and is executable
262/// 2. Product browsers cache (`install::find_installed_chrome`)
263/// 3. `$PATH` names (`google-chrome`, `chromium`, Edge/Brave aliases, …)
264/// 4. Platform known install paths (ProgramFiles / Applications / `/usr/bin` / snap / flatpak)
265/// 5. Home-local Puppeteer / Playwright caches
266///
267/// Snap/Flatpak hits emit a local `tracing` warning (sandbox may block automation).
268/// Does **not** shell out to `which`/`where` (pure PATH walk via [`crate::platform::which_bin`]).
269pub fn find_chrome() -> Option<PathBuf> {
270    if let Some(p) = crate::xdg::chrome_path_from_config() {
271        let path = PathBuf::from(p);
272        if crate::platform::is_executable_file(&path) {
273            crate::platform::warn_if_sandboxed_browser(&path);
274            return Some(path);
275        }
276    }
277    if let Some(p) = crate::install::find_installed_chrome() {
278        if crate::platform::is_executable_file(&p) {
279            crate::platform::warn_if_sandboxed_browser(&p);
280            return Some(p);
281        }
282    }
283
284    let cache_dir = crate::install::get_browsers_dir();
285    if cache_dir.exists() {
286        let _ = writeln!(
287            std::io::stderr(),
288            "Warning: Chrome cache directory exists ({}) but no Chrome binary found inside. \
289             Falling back to system Chrome (product browsers cache empty).",
290            cache_dir.display()
291        );
292    }
293
294    if let Some(p) = find_chrome_on_path() {
295        crate::platform::warn_if_sandboxed_browser(&p);
296        return Some(p);
297    }
298
299    if let Some(p) = find_chrome_known_paths() {
300        crate::platform::warn_if_sandboxed_browser(&p);
301        return Some(p);
302    }
303
304    if let Some(p) = find_puppeteer_chrome() {
305        if crate::platform::is_executable_file(&p) {
306            return Some(p);
307        }
308    }
309    if let Some(p) = find_playwright_chromium() {
310        if crate::platform::is_executable_file(&p) {
311            return Some(p);
312        }
313    }
314
315    None
316}
317
318/// Resolve browser names via `$PATH` without shelling out.
319fn find_chrome_on_path() -> Option<PathBuf> {
320    const NAMES: &[&str] = &[
321        "google-chrome",
322        "google-chrome-stable",
323        "google-chrome-beta",
324        "google-chrome-unstable",
325        "chromium",
326        "chromium-browser",
327        "microsoft-edge",
328        "microsoft-edge-stable",
329        "msedge",
330        "brave-browser",
331        "brave-browser-stable",
332        "brave",
333    ];
334    for name in NAMES {
335        if let Some(p) = crate::platform::which_bin(name) {
336            return Some(p);
337        }
338    }
339    None
340}
341
342/// Absolute install layouts per OS (env-expanded on Windows; no single hardcoded drive root).
343fn find_chrome_known_paths() -> Option<PathBuf> {
344    #[cfg(target_os = "macos")]
345    {
346        let mut candidates: Vec<PathBuf> = vec![
347            PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
348            PathBuf::from(
349                "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
350            ),
351            PathBuf::from(
352                "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
353            ),
354            PathBuf::from("/Applications/Chromium.app/Contents/MacOS/Chromium"),
355            PathBuf::from("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"),
356            PathBuf::from("/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"),
357        ];
358        if let Some(home) = dirs::home_dir() {
359            candidates.push(
360                home.join("Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
361            );
362            candidates.push(
363                home.join("Applications/Chromium.app/Contents/MacOS/Chromium"),
364            );
365            candidates.push(
366                home.join("Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"),
367            );
368        }
369        return crate::platform::first_existing_executable(candidates.iter().map(|p| p.as_path()));
370    }
371
372    #[cfg(target_os = "linux")]
373    {
374        let mut candidates: Vec<PathBuf> = vec![
375            PathBuf::from("/usr/bin/google-chrome"),
376            PathBuf::from("/usr/bin/google-chrome-stable"),
377            PathBuf::from("/usr/bin/google-chrome-beta"),
378            PathBuf::from("/usr/bin/google-chrome-unstable"),
379            PathBuf::from("/usr/bin/chromium"),
380            PathBuf::from("/usr/bin/chromium-browser"),
381            PathBuf::from("/usr/bin/microsoft-edge"),
382            PathBuf::from("/usr/bin/microsoft-edge-stable"),
383            PathBuf::from("/usr/bin/brave-browser"),
384            PathBuf::from("/opt/google/chrome/chrome"),
385            PathBuf::from("/opt/google/chrome/google-chrome"),
386            PathBuf::from("/opt/microsoft/msedge/msedge"),
387            PathBuf::from("/snap/bin/chromium"),
388            PathBuf::from("/snap/bin/chromium-browser"),
389            PathBuf::from("/var/lib/flatpak/exports/bin/com.google.Chrome"),
390            PathBuf::from("/var/lib/flatpak/exports/bin/org.chromium.Chromium"),
391            PathBuf::from("/var/lib/flatpak/exports/bin/com.brave.Browser"),
392            PathBuf::from("/var/lib/flatpak/exports/bin/com.microsoft.Edge"),
393        ];
394        if let Some(home) = dirs::home_dir() {
395            candidates.push(home.join(".local/share/flatpak/exports/bin/com.google.Chrome"));
396            candidates.push(home.join(".local/share/flatpak/exports/bin/org.chromium.Chromium"));
397            candidates.push(home.join(".local/share/flatpak/exports/bin/com.brave.Browser"));
398        }
399        return crate::platform::first_existing_executable(candidates.iter().map(|p| p.as_path()));
400    }
401
402    #[cfg(target_os = "windows")]
403    {
404        let mut candidates: Vec<PathBuf> = Vec::with_capacity(24);
405        // Prefer env-expanded Program Files / LocalAppData (Unicode-safe via var_os).
406        let rels = [
407            r"Google\Chrome\Application\chrome.exe",
408            r"Google\Chrome Beta\Application\chrome.exe",
409            r"Google\Chrome SxS\Application\chrome.exe", // Canary
410            r"Microsoft\Edge\Application\msedge.exe",
411            r"Microsoft\Edge Beta\Application\msedge.exe",
412            r"BraveSoftware\Brave-Browser\Application\brave.exe",
413        ];
414        for key in ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"] {
415            if let Some(base) = std::env::var_os(key) {
416                let base = PathBuf::from(base);
417                for rel in &rels {
418                    candidates.push(base.join(rel));
419                }
420            }
421        }
422        // Legacy hardcoded fallbacks when env vars are missing (rare).
423        candidates.push(PathBuf::from(
424            r"C:\Program Files\Google\Chrome\Application\chrome.exe",
425        ));
426        candidates.push(PathBuf::from(
427            r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
428        ));
429        candidates.push(PathBuf::from(
430            r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
431        ));
432        return crate::platform::first_existing_executable(candidates.iter().map(|p| p.as_path()));
433    }
434
435    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
436    {
437        None
438    }
439}
440
441fn should_disable_sandbox(existing_args: &[String]) -> bool {
442    if existing_args.iter().any(|a| a == "--no-sandbox") {
443        return false;
444    }
445    // Container/root detection only (no product CI env var as settings).
446    #[cfg(unix)]
447    {
448        // SAFETY:
449        // - Contract: detect effective root to decide Chrome `--no-sandbox`.
450        // - Invariant: `geteuid` is always safe; returns the process effective uid.
451        // - Root/container cannot run Chromium sandbox reliably; flag is a launch arg only.
452        // - See: `man 2 geteuid`; Chromium sandbox docs for containers.
453        if unsafe { libc::geteuid() } == 0 {
454            return true;
455        }
456    }
457    // Shared multiplatform probe (dockerenv, cgroup, k8s, podman).
458    crate::platform::HostEnvironment::detect().container
459}
460
461fn should_disable_dev_shm(existing_args: &[String]) -> bool {
462    if existing_args.iter().any(|a| a == "--disable-dev-shm-usage") {
463        return false;
464    }
465    #[cfg(unix)]
466    {
467        // SAFETY:
468        // - Contract: detect effective root to decide Chrome `--disable-dev-shm-usage`.
469        // - Invariant: `geteuid` is always safe; returns the process effective uid.
470        // - Root/container hosts often have tiny `/dev/shm`; flag is a launch arg only.
471        // - See: `man 2 geteuid`; Chromium headless container guidance.
472        if unsafe { libc::geteuid() } == 0 {
473            return true;
474        }
475    }
476    crate::platform::HostEnvironment::detect().container
477}
478
479/// Scan product XDG browsers cache and known home cache dirs (no third-party env vars).
480fn find_puppeteer_chrome() -> Option<PathBuf> {
481    let mut search_dirs = Vec::new();
482    if let Ok(bd) = crate::xdg::browsers_dir() {
483        search_dirs.push(bd);
484    }
485    if let Some(home) = dirs::home_dir() {
486        // Optional local caches under home (not env-driven).
487        search_dirs.push(home.join(".cache/puppeteer/chrome"));
488        search_dirs.push(home.join(".cache/ms-playwright"));
489    }
490    for dir in &search_dirs {
491        if !dir.is_dir() {
492            continue;
493        }
494        if let Ok(entries) = std::fs::read_dir(dir) {
495            let mut matches: Vec<PathBuf> = entries
496                .filter_map(|e| e.ok())
497                .filter(|e| e.path().is_dir())
498                .filter_map(|e| {
499                    let path = e.path();
500                    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
501                    if name.starts_with("chromium-") {
502                        let linux = path.join("chrome-linux64/chrome");
503                        if linux.exists() {
504                            return Some(linux);
505                        }
506                        let win = path.join("chrome-win64/chrome.exe");
507                        if win.exists() {
508                            return Some(win);
509                        }
510                    }
511                    let candidate = build_puppeteer_binary_path(&path);
512                    if candidate.exists() {
513                        Some(candidate)
514                    } else {
515                        None
516                    }
517                })
518                .collect();
519            matches.sort();
520            matches.reverse();
521            if let Some(p) = matches.into_iter().next() {
522                return Some(p);
523            }
524        }
525    }
526    None
527}
528
529#[cfg(target_os = "linux")]
530fn build_puppeteer_binary_path(version_dir: &Path) -> PathBuf {
531    version_dir.join("chrome-linux64/chrome")
532}
533
534#[cfg(target_os = "macos")]
535fn build_puppeteer_binary_path(version_dir: &Path) -> PathBuf {
536    let arm = version_dir.join(
537        "chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
538    );
539    if arm.exists() {
540        return arm;
541    }
542    version_dir.join(
543        "chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
544    )
545}
546
547#[cfg(target_os = "windows")]
548fn build_puppeteer_binary_path(version_dir: &Path) -> PathBuf {
549    version_dir.join(r"chrome-win64\chrome.exe")
550}
551
552#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
553fn build_puppeteer_binary_path(version_dir: &Path) -> PathBuf {
554    version_dir.join("chrome")
555}
556
557fn find_playwright_chromium() -> Option<PathBuf> {
558    // Home-local caches only (no PLAYWRIGHT_BROWSERS_PATH product env).
559    let mut search_dirs = Vec::new();
560    if let Some(home) = dirs::home_dir() {
561        search_dirs.push(home.join(".cache/ms-playwright"));
562    }
563    if let Ok(bd) = crate::xdg::browsers_dir() {
564        search_dirs.push(bd);
565    }
566    for dir in &search_dirs {
567        if !dir.is_dir() {
568            continue;
569        }
570        if let Ok(entries) = std::fs::read_dir(dir) {
571            let mut matches: Vec<PathBuf> = entries
572                .filter_map(|e| e.ok())
573                .filter(|e| {
574                    e.file_name()
575                        .to_str()
576                        .map(|n| n.starts_with("chromium-"))
577                        .unwrap_or(false)
578                })
579                .filter_map(|e| {
580                    let candidate = build_playwright_binary_path(&e.path());
581                    if candidate.exists() {
582                        Some(candidate)
583                    } else {
584                        None
585                    }
586                })
587                .collect();
588            matches.sort();
589            matches.reverse();
590            if let Some(p) = matches.into_iter().next() {
591                return Some(p);
592            }
593        }
594    }
595    None
596}
597
598#[cfg(target_os = "linux")]
599fn build_playwright_binary_path(chromium_dir: &Path) -> PathBuf {
600    let standard = chromium_dir.join("chrome-linux/chrome");
601    if standard.exists() {
602        return standard;
603    }
604    chromium_dir.join("chrome-linux64/chrome")
605}
606
607#[cfg(target_os = "macos")]
608fn build_playwright_binary_path(chromium_dir: &Path) -> PathBuf {
609    chromium_dir.join("chrome-mac/Chromium.app/Contents/MacOS/Chromium")
610}
611
612#[cfg(target_os = "windows")]
613fn build_playwright_binary_path(chromium_dir: &Path) -> PathBuf {
614    chromium_dir.join("chrome-win/chrome.exe")
615}
616
617#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
618fn build_playwright_binary_path(chromium_dir: &Path) -> PathBuf {
619    chromium_dir.join("chrome")
620}
621
622fn expand_tilde(path: &str) -> String {
623    if let Some(rest) = path.strip_prefix('~') {
624        if let Some(home) = dirs::home_dir() {
625            return home
626                .join(rest.strip_prefix('/').unwrap_or(rest))
627                .to_string_lossy()
628                .to_string();
629        }
630    }
631    path.to_string()
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637    use crate::test_utils::EnvGuard;
638
639    #[test]
640    fn test_find_chrome_returns_some_on_host() {
641        // Hosts without Chrome still exercise the function without panic.
642        let _ = find_chrome();
643    }
644
645    #[test]
646    fn test_expand_tilde() {
647        if dirs::home_dir().is_some() {
648            let expanded = expand_tilde("~/foo");
649            assert!(!expanded.starts_with('~'));
650            assert!(expanded.ends_with("foo") || expanded.ends_with("foo/"));
651        }
652    }
653
654    #[test]
655    fn test_expand_tilde_no_tilde() {
656        assert_eq!(expand_tilde("/tmp/x"), "/tmp/x");
657    }
658
659    #[test]
660    fn test_should_disable_sandbox_skips_if_already_set() {
661        assert!(!should_disable_sandbox(&["--no-sandbox".to_string()]));
662    }
663
664    #[test]
665    fn test_find_playwright_chromium_nonexistent() {
666        let g = EnvGuard::new(&["PLAYWRIGHT_BROWSERS_PATH"]);
667        g.set("PLAYWRIGHT_BROWSERS_PATH", "/nonexistent-playwright-path");
668        let result = find_playwright_chromium();
669        assert!(result.is_none());
670    }
671
672    #[test]
673    fn test_build_args_headless_includes_headless_flag() {
674        let opts = LaunchOptions {
675            headless: true,
676            ..Default::default()
677        };
678        let result = build_chrome_args(&opts).unwrap();
679        materialize_temp_user_data_dir_sync(&result).unwrap();
680        assert!(result.args.iter().any(|a| a == "--headless=new"));
681        assert!(result.args.iter().any(|a| a == "--hide-scrollbars"));
682        assert!(result
683            .args
684            .iter()
685            .any(|a| a == "--enable-unsafe-swiftshader"));
686        assert!(result.args.iter().any(|a| a == "--window-size=1280,720"));
687        assert!(result.temp_user_data_dir.is_some());
688        let dir = result.temp_user_data_dir.unwrap();
689        assert!(dir.exists());
690        let _ = std::fs::remove_dir_all(&dir);
691    }
692
693    #[test]
694    fn test_build_args_headed_no_headless_flag() {
695        let opts = LaunchOptions {
696            headless: false,
697            ..Default::default()
698        };
699        let result = build_chrome_args(&opts).unwrap();
700        materialize_temp_user_data_dir_sync(&result).unwrap();
701        assert!(!result.args.iter().any(|a| a.contains("--headless")));
702        assert!(!result.args.iter().any(|a| a == "--hide-scrollbars"));
703        assert!(result.temp_user_data_dir.is_some());
704        if let Some(ref dir) = result.temp_user_data_dir {
705            let _ = std::fs::remove_dir_all(dir);
706        }
707    }
708
709    #[test]
710    fn test_build_args_temp_user_data_dir_created() {
711        let opts = LaunchOptions::default();
712        let result = build_chrome_args(&opts).unwrap();
713        materialize_temp_user_data_dir_sync(&result).unwrap();
714        let dir = result.temp_user_data_dir.as_ref().unwrap();
715        assert!(dir.exists());
716        assert!(result
717            .args
718            .iter()
719            .any(|a| a.starts_with("--user-data-dir=")));
720        let _ = std::fs::remove_dir_all(dir);
721    }
722
723    #[test]
724    fn test_build_args_profile_no_temp_dir() {
725        let opts = LaunchOptions {
726            profile: Some("/tmp/my-profile".to_string()),
727            ..Default::default()
728        };
729        let result = build_chrome_args(&opts).unwrap();
730        materialize_temp_user_data_dir_sync(&result).unwrap();
731        assert!(result.temp_user_data_dir.is_none());
732        assert!(result
733            .args
734            .iter()
735            .any(|a| a == "--user-data-dir=/tmp/my-profile"));
736    }
737
738    #[test]
739    fn test_build_args_custom_window_size_not_overridden() {
740        let opts = LaunchOptions {
741            headless: true,
742            args: vec!["--window-size=1920,1080".to_string()],
743            ..Default::default()
744        };
745        let result = build_chrome_args(&opts).unwrap();
746        materialize_temp_user_data_dir_sync(&result).unwrap();
747        assert!(!result.args.iter().any(|a| a == "--window-size=1280,720"));
748        assert!(result.args.iter().any(|a| a == "--window-size=1920,1080"));
749        if let Some(ref dir) = result.temp_user_data_dir {
750            let _ = std::fs::remove_dir_all(dir);
751        }
752    }
753
754    #[test]
755    fn test_build_args_hide_scrollbars_false_suppresses_default_hide_scrollbars() {
756        let opts = LaunchOptions {
757            headless: true,
758            hide_scrollbars: false,
759            ..Default::default()
760        };
761        let result = build_chrome_args(&opts).unwrap();
762        materialize_temp_user_data_dir_sync(&result).unwrap();
763        assert!(!result.args.iter().any(|a| a == "--hide-scrollbars"));
764        if let Some(ref dir) = result.temp_user_data_dir {
765            let _ = std::fs::remove_dir_all(dir);
766        }
767    }
768
769    #[test]
770    fn test_build_args_start_maximized_suppresses_default_window_size() {
771        let opts = LaunchOptions {
772            headless: true,
773            args: vec!["--start-maximized".to_string()],
774            ..Default::default()
775        };
776        let result = build_chrome_args(&opts).unwrap();
777        materialize_temp_user_data_dir_sync(&result).unwrap();
778        assert!(!result.args.iter().any(|a| a == "--window-size=1280,720"));
779        assert!(result.args.iter().any(|a| a == "--start-maximized"));
780        if let Some(ref dir) = result.temp_user_data_dir {
781            let _ = std::fs::remove_dir_all(dir);
782        }
783    }
784
785    #[test]
786    fn test_build_args_disables_translate() {
787        let opts = LaunchOptions::default();
788        let result = build_chrome_args(&opts).unwrap();
789        materialize_temp_user_data_dir_sync(&result).unwrap();
790        assert!(result
791            .args
792            .iter()
793            .any(|a| a.contains("--disable-features") && a.contains("Translate")));
794        if let Some(ref dir) = result.temp_user_data_dir {
795            let _ = std::fs::remove_dir_all(dir);
796        }
797    }
798
799    #[test]
800    fn test_build_args_webgpu_default_off() {
801        let opts = LaunchOptions::default();
802        let result = build_chrome_args(&opts).unwrap();
803        materialize_temp_user_data_dir_sync(&result).unwrap();
804        assert!(!result.args.iter().any(|a| a == "--enable-unsafe-webgpu"));
805        if let Some(ref dir) = result.temp_user_data_dir {
806            let _ = std::fs::remove_dir_all(dir);
807        }
808    }
809
810    #[test]
811    fn test_build_args_restrict_webrtc_enforces_safe_policy() {
812        let opts = LaunchOptions {
813            restrict_webrtc: true,
814            args: vec!["--force-webrtc-ip-handling-policy=default".to_string()],
815            ..Default::default()
816        };
817        let result = build_chrome_args(&opts).unwrap();
818        materialize_temp_user_data_dir_sync(&result).unwrap();
819        let policies: Vec<&String> = result
820            .args
821            .iter()
822            .filter(|arg| arg.starts_with("--force-webrtc-ip-handling-policy="))
823            .collect();
824        assert_eq!(
825            policies,
826            vec![&"--force-webrtc-ip-handling-policy=disable_non_proxied_udp".to_string()]
827        );
828        if let Some(ref dir) = result.temp_user_data_dir {
829            let _ = std::fs::remove_dir_all(dir);
830        }
831    }
832
833    #[test]
834    fn test_build_args_webgpu_includes_webgpu_flags() {
835        let opts = LaunchOptions {
836            webgpu: true,
837            ..Default::default()
838        };
839        let result = build_chrome_args(&opts).unwrap();
840        materialize_temp_user_data_dir_sync(&result).unwrap();
841        assert!(result.args.iter().any(|a| a == "--enable-unsafe-webgpu"));
842        if cfg!(target_os = "linux") {
843            assert!(result.args.iter().any(|a| a == "--use-angle=vulkan"));
844            assert!(result.args.iter().any(|a| a == "--use-vulkan=swiftshader"));
845        }
846        if let Some(ref dir) = result.temp_user_data_dir {
847            let _ = std::fs::remove_dir_all(dir);
848        }
849    }
850
851    #[test]
852    fn test_build_args_merges_user_enable_features() {
853        let opts = LaunchOptions {
854            webgpu: true,
855            args: vec![
856                "--enable-features=Foo,Bar".to_string(),
857                "--some-other-flag".to_string(),
858                "--enable-features=NetworkService".to_string(),
859            ],
860            ..Default::default()
861        };
862        let result = build_chrome_args(&opts).unwrap();
863        materialize_temp_user_data_dir_sync(&result).unwrap();
864        let flags: Vec<&String> = result
865            .args
866            .iter()
867            .filter(|a| a.starts_with("--enable-features="))
868            .collect();
869        assert_eq!(flags.len(), 1);
870        let features: Vec<&str> = flags[0]
871            .strip_prefix("--enable-features=")
872            .unwrap()
873            .split(',')
874            .collect();
875        assert!(features.contains(&"NetworkService"));
876        assert!(features.contains(&"Foo"));
877        assert!(features.contains(&"Bar"));
878        assert!(result.args.iter().any(|a| a == "--some-other-flag"));
879        if let Some(ref dir) = result.temp_user_data_dir {
880            let _ = std::fs::remove_dir_all(dir);
881        }
882    }
883
884    #[test]
885    fn test_build_args_single_enable_features_flag() {
886        let opts = LaunchOptions {
887            webgpu: true,
888            ..Default::default()
889        };
890        let result = build_chrome_args(&opts).unwrap();
891        materialize_temp_user_data_dir_sync(&result).unwrap();
892        let count = result
893            .args
894            .iter()
895            .filter(|a| a.starts_with("--enable-features="))
896            .count();
897        assert_eq!(count, 1);
898        if let Some(ref dir) = result.temp_user_data_dir {
899            let _ = std::fs::remove_dir_all(dir);
900        }
901    }
902
903    #[test]
904    fn test_build_args_headless_with_extensions_skips_headless_flag() {
905        let opts = LaunchOptions {
906            headless: true,
907            extensions: Some(vec!["/tmp/ext".to_string()]),
908            ..Default::default()
909        };
910        let result = build_chrome_args(&opts).unwrap();
911        materialize_temp_user_data_dir_sync(&result).unwrap();
912        assert!(!result.args.iter().any(|a| a.contains("--headless")));
913        assert!(result
914            .args
915            .iter()
916            .any(|a| a.starts_with("--load-extension=")));
917        assert!(result.args.iter().any(|a| {
918            a.starts_with("--disable-features=")
919                && a.contains("DisableLoadExtensionCommandLineSwitch")
920        }));
921        if let Some(ref dir) = result.temp_user_data_dir {
922            let _ = std::fs::remove_dir_all(dir);
923        }
924    }
925
926    #[test]
927    fn test_build_args_ignore_https_errors_includes_flag() {
928        let opts = LaunchOptions {
929            ignore_https_errors: true,
930            ..Default::default()
931        };
932        let result = build_chrome_args(&opts).unwrap();
933        materialize_temp_user_data_dir_sync(&result).unwrap();
934        assert!(result
935            .args
936            .iter()
937            .any(|a| a == "--ignore-certificate-errors"));
938        if let Some(ref dir) = result.temp_user_data_dir {
939            let _ = std::fs::remove_dir_all(dir);
940        }
941    }
942
943    #[test]
944    fn test_build_args_ignore_https_errors_default_no_flag() {
945        let opts = LaunchOptions::default();
946        let result = build_chrome_args(&opts).unwrap();
947        materialize_temp_user_data_dir_sync(&result).unwrap();
948        assert!(!result
949            .args
950            .iter()
951            .any(|a| a == "--ignore-certificate-errors"));
952        if let Some(ref dir) = result.temp_user_data_dir {
953            let _ = std::fs::remove_dir_all(dir);
954        }
955    }
956}