Skip to main content

rightkit_browser/
config.rs

1use crate::error::{BrowserError, Result};
2use crate::policy::{AdmissionHook, EventSink, NetworkPolicy};
3use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6/// Where the browser keeps cookies, storage, and logins.
7#[derive(Clone, Debug, Eq, PartialEq)]
8pub enum ProfileSpec {
9    /// Throwaway profile deleted when the session drops (default).
10    Temporary,
11    /// Named persistent profile at `<root>/<name>`. One live session per name;
12    /// a second launch fails with `ProfileInUse` instead of corrupting state.
13    Named { root: PathBuf, name: String },
14    /// Caller-owned, existing absolute user-data directory. Never created or deleted
15    /// by the session; no named-profile validation or real-profile exclusion applies.
16    Directory(PathBuf),
17}
18
19#[derive(Clone)]
20pub struct LaunchOptions {
21    pub headless: bool,
22    /// Passes `--mute-audio`. Default true.
23    pub mute_audio: bool,
24    pub profile: ProfileSpec,
25    /// Explicit browser executable. When `None`, launch resolves Chrome for Testing with
26    /// [`chrome_for_testing_path`]. Ignored by [`Self::system_chrome_only`].
27    pub chrome_path: Option<PathBuf>,
28    /// Opt-in: when Chrome for Testing is not installed, fall back to the system browser
29    /// ([`find_chrome`]). Off by default: regular Google Chrome on macOS leaves an APFS
30    /// code-sign clone of its bundle behind for every instance that does not shut down cleanly.
31    pub allow_system_chrome: bool,
32    /// Resolve only through [`find_chrome`], ignoring `chrome_path` and Chrome for
33    /// Testing discovery. Off by default; enabled by [`Self::system_chrome_only`].
34    pub use_system_chrome_only: bool,
35    pub viewport: (u32, u32),
36    pub launch_timeout: Duration,
37    /// Extra Chrome flags, with or without leading `--`.
38    pub extra_args: Vec<String>,
39    /// Defaults to `<profile>/downloads` for temporary profiles and
40    /// `<profile>/rightkit-downloads` for persistent ones.
41    pub download_dir: Option<PathBuf>,
42    /// When set, `upload` refuses files outside this root.
43    pub upload_root: Option<PathBuf>,
44    /// Gates every navigation, input, and script action before it has any effect.
45    /// `None` admits everything (trusted callers).
46    pub admission: Option<AdmissionHook>,
47    /// Network/SSRF policy. Default blocks loopback, link-local, private ranges,
48    /// `localhost`, and `file:` unless allowed.
49    pub network: NetworkPolicy,
50    /// Receives start/stop/crash/denied lifecycle events (session id, reason, time).
51    pub on_event: Option<EventSink>,
52    /// Windows: the caller's job object (see [`Self::windows_job`]). Chromium and every
53    /// helper it starts are bound to it; a clone of these options keeps the duplicate open.
54    #[cfg(windows)]
55    pub windows_job: Option<std::sync::Arc<std::os::windows::io::OwnedHandle>>,
56}
57
58impl std::fmt::Debug for LaunchOptions {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        f.debug_struct("LaunchOptions")
61            .field("headless", &self.headless)
62            .field("profile", &self.profile)
63            .field("network", &self.network)
64            .field("admission", &self.admission.is_some())
65            .finish_non_exhaustive()
66    }
67}
68
69impl Default for LaunchOptions {
70    fn default() -> Self {
71        Self {
72            headless: true,
73            mute_audio: true,
74            profile: ProfileSpec::Temporary,
75            chrome_path: None,
76            allow_system_chrome: false,
77            use_system_chrome_only: false,
78            viewport: (1280, 800),
79            launch_timeout: Duration::from_secs(60),
80            extra_args: Vec::new(),
81            download_dir: None,
82            upload_root: None,
83            admission: None,
84            network: NetworkPolicy::default(),
85            on_event: None,
86            #[cfg(windows)]
87            windows_job: None,
88        }
89    }
90}
91
92impl LaunchOptions {
93    /// Opt in to the system Chrome/Chromium/Edge when Chrome for Testing is missing.
94    pub fn system_chrome(mut self) -> Self {
95        self.allow_system_chrome = true;
96        self.use_system_chrome_only = false;
97        self
98    }
99    /// Use only system Chrome/Chromium/Edge via [`find_chrome`]. Ignores explicit
100    /// `chrome_path` and Chrome for Testing installs/environment overrides; errors
101    /// with [`BrowserError::ChromeNotFound`] when no system browser is found.
102    pub fn system_chrome_only(mut self) -> Self {
103        self.use_system_chrome_only = true;
104        self.allow_system_chrome = false;
105        self
106    }
107    pub fn headed(mut self) -> Self {
108        self.headless = false;
109        self
110    }
111    pub fn admission(mut self, hook: AdmissionHook) -> Self {
112        self.admission = Some(hook);
113        self
114    }
115    pub fn network(mut self, policy: NetworkPolicy) -> Self {
116        self.network = policy;
117        self
118    }
119    pub fn on_event(
120        mut self,
121        f: impl Fn(&crate::policy::BrowserEvent) + Send + Sync + 'static,
122    ) -> Self {
123        self.on_event = Some(std::sync::Arc::new(f));
124        self
125    }
126    /// Windows: bind Chromium to the caller's job object (an in-process host's own job).
127    /// Chromium joins it before running its first instruction, beneath RightKit's own
128    /// kill-on-close jobs, none of which allow breakaway: terminating or closing the
129    /// caller's job kills the browser and all of its helpers. The handle is duplicated; the
130    /// session closes its duplicate as soon as Chromium has been bound.
131    #[cfg(windows)]
132    pub fn windows_job(
133        mut self,
134        job: std::os::windows::io::BorrowedHandle<'_>,
135    ) -> std::io::Result<Self> {
136        self.windows_job = Some(std::sync::Arc::new(job.try_clone_to_owned()?));
137        Ok(self)
138    }
139    pub fn named_profile(mut self, root: impl Into<PathBuf>, name: impl Into<String>) -> Self {
140        self.profile = ProfileSpec::Named {
141            root: root.into(),
142            name: name.into(),
143        };
144        self
145    }
146    /// Use an existing absolute Chrome user-data directory, including paths with
147    /// spaces or other filesystem-supported characters. Validated at launch; never
148    /// created or deleted. The caller owns exclusive access to this directory.
149    pub fn profile_dir(mut self, path: impl Into<PathBuf>) -> Self {
150        self.profile = ProfileSpec::Directory(path.into());
151        self
152    }
153}
154
155pub(crate) fn validate_profile_dir(path: &Path) -> Result<()> {
156    if !path.is_absolute() {
157        return Err(BrowserError::Profile(format!(
158            "profile directory must be absolute: {}",
159            path.display()
160        )));
161    }
162    let metadata = std::fs::metadata(path).map_err(|e| {
163        BrowserError::Profile(format!(
164            "profile directory must exist and be accessible: {} ({e})",
165            path.display()
166        ))
167    })?;
168    if !metadata.is_dir() {
169        return Err(BrowserError::Profile(format!(
170            "profile path is not a directory: {}",
171            path.display()
172        )));
173    }
174    Ok(())
175}
176
177pub(crate) fn validate_profile_name(name: &str) -> Result<()> {
178    let ok = !name.is_empty()
179        && name.len() <= 64
180        && !name.starts_with('.')
181        && name
182            .chars()
183            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
184    if ok {
185        Ok(())
186    } else {
187        Err(BrowserError::Profile(format!(
188            "invalid profile name '{name}'"
189        )))
190    }
191}
192
193/// Environment variable naming an explicit Chrome for Testing executable.
194pub const CHROME_FOR_TESTING_ENV: &str = "RIGHTKIT_CHROME_FOR_TESTING";
195
196/// Override the RightKit-managed Chrome for Testing version root.
197pub const CHROME_FOR_TESTING_ROOT_ENV: &str = "RIGHTKIT_CHROME_FOR_TESTING_ROOT";
198
199/// How to get Chrome for Testing; part of [`BrowserError::ChromeForTestingNotFound`].
200pub const CHROME_FOR_TESTING_INSTALL_HINT: &str = "Install Chrome for Testing (any one of):\n  \
201pnpm dlx @puppeteer/browsers install chrome@stable --path ~/.cache/puppeteer\n  \
202pnpm exec playwright install chromium\n\
203or point RIGHTKIT_CHROME_FOR_TESTING at its executable. Regular Google Chrome is used only \
204with LaunchOptions::system_chrome(): on macOS each killed instance leaks a code-sign clone.";
205
206const MAC_CFT_APP: &str = "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing";
207
208fn home_dir() -> Option<PathBuf> {
209    std::env::var_os("HOME")
210        .or_else(|| std::env::var_os("USERPROFILE"))
211        .map(PathBuf::from)
212}
213
214fn version_key(name: &str) -> Vec<u64> {
215    name.split(|c: char| !c.is_ascii_digit())
216        .filter(|p| !p.is_empty())
217        .filter_map(|p| p.parse().ok())
218        .collect()
219}
220
221/// Children of `root` whose names start with one of `prefixes`, newest version first.
222fn versioned_children(root: &Path, prefixes: &[&str]) -> Vec<PathBuf> {
223    let mut names: Vec<String> = std::fs::read_dir(root)
224        .map(|rd| {
225            rd.filter_map(|e| e.ok())
226                .map(|e| e.file_name().to_string_lossy().into_owned())
227                .filter(|n| prefixes.iter().any(|p| n.starts_with(p)))
228                .collect()
229        })
230        .unwrap_or_default();
231    names.sort_by(|a, b| version_key(b).cmp(&version_key(a)).then_with(|| b.cmp(a)));
232    names.into_iter().map(|n| root.join(n)).collect()
233}
234
235fn managed_chrome_for_testing_root(
236    home: &Path,
237    env_path: &impl Fn(&str) -> Option<PathBuf>,
238) -> PathBuf {
239    if let Some(root) = env_path(CHROME_FOR_TESTING_ROOT_ENV) {
240        return root;
241    }
242    let app_data = if cfg!(target_os = "macos") {
243        home.join("Library/Application Support")
244    } else if cfg!(target_os = "windows") {
245        env_path("LOCALAPPDATA").unwrap_or_else(|| home.join("AppData").join("Local"))
246    } else {
247        env_path("XDG_DATA_HOME").unwrap_or_else(|| home.join(".local/share"))
248    };
249    app_data.join("Orthic Labs/shared/chrome-for-testing")
250}
251
252fn managed_chrome_for_testing_candidates(root: &Path) -> Vec<PathBuf> {
253    // Match qa-chrome's dotted numeric version folders; ignore staging/metadata entries.
254    versioned_children(root, &[""])
255        .into_iter()
256        .filter(|dir| {
257            dir.is_dir()
258                && dir
259                    .file_name()
260                    .and_then(|n| n.to_str())
261                    .is_some_and(|name| {
262                        name.contains('.')
263                            && name.split('.').all(|part| {
264                                !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit())
265                            })
266                    })
267        })
268        .map(|dir| {
269            if cfg!(target_os = "macos") {
270                let sub = if cfg!(target_arch = "aarch64") {
271                    "chrome-mac-arm64"
272                } else {
273                    "chrome-mac-x64"
274                };
275                dir.join(sub).join(MAC_CFT_APP)
276            } else if cfg!(target_os = "windows") {
277                dir.join("chrome-win64/chrome.exe")
278            } else {
279                dir.join("chrome-linux64/chrome")
280            }
281        })
282        .collect()
283}
284
285/// Known Chrome for Testing install locations, in resolution order: RightKit-managed
286/// versions (`RIGHTKIT_CHROME_FOR_TESTING_ROOT` overrides the root), app installs, then
287/// Puppeteer's cache (`PUPPETEER_CACHE_DIR`), then Playwright's (`PLAYWRIGHT_BROWSERS_PATH`),
288/// newest version first within each cache.
289pub fn chrome_for_testing_candidates() -> Vec<PathBuf> {
290    let home = home_dir().unwrap_or_default();
291    let env_path = |k: &str| {
292        std::env::var_os(k)
293            .filter(|v| !v.is_empty())
294            .map(PathBuf::from)
295    };
296    let puppeteer = env_path("PUPPETEER_CACHE_DIR")
297        .unwrap_or_else(|| home.join(".cache").join("puppeteer"))
298        .join("chrome");
299    let managed_root = managed_chrome_for_testing_root(&home, &env_path);
300    let mut out = managed_chrome_for_testing_candidates(&managed_root);
301    if cfg!(target_os = "macos") {
302        out.push(Path::new("/Applications").join(MAC_CFT_APP));
303        out.push(home.join("Applications").join(MAC_CFT_APP));
304        let subs: [&str; 2] = if cfg!(target_arch = "aarch64") {
305            ["chrome-mac-arm64", "chrome-mac-x64"]
306        } else {
307            ["chrome-mac-x64", "chrome-mac-arm64"]
308        };
309        let playwright = env_path("PLAYWRIGHT_BROWSERS_PATH")
310            .unwrap_or_else(|| home.join("Library/Caches/ms-playwright"));
311        for dir in versioned_children(&puppeteer, &["mac_arm-", "mac-"])
312            .into_iter()
313            .chain(versioned_children(&playwright, &["chromium-"]))
314        {
315            for sub in subs {
316                out.push(dir.join(sub).join(MAC_CFT_APP));
317            }
318        }
319    } else if cfg!(target_os = "windows") {
320        let local = env_path("LOCALAPPDATA").unwrap_or_else(|| home.join(r"AppData\Local"));
321        for dir in versioned_children(&puppeteer, &["win64-", "win32-"]) {
322            out.push(dir.join("chrome-win64").join("chrome.exe"));
323            out.push(dir.join("chrome-win32").join("chrome.exe"));
324        }
325        let playwright =
326            env_path("PLAYWRIGHT_BROWSERS_PATH").unwrap_or_else(|| local.join("ms-playwright"));
327        for dir in versioned_children(&playwright, &["chromium-"]) {
328            out.push(dir.join("chrome-win64").join("chrome.exe"));
329        }
330    } else {
331        for dir in versioned_children(&puppeteer, &["linux-", "linux_arm-"]) {
332            out.push(dir.join("chrome-linux64").join("chrome"));
333            out.push(dir.join("chrome-linux-arm64").join("chrome"));
334        }
335        let playwright = env_path("PLAYWRIGHT_BROWSERS_PATH")
336            .unwrap_or_else(|| home.join(".cache").join("ms-playwright"));
337        for dir in versioned_children(&playwright, &["chromium-"]) {
338            out.push(dir.join("chrome-linux64").join("chrome"));
339        }
340    }
341    out
342}
343
344/// Resolve Chrome for Testing: `explicit`, then `RIGHTKIT_CHROME_FOR_TESTING`, then
345/// [`chrome_for_testing_candidates`]. Errors with install instructions; never returns
346/// regular Chrome.
347pub fn chrome_for_testing_path(explicit: Option<&Path>) -> Result<PathBuf> {
348    let missing = |what: String| {
349        BrowserError::ChromeForTestingNotFound(format!("{what}\n{CHROME_FOR_TESTING_INSTALL_HINT}"))
350    };
351    if let Some(p) = explicit {
352        return if p.exists() {
353            Ok(p.to_path_buf())
354        } else {
355            Err(missing(format!(
356                "explicit Chrome for Testing executable does not exist: {}",
357                p.display()
358            )))
359        };
360    }
361    if let Some(p) = std::env::var_os(CHROME_FOR_TESTING_ENV).filter(|v| !v.is_empty()) {
362        let p = PathBuf::from(p);
363        return if p.exists() {
364            Ok(p)
365        } else {
366            Err(missing(format!(
367                "{CHROME_FOR_TESTING_ENV} points at a missing file: {}",
368                p.display()
369            )))
370        };
371    }
372    let candidates = chrome_for_testing_candidates();
373    if let Some(p) = candidates.iter().find(|p| p.exists()) {
374        return Ok(p.clone());
375    }
376    let searched: Vec<String> = candidates
377        .iter()
378        .map(|p| format!("  {}", p.display()))
379        .collect();
380    Err(missing(format!(
381        "Chrome for Testing is not installed; searched:\n{}",
382        if searched.is_empty() {
383            "  (nothing)".to_string()
384        } else {
385            searched.join("\n")
386        }
387    )))
388}
389
390/// System-only selection takes priority; otherwise `chrome_path` as given, then Chrome
391/// for Testing, then (only with [`LaunchOptions::system_chrome`]) the system browser.
392pub(crate) fn resolve_executable(opts: &LaunchOptions) -> Result<PathBuf> {
393    resolve_executable_with(opts, || chrome_for_testing_path(None), find_chrome)
394}
395
396fn resolve_executable_with(
397    opts: &LaunchOptions,
398    cft: impl FnOnce() -> Result<PathBuf>,
399    system: impl FnOnce() -> Option<PathBuf>,
400) -> Result<PathBuf> {
401    if opts.use_system_chrome_only {
402        return system().ok_or(BrowserError::ChromeNotFound);
403    }
404    if let Some(p) = &opts.chrome_path {
405        return Ok(p.clone());
406    }
407    match cft() {
408        Ok(p) => Ok(p),
409        Err(e) if opts.allow_system_chrome => system().ok_or(e),
410        Err(e) => Err(e),
411    }
412}
413
414/// System Chrome, Chromium, then Edge. `CHROME` env overrides. Launch uses this only with
415/// [`LaunchOptions::system_chrome`] or [`LaunchOptions::system_chrome_only`];
416/// prefer [`chrome_for_testing_path`] for isolated automation.
417pub fn find_chrome() -> Option<PathBuf> {
418    if let Ok(p) = std::env::var("CHROME") {
419        let p = PathBuf::from(p);
420        if p.exists() {
421            return Some(p);
422        }
423    }
424    let candidates: &[&str] = if cfg!(target_os = "windows") {
425        &[
426            r"C:\Program Files\Google\Chrome\Application\chrome.exe",
427            r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
428            r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
429            r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
430        ]
431    } else if cfg!(target_os = "macos") {
432        &[
433            "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
434            "/Applications/Chromium.app/Contents/MacOS/Chromium",
435            "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
436        ]
437    } else {
438        &[
439            "/usr/bin/google-chrome",
440            "/usr/bin/chromium",
441            "/usr/bin/chromium-browser",
442            "/usr/bin/microsoft-edge",
443        ]
444    };
445    candidates
446        .iter()
447        .map(PathBuf::from)
448        .find(|p| Path::new(p).exists())
449}
450
451/// Chrome flags a caller may not pass: they would redirect the profile to the
452/// user's real browser data or expose the debugging endpoint outside this session.
453pub(crate) fn validate_extra_args(args: &[String]) -> Result<()> {
454    const FORBIDDEN: &[&str] = &[
455        "user-data-dir",
456        "profile-directory",
457        "remote-debugging-port",
458        "remote-debugging-address",
459        "remote-debugging-pipe",
460        "remote-allow-origins",
461        "disable-web-security",
462        "incognito-bypass",
463    ];
464    for a in args {
465        let name = a.trim_start_matches('-').split('=').next().unwrap_or("");
466        if FORBIDDEN.contains(&name) {
467            return Err(BrowserError::Invalid(format!(
468                "chrome flag '--{name}' is managed by the session"
469            )));
470        }
471    }
472    Ok(())
473}
474
475/// Refuse roots that are (or sit inside) a real browser's own user-data directory.
476pub(crate) fn reject_real_browser_profile(root: &Path) -> Result<()> {
477    let mut real: Vec<PathBuf> = Vec::new();
478    if let Some(home) = std::env::var_os("HOME")
479        .or_else(|| std::env::var_os("USERPROFILE"))
480        .map(PathBuf::from)
481    {
482        for rel in [
483            "Library/Application Support/Google/Chrome",
484            "Library/Application Support/Chromium",
485            "Library/Application Support/Microsoft Edge",
486            ".config/google-chrome",
487            ".config/chromium",
488            ".config/microsoft-edge",
489        ] {
490            real.push(home.join(rel));
491        }
492    }
493    if let Some(local) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from) {
494        for rel in [
495            r"Google\Chrome\User Data",
496            r"Chromium\User Data",
497            r"Microsoft\Edge\User Data",
498        ] {
499            real.push(local.join(rel));
500        }
501    }
502    std::fs::create_dir_all(root)?;
503    let root = root.canonicalize()?;
504    for r in real {
505        if let Ok(r) = r.canonicalize() {
506            if root.starts_with(&r) {
507                return Err(BrowserError::Profile(
508                    "refusing to use the user's real browser profile".into(),
509                ));
510            }
511        }
512    }
513    Ok(())
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519    use std::ffi::OsString;
520    use std::sync::{Mutex, MutexGuard};
521
522    // Serialize resolver tests that change process environment; restore even on panic.
523    static ENV_LOCK: Mutex<()> = Mutex::new(());
524
525    struct EnvGuard {
526        saved: Vec<(&'static str, Option<OsString>)>,
527        _lock: MutexGuard<'static, ()>,
528    }
529
530    impl EnvGuard {
531        fn new(values: &[(&'static str, Option<&Path>)]) -> Self {
532            let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
533            let saved = values
534                .iter()
535                .map(|(key, _)| (*key, std::env::var_os(key)))
536                .collect();
537            for (key, value) in values {
538                match value {
539                    Some(value) => std::env::set_var(key, value),
540                    None => std::env::remove_var(key),
541                }
542            }
543            Self { saved, _lock: lock }
544        }
545    }
546
547    impl Drop for EnvGuard {
548        fn drop(&mut self) {
549            for (key, value) in &self.saved {
550                match value {
551                    Some(value) => std::env::set_var(key, value),
552                    None => std::env::remove_var(key),
553                }
554            }
555        }
556    }
557
558    fn write_executable(path: &Path) {
559        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
560        std::fs::write(path, "fixture, not a browser").unwrap();
561    }
562
563    fn managed_executable(root: &Path, version: &str) -> PathBuf {
564        let relative = if cfg!(target_os = "macos") {
565            if cfg!(target_arch = "aarch64") {
566                "chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"
567            } else {
568                "chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"
569            }
570        } else if cfg!(target_os = "windows") {
571            "chrome-win64/chrome.exe"
572        } else {
573            "chrome-linux64/chrome"
574        };
575        root.join(version).join(relative)
576    }
577
578    #[test]
579    fn system_chrome_only_ignores_explicit_env_and_managed_cft() {
580        let temp = tempfile::tempdir().unwrap();
581        let root = temp.path().join("managed CfT");
582        let managed = managed_executable(&root, "130.10.0.0");
583        let explicit = temp.path().join("explicit CfT");
584        let system = temp.path().join("system Chrome");
585        for path in [&managed, &explicit, &system] {
586            write_executable(path);
587        }
588        let _env = EnvGuard::new(&[
589            (CHROME_FOR_TESTING_ROOT_ENV, Some(&root)),
590            (CHROME_FOR_TESTING_ENV, Some(&managed)),
591            ("CHROME", Some(&system)),
592        ]);
593        assert_eq!(chrome_for_testing_path(None).unwrap(), managed);
594        let opts = LaunchOptions {
595            chrome_path: Some(explicit),
596            ..LaunchOptions::default().system_chrome_only()
597        };
598        assert_eq!(resolve_executable(&opts).unwrap(), system);
599        assert_eq!(find_chrome().unwrap(), system);
600        // Also prove discovery is never called, regardless of installed CfT versions.
601        assert_eq!(
602            resolve_executable_with(
603                &opts,
604                || panic!("CfT discovery called"),
605                || Some(system.clone()),
606            )
607            .unwrap(),
608            system
609        );
610    }
611
612    #[test]
613    fn system_chrome_only_errors_when_system_browser_is_missing() {
614        let opts = LaunchOptions::default().system_chrome_only();
615        let err =
616            resolve_executable_with(&opts, || panic!("CfT discovery called"), || None).unwrap_err();
617        assert!(matches!(&err, BrowserError::ChromeNotFound));
618        assert_eq!(
619            err.to_string(),
620            "no Chrome, Chromium, or Edge executable found"
621        );
622    }
623
624    #[test]
625    fn defaults_and_system_chrome_preserve_cft_preference_and_fallback() {
626        let temp = tempfile::tempdir().unwrap();
627        let cft = temp.path().join("CfT");
628        let system = temp.path().join("system Chrome");
629        write_executable(&cft);
630        write_executable(&system);
631        let defaults = LaunchOptions::default();
632        assert!(defaults.headless && defaults.mute_audio);
633        assert!(!defaults.allow_system_chrome && !defaults.use_system_chrome_only);
634        for opts in [defaults.clone(), defaults.clone().system_chrome()] {
635            assert_eq!(
636                resolve_executable_with(
637                    &opts,
638                    || Ok(cft.clone()),
639                    || panic!("system lookup called"),
640                )
641                .unwrap(),
642                cft
643            );
644        }
645        assert!(matches!(
646            resolve_executable_with(
647                &defaults,
648                || Err(BrowserError::ChromeForTestingNotFound("missing".into())),
649                || panic!("default must not fall back"),
650            ),
651            Err(BrowserError::ChromeForTestingNotFound(_))
652        ));
653        let fallback = defaults.system_chrome_only().system_chrome();
654        assert!(!fallback.use_system_chrome_only);
655        assert_eq!(
656            resolve_executable_with(
657                &fallback,
658                || Err(BrowserError::ChromeForTestingNotFound("missing".into())),
659                || Some(system.clone()),
660            )
661            .unwrap(),
662            system
663        );
664    }
665
666    #[test]
667    fn managed_root_uses_platform_data_directory_and_env_override() {
668        let temp = tempfile::tempdir().unwrap();
669        let home = temp.path();
670        let suffix = Path::new("Orthic Labs/shared/chrome-for-testing");
671        let default_data = if cfg!(target_os = "macos") {
672            home.join("Library/Application Support")
673        } else if cfg!(target_os = "windows") {
674            home.join("AppData/Local")
675        } else {
676            home.join(".local/share")
677        };
678        assert_eq!(
679            managed_chrome_for_testing_root(home, &|_| None),
680            default_data.join(suffix)
681        );
682        let data = home.join("custom app data");
683        let with_data = |key: &str| {
684            if key == "LOCALAPPDATA" || key == "XDG_DATA_HOME" {
685                Some(data.clone())
686            } else {
687                None
688            }
689        };
690        let expected_data = if cfg!(target_os = "macos") {
691            default_data
692        } else {
693            data.clone()
694        };
695        assert_eq!(
696            managed_chrome_for_testing_root(home, &with_data),
697            expected_data.join(suffix)
698        );
699        let override_root = home.join("custom managed root");
700        assert_eq!(
701            managed_chrome_for_testing_root(home, &|key| {
702                if key == CHROME_FOR_TESTING_ROOT_ENV {
703                    Some(override_root.clone())
704                } else {
705                    None
706                }
707            }),
708            override_root
709        );
710    }
711
712    #[test]
713    fn managed_folder_wins_with_newest_numeric_version_first() {
714        let temp = tempfile::tempdir().unwrap();
715        let root = temp.path().join("managed Chrome for Testing");
716        let puppeteer = temp.path().join("puppeteer");
717        let older = managed_executable(&root, "129.99.0.0");
718        let recent = managed_executable(&root, "130.2.0.0");
719        let newest = managed_executable(&root, "130.10.0.0");
720        let legacy_dir = if cfg!(target_os = "macos") {
721            "mac-999.0.0.0"
722        } else if cfg!(target_os = "windows") {
723            "win64-999.0.0.0"
724        } else {
725            "linux-999.0.0.0"
726        };
727        let legacy = managed_executable(&puppeteer.join("chrome"), legacy_dir);
728        for path in [&older, &recent, &newest, &legacy] {
729            write_executable(path);
730        }
731        std::fs::create_dir(root.join("999.staging")).unwrap();
732        std::fs::write(root.join("999.0.0.0"), "not a version directory").unwrap();
733        let _env = EnvGuard::new(&[
734            (CHROME_FOR_TESTING_ROOT_ENV, Some(&root)),
735            (CHROME_FOR_TESTING_ENV, None),
736            ("PUPPETEER_CACHE_DIR", Some(&puppeteer)),
737        ]);
738        let candidates = chrome_for_testing_candidates();
739        assert_eq!(&candidates[..3], &[newest.clone(), recent.clone(), older]);
740        assert!(candidates.iter().position(|p| p == &legacy).unwrap() >= 3);
741        assert_eq!(chrome_for_testing_path(None).unwrap(), newest);
742        std::env::set_var(CHROME_FOR_TESTING_ENV, &legacy);
743        assert_eq!(chrome_for_testing_path(None).unwrap(), legacy);
744        std::env::remove_var(CHROME_FOR_TESTING_ENV);
745        std::fs::remove_file(&newest).unwrap();
746        assert_eq!(chrome_for_testing_path(None).unwrap(), recent);
747        // Explicit executable selection retains priority over managed discovery.
748        assert_eq!(chrome_for_testing_path(Some(&legacy)).unwrap(), legacy);
749    }
750}