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//! FORBIDDEN: dual spawn via Child/Command for Chrome production path.
5//! FORBIDDEN: BrowserFetcher embedded 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        // GAP-016 / PRD §5F: do not enable metrics subsystem (even "recording-only").
129        // Prefer hard-disable of metrics/crash reporter where Chromium honors flags.
130        "--disable-metrics".to_string(),
131        "--disable-metrics-reporting".to_string(),
132        "--disable-breakpad".to_string(),
133        "--disable-crash-reporter".to_string(),
134        "--disable-domain-reliability".to_string(),
135        "--disable-client-side-phishing-detection".to_string(),
136        "--disable-component-extensions-with-background-pages".to_string(),
137    ];
138
139    if options.webgpu {
140        args.push("--enable-unsafe-webgpu".to_string());
141        if cfg!(target_os = "linux") {
142            args.push("--use-angle=vulkan".to_string());
143            args.push("--use-vulkan=swiftshader".to_string());
144            args.push("--use-webgpu-adapter=swiftshader".to_string());
145            args.push("--disable-vulkan-surface".to_string());
146        }
147    }
148
149    if !options.use_real_keychain {
150        args.push("--password-store=basic".to_string());
151        args.push("--use-mock-keychain".to_string());
152    }
153
154    if options.headless && !has_extensions {
155        args.push("--headless=new".to_string());
156        if options.hide_scrollbars {
157            args.push("--hide-scrollbars".to_string());
158        }
159        args.push("--enable-unsafe-swiftshader".to_string());
160    }
161
162    if let Some(ref proxy) = options.proxy {
163        args.push(format!("--proxy-server={}", proxy));
164    }
165
166    if let Some(ref bypass) = options.proxy_bypass {
167        args.push(format!("--proxy-bypass-list={}", bypass));
168    }
169
170    let (user_data_dir, temp_user_data_dir) = if let Some(ref profile) = options.profile {
171        let expanded = expand_tilde(profile);
172        let dir = PathBuf::from(&expanded);
173        args.push(format!("--user-data-dir={}", expanded));
174        (dir, None)
175    } else {
176        let dir = std::env::temp_dir().join(format!(
177            "browser-automation-cli-chrome-{}",
178            uuid::Uuid::new_v4()
179        ));
180        std::fs::create_dir_all(&dir)
181            .map_err(|e| format!("Failed to create temp profile dir: {}", e))?;
182        args.push(format!("--user-data-dir={}", dir.display()));
183        (dir.clone(), Some(dir))
184    };
185
186    if options.ignore_https_errors {
187        args.push("--ignore-certificate-errors".to_string());
188    }
189
190    if options.allow_file_access {
191        args.push("--allow-file-access-from-files".to_string());
192        args.push("--allow-file-access".to_string());
193    }
194
195    if let Some(ref exts) = options.extensions {
196        if !exts.is_empty() {
197            let ext_list = exts.join(",");
198            args.push(format!("--load-extension={}", ext_list));
199            args.push(format!("--disable-extensions-except={}", ext_list));
200        }
201    }
202
203    let has_window_size = options
204        .args
205        .iter()
206        .any(|a| a.starts_with("--start-maximized") || a.starts_with("--window-size="));
207
208    if !has_window_size && options.headless && !has_extensions {
209        let (w, h) = options.viewport_size.unwrap_or((1280, 720));
210        args.push(format!("--window-size={},{}", w, h));
211    }
212
213    args.extend(user_args);
214
215    if options.restrict_webrtc {
216        args.retain(|arg| !arg.starts_with("--force-webrtc-ip-handling-policy="));
217        args.push("--force-webrtc-ip-handling-policy=disable_non_proxied_udp".to_string());
218    }
219
220    if should_disable_sandbox(&args) {
221        args.push("--no-sandbox".to_string());
222    }
223
224    if should_disable_dev_shm(&args) {
225        args.push("--disable-dev-shm-usage".to_string());
226    }
227
228    Ok(ChromeArgs {
229        args,
230        user_data_dir,
231        temp_user_data_dir,
232    })
233}
234
235/// Locate system Chrome/Chromium (XDG chrome_path → product cache → PATH → known install paths).
236///
237/// Product settings never read third-party env vars (Puppeteer/Playwright/CI).
238pub fn find_chrome() -> Option<PathBuf> {
239    if let Some(p) = crate::xdg::chrome_path_from_config() {
240        let path = PathBuf::from(p);
241        if path.exists() {
242            return Some(path);
243        }
244    }
245    if let Some(p) = crate::install::find_installed_chrome() {
246        return Some(p);
247    }
248
249    let cache_dir = crate::install::get_browsers_dir();
250    if cache_dir.exists() {
251        let _ = writeln!(
252            std::io::stderr(),
253            "Warning: Chrome cache directory exists ({}) but no Chrome binary found inside. \
254             Falling back to system Chrome (product browsers cache empty).",
255            cache_dir.display()
256        );
257    }
258
259    #[cfg(target_os = "macos")]
260    {
261        let candidates = [
262            "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
263            "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
264            "/Applications/Chromium.app/Contents/MacOS/Chromium",
265            "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
266        ];
267        for c in &candidates {
268            let p = PathBuf::from(c);
269            if p.exists() {
270                return Some(p);
271            }
272        }
273    }
274
275    #[cfg(target_os = "linux")]
276    {
277        let candidates = [
278            "google-chrome",
279            "google-chrome-stable",
280            "chromium-browser",
281            "chromium",
282            "brave-browser",
283            "brave-browser-stable",
284        ];
285        for name in &candidates {
286            if let Ok(output) = Command::new("which").arg(name).output() {
287                if output.status.success() {
288                    let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
289                    if !path.is_empty() {
290                        return Some(PathBuf::from(path));
291                    }
292                }
293            }
294        }
295    }
296
297    #[cfg(target_os = "windows")]
298    {
299        let candidates = [
300            r"C:\Program Files\Google\Chrome\Application\chrome.exe",
301            r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
302        ];
303        if let Ok(local) = std::env::var("LOCALAPPDATA") {
304            let chrome = PathBuf::from(&local).join(r"Google\Chrome\Application\chrome.exe");
305            if chrome.exists() {
306                return Some(chrome);
307            }
308            let brave =
309                PathBuf::from(&local).join(r"BraveSoftware\Brave-Browser\Application\brave.exe");
310            if brave.exists() {
311                return Some(brave);
312            }
313        }
314        for c in &candidates {
315            let p = PathBuf::from(c);
316            if p.exists() {
317                return Some(p);
318            }
319        }
320    }
321
322    if let Some(p) = find_puppeteer_chrome() {
323        return Some(p);
324    }
325    if let Some(p) = find_playwright_chromium() {
326        return Some(p);
327    }
328
329    None
330}
331
332fn should_disable_sandbox(existing_args: &[String]) -> bool {
333    if existing_args.iter().any(|a| a == "--no-sandbox") {
334        return false;
335    }
336    // Container/root detection only (no product CI env var).
337    #[cfg(unix)]
338    {
339        if unsafe { libc::geteuid() } == 0 {
340            return true;
341        }
342        if Path::new("/.dockerenv").exists() {
343            return true;
344        }
345        if Path::new("/run/.containerenv").exists() {
346            return true;
347        }
348        if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup") {
349            if cgroup.contains("docker") || cgroup.contains("kubepods") || cgroup.contains("lxc") {
350                return true;
351            }
352        }
353    }
354    false
355}
356
357fn should_disable_dev_shm(existing_args: &[String]) -> bool {
358    if existing_args.iter().any(|a| a == "--disable-dev-shm-usage") {
359        return false;
360    }
361    #[cfg(unix)]
362    {
363        if unsafe { libc::geteuid() } == 0 {
364            return true;
365        }
366        if Path::new("/.dockerenv").exists() || Path::new("/run/.containerenv").exists() {
367            return true;
368        }
369        if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup") {
370            if cgroup.contains("docker") || cgroup.contains("kubepods") || cgroup.contains("lxc") {
371                return true;
372            }
373        }
374    }
375    false
376}
377
378/// Scan product XDG browsers cache and known home cache dirs (no third-party env vars).
379fn find_puppeteer_chrome() -> Option<PathBuf> {
380    let mut search_dirs = Vec::new();
381    if let Ok(bd) = crate::xdg::browsers_dir() {
382        search_dirs.push(bd);
383    }
384    if let Some(home) = dirs::home_dir() {
385        // Optional local caches under home (not env-driven).
386        search_dirs.push(home.join(".cache/puppeteer/chrome"));
387        search_dirs.push(home.join(".cache/ms-playwright"));
388    }
389    for dir in &search_dirs {
390        if !dir.is_dir() {
391            continue;
392        }
393        if let Ok(entries) = std::fs::read_dir(dir) {
394            let mut matches: Vec<PathBuf> = entries
395                .filter_map(|e| e.ok())
396                .filter(|e| e.path().is_dir())
397                .filter_map(|e| {
398                    let path = e.path();
399                    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
400                    if name.starts_with("chromium-") {
401                        let linux = path.join("chrome-linux64/chrome");
402                        if linux.exists() {
403                            return Some(linux);
404                        }
405                        let win = path.join("chrome-win64/chrome.exe");
406                        if win.exists() {
407                            return Some(win);
408                        }
409                    }
410                    let candidate = build_puppeteer_binary_path(&path);
411                    if candidate.exists() {
412                        Some(candidate)
413                    } else {
414                        None
415                    }
416                })
417                .collect();
418            matches.sort();
419            matches.reverse();
420            if let Some(p) = matches.into_iter().next() {
421                return Some(p);
422            }
423        }
424    }
425    None
426}
427
428#[cfg(target_os = "linux")]
429fn build_puppeteer_binary_path(version_dir: &Path) -> PathBuf {
430    version_dir.join("chrome-linux64/chrome")
431}
432
433#[cfg(target_os = "macos")]
434fn build_puppeteer_binary_path(version_dir: &Path) -> PathBuf {
435    let arm = version_dir.join(
436        "chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
437    );
438    if arm.exists() {
439        return arm;
440    }
441    version_dir.join(
442        "chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
443    )
444}
445
446#[cfg(target_os = "windows")]
447fn build_puppeteer_binary_path(version_dir: &Path) -> PathBuf {
448    version_dir.join(r"chrome-win64\chrome.exe")
449}
450
451#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
452fn build_puppeteer_binary_path(version_dir: &Path) -> PathBuf {
453    version_dir.join("chrome")
454}
455
456fn find_playwright_chromium() -> Option<PathBuf> {
457    // Home-local caches only (no PLAYWRIGHT_BROWSERS_PATH product env).
458    let mut search_dirs = Vec::new();
459    if let Some(home) = dirs::home_dir() {
460        search_dirs.push(home.join(".cache/ms-playwright"));
461    }
462    if let Ok(bd) = crate::xdg::browsers_dir() {
463        search_dirs.push(bd);
464    }
465    for dir in &search_dirs {
466        if !dir.is_dir() {
467            continue;
468        }
469        if let Ok(entries) = std::fs::read_dir(dir) {
470            let mut matches: Vec<PathBuf> = entries
471                .filter_map(|e| e.ok())
472                .filter(|e| {
473                    e.file_name()
474                        .to_str()
475                        .map(|n| n.starts_with("chromium-"))
476                        .unwrap_or(false)
477                })
478                .filter_map(|e| {
479                    let candidate = build_playwright_binary_path(&e.path());
480                    if candidate.exists() {
481                        Some(candidate)
482                    } else {
483                        None
484                    }
485                })
486                .collect();
487            matches.sort();
488            matches.reverse();
489            if let Some(p) = matches.into_iter().next() {
490                return Some(p);
491            }
492        }
493    }
494    None
495}
496
497#[cfg(target_os = "linux")]
498fn build_playwright_binary_path(chromium_dir: &Path) -> PathBuf {
499    let standard = chromium_dir.join("chrome-linux/chrome");
500    if standard.exists() {
501        return standard;
502    }
503    chromium_dir.join("chrome-linux64/chrome")
504}
505
506#[cfg(target_os = "macos")]
507fn build_playwright_binary_path(chromium_dir: &Path) -> PathBuf {
508    chromium_dir.join("chrome-mac/Chromium.app/Contents/MacOS/Chromium")
509}
510
511#[cfg(target_os = "windows")]
512fn build_playwright_binary_path(chromium_dir: &Path) -> PathBuf {
513    chromium_dir.join("chrome-win/chrome.exe")
514}
515
516#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
517fn build_playwright_binary_path(chromium_dir: &Path) -> PathBuf {
518    chromium_dir.join("chrome")
519}
520
521fn expand_tilde(path: &str) -> String {
522    if let Some(rest) = path.strip_prefix('~') {
523        if let Some(home) = dirs::home_dir() {
524            return home
525                .join(rest.strip_prefix('/').unwrap_or(rest))
526                .to_string_lossy()
527                .to_string();
528        }
529    }
530    path.to_string()
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536    use crate::test_utils::EnvGuard;
537
538    #[test]
539    fn test_find_chrome_returns_some_on_host() {
540        // Hosts without Chrome still exercise the function without panic.
541        let _ = find_chrome();
542    }
543
544    #[test]
545    fn test_expand_tilde() {
546        if dirs::home_dir().is_some() {
547            let expanded = expand_tilde("~/foo");
548            assert!(!expanded.starts_with('~'));
549            assert!(expanded.ends_with("foo") || expanded.ends_with("foo/"));
550        }
551    }
552
553    #[test]
554    fn test_expand_tilde_no_tilde() {
555        assert_eq!(expand_tilde("/tmp/x"), "/tmp/x");
556    }
557
558    #[test]
559    fn test_should_disable_sandbox_skips_if_already_set() {
560        assert!(!should_disable_sandbox(&["--no-sandbox".to_string()]));
561    }
562
563    #[test]
564    fn test_find_playwright_chromium_nonexistent() {
565        let g = EnvGuard::new(&["PLAYWRIGHT_BROWSERS_PATH"]);
566        g.set("PLAYWRIGHT_BROWSERS_PATH", "/nonexistent-playwright-path");
567        let result = find_playwright_chromium();
568        assert!(result.is_none());
569    }
570
571    #[test]
572    fn test_build_args_headless_includes_headless_flag() {
573        let opts = LaunchOptions {
574            headless: true,
575            ..Default::default()
576        };
577        let result = build_chrome_args(&opts).unwrap();
578        assert!(result.args.iter().any(|a| a == "--headless=new"));
579        assert!(result.args.iter().any(|a| a == "--hide-scrollbars"));
580        assert!(result
581            .args
582            .iter()
583            .any(|a| a == "--enable-unsafe-swiftshader"));
584        assert!(result.args.iter().any(|a| a == "--window-size=1280,720"));
585        assert!(result.temp_user_data_dir.is_some());
586        let dir = result.temp_user_data_dir.unwrap();
587        assert!(dir.exists());
588        let _ = std::fs::remove_dir_all(&dir);
589    }
590
591    #[test]
592    fn test_build_args_headed_no_headless_flag() {
593        let opts = LaunchOptions {
594            headless: false,
595            ..Default::default()
596        };
597        let result = build_chrome_args(&opts).unwrap();
598        assert!(!result.args.iter().any(|a| a.contains("--headless")));
599        assert!(!result.args.iter().any(|a| a == "--hide-scrollbars"));
600        assert!(result.temp_user_data_dir.is_some());
601        if let Some(ref dir) = result.temp_user_data_dir {
602            let _ = std::fs::remove_dir_all(dir);
603        }
604    }
605
606    #[test]
607    fn test_build_args_temp_user_data_dir_created() {
608        let opts = LaunchOptions::default();
609        let result = build_chrome_args(&opts).unwrap();
610        let dir = result.temp_user_data_dir.as_ref().unwrap();
611        assert!(dir.exists());
612        assert!(result
613            .args
614            .iter()
615            .any(|a| a.starts_with("--user-data-dir=")));
616        let _ = std::fs::remove_dir_all(dir);
617    }
618
619    #[test]
620    fn test_build_args_profile_no_temp_dir() {
621        let opts = LaunchOptions {
622            profile: Some("/tmp/my-profile".to_string()),
623            ..Default::default()
624        };
625        let result = build_chrome_args(&opts).unwrap();
626        assert!(result.temp_user_data_dir.is_none());
627        assert!(result
628            .args
629            .iter()
630            .any(|a| a == "--user-data-dir=/tmp/my-profile"));
631    }
632
633    #[test]
634    fn test_build_args_custom_window_size_not_overridden() {
635        let opts = LaunchOptions {
636            headless: true,
637            args: vec!["--window-size=1920,1080".to_string()],
638            ..Default::default()
639        };
640        let result = build_chrome_args(&opts).unwrap();
641        assert!(!result.args.iter().any(|a| a == "--window-size=1280,720"));
642        assert!(result.args.iter().any(|a| a == "--window-size=1920,1080"));
643        if let Some(ref dir) = result.temp_user_data_dir {
644            let _ = std::fs::remove_dir_all(dir);
645        }
646    }
647
648    #[test]
649    fn test_build_args_hide_scrollbars_false_suppresses_default_hide_scrollbars() {
650        let opts = LaunchOptions {
651            headless: true,
652            hide_scrollbars: false,
653            ..Default::default()
654        };
655        let result = build_chrome_args(&opts).unwrap();
656        assert!(!result.args.iter().any(|a| a == "--hide-scrollbars"));
657        if let Some(ref dir) = result.temp_user_data_dir {
658            let _ = std::fs::remove_dir_all(dir);
659        }
660    }
661
662    #[test]
663    fn test_build_args_start_maximized_suppresses_default_window_size() {
664        let opts = LaunchOptions {
665            headless: true,
666            args: vec!["--start-maximized".to_string()],
667            ..Default::default()
668        };
669        let result = build_chrome_args(&opts).unwrap();
670        assert!(!result.args.iter().any(|a| a == "--window-size=1280,720"));
671        assert!(result.args.iter().any(|a| a == "--start-maximized"));
672        if let Some(ref dir) = result.temp_user_data_dir {
673            let _ = std::fs::remove_dir_all(dir);
674        }
675    }
676
677    #[test]
678    fn test_build_args_disables_translate() {
679        let opts = LaunchOptions::default();
680        let result = build_chrome_args(&opts).unwrap();
681        assert!(result
682            .args
683            .iter()
684            .any(|a| a.contains("--disable-features") && a.contains("Translate")));
685        if let Some(ref dir) = result.temp_user_data_dir {
686            let _ = std::fs::remove_dir_all(dir);
687        }
688    }
689
690    #[test]
691    fn test_build_args_webgpu_default_off() {
692        let opts = LaunchOptions::default();
693        let result = build_chrome_args(&opts).unwrap();
694        assert!(!result.args.iter().any(|a| a == "--enable-unsafe-webgpu"));
695        if let Some(ref dir) = result.temp_user_data_dir {
696            let _ = std::fs::remove_dir_all(dir);
697        }
698    }
699
700    #[test]
701    fn test_build_args_restrict_webrtc_enforces_safe_policy() {
702        let opts = LaunchOptions {
703            restrict_webrtc: true,
704            args: vec!["--force-webrtc-ip-handling-policy=default".to_string()],
705            ..Default::default()
706        };
707        let result = build_chrome_args(&opts).unwrap();
708        let policies: Vec<&String> = result
709            .args
710            .iter()
711            .filter(|arg| arg.starts_with("--force-webrtc-ip-handling-policy="))
712            .collect();
713        assert_eq!(
714            policies,
715            vec![&"--force-webrtc-ip-handling-policy=disable_non_proxied_udp".to_string()]
716        );
717        if let Some(ref dir) = result.temp_user_data_dir {
718            let _ = std::fs::remove_dir_all(dir);
719        }
720    }
721
722    #[test]
723    fn test_build_args_webgpu_includes_webgpu_flags() {
724        let opts = LaunchOptions {
725            webgpu: true,
726            ..Default::default()
727        };
728        let result = build_chrome_args(&opts).unwrap();
729        assert!(result.args.iter().any(|a| a == "--enable-unsafe-webgpu"));
730        if cfg!(target_os = "linux") {
731            assert!(result.args.iter().any(|a| a == "--use-angle=vulkan"));
732            assert!(result.args.iter().any(|a| a == "--use-vulkan=swiftshader"));
733        }
734        if let Some(ref dir) = result.temp_user_data_dir {
735            let _ = std::fs::remove_dir_all(dir);
736        }
737    }
738
739    #[test]
740    fn test_build_args_merges_user_enable_features() {
741        let opts = LaunchOptions {
742            webgpu: true,
743            args: vec![
744                "--enable-features=Foo,Bar".to_string(),
745                "--some-other-flag".to_string(),
746                "--enable-features=NetworkService".to_string(),
747            ],
748            ..Default::default()
749        };
750        let result = build_chrome_args(&opts).unwrap();
751        let flags: Vec<&String> = result
752            .args
753            .iter()
754            .filter(|a| a.starts_with("--enable-features="))
755            .collect();
756        assert_eq!(flags.len(), 1);
757        let features: Vec<&str> = flags[0]
758            .strip_prefix("--enable-features=")
759            .unwrap()
760            .split(',')
761            .collect();
762        assert!(features.contains(&"NetworkService"));
763        assert!(features.contains(&"Foo"));
764        assert!(features.contains(&"Bar"));
765        assert!(result.args.iter().any(|a| a == "--some-other-flag"));
766        if let Some(ref dir) = result.temp_user_data_dir {
767            let _ = std::fs::remove_dir_all(dir);
768        }
769    }
770
771    #[test]
772    fn test_build_args_single_enable_features_flag() {
773        let opts = LaunchOptions {
774            webgpu: true,
775            ..Default::default()
776        };
777        let result = build_chrome_args(&opts).unwrap();
778        let count = result
779            .args
780            .iter()
781            .filter(|a| a.starts_with("--enable-features="))
782            .count();
783        assert_eq!(count, 1);
784        if let Some(ref dir) = result.temp_user_data_dir {
785            let _ = std::fs::remove_dir_all(dir);
786        }
787    }
788
789    #[test]
790    fn test_build_args_headless_with_extensions_skips_headless_flag() {
791        let opts = LaunchOptions {
792            headless: true,
793            extensions: Some(vec!["/tmp/ext".to_string()]),
794            ..Default::default()
795        };
796        let result = build_chrome_args(&opts).unwrap();
797        assert!(!result.args.iter().any(|a| a.contains("--headless")));
798        assert!(result
799            .args
800            .iter()
801            .any(|a| a.starts_with("--load-extension=")));
802        assert!(result.args.iter().any(|a| {
803            a.starts_with("--disable-features=")
804                && a.contains("DisableLoadExtensionCommandLineSwitch")
805        }));
806        if let Some(ref dir) = result.temp_user_data_dir {
807            let _ = std::fs::remove_dir_all(dir);
808        }
809    }
810
811    #[test]
812    fn test_build_args_ignore_https_errors_includes_flag() {
813        let opts = LaunchOptions {
814            ignore_https_errors: true,
815            ..Default::default()
816        };
817        let result = build_chrome_args(&opts).unwrap();
818        assert!(result
819            .args
820            .iter()
821            .any(|a| a == "--ignore-certificate-errors"));
822        if let Some(ref dir) = result.temp_user_data_dir {
823            let _ = std::fs::remove_dir_all(dir);
824        }
825    }
826
827    #[test]
828    fn test_build_args_ignore_https_errors_default_no_flag() {
829        let opts = LaunchOptions::default();
830        let result = build_chrome_args(&opts).unwrap();
831        assert!(!result
832            .args
833            .iter()
834            .any(|a| a == "--ignore-certificate-errors"));
835        if let Some(ref dir) = result.temp_user_data_dir {
836            let _ = std::fs::remove_dir_all(dir);
837        }
838    }
839}