Skip to main content

zendriver/
tab.rs

1//! Per-page handle to a single CDP target session.
2//!
3//! [`Tab`] is the primary interaction surface in zendriver — most workflows
4//! are some sequence of `goto`, `find().css(...).one()`, `evaluate`,
5//! `screenshot`, and `wait_for_idle`. Each [`Tab`] owns its own
6//! [`InputController`] (cursor + held-modifier state), its own per-tab
7//! frame registry, and its own in-flight network tracker, so multiple tabs
8//! in the same [`crate::Browser`] don't interfere with one another.
9//!
10//! ```no_run
11//! # async fn ex() -> zendriver::Result<()> {
12//! let browser = zendriver::Browser::builder().launch().await?;
13//! let tab = browser.main_tab();
14//! tab.goto("https://example.com").await?;
15//! tab.wait_for_load().await?;
16//! let title: String = tab.evaluate_main("document.title").await?;
17//! assert_eq!(title, "Example Domain");
18//! # Ok(()) }
19//! ```
20
21use std::collections::HashMap;
22use std::path::PathBuf;
23use std::sync::Arc;
24use std::time::Duration;
25
26use futures::StreamExt;
27use serde::de::DeserializeOwned;
28use serde_json::{Value, json};
29use tokio::time::timeout;
30use tracing::trace;
31use zendriver_transport::SessionHandle;
32
33use crate::error::{Result, ZendriverError};
34use crate::frame::Frame;
35use crate::input::InputController;
36use crate::isolated_world::IsolatedWorldCache;
37use crate::screenshot::ScreenshotBuilder;
38
39const DEFAULT_LOAD_TIMEOUT: Duration = Duration::from_secs(30);
40
41/// Poll cadence for [`Tab::wait_for_ready_state`]'s `document.readyState`
42/// loop. Small enough to feel responsive, large enough not to spin the CDP
43/// channel.
44const READY_STATE_POLL_INTERVAL: Duration = Duration::from_millis(100);
45
46/// Fixed `(x, y)` viewport anchor for [`Tab::scroll_with`] gestures. A
47/// constant in-viewport point keeps page scrolls deterministic and
48/// single-dispatch (no `Page.getLayoutMetrics` round-trip to derive a
49/// center) — the scroll *distance* is what matters, not the anchor.
50const SCROLL_ANCHOR: (f64, f64) = (100.0, 100.0);
51
52/// Per-call knobs for [`Tab::reload_with`].
53///
54/// `Default` reloads with `ignore_cache: false` and no injected script —
55/// the same behavior as the plain [`Tab::reload`] shortcut. Set
56/// `ignore_cache: true` for a hard refresh, and/or
57/// `script_to_evaluate_on_load` to inject a script that runs on every frame
58/// load triggered by the reload.
59///
60/// # Examples
61///
62/// ```no_run
63/// # async fn ex() -> zendriver::Result<()> {
64/// use zendriver::ReloadOptions;
65/// # let browser = zendriver::Browser::builder().launch().await?;
66/// # let tab = browser.main_tab();
67/// tab.reload_with(ReloadOptions {
68///     ignore_cache: true,
69///     ..Default::default()
70/// }).await?;
71/// # Ok(()) }
72/// ```
73#[derive(Debug, Clone, Default)]
74pub struct ReloadOptions {
75    /// Bypass the HTTP cache for the reload (`Page.reload.ignoreCache`).
76    /// `false` by default — a soft refresh.
77    pub ignore_cache: bool,
78    /// Script source injected before any other page script on each frame
79    /// loaded by the reload (`Page.reload.scriptToEvaluateOnLoad`). Omitted
80    /// from the dispatch entirely when `None`.
81    pub script_to_evaluate_on_load: Option<String>,
82}
83
84/// Per-call knobs for [`Tab::scroll_with`].
85///
86/// `dx` / `dy` are signed pixel distances forwarded verbatim to
87/// `Input.synthesizeScrollGesture`'s `xDistance` / `yDistance`. Following
88/// the CDP convention a **negative** `dy` scrolls the page *down* (content
89/// moves up); a positive `dy` scrolls up. `speed` (px/s) plumbs through to
90/// the gesture's `speed` field when `Some`, and is omitted otherwise (Chrome
91/// picks its default).
92///
93/// For the common cases prefer the [`Tab::scroll_down`] / [`Tab::scroll_up`]
94/// shortcuts, which take an unsigned pixel amount and pick the sign for you.
95///
96/// # Examples
97///
98/// ```no_run
99/// # async fn ex() -> zendriver::Result<()> {
100/// use zendriver::ScrollOptions;
101/// # let browser = zendriver::Browser::builder().launch().await?;
102/// # let tab = browser.main_tab();
103/// // Scroll down 400px and right 50px at a fixed speed.
104/// tab.scroll_with(ScrollOptions {
105///     dx: 50.0,
106///     dy: -400.0,
107///     speed: Some(800),
108/// }).await?;
109/// # Ok(()) }
110/// ```
111#[derive(Debug, Clone, Default)]
112pub struct ScrollOptions {
113    /// Horizontal scroll distance in pixels (`synthesizeScrollGesture.xDistance`).
114    pub dx: f64,
115    /// Vertical scroll distance in pixels (`synthesizeScrollGesture.yDistance`).
116    /// Negative scrolls the page down (CDP convention); positive scrolls up.
117    pub dy: f64,
118    /// Optional gesture speed in pixels/second (`synthesizeScrollGesture.speed`).
119    /// Omitted from the dispatch when `None`.
120    pub speed: Option<i64>,
121}
122
123/// Runtime user-agent override for [`Tab::set_user_agent_with`].
124///
125/// `accept_language` / `platform` are optional refinements — leave them
126/// `None` to override only the UA string. Each `None` field is omitted from
127/// the `Emulation.setUserAgentOverride` dispatch entirely (not sent as
128/// `null`).
129///
130/// # Stealth interaction (read before using under a stealth profile)
131///
132/// The active [`StealthProfile`](zendriver_stealth::StealthProfile) observer
133/// already issues `Emulation.setUserAgentOverride` carrying a coherent
134/// `userAgentMetadata` block (UA Client-Hints). This override is
135/// **last-write-wins and sends NO `userAgentMetadata`**, so applying it under
136/// the Spoofed profile *clobbers* that Client-Hints coherence and can
137/// *increase* fingerprint detectability (the UA string and the UA-CH high
138/// entropy values would disagree). For stealth, prefer setting the UA through
139/// the stealth profile instead. Use this for non-stealth tabs or a deliberate
140/// per-tab UA change where you accept the coherence trade-off.
141///
142/// # Examples
143///
144/// ```no_run
145/// # async fn ex() -> zendriver::Result<()> {
146/// use zendriver::UserAgentOverride;
147/// # let browser = zendriver::Browser::builder().launch().await?;
148/// # let tab = browser.main_tab();
149/// tab.set_user_agent_with(UserAgentOverride {
150///     user_agent: "Mozilla/5.0 (custom) Gecko/20100101 Firefox/123.0".into(),
151///     accept_language: Some("en-US,en;q=0.9".into()),
152///     platform: Some("Linux x86_64".into()),
153/// }).await?;
154/// # Ok(()) }
155/// ```
156#[derive(Debug, Clone, Default)]
157pub struct UserAgentOverride {
158    /// Full `User-Agent` request-header / `navigator.userAgent` string
159    /// (`Emulation.setUserAgentOverride.userAgent`). Required.
160    pub user_agent: String,
161    /// `Accept-Language` header + `navigator.language(s)` override
162    /// (`Emulation.setUserAgentOverride.acceptLanguage`). Omitted when `None`.
163    pub accept_language: Option<String>,
164    /// `navigator.platform` override
165    /// (`Emulation.setUserAgentOverride.platform`). Omitted when `None`.
166    pub platform: Option<String>,
167}
168
169/// Document load milestone for [`Tab::wait_for_ready_state`].
170///
171/// Maps to the three values of the DOM `document.readyState` property and
172/// orders them by progress: `Loading` < `Interactive` < `Complete`. Passing a
173/// target to `wait_for_ready_state` polls until the document has reached *at
174/// least* that milestone — e.g. waiting for `Interactive` also returns once
175/// the page is fully `Complete`.
176///
177/// # Examples
178///
179/// ```no_run
180/// # async fn ex() -> zendriver::Result<()> {
181/// use zendriver::ReadyState;
182/// # let browser = zendriver::Browser::builder().launch().await?;
183/// # let tab = browser.main_tab();
184/// tab.goto("https://example.com").await?;
185/// tab.wait_for_ready_state(ReadyState::Complete).await?;
186/// # Ok(()) }
187/// ```
188#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
189pub enum ReadyState {
190    /// `document.readyState === "loading"` — the document is still parsing.
191    #[serde(rename = "loading")]
192    Loading,
193    /// `document.readyState === "interactive"` — parsed, but sub-resources
194    /// (images, stylesheets, frames) may still be loading.
195    #[serde(rename = "interactive")]
196    Interactive,
197    /// `document.readyState === "complete"` — the document and all
198    /// sub-resources have finished loading (the `load` event has fired).
199    #[serde(rename = "complete")]
200    Complete,
201}
202
203impl ReadyState {
204    /// Monotonic progress rank: `Loading` = 0 < `Interactive` = 1 <
205    /// `Complete` = 2. Used by [`Tab::wait_for_ready_state`] to decide whether
206    /// the observed state has reached the requested milestone.
207    fn rank(self) -> u8 {
208        match self {
209            ReadyState::Loading => 0,
210            ReadyState::Interactive => 1,
211            ReadyState::Complete => 2,
212        }
213    }
214
215    /// Parse a raw `document.readyState` string into a [`ReadyState`].
216    /// Returns `None` for any value other than the three documented states.
217    fn from_dom_str(s: &str) -> Option<Self> {
218        match s {
219            "loading" => Some(ReadyState::Loading),
220            "interactive" => Some(ReadyState::Interactive),
221            "complete" => Some(ReadyState::Complete),
222            _ => None,
223        }
224    }
225}
226
227/// A single resource within a frame whose content matched a
228/// [`Tab::search_frame_resources`] query.
229///
230/// `url` is the resource's request URL (the document, a script, a stylesheet,
231/// …) and `frame_id` is the CDP `frameId` of the frame that owns it. Returned
232/// only for resources whose body produced at least one match for the query.
233///
234/// # Examples
235///
236/// ```no_run
237/// # async fn ex() -> zendriver::Result<()> {
238/// # let browser = zendriver::Browser::builder().launch().await?;
239/// # let tab = browser.main_tab();
240/// tab.goto("https://example.com").await?;
241/// for m in tab.search_frame_resources("apiKey").await? {
242///     println!("match in {} (frame {})", m.url, m.frame_id);
243/// }
244/// # Ok(()) }
245/// ```
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub struct FrameResourceMatch {
248    /// Request URL of the matched resource.
249    pub url: String,
250    /// CDP `frameId` of the frame that owns the resource.
251    pub frame_id: String,
252}
253
254/// Handle to a single CDP target session — one open page in Chrome.
255///
256/// `Tab` is `Clone` (cheap — wraps an `Arc`) and `Send + Sync`, so the same
257/// handle can be passed across `tokio::spawn` boundaries freely. Dropping
258/// the last clone tears down the per-Tab background tasks (network tracker,
259/// frame lifecycle subscriber) but does NOT close the page in Chrome — call
260/// [`Tab::close`] for an explicit teardown.
261///
262/// Obtain a `Tab` from [`crate::Browser::main_tab`], [`crate::Browser::new_tab`],
263/// or [`crate::Browser::tabs`].
264#[derive(Clone, Debug)]
265pub struct Tab {
266    pub(crate) inner: Arc<TabInner>,
267}
268
269#[derive(Debug)]
270pub(crate) struct TabInner {
271    pub(crate) session: SessionHandle,
272    pub(crate) isolated_world: tokio::sync::Mutex<IsolatedWorldCache>,
273    /// Weak ref to the owning `BrowserInner`. Used by [`Tab::cookies`] to
274    /// hand back a [`crate::CookieJar`] bound to the browser's root
275    /// connection (Chrome's cookie store is browser-scoped, so per-tab jars
276    /// would all dispatch the same way). Reserved for future P4 tasks
277    /// (tabs registry walks, storage). `Weak` breaks the Browser→Tab→Browser
278    /// cycle.
279    pub(crate) browser: std::sync::Weak<crate::browser::BrowserInner>,
280    /// Per-Tab input controller. Each tab owns its own cursor + held-modifier
281    /// state — distinct tabs in the same Browser have independent pointers.
282    /// `Element` actions clone this `Arc` to drive `mouse::*` / `keyboard::*`
283    /// dispatch helpers; the shared mutex inside `InputController` serializes
284    /// per-tab writes without crossing tab boundaries.
285    pub(crate) input: Arc<InputController>,
286    /// CDP `targetId` for the page target this tab wraps. Cached at Tab
287    /// construction time (from `Target.attachedToTarget`'s `target_info`)
288    /// so multi-tab orchestration (`Browser::new_tab` correlation,
289    /// `Tab::activate`, `Tab::close`'s `Target.closeTarget` upgrade) can
290    /// dispatch by `targetId` without re-querying `Target.getTargetInfo`
291    /// per call.
292    pub(crate) target_id: String,
293    /// Per-Tab in-flight network request tracker. Constructed in
294    /// [`Tab::new`] alongside a background task (spawned via
295    /// [`crate::network_idle::InFlightTracker::run`]) that subscribes to
296    /// `Network.*` events and maintains the set. Consulted by
297    /// [`Tab::wait_for_idle`] / [`Tab::wait_for_idle_with`] for Playwright
298    /// `networkidle` semantics.
299    pub(crate) network_tracker: Arc<crate::network_idle::InFlightTracker>,
300    /// Cancellation token for the background tracker task. Fires on
301    /// [`Drop`] so the spawned task exits cleanly when the last clone of
302    /// this Tab goes away. Cloned by the spawned task at construction
303    /// time; cancelling here propagates to the task's `tokio::select!`
304    /// loop within one event tick.
305    pub(crate) network_cancel: tokio_util::sync::CancellationToken,
306    /// Lazily-discovered main [`Frame`] for this tab. First call to
307    /// [`Tab::main_frame`] sends `Page.getFrameTree`, extracts the top-level
308    /// frame id/url/name, constructs a `Frame` (sharing this tab's
309    /// session — the main frame is always same-process), and stores it
310    /// here. Subsequent calls return the cached `Frame` clone without
311    /// another round-trip.
312    pub(crate) main_frame: tokio::sync::OnceCell<Frame>,
313    /// Per-Tab download coordinator. Lazily initialized on the first
314    /// [`Tab::expect_download`] call (gated `expect`) — the constructor
315    /// allocates a tempdir, dispatches `Browser.setDownloadBehavior` once,
316    /// and spawns a long-running `Page.downloadProgress` subscriber. Held
317    /// behind a [`tokio::sync::OnceCell`] so the wiring happens exactly
318    /// once per Tab; subsequent `expect_download` calls reuse the same
319    /// coordinator (and therefore the same tempdir + subscriber).
320    ///
321    /// `Arc` because both the [`Tab`] (via this cell) and the spawned
322    /// progress subscriber task hold references to the same coordinator
323    /// state for the Tab's entire lifetime.
324    #[cfg(feature = "expect")]
325    pub(crate) download_setup:
326        tokio::sync::OnceCell<Arc<crate::expect::download::DownloadCoordinator>>,
327    /// Per-Tab frames registry keyed by CDP `frameId`. Populated by the
328    /// background subscriber spawned in [`Tab::new`] via
329    /// [`crate::frame::lifecycle::run`] which mutates the map in response
330    /// to `Page.frameAttached` / `Page.frameDetached` /
331    /// `Page.frameNavigated` events on this tab's session. Read by
332    /// [`Tab::frames`] / [`Tab::frame_by_url`] / [`Tab::frame_by_name`].
333    ///
334    /// Same-origin sub-frames go in this map directly; out-of-process
335    /// iframes (OOPIFs) take the `Target.attachedToTarget` path wired in
336    /// T16 and land here only after that observer registers them.
337    pub(crate) frames: Arc<tokio::sync::RwLock<HashMap<String, Frame>>>,
338    /// Cancellation token for the frame lifecycle subscriber task. Mirror
339    /// of [`TabInner::network_cancel`]: fires on [`Drop`] so the spawned
340    /// task exits cleanly when the last clone of this Tab goes away. The
341    /// task selects on this token alongside the three `Page.frame*`
342    /// subscriber streams so cancellation unblocks the select even if no
343    /// events are arriving.
344    pub(crate) frame_lifecycle_cancel: tokio_util::sync::CancellationToken,
345    /// Oneshot receiver published by [`Tab::goto`] (subscribes to
346    /// `Page.frameStoppedLoading` BEFORE issuing `Page.navigate` so the
347    /// event can't race past) and consumed by [`Tab::wait_for_load`].
348    /// `None` when there is no pending navigation — `wait_for_load` falls
349    /// back to a fresh subscribe + `document.readyState` short-circuit.
350    pub(crate) pending_load: tokio::sync::Mutex<Option<tokio::sync::oneshot::Receiver<()>>>,
351    /// Whether a download path / behavior has been established for this Tab
352    /// (via [`Tab::set_download_path`] or a prior [`Tab::download_file`]).
353    /// Mirrors nodriver's `_download_behavior` guard: [`Tab::download_file`]
354    /// only installs a default `cwd/downloads` path when this is still
355    /// `false`, so it never clobbers a directory the caller chose explicitly.
356    pub(crate) download_behavior_set: std::sync::atomic::AtomicBool,
357}
358
359impl Drop for TabInner {
360    fn drop(&mut self) {
361        // Signal the spawned `InFlightTracker::run` task to exit. The task
362        // selects on this token alongside the four `Network.*` subscriber
363        // streams; cancellation unblocks the select even if no events are
364        // arriving. Without this the task would leak per Tab on shutdown.
365        self.network_cancel.cancel();
366        // Signal the spawned `frame::lifecycle::run` task to exit. Same
367        // posture as `network_cancel` above — the task selects on this
368        // token alongside the three `Page.frame*` subscriber streams.
369        self.frame_lifecycle_cancel.cancel();
370    }
371}
372
373/// Tunables for [`Tab::wait_for_idle_opts`].
374///
375/// Construct from [`IdleOptions::default`] and override the fields you care
376/// about:
377///
378/// ```no_run
379/// # use std::time::Duration;
380/// # use zendriver::IdleOptions;
381/// let opts = IdleOptions {
382///     max_inflight_age: Some(Duration::from_secs(5)),
383///     ..Default::default()
384/// };
385/// ```
386#[derive(Debug, Clone)]
387pub struct IdleOptions {
388    /// Outer bound on the whole wait. [`Tab::wait_for_idle_opts`] returns
389    /// [`ZendriverError::Timeout`] once it elapses. Default: 30 s.
390    pub timeout: Duration,
391    /// The in-flight set must stay empty for this long to count as idle.
392    /// Default: 500 ms.
393    pub quiet_window: Duration,
394    /// Requests that have been in flight *longer than* this are ignored when
395    /// judging idleness — they are treated as stuck/background (a hung beacon,
396    /// long-poll, SSE stream, …) rather than active page loading. This lets
397    /// idle resolve even while such a request is still technically open.
398    ///
399    /// `None` (the default) waits for **every** request to terminate, which is
400    /// the historical behavior: a single never-completing request keeps the
401    /// tab non-idle until `timeout`.
402    pub max_inflight_age: Option<Duration>,
403}
404
405impl Default for IdleOptions {
406    fn default() -> Self {
407        Self {
408            timeout: Duration::from_secs(30),
409            quiet_window: Duration::from_millis(500),
410            max_inflight_age: None,
411        }
412    }
413}
414
415impl Tab {
416    pub(crate) fn new(
417        session: SessionHandle,
418        browser: std::sync::Weak<crate::browser::BrowserInner>,
419        input: Arc<InputController>,
420        target_id: String,
421    ) -> Self {
422        // Build the per-Tab network tracker + spawn its background subscriber
423        // task. The task calls `Network.enable` once, then maintains the
424        // in-flight set in response to `Network.requestWillBeSent` /
425        // `responseReceived` / `loadingFailed` / `loadingFinished` events
426        // arriving on this tab's session. `wait_for_idle` reads from the
427        // same `network_tracker` Arc.
428        let network_tracker = crate::network_idle::InFlightTracker::new();
429        let network_cancel = tokio_util::sync::CancellationToken::new();
430        tokio::spawn({
431            let tracker = network_tracker.clone();
432            let session_for_task = session.clone();
433            let cancel_for_task = network_cancel.clone();
434            async move {
435                tracker.run(session_for_task, cancel_for_task).await;
436            }
437        });
438
439        // Build the per-Tab frames registry + spawn the lifecycle
440        // subscriber. The task calls `Page.enable` once, then mutates the
441        // registry in response to `Page.frameAttached` (insert),
442        // `Page.frameNavigated` (update url / insert if unseen) and
443        // `Page.frameDetached` (remove). The `Arc<RwLock<_>>` lives on
444        // `TabInner::frames` so `Tab::frames` / `frame_by_url` /
445        // `frame_by_name` can take snapshots without going through the
446        // tracker task. The `Weak<TabInner>` is wired in via
447        // `Arc::new_cyclic` below so every `Frame` constructed by the
448        // subscriber can upgrade back to the owning Tab.
449        let frames: Arc<tokio::sync::RwLock<HashMap<String, Frame>>> =
450            Arc::new(tokio::sync::RwLock::new(HashMap::new()));
451        let frame_lifecycle_cancel = tokio_util::sync::CancellationToken::new();
452
453        let inner = Arc::new_cyclic(|weak: &std::sync::Weak<TabInner>| {
454            tokio::spawn({
455                let session_for_task = session.clone();
456                let frames_for_task = frames.clone();
457                let weak_for_task = weak.clone();
458                let cancel_for_task = frame_lifecycle_cancel.clone();
459                async move {
460                    crate::frame::lifecycle::run(
461                        session_for_task,
462                        frames_for_task,
463                        weak_for_task,
464                        cancel_for_task,
465                    )
466                    .await;
467                }
468            });
469            TabInner {
470                session,
471                isolated_world: tokio::sync::Mutex::new(IsolatedWorldCache::default()),
472                browser,
473                input,
474                target_id,
475                network_tracker,
476                network_cancel,
477                main_frame: tokio::sync::OnceCell::new(),
478                #[cfg(feature = "expect")]
479                download_setup: tokio::sync::OnceCell::new(),
480                frames,
481                frame_lifecycle_cancel,
482                pending_load: tokio::sync::Mutex::new(None),
483                download_behavior_set: std::sync::atomic::AtomicBool::new(false),
484            }
485        });
486
487        Self { inner }
488    }
489
490    /// Test-only constructor: builds a `Tab` with a deterministic seeded
491    /// [`InputController`] (native input profile, seed `42`) and an empty
492    /// `Weak` browser ref. Replaces the P3 `Tab::new(sess, Weak::new())`
493    /// pattern that paired with `Tab::input() -> Option<_>`; now that
494    /// `Tab::input()` returns `&Arc<InputController>` unconditionally, tests
495    /// must seed a controller at construction time.
496    ///
497    /// The synthetic `target_id` is derived from the session_id — tests that
498    /// need a specific `targetId` should use [`Tab::new_for_test_with_target`].
499    #[cfg(test)]
500    pub(crate) fn new_for_test(session: SessionHandle) -> Self {
501        let target_id = format!("test-target-{}", session.session_id());
502        Self::new(
503            session,
504            std::sync::Weak::new(),
505            crate::input::InputController::new_with_seed(
506                zendriver_stealth::InputProfile::native(),
507                42,
508            ),
509            target_id,
510        )
511    }
512
513    /// CDP `targetId` for the page target this tab wraps.
514    ///
515    /// Stable for the lifetime of the underlying target — used by
516    /// [`crate::Browser::new_tab`] to correlate a `Target.createTarget`
517    /// response with the [`Tab`] that the internal `TabRegistrar`
518    /// subsequently registers.
519    ///
520    /// # Examples
521    ///
522    /// ```no_run
523    /// # async fn ex() -> zendriver::Result<()> {
524    /// let browser = zendriver::Browser::builder().launch().await?;
525    /// let tab = browser.main_tab();
526    /// let id = tab.target_id();
527    /// assert!(!id.is_empty());
528    /// # Ok(()) }
529    /// ```
530    #[must_use]
531    pub fn target_id(&self) -> &str {
532        &self.inner.target_id
533    }
534
535    /// Per-Tab [`InputController`].
536    ///
537    /// Each tab carries its own cursor + modifier state; [`crate::Element`]
538    /// actions ([`crate::Element::click`], [`crate::Element::hover`],
539    /// [`crate::Element::type_text`], [`crate::Element::press`]) call this
540    /// to drive internal mouse / keyboard dispatch helpers. Always returns a
541    /// valid handle.
542    #[must_use]
543    pub fn input(&self) -> &Arc<InputController> {
544        &self.inner.input
545    }
546
547    /// Raw [`SessionHandle`] escape hatch.
548    ///
549    /// For advanced users who need to send CDP commands the high-level API
550    /// doesn't expose. Returns the underlying transport session bound to
551    /// this tab's `sessionId`.
552    ///
553    /// # Examples
554    ///
555    /// ```no_run
556    /// # async fn ex() -> zendriver::Result<()> {
557    /// # let browser = zendriver::Browser::builder().launch().await?;
558    /// # let tab = browser.main_tab();
559    /// let session = tab.session();
560    /// // Send a CDP command the high-level API doesn't wrap.
561    /// session.call("Page.bringToFront", serde_json::json!({})).await?;
562    /// # Ok(()) }
563    /// ```
564    pub fn session(&self) -> &SessionHandle {
565        &self.inner.session
566    }
567
568    /// Start a persistent network monitor over this tab's session.
569    ///
570    /// Returns a [`crate::monitor::MonitorBuilder`]; configure an optional URL
571    /// filter via [`MonitorBuilder::url_pattern`](crate::monitor::MonitorBuilder::url_pattern)
572    /// then call
573    /// [`start()`](crate::monitor::MonitorBuilder::start) to obtain a
574    /// [`crate::monitor::NetworkMonitor`] — a
575    /// [`Stream`](futures::Stream)`<Item = `[`NetworkEvent`](crate::monitor::NetworkEvent)`>`
576    /// over HTTP exchanges, WebSocket frames, and EventSource messages. The
577    /// monitor is passive (CDP `Network` domain) — read-only; use the
578    /// `interception` feature to modify requests.
579    ///
580    /// Dropping the returned monitor (or calling
581    /// [`stop()`](crate::monitor::NetworkMonitor::stop)) cancels its background
582    /// task.
583    ///
584    /// Gated by the `monitor` cargo feature.
585    ///
586    /// # Examples
587    ///
588    /// ```no_run
589    /// # use futures::StreamExt;
590    /// # async fn ex() -> zendriver::Result<()> {
591    /// # let browser = zendriver::Browser::builder().launch().await?;
592    /// # let tab = browser.main_tab();
593    /// let mut monitor = tab.monitor().url_pattern("/api/").start().await?;
594    /// tab.goto("https://example.com").await?;
595    /// while let Some(event) = monitor.next().await {
596    ///     if let zendriver::NetworkEvent::Http(exchange) = event {
597    ///         println!("{} -> {:?}", exchange.request.url, exchange.status());
598    ///     }
599    /// }
600    /// # Ok(()) }
601    /// ```
602    #[cfg(feature = "monitor")]
603    pub fn monitor(&self) -> crate::monitor::MonitorBuilder {
604        crate::monitor::MonitorBuilder::new(self.session().clone())
605    }
606
607    /// Make an HTTP request from the browser context (inherits cookies/CORS).
608    ///
609    /// Returns a [`RequestBuilder`][crate::request::RequestBuilder] that lets
610    /// you set the method, URL, headers, and body. Call
611    /// [`send()`][crate::request::RequestBuilder::send] to execute via
612    /// in-page `fetch`, or chain
613    /// [`bypass_cors()`][crate::request::RequestBuilder::bypass_cors] first to
614    /// use the privileged `Network.loadNetworkResource` path (GET only).
615    ///
616    /// # Examples
617    ///
618    /// ```no_run
619    /// # use serde_json::json;
620    /// # async fn ex() -> zendriver::Result<()> {
621    /// # let browser = zendriver::Browser::builder().launch().await?;
622    /// # let tab = browser.main_tab();
623    /// tab.goto("https://example.com").await?;
624    ///
625    /// // Simple GET
626    /// let resp = tab.request().get("https://example.com/api/data").send().await?;
627    /// println!("status={} body={}", resp.status(), resp.text()?);
628    ///
629    /// // POST with a JSON body
630    /// let resp = tab
631    ///     .request()
632    ///     .post("https://example.com/api/echo")
633    ///     .json(&json!({"key": "value"}))?
634    ///     .send()
635    ///     .await?;
636    /// println!("status={} body={}", resp.status(), resp.text()?);
637    /// # Ok(()) }
638    /// ```
639    pub fn request(&self) -> crate::request::RequestBuilder<'_> {
640        crate::request::RequestBuilder::new(self)
641    }
642
643    /// The top-level [`Frame`] for this tab.
644    ///
645    /// First call dispatches `Page.getFrameTree` on the tab's session,
646    /// extracts the top-level frame's `id` / `url` / `name`, and constructs
647    /// a [`Frame`] whose session is this tab's session (the main frame is
648    /// always same-process). The result is cached internally so subsequent
649    /// calls return the same `Frame` clone without a round-trip.
650    ///
651    /// # Errors
652    ///
653    /// Returns [`ZendriverError::Navigation`] if Chrome's response is
654    /// missing the top-level frame id.
655    ///
656    /// # Examples
657    ///
658    /// ```no_run
659    /// # async fn ex() -> zendriver::Result<()> {
660    /// # let browser = zendriver::Browser::builder().launch().await?;
661    /// # let tab = browser.main_tab();
662    /// tab.goto("https://example.com").await?;
663    /// let main = tab.main_frame().await?;
664    /// assert!(main.url().await.contains("example.com"));
665    /// # Ok(()) }
666    /// ```
667    pub async fn main_frame(&self) -> Result<Frame> {
668        let frame = self
669            .inner
670            .main_frame
671            .get_or_try_init(|| async {
672                let tree = self.call("Page.getFrameTree", json!({})).await?;
673                let frame_node = &tree["frameTree"]["frame"];
674                let frame_id = frame_node["id"]
675                    .as_str()
676                    .ok_or_else(|| {
677                        ZendriverError::Navigation(
678                            "Page.getFrameTree missing frameTree.frame.id".into(),
679                        )
680                    })?
681                    .to_string();
682                let url = frame_node["url"].as_str().unwrap_or("").to_string();
683                let name = frame_node["name"].as_str().map(str::to_string);
684                Ok::<_, ZendriverError>(Frame::new(
685                    frame_id,
686                    None,
687                    url,
688                    name,
689                    self.inner.session.clone(),
690                    Arc::downgrade(&self.inner),
691                ))
692            })
693            .await?;
694        Ok(frame.clone())
695    }
696
697    /// Snapshot of all currently-registered frames for this tab.
698    ///
699    /// The registry is maintained by an internal lifecycle subscriber spawned
700    /// when the Tab is constructed (see the [`crate::frame::lifecycle`]
701    /// module). Includes the top-level frame (once Chrome has emitted at
702    /// least one `Page.frameAttached` or `Page.frameNavigated` for it) plus
703    /// every same-origin sub-frame. Out-of-process iframes (OOPIFs) land in
704    /// this map via the [`crate::frame::oopif`] observer path.
705    ///
706    /// Sorted by [`Frame::id`] for a deterministic, run-to-run-stable order
707    /// (the backing registry is a [`HashMap`], whose iteration order is not
708    /// stable on its own) — callers relying on cross-frame result ordering
709    /// (e.g. `FindBuilder::include_frames`) get consistent results across
710    /// calls with the same frame set.
711    ///
712    /// # Examples
713    ///
714    /// ```no_run
715    /// # async fn ex() -> zendriver::Result<()> {
716    /// # let browser = zendriver::Browser::builder().launch().await?;
717    /// # let tab = browser.main_tab();
718    /// tab.goto("https://example.com").await?;
719    /// for f in tab.frames().await? {
720    ///     println!("frame {}: {}", f.id(), f.url().await);
721    /// }
722    /// # Ok(()) }
723    /// ```
724    pub async fn frames(&self) -> Result<Vec<Frame>> {
725        let mut frames: Vec<Frame> = self.inner.frames.read().await.values().cloned().collect();
726        frames.sort_by(|a, b| a.id().cmp(b.id()));
727        Ok(frames)
728    }
729
730    /// First frame in [`Tab::frames`] whose URL contains `url_substr`.
731    ///
732    /// Linear scan over the registry. Useful for picking a frame by its
733    /// origin (e.g. `tab.frame_by_url("docs.google.com")`) without knowing
734    /// the exact path. Returns `Ok(None)` if no frame matches; the registry
735    /// lock is released before returning so concurrent updates can land.
736    ///
737    /// # Examples
738    ///
739    /// ```no_run
740    /// # async fn ex() -> zendriver::Result<()> {
741    /// # let browser = zendriver::Browser::builder().launch().await?;
742    /// # let tab = browser.main_tab();
743    /// tab.goto("https://example.com").await?;
744    /// if let Some(iframe) = tab.frame_by_url("youtube.com").await? {
745    ///     println!("found iframe: {}", iframe.url().await);
746    /// }
747    /// # Ok(()) }
748    /// ```
749    pub async fn frame_by_url(&self, url_substr: &str) -> Result<Option<Frame>> {
750        let map = self.inner.frames.read().await;
751        for frame in map.values() {
752            if frame.url().await.contains(url_substr) {
753                return Ok(Some(frame.clone()));
754            }
755        }
756        Ok(None)
757    }
758
759    /// First frame in [`Tab::frames`] whose `name` attribute equals `name`.
760    ///
761    /// Linear scan. Frames without a name attribute (the common case for
762    /// the top-level frame and unnamed iframes) are skipped. Returns
763    /// `Ok(None)` if no frame matches.
764    ///
765    /// # Examples
766    ///
767    /// ```no_run
768    /// # async fn ex() -> zendriver::Result<()> {
769    /// # let browser = zendriver::Browser::builder().launch().await?;
770    /// # let tab = browser.main_tab();
771    /// if let Some(content) = tab.frame_by_name("content").await? {
772    ///     content.evaluate::<()>("document.body.scrollTop = 0").await?;
773    /// }
774    /// # Ok(()) }
775    /// ```
776    pub async fn frame_by_name(&self, name: &str) -> Result<Option<Frame>> {
777        let map = self.inner.frames.read().await;
778        Ok(map.values().find(|f| f.name() == Some(name)).cloned())
779    }
780
781    /// Browser-wide cookie store handle.
782    ///
783    /// Convenience accessor that delegates to the owning [`crate::Browser`]'s
784    /// root [`zendriver_transport::Connection`] — Chrome's cookie store is
785    /// browser-scoped, so this jar is functionally identical to
786    /// [`crate::Browser::cookies`] for the same browser.
787    ///
788    /// If the owning Browser has already been dropped (which shouldn't happen
789    /// in practice because Drop ordering keeps it alive while any Tab clone
790    /// exists, but is handled defensively here), the jar falls back to the
791    /// Tab's session-level connection.
792    ///
793    /// # Examples
794    ///
795    /// ```no_run
796    /// # async fn ex() -> zendriver::Result<()> {
797    /// # let browser = zendriver::Browser::builder().launch().await?;
798    /// # let tab = browser.main_tab();
799    /// tab.goto("https://example.com").await?;
800    /// let jar = tab.cookies();
801    /// let all = jar.all().await?;
802    /// println!("{} cookies set", all.len());
803    /// # Ok(()) }
804    /// ```
805    #[must_use]
806    pub fn cookies(&self) -> crate::CookieJar {
807        let conn = self.inner.browser.upgrade().map_or_else(
808            || self.inner.session.connection().clone(),
809            |b| b.conn.clone(),
810        );
811        crate::CookieJar::new(conn)
812    }
813
814    /// Per-tab `localStorage` accessor.
815    ///
816    /// The returned [`crate::Storage`] is configured with `is_local: true`
817    /// and dispatches against this tab's session; each operation re-resolves
818    /// the tab's current origin via a [`Tab::url`] round-trip (since
819    /// DOMStorage is origin-keyed and a navigation between calls would shift
820    /// the target storage area).
821    ///
822    /// `DOMStorage.enable` fires lazily on the first op per handle so
823    /// re-using the same handle across many calls pays the enable cost
824    /// exactly once.
825    ///
826    /// # Examples
827    ///
828    /// ```no_run
829    /// # async fn ex() -> zendriver::Result<()> {
830    /// # let browser = zendriver::Browser::builder().launch().await?;
831    /// # let tab = browser.main_tab();
832    /// tab.goto("https://example.com").await?;
833    /// let ls = tab.local_storage();
834    /// ls.set("theme", "dark").await?;
835    /// let v = ls.get("theme").await?;
836    /// assert_eq!(v.as_deref(), Some("dark"));
837    /// # Ok(()) }
838    /// ```
839    #[must_use]
840    pub fn local_storage(&self) -> crate::Storage {
841        crate::Storage::new(
842            self.inner.session.clone(),
843            true,
844            Arc::downgrade(&self.inner),
845        )
846    }
847
848    /// Per-tab `sessionStorage` accessor.
849    ///
850    /// Mirror of [`Tab::local_storage`] with `is_local: false` — backs the
851    /// per-tab, per-origin `sessionStorage` area instead of the persistent
852    /// localStorage.
853    ///
854    /// # Examples
855    ///
856    /// ```no_run
857    /// # async fn ex() -> zendriver::Result<()> {
858    /// # let browser = zendriver::Browser::builder().launch().await?;
859    /// # let tab = browser.main_tab();
860    /// tab.goto("https://example.com").await?;
861    /// tab.session_storage().set("draft", "hello").await?;
862    /// # Ok(()) }
863    /// ```
864    #[must_use]
865    pub fn session_storage(&self) -> crate::Storage {
866        crate::Storage::new(
867            self.inner.session.clone(),
868            false,
869            Arc::downgrade(&self.inner),
870        )
871    }
872
873    /// Helper: call a CDP method on this tab's session, parsing transport
874    /// errors into `ZendriverError`.
875    pub(crate) async fn call(&self, method: &str, params: Value) -> Result<Value> {
876        trace!(%method, "tab.call");
877        let res = self.inner.session.call(method, params).await?;
878        Ok(res)
879    }
880
881    /// Navigate the tab to `url`.
882    ///
883    /// Does NOT wait for the load to complete — call [`Tab::wait_for_load`]
884    /// (or [`Tab::wait_for_idle`]) afterward to block on the navigation.
885    ///
886    /// # Errors
887    ///
888    /// Returns [`ZendriverError::Navigation`] when Chrome reports
889    /// `errorText` on the `Page.navigate` response (e.g. DNS failure,
890    /// connection refused, invalid URL).
891    ///
892    /// # Examples
893    ///
894    /// ```no_run
895    /// # async fn ex() -> zendriver::Result<()> {
896    /// # let browser = zendriver::Browser::builder().launch().await?;
897    /// # let tab = browser.main_tab();
898    /// tab.goto("https://example.com").await?;
899    /// tab.wait_for_load().await?;
900    /// # Ok(()) }
901    /// ```
902    pub async fn goto(&self, url: impl AsRef<str>) -> Result<()> {
903        // Enable Page domain so we get FrameStoppedLoading events.
904        self.call("Page.enable", json!({})).await?;
905        // Subscribe to Page.frameStoppedLoading BEFORE issuing Page.navigate.
906        // The transport's event bus is a tokio broadcast channel, so a
907        // subscriber created after the event has already been published can
908        // never observe it. By subscribing first, then handing a oneshot to
909        // `wait_for_load`, we guarantee the next load event lands in our
910        // receiver regardless of how fast the page loads (e.g. localhost
911        // wiremock fixtures in CI).
912        let mut stream = self
913            .inner
914            .session
915            .subscribe::<Value>("Page.frameStoppedLoading");
916        let (tx, rx) = tokio::sync::oneshot::channel();
917        tokio::spawn(async move {
918            if stream.next().await.is_some() {
919                let _ = tx.send(());
920            }
921        });
922        *self.inner.pending_load.lock().await = Some(rx);
923
924        let url_s = url.as_ref().to_string();
925        let res = self.call("Page.navigate", json!({ "url": url_s })).await?;
926        if let Some(err) = res.get("errorText").and_then(|v| v.as_str()) {
927            if !err.is_empty() {
928                return Err(ZendriverError::Navigation(err.to_string()));
929            }
930        }
931        Ok(())
932    }
933
934    /// Wait until the main frame's load event fires.
935    ///
936    /// Subscribes to `Page.frameStoppedLoading` and waits for the first
937    /// event. Bounded by a 30s timeout.
938    ///
939    /// # Errors
940    ///
941    /// Returns [`ZendriverError::Timeout`] when no load event arrives
942    /// within 30s; [`ZendriverError::Navigation`] if the event stream
943    /// closes (transport teardown).
944    ///
945    /// # Examples
946    ///
947    /// ```no_run
948    /// # async fn ex() -> zendriver::Result<()> {
949    /// # let browser = zendriver::Browser::builder().launch().await?;
950    /// # let tab = browser.main_tab();
951    /// tab.goto("https://example.com").await?;
952    /// tab.wait_for_load().await?;
953    /// # Ok(()) }
954    /// ```
955    pub async fn wait_for_load(&self) -> Result<()> {
956        // Preferred path: consume the oneshot stashed by `goto`, which
957        // subscribed to `Page.frameStoppedLoading` BEFORE the navigation
958        // request — guaranteed delivery.
959        if let Some(rx) = self.inner.pending_load.lock().await.take() {
960            timeout(DEFAULT_LOAD_TIMEOUT, rx)
961                .await
962                .map_err(|_| ZendriverError::Timeout(DEFAULT_LOAD_TIMEOUT))?
963                .map_err(|_| ZendriverError::Navigation("page event stream closed".into()))?;
964            return Ok(());
965        }
966        // Fallback: no pending navigation (e.g. caller invoked
967        // `wait_for_load` without a preceding `goto`, or after the page
968        // navigated itself). Subscribe + short-circuit on the current
969        // `document.readyState` so an already-loaded page returns
970        // immediately rather than blocking on a missed event.
971        let mut stream = self
972            .inner
973            .session
974            .subscribe::<Value>("Page.frameStoppedLoading");
975        let ready: Option<String> = self
976            .call(
977                "Runtime.evaluate",
978                json!({
979                    "expression": "document.readyState",
980                    "returnByValue": true,
981                }),
982            )
983            .await
984            .ok()
985            .and_then(|v| v.get("result")?.get("value")?.as_str().map(str::to_owned));
986        if ready.as_deref() == Some("complete") {
987            return Ok(());
988        }
989        timeout(DEFAULT_LOAD_TIMEOUT, stream.next())
990            .await
991            .map_err(|_| ZendriverError::Timeout(DEFAULT_LOAD_TIMEOUT))?
992            .ok_or_else(|| ZendriverError::Navigation("page event stream closed".into()))?;
993        Ok(())
994    }
995
996    /// Block until the document reaches at least the `until` load milestone.
997    ///
998    /// Polls `document.readyState` (via a main-world `Runtime.evaluate`) every
999    /// [`READY_STATE_POLL_INTERVAL`] and returns as soon as the observed state
1000    /// ranks at or above `until` (see [`ReadyState`] for the
1001    /// `Loading < Interactive < Complete` ordering). Bounded by a 30s outer
1002    /// timeout, matching [`Tab::wait_for_load`].
1003    ///
1004    /// This is the finer-grained sibling of [`Tab::wait_for_load`]: use it when
1005    /// you want to proceed at `Interactive` (DOM parsed, scripts runnable)
1006    /// without waiting for every image / stylesheet that `Complete` implies.
1007    ///
1008    /// # Errors
1009    ///
1010    /// Returns [`ZendriverError::Timeout`] if the requested state is not
1011    /// reached within 30s; propagates [`ZendriverError::JsException`] if the
1012    /// `document.readyState` read raises.
1013    ///
1014    /// # Examples
1015    ///
1016    /// ```no_run
1017    /// # async fn ex() -> zendriver::Result<()> {
1018    /// use zendriver::ReadyState;
1019    /// # let browser = zendriver::Browser::builder().launch().await?;
1020    /// # let tab = browser.main_tab();
1021    /// tab.goto("https://example.com").await?;
1022    /// // Proceed as soon as the DOM is parsed, don't wait for sub-resources.
1023    /// tab.wait_for_ready_state(ReadyState::Interactive).await?;
1024    /// # Ok(()) }
1025    /// ```
1026    pub async fn wait_for_ready_state(&self, until: ReadyState) -> Result<()> {
1027        let deadline = tokio::time::Instant::now() + DEFAULT_LOAD_TIMEOUT;
1028        loop {
1029            let state: String = self.evaluate_main("document.readyState").await?;
1030            if let Some(observed) = ReadyState::from_dom_str(&state) {
1031                if observed.rank() >= until.rank() {
1032                    return Ok(());
1033                }
1034            }
1035            if tokio::time::Instant::now() >= deadline {
1036                return Err(ZendriverError::Timeout(DEFAULT_LOAD_TIMEOUT));
1037            }
1038            tokio::time::sleep(READY_STATE_POLL_INTERVAL).await;
1039        }
1040    }
1041
1042    /// Evaluate a JavaScript expression in an isolated world.
1043    ///
1044    /// Runs in a sandbox where page globals are NOT visible — the default for
1045    /// stealth-safe execution. The result is deserialized into `T`.
1046    ///
1047    /// If the cached isolated-world execution context was destroyed (e.g. by
1048    /// a page navigation), the cache is invalidated and the evaluation is
1049    /// retried once. One retry is enough: the failure mode this guards
1050    /// against is "navigation happened between cache-fetch and `Runtime.evaluate`",
1051    /// which is a one-shot race — recreating the world for the new context
1052    /// and re-issuing the call clears it. If the same call fails the
1053    /// second attempt the page has a real problem (target gone, isolated
1054    /// world refused to recreate) and further retries would only mask it.
1055    ///
1056    /// # Errors
1057    ///
1058    /// Returns [`ZendriverError::JsException`] when the expression raises;
1059    /// [`ZendriverError::Serde`] when the result cannot be decoded into `T`;
1060    /// [`ZendriverError::Navigation`] when the execution context is missing.
1061    ///
1062    /// # Examples
1063    ///
1064    /// ```no_run
1065    /// # async fn ex() -> zendriver::Result<()> {
1066    /// # let browser = zendriver::Browser::builder().launch().await?;
1067    /// # let tab = browser.main_tab();
1068    /// tab.goto("https://example.com").await?;
1069    /// let n: i32 = tab.evaluate("1 + 2").await?;
1070    /// assert_eq!(n, 3);
1071    /// # Ok(()) }
1072    /// ```
1073    pub async fn evaluate<T: DeserializeOwned>(&self, js: impl AsRef<str>) -> Result<T> {
1074        let js = js.as_ref();
1075        for attempt in 0..2 {
1076            let ctx_id = self.ensure_isolated_world().await?;
1077            let res = self
1078                .call(
1079                    "Runtime.evaluate",
1080                    json!({
1081                        "expression": js,
1082                        "contextId": ctx_id,
1083                        "returnByValue": true,
1084                        "awaitPromise": true,
1085                    }),
1086                )
1087                .await;
1088            match res {
1089                Ok(v) => {
1090                    if let Some(details) = v.get("exceptionDetails") {
1091                        let msg = details
1092                            .get("exception")
1093                            .and_then(|e| e.get("description"))
1094                            .and_then(|d| d.as_str())
1095                            .unwrap_or("unknown")
1096                            .to_string();
1097                        return Err(ZendriverError::JsException(msg));
1098                    }
1099                    let value = v
1100                        .get("result")
1101                        .and_then(|r| r.get("value"))
1102                        .cloned()
1103                        .unwrap_or(Value::Null);
1104                    return serde_json::from_value(value).map_err(ZendriverError::Serde);
1105                }
1106                // Chrome returns -32000 "Cannot find context with specified
1107                // id" when the execution context we cached was destroyed
1108                // (typically by a navigation). `From<CallError>` maps that
1109                // to `Navigation` (see `error.rs`), so we match on that
1110                // variant here — not on `Cdp` as the original P2 plan
1111                // suggested.
1112                Err(ZendriverError::Navigation(ref m))
1113                    if attempt == 0 && m.contains("Cannot find context") =>
1114                {
1115                    self.inner.isolated_world.lock().await.context_id = None;
1116                    continue;
1117                }
1118                Err(e) => return Err(e),
1119            }
1120        }
1121        unreachable!()
1122    }
1123
1124    /// Evaluate a JavaScript expression in the page main world.
1125    ///
1126    /// Page globals (e.g. `window.foo` set by page scripts) ARE visible.
1127    /// Escape hatch for cases where isolated-world semantics don't fit; for
1128    /// stealth-sensitive contexts prefer [`Tab::evaluate`].
1129    ///
1130    /// # Errors
1131    ///
1132    /// Returns [`ZendriverError::JsException`] when the expression raises;
1133    /// [`ZendriverError::Serde`] when the result cannot be decoded into `T`.
1134    ///
1135    /// # Examples
1136    ///
1137    /// ```no_run
1138    /// # async fn ex() -> zendriver::Result<()> {
1139    /// # let browser = zendriver::Browser::builder().launch().await?;
1140    /// # let tab = browser.main_tab();
1141    /// tab.goto("https://example.com").await?;
1142    /// let title: String = tab.evaluate_main("document.title").await?;
1143    /// println!("{title}");
1144    /// # Ok(()) }
1145    /// ```
1146    pub async fn evaluate_main<T: DeserializeOwned>(&self, js: impl AsRef<str>) -> Result<T> {
1147        let res = self
1148            .call(
1149                "Runtime.evaluate",
1150                json!({
1151                    "expression": js.as_ref(),
1152                    "returnByValue": true,
1153                    "awaitPromise": true,
1154                }),
1155            )
1156            .await?;
1157        if let Some(details) = res.get("exceptionDetails") {
1158            let msg = details
1159                .get("exception")
1160                .and_then(|e| e.get("description"))
1161                .and_then(|d| d.as_str())
1162                .unwrap_or("unknown")
1163                .to_string();
1164            return Err(ZendriverError::JsException(msg));
1165        }
1166        let value = res
1167            .get("result")
1168            .and_then(|r| r.get("value"))
1169            .cloned()
1170            .unwrap_or(Value::Null);
1171        serde_json::from_value(value).map_err(ZendriverError::Serde)
1172    }
1173
1174    /// Dump a named JavaScript object (or any expression) as an untyped
1175    /// [`serde_json::Value`].
1176    ///
1177    /// Evaluates `obj_name` in the page main world with `returnByValue: true`
1178    /// and hands back the deep-serialized `result.value`. This is the untyped
1179    /// sibling of [`Tab::evaluate_main`] — useful for grabbing a whole object
1180    /// graph (`tab.js_dumps("window.performance").await?`) when you don't have
1181    /// a concrete Rust type to deserialize into and just want to inspect the
1182    /// structure.
1183    ///
1184    /// As nodriver's `js_dumps` notes: complex objects may not be fully
1185    /// serializable (functions, cyclic references, host objects), so the
1186    /// result is a best-effort snapshot, not a source of truth.
1187    ///
1188    /// # Errors
1189    ///
1190    /// Returns [`ZendriverError::JsException`] when the expression raises.
1191    ///
1192    /// # Examples
1193    ///
1194    /// ```no_run
1195    /// # async fn ex() -> zendriver::Result<()> {
1196    /// # let browser = zendriver::Browser::builder().launch().await?;
1197    /// # let tab = browser.main_tab();
1198    /// tab.goto("https://example.com").await?;
1199    /// let perf = tab.js_dumps("window.performance.timing").await?;
1200    /// println!("{perf:#?}");
1201    /// # Ok(()) }
1202    /// ```
1203    pub async fn js_dumps(&self, obj_name: &str) -> Result<Value> {
1204        let res = self
1205            .call(
1206                "Runtime.evaluate",
1207                json!({
1208                    "expression": obj_name,
1209                    "returnByValue": true,
1210                }),
1211            )
1212            .await?;
1213        if let Some(details) = res.get("exceptionDetails") {
1214            let msg = details
1215                .get("exception")
1216                .and_then(|e| e.get("description"))
1217                .and_then(|d| d.as_str())
1218                .unwrap_or("unknown")
1219                .to_string();
1220            return Err(ZendriverError::JsException(msg));
1221        }
1222        Ok(res
1223            .get("result")
1224            .and_then(|r| r.get("value"))
1225            .cloned()
1226            .unwrap_or(Value::Null))
1227    }
1228
1229    /// Ensure an isolated-world execution context exists for this tab's main
1230    /// frame, returning its `executionContextId`. Cached after first call.
1231    pub(crate) async fn ensure_isolated_world(&self) -> Result<i64> {
1232        let mut cache = self.inner.isolated_world.lock().await;
1233        if let Some(ctx) = cache.context_id {
1234            return Ok(ctx);
1235        }
1236        // Discover the main frame id.
1237        let tree = self.call("Page.getFrameTree", json!({})).await?;
1238        let frame_id = tree["frameTree"]["frame"]["id"]
1239            .as_str()
1240            .ok_or_else(|| ZendriverError::Navigation("no main frame in Page.getFrameTree".into()))?
1241            .to_string();
1242        let res = self
1243            .call(
1244                "Page.createIsolatedWorld",
1245                json!({
1246                    "frameId": frame_id,
1247                    "worldName": "zendriver-eval",
1248                    "grantUniversalAccess": false,
1249                }),
1250            )
1251            .await?;
1252        let ctx_id = res["executionContextId"].as_i64().ok_or_else(|| {
1253            ZendriverError::Navigation(
1254                "Page.createIsolatedWorld did not return executionContextId".into(),
1255            )
1256        })?;
1257        cache.main_frame_id = Some(frame_id);
1258        cache.context_id = Some(ctx_id);
1259        Ok(ctx_id)
1260    }
1261
1262    /// Get the tab's current URL.
1263    ///
1264    /// Returns a parsed [`url::Url`]. Reads from `Target.getTargetInfo`'s
1265    /// `targetInfo.url`.
1266    ///
1267    /// # Errors
1268    ///
1269    /// Returns [`ZendriverError::Navigation`] when Chrome returns no URL or
1270    /// the URL is unparseable.
1271    ///
1272    /// # Examples
1273    ///
1274    /// ```no_run
1275    /// # async fn ex() -> zendriver::Result<()> {
1276    /// # let browser = zendriver::Browser::builder().launch().await?;
1277    /// # let tab = browser.main_tab();
1278    /// tab.goto("https://example.com/foo").await?;
1279    /// let u = tab.url().await?;
1280    /// assert_eq!(u.path(), "/foo");
1281    /// # Ok(()) }
1282    /// ```
1283    pub async fn url(&self) -> Result<url::Url> {
1284        let res = self.call("Target.getTargetInfo", json!({})).await?;
1285        let s = res["targetInfo"]["url"]
1286            .as_str()
1287            .ok_or_else(|| ZendriverError::Navigation("target has no url".into()))?;
1288        url::Url::parse(s).map_err(|e| ZendriverError::Navigation(e.to_string()))
1289    }
1290
1291    /// Get the tab's `<title>`.
1292    ///
1293    /// Reads from `Target.getTargetInfo`'s `targetInfo.title`. Returns an
1294    /// empty string when the page has no title.
1295    ///
1296    /// # Examples
1297    ///
1298    /// ```no_run
1299    /// # async fn ex() -> zendriver::Result<()> {
1300    /// # let browser = zendriver::Browser::builder().launch().await?;
1301    /// # let tab = browser.main_tab();
1302    /// tab.goto("https://example.com").await?;
1303    /// assert_eq!(tab.title().await?, "Example Domain");
1304    /// # Ok(()) }
1305    /// ```
1306    pub async fn title(&self) -> Result<String> {
1307        let res = self.call("Target.getTargetInfo", json!({})).await?;
1308        Ok(res["targetInfo"]["title"]
1309            .as_str()
1310            .unwrap_or("")
1311            .to_string())
1312    }
1313
1314    /// Construct a [`ScreenshotBuilder`] bound to this tab.
1315    ///
1316    /// Chain format / clip / quality / full-page options, then call
1317    /// [`ScreenshotBuilder::bytes`] or [`ScreenshotBuilder::save`] to
1318    /// execute the capture.
1319    ///
1320    /// For element-scoped screenshots, see [`crate::Element::screenshot`].
1321    ///
1322    /// # Examples
1323    ///
1324    /// ```no_run
1325    /// # async fn ex() -> zendriver::Result<()> {
1326    /// # let browser = zendriver::Browser::builder().launch().await?;
1327    /// # let tab = browser.main_tab();
1328    /// tab.goto("https://example.com").await?;
1329    /// tab.screenshot_builder()
1330    ///     .full_page(true)
1331    ///     .jpeg()
1332    ///     .quality(85)
1333    ///     .save("page.jpg").await?;
1334    /// # Ok(()) }
1335    /// ```
1336    #[must_use]
1337    pub fn screenshot_builder(&self) -> ScreenshotBuilder<'_> {
1338        ScreenshotBuilder::new(self)
1339    }
1340
1341    /// Capture a full-viewport PNG screenshot of this tab.
1342    ///
1343    /// Convenience wrapper over `self.screenshot_builder().png().bytes().await`.
1344    /// For JPEG / WebP / full-page / clipped captures, drive
1345    /// [`Tab::screenshot_builder`] directly. For element-scoped screenshots,
1346    /// see [`crate::Element::screenshot`].
1347    ///
1348    /// # Examples
1349    ///
1350    /// ```no_run
1351    /// # async fn ex() -> zendriver::Result<()> {
1352    /// # let browser = zendriver::Browser::builder().launch().await?;
1353    /// # let tab = browser.main_tab();
1354    /// tab.goto("https://example.com").await?;
1355    /// let png_bytes = tab.screenshot().await?;
1356    /// tokio::fs::write("page.png", png_bytes).await?;
1357    /// # Ok(()) }
1358    /// ```
1359    pub async fn screenshot(&self) -> Result<Vec<u8>> {
1360        self.screenshot_builder().png().bytes().await
1361    }
1362
1363    /// Close this tab in Chrome.
1364    ///
1365    /// Sends `Target.closeTarget { targetId }` at browser scope (no
1366    /// `session_id`) using the cached `targetId`. Chrome destroys the page
1367    /// target, which in turn produces a `Target.detachedFromTarget` event
1368    /// whose internal handler removes this tab from the browser's tab
1369    /// registry.
1370    ///
1371    /// Consumes `self` — the [`Tab`] handle is gone after this returns.
1372    ///
1373    /// # Examples
1374    ///
1375    /// ```no_run
1376    /// # async fn ex() -> zendriver::Result<()> {
1377    /// # let browser = zendriver::Browser::builder().launch().await?;
1378    /// let tab = browser.new_tab().await?;
1379    /// tab.goto("https://example.com").await?;
1380    /// tab.close().await?;
1381    /// # Ok(()) }
1382    /// ```
1383    pub async fn close(self) -> Result<()> {
1384        let target_id = self.target_id().to_string();
1385        self.inner
1386            .session
1387            .connection()
1388            .call_raw("Target.closeTarget", json!({ "targetId": target_id }), None)
1389            .await?;
1390        Ok(())
1391    }
1392
1393    /// Bring this tab to the foreground in Chrome.
1394    ///
1395    /// Sends `Target.activateTarget { targetId }` at browser scope (no
1396    /// `session_id`) using the cached `targetId`. Chrome focuses the page
1397    /// target so it becomes the visible/active tab.
1398    ///
1399    /// Unlike [`Tab::close`], this borrows `&self` — the tab remains usable
1400    /// after activation. Useful in multi-tab workflows where you want to
1401    /// surface a specific tab without tearing it down.
1402    ///
1403    /// # Examples
1404    ///
1405    /// ```no_run
1406    /// # async fn ex() -> zendriver::Result<()> {
1407    /// # let browser = zendriver::Browser::builder().launch().await?;
1408    /// let tab1 = browser.main_tab();
1409    /// let tab2 = browser.new_tab().await?;
1410    /// // Bring the first tab back to focus.
1411    /// tab1.activate().await?;
1412    /// # let _ = tab2;
1413    /// # Ok(()) }
1414    /// ```
1415    pub async fn activate(&self) -> Result<()> {
1416        let target_id = self.target_id().to_string();
1417        self.inner
1418            .session
1419            .connection()
1420            .call_raw(
1421                "Target.activateTarget",
1422                json!({ "targetId": target_id }),
1423                None,
1424            )
1425            .await?;
1426        Ok(())
1427    }
1428
1429    /// Navigate one step backward in the tab's session history.
1430    ///
1431    /// Fetches the history list via `Page.getNavigationHistory`, then
1432    /// dispatches `Page.navigateToHistoryEntry { entryId }` for the entry at
1433    /// `currentIndex - 1`.
1434    ///
1435    /// # Errors
1436    ///
1437    /// Returns [`ZendriverError::HistoryNavigation`] with `"no back history"`
1438    /// when `currentIndex <= 0`.
1439    ///
1440    /// # Examples
1441    ///
1442    /// ```no_run
1443    /// # async fn ex() -> zendriver::Result<()> {
1444    /// # let browser = zendriver::Browser::builder().launch().await?;
1445    /// # let tab = browser.main_tab();
1446    /// tab.goto("https://example.com").await?;
1447    /// tab.goto("https://example.org").await?;
1448    /// tab.back().await?;
1449    /// # Ok(()) }
1450    /// ```
1451    pub async fn back(&self) -> Result<()> {
1452        let history = self.call("Page.getNavigationHistory", json!({})).await?;
1453        let current_idx = history["currentIndex"].as_i64().ok_or_else(|| {
1454            ZendriverError::HistoryNavigation(
1455                "Page.getNavigationHistory missing currentIndex".into(),
1456            )
1457        })?;
1458        if current_idx <= 0 {
1459            return Err(ZendriverError::HistoryNavigation("no back history".into()));
1460        }
1461        let entry_id = history["entries"][(current_idx - 1) as usize]["id"].clone();
1462        self.call(
1463            "Page.navigateToHistoryEntry",
1464            json!({ "entryId": entry_id }),
1465        )
1466        .await?;
1467        Ok(())
1468    }
1469
1470    /// Navigate one step forward in the tab's session history.
1471    ///
1472    /// Fetches the history list via `Page.getNavigationHistory`, then
1473    /// dispatches `Page.navigateToHistoryEntry { entryId }` for the entry at
1474    /// `currentIndex + 1`.
1475    ///
1476    /// # Errors
1477    ///
1478    /// Returns [`ZendriverError::HistoryNavigation`] with `"no forward history"`
1479    /// when `currentIndex` is already at the last entry.
1480    ///
1481    /// # Examples
1482    ///
1483    /// ```no_run
1484    /// # async fn ex() -> zendriver::Result<()> {
1485    /// # let browser = zendriver::Browser::builder().launch().await?;
1486    /// # let tab = browser.main_tab();
1487    /// tab.goto("https://example.com").await?;
1488    /// tab.goto("https://example.org").await?;
1489    /// tab.back().await?;
1490    /// tab.forward().await?;
1491    /// # Ok(()) }
1492    /// ```
1493    pub async fn forward(&self) -> Result<()> {
1494        let history = self.call("Page.getNavigationHistory", json!({})).await?;
1495        let current_idx = history["currentIndex"].as_i64().ok_or_else(|| {
1496            ZendriverError::HistoryNavigation(
1497                "Page.getNavigationHistory missing currentIndex".into(),
1498            )
1499        })?;
1500        let entries = history["entries"].as_array().ok_or_else(|| {
1501            ZendriverError::HistoryNavigation("Page.getNavigationHistory missing entries".into())
1502        })?;
1503        if (current_idx + 1) as usize >= entries.len() {
1504            return Err(ZendriverError::HistoryNavigation(
1505                "no forward history".into(),
1506            ));
1507        }
1508        let entry_id = entries[(current_idx + 1) as usize]["id"].clone();
1509        self.call(
1510            "Page.navigateToHistoryEntry",
1511            json!({ "entryId": entry_id }),
1512        )
1513        .await?;
1514        Ok(())
1515    }
1516
1517    /// Reload the tab's current page.
1518    ///
1519    /// Dispatches `Page.reload` with `ignoreCache: false` — equivalent to a
1520    /// soft refresh.
1521    ///
1522    /// # Examples
1523    ///
1524    /// ```no_run
1525    /// # async fn ex() -> zendriver::Result<()> {
1526    /// # let browser = zendriver::Browser::builder().launch().await?;
1527    /// # let tab = browser.main_tab();
1528    /// tab.reload().await?;
1529    /// # Ok(()) }
1530    /// ```
1531    pub async fn reload(&self) -> Result<()> {
1532        self.call("Page.reload", json!({ "ignoreCache": false }))
1533            .await?;
1534        Ok(())
1535    }
1536
1537    /// Reload the tab's current page with explicit options.
1538    ///
1539    /// Dispatches `Page.reload` with the `ignoreCache` flag from `opts` and,
1540    /// when `opts.script_to_evaluate_on_load` is `Some`, a
1541    /// `scriptToEvaluateOnLoad` that runs before any other script on each
1542    /// frame the reload loads. The script field is omitted from the dispatch
1543    /// when `None`. For a plain soft refresh, use the [`Tab::reload`]
1544    /// shortcut.
1545    ///
1546    /// # Examples
1547    ///
1548    /// ```no_run
1549    /// # async fn ex() -> zendriver::Result<()> {
1550    /// use zendriver::ReloadOptions;
1551    /// # let browser = zendriver::Browser::builder().launch().await?;
1552    /// # let tab = browser.main_tab();
1553    /// tab.reload_with(ReloadOptions {
1554    ///     ignore_cache: true,
1555    ///     script_to_evaluate_on_load: Some("window.__reloaded = true".into()),
1556    /// }).await?;
1557    /// # Ok(()) }
1558    /// ```
1559    pub async fn reload_with(&self, opts: ReloadOptions) -> Result<()> {
1560        let mut params = json!({ "ignoreCache": opts.ignore_cache });
1561        if let Some(script) = opts.script_to_evaluate_on_load {
1562            params["scriptToEvaluateOnLoad"] = Value::String(script);
1563        }
1564        self.call("Page.reload", params).await?;
1565        Ok(())
1566    }
1567
1568    /// Full HTML source of the tab's current page.
1569    ///
1570    /// Dispatches `DOM.getDocument { depth: 0 }` to resolve the document's
1571    /// root `nodeId`, then `DOM.getOuterHTML { nodeId }` to serialize it.
1572    /// The result is the complete document markup including the doctype —
1573    /// the page-level analogue of [`crate::Frame::content`].
1574    ///
1575    /// # Errors
1576    ///
1577    /// Returns [`ZendriverError::Navigation`] when Chrome's response is
1578    /// missing the root `nodeId` or the serialized `outerHTML`.
1579    ///
1580    /// # Examples
1581    ///
1582    /// ```no_run
1583    /// # async fn ex() -> zendriver::Result<()> {
1584    /// # let browser = zendriver::Browser::builder().launch().await?;
1585    /// # let tab = browser.main_tab();
1586    /// tab.goto("https://example.com").await?;
1587    /// let html = tab.content().await?;
1588    /// assert!(html.contains("<html"));
1589    /// # Ok(()) }
1590    /// ```
1591    pub async fn content(&self) -> Result<String> {
1592        let doc = self.call("DOM.getDocument", json!({ "depth": 0 })).await?;
1593        let node_id = doc["root"]["nodeId"].as_i64().ok_or_else(|| {
1594            ZendriverError::Navigation("DOM.getDocument missing root.nodeId".into())
1595        })?;
1596        let res = self
1597            .call("DOM.getOuterHTML", json!({ "nodeId": node_id }))
1598            .await?;
1599        res["outerHTML"]
1600            .as_str()
1601            .map(str::to_string)
1602            .ok_or_else(|| ZendriverError::Navigation("DOM.getOuterHTML missing outerHTML".into()))
1603    }
1604
1605    /// Scroll the page down by `pixels`.
1606    ///
1607    /// Dispatches `Input.synthesizeScrollGesture` anchored at a fixed
1608    /// viewport point with a **negative** `yDistance` of `pixels` — the CDP
1609    /// convention where a negative `yDistance` moves the page content up
1610    /// (i.e. scrolls down). For horizontal scrolling, a custom speed, or
1611    /// scrolling up, see [`Tab::scroll_with`] / [`Tab::scroll_up`].
1612    ///
1613    /// # Examples
1614    ///
1615    /// ```no_run
1616    /// # async fn ex() -> zendriver::Result<()> {
1617    /// # let browser = zendriver::Browser::builder().launch().await?;
1618    /// # let tab = browser.main_tab();
1619    /// tab.goto("https://example.com").await?;
1620    /// tab.scroll_down(500.0).await?;
1621    /// # Ok(()) }
1622    /// ```
1623    pub async fn scroll_down(&self, pixels: f64) -> Result<()> {
1624        self.scroll_with(ScrollOptions {
1625            dx: 0.0,
1626            dy: -pixels,
1627            speed: None,
1628        })
1629        .await
1630    }
1631
1632    /// Scroll the page up by `pixels`.
1633    ///
1634    /// Mirror of [`Tab::scroll_down`] with a **positive** `yDistance` of
1635    /// `pixels`, which moves the page content down (scrolls up).
1636    ///
1637    /// # Examples
1638    ///
1639    /// ```no_run
1640    /// # async fn ex() -> zendriver::Result<()> {
1641    /// # let browser = zendriver::Browser::builder().launch().await?;
1642    /// # let tab = browser.main_tab();
1643    /// tab.goto("https://example.com").await?;
1644    /// tab.scroll_down(500.0).await?;
1645    /// tab.scroll_up(200.0).await?;
1646    /// # Ok(()) }
1647    /// ```
1648    pub async fn scroll_up(&self, pixels: f64) -> Result<()> {
1649        self.scroll_with(ScrollOptions {
1650            dx: 0.0,
1651            dy: pixels,
1652            speed: None,
1653        })
1654        .await
1655    }
1656
1657    /// Scroll the page by an explicit signed distance with optional speed.
1658    ///
1659    /// Dispatches `Input.synthesizeScrollGesture` anchored at a fixed
1660    /// viewport point (`x: 100, y: 100` — a stable, in-viewport anchor that
1661    /// avoids a `Page.getLayoutMetrics` round-trip), forwarding
1662    /// [`ScrollOptions::dx`] / [`ScrollOptions::dy`] to `xDistance` /
1663    /// `yDistance` and [`ScrollOptions::speed`] to `speed` when `Some`.
1664    /// Negative `dy` scrolls the page down (CDP convention).
1665    ///
1666    /// # Examples
1667    ///
1668    /// ```no_run
1669    /// # async fn ex() -> zendriver::Result<()> {
1670    /// use zendriver::ScrollOptions;
1671    /// # let browser = zendriver::Browser::builder().launch().await?;
1672    /// # let tab = browser.main_tab();
1673    /// tab.scroll_with(ScrollOptions { dx: 0.0, dy: -300.0, speed: Some(1200) }).await?;
1674    /// # Ok(()) }
1675    /// ```
1676    pub async fn scroll_with(&self, opts: ScrollOptions) -> Result<()> {
1677        // Fixed in-viewport anchor for the gesture. Picking a constant point
1678        // keeps the call deterministic + single-dispatch (no
1679        // Page.getLayoutMetrics round-trip to compute a center); the scroll
1680        // distance, not the anchor, is what callers care about.
1681        let mut params = json!({
1682            "x": SCROLL_ANCHOR.0,
1683            "y": SCROLL_ANCHOR.1,
1684            "xDistance": opts.dx,
1685            "yDistance": opts.dy,
1686        });
1687        if let Some(speed) = opts.speed {
1688            params["speed"] = Value::from(speed);
1689        }
1690        self.call("Input.synthesizeScrollGesture", params).await?;
1691        Ok(())
1692    }
1693
1694    /// Wait until the tab's network has been idle (0 in-flight requests)
1695    /// for 500ms, with a 30s outer timeout. Playwright `networkidle`
1696    /// semantics.
1697    ///
1698    /// Backed by a per-Tab in-flight network tracker that subscribes to
1699    /// `Network.requestWillBeSent` (insert) and the three terminal events
1700    /// (`responseReceived` / `loadingFailed` / `loadingFinished`, all
1701    /// remove).
1702    ///
1703    /// # Errors
1704    ///
1705    /// Returns [`ZendriverError::Timeout`] with the configured timeout
1706    /// duration when the network does not stay idle within the deadline.
1707    ///
1708    /// See [`Tab::wait_for_idle_with`] for tunable timeout + quiet window.
1709    ///
1710    /// # Examples
1711    ///
1712    /// ```no_run
1713    /// # async fn ex() -> zendriver::Result<()> {
1714    /// # let browser = zendriver::Browser::builder().launch().await?;
1715    /// # let tab = browser.main_tab();
1716    /// tab.goto("https://example.com").await?;
1717    /// tab.wait_for_idle().await?;
1718    /// # Ok(()) }
1719    /// ```
1720    pub async fn wait_for_idle(&self) -> Result<()> {
1721        self.wait_for_idle_opts(IdleOptions::default()).await
1722    }
1723
1724    /// Wait until the tab's network has been idle for `quiet_window`,
1725    /// bounded by `timeout`. Convenience wrapper over
1726    /// [`Tab::wait_for_idle_opts`] with no stuck-request eviction
1727    /// (`max_inflight_age: None`).
1728    ///
1729    /// # Errors
1730    ///
1731    /// Returns [`ZendriverError::Timeout`] (carrying the supplied `timeout`)
1732    /// once the outer deadline elapses.
1733    ///
1734    /// # Examples
1735    ///
1736    /// ```no_run
1737    /// # use std::time::Duration;
1738    /// # async fn ex() -> zendriver::Result<()> {
1739    /// # let browser = zendriver::Browser::builder().launch().await?;
1740    /// # let tab = browser.main_tab();
1741    /// tab.goto("https://example.com").await?;
1742    /// tab.wait_for_idle_with(
1743    ///     Duration::from_secs(60),
1744    ///     Duration::from_secs(1),
1745    /// ).await?;
1746    /// # Ok(()) }
1747    /// ```
1748    pub async fn wait_for_idle_with(
1749        &self,
1750        timeout: Duration,
1751        quiet_window: Duration,
1752    ) -> Result<()> {
1753        self.wait_for_idle_opts(IdleOptions {
1754            timeout,
1755            quiet_window,
1756            max_inflight_age: None,
1757        })
1758        .await
1759    }
1760
1761    /// Wait for network idle with full control over the policy via
1762    /// [`IdleOptions`].
1763    ///
1764    /// Algorithm: poll the in-flight set with a `Notify`-driven wake (or a
1765    /// 50ms fallback tick). Each iteration computes the number of *active*
1766    /// requests — every in-flight request when
1767    /// [`IdleOptions::max_inflight_age`] is `None`, otherwise only those in
1768    /// flight for less than that age (older ones are treated as stuck /
1769    /// background and ignored). Track `quiet_start = Some(now)` on the first
1770    /// observation of zero active requests; reset to `None` whenever the active
1771    /// count is non-zero or a membership change fires. Return once
1772    /// `now - quiet_start >= quiet_window`.
1773    ///
1774    /// The 50ms tick bounds latency both for the already-idle case (no further
1775    /// events fire) and for an age-out crossing (which emits no CDP event), so
1776    /// worst-case latency to detect "stayed idle long enough" is
1777    /// `quiet_window + 50ms`.
1778    ///
1779    /// # Errors
1780    ///
1781    /// Returns [`ZendriverError::Timeout`] (carrying [`IdleOptions::timeout`])
1782    /// once the outer deadline elapses.
1783    ///
1784    /// # Examples
1785    ///
1786    /// ```no_run
1787    /// # use std::time::Duration;
1788    /// # use zendriver::IdleOptions;
1789    /// # async fn ex() -> zendriver::Result<()> {
1790    /// # let browser = zendriver::Browser::builder().launch().await?;
1791    /// # let tab = browser.main_tab();
1792    /// tab.goto("https://example.com").await?;
1793    /// // Resolve even if a beacon / long-poll stays open past 5s.
1794    /// tab.wait_for_idle_opts(IdleOptions {
1795    ///     max_inflight_age: Some(Duration::from_secs(5)),
1796    ///     ..Default::default()
1797    /// }).await?;
1798    /// # Ok(()) }
1799    /// ```
1800    pub async fn wait_for_idle_opts(&self, opts: IdleOptions) -> Result<()> {
1801        let IdleOptions {
1802            timeout,
1803            quiet_window,
1804            max_inflight_age,
1805        } = opts;
1806        let tracker = self.inner.network_tracker.clone();
1807        let deadline = tokio::time::Instant::now() + timeout;
1808        let mut quiet_start: Option<tokio::time::Instant> = None;
1809        loop {
1810            // Arm the notification interest BEFORE reading the in-flight set
1811            // so a notification fired between the read and the `select!`
1812            // below is still delivered. `Notify::notified()` only catches
1813            // notifications fired after the future has been `enable()`d, so
1814            // doing it the other way around would let a request that started
1815            // *and finished* inside the quiet window slip past us with a
1816            // sustained count of 0 — `wait_for_idle` would return early.
1817            let notif = tracker.notifier.notified();
1818            tokio::pin!(notif);
1819            notif.as_mut().enable();
1820
1821            // "Active" = requests that still count toward busy-ness. With
1822            // `max_inflight_age` set, a request in flight longer than that age
1823            // is treated as stuck/background and excluded, so a never-
1824            // terminating request can no longer pin the tab non-idle forever.
1825            let active_count = {
1826                let set = tracker.in_flight.lock().await;
1827                match max_inflight_age {
1828                    None => set.len(),
1829                    Some(age) => {
1830                        let now = tokio::time::Instant::now();
1831                        set.values()
1832                            .filter(|inserted| now.duration_since(**inserted) < age)
1833                            .count()
1834                    }
1835                }
1836            };
1837            if active_count == 0 {
1838                let now = tokio::time::Instant::now();
1839                match quiet_start {
1840                    None => quiet_start = Some(now),
1841                    Some(start) if now.duration_since(start) >= quiet_window => {
1842                        return Ok(());
1843                    }
1844                    _ => {}
1845                }
1846            } else {
1847                quiet_start = None;
1848            }
1849            if tokio::time::Instant::now() >= deadline {
1850                return Err(ZendriverError::Timeout(timeout));
1851            }
1852            tokio::select! {
1853                () = tokio::time::sleep(Duration::from_millis(50)) => {}
1854                () = notif => {
1855                    // A membership change fired since we armed `notif`. Reset
1856                    // the quiet window — even if the set is back to zero by
1857                    // the next iteration, real activity occurred during this
1858                    // window so it doesn't count as "idle".
1859                    quiet_start = None;
1860                }
1861            }
1862        }
1863    }
1864
1865    /// Override this tab's user-agent string at runtime.
1866    ///
1867    /// Dispatches `Emulation.setUserAgentOverride { userAgent }`. Convenience
1868    /// shortcut over [`Tab::set_user_agent_with`] for the UA-only case (no
1869    /// `acceptLanguage` / `platform`).
1870    ///
1871    /// # Stealth warning
1872    ///
1873    /// This is **last-write-wins** over the stealth observer's own UA override
1874    /// and sends NO `userAgentMetadata`, so under the Spoofed stealth profile
1875    /// it clobbers the UA Client-Hints coherence the profile set up and can
1876    /// *increase* detectability. Prefer the stealth profile's UA for stealth;
1877    /// use this for non-stealth tabs or a deliberate per-tab UA change. See
1878    /// [`UserAgentOverride`] for the full rationale.
1879    ///
1880    /// # Examples
1881    ///
1882    /// ```no_run
1883    /// # async fn ex() -> zendriver::Result<()> {
1884    /// # let browser = zendriver::Browser::builder().launch().await?;
1885    /// # let tab = browser.main_tab();
1886    /// tab.set_user_agent("Mozilla/5.0 (compatible; MyBot/1.0)").await?;
1887    /// # Ok(()) }
1888    /// ```
1889    pub async fn set_user_agent(&self, user_agent: impl Into<String>) -> Result<()> {
1890        self.set_user_agent_with(UserAgentOverride {
1891            user_agent: user_agent.into(),
1892            ..Default::default()
1893        })
1894        .await
1895    }
1896
1897    /// Override this tab's user-agent (and optionally `Accept-Language` /
1898    /// platform) at runtime.
1899    ///
1900    /// Dispatches `Emulation.setUserAgentOverride` with `userAgent` always set
1901    /// and `acceptLanguage` / `platform` included only when the corresponding
1902    /// [`UserAgentOverride`] field is `Some` (omitted, not `null`, otherwise).
1903    /// For the UA-only case use the [`Tab::set_user_agent`] shortcut.
1904    ///
1905    /// # Stealth warning
1906    ///
1907    /// Same caveat as [`Tab::set_user_agent`]: this is last-write-wins and
1908    /// sends no `userAgentMetadata`, so under the Spoofed stealth profile it
1909    /// clobbers UA Client-Hints coherence. See [`UserAgentOverride`].
1910    ///
1911    /// # Examples
1912    ///
1913    /// ```no_run
1914    /// # async fn ex() -> zendriver::Result<()> {
1915    /// use zendriver::UserAgentOverride;
1916    /// # let browser = zendriver::Browser::builder().launch().await?;
1917    /// # let tab = browser.main_tab();
1918    /// tab.set_user_agent_with(UserAgentOverride {
1919    ///     user_agent: "Mozilla/5.0 (X11; Linux x86_64) Gecko/20100101 Firefox/123.0".into(),
1920    ///     accept_language: Some("de-DE,de;q=0.9".into()),
1921    ///     platform: Some("Linux x86_64".into()),
1922    /// }).await?;
1923    /// # Ok(()) }
1924    /// ```
1925    pub async fn set_user_agent_with(&self, ovr: UserAgentOverride) -> Result<()> {
1926        let mut params = json!({ "userAgent": ovr.user_agent });
1927        if let Some(lang) = ovr.accept_language {
1928            params["acceptLanguage"] = Value::String(lang);
1929        }
1930        if let Some(platform) = ovr.platform {
1931            params["platform"] = Value::String(platform);
1932        }
1933        self.call("Emulation.setUserAgentOverride", params).await?;
1934        Ok(())
1935    }
1936
1937    /// Move the cursor to `(x, y)` in viewport coordinates along a realistic
1938    /// Bezier path.
1939    ///
1940    /// Tab-level analogue of [`crate::Element::hover`] for an arbitrary
1941    /// coordinate (no element required) — useful for canvas/widget targets
1942    /// and CAPTCHA-checkbox flows where the hit point is a fixed pixel rather
1943    /// than a DOM node. Emits a sequence of `Input.dispatchMouseEvent
1944    /// { type: "mouseMoved" }` calls and advances the per-tab cursor state.
1945    ///
1946    /// # Examples
1947    ///
1948    /// ```no_run
1949    /// # async fn ex() -> zendriver::Result<()> {
1950    /// # let browser = zendriver::Browser::builder().launch().await?;
1951    /// # let tab = browser.main_tab();
1952    /// tab.mouse_move(120.0, 240.0).await?;
1953    /// # Ok(()) }
1954    /// ```
1955    pub async fn mouse_move(&self, x: f64, y: f64) -> Result<()> {
1956        let input = self.input().clone();
1957        crate::input::mouse::move_realistic(&input, self, x, y).await
1958    }
1959
1960    /// Click at `(x, y)` in viewport coordinates: a left, single, realistic
1961    /// click.
1962    ///
1963    /// Moves the cursor to the point along a Bezier path, then emits the
1964    /// `mousePressed` + `mouseReleased` pair. Tab-level analogue of
1965    /// [`crate::Element::click`] for a raw coordinate. For right-click /
1966    /// modifier-held / double-click / raw-teleport variants use
1967    /// [`Tab::mouse_click_with`].
1968    ///
1969    /// # Examples
1970    ///
1971    /// ```no_run
1972    /// # async fn ex() -> zendriver::Result<()> {
1973    /// # let browser = zendriver::Browser::builder().launch().await?;
1974    /// # let tab = browser.main_tab();
1975    /// tab.mouse_click(120.0, 240.0).await?;
1976    /// # Ok(()) }
1977    /// ```
1978    pub async fn mouse_click(&self, x: f64, y: f64) -> Result<()> {
1979        let input = self.input().clone();
1980        crate::input::mouse::click_at(
1981            &input,
1982            self,
1983            x,
1984            y,
1985            crate::input::mouse::MouseButton::Left,
1986            1,
1987            true,
1988        )
1989        .await
1990    }
1991
1992    /// Click at `(x, y)` in viewport coordinates with explicit
1993    /// [`crate::ClickOptions`].
1994    ///
1995    /// Maps `opts.button` / `opts.click_count` / `opts.realistic` onto the
1996    /// dispatch. Unlike [`crate::Element::click_with`], there is no element to
1997    /// gate on, so `opts.force` and `opts.position` are ignored — the click
1998    /// lands at the supplied `(x, y)` regardless. Use this for right-clicks /
1999    /// modifier-held clicks / double-clicks / raw teleports at a coordinate.
2000    ///
2001    /// # Examples
2002    ///
2003    /// ```no_run
2004    /// # async fn ex() -> zendriver::Result<()> {
2005    /// use zendriver::{ClickOptions, MouseButton};
2006    /// # let browser = zendriver::Browser::builder().launch().await?;
2007    /// # let tab = browser.main_tab();
2008    /// tab.mouse_click_with(50.0, 60.0, ClickOptions {
2009    ///     button: MouseButton::Right,
2010    ///     ..Default::default()
2011    /// }).await?;
2012    /// # Ok(()) }
2013    /// ```
2014    pub async fn mouse_click_with(
2015        &self,
2016        x: f64,
2017        y: f64,
2018        opts: crate::element::actions::ClickOptions,
2019    ) -> Result<()> {
2020        let input = self.input().clone();
2021        crate::input::mouse::click_at(
2022            &input,
2023            self,
2024            x,
2025            y,
2026            opts.button,
2027            opts.click_count,
2028            opts.realistic,
2029        )
2030        .await
2031    }
2032
2033    /// Flash a transient red dot at `(x, y)` for ~1 second — a visual debug
2034    /// aid.
2035    ///
2036    /// Injects a small absolutely-positioned `<div>` at the viewport
2037    /// coordinate via [`Tab::evaluate_main`], then schedules its own removal
2038    /// after roughly a second. Handy for eyeballing where [`Tab::mouse_click`]
2039    /// / [`Tab::mouse_move`] targets land when debugging coordinate math
2040    /// against a headful Chrome. Has no effect on input state and is not
2041    /// intended for production paths.
2042    ///
2043    /// # Examples
2044    ///
2045    /// ```no_run
2046    /// # async fn ex() -> zendriver::Result<()> {
2047    /// # let browser = zendriver::Browser::builder().launch().await?;
2048    /// # let tab = browser.main_tab();
2049    /// tab.flash_point(120.0, 240.0).await?;
2050    /// # Ok(()) }
2051    /// ```
2052    pub async fn flash_point(&self, x: f64, y: f64) -> Result<()> {
2053        // Build the dot in the page main world so it's painted into the real
2054        // document the user is watching. `returnByValue` short-circuits to
2055        // undefined; we only care about the side effect.
2056        let js = format!(
2057            "(() => {{ \
2058                const d = document.createElement('div'); \
2059                d.style.cssText = 'position:fixed;left:{x}px;top:{y}px;width:10px;height:10px;\
2060margin:-5px 0 0 -5px;border-radius:50%;background:red;z-index:2147483647;\
2061pointer-events:none;opacity:0.85;'; \
2062                document.body.appendChild(d); \
2063                setTimeout(() => d.remove(), 1000); \
2064            }})()"
2065        );
2066        let _: Value = self.evaluate_main(js).await?;
2067        Ok(())
2068    }
2069
2070    /// Drag the mouse from `from` to `to` with the left button held, moving in
2071    /// `steps` interpolated hops.
2072    ///
2073    /// Ports nodriver's `Tab.mouse_drag`: emits a `mousePressed` (left button)
2074    /// at `from`, then a sequence of `mouseMoved` events linearly interpolated
2075    /// toward `to`, then a `mouseReleased` (left) at `to`. A larger `steps`
2076    /// makes the drag look smoother / more human (nodriver suggests 50–100 for
2077    /// "very smooth"); `steps` of 0 or 1 collapses to a single move straight to
2078    /// `to`.
2079    ///
2080    /// This is the raw-coordinate, linearly-interpolated drag (faithful to
2081    /// nodriver) — it does **not** use the realistic Bezier path that
2082    /// [`Tab::mouse_move`] / [`crate::Element::click`] do, and it dispatches
2083    /// directly rather than advancing the per-Tab cursor state.
2084    ///
2085    /// # Errors
2086    ///
2087    /// Propagates [`ZendriverError::Transport`] / `Cdp` from the underlying
2088    /// `Input.dispatchMouseEvent` calls.
2089    ///
2090    /// # Examples
2091    ///
2092    /// ```no_run
2093    /// # async fn ex() -> zendriver::Result<()> {
2094    /// # let browser = zendriver::Browser::builder().launch().await?;
2095    /// # let tab = browser.main_tab();
2096    /// // Drag a slider handle 200px to the right over 40 smooth steps.
2097    /// tab.mouse_drag((120.0, 300.0), (320.0, 300.0), 40).await?;
2098    /// # Ok(()) }
2099    /// ```
2100    pub async fn mouse_drag(&self, from: (f64, f64), to: (f64, f64), steps: usize) -> Result<()> {
2101        // Press the left button at the source point.
2102        self.call(
2103            "Input.dispatchMouseEvent",
2104            json!({
2105                "type": "mousePressed",
2106                "x": from.0, "y": from.1,
2107                "button": "left",
2108                "clickCount": 1,
2109            }),
2110        )
2111        .await?;
2112
2113        // Interpolate the move. nodriver walks i in 0..=steps (steps+1 points,
2114        // the first coinciding with `from`); steps <= 1 collapses to a single
2115        // hop straight to `to`.
2116        let steps = steps.max(1);
2117        if steps == 1 {
2118            self.call(
2119                "Input.dispatchMouseEvent",
2120                json!({ "type": "mouseMoved", "x": to.0, "y": to.1 }),
2121            )
2122            .await?;
2123        } else {
2124            let step_x = (to.0 - from.0) / steps as f64;
2125            let step_y = (to.1 - from.1) / steps as f64;
2126            for i in 0..=steps {
2127                let x = from.0 + step_x * i as f64;
2128                let y = from.1 + step_y * i as f64;
2129                self.call(
2130                    "Input.dispatchMouseEvent",
2131                    json!({ "type": "mouseMoved", "x": x, "y": y }),
2132                )
2133                .await?;
2134            }
2135        }
2136
2137        // Release at the destination.
2138        self.call(
2139            "Input.dispatchMouseEvent",
2140            json!({
2141                "type": "mouseReleased",
2142                "x": to.0, "y": to.1,
2143                "button": "left",
2144                "clickCount": 1,
2145            }),
2146        )
2147        .await?;
2148        Ok(())
2149    }
2150
2151    /// Search the text content of every loaded frame resource for `query`,
2152    /// returning the resources that contain at least one match.
2153    ///
2154    /// Ports nodriver's `search_frame_resources`. Fetches the page's resource
2155    /// tree (`Page.getResourceTree`), walks every frame and its resources
2156    /// (recursing into child frames), and for each resource runs
2157    /// `Page.searchInResource { frameId, url, query }`. Resources whose search
2158    /// returns a non-empty match list are collected into the result as
2159    /// [`FrameResourceMatch`] records (resource URL + owning frame id).
2160    ///
2161    /// `query` is treated as a plain substring by Chrome (the underlying
2162    /// `searchInResource` defaults to non-regex, case-sensitive matching).
2163    /// Resources that error on search (e.g. a body Chrome no longer retains)
2164    /// are skipped rather than failing the whole call.
2165    ///
2166    /// # Errors
2167    ///
2168    /// Returns [`ZendriverError::Navigation`] if `Page.getResourceTree`'s
2169    /// response is missing the frame tree; transport errors from
2170    /// `getResourceTree` itself propagate.
2171    ///
2172    /// # Examples
2173    ///
2174    /// ```no_run
2175    /// # async fn ex() -> zendriver::Result<()> {
2176    /// # let browser = zendriver::Browser::builder().launch().await?;
2177    /// # let tab = browser.main_tab();
2178    /// tab.goto("https://example.com").await?;
2179    /// let hits = tab.search_frame_resources("__INITIAL_STATE__").await?;
2180    /// println!("{} resources matched", hits.len());
2181    /// # Ok(()) }
2182    /// ```
2183    pub async fn search_frame_resources(&self, query: &str) -> Result<Vec<FrameResourceMatch>> {
2184        let tree = self.call("Page.getResourceTree", json!({})).await?;
2185        let root = tree.get("frameTree").ok_or_else(|| {
2186            ZendriverError::Navigation("Page.getResourceTree missing frameTree".into())
2187        })?;
2188
2189        // Flatten the tree into (frame_id, resource_url) pairs. The frame node
2190        // carries its id under `frame.id`; resources live in `resources[]`
2191        // (each with a `url`), and nested frames in `childFrames[]` (each a
2192        // FrameResourceTree of the same shape).
2193        let mut pairs: Vec<(String, String)> = Vec::new();
2194        let mut stack = vec![root];
2195        while let Some(node) = stack.pop() {
2196            let frame_id = node["frame"]["id"].as_str().unwrap_or("").to_string();
2197            if let Some(resources) = node.get("resources").and_then(Value::as_array) {
2198                for res in resources {
2199                    if let Some(url) = res.get("url").and_then(Value::as_str) {
2200                        pairs.push((frame_id.clone(), url.to_string()));
2201                    }
2202                }
2203            }
2204            if let Some(children) = node.get("childFrames").and_then(Value::as_array) {
2205                stack.extend(children.iter());
2206            }
2207        }
2208
2209        // Search each resource; collect the ones with a non-empty match list.
2210        let mut matches = Vec::new();
2211        for (frame_id, url) in pairs {
2212            let res = self
2213                .call(
2214                    "Page.searchInResource",
2215                    json!({ "frameId": frame_id, "url": url, "query": query }),
2216                )
2217                .await;
2218            // Skip resources Chrome can't search (stale body, unsupported
2219            // type) rather than aborting the whole sweep.
2220            let Ok(res) = res else { continue };
2221            let has_match = res
2222                .get("result")
2223                .and_then(Value::as_array)
2224                .is_some_and(|m| !m.is_empty());
2225            if has_match {
2226                matches.push(FrameResourceMatch { url, frame_id });
2227            }
2228        }
2229        Ok(matches)
2230    }
2231
2232    /// Bring this tab's page to the front of its browser window.
2233    ///
2234    /// Dispatches `Page.bringToFront` on this tab's session. Distinct from
2235    /// [`Tab::activate`], which sends the browser-scope
2236    /// `Target.activateTarget` to switch the active tab — `bring_to_front`
2237    /// raises the page within its window (focus + paint) at session scope.
2238    /// nodriver exposes both; they serve slightly different purposes, so
2239    /// zendriver keeps both.
2240    ///
2241    /// # Examples
2242    ///
2243    /// ```no_run
2244    /// # async fn ex() -> zendriver::Result<()> {
2245    /// # let browser = zendriver::Browser::builder().launch().await?;
2246    /// # let tab = browser.main_tab();
2247    /// tab.bring_to_front().await?;
2248    /// # Ok(()) }
2249    /// ```
2250    pub async fn bring_to_front(&self) -> Result<()> {
2251        self.call("Page.bringToFront", json!({})).await?;
2252        Ok(())
2253    }
2254
2255    /// Dismiss Chrome's "Your connection is not private" interstitial by
2256    /// typing the magic bypass phrase.
2257    ///
2258    /// On the SSL/cert warning page, Chrome accepts the literal keystrokes
2259    /// `thisisunsafe` (typed anywhere with the page focused) as a proceed
2260    /// gesture. This focuses the page `<body>` and fast-types that phrase.
2261    /// No-op-ish on normal pages (the keystrokes go to the body and are
2262    /// harmless). Mirrors nodriver's `select("body").send_keys("thisisunsafe")`.
2263    ///
2264    /// # Errors
2265    ///
2266    /// Returns [`ZendriverError::ElementNotFound`] if the page has no `<body>`
2267    /// to focus (should not happen on a real interstitial).
2268    ///
2269    /// # Examples
2270    ///
2271    /// ```no_run
2272    /// # async fn ex() -> zendriver::Result<()> {
2273    /// # let browser = zendriver::Browser::builder().launch().await?;
2274    /// # let tab = browser.main_tab();
2275    /// tab.goto("https://self-signed.badssl.com").await?;
2276    /// tab.bypass_insecure_connection_warning().await?;
2277    /// # Ok(()) }
2278    /// ```
2279    pub async fn bypass_insecure_connection_warning(&self) -> Result<()> {
2280        let body = self.find().css("body").one().await?;
2281        body.type_text_fast("thisisunsafe").await
2282    }
2283
2284    /// Compose the DevTools front-end "inspector" URL for this tab.
2285    ///
2286    /// Returns a string of the form
2287    /// `http://{host}:{port}/devtools/inspector.html?ws={host}:{port}/devtools/page/{target_id}`,
2288    /// where `{host}:{port}` is the remote-debugging endpoint the owning
2289    /// [`crate::Browser`] connected to (parsed from Chrome's
2290    /// `DevTools listening on ws://HOST:PORT/...` launch line). Open the
2291    /// returned URL in any Chromium browser to attach the DevTools UI to this
2292    /// page. This returns the URL only — it does not launch a browser.
2293    ///
2294    /// # Errors
2295    ///
2296    /// Returns [`ZendriverError::Navigation`] when the owning browser has been
2297    /// dropped or its debug endpoint is unknown (e.g. a Tab constructed
2298    /// outside of a real `launch`, or a future transport that doesn't surface
2299    /// the endpoint).
2300    ///
2301    /// # Examples
2302    ///
2303    /// ```no_run
2304    /// # async fn ex() -> zendriver::Result<()> {
2305    /// # let browser = zendriver::Browser::builder().launch().await?;
2306    /// # let tab = browser.main_tab();
2307    /// let url = tab.inspector_url()?;
2308    /// println!("open this to inspect: {url}");
2309    /// # Ok(()) }
2310    /// ```
2311    pub fn inspector_url(&self) -> Result<String> {
2312        let browser = self.inner.browser.upgrade().ok_or_else(|| {
2313            ZendriverError::Navigation("inspector_url: owning Browser has been dropped".into())
2314        })?;
2315        let host_port = browser.debug_host_port.as_deref().ok_or_else(|| {
2316            ZendriverError::Navigation(
2317                "inspector_url: browser debug endpoint not known (not launched via Browser::builder?)".into(),
2318            )
2319        })?;
2320        let target_id = self.target_id();
2321        Ok(format!(
2322            "http://{host_port}/devtools/inspector.html?ws={host_port}/devtools/page/{target_id}"
2323        ))
2324    }
2325}
2326
2327impl Tab {
2328    /// Begin a chainable element query against this tab.
2329    ///
2330    /// Pick a selector kind (`.css`, `.xpath`, `.text`, `.text_exact`,
2331    /// `.text_regex`, `.text_regex_with_flags`, `.role`, `.role_named`),
2332    /// optionally apply modifiers (`.nth`, `.visible_only`, `.in_frame`,
2333    /// `.timeout`), then terminate with `.one()` or `.one_or_none()`.
2334    ///
2335    /// # Examples
2336    ///
2337    /// ```no_run
2338    /// # async fn ex() -> zendriver::Result<()> {
2339    /// # let browser = zendriver::Browser::builder().launch().await?;
2340    /// # let tab = browser.main_tab();
2341    /// tab.goto("https://example.com").await?;
2342    /// let h1 = tab.find().css("h1").one().await?;
2343    /// h1.click().await?;
2344    /// # Ok(()) }
2345    /// ```
2346    pub fn find(&self) -> crate::query::FindBuilder<'_> {
2347        crate::query::FindBuilder::new_for_tab(self)
2348    }
2349
2350    /// Begin a chainable element query against this tab that returns
2351    /// ALL matches.
2352    ///
2353    /// Mirrors [`Tab::find`] selectors + modifiers (no `nth`); terminate
2354    /// with `.many()` (errors on empty) or `.many_or_empty()` (returns
2355    /// empty `Vec` instead).
2356    ///
2357    /// # Examples
2358    ///
2359    /// ```no_run
2360    /// # async fn ex() -> zendriver::Result<()> {
2361    /// # let browser = zendriver::Browser::builder().launch().await?;
2362    /// # let tab = browser.main_tab();
2363    /// tab.goto("https://example.com").await?;
2364    /// let links = tab.find_all().css("a").many_or_empty().await?;
2365    /// println!("{} links", links.len());
2366    /// # Ok(()) }
2367    /// ```
2368    pub fn find_all(&self) -> crate::query::FindAllBuilder<'_> {
2369        crate::query::FindAllBuilder::new_for_tab(self)
2370    }
2371
2372    /// Find one element by CSS selector. Python-parity convenience for
2373    /// `find().css(sel).one()`. For modifiers (frames / nth / timeout) use the
2374    /// builder directly.
2375    ///
2376    /// # Errors
2377    ///
2378    /// Returns [`ZendriverError::ElementNotFound`] if no element matches.
2379    ///
2380    /// # Examples
2381    ///
2382    /// ```no_run
2383    /// # async fn ex() -> zendriver::Result<()> {
2384    /// # let browser = zendriver::Browser::builder().launch().await?;
2385    /// # let tab = browser.main_tab();
2386    /// tab.goto("https://example.com").await?;
2387    /// let h1 = tab.select("h1").await?;
2388    /// # let _ = h1;
2389    /// # Ok(()) }
2390    /// ```
2391    pub async fn select(&self, css: &str) -> crate::error::Result<crate::Element> {
2392        self.find().css(css).one().await
2393    }
2394
2395    /// Find all elements by CSS selector. Python-parity convenience for
2396    /// `find_all().css(sel).many()`.
2397    ///
2398    /// # Errors
2399    ///
2400    /// Returns [`ZendriverError::ElementNotFound`] if no elements match.
2401    ///
2402    /// # Examples
2403    ///
2404    /// ```no_run
2405    /// # async fn ex() -> zendriver::Result<()> {
2406    /// # let browser = zendriver::Browser::builder().launch().await?;
2407    /// # let tab = browser.main_tab();
2408    /// tab.goto("https://example.com").await?;
2409    /// let links = tab.select_all("a").await?;
2410    /// println!("{} links", links.len());
2411    /// # Ok(()) }
2412    /// ```
2413    pub async fn select_all(&self, css: &str) -> crate::error::Result<Vec<crate::Element>> {
2414        self.find_all().css(css).many().await
2415    }
2416
2417    /// Collect every linked URL on the page — the `href` of `[href]` elements
2418    /// (`<a>`, `<link>`, `<area>`, …) and the `src` of `[src]` elements
2419    /// (`<img>`, `<script>`, `<iframe>`, …).
2420    ///
2421    /// When `absolute` is `true` the URLs are read from each element's
2422    /// `.href` / `.src` DOM properties, which the browser has already resolved
2423    /// against the document base URL (so a relative `href="/a"` comes back as
2424    /// `https://host/a`). When `false` the raw attribute strings are returned
2425    /// verbatim (often relative, as authored). Empty / missing values are
2426    /// skipped.
2427    ///
2428    /// Mirrors nodriver's `get_all_urls`. This is the cheap string-list view;
2429    /// for live [`Element`](crate::Element) handles to those nodes use
2430    /// [`Tab::get_all_linked_sources`].
2431    ///
2432    /// # Errors
2433    ///
2434    /// Returns [`ZendriverError::JsException`] if the collector script raises.
2435    ///
2436    /// # Examples
2437    ///
2438    /// ```no_run
2439    /// # async fn ex() -> zendriver::Result<()> {
2440    /// # let browser = zendriver::Browser::builder().launch().await?;
2441    /// # let tab = browser.main_tab();
2442    /// tab.goto("https://example.com").await?;
2443    /// let urls = tab.get_all_urls(true).await?;
2444    /// for u in urls {
2445    ///     println!("{u}");
2446    /// }
2447    /// # Ok(()) }
2448    /// ```
2449    pub async fn get_all_urls(&self, absolute: bool) -> Result<Vec<String>> {
2450        // Read `.href` / `.src` DOM props (browser-resolved → absolute) when
2451        // `absolute`, else the raw `getAttribute` values. A single main-world
2452        // collector walks `[href], [src]` once and filters out empties.
2453        let js = format!(
2454            "(() => {{ \
2455                const out = []; \
2456                const abs = {absolute}; \
2457                for (const el of document.querySelectorAll('[href], [src]')) {{ \
2458                    const v = abs \
2459                        ? (el.href || el.src || '') \
2460                        : (el.getAttribute('href') || el.getAttribute('src') || ''); \
2461                    if (v) out.push(v); \
2462                }} \
2463                return out; \
2464            }})()"
2465        );
2466        self.evaluate_main(js).await
2467    }
2468
2469    /// Live [`Element`](crate::Element) handles for every linked-source node on
2470    /// the page (`[src], [href]`).
2471    ///
2472    /// Routes through [`Tab::find_all`] with a `[src], [href]` CSS selector and
2473    /// terminates with `many_or_empty`, so a page with no such elements yields
2474    /// an empty `Vec` rather than an error. Mirrors nodriver's
2475    /// `get_all_linked_sources`; unlike [`Tab::get_all_urls`] (which returns
2476    /// plain URL strings) this hands back interactable element handles you can
2477    /// click / screenshot / inspect.
2478    ///
2479    /// # Errors
2480    ///
2481    /// Propagates query/transport errors from the underlying
2482    /// [`Tab::find_all`] resolution.
2483    ///
2484    /// # Examples
2485    ///
2486    /// ```no_run
2487    /// # async fn ex() -> zendriver::Result<()> {
2488    /// # let browser = zendriver::Browser::builder().launch().await?;
2489    /// # let tab = browser.main_tab();
2490    /// tab.goto("https://example.com").await?;
2491    /// let assets = tab.get_all_linked_sources().await?;
2492    /// println!("{} linked sources", assets.len());
2493    /// # Ok(()) }
2494    /// ```
2495    pub async fn get_all_linked_sources(&self) -> Result<Vec<crate::Element>> {
2496        self.find_all().css("[src], [href]").many_or_empty().await
2497    }
2498
2499    /// Route this browser's downloads into `dir` at runtime, keeping each
2500    /// file's server-suggested name.
2501    ///
2502    /// Dispatches `Browser.setDownloadBehavior { behavior: "allow",
2503    /// downloadPath: <dir> }` at **browser scope** (no `sessionId`) — the
2504    /// connection beneath every tab is the same, and Chrome does not honor
2505    /// per-session download behavior reliably across versions, so the policy
2506    /// applies browser-wide. `dir` must already exist; Chrome writes files
2507    /// there under the names it would have used in the user's downloads
2508    /// folder.
2509    ///
2510    /// This is distinct from [`Tab::expect_download`]
2511    /// (gated by the `expect` feature), which configures `allowAndName`
2512    /// against a private tempdir to *capture* a single download for
2513    /// `await` + `save_to`. Use `set_download_path` when you just want
2514    /// downloads to land in a known directory with their natural names;
2515    /// use `expect_download` when you want to await and inspect one.
2516    ///
2517    /// # Errors
2518    ///
2519    /// Returns [`ZendriverError::Transport`] / `Cdp` if the CDP call fails.
2520    ///
2521    /// # Examples
2522    ///
2523    /// ```no_run
2524    /// # async fn ex() -> zendriver::Result<()> {
2525    /// # let browser = zendriver::Browser::builder().launch().await?;
2526    /// # let tab = browser.main_tab();
2527    /// tab.set_download_path("/tmp/downloads").await?;
2528    /// # Ok(()) }
2529    /// ```
2530    pub async fn set_download_path(&self, dir: impl Into<PathBuf>) -> Result<()> {
2531        let dir = dir.into();
2532        self.inner
2533            .session
2534            .connection()
2535            .call_raw(
2536                "Browser.setDownloadBehavior",
2537                json!({
2538                    "behavior": "allow",
2539                    "downloadPath": dir.to_string_lossy().to_string(),
2540                }),
2541                None,
2542            )
2543            .await?;
2544        self.inner
2545            .download_behavior_set
2546            .store(true, std::sync::atomic::Ordering::Relaxed);
2547        Ok(())
2548    }
2549
2550    /// Download the resource at `url` into the tab's download directory,
2551    /// driven entirely from the page itself.
2552    ///
2553    /// Ports nodriver's `download_file` mechanism: it injects a main-world
2554    /// script that `fetch`es `url`, wraps the response body in a `Blob`,
2555    /// creates an object URL, and clicks a synthetic `<a download>` anchor —
2556    /// so the bytes flow through the page's own network context (cookies,
2557    /// referer, same-origin credentials) and Chrome saves them via the
2558    /// configured download behavior. The anchor is removed and the object URL
2559    /// revoked shortly after the click.
2560    ///
2561    /// If no download directory has been set on this tab (via
2562    /// [`Tab::set_download_path`] or an earlier `download_file`), a default of
2563    /// `<cwd>/downloads` is created and installed first — matching nodriver.
2564    /// When `filename` is `None` the saved name is derived from the URL's last
2565    /// path segment (query string stripped).
2566    ///
2567    /// Returns once the injection script has been dispatched — it does **not**
2568    /// wait for the download to finish. For await/inspect semantics use
2569    /// [`Tab::expect_download`] (gated by the `expect` feature).
2570    ///
2571    /// # Errors
2572    ///
2573    /// Returns [`ZendriverError::Io`] if the default download directory cannot
2574    /// be created; propagates [`ZendriverError::JsException`] if the injected
2575    /// fetch/anchor script raises (e.g. a CORS-blocked `fetch`).
2576    ///
2577    /// # Examples
2578    ///
2579    /// ```no_run
2580    /// # async fn ex() -> zendriver::Result<()> {
2581    /// # let browser = zendriver::Browser::builder().launch().await?;
2582    /// # let tab = browser.main_tab();
2583    /// tab.set_download_path("/tmp/dl").await?;
2584    /// tab.download_file("https://example.com/file.pdf", None).await?;
2585    /// # Ok(()) }
2586    /// ```
2587    pub async fn download_file(
2588        &self,
2589        url: impl Into<String>,
2590        filename: Option<PathBuf>,
2591    ) -> Result<()> {
2592        let url = url.into();
2593
2594        // Establish a default download directory if the caller never set one
2595        // (mirrors nodriver's `_download_behavior` guard so we don't clobber
2596        // an explicitly-chosen directory).
2597        if !self
2598            .inner
2599            .download_behavior_set
2600            .load(std::sync::atomic::Ordering::Relaxed)
2601        {
2602            let dir = std::env::current_dir()?.join("downloads");
2603            std::fs::create_dir_all(&dir)?;
2604            self.set_download_path(dir).await?;
2605        }
2606
2607        // Derive the saved filename from the URL tail (query stripped) when
2608        // not supplied, matching nodriver.
2609        let filename = filename
2610            .as_ref()
2611            .map(|p| p.to_string_lossy().to_string())
2612            .unwrap_or_else(|| {
2613                url.rsplit('/')
2614                    .next()
2615                    .unwrap_or("")
2616                    .split('?')
2617                    .next()
2618                    .unwrap_or("")
2619                    .to_string()
2620            });
2621
2622        // Inject the fetch→blob→anchor[download]→click sequence into the page
2623        // main world (the document's own network context). Values are JSON-
2624        // encoded so quotes/specials in the URL or filename can't break out.
2625        let url_lit = serde_json::to_string(&url)?;
2626        let name_lit = serde_json::to_string(&filename)?;
2627        let js = format!(
2628            "(async () => {{ \
2629                const response = await fetch({url_lit}); \
2630                const blob = await response.blob(); \
2631                const href = URL.createObjectURL(blob); \
2632                const a = document.createElement('a'); \
2633                a.href = href; \
2634                a.download = {name_lit}; \
2635                document.body.appendChild(a); \
2636                a.click(); \
2637                setTimeout(() => {{ document.body.removeChild(a); URL.revokeObjectURL(href); }}, 500); \
2638            }})()"
2639        );
2640        let _: Value = self.evaluate_main(js).await?;
2641        Ok(())
2642    }
2643}
2644
2645#[cfg(feature = "expect")]
2646impl Tab {
2647    /// Register a one-shot expectation for the first
2648    /// `Network.requestWillBeSent` whose URL matches `pattern`.
2649    ///
2650    /// `pattern` is anything convertible to a [`crate::expect::UrlMatcher`]:
2651    /// `&str` / `String` build a substring matcher; [`regex::Regex`] builds
2652    /// a regex matcher. The returned
2653    /// [`RequestExpectation`](crate::expect::request::RequestExpectation)
2654    /// is awaitable directly (`expectation.await`) or via the
2655    /// Playwright-style `expectation.matched().await`; configure the
2656    /// timeout via
2657    /// [`timeout`](crate::expect::request::RequestExpectation::timeout)
2658    /// before awaiting.
2659    ///
2660    /// The subscriber task is spawned synchronously inside this call —
2661    /// the subscription is live by the time you receive the
2662    /// `RequestExpectation`, so a trigger action issued immediately
2663    /// after cannot race past us. `Network.enable` is already on per-Tab
2664    /// via the P4 in-flight tracker; this call does not re-enable.
2665    ///
2666    /// Gated by the `expect` cargo feature.
2667    #[must_use]
2668    pub fn expect_request(
2669        &self,
2670        pattern: impl Into<crate::expect::UrlMatcher>,
2671    ) -> crate::expect::request::RequestExpectation {
2672        crate::expect::request::register(self.session(), pattern.into())
2673    }
2674
2675    /// Register a one-shot expectation for the first
2676    /// `Network.responseReceived` whose URL matches `pattern`.
2677    ///
2678    /// `pattern` is anything convertible to a [`crate::expect::UrlMatcher`]:
2679    /// `&str` / `String` build a substring matcher; [`regex::Regex`] builds
2680    /// a regex matcher. The returned
2681    /// [`ResponseExpectation`](crate::expect::response::ResponseExpectation)
2682    /// is awaitable directly (`expectation.await`) or via the
2683    /// Playwright-style `expectation.matched().await`; configure the
2684    /// timeout via
2685    /// [`timeout`](crate::expect::response::ResponseExpectation::timeout)
2686    /// before awaiting.
2687    ///
2688    /// Resolves with a
2689    /// [`MatchedResponse`](crate::expect::response::MatchedResponse) whose
2690    /// [`body`](crate::expect::response::MatchedResponse::body) method
2691    /// fetches the response payload via `Network.getResponseBody`. Bodies
2692    /// are only retained for a short window after the response completes —
2693    /// call `body()` promptly.
2694    ///
2695    /// The subscriber task is spawned synchronously inside this call —
2696    /// the subscription is live by the time you receive the
2697    /// `ResponseExpectation`, so a trigger action issued immediately after
2698    /// cannot race past us. `Network.enable` is already on per-Tab via the
2699    /// P4 in-flight tracker; this call does not re-enable.
2700    ///
2701    /// Gated by the `expect` cargo feature.
2702    #[must_use]
2703    pub fn expect_response(
2704        &self,
2705        pattern: impl Into<crate::expect::UrlMatcher>,
2706    ) -> crate::expect::response::ResponseExpectation {
2707        crate::expect::response::register(self.session(), pattern.into())
2708    }
2709
2710    /// Register a one-shot expectation for the first
2711    /// `Page.javascriptDialogOpened` event on this tab.
2712    ///
2713    /// There is no URL pattern: dialogs don't carry a request URL the way
2714    /// requests/responses do — any dialog opened during the expectation
2715    /// window matches. The page URL is captured on the resolved
2716    /// [`MatchedDialog`](crate::expect::dialog::MatchedDialog) for context.
2717    ///
2718    /// The returned
2719    /// [`DialogExpectation`](crate::expect::dialog::DialogExpectation) is
2720    /// awaitable directly (`expectation.await`) or via the
2721    /// Playwright-style `expectation.matched().await`; configure the
2722    /// timeout via
2723    /// [`timeout`](crate::expect::dialog::DialogExpectation::timeout) before
2724    /// awaiting.
2725    ///
2726    /// Resolves with a
2727    /// [`MatchedDialog`](crate::expect::dialog::MatchedDialog) whose
2728    /// [`accept`](crate::expect::dialog::MatchedDialog::accept) /
2729    /// [`dismiss`](crate::expect::dialog::MatchedDialog::dismiss) methods
2730    /// dispatch `Page.handleJavaScriptDialog`.
2731    ///
2732    /// The subscriber task is spawned synchronously inside this call — the
2733    /// subscription is live by the time you receive the
2734    /// `DialogExpectation`, so a trigger action issued immediately after
2735    /// cannot race past us. `Page.enable` is already on per-Tab via P1's
2736    /// `Tab::goto`; this call does not re-enable.
2737    ///
2738    /// Gated by the `expect` cargo feature.
2739    #[must_use]
2740    pub fn expect_dialog(&self) -> crate::expect::dialog::DialogExpectation {
2741        crate::expect::dialog::register(self.session())
2742    }
2743
2744    /// Register a one-shot expectation for the first `Page.downloadWillBegin`
2745    /// on this tab.
2746    ///
2747    /// First call on a Tab also allocates a per-Tab tempdir, dispatches
2748    /// `Browser.setDownloadBehavior { behavior: "allowAndName", downloadPath
2749    /// }` at browser scope, and spawns a long-running `Page.downloadProgress`
2750    /// subscriber. The coordinator is reused across every subsequent
2751    /// `expect_download` call on the same tab. `Page.enable` is already on
2752    /// per-Tab via P1's `Tab::goto` / the frame lifecycle subscriber, so
2753    /// this call does not re-enable.
2754    ///
2755    /// Returned [`MatchedDownload`](crate::expect::download::MatchedDownload)
2756    /// exposes [`path`](crate::expect::download::MatchedDownload::path) /
2757    /// [`save_to`](crate::expect::download::MatchedDownload::save_to) for
2758    /// reaching the downloaded bytes once Chrome reports completion.
2759    ///
2760    /// Gated by the `expect` cargo feature.
2761    pub async fn expect_download(&self) -> Result<crate::expect::download::DownloadExpectation> {
2762        let coord = crate::expect::download::ensure_download_setup(
2763            &self.inner.download_setup,
2764            self.session(),
2765        )
2766        .await?;
2767        Ok(crate::expect::download::register(self.session(), coord))
2768    }
2769}
2770
2771#[cfg(feature = "cloudflare")]
2772impl Tab {
2773    /// Construct a
2774    /// [`CloudflareBypass`](zendriver_cloudflare::CloudflareBypass) bound to
2775    /// this tab's session.
2776    ///
2777    /// Chain
2778    /// [`poll_interval`](zendriver_cloudflare::CloudflareBypass::poll_interval)
2779    /// to tune the polling cadence, then call
2780    /// [`wait_for_clearance`](zendriver_cloudflare::CloudflareBypass::wait_for_clearance)
2781    /// to detect the Turnstile checkbox, click it at the canonical 15%
2782    /// offset, and poll until either the `cf-turnstile-response` token
2783    /// appears, the challenge container disappears, or the supplied timeout
2784    /// elapses. Use
2785    /// [`is_challenge_present`](zendriver_cloudflare::CloudflareBypass::is_challenge_present)
2786    /// for a one-shot probe without driving a click.
2787    ///
2788    /// **Stealth recommended.** Cloudflare Turnstile is somewhat forgiving
2789    /// of non-stealth Chrome, but `BrowserBuilder::stealth` significantly
2790    /// raises the clearance success rate.
2791    ///
2792    /// Gated by the `cloudflare` cargo feature.
2793    #[must_use]
2794    pub fn cloudflare(&self) -> zendriver_cloudflare::CloudflareBypass<'_> {
2795        zendriver_cloudflare::CloudflareBypass::new(self.session())
2796    }
2797}
2798
2799#[cfg(feature = "imperva")]
2800impl Tab {
2801    /// Construct an
2802    /// [`ImpervaBypass`](zendriver_imperva::ImpervaBypass) bound to this
2803    /// tab's session.
2804    ///
2805    /// Chain
2806    /// [`timeout`](zendriver_imperva::ImpervaBypass::timeout) /
2807    /// [`poll_interval`](zendriver_imperva::ImpervaBypass::poll_interval) /
2808    /// [`with_interception`](zendriver_imperva::ImpervaBypass::with_interception) /
2809    /// [`on_captcha`](zendriver_imperva::ImpervaBypass::on_captcha)
2810    /// builder methods, then call
2811    /// [`wait_for_clearance`](zendriver_imperva::ImpervaBypass::wait_for_clearance)
2812    /// to detect the active Imperva surface (modern reese84, legacy
2813    /// Incapsula, or CAPTCHA escalation) and poll until clearance.
2814    ///
2815    /// **Stealth required.** Without `BrowserBuilder::stealth`, the
2816    /// bypass will fail on nearly all real Imperva-protected sites.
2817    ///
2818    /// Gated by the `imperva` cargo feature.
2819    #[must_use]
2820    pub fn imperva(&self) -> zendriver_imperva::ImpervaBypass<'_> {
2821        zendriver_imperva::ImpervaBypass::new(self.session())
2822    }
2823}
2824
2825#[cfg(feature = "datadome")]
2826impl Tab {
2827    /// Construct a [`DataDomeBypass`](zendriver_datadome::DataDomeBypass) bound
2828    /// to this tab's session.
2829    ///
2830    /// Chain `timeout` / `poll_interval` / `with_interception` / `on_captcha`,
2831    /// then `wait_for_clearance` to detect the active DataDome surface
2832    /// (device-check, captcha, or block) and poll until the `datadome`
2833    /// clearance cookie lands.
2834    ///
2835    /// **Stealth strongly recommended.** DataDome's device-check scores the
2836    /// browser fingerprint; without `BrowserBuilder::stealth` (including the
2837    /// `Surface::Webgpu` coherence patch) the device-check will not clear.
2838    ///
2839    /// Gated by the `datadome` cargo feature.
2840    #[must_use]
2841    pub fn datadome(&self) -> zendriver_datadome::DataDomeBypass<'_> {
2842        zendriver_datadome::DataDomeBypass::new(self.session())
2843    }
2844}
2845
2846#[cfg(feature = "interception")]
2847impl Tab {
2848    /// Construct a fluent
2849    /// [`InterceptBuilder`](zendriver_interception::InterceptBuilder) for
2850    /// this tab's session.
2851    ///
2852    /// Chain rule registration (`.block(...)` / `.redirect(...)` /
2853    /// `.respond(...)` / `.modify_request(...)`) and optional CDP
2854    /// `RequestPattern` filters (`.pattern(...)` / `.at_request()` /
2855    /// `.at_response()` / `.resource(...)`), then call
2856    /// [`start`](zendriver_interception::InterceptBuilder::start) to spawn
2857    /// the rule-driven actor (returns an
2858    /// [`InterceptHandle`](zendriver_interception::InterceptHandle) whose
2859    /// `Drop` tears it down), or
2860    /// [`subscribe`](zendriver_interception::InterceptBuilder::subscribe)
2861    /// to receive raw
2862    /// [`PausedRequest`](zendriver_interception::PausedRequest)s on a
2863    /// stream you drive manually.
2864    ///
2865    /// Gated by the `interception` cargo feature.
2866    #[must_use]
2867    pub fn intercept(&self) -> zendriver_interception::InterceptBuilder<'_> {
2868        zendriver_interception::InterceptBuilder::new(self.session())
2869    }
2870}
2871
2872impl crate::traits::Queryable for Tab {
2873    fn find(&self) -> crate::query::FindBuilder<'_> {
2874        Tab::find(self)
2875    }
2876    fn find_all(&self) -> crate::query::FindAllBuilder<'_> {
2877        Tab::find_all(self)
2878    }
2879}
2880
2881#[async_trait::async_trait]
2882impl crate::traits::Evaluable for Tab {
2883    async fn evaluate<T>(&self, js: &str) -> crate::error::Result<T>
2884    where
2885        T: serde::de::DeserializeOwned + Send + 'static,
2886    {
2887        Tab::evaluate(self, js).await
2888    }
2889    async fn evaluate_main<T>(&self, js: &str) -> crate::error::Result<T>
2890    where
2891        T: serde::de::DeserializeOwned + Send + 'static,
2892    {
2893        Tab::evaluate_main(self, js).await
2894    }
2895}
2896
2897/// Live-probe seam for [`zendriver_stealth::Persona::from_browser`].
2898///
2899/// Implemented here (rather than in `zendriver-stealth`) so the stealth crate
2900/// stays free of a `zendriver` dependency. The `ZendriverError` from
2901/// [`Tab::evaluate`] is mapped into [`zendriver_stealth::StealthError::Probe`]
2902/// — going the other direction (a `From<ZendriverError>` in stealth) would
2903/// require stealth to depend on this crate, which is a cycle.
2904#[async_trait::async_trait]
2905impl zendriver_stealth::JsProbe for Tab {
2906    async fn eval_json(
2907        &self,
2908        js: &str,
2909    ) -> std::result::Result<Value, zendriver_stealth::StealthError> {
2910        self.evaluate::<Value>(js)
2911            .await
2912            .map_err(|e| zendriver_stealth::StealthError::Probe(e.to_string()))
2913    }
2914}
2915
2916#[cfg(test)]
2917#[allow(clippy::panic, clippy::unwrap_used)]
2918mod tests {
2919    use super::*;
2920    use zendriver_transport::testing::MockConnection;
2921
2922    #[tokio::test]
2923    async fn goto_sends_page_enable_then_page_navigate_with_url() {
2924        let (mut mock, conn) = MockConnection::pair();
2925        let sess = SessionHandle::new(conn.clone(), "S1");
2926        let tab = Tab::new_for_test(sess);
2927
2928        let fut = tokio::spawn({
2929            let t = tab.clone();
2930            async move { t.goto("https://example.com").await }
2931        });
2932
2933        let id_enable = mock.expect_cmd("Page.enable").await;
2934        mock.reply(id_enable, json!({})).await;
2935
2936        let id_nav = mock.expect_cmd("Page.navigate").await;
2937        assert_eq!(mock.last_sent()["params"]["url"], "https://example.com");
2938        mock.reply(id_nav, json!({ "frameId": "F1" })).await;
2939
2940        fut.await.unwrap().unwrap();
2941        conn.shutdown();
2942    }
2943
2944    #[tokio::test]
2945    async fn goto_returns_navigation_error_when_chrome_reports_errortext() {
2946        let (mut mock, conn) = MockConnection::pair();
2947        let sess = SessionHandle::new(conn.clone(), "S1");
2948        let tab = Tab::new_for_test(sess);
2949
2950        let fut = tokio::spawn({
2951            let t = tab.clone();
2952            async move { t.goto("https://bad.test").await }
2953        });
2954
2955        let id_enable = mock.expect_cmd("Page.enable").await;
2956        mock.reply(id_enable, json!({})).await;
2957
2958        let id_nav = mock.expect_cmd("Page.navigate").await;
2959        mock.reply(id_nav, json!({ "errorText": "net::ERR_NAME_NOT_RESOLVED" }))
2960            .await;
2961
2962        let res = fut.await.unwrap();
2963        match res {
2964            Err(ZendriverError::Navigation(m)) => assert!(m.contains("ERR_NAME_NOT_RESOLVED")),
2965            other => panic!("unexpected: {other:?}"),
2966        }
2967        conn.shutdown();
2968    }
2969
2970    // --- main-world evaluate (escape hatch) ----------------------------
2971
2972    #[tokio::test]
2973    async fn evaluate_main_returns_typed_value() {
2974        let (mut mock, conn) = MockConnection::pair();
2975        let sess = SessionHandle::new(conn.clone(), "S1");
2976        let tab = Tab::new_for_test(sess);
2977
2978        let fut = tokio::spawn({
2979            let t = tab.clone();
2980            async move { t.evaluate_main::<i32>("1+1").await }
2981        });
2982
2983        let id = mock.expect_cmd("Runtime.evaluate").await;
2984        assert_eq!(mock.last_sent()["params"]["expression"], "1+1");
2985        // Main-world evaluate must NOT pass a contextId.
2986        assert!(mock.last_sent()["params"].get("contextId").is_none());
2987        mock.reply(id, json!({ "result": { "value": 2, "type": "number" } }))
2988            .await;
2989        let n = fut.await.unwrap().unwrap();
2990        assert_eq!(n, 2);
2991        conn.shutdown();
2992    }
2993
2994    #[tokio::test]
2995    async fn evaluate_main_returns_js_exception_when_chrome_reports_one() {
2996        let (mut mock, conn) = MockConnection::pair();
2997        let sess = SessionHandle::new(conn.clone(), "S1");
2998        let tab = Tab::new_for_test(sess);
2999
3000        let fut = tokio::spawn({
3001            let t = tab.clone();
3002            async move { t.evaluate_main::<i32>("throw new Error('boom')").await }
3003        });
3004
3005        let id = mock.expect_cmd("Runtime.evaluate").await;
3006        mock.reply(
3007            id,
3008            json!({
3009                "result": { "type": "object", "subtype": "error" },
3010                "exceptionDetails": {
3011                    "exception": { "description": "Error: boom\n    at <anonymous>:1:7" }
3012                }
3013            }),
3014        )
3015        .await;
3016        let res = fut.await.unwrap();
3017        match res {
3018            Err(ZendriverError::JsException(m)) => assert!(m.contains("Error: boom")),
3019            other => panic!("unexpected: {other:?}"),
3020        }
3021        conn.shutdown();
3022    }
3023
3024    // --- isolated-world evaluate ---------------------------------------
3025
3026    #[tokio::test]
3027    async fn evaluate_isolated_creates_world_then_evaluates() {
3028        let (mut mock, conn) = MockConnection::pair();
3029        let sess = SessionHandle::new(conn.clone(), "S1");
3030        let tab = Tab::new_for_test(sess);
3031
3032        let fut = tokio::spawn({
3033            let t = tab.clone();
3034            async move { t.evaluate::<i32>("1+1").await }
3035        });
3036
3037        // 1. Page.getFrameTree → main frame id.
3038        let id_tree = mock.expect_cmd("Page.getFrameTree").await;
3039        mock.reply(
3040            id_tree,
3041            json!({ "frameTree": { "frame": { "id": "MAIN_FRAME" } } }),
3042        )
3043        .await;
3044
3045        // 2. Page.createIsolatedWorld → executionContextId.
3046        let id_world = mock.expect_cmd("Page.createIsolatedWorld").await;
3047        assert_eq!(mock.last_sent()["params"]["frameId"], "MAIN_FRAME");
3048        assert_eq!(mock.last_sent()["params"]["worldName"], "zendriver-eval");
3049        mock.reply(id_world, json!({ "executionContextId": 42 }))
3050            .await;
3051
3052        // 3. Runtime.evaluate with that contextId.
3053        let id_eval = mock.expect_cmd("Runtime.evaluate").await;
3054        assert_eq!(mock.last_sent()["params"]["expression"], "1+1");
3055        assert_eq!(mock.last_sent()["params"]["contextId"], 42);
3056        mock.reply(
3057            id_eval,
3058            json!({ "result": { "value": 2, "type": "number" } }),
3059        )
3060        .await;
3061
3062        let n = fut.await.unwrap().unwrap();
3063        assert_eq!(n, 2);
3064        conn.shutdown();
3065    }
3066
3067    #[tokio::test]
3068    async fn evaluate_caches_context_id_across_calls() {
3069        let (mut mock, conn) = MockConnection::pair();
3070        let sess = SessionHandle::new(conn.clone(), "S1");
3071        let tab = Tab::new_for_test(sess);
3072
3073        // First call: full handshake + eval.
3074        let fut1 = tokio::spawn({
3075            let t = tab.clone();
3076            async move { t.evaluate::<i32>("1").await }
3077        });
3078        let id_tree = mock.expect_cmd("Page.getFrameTree").await;
3079        mock.reply(
3080            id_tree,
3081            json!({ "frameTree": { "frame": { "id": "MAIN_FRAME" } } }),
3082        )
3083        .await;
3084        let id_world = mock.expect_cmd("Page.createIsolatedWorld").await;
3085        mock.reply(id_world, json!({ "executionContextId": 7 }))
3086            .await;
3087        let id_eval1 = mock.expect_cmd("Runtime.evaluate").await;
3088        assert_eq!(mock.last_sent()["params"]["contextId"], 7);
3089        mock.reply(
3090            id_eval1,
3091            json!({ "result": { "value": 1, "type": "number" } }),
3092        )
3093        .await;
3094        assert_eq!(fut1.await.unwrap().unwrap(), 1);
3095
3096        // Second call: must reuse the cached contextId → next outbound
3097        // frame should be Runtime.evaluate, with NO Page.getFrameTree or
3098        // Page.createIsolatedWorld in between.
3099        let fut2 = tokio::spawn({
3100            let t = tab.clone();
3101            async move { t.evaluate::<i32>("2").await }
3102        });
3103        let id_eval2 = mock.expect_cmd("Runtime.evaluate").await;
3104        assert_eq!(mock.last_sent()["params"]["contextId"], 7);
3105        assert_eq!(mock.last_sent()["params"]["expression"], "2");
3106        mock.reply(
3107            id_eval2,
3108            json!({ "result": { "value": 2, "type": "number" } }),
3109        )
3110        .await;
3111        assert_eq!(fut2.await.unwrap().unwrap(), 2);
3112
3113        conn.shutdown();
3114    }
3115
3116    #[tokio::test]
3117    async fn evaluate_recreates_world_after_context_destroyed_error() {
3118        let (mut mock, conn) = MockConnection::pair();
3119        let sess = SessionHandle::new(conn.clone(), "S1");
3120        let tab = Tab::new_for_test(sess);
3121
3122        // --- Call 1: establishes cache, succeeds. ---
3123        let fut1 = tokio::spawn({
3124            let t = tab.clone();
3125            async move { t.evaluate::<i32>("1").await }
3126        });
3127        let id_tree = mock.expect_cmd("Page.getFrameTree").await;
3128        mock.reply(
3129            id_tree,
3130            json!({ "frameTree": { "frame": { "id": "MAIN_FRAME" } } }),
3131        )
3132        .await;
3133        let id_world = mock.expect_cmd("Page.createIsolatedWorld").await;
3134        mock.reply(id_world, json!({ "executionContextId": 7 }))
3135            .await;
3136        let id_eval1 = mock.expect_cmd("Runtime.evaluate").await;
3137        mock.reply(
3138            id_eval1,
3139            json!({ "result": { "value": 1, "type": "number" } }),
3140        )
3141        .await;
3142        assert_eq!(fut1.await.unwrap().unwrap(), 1);
3143
3144        // --- Call 2: cached contextId is now stale. Runtime.evaluate
3145        //     returns -32000 "Cannot find context with specified id";
3146        //     evaluate must invalidate the cache, re-run the discovery
3147        //     handshake with a NEW contextId, then re-issue Runtime.evaluate.
3148        let fut2 = tokio::spawn({
3149            let t = tab.clone();
3150            async move { t.evaluate::<i32>("2").await }
3151        });
3152        // First Runtime.evaluate uses cached id 7 → CDP returns error.
3153        let id_eval_fail = mock.expect_cmd("Runtime.evaluate").await;
3154        assert_eq!(mock.last_sent()["params"]["contextId"], 7);
3155        mock.reply_err(
3156            id_eval_fail,
3157            -32000,
3158            "Cannot find context with specified id",
3159        )
3160        .await;
3161
3162        // Cache invalidated → discovery handshake re-runs.
3163        let id_tree2 = mock.expect_cmd("Page.getFrameTree").await;
3164        mock.reply(
3165            id_tree2,
3166            json!({ "frameTree": { "frame": { "id": "MAIN_FRAME_2" } } }),
3167        )
3168        .await;
3169        let id_world2 = mock.expect_cmd("Page.createIsolatedWorld").await;
3170        assert_eq!(mock.last_sent()["params"]["frameId"], "MAIN_FRAME_2");
3171        mock.reply(id_world2, json!({ "executionContextId": 99 }))
3172            .await;
3173
3174        // Retried Runtime.evaluate uses the fresh contextId.
3175        let id_eval_retry = mock.expect_cmd("Runtime.evaluate").await;
3176        assert_eq!(mock.last_sent()["params"]["contextId"], 99);
3177        mock.reply(
3178            id_eval_retry,
3179            json!({ "result": { "value": 2, "type": "number" } }),
3180        )
3181        .await;
3182        assert_eq!(fut2.await.unwrap().unwrap(), 2);
3183
3184        // --- Call 3: cache is fresh again → straight to Runtime.evaluate.
3185        let fut3 = tokio::spawn({
3186            let t = tab.clone();
3187            async move { t.evaluate::<i32>("3").await }
3188        });
3189        let id_eval3 = mock.expect_cmd("Runtime.evaluate").await;
3190        assert_eq!(mock.last_sent()["params"]["contextId"], 99);
3191        mock.reply(
3192            id_eval3,
3193            json!({ "result": { "value": 3, "type": "number" } }),
3194        )
3195        .await;
3196        assert_eq!(fut3.await.unwrap().unwrap(), 3);
3197
3198        conn.shutdown();
3199    }
3200
3201    // --- main_frame discovery (P4 T12) --------------------------------
3202
3203    /// First [`Tab::main_frame`] call dispatches `Page.getFrameTree`, parses
3204    /// the top-level frame, and constructs a [`Frame`] with `is_main() ==
3205    /// true`. Second call must NOT round-trip — the `OnceCell` caches the
3206    /// `Frame` so further outbound traffic is empty for the same tab.
3207    #[tokio::test]
3208    async fn main_frame_discovers_top_level_frame_and_caches() {
3209        let (mut mock, conn) = MockConnection::pair();
3210        let sess = SessionHandle::new(conn.clone(), "S1");
3211        let tab = Tab::new_for_test(sess);
3212
3213        let fut = tokio::spawn({
3214            let t = tab.clone();
3215            async move { t.main_frame().await }
3216        });
3217
3218        let id = mock.expect_cmd("Page.getFrameTree").await;
3219        mock.reply(
3220            id,
3221            json!({
3222                "frameTree": {
3223                    "frame": {
3224                        "id": "F0",
3225                        "url": "https://x.test",
3226                    }
3227                }
3228            }),
3229        )
3230        .await;
3231
3232        let frame = fut.await.unwrap().unwrap();
3233        assert_eq!(frame.id(), "F0");
3234        assert!(frame.is_main());
3235        assert!(frame.parent_id().is_none());
3236        assert!(frame.name().is_none());
3237        assert_eq!(frame.url().await, "https://x.test");
3238
3239        // Second call: must hit cache, no further outbound CDP traffic.
3240        let frame2 = tab.main_frame().await.unwrap();
3241        assert_eq!(frame2.id(), "F0");
3242        // Verify the mock saw no additional commands — `expect_cmd` would
3243        // time out internally on the next call. We check via the lighter
3244        // `try_next` shape: a follow-up request would be queued already.
3245        // Drop the connection to assert nothing else is in-flight.
3246        conn.shutdown();
3247    }
3248
3249    #[tokio::test]
3250    async fn url_returns_parsed_url_from_target_info() {
3251        let (mut mock, conn) = MockConnection::pair();
3252        let sess = SessionHandle::new(conn.clone(), "S1");
3253        let tab = Tab::new_for_test(sess);
3254
3255        let fut = tokio::spawn({
3256            let t = tab.clone();
3257            async move { t.url().await }
3258        });
3259
3260        let id = mock.expect_cmd("Target.getTargetInfo").await;
3261        mock.reply(
3262            id,
3263            json!({ "targetInfo": { "url": "https://example.com/x", "title": "ok" } }),
3264        )
3265        .await;
3266        let u = fut.await.unwrap().unwrap();
3267        assert_eq!(u.as_str(), "https://example.com/x");
3268        conn.shutdown();
3269    }
3270
3271    #[tokio::test]
3272    async fn close_sends_target_close_target_with_target_id() {
3273        let (mut mock, conn) = MockConnection::pair();
3274        let sess = SessionHandle::new(conn.clone(), "S42");
3275        // `Tab::new_for_test` derives a deterministic target_id from the
3276        // session_id: `test-target-S42` here.
3277        let tab = Tab::new_for_test(sess);
3278        assert_eq!(tab.target_id(), "test-target-S42");
3279
3280        let fut = tokio::spawn({
3281            let t = tab.clone();
3282            async move { t.close().await }
3283        });
3284
3285        let id = mock.expect_cmd("Target.closeTarget").await;
3286        assert_eq!(mock.last_sent()["params"]["targetId"], "test-target-S42");
3287        // Browser-scope command — no session_id.
3288        assert!(mock.last_sent().get("sessionId").is_none());
3289        mock.reply(id, json!({ "success": true })).await;
3290        fut.await.unwrap().unwrap();
3291        conn.shutdown();
3292    }
3293
3294    #[tokio::test]
3295    async fn activate_sends_target_activate_target_with_target_id() {
3296        let (mut mock, conn) = MockConnection::pair();
3297        let sess = SessionHandle::new(conn.clone(), "S99");
3298        // `Tab::new_for_test` derives a deterministic target_id from the
3299        // session_id: `test-target-S99` here.
3300        let tab = Tab::new_for_test(sess);
3301        assert_eq!(tab.target_id(), "test-target-S99");
3302
3303        let fut = tokio::spawn({
3304            let t = tab.clone();
3305            async move { t.activate().await }
3306        });
3307
3308        let id = mock.expect_cmd("Target.activateTarget").await;
3309        assert_eq!(mock.last_sent()["params"]["targetId"], "test-target-S99");
3310        // Browser-scope command — no session_id.
3311        assert!(mock.last_sent().get("sessionId").is_none());
3312        mock.reply(id, json!({})).await;
3313        fut.await.unwrap().unwrap();
3314        conn.shutdown();
3315    }
3316
3317    #[tokio::test]
3318    async fn screenshot_sends_page_capturescreenshot_without_clip_and_decodes_base64() {
3319        let (mut mock, conn) = MockConnection::pair();
3320        let sess = SessionHandle::new(conn.clone(), "S1");
3321        let tab = Tab::new_for_test(sess);
3322
3323        let fut = tokio::spawn({
3324            let t = tab.clone();
3325            async move { t.screenshot().await }
3326        });
3327
3328        let id = mock.expect_cmd("Page.captureScreenshot").await;
3329        let sent = mock.last_sent();
3330        assert_eq!(sent["params"]["format"], "png");
3331        // Tab::screenshot must NOT pass a clip — that's Element::screenshot.
3332        assert!(sent["params"].get("clip").is_none());
3333        // "PNG!" → b"PNG!" once base64-decoded.
3334        mock.reply(id, json!({ "data": "UE5HIQ==" })).await;
3335
3336        let bytes = fut.await.unwrap().unwrap();
3337        assert_eq!(bytes, b"PNG!");
3338        conn.shutdown();
3339    }
3340
3341    #[tokio::test]
3342    async fn title_returns_string_from_target_info() {
3343        let (mut mock, conn) = MockConnection::pair();
3344        let sess = SessionHandle::new(conn.clone(), "S1");
3345        let tab = Tab::new_for_test(sess);
3346
3347        let fut = tokio::spawn({
3348            let t = tab.clone();
3349            async move { t.title().await }
3350        });
3351
3352        let id = mock.expect_cmd("Target.getTargetInfo").await;
3353        mock.reply(
3354            id,
3355            json!({ "targetInfo": { "url": "https://x", "title": "Hello" } }),
3356        )
3357        .await;
3358        let s = fut.await.unwrap().unwrap();
3359        assert_eq!(s, "Hello");
3360        conn.shutdown();
3361    }
3362
3363    // --- nav history: back / forward / reload --------------------------
3364
3365    #[tokio::test]
3366    async fn back_dispatches_navigate_to_history_entry_at_prev_index() {
3367        let (mut mock, conn) = MockConnection::pair();
3368        let sess = SessionHandle::new(conn.clone(), "S1");
3369        let tab = Tab::new_for_test(sess);
3370
3371        let fut = tokio::spawn({
3372            let t = tab.clone();
3373            async move { t.back().await }
3374        });
3375
3376        let id_hist = mock.expect_cmd("Page.getNavigationHistory").await;
3377        mock.reply(
3378            id_hist,
3379            json!({
3380                "currentIndex": 1,
3381                "entries": [
3382                    { "id": 10, "url": "https://a.test" },
3383                    { "id": 11, "url": "https://b.test" },
3384                ],
3385            }),
3386        )
3387        .await;
3388
3389        let id_nav = mock.expect_cmd("Page.navigateToHistoryEntry").await;
3390        // Should target the entry at currentIndex - 1 (id=10).
3391        assert_eq!(mock.last_sent()["params"]["entryId"], 10);
3392        mock.reply(id_nav, json!({})).await;
3393
3394        fut.await.unwrap().unwrap();
3395        conn.shutdown();
3396    }
3397
3398    #[tokio::test]
3399    async fn back_errors_when_current_index_is_zero() {
3400        let (mut mock, conn) = MockConnection::pair();
3401        let sess = SessionHandle::new(conn.clone(), "S1");
3402        let tab = Tab::new_for_test(sess);
3403
3404        let fut = tokio::spawn({
3405            let t = tab.clone();
3406            async move { t.back().await }
3407        });
3408
3409        let id_hist = mock.expect_cmd("Page.getNavigationHistory").await;
3410        mock.reply(
3411            id_hist,
3412            json!({
3413                "currentIndex": 0,
3414                "entries": [{ "id": 10, "url": "https://a.test" }],
3415            }),
3416        )
3417        .await;
3418
3419        let res = fut.await.unwrap();
3420        match res {
3421            Err(ZendriverError::HistoryNavigation(m)) => assert!(m.contains("no back history")),
3422            other => panic!("unexpected: {other:?}"),
3423        }
3424        conn.shutdown();
3425    }
3426
3427    #[tokio::test]
3428    async fn reload_dispatches_page_reload_with_ignore_cache_false() {
3429        let (mut mock, conn) = MockConnection::pair();
3430        let sess = SessionHandle::new(conn.clone(), "S1");
3431        let tab = Tab::new_for_test(sess);
3432
3433        let fut = tokio::spawn({
3434            let t = tab.clone();
3435            async move { t.reload().await }
3436        });
3437
3438        let id = mock.expect_cmd("Page.reload").await;
3439        assert_eq!(mock.last_sent()["params"]["ignoreCache"], false);
3440        mock.reply(id, json!({})).await;
3441
3442        fut.await.unwrap().unwrap();
3443        conn.shutdown();
3444    }
3445
3446    #[tokio::test]
3447    async fn reload_with_sets_ignore_cache_and_script() {
3448        let (mut mock, conn) = MockConnection::pair();
3449        let sess = SessionHandle::new(conn.clone(), "S1");
3450        let tab = Tab::new_for_test(sess);
3451
3452        let fut = tokio::spawn({
3453            let t = tab.clone();
3454            async move {
3455                t.reload_with(ReloadOptions {
3456                    ignore_cache: true,
3457                    script_to_evaluate_on_load: Some("x".into()),
3458                })
3459                .await
3460            }
3461        });
3462
3463        let id = mock.expect_cmd("Page.reload").await;
3464        assert_eq!(mock.last_sent()["params"]["ignoreCache"], true);
3465        assert_eq!(mock.last_sent()["params"]["scriptToEvaluateOnLoad"], "x");
3466        mock.reply(id, json!({})).await;
3467
3468        fut.await.unwrap().unwrap();
3469        conn.shutdown();
3470    }
3471
3472    #[tokio::test]
3473    async fn reload_with_omits_script_when_none() {
3474        let (mut mock, conn) = MockConnection::pair();
3475        let sess = SessionHandle::new(conn.clone(), "S1");
3476        let tab = Tab::new_for_test(sess);
3477
3478        let fut = tokio::spawn({
3479            let t = tab.clone();
3480            async move {
3481                t.reload_with(ReloadOptions {
3482                    ignore_cache: false,
3483                    script_to_evaluate_on_load: None,
3484                })
3485                .await
3486            }
3487        });
3488
3489        let id = mock.expect_cmd("Page.reload").await;
3490        assert_eq!(mock.last_sent()["params"]["ignoreCache"], false);
3491        // scriptToEvaluateOnLoad must be omitted entirely when None.
3492        assert!(
3493            mock.last_sent()["params"]
3494                .get("scriptToEvaluateOnLoad")
3495                .is_none()
3496        );
3497        mock.reply(id, json!({})).await;
3498
3499        fut.await.unwrap().unwrap();
3500        conn.shutdown();
3501    }
3502
3503    // --- Tab::content (B1) ---------------------------------------------
3504
3505    #[tokio::test]
3506    async fn content_dispatches_get_document_then_outer_html() {
3507        let (mut mock, conn) = MockConnection::pair();
3508        let sess = SessionHandle::new(conn.clone(), "S1");
3509        let tab = Tab::new_for_test(sess);
3510
3511        let fut = tokio::spawn({
3512            let t = tab.clone();
3513            async move { t.content().await }
3514        });
3515
3516        // 1. DOM.getDocument { depth: 0 } → root nodeId.
3517        let id_doc = mock.expect_cmd("DOM.getDocument").await;
3518        assert_eq!(mock.last_sent()["params"]["depth"], 0);
3519        mock.reply(id_doc, json!({ "root": { "nodeId": 7 } })).await;
3520
3521        // 2. DOM.getOuterHTML { nodeId } → outerHTML string.
3522        let id_html = mock.expect_cmd("DOM.getOuterHTML").await;
3523        assert_eq!(mock.last_sent()["params"]["nodeId"], 7);
3524        mock.reply(
3525            id_html,
3526            json!({ "outerHTML": "<!DOCTYPE html><html><body>hi</body></html>" }),
3527        )
3528        .await;
3529
3530        let html = fut.await.unwrap().unwrap();
3531        assert_eq!(html, "<!DOCTYPE html><html><body>hi</body></html>");
3532        conn.shutdown();
3533    }
3534
3535    // --- Tab scroll (B3) -----------------------------------------------
3536
3537    #[tokio::test]
3538    async fn scroll_down_synthesizes_negative_y_gesture() {
3539        let (mut mock, conn) = MockConnection::pair();
3540        let sess = SessionHandle::new(conn.clone(), "S1");
3541        let tab = Tab::new_for_test(sess);
3542
3543        let fut = tokio::spawn({
3544            let t = tab.clone();
3545            async move { t.scroll_down(300.0).await }
3546        });
3547
3548        let id = mock.expect_cmd("Input.synthesizeScrollGesture").await;
3549        let p = mock.last_sent();
3550        // scroll_down(px) maps to a NEGATIVE yDistance (CDP: negative
3551        // yDistance scrolls the page down / content up).
3552        assert_eq!(p["params"]["yDistance"], -300.0);
3553        assert_eq!(p["params"]["xDistance"], 0.0);
3554        // Anchored at the fixed viewport point (100, 100).
3555        assert_eq!(p["params"]["x"], 100.0);
3556        assert_eq!(p["params"]["y"], 100.0);
3557        // No speed override by default.
3558        assert!(p["params"].get("speed").is_none());
3559        mock.reply(id, json!({})).await;
3560
3561        fut.await.unwrap().unwrap();
3562        conn.shutdown();
3563    }
3564
3565    #[tokio::test]
3566    async fn scroll_up_synthesizes_positive_y_gesture() {
3567        let (mut mock, conn) = MockConnection::pair();
3568        let sess = SessionHandle::new(conn.clone(), "S1");
3569        let tab = Tab::new_for_test(sess);
3570
3571        let fut = tokio::spawn({
3572            let t = tab.clone();
3573            async move { t.scroll_up(150.0).await }
3574        });
3575
3576        let id = mock.expect_cmd("Input.synthesizeScrollGesture").await;
3577        // scroll_up(px) maps to a POSITIVE yDistance.
3578        assert_eq!(mock.last_sent()["params"]["yDistance"], 150.0);
3579        mock.reply(id, json!({})).await;
3580
3581        fut.await.unwrap().unwrap();
3582        conn.shutdown();
3583    }
3584
3585    #[tokio::test]
3586    async fn scroll_with_forwards_speed() {
3587        let (mut mock, conn) = MockConnection::pair();
3588        let sess = SessionHandle::new(conn.clone(), "S1");
3589        let tab = Tab::new_for_test(sess);
3590
3591        let fut = tokio::spawn({
3592            let t = tab.clone();
3593            async move {
3594                t.scroll_with(ScrollOptions {
3595                    dx: 25.0,
3596                    dy: -400.0,
3597                    speed: Some(800),
3598                })
3599                .await
3600            }
3601        });
3602
3603        let id = mock.expect_cmd("Input.synthesizeScrollGesture").await;
3604        let p = mock.last_sent();
3605        // dx/dy forward verbatim to xDistance/yDistance; speed plumbs through.
3606        assert_eq!(p["params"]["xDistance"], 25.0);
3607        assert_eq!(p["params"]["yDistance"], -400.0);
3608        assert_eq!(p["params"]["speed"], 800);
3609        mock.reply(id, json!({})).await;
3610
3611        fut.await.unwrap().unwrap();
3612        conn.shutdown();
3613    }
3614
3615    #[tokio::test]
3616    async fn scroll_with_omits_speed_when_none() {
3617        let (mut mock, conn) = MockConnection::pair();
3618        let sess = SessionHandle::new(conn.clone(), "S1");
3619        let tab = Tab::new_for_test(sess);
3620
3621        let fut = tokio::spawn({
3622            let t = tab.clone();
3623            async move {
3624                t.scroll_with(ScrollOptions {
3625                    dx: 0.0,
3626                    dy: 100.0,
3627                    speed: None,
3628                })
3629                .await
3630            }
3631        });
3632
3633        let id = mock.expect_cmd("Input.synthesizeScrollGesture").await;
3634        assert!(mock.last_sent()["params"].get("speed").is_none());
3635        mock.reply(id, json!({})).await;
3636
3637        fut.await.unwrap().unwrap();
3638        conn.shutdown();
3639    }
3640
3641    // --- Tab::cookies (P4 T10) ----------------------------------------
3642
3643    /// [`Tab::cookies`] returns a [`crate::CookieJar`] bound to the owning
3644    /// browser's root connection — discovered via the cached `Weak<BrowserInner>`
3645    /// upgrade. The test builds a synthetic `BrowserInner` with a known
3646    /// connection, attaches a Tab whose Weak ref points at it, and asserts
3647    /// that calling `.set(...)` dispatches `Storage.setCookies` on that
3648    /// browser-level connection (not the Tab's session channel).
3649    #[tokio::test]
3650    async fn tab_cookies_dispatches_through_browser_connection_via_weak_upgrade() {
3651        use crate::browser::BrowserInner;
3652        use crate::cookies::Cookie;
3653        use std::collections::HashMap;
3654        use std::sync::{Arc, Weak};
3655
3656        let input_profile = zendriver_stealth::InputProfile::native();
3657        let (mut mock, conn) = MockConnection::pair();
3658
3659        let inner = Arc::new_cyclic(|weak: &Weak<BrowserInner>| {
3660            let main_session = SessionHandle::new(conn.clone(), "S1");
3661            let main_input = crate::input::InputController::new(input_profile.clone());
3662            let main_tab = Tab::new(main_session, weak.clone(), main_input, "T1".to_string());
3663            let mut map = HashMap::new();
3664            map.insert("S1".to_string(), main_tab.clone());
3665            BrowserInner {
3666                conn: conn.clone(),
3667                main_tab,
3668                child: tokio::sync::Mutex::new(None),
3669                job: crate::browser::ProcessJob::none(),
3670                _user_data: None,
3671                _extension_dirs: Vec::new(),
3672                owns_process: false,
3673                stealth_input_profile: input_profile.clone(),
3674                tabs: tokio::sync::RwLock::new(map),
3675                debug_host_port: None,
3676                ws_url: None,
3677                tabs_changed: tokio::sync::Notify::new(),
3678                #[cfg(feature = "interception")]
3679                proxy_auth_handle: std::sync::OnceLock::new(),
3680                #[cfg(feature = "interception")]
3681                context_proxy_auth: tokio::sync::Mutex::new(HashMap::new()),
3682                #[cfg(feature = "tracker-blocking")]
3683                tracker_matcher: None,
3684                #[cfg(feature = "interception")]
3685                session_intercept_handles: tokio::sync::Mutex::new(std::collections::HashMap::new()),
3686            }
3687        });
3688        let tab = inner.main_tab.clone();
3689        let jar = tab.cookies();
3690
3691        let fut = tokio::spawn(async move {
3692            jar.set(Cookie {
3693                name: "sid".into(),
3694                value: "abc".into(),
3695                domain: ".example.com".into(),
3696                path: "/".into(),
3697                expires: None,
3698                http_only: false,
3699                secure: false,
3700                same_site: None,
3701                url: None,
3702                ..Default::default()
3703            })
3704            .await
3705        });
3706
3707        let id = mock.expect_cmd("Storage.setCookies").await;
3708        let params = &mock.last_sent()["params"];
3709        let arr = params["cookies"]
3710            .as_array()
3711            .expect("setCookies payload must carry a cookies array");
3712        assert_eq!(arr.len(), 1);
3713        assert_eq!(arr[0]["name"], "sid");
3714        // Browser-scope command — no session_id (jar dispatches against
3715        // the browser's connection, not the tab's session).
3716        assert!(mock.last_sent().get("sessionId").is_none());
3717        mock.reply(id, json!({})).await;
3718
3719        fut.await.unwrap().unwrap();
3720        // Keep `inner` alive until after the dispatch so the Weak upgrade
3721        // succeeds — that's the path under test.
3722        drop(inner);
3723        conn.shutdown();
3724    }
3725
3726    // --- frame lifecycle subscriber (P4 T15) ---------------------------
3727
3728    /// End-to-end: emit `Page.frameAttached` for a new same-origin
3729    /// sub-frame; `tab.frames()` should expose it. Then emit
3730    /// `Page.frameDetached` for the same `frameId` and assert the
3731    /// registry shrinks back to empty.
3732    ///
3733    /// Mirrors the [`InFlightTracker`] test pattern — synchronize on the
3734    /// subscriber's outbound `Page.enable` call before driving events,
3735    /// then poll the registry shape (the lifecycle task processes events
3736    /// asynchronously).
3737    #[tokio::test]
3738    async fn frame_lifecycle_attach_then_detach_round_trip() {
3739        let (mut mock, conn) = MockConnection::pair();
3740        let sess = SessionHandle::new(conn.clone(), "S1");
3741        let tab = Tab::new_for_test(sess);
3742
3743        // Synchronize: wait until the background lifecycle task has run
3744        // far enough to issue `Page.enable`. Once that command lands in
3745        // the mock's outbound queue, the three `Page.frame*` subscriptions
3746        // are already registered, so any subsequent
3747        // `emit_event_for_session` will be routed to them.
3748        let id_enable =
3749            tokio::time::timeout(Duration::from_secs(2), mock.expect_cmd("Page.enable"))
3750                .await
3751                .expect("frame lifecycle did not send Page.enable within 2s");
3752        mock.reply(id_enable, json!({})).await;
3753
3754        // Emit a Page.frameAttached event for a child frame.
3755        mock.emit_event_for_session(
3756            "Page.frameAttached",
3757            json!({
3758                "frameId": "FCHILD",
3759                "parentFrameId": "FROOT",
3760            }),
3761            "S1",
3762        )
3763        .await;
3764
3765        // Poll until the subscriber processes the event (async).
3766        for _ in 0..50 {
3767            if !tab.inner.frames.read().await.is_empty() {
3768                break;
3769            }
3770            tokio::time::sleep(Duration::from_millis(10)).await;
3771        }
3772
3773        let frames = tab.frames().await.unwrap();
3774        assert_eq!(frames.len(), 1, "expected one frame after attach event");
3775        let attached = &frames[0];
3776        assert_eq!(attached.id(), "FCHILD");
3777        assert_eq!(attached.parent_id(), Some("FROOT"));
3778        assert!(!attached.is_main());
3779
3780        // Emit a Page.frameDetached for the same frame.
3781        mock.emit_event_for_session("Page.frameDetached", json!({ "frameId": "FCHILD" }), "S1")
3782            .await;
3783
3784        for _ in 0..50 {
3785            if tab.inner.frames.read().await.is_empty() {
3786                break;
3787            }
3788            tokio::time::sleep(Duration::from_millis(10)).await;
3789        }
3790
3791        let frames_after = tab.frames().await.unwrap();
3792        assert!(
3793            frames_after.is_empty(),
3794            "expected registry to drain after detach event",
3795        );
3796
3797        conn.shutdown();
3798    }
3799
3800    /// `Tab::frames()` sorts by [`Frame::id`] regardless of registry
3801    /// (insertion/`HashMap`) order, so cross-frame callers like
3802    /// `FindBuilder::include_frames` see a deterministic, run-to-run-stable
3803    /// order instead of `HashMap::values()`'s unspecified iteration order.
3804    #[tokio::test]
3805    async fn frames_are_sorted_by_id_regardless_of_attach_order() {
3806        let (mut mock, conn) = MockConnection::pair();
3807        let sess = SessionHandle::new(conn.clone(), "S1");
3808        let tab = Tab::new_for_test(sess);
3809
3810        let id_enable =
3811            tokio::time::timeout(Duration::from_secs(2), mock.expect_cmd("Page.enable"))
3812                .await
3813                .expect("frame lifecycle did not send Page.enable within 2s");
3814        mock.reply(id_enable, json!({})).await;
3815
3816        // Attach frames out of lexical order: "FC", "FA", "FB". Each gets
3817        // its own `parentFrameId` — sharing one would trip the lifecycle
3818        // subscriber's same-parent/empty-url "stale provisional sibling"
3819        // sweep (see `frame::lifecycle::run`), which is unrelated to what
3820        // this test is exercising.
3821        for (frame_id, parent_id) in [("FC", "FROOT1"), ("FA", "FROOT2"), ("FB", "FROOT3")] {
3822            mock.emit_event_for_session(
3823                "Page.frameAttached",
3824                json!({
3825                    "frameId": frame_id,
3826                    "parentFrameId": parent_id,
3827                }),
3828                "S1",
3829            )
3830            .await;
3831        }
3832
3833        for _ in 0..50 {
3834            if tab.inner.frames.read().await.len() == 3 {
3835                break;
3836            }
3837            tokio::time::sleep(Duration::from_millis(10)).await;
3838        }
3839
3840        let frames = tab.frames().await.unwrap();
3841        let ids: Vec<&str> = frames.iter().map(Frame::id).collect();
3842        assert_eq!(
3843            ids,
3844            vec!["FA", "FB", "FC"],
3845            "frames() should be sorted by id, not HashMap/attach order"
3846        );
3847
3848        conn.shutdown();
3849    }
3850
3851    // --- wait_for_idle quiet-window enforcement ------------------------
3852
3853    /// End-to-end: emit a `Network.requestWillBeSent` event, then 100ms
3854    /// later emit `Network.responseReceived` for the same id. With a 500ms
3855    /// quiet window + 2s outer timeout, `wait_for_idle_with` should
3856    /// resolve `Ok(())` within ~600ms of the response (500ms quiet +
3857    /// scheduling slack). Asserts the call returns within 1.5s of the
3858    /// response event — a generous bound that still rejects "never
3859    /// resolves" without flaking on a loaded CI machine.
3860    #[tokio::test]
3861    async fn wait_for_idle_resolves_after_quiet_window_post_response() {
3862        let (mut mock, conn) = MockConnection::pair();
3863        let sess = SessionHandle::new(conn.clone(), "S1");
3864        let tab = Tab::new_for_test(sess);
3865
3866        // Synchronize: wait until the background tracker task has run far
3867        // enough to issue `Network.enable`. Once that command lands in the
3868        // mock's outbound queue, the subscriptions are already registered
3869        // (created in `InFlightTracker::run` before the enable spawn) — so
3870        // any subsequent `emit_event_for_session` will be routed to them.
3871        let id_enable =
3872            tokio::time::timeout(Duration::from_secs(2), mock.expect_cmd("Network.enable"))
3873                .await
3874                .expect("tracker did not send Network.enable within 2s");
3875        mock.reply(id_enable, json!({})).await;
3876
3877        // Insert via requestWillBeSent.
3878        mock.emit_event_for_session(
3879            "Network.requestWillBeSent",
3880            json!({ "requestId": "R1" }),
3881            "S1",
3882        )
3883        .await;
3884        // Wait for the tracker to actually observe the insert before
3885        // starting the wait — otherwise wait_for_idle could see an empty
3886        // set on its first poll and resolve immediately.
3887        for _ in 0..50 {
3888            if tab.inner.network_tracker.in_flight.lock().await.len() == 1 {
3889                break;
3890            }
3891            tokio::time::sleep(Duration::from_millis(10)).await;
3892        }
3893        assert_eq!(
3894            tab.inner.network_tracker.in_flight.lock().await.len(),
3895            1,
3896            "request did not register before wait_for_idle starts",
3897        );
3898
3899        let fut = tokio::spawn({
3900            let t = tab.clone();
3901            async move {
3902                t.wait_for_idle_with(Duration::from_secs(2), Duration::from_millis(500))
3903                    .await
3904            }
3905        });
3906
3907        // Hold the request in-flight briefly, then close it. After this
3908        // emit, the tracker drains to empty and the 500ms quiet window
3909        // starts ticking.
3910        tokio::time::sleep(Duration::from_millis(100)).await;
3911        let response_at = tokio::time::Instant::now();
3912        mock.emit_event_for_session(
3913            "Network.responseReceived",
3914            json!({ "requestId": "R1" }),
3915            "S1",
3916        )
3917        .await;
3918
3919        let res = tokio::time::timeout(Duration::from_millis(1500), fut)
3920            .await
3921            .expect("wait_for_idle did not resolve within 1500ms after response");
3922        res.unwrap().unwrap();
3923        let elapsed = response_at.elapsed();
3924        // 500ms quiet window + slack; must be at least 500ms.
3925        assert!(
3926            elapsed >= Duration::from_millis(450),
3927            "resolved too early ({elapsed:?}) — quiet window not enforced",
3928        );
3929        assert!(
3930            elapsed < Duration::from_millis(1500),
3931            "resolved too late ({elapsed:?})",
3932        );
3933
3934        conn.shutdown();
3935    }
3936
3937    /// Regression: the in-flight set going 1 → 0 → 1 within the quiet
3938    /// window must NOT cause `wait_for_idle_with` to resolve early. The
3939    /// quiet window measures sustained idleness, not a single
3940    /// instantaneous touch-of-zero.
3941    ///
3942    /// Sequence:
3943    /// 1. R1 starts (in_flight = 1).
3944    /// 2. R1 completes (in_flight = 0). Quiet window starts.
3945    /// 3. ~100ms later, well inside the 200ms quiet window, R2 starts
3946    ///    (in_flight = 1). Quiet window MUST reset to `None`.
3947    /// 4. R2 completes (in_flight = 0). New quiet window starts.
3948    /// 5. `wait_for_idle_with` resolves only after R2's quiet window
3949    ///    closes.
3950    ///
3951    /// Assertion: elapsed time from R1's response (step 2) to the future
3952    /// resolving is at least `delay-between-completions (~100ms) +
3953    /// quiet_window (200ms)`. A buggy implementation that ignored the
3954    /// in-window R2 burst would resolve at ~200ms and fail the lower
3955    /// bound.
3956    #[tokio::test]
3957    async fn wait_for_idle_does_not_return_early_if_new_request_arrives_in_quiet_window() {
3958        let (mut mock, conn) = MockConnection::pair();
3959        let sess = SessionHandle::new(conn.clone(), "S1");
3960        let tab = Tab::new_for_test(sess);
3961
3962        let id_enable =
3963            tokio::time::timeout(Duration::from_secs(2), mock.expect_cmd("Network.enable"))
3964                .await
3965                .expect("tracker did not send Network.enable within 2s");
3966        mock.reply(id_enable, json!({})).await;
3967
3968        // Insert R1 and wait for the tracker to observe it before
3969        // starting wait_for_idle (mirrors the sibling test).
3970        mock.emit_event_for_session(
3971            "Network.requestWillBeSent",
3972            json!({ "requestId": "R1" }),
3973            "S1",
3974        )
3975        .await;
3976        for _ in 0..50 {
3977            if tab.inner.network_tracker.in_flight.lock().await.len() == 1 {
3978                break;
3979            }
3980            tokio::time::sleep(Duration::from_millis(10)).await;
3981        }
3982        assert_eq!(
3983            tab.inner.network_tracker.in_flight.lock().await.len(),
3984            1,
3985            "R1 did not register before wait_for_idle starts",
3986        );
3987
3988        let fut = tokio::spawn({
3989            let t = tab.clone();
3990            async move {
3991                // 5s outer timeout: plenty of headroom for the worst-case
3992                // scheduling on a loaded CI. 200ms quiet window: small
3993                // enough that the test finishes fast, large enough that
3994                // the 100ms gap fits comfortably inside it.
3995                t.wait_for_idle_with(Duration::from_secs(5), Duration::from_millis(200))
3996                    .await
3997            }
3998        });
3999
4000        // Drain R1 — quiet window opens here.
4001        tokio::time::sleep(Duration::from_millis(50)).await;
4002        let r1_response_at = tokio::time::Instant::now();
4003        mock.emit_event_for_session(
4004            "Network.responseReceived",
4005            json!({ "requestId": "R1" }),
4006            "S1",
4007        )
4008        .await;
4009
4010        // ~100ms later (still inside the 200ms quiet window), insert R2.
4011        // A correct implementation resets quiet_start; a buggy one would
4012        // already be near the 200ms threshold and resolve any moment.
4013        tokio::time::sleep(Duration::from_millis(100)).await;
4014        mock.emit_event_for_session(
4015            "Network.requestWillBeSent",
4016            json!({ "requestId": "R2" }),
4017            "S1",
4018        )
4019        .await;
4020        // Wait for the tracker to actually observe the insert before
4021        // closing it — otherwise R2 could complete before the tracker
4022        // even noticed it started, defeating the test.
4023        for _ in 0..50 {
4024            if tab
4025                .inner
4026                .network_tracker
4027                .in_flight
4028                .lock()
4029                .await
4030                .contains_key("R2")
4031            {
4032                break;
4033            }
4034            tokio::time::sleep(Duration::from_millis(10)).await;
4035        }
4036        assert!(
4037            tab.inner
4038                .network_tracker
4039                .in_flight
4040                .lock()
4041                .await
4042                .contains_key("R2"),
4043            "R2 did not register inside quiet window",
4044        );
4045
4046        // Hold R2 in-flight briefly, then close it. A new quiet window
4047        // starts from this point — wait_for_idle must wait it out.
4048        tokio::time::sleep(Duration::from_millis(50)).await;
4049        mock.emit_event_for_session(
4050            "Network.responseReceived",
4051            json!({ "requestId": "R2" }),
4052            "S1",
4053        )
4054        .await;
4055
4056        let res = tokio::time::timeout(Duration::from_secs(2), fut)
4057            .await
4058            .expect("wait_for_idle did not resolve within 2s after R2 completed");
4059        res.unwrap().unwrap();
4060        let total_elapsed = r1_response_at.elapsed();
4061
4062        // Lower bound: R1-response (T0) → 100ms gap → R2 starts → 50ms
4063        // hold → R2 response → 200ms quiet window → resolve. Total ≥
4064        // 350ms. A bug that ignored R2's in-window arrival would resolve
4065        // at T0 + 200ms = 200ms.
4066        assert!(
4067            total_elapsed >= Duration::from_millis(330),
4068            "wait_for_idle resolved too early ({total_elapsed:?}); R2 inside quiet \
4069             window must have reset quiet_start, requiring a fresh post-R2 quiet \
4070             window before resolving",
4071        );
4072
4073        conn.shutdown();
4074    }
4075
4076    /// Regression for the "burst within tick" race: a request that fires
4077    /// *and finishes* inside one 50ms poll tick takes the in-flight set
4078    /// 0 → 1 → 0 without ever being observed at a >0 read. A naive
4079    /// implementation that only checks the set len would see sustained
4080    /// 0 and resolve early. The fix arms a `Notify::notified()` future
4081    /// before each count read so the two membership events from the burst
4082    /// both hit an armed waker; on the next iteration we wake via the
4083    /// notifier arm and reset `quiet_start`.
4084    #[tokio::test]
4085    async fn wait_for_idle_burst_inside_tick_resets_quiet_window() {
4086        let (mut mock, conn) = MockConnection::pair();
4087        let sess = SessionHandle::new(conn.clone(), "S1");
4088        let tab = Tab::new_for_test(sess);
4089
4090        let id_enable =
4091            tokio::time::timeout(Duration::from_secs(2), mock.expect_cmd("Network.enable"))
4092                .await
4093                .expect("tracker did not send Network.enable within 2s");
4094        mock.reply(id_enable, json!({})).await;
4095
4096        // Start with the set already at 0 — wait_for_idle should accumulate
4097        // a quiet window from T0. The inner `wait_for_idle_with` timeout is
4098        // deliberately generous (30s) so a slow / loaded CI runner doesn't
4099        // flake the test; the correctness assertion below uses a strict
4100        // lower bound on `elapsed` and an outer 10s tokio::time::timeout to
4101        // catch a too-early resolve or a hang.
4102        let fut = tokio::spawn({
4103            let t = tab.clone();
4104            async move {
4105                t.wait_for_idle_with(Duration::from_secs(30), Duration::from_millis(300))
4106                    .await
4107            }
4108        });
4109        let started_at = tokio::time::Instant::now();
4110
4111        // Let the wait_for_idle loop tick once on an empty set so
4112        // `quiet_start` is firmly armed.
4113        tokio::time::sleep(Duration::from_millis(75)).await;
4114
4115        // Burst: fire R-burst's requestWillBeSent + responseReceived
4116        // back-to-back. The tracker should observe both transitions and
4117        // notify on each; the wait_for_idle loop should wake on the notif
4118        // arm and reset quiet_start even though the set's instantaneous
4119        // value returns to 0.
4120        mock.emit_event_for_session(
4121            "Network.requestWillBeSent",
4122            json!({ "requestId": "Rburst" }),
4123            "S1",
4124        )
4125        .await;
4126        mock.emit_event_for_session(
4127            "Network.responseReceived",
4128            json!({ "requestId": "Rburst" }),
4129            "S1",
4130        )
4131        .await;
4132
4133        // Outer timeout deliberately generous (10s) so a slow / loaded
4134        // CI runner doesn't flake the test. The correctness assertion
4135        // below uses a strict lower bound on `elapsed` to catch a
4136        // too-early resolve regardless of how long the slack window is.
4137        let res = tokio::time::timeout(Duration::from_secs(10), fut)
4138            .await
4139            .expect("wait_for_idle did not resolve within 10s");
4140        res.unwrap().unwrap();
4141
4142        // Lower bound: 75ms initial sleep + 300ms quiet window after the
4143        // burst's last notification = 375ms. A bug that ignored the burst
4144        // would resolve at started_at + 300ms = 300ms.
4145        let elapsed = started_at.elapsed();
4146        assert!(
4147            elapsed >= Duration::from_millis(355),
4148            "wait_for_idle resolved too early ({elapsed:?}); 0→1→0 burst inside \
4149             quiet window must reset quiet_start",
4150        );
4151
4152        conn.shutdown();
4153    }
4154
4155    /// A request that never receives a terminal CDP event (a hung beacon /
4156    /// long-poll / stuck XHR) must not pin `wait_for_idle` forever when the
4157    /// caller sets [`IdleOptions::max_inflight_age`]: once it has been in
4158    /// flight longer than that age it stops counting toward "active network",
4159    /// so the quiet window can elapse and the call resolves.
4160    #[tokio::test]
4161    async fn wait_for_idle_opts_evicts_stuck_request_past_max_inflight_age() {
4162        let (mut mock, conn) = MockConnection::pair();
4163        let sess = SessionHandle::new(conn.clone(), "S1");
4164        let tab = Tab::new_for_test(sess);
4165
4166        let id_enable =
4167            tokio::time::timeout(Duration::from_secs(2), mock.expect_cmd("Network.enable"))
4168                .await
4169                .expect("tracker did not send Network.enable within 2s");
4170        mock.reply(id_enable, json!({})).await;
4171
4172        // Insert a request and NEVER emit a terminal event for it.
4173        mock.emit_event_for_session(
4174            "Network.requestWillBeSent",
4175            json!({ "requestId": "STUCK" }),
4176            "S1",
4177        )
4178        .await;
4179        for _ in 0..50 {
4180            if tab.inner.network_tracker.in_flight.lock().await.len() == 1 {
4181                break;
4182            }
4183            tokio::time::sleep(Duration::from_millis(10)).await;
4184        }
4185        assert_eq!(
4186            tab.inner.network_tracker.in_flight.lock().await.len(),
4187            1,
4188            "stuck request did not register before wait_for_idle starts",
4189        );
4190
4191        // age 300ms + 200ms window ⇒ resolves ~500ms even though the request
4192        // never completes. The 5s inner timeout is never reached on success;
4193        // the 3s outer timeout below fails the test if eviction is missing
4194        // (the stub hangs to the inner timeout instead of resolving).
4195        let start = tokio::time::Instant::now();
4196        let res = tokio::time::timeout(
4197            Duration::from_secs(3),
4198            tab.wait_for_idle_opts(IdleOptions {
4199                timeout: Duration::from_secs(5),
4200                quiet_window: Duration::from_millis(200),
4201                max_inflight_age: Some(Duration::from_millis(300)),
4202            }),
4203        )
4204        .await
4205        .expect("wait_for_idle_opts did not resolve despite max_inflight_age eviction");
4206        res.unwrap();
4207        let elapsed = start.elapsed();
4208
4209        // Must clear the eviction age (300ms) + quiet window (200ms).
4210        assert!(
4211            elapsed >= Duration::from_millis(450),
4212            "resolved too early ({elapsed:?}); eviction age + quiet window not enforced",
4213        );
4214        // Eviction is a counting filter, not a removal: the never-terminated id
4215        // still lingers in the map (so a `None` waiter would still block on it).
4216        assert_eq!(
4217            tab.inner.network_tracker.in_flight.lock().await.len(),
4218            1,
4219            "stuck id should remain in the map (age filter, not prune)",
4220        );
4221
4222        conn.shutdown();
4223    }
4224
4225    // --- Tab::intercept (P5 T7, feature = "interception") -------------
4226
4227    /// `tab.intercept().block("*").start()` should spawn the rule actor on
4228    /// the tab's session: assert `Fetch.enable` lands and a matching
4229    /// `Fetch.requestPaused` triggers `Fetch.failRequest`. Verifies the
4230    /// `Tab::intercept` shim plumbs into `InterceptBuilder` end-to-end.
4231    #[cfg(feature = "interception")]
4232    #[tokio::test]
4233    async fn intercept_block_all_dispatches_fail_request_via_tab_shim() {
4234        let (mut mock, conn) = MockConnection::pair();
4235        let sess = SessionHandle::new(conn.clone(), "S1");
4236        let tab = Tab::new_for_test(sess);
4237
4238        let handle = tab.intercept().block("*").unwrap().start();
4239
4240        // Side-task `Fetch.enable` must land first; default match-all
4241        // pattern is injected when none was registered explicitly.
4242        let enable_id =
4243            tokio::time::timeout(Duration::from_secs(2), mock.expect_cmd("Fetch.enable"))
4244                .await
4245                .expect("intercept did not send Fetch.enable within 2s");
4246        let enable_params = mock.last_sent()["params"].clone();
4247        assert_eq!(enable_params["handleAuthRequests"], false);
4248        assert_eq!(enable_params["patterns"][0]["urlPattern"], "*");
4249        mock.reply(enable_id, json!({})).await;
4250
4251        // Any paused URL matches the `block("*")` rule.
4252        mock.emit_event_for_session(
4253            "Fetch.requestPaused",
4254            json!({
4255                "requestId": "REQ-1",
4256                "request": {
4257                    "url": "https://any.test/whatever",
4258                    "method": "GET",
4259                    "headers": {},
4260                },
4261                "resourceType": "Document",
4262            }),
4263            "S1",
4264        )
4265        .await;
4266
4267        let fail_id =
4268            tokio::time::timeout(Duration::from_secs(2), mock.expect_cmd("Fetch.failRequest"))
4269                .await
4270                .expect("actor did not send Fetch.failRequest within 2s");
4271        let fail_params = mock.last_sent()["params"].clone();
4272        assert_eq!(fail_params["requestId"], "REQ-1");
4273        assert_eq!(fail_params["errorReason"], "BlockedByClient");
4274        mock.reply(fail_id, json!({})).await;
4275
4276        let stop_fut = tokio::spawn(handle.stop());
4277        let disable_id =
4278            tokio::time::timeout(Duration::from_secs(2), mock.expect_cmd("Fetch.disable"))
4279                .await
4280                .expect("actor did not send Fetch.disable on stop()");
4281        mock.reply(disable_id, json!({})).await;
4282        stop_fut
4283            .await
4284            .expect("stop() task panicked")
4285            .expect("stop() returned Err");
4286
4287        conn.shutdown();
4288    }
4289
4290    // --- B4: set_user_agent --------------------------------------------
4291
4292    #[tokio::test]
4293    async fn set_user_agent_dispatches_emulation_override() {
4294        let (mut mock, conn) = MockConnection::pair();
4295        let sess = SessionHandle::new(conn.clone(), "S1");
4296        let tab = Tab::new_for_test(sess);
4297
4298        let fut = tokio::spawn({
4299            let t = tab.clone();
4300            async move {
4301                t.set_user_agent("Mozilla/5.0 (compatible; MyBot/1.0)")
4302                    .await
4303            }
4304        });
4305
4306        let id = mock.expect_cmd("Emulation.setUserAgentOverride").await;
4307        let sent = mock.last_sent();
4308        assert_eq!(
4309            sent["params"]["userAgent"],
4310            "Mozilla/5.0 (compatible; MyBot/1.0)"
4311        );
4312        // UA-only shortcut omits the optional fields entirely.
4313        assert!(sent["params"].get("acceptLanguage").is_none());
4314        assert!(sent["params"].get("platform").is_none());
4315        mock.reply(id, json!({})).await;
4316
4317        fut.await.unwrap().unwrap();
4318        conn.shutdown();
4319    }
4320
4321    #[tokio::test]
4322    async fn set_user_agent_with_includes_lang_and_platform() {
4323        let (mut mock, conn) = MockConnection::pair();
4324        let sess = SessionHandle::new(conn.clone(), "S1");
4325        let tab = Tab::new_for_test(sess);
4326
4327        let fut = tokio::spawn({
4328            let t = tab.clone();
4329            async move {
4330                t.set_user_agent_with(UserAgentOverride {
4331                    user_agent: "UA/1.0".into(),
4332                    accept_language: Some("en-US,en;q=0.9".into()),
4333                    platform: Some("Linux x86_64".into()),
4334                })
4335                .await
4336            }
4337        });
4338
4339        let id = mock.expect_cmd("Emulation.setUserAgentOverride").await;
4340        let sent = mock.last_sent();
4341        assert_eq!(sent["params"]["userAgent"], "UA/1.0");
4342        assert_eq!(sent["params"]["acceptLanguage"], "en-US,en;q=0.9");
4343        assert_eq!(sent["params"]["platform"], "Linux x86_64");
4344        mock.reply(id, json!({})).await;
4345
4346        fut.await.unwrap().unwrap();
4347        conn.shutdown();
4348    }
4349
4350    // --- B5: raw mouse + flash_point -----------------------------------
4351
4352    #[tokio::test]
4353    async fn mouse_click_emits_pressed_released_at_coords() {
4354        let (mut mock, conn) = MockConnection::pair();
4355        let sess = SessionHandle::new(conn.clone(), "S1");
4356        let tab = Tab::new_for_test(sess);
4357
4358        let fut = tokio::spawn({
4359            let t = tab.clone();
4360            async move { t.mouse_click(123.0, 456.0).await }
4361        });
4362
4363        // Realistic click: N mouseMoved frames (Bezier), then exactly one
4364        // mousePressed + one mouseReleased. Drain every dispatch; the final
4365        // two must be mousePressed then mouseReleased at (123, 456).
4366        let mut saw_pressed = false;
4367        let mut saw_released = false;
4368        let mut last_two: Vec<String> = Vec::new();
4369        loop {
4370            let next = tokio::time::timeout(
4371                Duration::from_millis(500),
4372                mock.expect_cmd("Input.dispatchMouseEvent"),
4373            )
4374            .await;
4375            match next {
4376                Ok(id) => {
4377                    let sent = mock.last_sent();
4378                    let kind = sent["params"]["type"].as_str().unwrap_or("").to_string();
4379                    if kind == "mousePressed" || kind == "mouseReleased" {
4380                        // Press/release land at the exact target coordinate.
4381                        assert_eq!(sent["params"]["x"], 123.0);
4382                        assert_eq!(sent["params"]["y"], 456.0);
4383                        assert_eq!(sent["params"]["button"], "left");
4384                        if kind == "mousePressed" {
4385                            saw_pressed = true;
4386                        } else {
4387                            saw_released = true;
4388                        }
4389                    }
4390                    last_two.push(kind);
4391                    mock.reply(id, json!({})).await;
4392                }
4393                Err(_) => break,
4394            }
4395        }
4396
4397        fut.await.unwrap().unwrap();
4398        assert!(saw_pressed, "expected a mousePressed dispatch");
4399        assert!(saw_released, "expected a mouseReleased dispatch");
4400        let tail: Vec<&str> = last_two.iter().rev().take(2).map(String::as_str).collect();
4401        // Reversed: [released, pressed] — i.e. the final two in order are
4402        // mousePressed then mouseReleased.
4403        assert_eq!(
4404            tail,
4405            vec!["mouseReleased", "mousePressed"],
4406            "final two dispatches must be mousePressed then mouseReleased"
4407        );
4408        conn.shutdown();
4409    }
4410
4411    #[tokio::test]
4412    async fn mouse_move_emits_mousemoved() {
4413        let (mut mock, conn) = MockConnection::pair();
4414        let sess = SessionHandle::new(conn.clone(), "S1");
4415        let tab = Tab::new_for_test(sess);
4416
4417        let fut = tokio::spawn({
4418            let t = tab.clone();
4419            async move { t.mouse_move(50.0, 60.0).await }
4420        });
4421
4422        // Realistic move emits one-or-more mouseMoved dispatches and NO
4423        // press/release. Drain them all, asserting type along the way.
4424        let mut saw_moved = false;
4425        loop {
4426            let next = tokio::time::timeout(
4427                Duration::from_millis(500),
4428                mock.expect_cmd("Input.dispatchMouseEvent"),
4429            )
4430            .await;
4431            match next {
4432                Ok(id) => {
4433                    let kind = mock.last_sent()["params"]["type"]
4434                        .as_str()
4435                        .unwrap_or("")
4436                        .to_string();
4437                    assert_eq!(kind, "mouseMoved", "mouse_move must only emit mouseMoved");
4438                    saw_moved = true;
4439                    mock.reply(id, json!({})).await;
4440                }
4441                Err(_) => break,
4442            }
4443        }
4444
4445        fut.await.unwrap().unwrap();
4446        assert!(saw_moved, "expected at least one mouseMoved dispatch");
4447        conn.shutdown();
4448    }
4449
4450    #[tokio::test]
4451    async fn flash_point_dispatches_evaluate() {
4452        let (mut mock, conn) = MockConnection::pair();
4453        let sess = SessionHandle::new(conn.clone(), "S1");
4454        let tab = Tab::new_for_test(sess);
4455
4456        let fut = tokio::spawn({
4457            let t = tab.clone();
4458            async move { t.flash_point(12.0, 34.0).await }
4459        });
4460
4461        // flash_point injects a transient dot via a single main-world
4462        // Runtime.evaluate. Assert the JS references the coordinates and a
4463        // self-removal timer.
4464        let id = mock.expect_cmd("Runtime.evaluate").await;
4465        let expr = mock.last_sent()["params"]["expression"]
4466            .as_str()
4467            .unwrap_or("")
4468            .to_string();
4469        assert!(expr.contains("createElement"), "should build a dot element");
4470        assert!(
4471            expr.contains("12px") && expr.contains("34px"),
4472            "should position at (x, y)"
4473        );
4474        assert!(
4475            expr.contains("setTimeout") && expr.contains("remove"),
4476            "should self-remove"
4477        );
4478        mock.reply(id, json!({ "result": { "type": "undefined" } }))
4479            .await;
4480
4481        fut.await.unwrap().unwrap();
4482        conn.shutdown();
4483    }
4484
4485    // --- B8: bring_to_front / bypass_insecure_connection_warning / inspector_url
4486
4487    #[tokio::test]
4488    async fn bring_to_front_dispatches_page_bring_to_front() {
4489        let (mut mock, conn) = MockConnection::pair();
4490        let sess = SessionHandle::new(conn.clone(), "S1");
4491        let tab = Tab::new_for_test(sess);
4492
4493        let fut = tokio::spawn({
4494            let t = tab.clone();
4495            async move { t.bring_to_front().await }
4496        });
4497
4498        let id = mock.expect_cmd("Page.bringToFront").await;
4499        // Session-scope command (unlike activate's browser-scope
4500        // Target.activateTarget): the MockConnection session call path is used.
4501        mock.reply(id, json!({})).await;
4502
4503        fut.await.unwrap().unwrap();
4504        conn.shutdown();
4505    }
4506
4507    #[tokio::test]
4508    async fn bypass_insecure_connection_warning_focuses_body_and_types_phrase() {
4509        let (mut mock, conn) = MockConnection::pair();
4510        let sess = SessionHandle::new(conn.clone(), "S1");
4511        let tab = Tab::new_for_test(sess);
4512
4513        let fut = tokio::spawn({
4514            let t = tab.clone();
4515            async move { t.bypass_insecure_connection_warning().await }
4516        });
4517
4518        // Step 1: find().css("body").one() resolves via querySelectorAll →
4519        // getProperties → describeNode.
4520        let id_q = mock.expect_cmd("Runtime.evaluate").await;
4521        let expr = mock.last_sent()["params"]["expression"]
4522            .as_str()
4523            .unwrap_or("")
4524            .to_string();
4525        assert!(
4526            expr.contains("document.querySelectorAll") && expr.contains("body"),
4527            "expected querySelectorAll for body, got: {expr}"
4528        );
4529        mock.reply(
4530            id_q,
4531            json!({ "result": { "objectId": "RArr", "type": "object", "subtype": "array" } }),
4532        )
4533        .await;
4534        let id_p = mock.expect_cmd("Runtime.getProperties").await;
4535        mock.reply(
4536            id_p,
4537            json!({
4538                "result": [
4539                    { "name": "0", "value": { "objectId": "RBody", "type": "object", "subtype": "node" } },
4540                    { "name": "length", "value": { "value": 1, "type": "number" } }
4541                ]
4542            }),
4543        )
4544        .await;
4545        let id_d = mock.expect_cmd("DOM.describeNode").await;
4546        mock.reply(id_d, json!({ "node": { "backendNodeId": 7 } }))
4547            .await;
4548
4549        // Step 2: type_text_fast focuses the body first — actionability gate
4550        // (TEXT_INPUT = visible → enabled, 2 callFunctionOn) then this.focus()
4551        // (1 callFunctionOn). Reply truthy/undefined to each.
4552        for _ in 0..2 {
4553            let id = mock.expect_cmd("Runtime.callFunctionOn").await;
4554            mock.reply(
4555                id,
4556                json!({ "result": { "value": true, "type": "boolean" } }),
4557            )
4558            .await;
4559        }
4560        let id_focus = mock.expect_cmd("Runtime.callFunctionOn").await;
4561        mock.reply(id_focus, json!({ "result": { "type": "undefined" } }))
4562            .await;
4563
4564        // Step 3: each of the 12 chars of "thisisunsafe" emits a keyDown +
4565        // keyUp via Input.dispatchKeyEvent. Drain them all and reconstruct
4566        // the typed string from the keyDown events.
4567        let phrase = "thisisunsafe";
4568        let mut typed = String::new();
4569        loop {
4570            let next = tokio::time::timeout(
4571                Duration::from_millis(500),
4572                mock.expect_cmd("Input.dispatchKeyEvent"),
4573            )
4574            .await;
4575            match next {
4576                Ok(id) => {
4577                    let sent = mock.last_sent();
4578                    if sent["params"]["type"] == "keyDown" {
4579                        if let Some(k) = sent["params"]["key"].as_str() {
4580                            typed.push_str(k);
4581                        }
4582                    }
4583                    mock.reply(id, json!({})).await;
4584                }
4585                Err(_) => break,
4586            }
4587        }
4588
4589        fut.await.unwrap().unwrap();
4590        assert_eq!(typed, phrase, "must type the literal bypass phrase");
4591        conn.shutdown();
4592    }
4593
4594    #[tokio::test]
4595    async fn inspector_url_composes_expected_string() {
4596        // `inspector_url` reaches the owning browser's `debug_host_port` via
4597        // the Tab's Weak<BrowserInner>. Build a real BrowserInner (test
4598        // helper) and set the endpoint, then mint a Tab bound to it.
4599        let (_mock, conn) = MockConnection::pair();
4600        let inner = crate::browser::test_only_inner_from_conn(conn.clone());
4601        // Endpoint is `None` for the test helper by default → error path.
4602        let tab = inner.main_tab.clone();
4603        let err = tab.inspector_url().unwrap_err();
4604        assert!(
4605            matches!(err, ZendriverError::Navigation(_)),
4606            "missing endpoint should surface Navigation error, got {err:?}"
4607        );
4608        conn.shutdown();
4609    }
4610
4611    #[tokio::test]
4612    async fn inspector_url_with_endpoint_builds_devtools_frontend_url() {
4613        let (_mock, conn) = MockConnection::pair();
4614        // Construct a BrowserInner with a known debug endpoint + main tab
4615        // target id so we can assert the exact composed URL.
4616        let inner =
4617            std::sync::Arc::new_cyclic(|weak: &std::sync::Weak<crate::browser::BrowserInner>| {
4618                let session = SessionHandle::new(conn.clone(), "S1");
4619                let input =
4620                    crate::input::InputController::new(zendriver_stealth::InputProfile::native());
4621                let main_tab = Tab::new(session, weak.clone(), input, "TARGET-XYZ".to_string());
4622                let mut map = std::collections::HashMap::new();
4623                map.insert("S1".to_string(), main_tab.clone());
4624                crate::browser::BrowserInner {
4625                    conn: conn.clone(),
4626                    main_tab,
4627                    child: tokio::sync::Mutex::new(None),
4628                    job: crate::browser::ProcessJob::none(),
4629                    _user_data: None,
4630                    _extension_dirs: Vec::new(),
4631                    owns_process: false,
4632                    stealth_input_profile: zendriver_stealth::InputProfile::native(),
4633                    tabs: tokio::sync::RwLock::new(map),
4634                    debug_host_port: Some("127.0.0.1:9222".to_string()),
4635                    ws_url: None,
4636                    tabs_changed: tokio::sync::Notify::new(),
4637                    #[cfg(feature = "interception")]
4638                    proxy_auth_handle: std::sync::OnceLock::new(),
4639                    #[cfg(feature = "interception")]
4640                    context_proxy_auth: tokio::sync::Mutex::new(HashMap::new()),
4641                    #[cfg(feature = "tracker-blocking")]
4642                    tracker_matcher: None,
4643                    #[cfg(feature = "interception")]
4644                    session_intercept_handles: tokio::sync::Mutex::new(
4645                        std::collections::HashMap::new(),
4646                    ),
4647                }
4648            });
4649        let url = inner.main_tab.inspector_url().unwrap();
4650        assert_eq!(
4651            url,
4652            "http://127.0.0.1:9222/devtools/inspector.html?ws=127.0.0.1:9222/devtools/page/TARGET-XYZ"
4653        );
4654        conn.shutdown();
4655    }
4656
4657    /// [`Tab::set_download_path`] dispatches `Browser.setDownloadBehavior`
4658    /// with `behavior: "allow"` (keeps suggested filenames — distinct from
4659    /// the `expect_download` coordinator's `allowAndName`) and the chosen
4660    /// `downloadPath`, at browser scope.
4661    #[tokio::test]
4662    async fn tab_set_download_path_dispatches_set_download_behavior_allow() {
4663        let (mut mock, conn) = MockConnection::pair();
4664        let sess = SessionHandle::new(conn.clone(), "S1");
4665        let tab = Tab::new_for_test(sess);
4666
4667        let fut = tokio::spawn({
4668            let t = tab.clone();
4669            async move { t.set_download_path("/tmp/x").await }
4670        });
4671
4672        let id = mock.expect_cmd("Browser.setDownloadBehavior").await;
4673        let params = &mock.last_sent()["params"];
4674        assert_eq!(params["behavior"], "allow");
4675        assert_eq!(params["downloadPath"], "/tmp/x");
4676        mock.reply(id, json!({})).await;
4677
4678        fut.await.unwrap().unwrap();
4679        conn.shutdown();
4680    }
4681
4682    // --- E1: js_dumps --------------------------------------------------
4683
4684    #[tokio::test]
4685    async fn js_dumps_evaluates_with_return_by_value_and_returns_value() {
4686        let (mut mock, conn) = MockConnection::pair();
4687        let sess = SessionHandle::new(conn.clone(), "S1");
4688        let tab = Tab::new_for_test(sess);
4689
4690        let fut = tokio::spawn({
4691            let t = tab.clone();
4692            async move { t.js_dumps("window.foo").await }
4693        });
4694
4695        let id = mock.expect_cmd("Runtime.evaluate").await;
4696        let sent = mock.last_sent();
4697        assert_eq!(sent["params"]["expression"], "window.foo");
4698        // Must request a by-value deep serialization (untyped dump).
4699        assert_eq!(sent["params"]["returnByValue"], true);
4700        mock.reply(
4701            id,
4702            json!({ "result": { "type": "object", "value": { "a": 1, "b": [2, 3] } } }),
4703        )
4704        .await;
4705
4706        let val = fut.await.unwrap().unwrap();
4707        assert_eq!(val, json!({ "a": 1, "b": [2, 3] }));
4708        conn.shutdown();
4709    }
4710
4711    // --- E2: get_all_urls + get_all_linked_sources ---------------------
4712
4713    #[tokio::test]
4714    async fn get_all_urls_evaluates_collector_reading_href_and_src() {
4715        let (mut mock, conn) = MockConnection::pair();
4716        let sess = SessionHandle::new(conn.clone(), "S1");
4717        let tab = Tab::new_for_test(sess);
4718
4719        let fut = tokio::spawn({
4720            let t = tab.clone();
4721            async move { t.get_all_urls(true).await }
4722        });
4723
4724        // Single main-world collector walks [href], [src]; the JS must read
4725        // both attribute kinds and (absolute=true) the resolved DOM props.
4726        let id = mock.expect_cmd("Runtime.evaluate").await;
4727        let expr = mock.last_sent()["params"]["expression"]
4728            .as_str()
4729            .unwrap_or("")
4730            .to_string();
4731        assert!(
4732            expr.contains("href") && expr.contains("src"),
4733            "collector must read both href and src, got: {expr}"
4734        );
4735        assert!(
4736            expr.contains("querySelectorAll"),
4737            "collector must query the DOM, got: {expr}"
4738        );
4739        mock.reply(
4740            id,
4741            json!({ "result": { "type": "object", "value": ["https://x.test/a", "https://x.test/b.png"] } }),
4742        )
4743        .await;
4744
4745        let urls = fut.await.unwrap().unwrap();
4746        assert_eq!(urls, vec!["https://x.test/a", "https://x.test/b.png"]);
4747        conn.shutdown();
4748    }
4749
4750    #[tokio::test]
4751    async fn get_all_linked_sources_routes_through_find_all_css() {
4752        let (mut mock, conn) = MockConnection::pair();
4753        let sess = SessionHandle::new(conn.clone(), "S1");
4754        let tab = Tab::new_for_test(sess);
4755
4756        let fut = tokio::spawn({
4757            let t = tab.clone();
4758            async move { t.get_all_linked_sources().await }
4759        });
4760
4761        // find_all().css("[src], [href]") resolves via a querySelectorAll
4762        // Runtime.evaluate carrying that exact selector, then getProperties,
4763        // then a describeNode per node. Return one node so `many()` resolves
4764        // on the first poll (an empty result would re-poll until the 10s
4765        // default timeout — see `many_or_empty_returns_empty_vec_on_timeout`).
4766        let id = mock.expect_cmd("Runtime.evaluate").await;
4767        let expr = mock.last_sent()["params"]["expression"]
4768            .as_str()
4769            .unwrap_or("")
4770            .to_string();
4771        assert!(
4772            expr.contains("querySelectorAll") && expr.contains("[src], [href]"),
4773            "must route through find_all css with the linked-source selector, got: {expr}"
4774        );
4775        mock.reply(
4776            id,
4777            json!({ "result": { "objectId": "RArr", "type": "object", "subtype": "array" } }),
4778        )
4779        .await;
4780        let id_p = mock.expect_cmd("Runtime.getProperties").await;
4781        mock.reply(
4782            id_p,
4783            json!({ "result": [
4784                { "name": "0", "value": { "objectId": "R0", "type": "object", "subtype": "node" } },
4785                { "name": "length", "value": { "value": 1, "type": "number" } }
4786            ] }),
4787        )
4788        .await;
4789        let id_d = mock.expect_cmd("DOM.describeNode").await;
4790        mock.reply(id_d, json!({ "node": { "backendNodeId": 20 } }))
4791            .await;
4792
4793        let els = fut.await.unwrap().unwrap();
4794        assert_eq!(els.len(), 1, "should return the one linked-source element");
4795        conn.shutdown();
4796    }
4797
4798    // --- E3: wait_for_ready_state --------------------------------------
4799
4800    #[tokio::test]
4801    async fn wait_for_ready_state_polls_until_target_reached() {
4802        let (mut mock, conn) = MockConnection::pair();
4803        let sess = SessionHandle::new(conn.clone(), "S1");
4804        let tab = Tab::new_for_test(sess);
4805
4806        let fut = tokio::spawn({
4807            let t = tab.clone();
4808            async move { t.wait_for_ready_state(ReadyState::Complete).await }
4809        });
4810
4811        // Poll 1: still "loading" (rank 0 < Complete) → must keep polling.
4812        let id1 = mock.expect_cmd("Runtime.evaluate").await;
4813        assert_eq!(
4814            mock.last_sent()["params"]["expression"],
4815            "document.readyState"
4816        );
4817        mock.reply(
4818            id1,
4819            json!({ "result": { "type": "string", "value": "loading" } }),
4820        )
4821        .await;
4822
4823        // Poll 2: now "complete" → resolves Ok.
4824        let id2 = mock.expect_cmd("Runtime.evaluate").await;
4825        mock.reply(
4826            id2,
4827            json!({ "result": { "type": "string", "value": "complete" } }),
4828        )
4829        .await;
4830
4831        fut.await.unwrap().unwrap();
4832        conn.shutdown();
4833    }
4834
4835    #[tokio::test]
4836    async fn wait_for_ready_state_returns_when_observed_state_exceeds_target() {
4837        // Asking for Interactive must also resolve when the page is already
4838        // fully Complete (Complete ⊇ Interactive ordering).
4839        let (mut mock, conn) = MockConnection::pair();
4840        let sess = SessionHandle::new(conn.clone(), "S1");
4841        let tab = Tab::new_for_test(sess);
4842
4843        let fut = tokio::spawn({
4844            let t = tab.clone();
4845            async move { t.wait_for_ready_state(ReadyState::Interactive).await }
4846        });
4847
4848        let id = mock.expect_cmd("Runtime.evaluate").await;
4849        mock.reply(
4850            id,
4851            json!({ "result": { "type": "string", "value": "complete" } }),
4852        )
4853        .await;
4854
4855        fut.await.unwrap().unwrap();
4856        conn.shutdown();
4857    }
4858
4859    // --- E4: download_file ---------------------------------------------
4860
4861    #[tokio::test]
4862    async fn download_file_sets_behavior_then_injects_fetch_anchor_script() {
4863        let (mut mock, conn) = MockConnection::pair();
4864        let sess = SessionHandle::new(conn.clone(), "S1");
4865        let tab = Tab::new_for_test(sess);
4866
4867        let fut = tokio::spawn({
4868            let t = tab.clone();
4869            async move {
4870                t.download_file("https://x.test/path/file.pdf?token=abc", None)
4871                    .await
4872            }
4873        });
4874
4875        // No path set yet → download_file installs a default download
4876        // directory first (browser-scope setDownloadBehavior, behavior allow).
4877        let id_dl = mock.expect_cmd("Browser.setDownloadBehavior").await;
4878        let dl = mock.last_sent();
4879        assert_eq!(dl["params"]["behavior"], "allow");
4880        assert!(
4881            dl["params"]["downloadPath"]
4882                .as_str()
4883                .unwrap_or("")
4884                .ends_with("downloads"),
4885            "default download path should end with /downloads"
4886        );
4887        mock.reply(id_dl, json!({})).await;
4888
4889        // Then the page-driven fetch→blob→anchor[download]→click injection.
4890        let id_eval = mock.expect_cmd("Runtime.evaluate").await;
4891        let expr = mock.last_sent()["params"]["expression"]
4892            .as_str()
4893            .unwrap_or("")
4894            .to_string();
4895        assert!(expr.contains("fetch("), "must fetch the url");
4896        assert!(
4897            expr.contains("createElement('a')") && expr.contains(".download"),
4898            "must build a download anchor"
4899        );
4900        assert!(expr.contains(".click()"), "must click the anchor");
4901        // URL is carried into the script; filename derived from the tail with
4902        // the query string stripped.
4903        assert!(
4904            expr.contains("https://x.test/path/file.pdf?token=abc"),
4905            "url must be injected"
4906        );
4907        assert!(
4908            expr.contains("\"file.pdf\""),
4909            "filename should be derived from url tail (query stripped), got: {expr}"
4910        );
4911        mock.reply(id_eval, json!({ "result": { "type": "undefined" } }))
4912            .await;
4913
4914        fut.await.unwrap().unwrap();
4915        conn.shutdown();
4916    }
4917
4918    #[tokio::test]
4919    async fn download_file_skips_default_path_when_already_set() {
4920        let (mut mock, conn) = MockConnection::pair();
4921        let sess = SessionHandle::new(conn.clone(), "S1");
4922        let tab = Tab::new_for_test(sess);
4923
4924        // Pre-set a download path; download_file must NOT re-install a default.
4925        let set_fut = tokio::spawn({
4926            let t = tab.clone();
4927            async move { t.set_download_path("/tmp/chosen").await }
4928        });
4929        let id_set = mock.expect_cmd("Browser.setDownloadBehavior").await;
4930        assert_eq!(mock.last_sent()["params"]["downloadPath"], "/tmp/chosen");
4931        mock.reply(id_set, json!({})).await;
4932        set_fut.await.unwrap().unwrap();
4933
4934        let fut = tokio::spawn({
4935            let t = tab.clone();
4936            async move {
4937                t.download_file("https://x.test/a.bin", Some(PathBuf::from("renamed.bin")))
4938                    .await
4939            }
4940        });
4941
4942        // Next command must be the injection evaluate directly — no second
4943        // setDownloadBehavior.
4944        let id_eval = mock.expect_cmd("Runtime.evaluate").await;
4945        let expr = mock.last_sent()["params"]["expression"]
4946            .as_str()
4947            .unwrap_or("")
4948            .to_string();
4949        // Explicit filename wins over the url-derived one.
4950        assert!(
4951            expr.contains("\"renamed.bin\""),
4952            "explicit filename must be used"
4953        );
4954        mock.reply(id_eval, json!({ "result": { "type": "undefined" } }))
4955            .await;
4956
4957        fut.await.unwrap().unwrap();
4958        conn.shutdown();
4959    }
4960
4961    // --- E5: mouse_drag ------------------------------------------------
4962
4963    #[tokio::test]
4964    async fn mouse_drag_emits_pressed_moves_released_in_order() {
4965        let (mut mock, conn) = MockConnection::pair();
4966        let sess = SessionHandle::new(conn.clone(), "S1");
4967        let tab = Tab::new_for_test(sess);
4968
4969        let fut = tokio::spawn({
4970            let t = tab.clone();
4971            async move { t.mouse_drag((10.0, 20.0), (110.0, 20.0), 4).await }
4972        });
4973
4974        // Drain every dispatch: expect mousePressed(left) first, then ≥1
4975        // mouseMoved, then mouseReleased(left) last.
4976        let mut kinds: Vec<String> = Vec::new();
4977        let mut first_button = String::new();
4978        let mut last_button = String::new();
4979        loop {
4980            let next = tokio::time::timeout(
4981                Duration::from_millis(500),
4982                mock.expect_cmd("Input.dispatchMouseEvent"),
4983            )
4984            .await;
4985            match next {
4986                Ok(id) => {
4987                    let sent = mock.last_sent();
4988                    let kind = sent["params"]["type"].as_str().unwrap_or("").to_string();
4989                    if kind == "mousePressed" {
4990                        first_button = sent["params"]["button"].as_str().unwrap_or("").to_string();
4991                        // Press at the source point.
4992                        assert_eq!(sent["params"]["x"], 10.0);
4993                        assert_eq!(sent["params"]["y"], 20.0);
4994                    }
4995                    if kind == "mouseReleased" {
4996                        last_button = sent["params"]["button"].as_str().unwrap_or("").to_string();
4997                        // Release at the destination point.
4998                        assert_eq!(sent["params"]["x"], 110.0);
4999                        assert_eq!(sent["params"]["y"], 20.0);
5000                    }
5001                    kinds.push(kind);
5002                    mock.reply(id, json!({})).await;
5003                }
5004                Err(_) => break,
5005            }
5006        }
5007
5008        fut.await.unwrap().unwrap();
5009        assert_eq!(kinds.first().map(String::as_str), Some("mousePressed"));
5010        assert_eq!(kinds.last().map(String::as_str), Some("mouseReleased"));
5011        assert!(
5012            kinds.iter().any(|k| k == "mouseMoved"),
5013            "expected at least one mouseMoved between press and release"
5014        );
5015        assert_eq!(first_button, "left", "drag presses the left button");
5016        assert_eq!(last_button, "left", "drag releases the left button");
5017        conn.shutdown();
5018    }
5019
5020    // --- E6: search_frame_resources ------------------------------------
5021
5022    #[tokio::test]
5023    async fn search_frame_resources_walks_tree_then_searches_each_resource() {
5024        let (mut mock, conn) = MockConnection::pair();
5025        let sess = SessionHandle::new(conn.clone(), "S1");
5026        let tab = Tab::new_for_test(sess);
5027
5028        let fut = tokio::spawn({
5029            let t = tab.clone();
5030            async move { t.search_frame_resources("needle").await }
5031        });
5032
5033        // 1. Page.getResourceTree → one frame with two resources.
5034        let id_tree = mock.expect_cmd("Page.getResourceTree").await;
5035        mock.reply(
5036            id_tree,
5037            json!({
5038                "frameTree": {
5039                    "frame": { "id": "FRAME_A" },
5040                    "resources": [
5041                        { "url": "https://x.test/app.js", "type": "Script" },
5042                        { "url": "https://x.test/style.css", "type": "Stylesheet" },
5043                    ],
5044                }
5045            }),
5046        )
5047        .await;
5048
5049        // 2. Page.searchInResource for resource #1 → a match.
5050        let id_s1 = mock.expect_cmd("Page.searchInResource").await;
5051        let s1 = mock.last_sent();
5052        assert_eq!(s1["params"]["frameId"], "FRAME_A");
5053        assert_eq!(s1["params"]["query"], "needle");
5054        let first_url = s1["params"]["url"].as_str().unwrap_or("").to_string();
5055        mock.reply(
5056            id_s1,
5057            json!({ "result": [ { "lineNumber": 3, "lineContent": "var x = needle" } ] }),
5058        )
5059        .await;
5060
5061        // 3. searchInResource for resource #2 → no match (empty result).
5062        let id_s2 = mock.expect_cmd("Page.searchInResource").await;
5063        let second_url = mock.last_sent()["params"]["url"]
5064            .as_str()
5065            .unwrap_or("")
5066            .to_string();
5067        mock.reply(id_s2, json!({ "result": [] })).await;
5068
5069        let matches = fut.await.unwrap().unwrap();
5070        // Only the first resource matched.
5071        assert_eq!(matches.len(), 1);
5072        assert_eq!(matches[0].frame_id, "FRAME_A");
5073        assert_eq!(matches[0].url, first_url);
5074        // Sanity: both resources were searched (the two URLs differ).
5075        assert_ne!(first_url, second_url);
5076        conn.shutdown();
5077    }
5078}