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}