Skip to main content

browser_automation_cli/native/cdp/
chrome.rs

1//! Chrome discovery + launch option args for chromiumoxide one-shot.
2#![allow(missing_docs)]
3//!
4//! PROIBIDO: dual spawn via Child/Command for Chrome production path.
5//! PROIBIDO: BrowserFetcher embutido no MVP (system Chrome only).
6//! Launch ownership: `oxide::launch_with_oxide` → `Browser::launch`.
7
8use std::io::Write;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12/// Options shared by CLI → BrowserManager → oxide launch.
13#[derive(Debug, Clone)]
14pub struct LaunchOptions {
15    pub headless: bool,
16    pub executable_path: Option<String>,
17    pub proxy: Option<String>,
18    pub proxy_bypass: Option<String>,
19    pub proxy_username: Option<String>,
20    pub proxy_password: Option<String>,
21    pub profile: Option<String>,
22    pub args: Vec<String>,
23    pub allow_file_access: bool,
24    pub extensions: Option<Vec<String>>,
25    pub storage_state: Option<String>,
26    pub user_agent: Option<String>,
27    pub ignore_https_errors: bool,
28    pub color_scheme: Option<String>,
29    pub download_path: Option<String>,
30    /// Hide native scrollbars in headless Chromium screenshots.
31    pub hide_scrollbars: bool,
32    /// Initial viewport for `--window-size`.
33    pub viewport_size: Option<(u32, u32)>,
34    /// When true, omit mock keychain flags (real system keychain).
35    pub use_real_keychain: bool,
36    /// Enable WebGPU (SwiftShader on Linux when needed).
37    pub webgpu: bool,
38    /// Opt-out Xvfb for headed Linux (legacy flag retained for CLI compat).
39    pub no_xvfb: bool,
40    /// Restrict WebRTC to proxied transports.
41    pub restrict_webrtc: bool,
42}
43
44impl Default for LaunchOptions {
45    fn default() -> Self {
46        Self {
47            headless: true,
48            executable_path: None,
49            proxy: None,
50            proxy_bypass: None,
51            proxy_username: None,
52            proxy_password: None,
53            profile: None,
54            args: Vec::new(),
55            allow_file_access: false,
56            extensions: None,
57            storage_state: None,
58            user_agent: None,
59            ignore_https_errors: false,
60            color_scheme: None,
61            download_path: None,
62            hide_scrollbars: true,
63            viewport_size: None,
64            use_real_keychain: false,
65            webgpu: false,
66            no_xvfb: false,
67            restrict_webrtc: false,
68        }
69    }
70}
71
72/// Resolved Chrome CLI args + user-data-dir for chromiumoxide launch.
73pub(crate) struct ChromeArgs {
74    pub args: Vec<String>,
75    pub user_data_dir: PathBuf,
76    pub temp_user_data_dir: Option<PathBuf>,
77}
78
79/// Build Chrome flags from [`LaunchOptions`] (used by oxide one-shot path).
80pub(crate) fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
81    // Chrome only honors the last --enable-features switch.
82    let mut enable_features: Vec<String> = vec![
83        "NetworkService".to_string(),
84        "NetworkServiceInProcess".to_string(),
85    ];
86    if options.webgpu && cfg!(target_os = "linux") {
87        enable_features.push("Vulkan".to_string());
88    }
89
90    let mut user_args: Vec<String> = Vec::new();
91    for arg in &options.args {
92        if let Some(values) = arg.strip_prefix("--enable-features=") {
93            for feature in values.split(',').map(str::trim).filter(|f| !f.is_empty()) {
94                if !enable_features.iter().any(|f| f == feature) {
95                    enable_features.push(feature.to_string());
96                }
97            }
98        } else {
99            user_args.push(arg.clone());
100        }
101    }
102
103    // Chrome only honors the last --disable-features switch — keep a single list.
104    let mut disable_features: Vec<String> = vec!["Translate".to_string()];
105    let has_extensions = options
106        .extensions
107        .as_ref()
108        .is_some_and(|exts| !exts.is_empty());
109    if has_extensions {
110        // Chrome 127+ gates --load-extension behind this feature (must disable the gate).
111        disable_features.push("DisableLoadExtensionCommandLineSwitch".to_string());
112    }
113
114    let mut args = vec![
115        "--remote-debugging-port=0".to_string(),
116        "--no-first-run".to_string(),
117        "--no-default-browser-check".to_string(),
118        "--disable-background-networking".to_string(),
119        "--disable-backgrounding-occluded-windows".to_string(),
120        "--disable-component-update".to_string(),
121        "--disable-default-apps".to_string(),
122        "--disable-hang-monitor".to_string(),
123        "--disable-popup-blocking".to_string(),
124        "--disable-prompt-on-repost".to_string(),
125        "--disable-sync".to_string(),
126        format!("--disable-features={}", disable_features.join(",")),
127        format!("--enable-features={}", enable_features.join(",")),
128        "--metrics-recording-only".to_string(),
129    ];
130
131    if options.webgpu {
132        args.push("--enable-unsafe-webgpu".to_string());
133        if cfg!(target_os = "linux") {
134            args.push("--use-angle=vulkan".to_string());
135            args.push("--use-vulkan=swiftshader".to_string());
136            args.push("--use-webgpu-adapter=swiftshader".to_string());
137            args.push("--disable-vulkan-surface".to_string());
138        }
139    }
140
141    if !options.use_real_keychain {
142        args.push("--password-store=basic".to_string());
143        args.push("--use-mock-keychain".to_string());
144    }
145
146    if options.headless && !has_extensions {
147        args.push("--headless=new".to_string());
148        if options.hide_scrollbars {
149            args.push("--hide-scrollbars".to_string());
150        }
151        args.push("--enable-unsafe-swiftshader".to_string());
152    }
153
154    if let Some(ref proxy) = options.proxy {
155        args.push(format!("--proxy-server={}", proxy));
156    }
157
158    if let Some(ref bypass) = options.proxy_bypass {
159        args.push(format!("--proxy-bypass-list={}", bypass));
160    }
161
162    let (user_data_dir, temp_user_data_dir) = if let Some(ref profile) = options.profile {
163        let expanded = expand_tilde(profile);
164        let dir = PathBuf::from(&expanded);
165        args.push(format!("--user-data-dir={}", expanded));
166        (dir, None)
167    } else {
168        let dir = std::env::temp_dir().join(format!(
169            "browser-automation-cli-chrome-{}",
170            uuid::Uuid::new_v4()
171        ));
172        std::fs::create_dir_all(&dir)
173            .map_err(|e| format!("Failed to create temp profile dir: {}", e))?;
174        args.push(format!("--user-data-dir={}", dir.display()));
175        (dir.clone(), Some(dir))
176    };
177
178    if options.ignore_https_errors {
179        args.push("--ignore-certificate-errors".to_string());
180    }
181
182    if options.allow_file_access {
183        args.push("--allow-file-access-from-files".to_string());
184        args.push("--allow-file-access".to_string());
185    }
186
187    if let Some(ref exts) = options.extensions {
188        if !exts.is_empty() {
189            let ext_list = exts.join(",");
190            args.push(format!("--load-extension={}", ext_list));
191            args.push(format!("--disable-extensions-except={}", ext_list));
192        }
193    }
194
195    let has_window_size = options
196        .args
197        .iter()
198        .any(|a| a.starts_with("--start-maximized") || a.starts_with("--window-size="));
199
200    if !has_window_size && options.headless && !has_extensions {
201        let (w, h) = options.viewport_size.unwrap_or((1280, 720));
202        args.push(format!("--window-size={},{}", w, h));
203    }
204
205    args.extend(user_args);
206
207    if options.restrict_webrtc {
208        args.retain(|arg| !arg.starts_with("--force-webrtc-ip-handling-policy="));
209        args.push("--force-webrtc-ip-handling-policy=disable_non_proxied_udp".to_string());
210    }
211
212    if should_disable_sandbox(&args) {
213        args.push("--no-sandbox".to_string());
214    }
215
216    if should_disable_dev_shm(&args) {
217        args.push("--disable-dev-shm-usage".to_string());
218    }
219
220    Ok(ChromeArgs {
221        args,
222        user_data_dir,
223        temp_user_data_dir,
224    })
225}
226
227/// Locate system Chrome/Chromium (product cache → PATH → Playwright/Puppeteer caches).
228pub fn find_chrome() -> Option<PathBuf> {
229    if let Some(p) = crate::install::find_installed_chrome() {
230        return Some(p);
231    }
232
233    let cache_dir = crate::install::get_browsers_dir();
234    if cache_dir.exists() {
235        let _ = writeln!(
236            std::io::stderr(),
237            "Warning: Chrome cache directory exists ({}) but no Chrome binary found inside. \
238             Falling back to system Chrome (product browsers cache empty).",
239            cache_dir.display()
240        );
241    }
242
243    #[cfg(target_os = "macos")]
244    {
245        let candidates = [
246            "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
247            "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
248            "/Applications/Chromium.app/Contents/MacOS/Chromium",
249            "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
250        ];
251        for c in &candidates {
252            let p = PathBuf::from(c);
253            if p.exists() {
254                return Some(p);
255            }
256        }
257    }
258
259    #[cfg(target_os = "linux")]
260    {
261        let candidates = [
262            "google-chrome",
263            "google-chrome-stable",
264            "chromium-browser",
265            "chromium",
266            "brave-browser",
267            "brave-browser-stable",
268        ];
269        for name in &candidates {
270            if let Ok(output) = Command::new("which").arg(name).output() {
271                if output.status.success() {
272                    let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
273                    if !path.is_empty() {
274                        return Some(PathBuf::from(path));
275                    }
276                }
277            }
278        }
279    }
280
281    #[cfg(target_os = "windows")]
282    {
283        let candidates = [
284            r"C:\Program Files\Google\Chrome\Application\chrome.exe",
285            r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
286        ];
287        if let Ok(local) = std::env::var("LOCALAPPDATA") {
288            let chrome = PathBuf::from(&local).join(r"Google\Chrome\Application\chrome.exe");
289            if chrome.exists() {
290                return Some(chrome);
291            }
292            let brave =
293                PathBuf::from(&local).join(r"BraveSoftware\Brave-Browser\Application\brave.exe");
294            if brave.exists() {
295                return Some(brave);
296            }
297        }
298        for c in &candidates {
299            let p = PathBuf::from(c);
300            if p.exists() {
301                return Some(p);
302            }
303        }
304    }
305
306    if let Some(p) = find_puppeteer_chrome() {
307        return Some(p);
308    }
309    if let Some(p) = find_playwright_chromium() {
310        return Some(p);
311    }
312
313    None
314}
315
316fn should_disable_sandbox(existing_args: &[String]) -> bool {
317    if existing_args.iter().any(|a| a == "--no-sandbox") {
318        return false;
319    }
320    if std::env::var("CI").is_ok() {
321        return true;
322    }
323    #[cfg(unix)]
324    {
325        if unsafe { libc::geteuid() } == 0 {
326            return true;
327        }
328        if Path::new("/.dockerenv").exists() {
329            return true;
330        }
331        if Path::new("/run/.containerenv").exists() {
332            return true;
333        }
334        if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup") {
335            if cgroup.contains("docker") || cgroup.contains("kubepods") || cgroup.contains("lxc") {
336                return true;
337            }
338        }
339    }
340    false
341}
342
343fn should_disable_dev_shm(existing_args: &[String]) -> bool {
344    if existing_args.iter().any(|a| a == "--disable-dev-shm-usage") {
345        return false;
346    }
347    if std::env::var("CI").is_ok() {
348        return true;
349    }
350    #[cfg(unix)]
351    {
352        if unsafe { libc::geteuid() } == 0 {
353            return true;
354        }
355        if Path::new("/.dockerenv").exists() || Path::new("/run/.containerenv").exists() {
356            return true;
357        }
358        if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup") {
359            if cgroup.contains("docker") || cgroup.contains("kubepods") || cgroup.contains("lxc") {
360                return true;
361            }
362        }
363    }
364    false
365}
366
367fn find_puppeteer_chrome() -> Option<PathBuf> {
368    let mut search_dirs = Vec::new();
369    if let Ok(custom) = std::env::var("PUPPETEER_CACHE_DIR") {
370        search_dirs.push(PathBuf::from(custom).join("chrome"));
371    }
372    if let Some(home) = dirs::home_dir() {
373        search_dirs.push(home.join(".cache/puppeteer/chrome"));
374    }
375    for dir in &search_dirs {
376        if !dir.is_dir() {
377            continue;
378        }
379        if let Ok(entries) = std::fs::read_dir(dir) {
380            let mut matches: Vec<PathBuf> = entries
381                .filter_map(|e| e.ok())
382                .filter(|e| e.path().is_dir())
383                .filter_map(|e| {
384                    let candidate = build_puppeteer_binary_path(&e.path());
385                    if candidate.exists() {
386                        Some(candidate)
387                    } else {
388                        None
389                    }
390                })
391                .collect();
392            matches.sort();
393            matches.reverse();
394            if let Some(p) = matches.into_iter().next() {
395                return Some(p);
396            }
397        }
398    }
399    None
400}
401
402#[cfg(target_os = "linux")]
403fn build_puppeteer_binary_path(version_dir: &Path) -> PathBuf {
404    version_dir.join("chrome-linux64/chrome")
405}
406
407#[cfg(target_os = "macos")]
408fn build_puppeteer_binary_path(version_dir: &Path) -> PathBuf {
409    let arm = version_dir.join(
410        "chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
411    );
412    if arm.exists() {
413        return arm;
414    }
415    version_dir.join(
416        "chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
417    )
418}
419
420#[cfg(target_os = "windows")]
421fn build_puppeteer_binary_path(version_dir: &Path) -> PathBuf {
422    version_dir.join(r"chrome-win64\chrome.exe")
423}
424
425#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
426fn build_puppeteer_binary_path(version_dir: &Path) -> PathBuf {
427    version_dir.join("chrome")
428}
429
430fn find_playwright_chromium() -> Option<PathBuf> {
431    let mut search_dirs = Vec::new();
432    if let Ok(custom) = std::env::var("PLAYWRIGHT_BROWSERS_PATH") {
433        search_dirs.push(PathBuf::from(custom));
434    }
435    if let Some(home) = dirs::home_dir() {
436        search_dirs.push(home.join(".cache/ms-playwright"));
437    }
438    for dir in &search_dirs {
439        if !dir.is_dir() {
440            continue;
441        }
442        if let Ok(entries) = std::fs::read_dir(dir) {
443            let mut matches: Vec<PathBuf> = entries
444                .filter_map(|e| e.ok())
445                .filter(|e| {
446                    e.file_name()
447                        .to_str()
448                        .map(|n| n.starts_with("chromium-"))
449                        .unwrap_or(false)
450                })
451                .filter_map(|e| {
452                    let candidate = build_playwright_binary_path(&e.path());
453                    if candidate.exists() {
454                        Some(candidate)
455                    } else {
456                        None
457                    }
458                })
459                .collect();
460            matches.sort();
461            matches.reverse();
462            if let Some(p) = matches.into_iter().next() {
463                return Some(p);
464            }
465        }
466    }
467    None
468}
469
470#[cfg(target_os = "linux")]
471fn build_playwright_binary_path(chromium_dir: &Path) -> PathBuf {
472    let standard = chromium_dir.join("chrome-linux/chrome");
473    if standard.exists() {
474        return standard;
475    }
476    chromium_dir.join("chrome-linux64/chrome")
477}
478
479#[cfg(target_os = "macos")]
480fn build_playwright_binary_path(chromium_dir: &Path) -> PathBuf {
481    chromium_dir.join("chrome-mac/Chromium.app/Contents/MacOS/Chromium")
482}
483
484#[cfg(target_os = "windows")]
485fn build_playwright_binary_path(chromium_dir: &Path) -> PathBuf {
486    chromium_dir.join("chrome-win/chrome.exe")
487}
488
489#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
490fn build_playwright_binary_path(chromium_dir: &Path) -> PathBuf {
491    chromium_dir.join("chrome")
492}
493
494fn expand_tilde(path: &str) -> String {
495    if let Some(rest) = path.strip_prefix('~') {
496        if let Some(home) = dirs::home_dir() {
497            return home
498                .join(rest.strip_prefix('/').unwrap_or(rest))
499                .to_string_lossy()
500                .to_string();
501        }
502    }
503    path.to_string()
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use crate::test_utils::EnvGuard;
510
511    #[test]
512    fn test_find_chrome_returns_some_on_host() {
513        // Hosts without Chrome still exercise the function without panic.
514        let _ = find_chrome();
515    }
516
517    #[test]
518    fn test_expand_tilde() {
519        if dirs::home_dir().is_some() {
520            let expanded = expand_tilde("~/foo");
521            assert!(!expanded.starts_with('~'));
522            assert!(expanded.ends_with("foo") || expanded.ends_with("foo/"));
523        }
524    }
525
526    #[test]
527    fn test_expand_tilde_no_tilde() {
528        assert_eq!(expand_tilde("/tmp/x"), "/tmp/x");
529    }
530
531    #[test]
532    fn test_should_disable_sandbox_skips_if_already_set() {
533        assert!(!should_disable_sandbox(&["--no-sandbox".to_string()]));
534    }
535
536    #[test]
537    fn test_find_playwright_chromium_nonexistent() {
538        let g = EnvGuard::new(&["PLAYWRIGHT_BROWSERS_PATH"]);
539        g.set("PLAYWRIGHT_BROWSERS_PATH", "/nonexistent-playwright-path");
540        let result = find_playwright_chromium();
541        assert!(result.is_none());
542    }
543
544    #[test]
545    fn test_build_args_headless_includes_headless_flag() {
546        let opts = LaunchOptions {
547            headless: true,
548            ..Default::default()
549        };
550        let result = build_chrome_args(&opts).unwrap();
551        assert!(result.args.iter().any(|a| a == "--headless=new"));
552        assert!(result.args.iter().any(|a| a == "--hide-scrollbars"));
553        assert!(result
554            .args
555            .iter()
556            .any(|a| a == "--enable-unsafe-swiftshader"));
557        assert!(result.args.iter().any(|a| a == "--window-size=1280,720"));
558        assert!(result.temp_user_data_dir.is_some());
559        let dir = result.temp_user_data_dir.unwrap();
560        assert!(dir.exists());
561        let _ = std::fs::remove_dir_all(&dir);
562    }
563
564    #[test]
565    fn test_build_args_headed_no_headless_flag() {
566        let opts = LaunchOptions {
567            headless: false,
568            ..Default::default()
569        };
570        let result = build_chrome_args(&opts).unwrap();
571        assert!(!result.args.iter().any(|a| a.contains("--headless")));
572        assert!(!result.args.iter().any(|a| a == "--hide-scrollbars"));
573        assert!(result.temp_user_data_dir.is_some());
574        if let Some(ref dir) = result.temp_user_data_dir {
575            let _ = std::fs::remove_dir_all(dir);
576        }
577    }
578
579    #[test]
580    fn test_build_args_temp_user_data_dir_created() {
581        let opts = LaunchOptions::default();
582        let result = build_chrome_args(&opts).unwrap();
583        let dir = result.temp_user_data_dir.as_ref().unwrap();
584        assert!(dir.exists());
585        assert!(result
586            .args
587            .iter()
588            .any(|a| a.starts_with("--user-data-dir=")));
589        let _ = std::fs::remove_dir_all(dir);
590    }
591
592    #[test]
593    fn test_build_args_profile_no_temp_dir() {
594        let opts = LaunchOptions {
595            profile: Some("/tmp/my-profile".to_string()),
596            ..Default::default()
597        };
598        let result = build_chrome_args(&opts).unwrap();
599        assert!(result.temp_user_data_dir.is_none());
600        assert!(result
601            .args
602            .iter()
603            .any(|a| a == "--user-data-dir=/tmp/my-profile"));
604    }
605
606    #[test]
607    fn test_build_args_custom_window_size_not_overridden() {
608        let opts = LaunchOptions {
609            headless: true,
610            args: vec!["--window-size=1920,1080".to_string()],
611            ..Default::default()
612        };
613        let result = build_chrome_args(&opts).unwrap();
614        assert!(!result.args.iter().any(|a| a == "--window-size=1280,720"));
615        assert!(result.args.iter().any(|a| a == "--window-size=1920,1080"));
616        if let Some(ref dir) = result.temp_user_data_dir {
617            let _ = std::fs::remove_dir_all(dir);
618        }
619    }
620
621    #[test]
622    fn test_build_args_hide_scrollbars_false_suppresses_default_hide_scrollbars() {
623        let opts = LaunchOptions {
624            headless: true,
625            hide_scrollbars: false,
626            ..Default::default()
627        };
628        let result = build_chrome_args(&opts).unwrap();
629        assert!(!result.args.iter().any(|a| a == "--hide-scrollbars"));
630        if let Some(ref dir) = result.temp_user_data_dir {
631            let _ = std::fs::remove_dir_all(dir);
632        }
633    }
634
635    #[test]
636    fn test_build_args_start_maximized_suppresses_default_window_size() {
637        let opts = LaunchOptions {
638            headless: true,
639            args: vec!["--start-maximized".to_string()],
640            ..Default::default()
641        };
642        let result = build_chrome_args(&opts).unwrap();
643        assert!(!result.args.iter().any(|a| a == "--window-size=1280,720"));
644        assert!(result.args.iter().any(|a| a == "--start-maximized"));
645        if let Some(ref dir) = result.temp_user_data_dir {
646            let _ = std::fs::remove_dir_all(dir);
647        }
648    }
649
650    #[test]
651    fn test_build_args_disables_translate() {
652        let opts = LaunchOptions::default();
653        let result = build_chrome_args(&opts).unwrap();
654        assert!(result
655            .args
656            .iter()
657            .any(|a| a.contains("--disable-features") && a.contains("Translate")));
658        if let Some(ref dir) = result.temp_user_data_dir {
659            let _ = std::fs::remove_dir_all(dir);
660        }
661    }
662
663    #[test]
664    fn test_build_args_webgpu_default_off() {
665        let opts = LaunchOptions::default();
666        let result = build_chrome_args(&opts).unwrap();
667        assert!(!result.args.iter().any(|a| a == "--enable-unsafe-webgpu"));
668        if let Some(ref dir) = result.temp_user_data_dir {
669            let _ = std::fs::remove_dir_all(dir);
670        }
671    }
672
673    #[test]
674    fn test_build_args_restrict_webrtc_enforces_safe_policy() {
675        let opts = LaunchOptions {
676            restrict_webrtc: true,
677            args: vec!["--force-webrtc-ip-handling-policy=default".to_string()],
678            ..Default::default()
679        };
680        let result = build_chrome_args(&opts).unwrap();
681        let policies: Vec<&String> = result
682            .args
683            .iter()
684            .filter(|arg| arg.starts_with("--force-webrtc-ip-handling-policy="))
685            .collect();
686        assert_eq!(
687            policies,
688            vec![&"--force-webrtc-ip-handling-policy=disable_non_proxied_udp".to_string()]
689        );
690        if let Some(ref dir) = result.temp_user_data_dir {
691            let _ = std::fs::remove_dir_all(dir);
692        }
693    }
694
695    #[test]
696    fn test_build_args_webgpu_includes_webgpu_flags() {
697        let opts = LaunchOptions {
698            webgpu: true,
699            ..Default::default()
700        };
701        let result = build_chrome_args(&opts).unwrap();
702        assert!(result.args.iter().any(|a| a == "--enable-unsafe-webgpu"));
703        if cfg!(target_os = "linux") {
704            assert!(result.args.iter().any(|a| a == "--use-angle=vulkan"));
705            assert!(result.args.iter().any(|a| a == "--use-vulkan=swiftshader"));
706        }
707        if let Some(ref dir) = result.temp_user_data_dir {
708            let _ = std::fs::remove_dir_all(dir);
709        }
710    }
711
712    #[test]
713    fn test_build_args_merges_user_enable_features() {
714        let opts = LaunchOptions {
715            webgpu: true,
716            args: vec![
717                "--enable-features=Foo,Bar".to_string(),
718                "--some-other-flag".to_string(),
719                "--enable-features=NetworkService".to_string(),
720            ],
721            ..Default::default()
722        };
723        let result = build_chrome_args(&opts).unwrap();
724        let flags: Vec<&String> = result
725            .args
726            .iter()
727            .filter(|a| a.starts_with("--enable-features="))
728            .collect();
729        assert_eq!(flags.len(), 1);
730        let features: Vec<&str> = flags[0]
731            .strip_prefix("--enable-features=")
732            .unwrap()
733            .split(',')
734            .collect();
735        assert!(features.contains(&"NetworkService"));
736        assert!(features.contains(&"Foo"));
737        assert!(features.contains(&"Bar"));
738        assert!(result.args.iter().any(|a| a == "--some-other-flag"));
739        if let Some(ref dir) = result.temp_user_data_dir {
740            let _ = std::fs::remove_dir_all(dir);
741        }
742    }
743
744    #[test]
745    fn test_build_args_single_enable_features_flag() {
746        let opts = LaunchOptions {
747            webgpu: true,
748            ..Default::default()
749        };
750        let result = build_chrome_args(&opts).unwrap();
751        let count = result
752            .args
753            .iter()
754            .filter(|a| a.starts_with("--enable-features="))
755            .count();
756        assert_eq!(count, 1);
757        if let Some(ref dir) = result.temp_user_data_dir {
758            let _ = std::fs::remove_dir_all(dir);
759        }
760    }
761
762    #[test]
763    fn test_build_args_headless_with_extensions_skips_headless_flag() {
764        let opts = LaunchOptions {
765            headless: true,
766            extensions: Some(vec!["/tmp/ext".to_string()]),
767            ..Default::default()
768        };
769        let result = build_chrome_args(&opts).unwrap();
770        assert!(!result.args.iter().any(|a| a.contains("--headless")));
771        assert!(result
772            .args
773            .iter()
774            .any(|a| a.starts_with("--load-extension=")));
775        assert!(result.args.iter().any(|a| {
776            a.starts_with("--disable-features=")
777                && a.contains("DisableLoadExtensionCommandLineSwitch")
778        }));
779        if let Some(ref dir) = result.temp_user_data_dir {
780            let _ = std::fs::remove_dir_all(dir);
781        }
782    }
783
784    #[test]
785    fn test_build_args_ignore_https_errors_includes_flag() {
786        let opts = LaunchOptions {
787            ignore_https_errors: true,
788            ..Default::default()
789        };
790        let result = build_chrome_args(&opts).unwrap();
791        assert!(result
792            .args
793            .iter()
794            .any(|a| a == "--ignore-certificate-errors"));
795        if let Some(ref dir) = result.temp_user_data_dir {
796            let _ = std::fs::remove_dir_all(dir);
797        }
798    }
799
800    #[test]
801    fn test_build_args_ignore_https_errors_default_no_flag() {
802        let opts = LaunchOptions::default();
803        let result = build_chrome_args(&opts).unwrap();
804        assert!(!result
805            .args
806            .iter()
807            .any(|a| a == "--ignore-certificate-errors"));
808        if let Some(ref dir) = result.temp_user_data_dir {
809            let _ = std::fs::remove_dir_all(dir);
810        }
811    }
812}