Skip to main content

car_browser/
chromium.rs

1//! Headless Chromium backend via chromiumoxide.
2//!
3//! Implements `BrowserBackend` using Chrome DevTools Protocol (CDP).
4//! Requires a Chromium/Chrome binary on the system.
5
6use async_trait::async_trait;
7use chromiumoxide::browser::{Browser, BrowserConfig};
8use chromiumoxide::cdp::browser_protocol::accessibility::GetFullAxTreeParams;
9use chromiumoxide::cdp::browser_protocol::dom::{BackendNodeId, FocusParams, GetBoxModelParams};
10use chromiumoxide::cdp::browser_protocol::input::{
11    DispatchKeyEventParams, DispatchKeyEventType, DispatchMouseEventParams, DispatchMouseEventType,
12    InsertTextParams, MouseButton,
13};
14use chromiumoxide::cdp::browser_protocol::page::{
15    CaptureScreenshotFormat, GetNavigationHistoryParams, NavigateToHistoryEntryParams, ReloadParams,
16};
17use chromiumoxide::Page;
18use futures::StreamExt;
19use std::collections::HashMap;
20use std::sync::Arc;
21use std::sync::Mutex as StdMutex;
22use std::time::Duration;
23
24/// Per-tab deadline for the nav-state refresh `list_tabs` does.
25///
26/// The drawer refreshes its tab strip through that call, so an unbounded
27/// sequential walk let one wedged target block the whole strip. Generous
28/// relative to a healthy round trip (milliseconds), short enough that a hung
29/// target costs one refresh rather than the session.
30const NAV_STATE_TIMEOUT: Duration = Duration::from_secs(2);
31use tokio::sync::{watch, RwLock};
32use tokio::task::JoinHandle;
33use tokio::time::timeout;
34
35use crate::backend::{BrowserBackend, BrowserError};
36use crate::models::{A11yNode, Bounds, Modifier, Viewport, WaitCondition};
37use crate::tabs::{TabId, TabInfo, TabRegistry, TabsSnapshot};
38
39/// Cached mapping from ax_N node IDs to their CDP backend DOM node IDs.
40/// Populated during `get_accessibility_tree()`, consumed by `click_element()`
41/// and `focus_element()`.
42type AxNodeCache = HashMap<String, BackendNodeId>;
43
44/// Headless Chromium browser backend.
45pub struct ChromiumBackend {
46    /// Every open tab, plus which one is active. Every existing
47    /// perception/navigation/input method below routes to whichever page
48    /// [`Self::get_page`] reports as active — that's what makes "acts on the
49    /// ACTIVE page" hold automatically as tabs are opened, closed, and
50    /// switched; nothing below `get_page()` needed to change when tab
51    /// support was added, only what "the page" means did.
52    ///
53    /// `std::sync::RwLock`, not tokio's, so the synchronous
54    /// `BrowserBackend::get_current_url` can read from it — same reasoning
55    /// as `ax_node_cache` below.
56    tabs: std::sync::RwLock<TabRegistry<Page>>,
57    browser: Arc<RwLock<Option<Browser>>>,
58    viewport_width: u32,
59    viewport_height: u32,
60    /// Cached URL of the currently (or most recently) active tab. Kept as
61    /// its own field — rather than always derived fresh from `tabs` — so
62    /// `get_current_url()` keeps returning `Ok` with the last known value
63    /// even in the empty state (no tabs open, e.g. after `shutdown()`),
64    /// exactly like the pre-tabs single-page backend, whose `cached_url` was
65    /// never reset by `shutdown()` either.
66    /// Uses std::sync::RwLock so get_current_url() can be synchronous.
67    cached_url: std::sync::RwLock<String>,
68    /// Cached mapping from ax_N IDs to BackendNodeId, populated by
69    /// get_accessibility_tree(). Used by click_element/focus_element/type_into_element
70    /// to resolve ax_N to real DOM coordinates. Cleared whenever the active
71    /// page identity changes (tab open/switch/close-with-neighbor) — an ax_N
72    /// id only makes sense against whichever page's tree produced it.
73    ax_node_cache: std::sync::RwLock<AxNodeCache>,
74    /// Per-instance Chromium profile directory. Held so the
75    /// directory survives until ChromiumBackend is dropped, then
76    /// auto-cleans (TempDir runs `remove_dir_all` in its Drop).
77    /// `None` when this backend launched against a PERSISTENT directory —
78    /// the caller owns that directory's lifecycle.
79    /// See #148: under Playwright workers running in parallel, the
80    /// chromiumoxide default profile dir caused SingletonLock
81    /// contention; per-instance dirs eliminate the collision.
82    _profile_dir: Option<tempfile::TempDir>,
83    /// This backend's hold on a persistent profile directory, released on
84    /// drop. `None` when it launched ephemeral (nothing to hold) — including
85    /// the fallback case where a persistent directory was wanted but another
86    /// live backend in this process already held it. See [`crate::profile`].
87    _profile_claim: Option<crate::profile::ProfileClaim>,
88    /// PID of the Chrome subprocess. Captured at launch so `Drop`
89    /// can synchronously SIGKILL it without needing the tokio
90    /// runtime. chromiumoxide relies on tokio's `kill_on_drop`,
91    /// which only fires if the runtime is still alive when the
92    /// `Browser` drops — on process panic/abort the Chrome
93    /// subprocess is reparented to PID 1 and leaks. We bypass that
94    /// by remembering the PID ourselves.
95    chrome_pid: Option<u32>,
96    /// Handle to the spawned tokio task that drains chromiumoxide's
97    /// CDP event stream. Aborted in `shutdown()` and `Drop` so the
98    /// task doesn't outlive the backend (it holds the CDP channel,
99    /// which keeps the chromiumoxide `Browser` from quiescing).
100    handler_task: StdMutex<Option<JoinHandle<()>>>,
101    /// Serializes `navigate`'s empty-state reopen across its check AND the
102    /// `open_tab()` await — see the call site. An async mutex because the
103    /// critical section contains an await, which is exactly what the registry
104    /// lock could not span.
105    reopen: tokio::sync::Mutex<()>,
106}
107
108/// True iff `url` is on the same (scheme, host, port) as `origin`.
109///
110/// Used to enforce origin-locality for `set_local_storage`: localStorage is
111/// origin-scoped in the browser, so setting items from the wrong page would
112/// either fail silently or mutate the wrong origin's state. We require the
113/// caller to navigate to the origin first.
114fn current_origin_matches(url: &str, origin: &str) -> bool {
115    // Empty or `about:blank` is allowed — treat as a pre-page state where
116    // localStorage operations will be attached to the first real
117    // navigation. Chromium's about:blank has no persistent storage so the
118    // `evaluate` call will simply set localStorage on the next real page.
119    if url.is_empty() || url.starts_with("about:") {
120        return true;
121    }
122    let extract = |s: &str| {
123        let (scheme, rest) = s.split_once("://")?;
124        let host_part = rest.split('/').next().unwrap_or("");
125        Some(format!("{}://{}", scheme, host_part))
126    };
127    match (extract(url), extract(origin)) {
128        (Some(a), Some(b)) => a == b,
129        _ => false,
130    }
131}
132
133/// Convert our `Modifier` enum to CDP modifier bitmask.
134/// CDP defines: Alt=1, Ctrl=2, Meta/Command=4, Shift=8.
135fn modifiers_to_cdp_flags(modifiers: &[Modifier]) -> i64 {
136    let mut flags: i64 = 0;
137    for m in modifiers {
138        flags |= match m {
139            Modifier::Alt => 1,
140            Modifier::Control => 2,
141            Modifier::Meta => 4,
142            Modifier::Shift => 8,
143        };
144    }
145    flags
146}
147
148/// Map a poisoned `tabs` lock to a `BrowserError`. Shared by every tab
149/// operation so the error text is consistent in one place.
150fn tabs_lock_poisoned<E: std::fmt::Display>(e: E) -> BrowserError {
151    BrowserError::PlatformInternal(format!("tabs lock poisoned: {e}"))
152}
153
154/// Best-effort live nav-state read for one page: url, title, and
155/// back/forward availability, all from a single `Page.getNavigationHistory`
156/// call. Its response's `entries[current_index]` already carries the
157/// current entry's `url` and `title` (`NavigationEntry` in chromiumoxide_cdp)
158/// — no need for the separate `page.url()` / `page.evaluate("document.title")`
159/// round trips a JS-eval-based title read would cost, which also keeps this
160/// off the perception boundary's DOM-read surface. `None` if the CDP call
161/// fails, or if `current_index` doesn't land on an entry (empty/out-of-range
162/// — shouldn't happen against a real page, but guarded rather than assumed)
163/// — the caller keeps the tab's last-known values rather than blanking them
164/// out on a transient error.
165async fn fetch_nav_state(page: &Page) -> Option<(String, String, bool, bool)> {
166    let history = page
167        .execute(GetNavigationHistoryParams::default())
168        .await
169        .ok()?;
170    let idx = history.result.current_index;
171    let entries = &history.result.entries;
172    let current = usize::try_from(idx).ok().and_then(|i| entries.get(i))?;
173    let floor = history_floor(entries.first().map(|e| e.url.as_str()));
174    let can_go_back = usize::try_from(idx).is_ok_and(|i| i > floor);
175    let can_go_forward = idx < entries.len() as i64 - 1;
176    Some((
177        current.url.clone(),
178        current.title.clone(),
179        can_go_back,
180        can_go_forward,
181    ))
182}
183
184/// Which way [`ChromiumBackend::step_history`] moves through a tab's history.
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub enum HistoryStep {
187    Back,
188    Forward,
189}
190
191impl HistoryStep {
192    /// How the failure to move reads to a caller — the nav bar's Back and
193    /// Forward buttons are disabled precisely when this would happen, so it
194    /// is a race or a client bug, not an ordinary outcome.
195    fn nothing_there(self) -> &'static str {
196        match self {
197            HistoryStep::Back => "no page to go back to",
198            HistoryStep::Forward => "no page to go forward to",
199        }
200    }
201}
202
203/// The entry id one step from `current_index` in `entry_ids`, or `None` when
204/// there is nothing in that direction.
205///
206/// Split out from the CDP call for the same reason `fetch_nav_state`'s index
207/// guard is written the way it is: `Page.getNavigationHistory` hands back a
208/// `currentIndex` that is only *documented* to be in range, and this is the
209/// one piece of history navigation that can be tested without a live Chrome.
210/// A negative index, an index past the end, and an empty history all answer
211/// `None` rather than panicking on the slice.
212fn adjacent_entry_id(
213    current_index: i64,
214    entry_ids: &[i64],
215    step: HistoryStep,
216    floor: usize,
217) -> Option<i64> {
218    let current = usize::try_from(current_index).ok()?;
219    if current >= entry_ids.len() {
220        return None;
221    }
222    let target = match step {
223        HistoryStep::Back => {
224            let target = current.checked_sub(1)?;
225            // Never step onto the tab's birth entry: Back on a page reached
226            // by a single navigation would land on a blank page that reads as
227            // the empty state, which is not "the prior page" by any reading.
228            if target < floor {
229                return None;
230            }
231            target
232        }
233        HistoryStep::Forward => current + 1,
234    };
235    entry_ids.get(target).copied()
236}
237
238/// Index of the first history entry that represents a page somebody actually
239/// went to.
240///
241/// A tab is born at `about:blank`, and Chromium records that as history entry
242/// zero — so after ONE navigation `currentIndex` is already 1 and the naive
243/// `index > 0` test reports "you can go back", to a blank page. The outcome is
244/// explicit that Back "enables after a second navigation", so entry zero is
245/// excluded when it is the birth entry.
246///
247/// Only entry ZERO is treated this way. A caller who deliberately navigates to
248/// `about:blank` later has genuinely been there, and going back to it is
249/// correct.
250fn history_floor(first_url: Option<&str>) -> usize {
251    match first_url {
252        Some(url) if url == "about:blank" || url.is_empty() => 1,
253        _ => 0,
254    }
255}
256
257/// Whether [`ChromiumBackend::navigate`] needs to open a tab before it can
258/// act: true exactly when `tabs` is in the empty state (no active page —
259/// e.g. every tab was just closed). Pure and generic over the page handle
260/// type, so it is unit-testable the same way `tabs.rs` tests `TabRegistry`
261/// itself (a synthetic `FakePage`, no live Chromium) — only the actual CDP
262/// tab-open (`ChromiumBackend::open_tab`) needs one.
263fn needs_a_tab_before_navigating<P: Clone>(tabs: &TabRegistry<P>) -> bool {
264    tabs.active_page().is_none()
265}
266
267/// Whether a Chromium launch failure means "another live instance already
268/// holds this profile directory".
269///
270/// Chromium's `ProcessSingleton` reports it precisely, so reacting to the
271/// error is enough — and is strictly better than the alternatives. An
272/// in-process registry cannot see another PROCESS (two supervised agents both
273/// derive `$CAR_HOME/browser-profile`, which is the case that actually
274/// happens), and a lock file's post-crash staleness is its own failure mode.
275///
276/// Matched on the two stable fragments of the message rather than the whole
277/// string: the path and errno text vary, `SingletonLock` and "profile" do not.
278/// Deliberately narrow — a fallback triggered by an unrelated failure would
279/// silently strand a user's sign-ins in a directory nothing reads.
280fn is_profile_in_use(error: &str) -> bool {
281    let lower = error.to_ascii_lowercase();
282    lower.contains("singletonlock")
283        || (lower.contains("profile") && lower.contains("already"))
284        || lower.contains("cannot create a profile directory")
285}
286
287/// One launch attempt against one profile directory.
288///
289/// Split out so the fallback path can repeat it verbatim: a retry that
290/// rebuilt the config differently would be a second, untested configuration.
291async fn launch_chromium(
292    opts: &LaunchOptions,
293    profile_dir: &std::path::Path,
294) -> Result<(Browser, chromiumoxide::handler::Handler), String> {
295    let mut builder = BrowserConfig::builder().window_size(opts.width, opts.height);
296    builder = if opts.headless {
297        builder.new_headless_mode()
298    } else {
299        builder.with_head()
300    };
301    if !opts.extra_args.is_empty() {
302        // chromiumoxide's BrowserConfig::builder().args takes
303        // an `IntoIterator<Item = impl Into<String>>` and
304        // appends each verbatim to Chromium's argv.
305        builder = builder.args(opts.extra_args.iter().map(String::as_str));
306    }
307    let config = builder
308        .user_data_dir(profile_dir)
309        .build()
310        .map_err(|e| format!("Config error: {e}"))?;
311    Browser::launch(config).await.map_err(|e| e.to_string())
312}
313
314/// Options for launching a `ChromiumBackend`.
315///
316/// `headless = false` shows a real visible Chromium window — intended for
317/// interactive flows like first-time authentication (LinkedIn, OAuth, SSO)
318/// where a human needs to complete sign-in / 2FA / captcha before the rest
319/// of the script runs headless against the persisted cookies.
320#[derive(Debug, Clone)]
321pub struct LaunchOptions {
322    pub width: u32,
323    pub height: u32,
324    pub headless: bool,
325    /// Extra command-line flags appended to Chromium's argv at
326    /// launch. Use cases include the Google Meet bot (#112) which
327    /// needs `--use-fake-ui-for-media-stream`,
328    /// `--autoplay-policy=no-user-gesture-required`, and the
329    /// container-friendly trio (`--no-sandbox`,
330    /// `--disable-dev-shm-usage`, `--disable-setuid-sandbox`).
331    /// Flags are passed verbatim — callers responsible for the
332    /// correctness of what they append.
333    pub extra_args: Vec<String>,
334    /// Persistent Chromium profile to launch against, or `None` for the
335    /// per-instance ephemeral tempdir that is this crate's default.
336    ///
337    /// This is how CAR code asks for persistence. It is an OPTION rather
338    /// than an environment variable because the env var is process-global:
339    /// one component setting it silently changed every other browser in the
340    /// process, including `browser.run`'s per-connection browsers, which is
341    /// how a drawer launch used to make every later `browser.run` die on
342    /// SingletonLock. `CAR_BROWSER_PROFILE_DIR` remains the user-facing
343    /// knob, read below when no explicit directory is given — but CAR code
344    /// never writes it.
345    ///
346    /// A directory already held by a live backend in this process is not an
347    /// error: the launch falls back to the ephemeral default and logs, which
348    /// starts a working (signed-out) browser instead of none at all. See
349    /// [`crate::profile`].
350    pub profile_dir: Option<std::path::PathBuf>,
351}
352
353impl Default for LaunchOptions {
354    fn default() -> Self {
355        Self {
356            width: 1280,
357            height: 720,
358            headless: true,
359            extra_args: Vec::new(),
360            profile_dir: None,
361        }
362    }
363}
364
365/// Prepare an explicitly owned directory so even an operator profile override
366/// cannot make two isolated sessions share Chromium state.
367fn isolated_launch_options(
368    mut opts: LaunchOptions,
369) -> Result<(LaunchOptions, tempfile::TempDir), BrowserError> {
370    let profile = tempfile::Builder::new()
371        .prefix("car-browser-isolated-")
372        .tempdir()
373        .map_err(|e| BrowserError::NotAvailable(format!("create isolated browser profile: {e}")))?;
374    opts.profile_dir = Some(profile.path().to_path_buf());
375    Ok((opts, profile))
376}
377
378impl ChromiumBackend {
379    /// Launch a new headless Chromium instance.
380    pub async fn launch() -> Result<Self, BrowserError> {
381        Self::launch_with_viewport(1280, 720).await
382    }
383
384    /// Launch with specific viewport dimensions (headless).
385    pub async fn launch_with_viewport(width: u32, height: u32) -> Result<Self, BrowserError> {
386        Self::launch_with_options(LaunchOptions {
387            width,
388            height,
389            headless: true,
390            extra_args: Vec::new(),
391            profile_dir: None,
392        })
393        .await
394    }
395
396    /// Launch with a fresh profile owned until this backend is dropped.
397    /// Cookies and sign-ins never persist into another isolated session.
398    /// Explicit ownership overrides both opts.profile_dir and the environment.
399    pub async fn launch_isolated_with_options(opts: LaunchOptions) -> Result<Self, BrowserError> {
400        let (opts, profile) = isolated_launch_options(opts)?;
401        let mut backend = Self::launch_with_options(opts).await?;
402        // A SingletonLock fallback already owns the directory it actually uses.
403        // Preserve that owner; replacing it would delete a live profile. Our
404        // unused original directory then drops normally.
405        if backend._profile_dir.is_none() {
406            backend._profile_dir = Some(profile);
407        }
408        Ok(backend)
409    }
410
411    /// Launch with full options, including headless/headed toggle.
412    ///
413    /// `headless = true` uses Chromium's new headless mode (`--headless=new`);
414    /// `headless = false` shows a visible window (for interactive auth etc.).
415    pub async fn launch_with_options(opts: LaunchOptions) -> Result<Self, BrowserError> {
416        // Per-instance Chromium profile directory.
417        //
418        // chromiumoxide defaults to a fixed path under $TMPDIR;
419        // when the same default is reused by N parallel processes
420        // (e.g. Playwright workers each constructing a CarRuntime),
421        // they contend over Chromium's `SingletonLock` and the
422        // losers either silently get a wrong-port DevTools handshake
423        // or hang on navigate (#148).
424        //
425        // Each ChromiumBackend now gets its own tempdir, scoped to
426        // its lifetime. Callers who *want* persistence (cookies +
427        // localStorage between runs) can set CAR_BROWSER_PROFILE_DIR
428        // and accept they're back on the hook for parallel-launch
429        // coordination.
430        //
431        // Resolution order: the caller's explicit `profile_dir`, then the
432        // user's `CAR_BROWSER_PROFILE_DIR`, then the ephemeral default. A
433        // persistent directory is CLAIMED for this backend's lifetime; a
434        // second launch wanting the same one falls back to ephemeral rather
435        // than dying on Chromium's SingletonLock (see `crate::profile`).
436        let requested = opts.profile_dir.clone().or_else(|| {
437            std::env::var("CAR_BROWSER_PROFILE_DIR")
438                .ok()
439                .filter(|p| !p.is_empty())
440                .map(std::path::PathBuf::from)
441        });
442        let claim = requested.as_deref().and_then(|dir| {
443            let claim = crate::profile::ProfileClaim::acquire(dir);
444            if claim.is_none() {
445                tracing::warn!(
446                    profile_dir = %dir.display(),
447                    "another browser in this process already holds this Chromium profile; \
448                     launching against a throwaway profile instead — this browser starts \
449                     signed out"
450                );
451            }
452            claim
453        });
454        let (profile_dir, profile_handle) = match (&requested, &claim) {
455            // Persistent, and ours.
456            (Some(dir), Some(_)) => (dir.clone(), None),
457            // Either no persistence was asked for, or it was asked for and
458            // somebody else holds it — both land on a fresh tempdir.
459            _ => {
460                let td = tempfile::Builder::new()
461                    .prefix("car-browser-profile-")
462                    .tempdir()
463                    .map_err(|e| {
464                        BrowserError::NotAvailable(format!("create per-instance profile dir: {e}"))
465                    })?;
466                (td.path().to_path_buf(), Some(td))
467            }
468        };
469
470        // Launch, and if a SHARED profile directory turns out to be held by
471        // another PROCESS, fall back to a throwaway one.
472        //
473        // The in-process claim above cannot see across processes, and the
474        // shipped topology has two of them: two supervised agent processes
475        // both derive `$CAR_HOME/browser-profile`, so the second one's
476        // Chromium dies at startup on `SingletonLock: File exists`. A file
477        // lock would catch it, but its own post-crash staleness is a worse
478        // failure mode — and Chromium already tells us, precisely, in the
479        // error. Reacting to that is the smaller and more honest mechanism:
480        // the same degradation as the in-process fallback (this browser works
481        // and starts signed out) instead of no browser at all.
482        //
483        // Once only. A second SingletonLock failure on a FRESH tempdir would
484        // not mean contention, so retrying again would just be a loop.
485        // `profile_dir` is not needed past this point — the config carries it
486        // and `profile_handle` owns the tempdir's lifetime — so the launched
487        // directory is bound out only to keep the two arms symmetric.
488        let (browser, handler, _launched_in, profile_handle, claim) =
489            match launch_chromium(&opts, &profile_dir).await {
490                Ok((browser, handler)) => (browser, handler, profile_dir, profile_handle, claim),
491                Err(error) if profile_handle.is_none() && is_profile_in_use(&error) => {
492                    tracing::warn!(
493                        profile_dir = %profile_dir.display(),
494                        %error,
495                        "another process already holds this Chromium profile; relaunching \
496                         against a throwaway profile — this browser starts signed out"
497                    );
498                    let td = tempfile::Builder::new()
499                        .prefix("car-browser-profile-")
500                        .tempdir()
501                        .map_err(|e| {
502                            BrowserError::NotAvailable(format!(
503                                "create per-instance profile dir: {e}"
504                            ))
505                        })?;
506                    let fallback = td.path().to_path_buf();
507                    let (browser, handler) =
508                        launch_chromium(&opts, &fallback).await.map_err(|e| {
509                            BrowserError::NotAvailable(format!("Failed to launch Chrome: {e}"))
510                        })?;
511                    // Release the claim: this backend is not using that
512                    // directory after all, so the next launch in this process
513                    // may try it.
514                    (browser, handler, fallback, Some(td), None)
515                }
516                Err(e) => {
517                    return Err(BrowserError::NotAvailable(format!(
518                        "Failed to launch Chrome: {e}"
519                    )))
520                }
521            };
522        let mut browser = browser;
523        let mut handler = handler;
524
525        // Capture the Chrome subprocess PID up front. `get_mut_child`
526        // returns the underlying tokio Child; its `id()` is `Some`
527        // until the process is reaped. We keep this so `Drop` can
528        // SIGKILL synchronously without awaiting (see field docs).
529        let chrome_pid = browser.get_mut_child().and_then(|c| c.inner.id());
530
531        // Spawn the CDP event handler. We hold the JoinHandle so
532        // shutdown()/Drop can abort it — otherwise it lives for the
533        // process lifetime and keeps the CDP channel open, which
534        // prevents chromiumoxide from cleanly tearing down the
535        // Browser when callers expect `_browser.take()` to suffice.
536        let handler_task =
537            tokio::spawn(async move { while let Some(_event) = handler.next().await {} });
538
539        let page = browser
540            .new_page("about:blank")
541            .await
542            .map_err(|e| BrowserError::NotAvailable(format!("Failed to create page: {}", e)))?;
543
544        // Exactly one tab, open and active, mirroring the single-page
545        // backend this replaces — multi-tab support only changes behavior
546        // once a caller actually opens a second tab.
547        let (mut tabs, _initial_rx) = TabRegistry::new();
548        tabs.open(page, "about:blank", "");
549
550        Ok(Self {
551            tabs: std::sync::RwLock::new(tabs),
552            browser: Arc::new(RwLock::new(Some(browser))),
553            viewport_width: opts.width,
554            viewport_height: opts.height,
555            cached_url: std::sync::RwLock::new("about:blank".to_string()),
556            ax_node_cache: std::sync::RwLock::new(HashMap::new()),
557            _profile_dir: profile_handle,
558            _profile_claim: claim,
559            chrome_pid,
560            handler_task: StdMutex::new(Some(handler_task)),
561            reopen: tokio::sync::Mutex::new(()),
562        })
563    }
564
565    /// OS process ID of the spawned Chrome subprocess, if known.
566    ///
567    /// Returns `None` when chromiumoxide did not spawn the process
568    /// itself (e.g. attached to an existing browser, which we do
569    /// not currently do but the API allows). Stable across the
570    /// lifetime of this backend — Chrome is not respawned on its
571    /// own — so callers can use it to assert cleanup in tests.
572    pub fn chrome_pid(&self) -> Option<u32> {
573        self.chrome_pid
574    }
575
576    async fn get_page(&self) -> Result<Page, BrowserError> {
577        self.tabs
578            .read()
579            .map_err(tabs_lock_poisoned)?
580            .active_page()
581            .ok_or(BrowserError::NotAvailable("Page closed".into()))
582    }
583
584    /// The live CDP page handle for the ACTIVE tab.
585    ///
586    /// Exposed for callers that need to drive CDP directly rather than through
587    /// [`BrowserBackend`] — screen recording ([`crate::recorder`]) subscribes to
588    /// `Page.screencastFrame`, which has no equivalent on the backend trait
589    /// (that trait is deliberately about ACTIONS, not raw protocol access).
590    /// A caller that wants a live preview to follow the active tab as it
591    /// changes should pair this with [`Self::subscribe_tabs`]: watch for a
592    /// `TabEvent::ActiveChanged`, then call this again to move capture to
593    /// the new page.
594    pub async fn page_handle(&self) -> Result<Page, BrowserError> {
595        self.get_page().await
596    }
597
598    // =========================================================================
599    // Tabs
600    // =========================================================================
601    //
602    // Inherent methods, not part of `BrowserBackend` — the trait is
603    // deliberately scoped to "act on THE page" (Manifesto Principles 3-5:
604    // perception + human-equivalent input on one page at a time), and every
605    // method on it keeps meaning exactly that, now routed through whichever
606    // tab is active. Tab management is a separate, control-surface-facing
607    // capability layered on top, which is why it lives here instead of on
608    // the trait: a caller that only ever drives one page (the FFI bindings,
609    // `car-server-core`'s `browse_*` tools) never needs to know tabs exist.
610
611    /// List every open tab, in strip order, refreshed against live CDP state.
612    ///
613    /// A background tab can navigate on its own — a clicked link, a JS
614    /// redirect — without ever going through [`Self::navigate`], so a purely
615    /// cached value would go stale for anything but the tab that was active
616    /// at the time. Best-effort per tab: a refresh failure on one tab (mid
617    /// navigation, target gone) leaves its last-known values rather than
618    /// failing the whole list.
619    pub async fn list_tabs(&self) -> Result<Vec<TabInfo>, BrowserError> {
620        let pages: Vec<(TabId, Page)> = self.tabs.read().map_err(tabs_lock_poisoned)?.pages();
621
622        // Bounded per tab, and fetched concurrently.
623        //
624        // This is the drawer's hot path — every presentation refresh calls
625        // it — and it was a sequential walk with no deadline, so ONE wedged
626        // target (a page in a modal beforeunload, a hung renderer) blocked
627        // the whole tab strip indefinitely. The documented behaviour is
628        // best-effort per tab: `fetch_nav_state` returning `None` already
629        // means "keep the last known state for this tab", which was only
630        // reachable for a target that errored PROMPTLY. The timeout is what
631        // makes it reachable for one that hangs.
632        let refreshed = futures::future::join_all(pages.iter().map(|(id, page)| async move {
633            let state = tokio::time::timeout(NAV_STATE_TIMEOUT, fetch_nav_state(page))
634                .await
635                .ok()
636                .flatten();
637            (*id, state)
638        }))
639        .await;
640        if let Ok(mut tabs) = self.tabs.write() {
641            for (id, state) in refreshed {
642                if let Some((url, title, can_back, can_fwd)) = state {
643                    tabs.update_nav_state(id, url, title, can_back, can_fwd);
644                }
645            }
646        }
647
648        self.tabs
649            .read()
650            .map(|tabs| tabs.list())
651            .map_err(tabs_lock_poisoned)
652    }
653
654    /// Open a new blank tab and make it active. Returns the new tab's id.
655    pub async fn open_tab(&self) -> Result<TabId, BrowserError> {
656        let page = {
657            let guard = self.browser.read().await;
658            let browser = guard
659                .as_ref()
660                .ok_or_else(|| BrowserError::NotAvailable("Browser closed".into()))?;
661            browser
662                .new_page("about:blank")
663                .await
664                .map_err(|e| BrowserError::PlatformInternal(format!("open tab: {e}")))?
665        };
666        let id = self
667            .tabs
668            .write()
669            .map_err(tabs_lock_poisoned)?
670            .open(page, "about:blank", "");
671        // Opening a tab always changes which one is active (the new tab).
672        self.on_active_page_changed();
673        Ok(id)
674    }
675
676    /// Close the tab `id`. Closing the active tab activates a neighbor;
677    /// closing the last tab leaves the well-defined empty state (no page)
678    /// rather than a crash or a dangling handle. A safe no-op if `id` does
679    /// not name an open tab — closing something already closed is not an
680    /// error.
681    pub async fn close_tab(&self, id: TabId) -> Result<(), BrowserError> {
682        let (page, active_changed) = {
683            let mut tabs = self.tabs.write().map_err(tabs_lock_poisoned)?;
684            let before = tabs.active_id();
685            let page = tabs.close(id);
686            (page, before != tabs.active_id())
687        };
688        if let Some(page) = page {
689            let _ = timeout(Duration::from_secs(2), page.close()).await;
690        }
691        if active_changed {
692            self.on_active_page_changed();
693        }
694        Ok(())
695    }
696
697    /// Make `id` the active tab. Errors if no tab has that id.
698    ///
699    /// **This does NOT send `Page.bringToFront`,** and that is load-bearing
700    /// for the drawer rather than an oversight: CAR's "active tab" is which
701    /// target its own calls address, and the screencast attaches per target,
702    /// so a background target still composites and still emits frames. The
703    /// assumption being relied on is exactly that — CDP screencast works on a
704    /// non-foreground target. If a future Chromium stops compositing
705    /// background targets, the symptom is a drawer that goes still after a
706    /// tab switch, and the fix is a `bringToFront` here (which would also
707    /// raise a real window for a HEADED browser, so it is not free).
708    pub async fn switch_tab(&self, id: TabId) -> Result<(), BrowserError> {
709        let active_changed = {
710            let mut tabs = self.tabs.write().map_err(tabs_lock_poisoned)?;
711            let before = tabs.active_id();
712            tabs.switch(id)
713                .map_err(|e| BrowserError::NotAvailable(e.to_string()))?;
714            before != tabs.active_id()
715        };
716        if active_changed {
717            self.on_active_page_changed();
718        }
719        Ok(())
720    }
721
722    /// The active tab's id, if any (`None` in the empty state — no tabs
723    /// open).
724    pub fn active_tab_id(&self) -> Option<TabId> {
725        self.tabs.read().ok().and_then(|t| t.active_id())
726    }
727
728    /// Move the ACTIVE tab one step through its own history.
729    ///
730    /// `Page.navigateToHistoryEntry` on the adjacent entry, which is what
731    /// actually drives Chromium's session history — a synthesised Cmd+Left
732    /// keystroke does not, because the shortcut is browser chrome the CDP
733    /// input domain never reaches.
734    ///
735    /// Per-tab by construction: the history lives on the page, and
736    /// [`Self::get_page`] resolves the active tab, so switching tabs switches
737    /// which history this walks. Errors when there is nothing in that
738    /// direction rather than silently doing nothing — the nav buttons are
739    /// disabled exactly then, so arriving here means a race or a client bug,
740    /// and a silent success would be indistinguishable from a page that
741    /// failed to move.
742    pub async fn step_history(&self, step: HistoryStep) -> Result<(), BrowserError> {
743        let page = self.get_page().await?;
744        let history = page
745            .execute(GetNavigationHistoryParams::default())
746            .await
747            .map_err(|e| BrowserError::NavigationFailed(format!("getNavigationHistory: {e}")))?;
748        let entries = &history.result.entries;
749        let floor = history_floor(entries.first().map(|e| e.url.as_str()));
750        let entry_ids: Vec<i64> = entries.iter().map(|e| e.id).collect();
751        let entry_id = adjacent_entry_id(history.result.current_index, &entry_ids, step, floor)
752            .ok_or_else(|| BrowserError::NavigationFailed(step.nothing_there().to_string()))?;
753
754        page.execute(NavigateToHistoryEntryParams::new(entry_id))
755            .await
756            .map_err(|e| BrowserError::NavigationFailed(format!("navigateToHistoryEntry: {e}")))?;
757        self.settle_after_history_move(&page).await;
758        Ok(())
759    }
760
761    /// Reload the ACTIVE tab.
762    ///
763    /// Plain reload, not cache-bypassing: this is the nav bar's reload button,
764    /// which is the ordinary one.
765    pub async fn reload(&self) -> Result<(), BrowserError> {
766        let page = self.get_page().await?;
767        page.execute(ReloadParams::default())
768            .await
769            .map_err(|e| BrowserError::NavigationFailed(format!("reload: {e}")))?;
770        self.settle_after_history_move(&page).await;
771        Ok(())
772    }
773
774    /// Shared tail of the three history operations: wait for the resulting
775    /// navigation, then resync the caches `navigate()` resyncs, so a caller
776    /// listing tabs immediately after does not see pre-move values.
777    ///
778    /// Best-effort on the wait: a same-document history move (an in-page
779    /// anchor) may not produce a navigation event at all, and that is a
780    /// successful move, not a failure.
781    async fn settle_after_history_move(&self, page: &Page) {
782        let _ = timeout(Duration::from_secs(10), page.wait_for_navigation()).await;
783        self.refresh_cached_url().await;
784        self.sync_active_tab_nav_state(page).await;
785    }
786
787    /// Subscribe to tab lifecycle and nav-state change notifications.
788    /// `watch::Receiver::borrow()` sees the current full snapshot
789    /// immediately; `.changed().await` waits for the next mutation
790    /// (open/close/switch/nav-state update). This is the seam a
791    /// presentation surface uses to keep a tab strip live, and the one a
792    /// screencast pump owner uses to notice the active tab changed and move
793    /// capture to the new page (see [`Self::page_handle`] and
794    /// `crate::screencast`).
795    pub fn subscribe_tabs(&self) -> watch::Receiver<TabsSnapshot> {
796        // The registry always holds its own anchor receiver (see
797        // `TabRegistry`'s field doc), so this lock can't be poisoned by a
798        // panic inside `subscribe()` itself — `expect` here only fires if a
799        // PRIOR operation panicked while holding the write lock.
800        self.tabs.read().expect("tabs lock poisoned").subscribe()
801    }
802
803    /// Invalidate state that's only valid for whichever page was PREVIOUSLY
804    /// active, after the active page identity has just changed (tab
805    /// open/switch/close-with-neighbor).
806    ///
807    /// Clears `ax_node_cache` (ax_N ids only make sense against whichever
808    /// page's tree produced them — carrying them over would resolve a
809    /// click/type against the wrong page's element; callers must call
810    /// `get_accessibility_tree()` again on the new active page first, same
811    /// as they already must after any navigation) and resyncs `cached_url`
812    /// to the new active tab's last-known URL, so `get_current_url()`
813    /// reflects the tab that's now active rather than the one that was.
814    fn on_active_page_changed(&self) {
815        if let Ok(mut cache) = self.ax_node_cache.write() {
816            cache.clear();
817        }
818        let active_url = self.tabs.read().ok().and_then(|t| t.active_url());
819        if let Some(url) = active_url {
820            if let Ok(mut cached) = self.cached_url.write() {
821                *cached = url;
822            }
823        }
824        // Empty state (no active tab, e.g. the last tab just closed):
825        // deliberately leave cached_url at its last value, matching the
826        // pre-tabs backend's shutdown() behavior — see the field doc.
827    }
828
829    /// Update the cached URL by querying the page asynchronously.
830    async fn refresh_cached_url(&self) {
831        if let Ok(page) = self.get_page().await {
832            // Bounded, like every other post-navigation CDP read here. This
833            // runs from `settle_after_history_move`, which carefully bounds its
834            // navigation wait and then made two unbounded round trips straight
835            // after it — so a target that wedged during the history move hung
836            // the nav-bar Back/Forward/Reload call itself, indefinitely.
837            if let Ok(Ok(Some(url))) = timeout(NAV_STATE_TIMEOUT, page.url()).await {
838                if let Ok(mut cached) = self.cached_url.write() {
839                    *cached = url;
840                }
841            }
842        }
843    }
844
845    /// Refresh the active tab's registry entry (url/title/back-forward) from
846    /// `page`'s live CDP state, after a navigation this backend just drove.
847    /// Best-effort: a failed refresh just leaves the registry's prior
848    /// values, same tolerance as `list_tabs`'s per-tab refresh.
849    async fn sync_active_tab_nav_state(&self, page: &Page) {
850        let Some(id) = self.active_tab_id() else {
851            return;
852        };
853        // The same `NAV_STATE_TIMEOUT` `list_tabs` wraps this identical call
854        // in, put in the HELPER so both call sites get it. Best-effort already
855        // means "keep the last known state for this tab" — the deadline is what
856        // makes that reachable for a target that hangs rather than errors.
857        if let Ok(Some((url, title, can_back, can_fwd))) =
858            timeout(NAV_STATE_TIMEOUT, fetch_nav_state(page)).await
859        {
860            if let Ok(mut tabs) = self.tabs.write() {
861                tabs.update_nav_state(id, url, title, can_back, can_fwd);
862            }
863        }
864    }
865
866    /// Look up the BackendNodeId for an ax_N node ID from the cache.
867    fn resolve_backend_node_id(&self, node_id: &str) -> Result<BackendNodeId, BrowserError> {
868        let cache = self.ax_node_cache.read().map_err(|e| {
869            BrowserError::PlatformInternal(format!("Failed to read ax_node_cache: {}", e))
870        })?;
871        cache.get(node_id).copied().ok_or_else(|| {
872            BrowserError::ElementNotFound(format!(
873                "No cached BackendNodeId for '{}'. Call get_accessibility_tree() first.",
874                node_id
875            ))
876        })
877    }
878
879    /// Get the bounding box center for a BackendNodeId via CDP DOM.getBoxModel.
880    async fn get_element_center(
881        &self,
882        backend_node_id: BackendNodeId,
883    ) -> Result<(f64, f64), BrowserError> {
884        let page = self.get_page().await?;
885        let params = GetBoxModelParams::builder()
886            .backend_node_id(backend_node_id)
887            .build();
888        let result = page
889            .execute(params)
890            .await
891            .map_err(|e| BrowserError::ElementNotFound(format!("DOM.getBoxModel failed: {}", e)))?;
892
893        // The content quad is 8 floats: [x1,y1, x2,y2, x3,y3, x4,y4]
894        let quad = result.result.model.content.inner();
895        if quad.len() < 8 {
896            return Err(BrowserError::PlatformInternal(
897                "Content quad has fewer than 8 values".into(),
898            ));
899        }
900        // Compute center from the four corners
901        let cx = (quad[0] + quad[2] + quad[4] + quad[6]) / 4.0;
902        let cy = (quad[1] + quad[3] + quad[5] + quad[7]) / 4.0;
903        Ok((cx, cy))
904    }
905
906    /// Focus a DOM element by BackendNodeId via CDP DOM.focus.
907    async fn focus_by_backend_node_id(
908        &self,
909        backend_node_id: BackendNodeId,
910    ) -> Result<(), BrowserError> {
911        let page = self.get_page().await?;
912        let params = FocusParams::builder()
913            .backend_node_id(backend_node_id)
914            .build();
915        page.execute(params)
916            .await
917            .map_err(|e| BrowserError::InputFailed(format!("DOM.focus failed: {}", e)))?;
918        Ok(())
919    }
920}
921
922#[async_trait]
923impl BrowserBackend for ChromiumBackend {
924    async fn capture_screenshot(&self) -> Result<Vec<u8>, BrowserError> {
925        let page = self.get_page().await?;
926        page.screenshot(
927            chromiumoxide::page::ScreenshotParams::builder()
928                .format(CaptureScreenshotFormat::Png)
929                .build(),
930        )
931        .await
932        .map_err(|e| BrowserError::ScreenshotFailed(e.to_string()))
933    }
934
935    async fn get_accessibility_tree(&self) -> Result<Vec<A11yNode>, BrowserError> {
936        let page = self.get_page().await?;
937        let result = page
938            .execute(GetFullAxTreeParams::default())
939            .await
940            .map_err(|e| BrowserError::AccessibilityFailed(e.to_string()))?;
941
942        // Update cached URL while we have the page
943        self.refresh_cached_url().await;
944
945        let mut new_cache = AxNodeCache::new();
946
947        let mut nodes: Vec<A11yNode> = Vec::new();
948        for (i, n) in result.result.nodes.iter().enumerate() {
949            if n.ignored {
950                continue;
951            }
952
953            let ax_id = format!("ax_{}", i);
954
955            // Cache the BackendNodeId for later use by click_element/focus_element
956            if let Some(backend_id) = n.backend_dom_node_id {
957                new_cache.insert(ax_id.clone(), backend_id);
958            }
959
960            let role = n
961                .role
962                .as_ref()
963                .and_then(|r| r.value.as_ref())
964                .and_then(|v| v.as_str())
965                .unwrap_or("unknown")
966                .to_string();
967
968            let name = n
969                .name
970                .as_ref()
971                .and_then(|v| v.value.as_ref())
972                .and_then(|v| v.as_str())
973                .filter(|s| !s.is_empty())
974                .map(|s| s.to_string());
975
976            let value = n
977                .value
978                .as_ref()
979                .and_then(|v| v.value.as_ref())
980                .and_then(|v| v.as_str())
981                .filter(|s| !s.is_empty())
982                .map(|s| s.to_string());
983
984            let children: Vec<String> = n
985                .child_ids
986                .as_ref()
987                .map(|ids| ids.iter().map(|id| format!("ax_{}", id.as_ref())).collect())
988                .unwrap_or_default();
989
990            // Resolve real bounds via DOM.getBoxModel if we have a backend node ID.
991            // Fall back to zero-sized bounds for nodes without a DOM backing (e.g. root).
992            let bounds = if let Some(backend_id) = n.backend_dom_node_id {
993                let bm_params = GetBoxModelParams::builder()
994                    .backend_node_id(backend_id)
995                    .build();
996                if let Ok(bm_result) = page.execute(bm_params).await {
997                    let quad = bm_result.result.model.content.inner();
998                    if quad.len() >= 8 {
999                        let x = quad[0];
1000                        let y = quad[1];
1001                        let width = quad[2] - quad[0];
1002                        let height = quad[5] - quad[1];
1003                        Bounds::new(x, y, width.max(0.0), height.max(0.0))
1004                    } else {
1005                        Bounds::new(0.0, 0.0, 0.0, 0.0)
1006                    }
1007                } else {
1008                    Bounds::new(0.0, 0.0, 0.0, 0.0)
1009                }
1010            } else {
1011                Bounds::new(0.0, 0.0, 0.0, 0.0)
1012            };
1013
1014            nodes.push(A11yNode {
1015                node_id: ax_id,
1016                role,
1017                name,
1018                value,
1019                bounds,
1020                children,
1021                focusable: true,
1022                focused: false,
1023                disabled: false,
1024            });
1025        }
1026
1027        // Update the shared cache
1028        if let Ok(mut cache) = self.ax_node_cache.write() {
1029            *cache = new_cache;
1030        }
1031
1032        Ok(nodes)
1033    }
1034
1035    fn get_viewport(&self) -> Result<Viewport, BrowserError> {
1036        Ok(Viewport {
1037            width: self.viewport_width,
1038            height: self.viewport_height,
1039            device_pixel_ratio: 1.0,
1040        })
1041    }
1042
1043    fn get_current_url(&self) -> Result<String, BrowserError> {
1044        self.cached_url
1045            .read()
1046            .map(|url| url.clone())
1047            .map_err(|e| BrowserError::PlatformInternal(format!("URL cache lock poisoned: {}", e)))
1048    }
1049
1050    async fn get_page_title(&self) -> Result<String, BrowserError> {
1051        let page = self.get_page().await?;
1052        page.evaluate("document.title")
1053            .await
1054            .map_err(|e| BrowserError::PlatformInternal(e.to_string()))?
1055            .into_value::<String>()
1056            .map_err(|e| BrowserError::PlatformInternal(e.to_string()))
1057    }
1058
1059    async fn navigate(&self, url: &str) -> Result<(), BrowserError> {
1060        // Closing the last tab is an explicit, well-defined outcome (the
1061        // drawer returns to the empty state — "Enter a URL to open a
1062        // page") — but `get_page()` has nothing to resolve once the tab
1063        // registry is empty, and would fail this call with "Page closed".
1064        // Reopen a tab first instead: this is also the ONLY way the
1065        // supervised agent can recover once the user has closed every tab,
1066        // since `browse_*` has no separate tab-open tool — without this,
1067        // every subsequent browse_* call fails the same way for the rest
1068        // of the run. `open_tab()` itself errors if the browser is gone,
1069        // so this only ever opens a tab when the browser is actually alive.
1070        // Serialized across the check AND the reopen, by an async mutex.
1071        //
1072        // Taking the registry's own write lock here did NOT do that, whatever
1073        // the comment used to claim: `open_tab()` is async and a
1074        // `std::sync::RwLockWriteGuard` cannot be held across an await without
1075        // making the future `!Send`, so the guard was released at the end of
1076        // its block — a plain read wearing a write lock's clothes. Two
1077        // concurrent navigations into the empty state (the drawer's URL bar
1078        // and an in-flight `browse_navigate` during a take-control handover is
1079        // the live pair) could both see "no tabs", both open one, and strand
1080        // one of the two navigations on a page nobody is watching.
1081        //
1082        // The re-check inside the guard is what makes the loser cheap: it
1083        // sees the winner's tab and opens nothing.
1084        let _reopen = self.reopen.lock().await;
1085        let needs_tab = {
1086            let tabs = self.tabs.read().map_err(tabs_lock_poisoned)?;
1087            needs_a_tab_before_navigating(&tabs)
1088        };
1089        if needs_tab {
1090            self.open_tab().await?;
1091        }
1092        drop(_reopen);
1093        let page = self.get_page().await?;
1094        page.goto(url)
1095            .await
1096            .map_err(|e| BrowserError::NavigationFailed(e.to_string()))?;
1097        page.wait_for_navigation()
1098            .await
1099            .map_err(|e| BrowserError::NavigationFailed(e.to_string()))?;
1100
1101        // Update cached URL after navigation
1102        if let Ok(mut cached) = self.cached_url.write() {
1103            *cached = url.to_string();
1104        }
1105        // Also refresh from the page in case of redirects
1106        self.refresh_cached_url().await;
1107        // Keep the tab registry's per-tab nav state (url/title/back-forward)
1108        // current for whichever tab this navigation happened on, so a
1109        // caller listing tabs right after doesn't see stale pre-navigation
1110        // values while waiting for the next list_tabs() refresh sweep.
1111        self.sync_active_tab_nav_state(&page).await;
1112
1113        Ok(())
1114    }
1115
1116    async fn inject_click(&self, x: f64, y: f64) -> Result<(), BrowserError> {
1117        let page = self.get_page().await?;
1118        page.execute(
1119            DispatchMouseEventParams::builder()
1120                .r#type(DispatchMouseEventType::MousePressed)
1121                .x(x)
1122                .y(y)
1123                .button(MouseButton::Left)
1124                .click_count(1)
1125                .build()
1126                .unwrap(),
1127        )
1128        .await
1129        .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
1130
1131        page.execute(
1132            DispatchMouseEventParams::builder()
1133                .r#type(DispatchMouseEventType::MouseReleased)
1134                .x(x)
1135                .y(y)
1136                .button(MouseButton::Left)
1137                .click_count(1)
1138                .build()
1139                .unwrap(),
1140        )
1141        .await
1142        .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
1143
1144        Ok(())
1145    }
1146
1147    async fn inject_text(&self, text: &str) -> Result<(), BrowserError> {
1148        let page = self.get_page().await?;
1149        for ch in text.chars() {
1150            page.execute(
1151                DispatchKeyEventParams::builder()
1152                    .r#type(DispatchKeyEventType::Char)
1153                    .text(ch.to_string())
1154                    .build()
1155                    .unwrap(),
1156            )
1157            .await
1158            .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
1159        }
1160        Ok(())
1161    }
1162
1163    async fn inject_keypress(&self, key: &str, modifiers: &[Modifier]) -> Result<(), BrowserError> {
1164        let page = self.get_page().await?;
1165        let cdp_modifiers = modifiers_to_cdp_flags(modifiers);
1166        // `key` + `modifiers` alone delivers a DOM event a page can observe
1167        // and nothing else: Chromium's editing layer dispatches on
1168        // `windowsVirtualKeyCode`, and text entry on `text`. Without them
1169        // Backspace deletes nothing, the arrows move no caret, and Enter
1170        // submits no form — all live-observed. See `crate::keymap`.
1171        let held = modifiers
1172            .iter()
1173            .any(|m| matches!(m, Modifier::Control | Modifier::Meta));
1174        let d = crate::keymap::describe_key(key, held);
1175
1176        for kind in [DispatchKeyEventType::KeyDown, DispatchKeyEventType::KeyUp] {
1177            let mut builder = DispatchKeyEventParams::builder()
1178                .r#type(kind.clone())
1179                .key(d.key.clone())
1180                .modifiers(cdp_modifiers);
1181            if let Some(code) = d.code {
1182                builder = builder.code(code.to_string());
1183            }
1184            if let Some(vk) = d.virtual_key_code {
1185                // Chromium reads the native code on macOS for some editing
1186                // commands; setting both to the same value is what Puppeteer
1187                // does and is correct for every key this maps.
1188                builder = builder
1189                    .windows_virtual_key_code(vk)
1190                    .native_virtual_key_code(vk);
1191            }
1192            // `text` belongs on the keyDown only — a keyUp carrying text
1193            // inserts the character a second time.
1194            if kind == DispatchKeyEventType::KeyDown {
1195                if let Some(text) = &d.text {
1196                    builder = builder.text(text.clone()).unmodified_text(text.clone());
1197                }
1198            }
1199            page.execute(builder.build().unwrap())
1200                .await
1201                .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
1202        }
1203
1204        Ok(())
1205    }
1206
1207    /// Insert `text` at the caret, replacing the selection — paste semantics.
1208    ///
1209    /// A CDP key event can never paste: the clipboard is the browser's, not
1210    /// the page's, and `Input.dispatchKeyEvent` has no access to it, so a
1211    /// synthesised Cmd+V delivers a key event and nothing arrives. The host
1212    /// reads its own pasteboard and sends the string here instead.
1213    ///
1214    /// `Input.insertText` rather than per-character key events because that
1215    /// IS the paste: one insertion, replacing the selection, without firing
1216    /// N keydown handlers a page might treat as N separate keystrokes.
1217    async fn insert_text(&self, text: &str) -> Result<(), BrowserError> {
1218        let page = self.get_page().await?;
1219        page.execute(InsertTextParams::new(text.to_string()))
1220            .await
1221            .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
1222        Ok(())
1223    }
1224
1225    async fn inject_scroll(&self, delta_y: i32) -> Result<(), BrowserError> {
1226        let page = self.get_page().await?;
1227        page.execute(
1228            DispatchMouseEventParams::builder()
1229                .r#type(DispatchMouseEventType::MouseWheel)
1230                .x(self.viewport_width as f64 / 2.0)
1231                .y(self.viewport_height as f64 / 2.0)
1232                .delta_x(0.0)
1233                .delta_y(delta_y as f64)
1234                .build()
1235                .unwrap(),
1236        )
1237        .await
1238        .map_err(|e| BrowserError::InputFailed(e.to_string()))?;
1239        Ok(())
1240    }
1241
1242    async fn click_element(&self, node_id: &str) -> Result<(), BrowserError> {
1243        let backend_node_id = self.resolve_backend_node_id(node_id)?;
1244        let (cx, cy) = self.get_element_center(backend_node_id).await?;
1245        self.inject_click(cx, cy).await
1246    }
1247
1248    async fn type_into_element(&self, node_id: &str, text: &str) -> Result<(), BrowserError> {
1249        let backend_node_id = self.resolve_backend_node_id(node_id)?;
1250        self.focus_by_backend_node_id(backend_node_id).await?;
1251        self.inject_text(text).await
1252    }
1253
1254    async fn focus_element(&self, node_id: &str) -> Result<(), BrowserError> {
1255        let backend_node_id = self.resolve_backend_node_id(node_id)?;
1256        self.focus_by_backend_node_id(backend_node_id).await
1257    }
1258
1259    async fn is_page_loaded(&self) -> Result<bool, BrowserError> {
1260        let page = self.get_page().await?;
1261        let state = page
1262            .evaluate("document.readyState")
1263            .await
1264            .map_err(|e| BrowserError::PlatformInternal(e.to_string()))?
1265            .into_value::<String>()
1266            .unwrap_or_default();
1267        Ok(state == "complete")
1268    }
1269
1270    async fn wait_until(
1271        &self,
1272        condition: &WaitCondition,
1273        timeout_ms: u64,
1274    ) -> Result<bool, BrowserError> {
1275        let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
1276
1277        // Snapshot the URL at entry for UrlChanged comparisons.
1278        // Errors here turn into an empty baseline rather than
1279        // failing the wait — a missing baseline matches anything.
1280        let entry_url = self.get_current_url().unwrap_or_default();
1281
1282        loop {
1283            let met = match condition {
1284                WaitCondition::PageLoaded => self.is_page_loaded().await?,
1285                WaitCondition::UrlChanged => {
1286                    let now = self.get_current_url().unwrap_or_default();
1287                    !now.is_empty() && now != entry_url
1288                }
1289                WaitCondition::A11yContainsText { text } => {
1290                    let needle = text.to_lowercase();
1291                    let nodes = self.get_accessibility_tree().await?;
1292                    nodes.iter().any(|n| {
1293                        n.name
1294                            .as_ref()
1295                            .map(|name| name.to_lowercase().contains(&needle))
1296                            .unwrap_or(false)
1297                    })
1298                }
1299                WaitCondition::ElementWithName {
1300                    name_contains,
1301                    role,
1302                } => {
1303                    self.element_exists_a11y(name_contains, role.as_deref())
1304                        .await?
1305                }
1306            };
1307            if met {
1308                return Ok(true);
1309            }
1310            if tokio::time::Instant::now() >= deadline {
1311                return Ok(false);
1312            }
1313            tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
1314        }
1315    }
1316
1317    async fn element_exists_a11y(
1318        &self,
1319        name_contains: &str,
1320        role: Option<&str>,
1321    ) -> Result<bool, BrowserError> {
1322        let nodes = self.get_accessibility_tree().await?;
1323        Ok(nodes.iter().any(|n| {
1324            let name_match = n
1325                .name
1326                .as_ref()
1327                .map(|name| name.to_lowercase().contains(&name_contains.to_lowercase()))
1328                .unwrap_or(false);
1329            if !name_match {
1330                return false;
1331            }
1332            match role {
1333                Some(r) => n.role.to_lowercase() == r.to_lowercase(),
1334                None => true,
1335            }
1336        }))
1337    }
1338
1339    async fn set_cookies(
1340        &self,
1341        cookies: &[crate::models::CookieParam],
1342    ) -> Result<(), BrowserError> {
1343        let page = self.get_page().await?;
1344        for cookie in cookies {
1345            let mut cdp_cookie = chromiumoxide::cdp::browser_protocol::network::CookieParam::new(
1346                &cookie.name,
1347                &cookie.value,
1348            );
1349            cdp_cookie.domain = Some(cookie.domain.clone());
1350            cdp_cookie.path = Some(cookie.path.clone());
1351            if cookie.secure {
1352                cdp_cookie.secure = Some(true);
1353            }
1354            if cookie.http_only {
1355                cdp_cookie.http_only = Some(true);
1356            }
1357            page.set_cookie(cdp_cookie)
1358                .await
1359                .map_err(|e| BrowserError::PlatformInternal(format!("set_cookie failed: {}", e)))?;
1360        }
1361        Ok(())
1362    }
1363
1364    async fn set_local_storage(
1365        &self,
1366        origin: &str,
1367        items: &[(String, String)],
1368    ) -> Result<(), BrowserError> {
1369        let page = self.get_page().await?;
1370        // localStorage is origin-scoped — require the page already be at
1371        // the target origin so we don't silently navigate behind the
1372        // caller's back. Callers replay via `set_local_storage` after a
1373        // `navigate` to the matching origin (or before first `navigate`
1374        // if the caller wants the script to navigate elsewhere).
1375        let current = self.get_current_url().unwrap_or_default();
1376        if !current_origin_matches(&current, origin) {
1377            return Err(BrowserError::PlatformInternal(format!(
1378                "set_local_storage: page must be at origin '{}' first (currently '{}'). \
1379                 Add a `navigate` op before set_local_storage, or call set_local_storage \
1380                 before any navigate (pre-page state).",
1381                origin, current
1382            )));
1383        }
1384
1385        // Don't swallow JSON encoding errors — `serde_json::to_string` on a
1386        // &str can fail in theory; treat that as a platform bug, not a
1387        // silent empty string.
1388        for (key, value) in items {
1389            let k = serde_json::to_string(key)
1390                .map_err(|e| BrowserError::PlatformInternal(format!("encode key: {}", e)))?;
1391            let v = serde_json::to_string(value)
1392                .map_err(|e| BrowserError::PlatformInternal(format!("encode value: {}", e)))?;
1393            let js = format!("localStorage.setItem({}, {})", k, v);
1394            page.evaluate(js).await.map_err(|e| {
1395                BrowserError::PlatformInternal(format!("localStorage.setItem failed: {}", e))
1396            })?;
1397        }
1398        Ok(())
1399    }
1400
1401    async fn set_extra_headers(&self, headers: &[(String, String)]) -> Result<(), BrowserError> {
1402        let page = self.get_page().await?;
1403        // Enable network domain first
1404        page.execute(chromiumoxide::cdp::browser_protocol::network::EnableParams::default())
1405            .await
1406            .map_err(|e| BrowserError::PlatformInternal(format!("network enable failed: {}", e)))?;
1407
1408        let header_obj: serde_json::Value = headers
1409            .iter()
1410            .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
1411            .collect::<serde_json::Map<String, serde_json::Value>>()
1412            .into();
1413        let params = chromiumoxide::cdp::browser_protocol::network::SetExtraHttpHeadersParams::new(
1414            chromiumoxide::cdp::browser_protocol::network::Headers::new(header_obj),
1415        );
1416        page.execute(params).await.map_err(|e| {
1417            BrowserError::PlatformInternal(format!("set_extra_headers failed: {}", e))
1418        })?;
1419        Ok(())
1420    }
1421
1422    async fn shutdown(&self) -> Result<(), BrowserError> {
1423        // Order matters:
1424        //   1. Close every open tab's page (frees CDP resources).
1425        //   2. Abort the CDP event handler task so the channel
1426        //      isn't held open while we try to close the Browser.
1427        //   3. Send Browser.close via CDP, then wait on the Child.
1428        //
1429        // Each step is bounded by a short timeout so a hung or
1430        // crashed Chrome can't wedge shutdown. If `close`/`wait`
1431        // time out the explicit kill in `Drop` (or the next call
1432        // path) still terminates the process by PID.
1433        let pages: Vec<Page> = self
1434            .tabs
1435            .write()
1436            .map(|mut tabs| tabs.close_all())
1437            .unwrap_or_default();
1438        // Concurrently, so step 1's budget is 2s TOTAL rather than 2s per tab.
1439        // The tab strip made "several open tabs" ordinary and a sequential walk
1440        // turned the stated bound into N x 2s — `list_tabs` was given exactly
1441        // this treatment for exactly this reason.
1442        futures::future::join_all(
1443            pages
1444                .into_iter()
1445                .map(|page| async move { timeout(Duration::from_secs(2), page.close()).await }),
1446        )
1447        .await;
1448        if let Some(h) = self.handler_task.lock().ok().and_then(|mut g| g.take()) {
1449            h.abort();
1450        }
1451        if let Some(mut browser) = self.browser.write().await.take() {
1452            // Best-effort graceful close, then reap. If `close` is
1453            // unresponsive (Chrome already dead, CDP channel torn
1454            // down, etc.) fall back to `kill` so we never leave a
1455            // running subprocess behind.
1456            let close_ok = timeout(Duration::from_secs(2), browser.close())
1457                .await
1458                .map(|r| r.is_ok())
1459                .unwrap_or(false);
1460            if !close_ok {
1461                let _ = timeout(Duration::from_secs(2), browser.kill()).await;
1462            }
1463            let _ = timeout(Duration::from_secs(2), browser.wait()).await;
1464        }
1465        Ok(())
1466    }
1467}
1468
1469impl Drop for ChromiumBackend {
1470    /// Synchronous backstop for the `shutdown()` happy path.
1471    ///
1472    /// `shutdown()` is async, so callers who let a `ChromiumBackend`
1473    /// drop on a panic, an early return, or process exit never get a
1474    /// chance to run it. chromiumoxide's own `Browser::Drop` relies
1475    /// on tokio's `kill_on_drop`, which only fires while the tokio
1476    /// runtime is alive — which it usually isn't during teardown.
1477    ///
1478    /// macOS does not deliver a parent-death signal, so any Chrome
1479    /// subprocess still alive at this moment would be reparented to
1480    /// launchd (PPID=1) and leak forever. We avoid that by SIGKILL'ing
1481    /// the captured PID directly.
1482    fn drop(&mut self) {
1483        // Abort the CDP event-pump task. `abort()` is non-blocking;
1484        // the task is detached after this and won't be observable.
1485        if let Some(h) = self.handler_task.get_mut().ok().and_then(|g| g.take()) {
1486            h.abort();
1487        }
1488        // SIGKILL the Chrome subprocess if we still have its PID.
1489        // `kill(pid, 0)` is a liveness probe — if it errors with
1490        // ESRCH the process is already gone and we skip the signal.
1491        #[cfg(unix)]
1492        if let Some(pid) = self.chrome_pid {
1493            // SAFETY: `kill(2)` is a syscall with no aliasing or
1494            // memory-safety concerns. We only read errno via the
1495            // return value.
1496            unsafe {
1497                if libc::kill(pid as libc::pid_t, 0) == 0 {
1498                    libc::kill(pid as libc::pid_t, libc::SIGKILL);
1499                }
1500            }
1501        }
1502        // On non-Unix targets we rely on tokio's `kill_on_drop`,
1503        // which on Windows uses TerminateProcess synchronously
1504        // from the Child's Drop. The orphan pattern that motivated
1505        // this fix is macOS-specific.
1506    }
1507}
1508
1509#[cfg(test)]
1510mod tests {
1511    use super::*;
1512
1513    /// The entry ids CDP hands back are opaque and NOT positional — they are
1514    /// allocated per navigation — so the test data deliberately uses ids that
1515    /// are neither contiguous nor equal to their index. Selecting by index and
1516    /// then reading the id out is the whole job; getting that backwards would
1517    /// navigate to some unrelated entry.
1518    // ---- the cross-process profile fallback ----------------------------
1519
1520    /// The literal message Chromium emits when another live instance holds
1521    /// the profile — the case the in-process claim registry structurally
1522    /// cannot see, because the other holder is another PROCESS (two
1523    /// supervised agents both derive `$CAR_HOME/browser-profile`).
1524    const SINGLETON_LOCK_FAILURE: &str = "Failed to create /Users/x/.car/browser-profile/SingletonLock: File exists (17) Aborting now to avoid profile corruption.";
1525
1526    #[test]
1527    fn isolated_profiles_have_distinct_owned_directories_and_cleanup() {
1528        let assistant = tempfile::tempdir().unwrap();
1529        let opts = || LaunchOptions {
1530            profile_dir: Some(assistant.path().into()),
1531            ..Default::default()
1532        };
1533        let (first, owner_a) = isolated_launch_options(opts()).unwrap();
1534        let (second, owner_b) = isolated_launch_options(opts()).unwrap();
1535        let a = first.profile_dir.unwrap();
1536        let b = second.profile_dir.unwrap();
1537        assert_ne!(a, b);
1538        assert_ne!(a, assistant.path());
1539        assert_ne!(b, assistant.path());
1540        if let Some(operator_profile) = std::env::var_os("CAR_BROWSER_PROFILE_DIR") {
1541            assert_ne!(a, std::path::PathBuf::from(operator_profile.clone()));
1542            assert_ne!(b, std::path::PathBuf::from(operator_profile));
1543        }
1544        std::fs::write(a.join("cookie-fixture"), "a").unwrap();
1545        std::fs::write(b.join("cookie-fixture"), "b").unwrap();
1546        drop(owner_a);
1547        assert!(!a.exists());
1548        assert!(b.join("cookie-fixture").exists());
1549        assert!(assistant.path().exists());
1550        drop(owner_b);
1551        assert!(!b.exists());
1552        assert!(assistant.path().exists());
1553    }
1554
1555    #[test]
1556    fn a_singleton_lock_failure_is_recognised_as_the_profile_being_in_use() {
1557        assert!(is_profile_in_use(SINGLETON_LOCK_FAILURE));
1558        // The path and errno text vary between platforms and users; the
1559        // fragments matched do not.
1560        assert!(is_profile_in_use(
1561            "Failed to create /tmp/p/SingletonLock: File exists (17)"
1562        ));
1563        assert!(is_profile_in_use(
1564            "The profile appears to be in use by another Chromium process already"
1565        ));
1566        assert!(is_profile_in_use("Cannot create a profile directory"));
1567    }
1568
1569    /// Deliberately narrow: falling back on an unrelated failure would
1570    /// silently strand a user's sign-ins in a directory nothing reads.
1571    #[test]
1572    fn unrelated_launch_failures_do_not_trigger_the_fallback() {
1573        for error in [
1574            "Failed to launch Chrome: No such file or directory (os error 2)",
1575            "Config error: could not find chrome executable",
1576            "Connection closed before the DevTools handshake completed",
1577            "Timed out waiting for the browser to start",
1578        ] {
1579            assert!(!is_profile_in_use(error), "must not react to: {error}");
1580        }
1581    }
1582
1583    /// The retry decision, stated as the launch path applies it: retry once,
1584    /// only when the directory was SHARED (a launch already on a throwaway
1585    /// dir has no better dir to move to — retrying would just loop) and only
1586    /// on the in-use signature.
1587    #[test]
1588    fn only_a_shared_profile_hitting_the_lock_falls_back() {
1589        // (was_ephemeral, error) -> should fall back
1590        let decide = |was_ephemeral: bool, error: &str| !was_ephemeral && is_profile_in_use(error);
1591
1592        assert!(
1593            decide(false, SINGLETON_LOCK_FAILURE),
1594            "a shared profile held by another process falls back to a throwaway one"
1595        );
1596        assert!(
1597            !decide(true, SINGLETON_LOCK_FAILURE),
1598            "already on a throwaway dir: a second lock failure is not contention, and retrying would loop"
1599        );
1600        assert!(
1601            !decide(false, "Failed to launch Chrome: No such file or directory"),
1602            "a missing Chrome is not a profile collision"
1603        );
1604    }
1605
1606    const IDS: [i64; 4] = [7, 12, 30, 31];
1607
1608    /// A tab that has never been navigated: its only entry is the birth
1609    /// `about:blank`, so nothing is behind it.
1610    #[test]
1611    fn a_fresh_tab_can_go_neither_way() {
1612        assert_eq!(history_floor(Some("about:blank")), 1);
1613        assert!(!can_go_back_at(0, 1, Some("about:blank")));
1614    }
1615
1616    /// The bug live verification caught: ONE navigation already reported
1617    /// `can_go_back: true`, and pressing Back landed on the blank page.
1618    /// The outcome says Back "enables after a second navigation".
1619    #[test]
1620    fn one_navigation_does_not_enable_back() {
1621        // entries = [about:blank, page1], currentIndex = 1
1622        assert!(!can_go_back_at(1, 2, Some("about:blank")));
1623        assert_eq!(
1624            adjacent_entry_id(1, &[7, 12], HistoryStep::Back, 1),
1625            None,
1626            "and Back has nowhere to go rather than landing on about:blank"
1627        );
1628    }
1629
1630    #[test]
1631    fn a_second_navigation_enables_back_onto_the_first_real_page() {
1632        // entries = [about:blank, page1, page2], currentIndex = 2
1633        assert!(can_go_back_at(2, 3, Some("about:blank")));
1634        assert_eq!(
1635            adjacent_entry_id(2, &[7, 12, 30], HistoryStep::Back, 1),
1636            Some(12),
1637            "back lands on the FIRST REAL page, not the birth entry"
1638        );
1639    }
1640
1641    /// Only entry zero is the birth entry. Someone who deliberately
1642    /// navigates to about:blank later has genuinely been there.
1643    #[test]
1644    fn a_deliberate_later_about_blank_is_a_real_entry() {
1645        // entries = [page1, about:blank], currentIndex = 1
1646        assert_eq!(history_floor(Some("http://example.test/")), 0);
1647        assert!(can_go_back_at(1, 2, Some("http://example.test/")));
1648        assert_eq!(
1649            adjacent_entry_id(1, &[7, 12], HistoryStep::Back, 0),
1650            Some(7)
1651        );
1652    }
1653
1654    /// Mirrors `fetch_nav_state`'s derivation so the wire-visible
1655    /// `can_go_back` is what these cases actually assert on.
1656    fn can_go_back_at(current_index: i64, _len: usize, first_url: Option<&str>) -> bool {
1657        let floor = history_floor(first_url);
1658        usize::try_from(current_index).is_ok_and(|i| i > floor)
1659    }
1660
1661    #[test]
1662    fn back_picks_the_previous_entry_id() {
1663        assert_eq!(adjacent_entry_id(2, &IDS, HistoryStep::Back, 0), Some(12));
1664        assert_eq!(adjacent_entry_id(1, &IDS, HistoryStep::Back, 0), Some(7));
1665    }
1666
1667    #[test]
1668    fn forward_picks_the_next_entry_id() {
1669        assert_eq!(
1670            adjacent_entry_id(0, &IDS, HistoryStep::Forward, 0),
1671            Some(12)
1672        );
1673        assert_eq!(
1674            adjacent_entry_id(2, &IDS, HistoryStep::Forward, 0),
1675            Some(31)
1676        );
1677    }
1678
1679    /// The nav bar disables Back on the first entry; this is what the backend
1680    /// answers if a call arrives anyway (a race, or a client bug).
1681    #[test]
1682    fn back_from_the_first_entry_has_nowhere_to_go() {
1683        assert_eq!(adjacent_entry_id(0, &IDS, HistoryStep::Back, 0), None);
1684    }
1685
1686    #[test]
1687    fn forward_from_the_last_entry_has_nowhere_to_go() {
1688        assert_eq!(adjacent_entry_id(3, &IDS, HistoryStep::Forward, 0), None);
1689    }
1690
1691    #[test]
1692    fn an_empty_history_has_nothing_in_either_direction() {
1693        assert_eq!(adjacent_entry_id(0, &[], HistoryStep::Back, 0), None);
1694        assert_eq!(adjacent_entry_id(0, &[], HistoryStep::Forward, 0), None);
1695        assert_eq!(history_floor(None), 0);
1696    }
1697
1698    /// Same guard `fetch_nav_state` carries: `currentIndex` is only documented
1699    /// to be in range, and indexing a slice on trust is how a malformed
1700    /// response becomes a panic instead of a clean error.
1701    #[test]
1702    fn an_out_of_range_current_index_is_refused_not_indexed() {
1703        assert_eq!(adjacent_entry_id(-1, &IDS, HistoryStep::Back, 0), None);
1704        assert_eq!(adjacent_entry_id(-1, &IDS, HistoryStep::Forward, 0), None);
1705        assert_eq!(adjacent_entry_id(9, &IDS, HistoryStep::Back, 0), None);
1706        assert_eq!(adjacent_entry_id(9, &IDS, HistoryStep::Forward, 0), None);
1707        assert_eq!(
1708            adjacent_entry_id(i64::MAX, &IDS, HistoryStep::Forward, 0),
1709            None
1710        );
1711    }
1712
1713    #[test]
1714    fn each_direction_names_what_was_missing() {
1715        assert_eq!(HistoryStep::Back.nothing_there(), "no page to go back to");
1716        assert_eq!(
1717            HistoryStep::Forward.nothing_there(),
1718            "no page to go forward to"
1719        );
1720    }
1721
1722    // ---- needs_a_tab_before_navigating: the empty-state reopen decision --
1723    //
1724    // Same style as `tabs.rs`'s own tests: a synthetic `FakePage` standing
1725    // in for `chromiumoxide::Page`, so the decision `navigate()` makes is
1726    // provable without a live Chromium.
1727
1728    type FakePage = &'static str;
1729
1730    #[test]
1731    fn a_fresh_empty_registry_needs_a_tab_before_navigating() {
1732        let (reg, _rx) = TabRegistry::<FakePage>::new();
1733        assert!(needs_a_tab_before_navigating(&reg));
1734    }
1735
1736    #[test]
1737    fn an_open_tab_needs_no_reopening_before_navigating() {
1738        let (mut reg, _rx) = TabRegistry::<FakePage>::new();
1739        reg.open("page-a", "http://a", "A");
1740        assert!(!needs_a_tab_before_navigating(&reg));
1741    }
1742
1743    #[test]
1744    fn closing_the_last_tab_needs_a_tab_again() {
1745        // The exact scenario the finding names: the user closes the drawer's
1746        // last tab (the empty state), then navigates — this is what must
1747        // trip the reopen rather than fail with "Page closed".
1748        let (mut reg, _rx) = TabRegistry::<FakePage>::new();
1749        let only = reg.open("page-a", "http://a", "A");
1750        reg.close(only);
1751        assert!(needs_a_tab_before_navigating(&reg));
1752    }
1753
1754    #[test]
1755    fn closing_a_background_tab_still_needs_no_reopening() {
1756        let (mut reg, _rx) = TabRegistry::<FakePage>::new();
1757        let first = reg.open("page-a", "http://a", "A");
1758        reg.open("page-b", "http://b", "B");
1759        reg.close(first);
1760        assert!(!needs_a_tab_before_navigating(&reg));
1761    }
1762}