Skip to main content

browser_control/cli/
env_resolver.rs

1//! Resolve the `BROWSER_CONTROL` environment variable / `--browser` argument
2//! into a concrete browser endpoint.
3//!
4//! Parsing priority (most-to-least specific):
5//! 1. URL with scheme `ws://`, `wss://`, `http://`, `https://` → [`BrowserSelector::Url`].
6//! 2. Absolute path that exists on disk → [`BrowserSelector::ExecutablePath`].
7//! 3. String that matches a [`Kind`] (case-insensitive) → [`BrowserSelector::Kind`].
8//! 4. Anything else → [`BrowserSelector::Name`].
9
10use std::path::{Path, PathBuf};
11
12use anyhow::{anyhow, bail, Context, Result};
13use futures_util::future::BoxFuture;
14use serde::Serialize;
15
16use crate::detect::{Engine, Installed, Kind};
17use crate::registry::Registry;
18
19/// Parsed form of a `BROWSER_CONTROL` value.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum BrowserSelector {
22    /// Direct CDP/BiDi WebSocket or HTTP URL.
23    Url(url::Url),
24    /// Friendly instance name like `firefox-pikachu`.
25    Name(String),
26    /// Browser kind like `chrome` or `firefox`.
27    Kind(Kind),
28    /// Absolute path to a browser executable (must exist).
29    ExecutablePath(PathBuf),
30}
31
32/// Where the resolved browser came from.
33#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
34#[serde(tag = "source", rename_all = "lowercase")]
35pub enum Source {
36    /// An external CDP/BiDi endpoint passed by URL. No registry write.
37    External,
38    /// A registered browser, identified by friendly name.
39    Registered { name: String },
40}
41
42/// Result of resolving a [`BrowserSelector`].
43#[derive(Debug, Clone, Serialize)]
44pub struct ResolvedBrowser {
45    pub endpoint: String,
46    pub engine: Engine,
47    pub source: Source,
48}
49
50/// I/O surface for [`resolve_with`]. Implementations provide HTTP discovery
51/// (`/json/version`) and the list of installed browsers.
52pub trait Resolver: Send + Sync {
53    fn fetch_version<'a>(&'a self, base: &'a str) -> BoxFuture<'a, Result<String>>;
54    fn list_installed(&self) -> Vec<Installed>;
55}
56
57/// Production resolver: real HTTP + on-disk browser detection.
58pub struct DefaultResolver;
59
60impl Resolver for DefaultResolver {
61    fn fetch_version<'a>(&'a self, base: &'a str) -> BoxFuture<'a, Result<String>> {
62        Box::pin(async move {
63            let client = reqwest::Client::builder()
64                .timeout(std::time::Duration::from_secs(2))
65                .build()
66                .context("building reqwest client")?;
67            let url = format!("{}/json/version", base.trim_end_matches('/'));
68            let v: serde_json::Value = client
69                .get(&url)
70                .send()
71                .await
72                .with_context(|| format!("GET {url}"))?
73                .error_for_status()
74                .with_context(|| format!("GET {url}"))?
75                .json()
76                .await
77                .with_context(|| format!("decode JSON from {url}"))?;
78            let ws = v
79                .get("webSocketDebuggerUrl")
80                .and_then(|x| x.as_str())
81                .ok_or_else(|| anyhow!("response from {url} missing webSocketDebuggerUrl"))?
82                .to_string();
83            Ok(ws)
84        })
85    }
86
87    fn list_installed(&self) -> Vec<Installed> {
88        crate::detect::list_installed()
89    }
90}
91
92/// Parse a raw `BROWSER_CONTROL` value into a [`BrowserSelector`].
93pub fn parse(value: &str) -> Result<BrowserSelector> {
94    let v = value.trim();
95    if v.is_empty() {
96        bail!("BROWSER_CONTROL value is empty");
97    }
98
99    let lower = v.to_ascii_lowercase();
100    if lower.starts_with("ws://")
101        || lower.starts_with("wss://")
102        || lower.starts_with("http://")
103        || lower.starts_with("https://")
104    {
105        let u = url::Url::parse(v).with_context(|| format!("parsing URL {v}"))?;
106        return Ok(BrowserSelector::Url(u));
107    }
108
109    let path = Path::new(v);
110    if path.is_absolute() && path.exists() {
111        return Ok(BrowserSelector::ExecutablePath(path.to_path_buf()));
112    }
113
114    if let Some(k) = Kind::parse(v) {
115        return Ok(BrowserSelector::Kind(k));
116    }
117
118    Ok(BrowserSelector::Name(v.to_string()))
119}
120
121/// A target addressing both a browser instance and (optionally) a named tab.
122///
123/// The CLI positional that used to be a bare browser selector now accepts
124/// either `<browser>` or `<browser>/<tab>`. A bare value parses as today;
125/// a value containing `/` (after the first character, to avoid collision
126/// with absolute paths) is split into a browser part and a tab name.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct BrowserTarget {
129    pub browser: BrowserSelector,
130    pub tab: Option<String>,
131}
132
133/// Parse a positional `<browser>[/<tab>]` argument.
134///
135/// Parse precedence — first match wins:
136/// 1. URL scheme (`ws://`, `wss://`, `http://`, `https://`) → [`BrowserSelector::Url`], no tab.
137/// 2. Absolute path that exists → [`BrowserSelector::ExecutablePath`], no tab.
138/// 3. Value containing `/` after position 0 → split on first `/`: left is
139///    parsed as a bare selector, right is validated as a tab name.
140/// 4. Matches a [`Kind`] → [`BrowserSelector::Kind`], no tab.
141/// 5. Otherwise [`BrowserSelector::Name`], no tab.
142///
143/// Backward compat: any value without an embedded `/` parses identically
144/// to [`parse`].
145pub fn parse_target(value: &str) -> Result<BrowserTarget> {
146    let v = value.trim();
147    if v.is_empty() {
148        bail!("BROWSER_CONTROL value is empty");
149    }
150    let lower = v.to_ascii_lowercase();
151    if lower.starts_with("ws://")
152        || lower.starts_with("wss://")
153        || lower.starts_with("http://")
154        || lower.starts_with("https://")
155    {
156        let u = url::Url::parse(v).with_context(|| format!("parsing URL {v}"))?;
157        return Ok(BrowserTarget {
158            browser: BrowserSelector::Url(u),
159            tab: None,
160        });
161    }
162    let path = Path::new(v);
163    if path.is_absolute() && path.exists() {
164        return Ok(BrowserTarget {
165            browser: BrowserSelector::ExecutablePath(path.to_path_buf()),
166            tab: None,
167        });
168    }
169    // Split on `/` only if the slash is *not* the leading character — that
170    // case is covered by the absolute-path branch above (even if the path
171    // doesn't exist, we don't want to treat `/foo/bar` as `browser=/foo`).
172    if let Some(idx) = v.find('/') {
173        if idx > 0 {
174            let (left, right) = v.split_at(idx);
175            let tab = &right[1..];
176            validate_tab_name(tab)?;
177            return Ok(BrowserTarget {
178                browser: parse(left)?,
179                tab: Some(tab.to_string()),
180            });
181        }
182    }
183    Ok(BrowserTarget {
184        browser: parse(v)?,
185        tab: None,
186    })
187}
188
189/// Validate a tab name: lowercase ASCII alpha/digit plus `-`/`_`, length
190/// 1..=64, and the leading-`_` namespace is reserved (used by internal
191/// scratch / system rows so agents can't collide with them).
192pub fn validate_tab_name(name: &str) -> Result<()> {
193    if name.is_empty() {
194        bail!("tab name is empty");
195    }
196    if name.len() > 64 {
197        bail!("tab name `{name}` exceeds 64 characters");
198    }
199    if name.starts_with('_') {
200        bail!("tab name `{name}` starts with `_` (reserved namespace)");
201    }
202    if !name
203        .bytes()
204        .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
205    {
206        bail!(
207            "tab name `{name}` contains invalid characters \
208             (allowed: a-z, 0-9, `-`, `_`)"
209        );
210    }
211    Ok(())
212}
213
214/// Heuristic engine detection for a WebSocket URL.
215///
216/// A path beginning with `/session` (WebDriver BiDi) → [`Engine::Bidi`];
217/// anything else (e.g. `/devtools/browser/<id>`) → [`Engine::Cdp`].
218fn engine_from_ws_url(u: &url::Url) -> Engine {
219    let path = u.path();
220    if path.starts_with("/session") || path.contains("/session/") {
221        Engine::Bidi
222    } else {
223        Engine::Cdp
224    }
225}
226
227/// Resolve a selector using the real environment.
228pub async fn resolve(selector: BrowserSelector, registry: &Registry) -> Result<ResolvedBrowser> {
229    resolve_with(selector, registry, &DefaultResolver).await
230}
231
232/// Resolve a selector using an injected [`Resolver`] for I/O.
233pub async fn resolve_with<R: Resolver>(
234    selector: BrowserSelector,
235    registry: &Registry,
236    r: &R,
237) -> Result<ResolvedBrowser> {
238    match selector {
239        BrowserSelector::Url(u) => resolve_url(u, r).await,
240        BrowserSelector::Name(name) => resolve_name(&name, registry),
241        BrowserSelector::Kind(k) => resolve_kind(k, registry),
242        BrowserSelector::ExecutablePath(p) => resolve_path(&p, registry, r),
243    }
244}
245
246async fn resolve_url<R: Resolver>(u: url::Url, r: &R) -> Result<ResolvedBrowser> {
247    match u.scheme() {
248        "ws" | "wss" => Ok(ResolvedBrowser {
249            engine: engine_from_ws_url(&u),
250            endpoint: u.to_string(),
251            source: Source::External,
252        }),
253        "http" | "https" => {
254            let base = u.as_str().trim_end_matches('/').to_string();
255            let ws = r.fetch_version(&base).await?;
256            let ws_url = url::Url::parse(&ws)
257                .with_context(|| format!("parsing webSocketDebuggerUrl {ws}"))?;
258            Ok(ResolvedBrowser {
259                engine: engine_from_ws_url(&ws_url),
260                endpoint: ws,
261                source: Source::External,
262            })
263        }
264        other => bail!("unsupported URL scheme: {other}"),
265    }
266}
267
268fn resolve_name(name: &str, registry: &Registry) -> Result<ResolvedBrowser> {
269    let row = registry
270        .get_by_name(name)
271        .with_context(|| format!("looking up browser {name}"))?
272        .ok_or_else(|| anyhow!("no registered browser named {name}"))?;
273    Ok(ResolvedBrowser {
274        endpoint: row.endpoint,
275        engine: row.engine,
276        source: Source::Registered { name: row.name },
277    })
278}
279
280fn resolve_kind(kind: Kind, registry: &Registry) -> Result<ResolvedBrowser> {
281    let row = registry
282        .first_alive_by_kind(kind)
283        .with_context(|| format!("looking up alive {kind} browser"))?
284        .ok_or_else(|| anyhow!("no running {kind} browser found in registry"))?;
285    Ok(ResolvedBrowser {
286        endpoint: row.endpoint,
287        engine: row.engine,
288        source: Source::Registered { name: row.name },
289    })
290}
291
292fn resolve_path<R: Resolver>(path: &Path, registry: &Registry, r: &R) -> Result<ResolvedBrowser> {
293    let installed = r.list_installed();
294    let kind = installed
295        .iter()
296        .find(|i| i.executable == path)
297        .map(|i| i.kind)
298        .ok_or_else(|| {
299            anyhow!(
300                "executable {} does not match any known installed browser",
301                path.display()
302            )
303        })?;
304    resolve_kind(kind, registry)
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use crate::registry::BrowserRow;
311    use std::sync::Mutex;
312
313    fn row(name: &str, kind: Kind, port: u16, started_at: &str) -> BrowserRow {
314        BrowserRow {
315            name: name.to_string(),
316            kind,
317            engine: kind.engine(),
318            pid: std::process::id(),
319            endpoint: format!("ws://127.0.0.1:{port}/devtools/browser/abcd"),
320            port,
321            profile_dir: PathBuf::from(format!("/tmp/profiles/{name}")),
322            executable: PathBuf::from("/usr/bin/example"),
323            headless: false,
324            started_at: started_at.to_string(),
325        }
326    }
327
328    /// Bind a TCP listener on a random local port and return (listener, port).
329    /// Keep the listener alive for the duration of liveness checks.
330    fn alive_listener() -> (std::net::TcpListener, u16) {
331        let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
332        let port = l.local_addr().unwrap().port();
333        (l, port)
334    }
335
336    struct FakeResolver {
337        fetch_version_result: Mutex<Option<Result<String>>>,
338        installed: Vec<Installed>,
339    }
340
341    impl FakeResolver {
342        fn ok(ws: &str) -> Self {
343            Self {
344                fetch_version_result: Mutex::new(Some(Ok(ws.to_string()))),
345                installed: Vec::new(),
346            }
347        }
348        fn empty() -> Self {
349            Self {
350                fetch_version_result: Mutex::new(None),
351                installed: Vec::new(),
352            }
353        }
354        fn with_installed(installed: Vec<Installed>) -> Self {
355            Self {
356                fetch_version_result: Mutex::new(None),
357                installed,
358            }
359        }
360    }
361
362    impl Resolver for FakeResolver {
363        fn fetch_version<'a>(&'a self, _base: &'a str) -> BoxFuture<'a, Result<String>> {
364            let taken = self
365                .fetch_version_result
366                .lock()
367                .unwrap()
368                .take()
369                .unwrap_or_else(|| Err(anyhow!("fetch_version not configured")));
370            Box::pin(async move { taken })
371        }
372        fn list_installed(&self) -> Vec<Installed> {
373            self.installed.clone()
374        }
375    }
376
377    // --- parse() ----------------------------------------------------------
378
379    #[test]
380    fn parse_ws_url() {
381        let s = "ws://x:9222/devtools/browser/abc";
382        match parse(s).unwrap() {
383            BrowserSelector::Url(u) => assert_eq!(u.as_str(), s),
384            other => panic!("expected Url, got {other:?}"),
385        }
386    }
387
388    #[test]
389    fn parse_http_url() {
390        match parse("http://x:9222").unwrap() {
391            BrowserSelector::Url(u) => assert_eq!(u.scheme(), "http"),
392            other => panic!("expected Url, got {other:?}"),
393        }
394    }
395
396    #[test]
397    fn parse_absolute_nonexistent_path_falls_through_to_name() {
398        // Absolute path that does not exist on disk falls through to Name per spec.
399        let s = "/non/existent/path/to/nothing-xyz-12345";
400        match parse(s).unwrap() {
401            BrowserSelector::Name(n) => assert_eq!(n, s),
402            other => panic!("expected Name, got {other:?}"),
403        }
404    }
405
406    #[test]
407    fn parse_existing_absolute_path_is_executable_path() {
408        let f = tempfile::NamedTempFile::new().unwrap();
409        let p = f.path().to_path_buf();
410        assert!(p.is_absolute());
411        match parse(p.to_str().unwrap()).unwrap() {
412            BrowserSelector::ExecutablePath(got) => assert_eq!(got, p),
413            other => panic!("expected ExecutablePath, got {other:?}"),
414        }
415    }
416
417    #[test]
418    fn parse_kind_lowercase() {
419        assert_eq!(
420            parse("chrome").unwrap(),
421            BrowserSelector::Kind(Kind::Chrome)
422        );
423    }
424
425    #[test]
426    fn parse_kind_case_insensitive() {
427        assert_eq!(
428            parse("FIREFOX").unwrap(),
429            BrowserSelector::Kind(Kind::Firefox)
430        );
431    }
432
433    #[test]
434    fn parse_friendly_name() {
435        assert_eq!(
436            parse("firefox-pikachu").unwrap(),
437            BrowserSelector::Name("firefox-pikachu".to_string())
438        );
439    }
440
441    // --- parse_target() ---------------------------------------------------
442
443    #[test]
444    fn parse_target_bare_name_has_no_tab() {
445        let t = parse_target("firefox-pikachu").unwrap();
446        assert_eq!(
447            t.browser,
448            BrowserSelector::Name("firefox-pikachu".to_string())
449        );
450        assert!(t.tab.is_none());
451    }
452
453    #[test]
454    fn parse_target_name_slash_tab_splits() {
455        let t = parse_target("brave-twilight/scrape-cart").unwrap();
456        assert_eq!(
457            t.browser,
458            BrowserSelector::Name("brave-twilight".to_string())
459        );
460        assert_eq!(t.tab.as_deref(), Some("scrape-cart"));
461    }
462
463    #[test]
464    fn parse_target_kind_slash_tab_splits() {
465        let t = parse_target("chrome/login").unwrap();
466        assert_eq!(t.browser, BrowserSelector::Kind(Kind::Chrome));
467        assert_eq!(t.tab.as_deref(), Some("login"));
468    }
469
470    #[test]
471    fn parse_target_url_keeps_slashes_in_url() {
472        let s = "ws://127.0.0.1:9222/devtools/browser/abc";
473        let t = parse_target(s).unwrap();
474        match t.browser {
475            BrowserSelector::Url(u) => assert_eq!(u.as_str(), s),
476            other => panic!("expected Url, got {other:?}"),
477        }
478        assert!(t.tab.is_none());
479    }
480
481    #[test]
482    fn parse_target_absolute_nonexistent_path_falls_through_to_name_with_slashes() {
483        // Leading `/` activates the path branch first; if the path doesn't
484        // exist, fall through to Name (no tab split, matches parse()).
485        let s = "/non/existent/path/to/nothing-xyz-12345";
486        let t = parse_target(s).unwrap();
487        assert_eq!(t.browser, BrowserSelector::Name(s.to_string()));
488        assert!(t.tab.is_none());
489    }
490
491    #[test]
492    fn parse_target_existing_absolute_path_with_no_slash_in_tab_slot() {
493        let f = tempfile::NamedTempFile::new().unwrap();
494        let t = parse_target(f.path().to_str().unwrap()).unwrap();
495        match &t.browser {
496            BrowserSelector::ExecutablePath(p) => assert_eq!(p, f.path()),
497            other => panic!("expected ExecutablePath, got {other:?}"),
498        }
499        assert!(t.tab.is_none());
500    }
501
502    #[test]
503    fn parse_target_validates_tab_name() {
504        assert!(parse_target("brave/UPPER").is_err()); // uppercase
505        assert!(parse_target("brave/has space").is_err()); // whitespace
506        assert!(parse_target("brave/_internal").is_err()); // reserved namespace
507        assert!(parse_target("brave/").is_err()); // empty tab
508    }
509
510    // --- validate_tab_name() ---------------------------------------------
511
512    #[test]
513    fn validate_tab_name_accepts_canonical_forms() {
514        validate_tab_name("scrape-cart").unwrap();
515        validate_tab_name("a").unwrap();
516        validate_tab_name("checkout-flow-2").unwrap();
517        validate_tab_name("page_a").unwrap();
518        validate_tab_name("042").unwrap();
519    }
520
521    #[test]
522    fn validate_tab_name_rejects_invalid() {
523        assert!(validate_tab_name("").is_err());
524        assert!(validate_tab_name("a".repeat(65).as_str()).is_err());
525        assert!(validate_tab_name("_scratch").is_err());
526        assert!(validate_tab_name("Tab").is_err());
527        assert!(validate_tab_name("a/b").is_err());
528        assert!(validate_tab_name("a b").is_err());
529    }
530
531    // --- resolve_with() ---------------------------------------------------
532
533    #[tokio::test]
534    async fn url_ws_returned_verbatim_cdp_engine() {
535        let reg = Registry::open_in_memory().unwrap();
536        let r = FakeResolver::empty();
537        let sel = parse("ws://127.0.0.1:9222/devtools/browser/abc").unwrap();
538        let got = resolve_with(sel, &reg, &r).await.unwrap();
539        assert_eq!(got.endpoint, "ws://127.0.0.1:9222/devtools/browser/abc");
540        assert_eq!(got.engine, Engine::Cdp);
541        assert_eq!(got.source, Source::External);
542    }
543
544    #[tokio::test]
545    async fn url_ws_session_path_is_bidi() {
546        let reg = Registry::open_in_memory().unwrap();
547        let r = FakeResolver::empty();
548        let sel = parse("ws://127.0.0.1:9222/session/abc123").unwrap();
549        let got = resolve_with(sel, &reg, &r).await.unwrap();
550        assert_eq!(got.engine, Engine::Bidi);
551    }
552
553    #[tokio::test]
554    async fn url_http_invokes_fetch_version() {
555        let reg = Registry::open_in_memory().unwrap();
556        let r = FakeResolver::ok("ws://127.0.0.1:9222/devtools/browser/discovered");
557        let sel = parse("http://127.0.0.1:9222").unwrap();
558        let got = resolve_with(sel, &reg, &r).await.unwrap();
559        assert_eq!(
560            got.endpoint,
561            "ws://127.0.0.1:9222/devtools/browser/discovered"
562        );
563        assert_eq!(got.engine, Engine::Cdp);
564        assert_eq!(got.source, Source::External);
565    }
566
567    #[tokio::test]
568    async fn kind_with_one_running_resolves() {
569        let reg = Registry::open_in_memory().unwrap();
570        let (_listener, port) = alive_listener();
571        let r = row("chrome-foxtrot", Kind::Chrome, port, "2024-06-01T00:00:00Z");
572        reg.insert(&r).unwrap();
573        let fake = FakeResolver::empty();
574        let got = resolve_with(BrowserSelector::Kind(Kind::Chrome), &reg, &fake)
575            .await
576            .unwrap();
577        assert_eq!(got.endpoint, r.endpoint);
578        assert_eq!(got.engine, Engine::Cdp);
579        assert_eq!(
580            got.source,
581            Source::Registered {
582                name: "chrome-foxtrot".to_string()
583            }
584        );
585    }
586
587    #[tokio::test]
588    async fn kind_with_none_running_errors() {
589        let reg = Registry::open_in_memory().unwrap();
590        let fake = FakeResolver::empty();
591        let err = resolve_with(BrowserSelector::Kind(Kind::Chrome), &reg, &fake)
592            .await
593            .unwrap_err();
594        assert!(format!("{err:#}").to_lowercase().contains("chrome"));
595    }
596
597    #[tokio::test]
598    async fn name_lookup_hit() {
599        let reg = Registry::open_in_memory().unwrap();
600        let r = row(
601            "firefox-pikachu",
602            Kind::Firefox,
603            9111,
604            "2024-06-01T00:00:00Z",
605        );
606        reg.insert(&r).unwrap();
607        let fake = FakeResolver::empty();
608        let got = resolve_with(
609            BrowserSelector::Name("firefox-pikachu".to_string()),
610            &reg,
611            &fake,
612        )
613        .await
614        .unwrap();
615        assert_eq!(got.endpoint, r.endpoint);
616        assert_eq!(got.engine, Engine::Bidi);
617        assert_eq!(
618            got.source,
619            Source::Registered {
620                name: "firefox-pikachu".to_string()
621            }
622        );
623    }
624
625    #[tokio::test]
626    async fn name_lookup_miss_errors() {
627        let reg = Registry::open_in_memory().unwrap();
628        let fake = FakeResolver::empty();
629        let err = resolve_with(BrowserSelector::Name("nope".to_string()), &reg, &fake)
630            .await
631            .unwrap_err();
632        assert!(format!("{err:#}").contains("nope"));
633    }
634
635    #[tokio::test]
636    async fn executable_path_maps_to_kind_then_errors_when_no_running() {
637        let reg = Registry::open_in_memory().unwrap();
638        let exe = PathBuf::from("/opt/myorg/chrome");
639        let installed = vec![Installed {
640            kind: Kind::Chrome,
641            executable: exe.clone(),
642            version: "130.0.0.0".to_string(),
643            engine: Engine::Cdp,
644        }];
645        let fake = FakeResolver::with_installed(installed);
646        let err = resolve_with(BrowserSelector::ExecutablePath(exe), &reg, &fake)
647            .await
648            .unwrap_err();
649        // Mapped to Chrome, but nothing running.
650        assert!(format!("{err:#}").to_lowercase().contains("chrome"));
651    }
652
653    #[tokio::test]
654    async fn executable_path_resolves_via_kind_when_running() {
655        let reg = Registry::open_in_memory().unwrap();
656        let (_listener, port) = alive_listener();
657        let r = row("chrome-x", Kind::Chrome, port, "2024-06-01T00:00:00Z");
658        reg.insert(&r).unwrap();
659
660        let exe = PathBuf::from("/opt/myorg/chrome");
661        let installed = vec![Installed {
662            kind: Kind::Chrome,
663            executable: exe.clone(),
664            version: "130.0.0.0".to_string(),
665            engine: Engine::Cdp,
666        }];
667        let fake = FakeResolver::with_installed(installed);
668        let got = resolve_with(BrowserSelector::ExecutablePath(exe), &reg, &fake)
669            .await
670            .unwrap();
671        assert_eq!(got.endpoint, r.endpoint);
672    }
673
674    #[tokio::test]
675    async fn executable_path_unknown_errors() {
676        let reg = Registry::open_in_memory().unwrap();
677        let fake = FakeResolver::with_installed(Vec::new());
678        let err = resolve_with(
679            BrowserSelector::ExecutablePath(PathBuf::from("/totally/unknown")),
680            &reg,
681            &fake,
682        )
683        .await
684        .unwrap_err();
685        assert!(format!("{err:#}").contains("/totally/unknown"));
686    }
687}