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(¤t, 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(®));
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(®));
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(®));
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(®));
1761 }
1762}