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    match crate::registry::liveness(&row) {
274        crate::registry::BrowserLiveness::Alive => {}
275        crate::registry::BrowserLiveness::DeadPid => {
276            registry
277                .delete(&row.name)
278                .with_context(|| format!("pruning stale browser {}", row.name))?;
279            bail!(
280                "registered browser `{}` is stale (pid {} is no longer running); \
281                 pruned it from the registry. Select a live browser by kind with `-b {}` or \
282                 update the default with `browser-control set default {}`",
283                row.name,
284                row.pid,
285                row.kind.as_str(),
286                row.kind.as_str()
287            );
288        }
289        crate::registry::BrowserLiveness::EndpointUnreachable => {
290            bail!(
291                "registered browser `{}` is not reachable at {} (pid {} still exists); \
292                 keeping the registry row because this can be transient. Select a live browser \
293                 by kind with `-b {}` or update the default with `browser-control set default {}`",
294                row.name,
295                row.endpoint,
296                row.pid,
297                row.kind.as_str(),
298                row.kind.as_str()
299            );
300        }
301    }
302    Ok(ResolvedBrowser {
303        endpoint: row.endpoint,
304        engine: row.engine,
305        source: Source::Registered { name: row.name },
306    })
307}
308
309fn resolve_kind(kind: Kind, registry: &Registry) -> Result<ResolvedBrowser> {
310    let row = registry
311        .first_alive_by_kind(kind)
312        .with_context(|| format!("looking up alive {kind} browser"))?
313        .ok_or_else(|| anyhow!("no running {kind} browser found in registry"))?;
314    Ok(ResolvedBrowser {
315        endpoint: row.endpoint,
316        engine: row.engine,
317        source: Source::Registered { name: row.name },
318    })
319}
320
321fn resolve_path<R: Resolver>(path: &Path, registry: &Registry, r: &R) -> Result<ResolvedBrowser> {
322    let installed = r.list_installed();
323    let kind = installed
324        .iter()
325        .find(|i| i.executable == path)
326        .map(|i| i.kind)
327        .ok_or_else(|| {
328            anyhow!(
329                "executable {} does not match any known installed browser",
330                path.display()
331            )
332        })?;
333    resolve_kind(kind, registry)
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use crate::registry::BrowserRow;
340    use std::sync::Mutex;
341
342    fn row(name: &str, kind: Kind, port: u16, started_at: &str) -> BrowserRow {
343        BrowserRow {
344            name: name.to_string(),
345            kind,
346            engine: kind.engine(),
347            pid: std::process::id(),
348            endpoint: format!("ws://127.0.0.1:{port}/devtools/browser/abcd"),
349            port,
350            profile_dir: PathBuf::from(format!("/tmp/profiles/{name}")),
351            executable: PathBuf::from("/usr/bin/example"),
352            headless: false,
353            started_at: started_at.to_string(),
354        }
355    }
356
357    /// Bind a TCP listener on a random local port and return (listener, port).
358    /// Keep the listener alive for the duration of liveness checks.
359    fn alive_listener() -> (std::net::TcpListener, u16) {
360        let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
361        let port = l.local_addr().unwrap().port();
362        (l, port)
363    }
364
365    struct FakeResolver {
366        fetch_version_result: Mutex<Option<Result<String>>>,
367        installed: Vec<Installed>,
368    }
369
370    impl FakeResolver {
371        fn ok(ws: &str) -> Self {
372            Self {
373                fetch_version_result: Mutex::new(Some(Ok(ws.to_string()))),
374                installed: Vec::new(),
375            }
376        }
377        fn empty() -> Self {
378            Self {
379                fetch_version_result: Mutex::new(None),
380                installed: Vec::new(),
381            }
382        }
383        fn with_installed(installed: Vec<Installed>) -> Self {
384            Self {
385                fetch_version_result: Mutex::new(None),
386                installed,
387            }
388        }
389    }
390
391    impl Resolver for FakeResolver {
392        fn fetch_version<'a>(&'a self, _base: &'a str) -> BoxFuture<'a, Result<String>> {
393            let taken = self
394                .fetch_version_result
395                .lock()
396                .unwrap()
397                .take()
398                .unwrap_or_else(|| Err(anyhow!("fetch_version not configured")));
399            Box::pin(async move { taken })
400        }
401        fn list_installed(&self) -> Vec<Installed> {
402            self.installed.clone()
403        }
404    }
405
406    // --- parse() ----------------------------------------------------------
407
408    #[test]
409    fn parse_ws_url() {
410        let s = "ws://x:9222/devtools/browser/abc";
411        match parse(s).unwrap() {
412            BrowserSelector::Url(u) => assert_eq!(u.as_str(), s),
413            other => panic!("expected Url, got {other:?}"),
414        }
415    }
416
417    #[test]
418    fn parse_http_url() {
419        match parse("http://x:9222").unwrap() {
420            BrowserSelector::Url(u) => assert_eq!(u.scheme(), "http"),
421            other => panic!("expected Url, got {other:?}"),
422        }
423    }
424
425    #[test]
426    fn parse_absolute_nonexistent_path_falls_through_to_name() {
427        // Absolute path that does not exist on disk falls through to Name per spec.
428        let s = "/non/existent/path/to/nothing-xyz-12345";
429        match parse(s).unwrap() {
430            BrowserSelector::Name(n) => assert_eq!(n, s),
431            other => panic!("expected Name, got {other:?}"),
432        }
433    }
434
435    #[test]
436    fn parse_existing_absolute_path_is_executable_path() {
437        let f = tempfile::NamedTempFile::new().unwrap();
438        let p = f.path().to_path_buf();
439        assert!(p.is_absolute());
440        match parse(p.to_str().unwrap()).unwrap() {
441            BrowserSelector::ExecutablePath(got) => assert_eq!(got, p),
442            other => panic!("expected ExecutablePath, got {other:?}"),
443        }
444    }
445
446    #[test]
447    fn parse_kind_lowercase() {
448        assert_eq!(
449            parse("chrome").unwrap(),
450            BrowserSelector::Kind(Kind::Chrome)
451        );
452    }
453
454    #[test]
455    fn parse_kind_case_insensitive() {
456        assert_eq!(
457            parse("FIREFOX").unwrap(),
458            BrowserSelector::Kind(Kind::Firefox)
459        );
460    }
461
462    #[test]
463    fn parse_friendly_name() {
464        assert_eq!(
465            parse("firefox-pikachu").unwrap(),
466            BrowserSelector::Name("firefox-pikachu".to_string())
467        );
468    }
469
470    // --- parse_target() ---------------------------------------------------
471
472    #[test]
473    fn parse_target_bare_name_has_no_tab() {
474        let t = parse_target("firefox-pikachu").unwrap();
475        assert_eq!(
476            t.browser,
477            BrowserSelector::Name("firefox-pikachu".to_string())
478        );
479        assert!(t.tab.is_none());
480    }
481
482    #[test]
483    fn parse_target_name_slash_tab_splits() {
484        let t = parse_target("brave-twilight/scrape-cart").unwrap();
485        assert_eq!(
486            t.browser,
487            BrowserSelector::Name("brave-twilight".to_string())
488        );
489        assert_eq!(t.tab.as_deref(), Some("scrape-cart"));
490    }
491
492    #[test]
493    fn parse_target_kind_slash_tab_splits() {
494        let t = parse_target("chrome/login").unwrap();
495        assert_eq!(t.browser, BrowserSelector::Kind(Kind::Chrome));
496        assert_eq!(t.tab.as_deref(), Some("login"));
497    }
498
499    #[test]
500    fn parse_target_url_keeps_slashes_in_url() {
501        let s = "ws://127.0.0.1:9222/devtools/browser/abc";
502        let t = parse_target(s).unwrap();
503        match t.browser {
504            BrowserSelector::Url(u) => assert_eq!(u.as_str(), s),
505            other => panic!("expected Url, got {other:?}"),
506        }
507        assert!(t.tab.is_none());
508    }
509
510    #[test]
511    fn parse_target_absolute_nonexistent_path_falls_through_to_name_with_slashes() {
512        // Leading `/` activates the path branch first; if the path doesn't
513        // exist, fall through to Name (no tab split, matches parse()).
514        let s = "/non/existent/path/to/nothing-xyz-12345";
515        let t = parse_target(s).unwrap();
516        assert_eq!(t.browser, BrowserSelector::Name(s.to_string()));
517        assert!(t.tab.is_none());
518    }
519
520    #[test]
521    fn parse_target_existing_absolute_path_with_no_slash_in_tab_slot() {
522        let f = tempfile::NamedTempFile::new().unwrap();
523        let t = parse_target(f.path().to_str().unwrap()).unwrap();
524        match &t.browser {
525            BrowserSelector::ExecutablePath(p) => assert_eq!(p, f.path()),
526            other => panic!("expected ExecutablePath, got {other:?}"),
527        }
528        assert!(t.tab.is_none());
529    }
530
531    #[test]
532    fn parse_target_validates_tab_name() {
533        assert!(parse_target("brave/UPPER").is_err()); // uppercase
534        assert!(parse_target("brave/has space").is_err()); // whitespace
535        assert!(parse_target("brave/_internal").is_err()); // reserved namespace
536        assert!(parse_target("brave/").is_err()); // empty tab
537    }
538
539    // --- validate_tab_name() ---------------------------------------------
540
541    #[test]
542    fn validate_tab_name_accepts_canonical_forms() {
543        validate_tab_name("scrape-cart").unwrap();
544        validate_tab_name("a").unwrap();
545        validate_tab_name("checkout-flow-2").unwrap();
546        validate_tab_name("page_a").unwrap();
547        validate_tab_name("042").unwrap();
548    }
549
550    #[test]
551    fn validate_tab_name_rejects_invalid() {
552        assert!(validate_tab_name("").is_err());
553        assert!(validate_tab_name("a".repeat(65).as_str()).is_err());
554        assert!(validate_tab_name("_scratch").is_err());
555        assert!(validate_tab_name("Tab").is_err());
556        assert!(validate_tab_name("a/b").is_err());
557        assert!(validate_tab_name("a b").is_err());
558    }
559
560    // --- resolve_with() ---------------------------------------------------
561
562    #[tokio::test]
563    async fn url_ws_returned_verbatim_cdp_engine() {
564        let reg = Registry::open_in_memory().unwrap();
565        let r = FakeResolver::empty();
566        let sel = parse("ws://127.0.0.1:9222/devtools/browser/abc").unwrap();
567        let got = resolve_with(sel, &reg, &r).await.unwrap();
568        assert_eq!(got.endpoint, "ws://127.0.0.1:9222/devtools/browser/abc");
569        assert_eq!(got.engine, Engine::Cdp);
570        assert_eq!(got.source, Source::External);
571    }
572
573    #[tokio::test]
574    async fn url_ws_session_path_is_bidi() {
575        let reg = Registry::open_in_memory().unwrap();
576        let r = FakeResolver::empty();
577        let sel = parse("ws://127.0.0.1:9222/session/abc123").unwrap();
578        let got = resolve_with(sel, &reg, &r).await.unwrap();
579        assert_eq!(got.engine, Engine::Bidi);
580    }
581
582    #[tokio::test]
583    async fn url_http_invokes_fetch_version() {
584        let reg = Registry::open_in_memory().unwrap();
585        let r = FakeResolver::ok("ws://127.0.0.1:9222/devtools/browser/discovered");
586        let sel = parse("http://127.0.0.1:9222").unwrap();
587        let got = resolve_with(sel, &reg, &r).await.unwrap();
588        assert_eq!(
589            got.endpoint,
590            "ws://127.0.0.1:9222/devtools/browser/discovered"
591        );
592        assert_eq!(got.engine, Engine::Cdp);
593        assert_eq!(got.source, Source::External);
594    }
595
596    #[tokio::test]
597    async fn kind_with_one_running_resolves() {
598        let reg = Registry::open_in_memory().unwrap();
599        let (_listener, port) = alive_listener();
600        let r = row("chrome-foxtrot", Kind::Chrome, port, "2024-06-01T00:00:00Z");
601        reg.insert(&r).unwrap();
602        let fake = FakeResolver::empty();
603        let got = resolve_with(BrowserSelector::Kind(Kind::Chrome), &reg, &fake)
604            .await
605            .unwrap();
606        assert_eq!(got.endpoint, r.endpoint);
607        assert_eq!(got.engine, Engine::Cdp);
608        assert_eq!(
609            got.source,
610            Source::Registered {
611                name: "chrome-foxtrot".to_string()
612            }
613        );
614    }
615
616    #[tokio::test]
617    async fn kind_with_none_running_errors() {
618        let reg = Registry::open_in_memory().unwrap();
619        let fake = FakeResolver::empty();
620        let err = resolve_with(BrowserSelector::Kind(Kind::Chrome), &reg, &fake)
621            .await
622            .unwrap_err();
623        assert!(format!("{err:#}").to_lowercase().contains("chrome"));
624    }
625
626    #[tokio::test]
627    async fn name_lookup_hit() {
628        let reg = Registry::open_in_memory().unwrap();
629        let (_listener, port) = alive_listener();
630        let r = row(
631            "firefox-pikachu",
632            Kind::Firefox,
633            port,
634            "2024-06-01T00:00:00Z",
635        );
636        reg.insert(&r).unwrap();
637        let fake = FakeResolver::empty();
638        let got = resolve_with(
639            BrowserSelector::Name("firefox-pikachu".to_string()),
640            &reg,
641            &fake,
642        )
643        .await
644        .unwrap();
645        assert_eq!(got.endpoint, r.endpoint);
646        assert_eq!(got.engine, Engine::Bidi);
647        assert_eq!(
648            got.source,
649            Source::Registered {
650                name: "firefox-pikachu".to_string()
651            }
652        );
653    }
654
655    #[tokio::test]
656    async fn name_lookup_stale_prunes_and_errors() {
657        let reg = Registry::open_in_memory().unwrap();
658        let mut r = row("brave-cosmos", Kind::Brave, 9111, "2024-06-01T00:00:00Z");
659        r.pid = 99_999_999;
660        reg.insert(&r).unwrap();
661        let fake = FakeResolver::empty();
662        let err = resolve_with(
663            BrowserSelector::Name("brave-cosmos".to_string()),
664            &reg,
665            &fake,
666        )
667        .await
668        .unwrap_err();
669        let msg = format!("{err:#}");
670        assert!(msg.contains("brave-cosmos"), "got: {msg}");
671        assert!(msg.contains("stale"), "got: {msg}");
672        assert!(msg.contains("set default brave"), "got: {msg}");
673        assert!(reg.get_by_name("brave-cosmos").unwrap().is_none());
674    }
675
676    #[tokio::test]
677    async fn name_lookup_endpoint_unreachable_is_retained() {
678        let reg = Registry::open_in_memory().unwrap();
679        let r = row("brave-cumin", Kind::Brave, 9111, "2024-06-01T00:00:00Z");
680        reg.insert(&r).unwrap();
681        let fake = FakeResolver::empty();
682        let err = resolve_with(
683            BrowserSelector::Name("brave-cumin".to_string()),
684            &reg,
685            &fake,
686        )
687        .await
688        .unwrap_err();
689        let msg = format!("{err:#}");
690        assert!(msg.contains("brave-cumin"), "got: {msg}");
691        assert!(msg.contains("not reachable"), "got: {msg}");
692        assert!(msg.contains("keeping the registry row"), "got: {msg}");
693        assert!(reg.get_by_name("brave-cumin").unwrap().is_some());
694    }
695
696    #[tokio::test]
697    async fn name_lookup_miss_errors() {
698        let reg = Registry::open_in_memory().unwrap();
699        let fake = FakeResolver::empty();
700        let err = resolve_with(BrowserSelector::Name("nope".to_string()), &reg, &fake)
701            .await
702            .unwrap_err();
703        assert!(format!("{err:#}").contains("nope"));
704    }
705
706    #[tokio::test]
707    async fn executable_path_maps_to_kind_then_errors_when_no_running() {
708        let reg = Registry::open_in_memory().unwrap();
709        let exe = PathBuf::from("/opt/myorg/chrome");
710        let installed = vec![Installed {
711            kind: Kind::Chrome,
712            executable: exe.clone(),
713            version: "130.0.0.0".to_string(),
714            engine: Engine::Cdp,
715        }];
716        let fake = FakeResolver::with_installed(installed);
717        let err = resolve_with(BrowserSelector::ExecutablePath(exe), &reg, &fake)
718            .await
719            .unwrap_err();
720        // Mapped to Chrome, but nothing running.
721        assert!(format!("{err:#}").to_lowercase().contains("chrome"));
722    }
723
724    #[tokio::test]
725    async fn executable_path_resolves_via_kind_when_running() {
726        let reg = Registry::open_in_memory().unwrap();
727        let (_listener, port) = alive_listener();
728        let r = row("chrome-x", Kind::Chrome, port, "2024-06-01T00:00:00Z");
729        reg.insert(&r).unwrap();
730
731        let exe = PathBuf::from("/opt/myorg/chrome");
732        let installed = vec![Installed {
733            kind: Kind::Chrome,
734            executable: exe.clone(),
735            version: "130.0.0.0".to_string(),
736            engine: Engine::Cdp,
737        }];
738        let fake = FakeResolver::with_installed(installed);
739        let got = resolve_with(BrowserSelector::ExecutablePath(exe), &reg, &fake)
740            .await
741            .unwrap();
742        assert_eq!(got.endpoint, r.endpoint);
743    }
744
745    #[tokio::test]
746    async fn executable_path_unknown_errors() {
747        let reg = Registry::open_in_memory().unwrap();
748        let fake = FakeResolver::with_installed(Vec::new());
749        let err = resolve_with(
750            BrowserSelector::ExecutablePath(PathBuf::from("/totally/unknown")),
751            &reg,
752            &fake,
753        )
754        .await
755        .unwrap_err();
756        assert!(format!("{err:#}").contains("/totally/unknown"));
757    }
758}