Skip to main content

ferridriver/
page.rs

1//! High-level Page API -- mirrors Playwright's Page interface.
2//!
3//! All interaction methods auto-wait for element actionability.
4//! Locator methods are lazy (don't query DOM until action).
5
6use crate::actions;
7use crate::backend::{AnyPage, CookieData, ImageFormat, ScreenshotOpts};
8use crate::error::Result;
9use crate::events::{EventEmitter, PageEvent};
10use crate::frame::Frame;
11use crate::frame_cache::FrameCache;
12use crate::locator::Locator;
13use crate::options::{FrameSelector, GotoOptions, RoleOptions, ScreenshotOptions, TextOptions, WaitOptions};
14use crate::snapshot;
15use std::sync::Arc;
16use std::sync::Mutex;
17use std::sync::atomic::{AtomicU64, Ordering};
18use tokio::sync::Mutex as AsyncMutex;
19
20/// High-level page API, mirrors Playwright's Page interface.
21/// Always constructed behind `Arc<Page>` — locators, frames, and consumers
22/// hold Arc refs. No cloning of the Page struct itself.
23pub struct Page {
24  pub(crate) inner: AnyPage,
25  default_timeout: AtomicU64,
26  /// Per Playwright: split from `default_timeout`. Navigation-family APIs
27  /// (`goto`, `reload`, `go_back`, `go_forward`, `wait_for_url`) use this
28  /// when explicitly set. The `u64::MAX` sentinel means "not set — fall
29  /// back to `default_timeout`"; `0` means "infinite" (Playwright parity).
30  default_navigation_timeout: AtomicU64,
31  snapshot_tracker: Arc<AsyncMutex<snapshot::SnapshotTracker>>,
32  mouse_position: Mutex<(f64, f64)>,
33  context_ref: Option<crate::context::ContextRef>,
34  /// Human-readable `reason` passed to the last `close({ reason })` call,
35  /// surfaced on subsequent `TargetClosed` errors — Playwright parity.
36  close_reason: Mutex<Option<String>>,
37  /// Persistent emulated-media state. Playwright tracks per-field state so
38  /// that `emulateMedia({colorScheme: 'dark'})` followed by
39  /// `emulateMedia({media: 'print'})` keeps both active — each call is a
40  /// partial update, not a replacement. See
41  /// `/tmp/playwright/packages/playwright-core/src/server/page.ts:585`.
42  emulated_media: Mutex<crate::options::EmulateMediaOptions>,
43  /// Client-side frame tree cache. Playwright keeps `Page._frames`,
44  /// `Frame._parentFrame`, `Frame._url`, `Frame._detached` etc. up to
45  /// date via wire-level `frameAttached`/`frameDetached`/`frameNavigated`
46  /// events so that sync accessors (`mainFrame`, `frames`, `frame`,
47  /// `parentFrame`, `childFrames`, `name`, `url`, `isDetached`) never
48  /// await. ferridriver does the same. The actual storage lives on the
49  /// backend (`AnyPage::frame_cache()`) so it outlives short-lived
50  /// `Arc<Page>` wrappers — see `Page::seed_frame_cache` for the
51  /// idempotent listener that keeps it fresh.
52  frame_cache: Arc<Mutex<FrameCache>>,
53  /// Live [`crate::video::Video`] handle when this page was opened
54  /// inside a context with `record_video` enabled. `None` otherwise
55  /// (matches Playwright's `page.video(): null | Video` —
56  /// `types.d.ts:4756`). Populated by
57  /// [`crate::state::BrowserState::register_opened_page`] at page
58  /// registration time, when the recording runtime is spawned.
59  video: Mutex<Option<Arc<crate::video::Video>>>,
60  /// Registered `addLocatorHandler` callbacks. Consulted before every
61  /// actionability retry (see [`crate::locator_handler::perform_checkpoint`]).
62  locator_handlers: crate::locator_handler::LocatorHandlerRegistry,
63}
64
65impl Page {
66  /// Highlight helpers (`createHighlight`, `hideHighlight`) layered onto the
67  /// injected engine. Shared with the codegen recorder.
68  const RECORDER_SUPPORT_JS: &'static str = include_str!("injected/dist/recorder-support.min.js");
69  /// Interactive locator picker injected by [`Page::pick_locator`].
70  const PICKER_JS: &'static str = include_str!("picker.js");
71
72  /// Construct a Page (no `BrowserContext`). Synchronous — only
73  /// spawns the frame-cache listener; the eager `Page.getFrameTree`
74  /// RTT was dropped (see `PERF_AUDIT` §M.4). The listener seeds
75  /// the cache on the first frame event, and
76  /// `Self::ensure_frame_cache_seeded` does an on-demand fetch
77  /// only when a sync accessor fires before any navigation.
78  #[must_use]
79  pub fn new(inner: AnyPage) -> Arc<Self> {
80    let frame_cache = inner.frame_cache().clone();
81    let page = Arc::new(Self {
82      inner,
83      default_timeout: AtomicU64::new(30000),
84      default_navigation_timeout: AtomicU64::new(u64::MAX),
85      snapshot_tracker: Arc::new(AsyncMutex::new(snapshot::SnapshotTracker::new())),
86      mouse_position: Mutex::new((0.0, 0.0)),
87      context_ref: None,
88      close_reason: Mutex::new(None),
89      emulated_media: Mutex::new(crate::options::EmulateMediaOptions::default()),
90      frame_cache,
91      video: Mutex::new(None),
92      locator_handlers: crate::locator_handler::LocatorHandlerRegistry::default(),
93    });
94    // Wire the backend's weak back-reference before the frame cache
95    // starts seeding — the file-chooser listener (spawned in
96    // `attach_listeners`) reads this slot per event and silently
97    // drops events that arrive before the page is addressable.
98    page.inner.set_page_backref(Arc::downgrade(&page));
99    page.seed_frame_cache();
100    page
101  }
102
103  /// Construct a Page bound to a `BrowserContext`. Same init
104  /// contract as [`Self::new`].
105  #[must_use]
106  pub fn with_context(inner: AnyPage, context: crate::context::ContextRef) -> Arc<Self> {
107    let frame_cache = inner.frame_cache().clone();
108    let page = Arc::new(Self {
109      inner,
110      default_timeout: AtomicU64::new(30000),
111      default_navigation_timeout: AtomicU64::new(u64::MAX),
112      snapshot_tracker: Arc::new(AsyncMutex::new(snapshot::SnapshotTracker::new())),
113      mouse_position: Mutex::new((0.0, 0.0)),
114      context_ref: Some(context),
115      close_reason: Mutex::new(None),
116      emulated_media: Mutex::new(crate::options::EmulateMediaOptions::default()),
117      frame_cache,
118      video: Mutex::new(None),
119      locator_handlers: crate::locator_handler::LocatorHandlerRegistry::default(),
120    });
121    page.inner.set_page_backref(Arc::downgrade(&page));
122    page.seed_frame_cache();
123    page
124  }
125
126  // ── Frame cache plumbing (Playwright-parity sync frame accessors) ─────
127
128  /// Read from the Page's frame cache under the shared lock.
129  pub(crate) fn with_frame_cache<R>(&self, f: impl FnOnce(&FrameCache) -> R) -> R {
130    match self.frame_cache.lock() {
131      Ok(g) => f(&g),
132      Err(poisoned) => f(&poisoned.into_inner()),
133    }
134  }
135
136  /// Internal: spawn the `FrameAttached` / `FrameDetached` /
137  /// `FrameNavigated` listener that keeps the backend's frame cache
138  /// fresh. Idempotent — only the first wrapper for a given backend
139  /// spawns the listener; subsequent wrappers see the latch set and
140  /// skip the spawn so we don't end up with N listeners all writing
141  /// the same cache. The listener task holds `Arc` clones of the
142  /// cache + event emitter, so it lives until the backend page is
143  /// dropped (broadcast sender drops → recv returns Err → task
144  /// exits).
145  fn seed_frame_cache(self: &Arc<Self>) {
146    if self
147      .inner
148      .frame_listener_started()
149      .swap(true, std::sync::atomic::Ordering::SeqCst)
150    {
151      return;
152    }
153    let cache = Arc::clone(&self.frame_cache);
154    let mut rx = self.inner.events().subscribe();
155    tokio::spawn(async move {
156      while let Ok(event) = rx.recv().await {
157        match event {
158          PageEvent::FrameAttached(info) => {
159            if let Ok(mut g) = cache.lock() {
160              g.attach(info);
161            }
162          },
163          PageEvent::FrameDetached { frame_id } => {
164            if let Ok(mut g) = cache.lock() {
165              g.detach(&frame_id);
166            }
167          },
168          PageEvent::FrameNavigated(info) => {
169            if let Ok(mut g) = cache.lock() {
170              g.navigated(info);
171            }
172          },
173          _ => {},
174        }
175      }
176    });
177  }
178
179  /// Lazy frame-cache seed. Returns immediately when the cache
180  /// already has a main frame (populated either by a prior
181  /// `Page.frameNavigated` event or by an earlier call). Otherwise:
182  ///
183  /// 1. Tries the backend's cached `peek_main_frame_id()` first
184  ///    (populated for free from the `Page.navigate` response when
185  ///    the user has already called `goto`) — seeds a synthetic
186  ///    `FrameInfo` with that id and an empty url, no RTT.
187  /// 2. Falls back to `Page.getFrameTree` if the backend has no
188  ///    cached frame id (no prior navigation).
189  ///
190  /// Called from [`Self::goto`] after `inner.goto` returns to
191  /// guarantee `main_frame()` works for the synchronous accessor
192  /// the user is about to invoke. The RTT path fires at most once
193  /// per page lifetime and is skipped entirely for the common case
194  /// (navigate-then-query test flows — the bench's 100% case).
195  pub(crate) async fn ensure_frame_cache_seeded(self: &Arc<Self>) -> Result<()> {
196    let already_seeded = self.with_frame_cache(|c| c.main_frame_id().is_some());
197    if already_seeded {
198      return Ok(());
199    }
200    if let Some(fid) = self.inner.peek_main_frame_id() {
201      if let Ok(mut g) = self.frame_cache.lock() {
202        if g.main_frame_id().is_none() {
203          g.attach(crate::backend::FrameInfo {
204            frame_id: fid,
205            parent_frame_id: None,
206            name: String::new(),
207            url: String::new(),
208          });
209        }
210      }
211      return Ok(());
212    }
213    let infos = self.inner.get_frame_tree().await?;
214    if let Ok(mut g) = self.frame_cache.lock() {
215      if g.main_frame_id().is_none() {
216        g.seed(infos);
217      }
218    }
219    Ok(())
220  }
221
222  /// Refresh the frame cache from the backend without touching the
223  /// listener. Useful when a caller wants to guarantee freshness before
224  /// reading sync accessors (e.g. right after a navigation where event
225  /// delivery is racing with the caller).
226  ///
227  /// # Errors
228  ///
229  /// Returns an error if the backend's `get_frame_tree()` call fails.
230  pub async fn sync_frames(self: &Arc<Self>) -> Result<()> {
231    let infos = self.inner.get_frame_tree().await?;
232    if let Ok(mut g) = self.frame_cache.lock() {
233      g.seed(infos);
234    }
235    Ok(())
236  }
237
238  /// Get the `BrowserContext` this page belongs to (matches Playwright's `page.context()`).
239  #[must_use]
240  pub fn context(&self) -> Option<&crate::context::ContextRef> {
241    self.context_ref.as_ref()
242  }
243
244  /// Access the underlying backend page (escape hatch).
245  #[must_use]
246  pub fn inner(&self) -> &AnyPage {
247    &self.inner
248  }
249
250  /// Set the default timeout for all operations (milliseconds).
251  pub fn set_default_timeout(&self, ms: u64) {
252    self.default_timeout.store(ms, Ordering::Relaxed);
253  }
254
255  /// Get the default timeout (milliseconds).
256  #[must_use]
257  pub fn default_timeout(&self) -> u64 {
258    self.default_timeout.load(Ordering::Relaxed)
259  }
260
261  /// Set the default timeout for navigation-family operations
262  /// (`goto`, `reload`, `go_back`, `go_forward`, `wait_for_url`). Mirrors
263  /// Playwright's `page.setDefaultNavigationTimeout(timeout)`. Overrides
264  /// the non-navigation default returned by [`Self::default_timeout`] for
265  /// navigation calls only. Passing `0` means "no timeout" (Playwright
266  /// parity).
267  pub fn set_default_navigation_timeout(&self, ms: u64) {
268    self.default_navigation_timeout.store(ms, Ordering::Relaxed);
269  }
270
271  /// Current effective navigation timeout (milliseconds). If
272  /// [`Self::set_default_navigation_timeout`] was not called, returns the
273  /// same value as [`Self::default_timeout`].
274  #[must_use]
275  pub fn default_navigation_timeout(&self) -> u64 {
276    match self.default_navigation_timeout.load(Ordering::Relaxed) {
277      u64::MAX => self.default_timeout(),
278      v => v,
279    }
280  }
281
282  /// Get the current viewport size by querying the browser.
283  ///
284  /// # Errors
285  ///
286  /// Returns an error if the JavaScript evaluation fails.
287  pub async fn viewport_size(&self) -> Result<(i64, i64)> {
288    let r = self
289      .inner
290      .evaluate("JSON.stringify({w:window.innerWidth,h:window.innerHeight})")
291      .await?;
292    let s = r
293      .and_then(|v| v.as_str().map(std::string::ToString::to_string))
294      .unwrap_or_default();
295    let parsed: serde_json::Value = serde_json::from_str(&s).unwrap_or_default();
296    let w = parsed.get("w").and_then(serde_json::Value::as_i64).unwrap_or(0);
297    let h = parsed.get("h").and_then(serde_json::Value::as_i64).unwrap_or(0);
298    Ok((w, h))
299  }
300
301  // ── Navigation ──────────────────────────────────────────────────────────
302
303  /// Navigate to a URL with optional options (waitUntil, timeout).
304  ///
305  /// Returns the main-document `Response` when the backend can observe
306  /// it, or `None` for same-document navigations (no new request was
307  /// issued) / backends that genuinely cannot expose the main-document
308  /// response (stock `WKWebView` has no public API for this — see the
309  /// §1.4 backend gap matrix in `PLAYWRIGHT_COMPAT.md`). Mirrors
310  /// Playwright's `Promise<Response | null>` contract on `page.goto`.
311  ///
312  /// # Errors
313  ///
314  /// Returns an error if the navigation fails or the wait condition times out.
315  #[tracing::instrument(skip(self, opts), fields(url))]
316  pub async fn goto(
317    self: &Arc<Self>,
318    url: &str,
319    opts: Option<GotoOptions>,
320  ) -> Result<Option<crate::network::Response>> {
321    // Resolve against the context's `baseURL` option when set —
322    // mirrors Playwright's `constructURLBasedOnBaseURL` applied in
323    // `Page._goto` (`/tmp/playwright/packages/playwright-core/src/client/page.ts`).
324    // Absolute URLs passthrough; relative paths resolve against baseURL.
325    let resolved = self.resolve_with_base_url(url).await;
326    tracing::debug!(target: "ferridriver::action", action = "goto", url = %resolved, "page.goto");
327    let (lifecycle, timeout) = Self::resolve_nav_opts(opts.as_ref(), self.default_navigation_timeout());
328    let referer = opts.as_ref().and_then(|o| o.referer.as_deref());
329    let result = self.inner.goto(&resolved, lifecycle, timeout, referer).await;
330    // PERF_AUDIT.md §M.4 — bootstrap no longer fetches `Page.getFrameTree`,
331    // so the wrapper's frame cache is empty on a fresh page until the
332    // first navigation event lands. The CDP backend captures the
333    // top-level `frameId` from `Page.navigate`'s response and we read
334    // it here via `peek_main_frame_id()` to seed the cache without
335    // an extra RTT — the synchronous `main_frame()` call the user is
336    // about to make then sees a populated cache. `ensure_frame_cache_seeded`
337    // is a no-op when a `Page.frameNavigated` event has already
338    // populated the cache via the listener (the common path on
339    // network-light tests where the listener task gets scheduled
340    // before goto returns).
341    let _ = self.ensure_frame_cache_seeded().await;
342    // BiDi subframe seeding: WebDriver BiDi's `browsingContext.contextCreated`
343    // fires asynchronously for child iframes after `browsingContext.navigate`
344    // completes, and the iframe's `name` attribute lives in the DOM —
345    // not in the contextCreated payload. The listener-driven cache
346    // therefore lags any synchronous `page.frame(name)` / `page.frames()`
347    // call made right after `goto` returns. Mirror Playwright's
348    // `bidiBrowser._onBrowsingContextCreated`
349    // (`/tmp/playwright/packages/playwright-core/src/server/bidi/bidiBrowser.ts:146`)
350    // by fetching the full subtree on the goto-return path and seeding
351    // the wrapper's cache directly — `BidiPage::get_frame_tree` already
352    // does the parallel `window.name` resolution for unnamed children,
353    // so the wrapper sees a fully-populated cache.
354    // Backends without per-frame attach/navigate events on the wire need an
355    // explicit cache seed after navigation so synchronous `page.frame(...)`
356    // / `page.frames()` calls see the iframes the test just navigated to.
357    // - BiDi: `browsingContext.contextCreated` arrives async with empty
358    //   `name`; the cache won't reflect the DOM until our `getTree` probe
359    //   runs.
360    // - WebKit (Playwright WebKit, cross-platform): no FrameAttached
361    //   events at all — the cache is populated solely by `get_frame_tree`'s
362    //   DOM probe, which must run after EVERY navigation since
363    //   `ensure_frame_cache_seeded`'s early-return on `main_frame_id`
364    //   present would otherwise skip refreshing the iframe set on a
365    //   reused page.
366    let needs_sync = matches!(
367      self.inner.kind(),
368      crate::backend::BackendKind::Bidi | crate::backend::BackendKind::WebKit
369    );
370    if needs_sync {
371      // Single pass — extra sync rounds would push past the
372      // `setTimeout(confirm, 80)` window dialog tests rely on between
373      // goto-returning and the user subscribing to `waitForEvent`.
374      // Stragglers get picked up via the live FrameAttached listener.
375      let _ = self.sync_frames().await;
376    }
377    result
378  }
379
380  /// Resolve a user-supplied URL against the owning context's
381  /// `baseURL` option. Returns the input unchanged when no context
382  /// is attached, no `baseURL` is set, or the input is already
383  /// absolute. See
384  /// [`crate::options::construct_url_with_base`] for the resolution
385  /// rules.
386  async fn resolve_with_base_url(&self, url: &str) -> String {
387    let Some(ctx) = self.context_ref.as_ref() else {
388      return url.to_string();
389    };
390    let state = ctx.state.read().await;
391    let Some(bag) = state.get_context_options(&ctx.key.to_composite()) else {
392      return url.to_string();
393    };
394    crate::options::construct_url_with_base(bag.base_url.as_deref(), url)
395  }
396
397  /// Navigate back in history. Returns the main-document `Response` on
398  /// the same basis as `goto` (or `None`).
399  ///
400  /// # Errors
401  ///
402  /// Returns an error if the navigation fails or the wait condition times out.
403  pub async fn go_back(&self, opts: Option<GotoOptions>) -> Result<Option<crate::network::Response>> {
404    let (lifecycle, timeout) = Self::resolve_nav_opts(opts.as_ref(), self.default_navigation_timeout());
405    self.inner.go_back(lifecycle, timeout).await
406  }
407
408  /// Navigate forward in history. Returns the main-document `Response`
409  /// on the same basis as `goto` (or `None`).
410  ///
411  /// # Errors
412  ///
413  /// Returns an error if the navigation fails or the wait condition times out.
414  pub async fn go_forward(&self, opts: Option<GotoOptions>) -> Result<Option<crate::network::Response>> {
415    let (lifecycle, timeout) = Self::resolve_nav_opts(opts.as_ref(), self.default_navigation_timeout());
416    self.inner.go_forward(lifecycle, timeout).await
417  }
418
419  /// Reload the current page. Returns the main-document `Response` on
420  /// the same basis as `goto` (or `None`).
421  ///
422  /// # Errors
423  ///
424  /// Returns an error if the reload fails or the wait condition times out.
425  pub async fn reload(&self, opts: Option<GotoOptions>) -> Result<Option<crate::network::Response>> {
426    let (lifecycle, timeout) = Self::resolve_nav_opts(opts.as_ref(), self.default_navigation_timeout());
427    self.inner.reload(lifecycle, timeout).await
428  }
429
430  /// Parse `GotoOptions` into backend `NavLifecycle` + timeout.
431  fn resolve_nav_opts(opts: Option<&GotoOptions>, default_timeout: u64) -> (crate::backend::NavLifecycle, u64) {
432    let wait_until = opts.and_then(|o| o.wait_until.as_deref()).unwrap_or("load");
433    let timeout = opts.and_then(|o| o.timeout).unwrap_or(default_timeout);
434    (crate::backend::NavLifecycle::parse_lifecycle(wait_until), timeout)
435  }
436
437  /// Get the current page URL — the main frame's URL.
438  ///
439  /// Playwright: [`page.url()`](https://playwright.dev/docs/api/class-page#page-url)
440  /// is **synchronous** (`url(): string`). It reads the locally-tracked
441  /// main-frame URL (kept current by navigation/lifecycle events), the
442  /// same source [`Frame::url`] uses — no backend round-trip.
443  #[must_use]
444  pub fn url(&self) -> String {
445    self.with_frame_cache(|c| {
446      c.main_frame_id()
447        .and_then(|id| c.record(&id).map(|r| r.info.url.clone()))
448        .unwrap_or_default()
449    })
450  }
451
452  /// Get the current page title.
453  ///
454  /// # Errors
455  ///
456  /// Returns an error if the title cannot be retrieved from the backend.
457  pub async fn title(&self) -> Result<String> {
458    self.inner.title().await.map(std::option::Option::unwrap_or_default)
459  }
460
461  // ── Locators (delegate to mainFrame — Playwright parity) ───────────
462  //
463  // `Page` is a facade over `mainFrame` for ergonomics. Mirrors
464  // `/tmp/playwright/packages/playwright-core/src/client/page.ts:307+`,
465  // where every locator-construction and action method does
466  // `this._mainFrame.<method>(...)`. The Frame is the unit of execution
467  // context; Page never constructs Locators directly.
468
469  #[must_use]
470  pub fn locator(self: &Arc<Self>, selector: &str, options: Option<crate::options::FilterOptions>) -> Locator {
471    self.main_frame().locator(selector, options)
472  }
473
474  /// Internal accessor for the locator-handler registry. Consumed by the
475  /// actionability checkpoint and by the public add/remove methods below.
476  pub(crate) fn locator_handlers(&self) -> &crate::locator_handler::LocatorHandlerRegistry {
477    &self.locator_handlers
478  }
479
480  /// Register a handler that runs when `locator` becomes visible during an
481  /// actionability wait. Mirrors Playwright
482  /// `page.addLocatorHandler(locator, handler, options?: { times?, noWaitAfter? })`
483  /// (`/tmp/playwright/packages/playwright-core/src/client/page.ts:397`).
484  ///
485  /// The handler must belong to the main frame of this page. `times: Some(0)`
486  /// registers nothing. When `times` runs out the handler auto-removes.
487  ///
488  /// # Errors
489  ///
490  /// Returns [`crate::error::FerriError`] if `locator` is not bound to this
491  /// page's main frame.
492  pub fn add_locator_handler(
493    self: &Arc<Self>,
494    locator: &Locator,
495    handler: crate::locator_handler::LocatorHandlerFn,
496    times: Option<u32>,
497    no_wait_after: bool,
498  ) -> Result<()> {
499    if !locator.frame.is_main_frame() {
500      return Err(crate::error::FerriError::protocol(
501        "addLocatorHandler",
502        "Locator must belong to the main frame of this page",
503      ));
504    }
505    self
506      .locator_handlers
507      .register(locator.selector().to_string(), handler, times, no_wait_after);
508    Ok(())
509  }
510
511  /// Remove all handlers registered for `locator`. Mirrors Playwright
512  /// `page.removeLocatorHandler(locator)`
513  /// (`/tmp/playwright/packages/playwright-core/src/client/page.ts:423`).
514  pub fn remove_locator_handler(self: &Arc<Self>, locator: &Locator) {
515    self.locator_handlers.remove_by_selector(locator.selector());
516  }
517
518  #[must_use]
519  pub fn get_by_role(self: &Arc<Self>, role: &str, opts: &RoleOptions) -> Locator {
520    self.main_frame().get_by_role(role, opts)
521  }
522
523  #[must_use]
524  pub fn get_by_text(self: &Arc<Self>, text: &crate::options::StringOrRegex, opts: &TextOptions) -> Locator {
525    self.main_frame().get_by_text(text, opts)
526  }
527
528  #[must_use]
529  pub fn get_by_label(self: &Arc<Self>, text: &crate::options::StringOrRegex, opts: &TextOptions) -> Locator {
530    self.main_frame().get_by_label(text, opts)
531  }
532
533  #[must_use]
534  pub fn get_by_placeholder(self: &Arc<Self>, text: &crate::options::StringOrRegex, opts: &TextOptions) -> Locator {
535    self.main_frame().get_by_placeholder(text, opts)
536  }
537
538  #[must_use]
539  pub fn get_by_alt_text(self: &Arc<Self>, text: &crate::options::StringOrRegex, opts: &TextOptions) -> Locator {
540    self.main_frame().get_by_alt_text(text, opts)
541  }
542
543  #[must_use]
544  pub fn get_by_title(self: &Arc<Self>, text: &crate::options::StringOrRegex, opts: &TextOptions) -> Locator {
545    self.main_frame().get_by_title(text, opts)
546  }
547
548  #[must_use]
549  pub fn get_by_test_id(self: &Arc<Self>, test_id: &crate::options::StringOrRegex) -> Locator {
550    self.main_frame().get_by_test_id(test_id)
551  }
552
553  /// Create a `FrameLocator` for an `<iframe>` matching the selector.
554  ///
555  /// Equivalent to Playwright's `page.frameLocator(selector)`.
556  #[must_use]
557  pub fn frame_locator(self: &Arc<Self>, selector: &str) -> crate::locator::FrameLocator {
558    self.main_frame().frame_locator(selector)
559  }
560
561  // ── Handle materialisation (Playwright `page.$` / `page.$$`) ─────
562
563  /// Resolve the selector once and return a lifecycle
564  /// [`crate::element_handle::ElementHandle`] — or `None` when the
565  /// selector matches no element. Mirrors Playwright's
566  /// `page.querySelector(selector)` /
567  /// `page.$(selector)` (`/tmp/playwright/packages/playwright-core/src/client/page.ts`).
568  ///
569  /// Unlike [`Self::locator`] (lazy, re-resolves on every action), the
570  /// returned handle is pinned to the element resolved at call time.
571  /// Subsequent DOM mutations that remove the element won't invalidate
572  /// the handle itself — actions against a detached element surface a
573  /// backend-specific error — but the handle's lifecycle is decoupled
574  /// from the page's frame cache. Callers release it via
575  /// [`crate::element_handle::ElementHandle::dispose`] or let it
576  /// drop when the page closes.
577  ///
578  /// # Errors
579  ///
580  /// Returns an error if the backend cannot execute the underlying
581  /// query (protocol failure, target closed, etc.). A selector that
582  /// does not match any element returns `Ok(None)`.
583  pub async fn query_selector(
584    self: &Arc<Self>,
585    selector: &str,
586  ) -> Result<Option<crate::element_handle::ElementHandle>> {
587    match self.inner.find_element(selector).await {
588      Ok(element) => {
589        let handle = crate::element_handle::ElementHandle::from_any_element(Arc::clone(self), element).await?;
590        Ok(Some(handle))
591      },
592      Err(err) if is_element_not_found(&err) => Ok(None),
593      Err(err) => Err(err),
594    }
595  }
596
597  /// Playwright: `page.querySelectorAll(selector): Promise<ElementHandle[]>`.
598  /// Returns one [`crate::element_handle::ElementHandle`] per match in
599  /// document order. Each element is pinned individually — disposing
600  /// one does not affect the others.
601  ///
602  /// Implementation uses the selector engine's `query_all` which
603  /// tags every match with `data-fd-sel='<i>'`; we then evaluate a
604  /// lookup by tag for each index and wrap the result. Tags are
605  /// cleaned up on completion.
606  ///
607  /// # Errors
608  ///
609  /// Returns an error on selector parse failure, protocol error, or
610  /// if a match cannot be resolved (e.g. the DOM changed mid-iteration).
611  pub async fn query_selector_all(
612    self: &Arc<Self>,
613    selector: &str,
614  ) -> Result<Vec<crate::element_handle::ElementHandle>> {
615    let matches = crate::selectors::query_all(&self.inner, selector, None).await?;
616    let count = matches.len();
617    let mut handles = Vec::with_capacity(count);
618    for i in 0..count {
619      let tagged = format!("window.__fd.selOne([{{\"engine\":\"css\",\"body\":\"[data-fd-sel='{i}']\"}}])");
620      match self.inner.evaluate_to_element(&tagged, None).await {
621        Ok(element) => {
622          handles.push(crate::element_handle::ElementHandle::from_any_element(Arc::clone(self), element).await?);
623        },
624        Err(err) => {
625          crate::selectors::cleanup_tags(&self.inner).await;
626          return Err(err);
627        },
628      }
629    }
630    crate::selectors::cleanup_tags(&self.inner).await;
631    Ok(handles)
632  }
633
634  // ── evaluate (Playwright parity) ─────────────────────────────────────
635
636  /// Playwright: `page.evaluate(pageFunction, arg?): Promise<R>`
637  /// (`/tmp/playwright/packages/playwright-core/src/client/page.ts:515`).
638  /// Delegates to the main frame, same as Playwright's `this._mainFrame.evaluate(...)`.
639  ///
640  /// # Errors
641  ///
642  /// Returns a [`crate::error::FerriError`] on page-side exception or
643  /// protocol failure.
644  pub async fn evaluate(
645    self: &Arc<Self>,
646    fn_source: &str,
647    arg: crate::protocol::SerializedArgument,
648    is_function: Option<bool>,
649  ) -> Result<crate::protocol::SerializedValue> {
650    self.main_frame().evaluate(fn_source, arg, is_function).await
651  }
652
653  /// Playwright: `page.evaluateHandle(pageFunction, arg?): Promise<JSHandle>`.
654  /// Delegates to the main frame.
655  ///
656  /// # Errors
657  ///
658  /// See [`Self::evaluate`].
659  pub async fn evaluate_handle(
660    self: &Arc<Self>,
661    fn_source: &str,
662    arg: crate::protocol::SerializedArgument,
663    is_function: Option<bool>,
664  ) -> Result<crate::js_handle::JSHandle> {
665    self.main_frame().evaluate_handle(fn_source, arg, is_function).await
666  }
667
668  // ── Action methods (delegate to mainFrame — Playwright parity) ─────
669  //
670  // Mirrors `/tmp/playwright/packages/playwright-core/src/client/page.ts:658+`:
671  // every action delegates to `this._mainFrame.<method>(...)`. The
672  // `tracing::debug!` events stay at this layer so logs identify the
673  // top-level entry point.
674
675  /// Click on an element matching the selector. Accepts Playwright's
676  /// full `PageClickOptions` bag (see [`crate::options::ClickOptions`]).
677  /// Delegates to `mainFrame().click(selector, opts)`.
678  ///
679  /// # Errors
680  ///
681  /// Returns an error if the element is not found or the click fails.
682  pub async fn click(self: &Arc<Self>, selector: &str, opts: Option<crate::options::ClickOptions>) -> Result<()> {
683    tracing::debug!(target: "ferridriver::action", action = "click", selector, "page.click");
684    self.main_frame().click(selector, opts).await
685  }
686
687  /// Double-click an element matching the selector.
688  ///
689  /// # Errors
690  ///
691  /// Returns an error if the element is not found or the double-click fails.
692  pub async fn dblclick(self: &Arc<Self>, selector: &str, opts: Option<crate::options::DblClickOptions>) -> Result<()> {
693    tracing::debug!(target: "ferridriver::action", action = "dblclick", selector, "page.dblclick");
694    self.main_frame().dblclick(selector, opts).await
695  }
696
697  /// Fill an input element matching the selector with a value.
698  ///
699  /// # Errors
700  ///
701  /// Returns an error if the element is not found or is not fillable.
702  pub async fn fill(
703    self: &Arc<Self>,
704    selector: &str,
705    value: &str,
706    opts: Option<crate::options::FillOptions>,
707  ) -> Result<()> {
708    tracing::debug!(target: "ferridriver::action", action = "fill", selector, "page.fill");
709    self.main_frame().fill(selector, value, opts).await
710  }
711
712  /// Type text character-by-character into an element matching the selector.
713  ///
714  /// # Errors
715  ///
716  /// Returns an error if the element is not found or typing fails.
717  pub async fn r#type(
718    self: &Arc<Self>,
719    selector: &str,
720    text: &str,
721    opts: Option<crate::options::TypeOptions>,
722  ) -> Result<()> {
723    self.main_frame().r#type(selector, text, opts).await
724  }
725
726  /// Press a key on an element matching the selector.
727  ///
728  /// # Errors
729  ///
730  /// Returns an error if the element is not found or the key press fails.
731  pub async fn press(
732    self: &Arc<Self>,
733    selector: &str,
734    key: &str,
735    opts: Option<crate::options::PressOptions>,
736  ) -> Result<()> {
737    self.main_frame().press(selector, key, opts).await
738  }
739
740  /// Hover over an element matching the selector.
741  ///
742  /// # Errors
743  ///
744  /// Returns an error if the element is not found or the hover fails.
745  pub async fn hover(self: &Arc<Self>, selector: &str, opts: Option<crate::options::HoverOptions>) -> Result<()> {
746    self.main_frame().hover(selector, opts).await
747  }
748
749  /// Select an option in a `<select>` element matching the selector.
750  ///
751  /// # Errors
752  ///
753  /// Returns an error if the element is not found or the option cannot be selected.
754  pub async fn select_option(
755    self: &Arc<Self>,
756    selector: &str,
757    values: Vec<crate::options::SelectOptionValue>,
758    opts: Option<crate::options::SelectOptionOptions>,
759  ) -> Result<Vec<String>> {
760    self.main_frame().select_option(selector, values, opts).await
761  }
762
763  /// Set input files on a file input element matching the selector.
764  ///
765  /// # Errors
766  ///
767  /// Returns an error if the element is not found or file setting fails.
768  pub async fn set_input_files(
769    self: &Arc<Self>,
770    selector: &str,
771    files: crate::options::InputFiles,
772    opts: Option<crate::options::SetInputFilesOptions>,
773  ) -> Result<()> {
774    self.main_frame().set_input_files(selector, files, opts).await
775  }
776
777  /// Check a checkbox or radio button matching the selector.
778  ///
779  /// # Errors
780  ///
781  /// Returns an error if the element is not found or is not checkable.
782  pub async fn check(self: &Arc<Self>, selector: &str, opts: Option<crate::options::CheckOptions>) -> Result<()> {
783    self.main_frame().check(selector, opts).await
784  }
785
786  /// Uncheck a checkbox matching the selector.
787  ///
788  /// # Errors
789  ///
790  /// Returns an error if the element is not found or is not uncheckable.
791  pub async fn uncheck(self: &Arc<Self>, selector: &str, opts: Option<crate::options::CheckOptions>) -> Result<()> {
792    self.main_frame().uncheck(selector, opts).await
793  }
794
795  /// Set a checkbox or radio matching `selector` to `checked`. Mirrors
796  /// Playwright's `page.setChecked(selector, checked, options?)`
797  /// (`/tmp/playwright/packages/playwright-core/src/client/frame.ts:439`).
798  ///
799  /// # Errors
800  ///
801  /// Returns an error if the element is not found or is not checkable.
802  pub async fn set_checked(
803    self: &Arc<Self>,
804    selector: &str,
805    checked: bool,
806    opts: Option<crate::options::CheckOptions>,
807  ) -> Result<()> {
808    self.main_frame().set_checked(selector, checked, opts).await
809  }
810
811  /// Tap (touch) the element matched by `selector`. Mirrors Playwright's
812  /// `page.tap(selector, options?)`
813  /// (`/tmp/playwright/packages/playwright-core/src/client/frame.ts:308`).
814  /// Distinct from `Touchscreen::tap(x, y)` which is the lower-level
815  /// coordinate-based touch primitive.
816  ///
817  /// # Errors
818  ///
819  /// Returns an error if the element is not found or the tap fails.
820  pub async fn tap(self: &Arc<Self>, selector: &str, opts: Option<crate::options::TapOptions>) -> Result<()> {
821    self.main_frame().tap(selector, opts).await
822  }
823
824  // ── Content ─────────────────────────────────────────────────────────────
825
826  /// Get the full HTML content of the page.
827  ///
828  /// # Errors
829  ///
830  /// Returns an error if the content cannot be retrieved.
831  pub async fn content(&self) -> Result<String> {
832    self.inner.content().await
833  }
834
835  /// Set the page's HTML content.
836  ///
837  /// # Errors
838  ///
839  /// Returns an error if the content cannot be set.
840  pub async fn set_content(&self, html: &str) -> Result<()> {
841    self.inner.set_content(html).await?;
842    // Playwright `page.setContent` defaults to `waitUntil: 'load'`.
843    // Wait for the injected document to finish loading so its
844    // subframes attach, then refresh the frame cache from the live
845    // tree: the `FrameAttached` listener can miss iframes inserted via
846    // `Page.setDocumentContent` on a never-navigated page (the parent
847    // main frame was never event-seeded), so `frames()` /
848    // `frameLocator` would otherwise never see them.
849    let _ = self.wait_for_load_state(Some("load")).await;
850    if let Ok(infos) = self.inner.get_frame_tree().await
851      && let Ok(mut g) = self.frame_cache.lock()
852    {
853      g.seed(infos);
854    }
855    Ok(())
856  }
857
858  /// Extract the page content as markdown.
859  ///
860  /// # Errors
861  ///
862  /// Returns an error if the markdown extraction fails.
863  pub async fn markdown(&self) -> Result<String> {
864    actions::extract_markdown(&self.inner).await
865  }
866
867  /// Get the text content of an element matching the selector.
868  ///
869  /// # Errors
870  ///
871  /// Returns an error if the element is not found.
872  pub async fn text_content(self: &Arc<Self>, selector: &str) -> Result<Option<String>> {
873    self.main_frame().text_content(selector).await
874  }
875
876  /// Get the inner text of an element matching the selector.
877  ///
878  /// # Errors
879  ///
880  /// Returns an error if the element is not found.
881  pub async fn inner_text(self: &Arc<Self>, selector: &str) -> Result<String> {
882    self.main_frame().inner_text(selector).await
883  }
884
885  /// Get the inner HTML of an element matching the selector.
886  ///
887  /// # Errors
888  ///
889  /// Returns an error if the element is not found.
890  pub async fn inner_html(self: &Arc<Self>, selector: &str) -> Result<String> {
891    self.main_frame().inner_html(selector).await
892  }
893
894  /// Get an attribute value from an element matching the selector.
895  ///
896  /// # Errors
897  ///
898  /// Returns an error if the element is not found.
899  pub async fn get_attribute(self: &Arc<Self>, selector: &str, name: &str) -> Result<Option<String>> {
900    self.main_frame().get_attribute(selector, name).await
901  }
902
903  /// Get the input value of a form element matching the selector.
904  ///
905  /// # Errors
906  ///
907  /// Returns an error if the element is not found.
908  pub async fn input_value(self: &Arc<Self>, selector: &str) -> Result<String> {
909    self.main_frame().input_value(selector).await
910  }
911
912  // ── State checks (delegate to mainFrame) ────────────────────────────
913
914  /// Check if an element matching the selector is visible.
915  ///
916  /// # Errors
917  ///
918  /// Returns an error if the element is not found.
919  pub async fn is_visible(self: &Arc<Self>, selector: &str) -> Result<bool> {
920    self.main_frame().is_visible(selector).await
921  }
922
923  /// Check if an element matching the selector is hidden.
924  ///
925  /// # Errors
926  ///
927  /// Returns an error if the element is not found.
928  pub async fn is_hidden(self: &Arc<Self>, selector: &str) -> Result<bool> {
929    self.main_frame().is_hidden(selector).await
930  }
931
932  /// Check if an element matching the selector is enabled.
933  ///
934  /// # Errors
935  ///
936  /// Returns an error if the element is not found.
937  pub async fn is_enabled(self: &Arc<Self>, selector: &str) -> Result<bool> {
938    self.main_frame().is_enabled(selector).await
939  }
940
941  /// Check if an element matching the selector is disabled.
942  ///
943  /// # Errors
944  ///
945  /// Returns an error if the element is not found.
946  pub async fn is_disabled(self: &Arc<Self>, selector: &str) -> Result<bool> {
947    self.main_frame().is_disabled(selector).await
948  }
949
950  /// Check if a checkbox or radio button matching the selector is checked.
951  ///
952  /// # Errors
953  ///
954  /// Returns an error if the element is not found.
955  pub async fn is_checked(self: &Arc<Self>, selector: &str) -> Result<bool> {
956    self.main_frame().is_checked(selector).await
957  }
958
959  // ── Waiting ─────────────────────────────────────────────────────────────
960
961  /// Wait for an element matching the selector to satisfy the wait condition.
962  ///
963  /// # Errors
964  ///
965  /// Returns an error if the wait times out.
966  pub async fn wait_for_selector(self: &Arc<Self>, selector: &str, opts: WaitOptions) -> Result<()> {
967    self.locator(selector, None).wait_for(opts).await
968  }
969
970  /// Wait for the page URL to match the given matcher.
971  ///
972  /// Accepts glob, regex, or predicate via [`crate::url_matcher::UrlMatcher`].
973  /// Mirrors Playwright's `page.waitForURL(url | RegExp | predicate)` semantic.
974  ///
975  /// # Errors
976  ///
977  /// Returns an error if the wait times out.
978  pub async fn wait_for_url(&self, matcher: crate::url_matcher::UrlMatcher) -> Result<()> {
979    let timeout_ms = self.default_navigation_timeout();
980    let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
981    loop {
982      if tokio::time::Instant::now() >= deadline {
983        return Err(crate::error::FerriError::timeout(
984          format!("waiting for URL matching {:?}", matcher.identifier()),
985          timeout_ms,
986        ));
987      }
988      let current = self.url();
989      if matcher.matches(&current) {
990        return Ok(());
991      }
992      tokio::time::sleep(std::time::Duration::from_millis(100)).await;
993    }
994  }
995
996  pub async fn wait_for_timeout(&self, ms: u64) {
997    tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
998  }
999
1000  /// Wait for a specific load state. Supported states:
1001  /// - `"load"` (default) - wait for `document.readyState === "complete"`
1002  /// - `"domcontentloaded"` - wait for `document.readyState !== "loading"`
1003  /// - `"networkidle"` - wait for no network activity for 500ms
1004  ///
1005  /// # Errors
1006  ///
1007  /// Returns an error if the wait times out before the load state is reached.
1008  pub async fn wait_for_load_state(&self, state: Option<&str>) -> Result<()> {
1009    let state = state.unwrap_or("load");
1010    let timeout_ms = self.default_timeout();
1011    let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
1012
1013    match state {
1014      "domcontentloaded" => loop {
1015        if tokio::time::Instant::now() >= deadline {
1016          return Err(crate::error::FerriError::timeout(
1017            "waiting for domcontentloaded",
1018            timeout_ms,
1019          ));
1020        }
1021        if let Ok(Some(v)) = self.inner.evaluate("document.readyState").await {
1022          let s = v.as_str().unwrap_or("loading");
1023          if s == "interactive" || s == "complete" {
1024            return Ok(());
1025          }
1026        }
1027        tokio::time::sleep(std::time::Duration::from_millis(16)).await;
1028      },
1029      "networkidle" => {
1030        // Wait for no pending network requests for 500ms.
1031        // Uses Performance API to detect network activity.
1032        let mut idle_since = tokio::time::Instant::now();
1033        let idle_threshold = std::time::Duration::from_millis(500);
1034        loop {
1035          if tokio::time::Instant::now() >= deadline {
1036            return Err(crate::error::FerriError::timeout("waiting for networkidle", timeout_ms));
1037          }
1038          // Check if there are pending resource loads
1039          let has_pending = self
1040            .inner
1041            .evaluate(
1042              "(function(){var p=performance.getEntriesByType('resource');\
1043             var now=performance.now();\
1044             return p.some(function(e){return e.responseEnd===0 || (now - e.responseEnd) < 100})})()",
1045            )
1046            .await
1047            .ok()
1048            .flatten();
1049          if has_pending == Some(serde_json::Value::Bool(true)) {
1050            idle_since = tokio::time::Instant::now();
1051          } else if tokio::time::Instant::now() - idle_since >= idle_threshold {
1052            return Ok(());
1053          }
1054          tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1055        }
1056      },
1057      _ => {
1058        // "load" -- wait for document.readyState === "complete"
1059        loop {
1060          if tokio::time::Instant::now() >= deadline {
1061            return Err(crate::error::FerriError::timeout("waiting for load state", timeout_ms));
1062          }
1063          if let Ok(Some(v)) = self.inner.evaluate("document.readyState").await {
1064            if v.as_str() == Some("complete") {
1065              return Ok(());
1066            }
1067          }
1068          tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1069        }
1070      },
1071    }
1072  }
1073
1074  // ── Screenshots ─────────────────────────────────────────────────────────
1075
1076  /// Take a screenshot of the page. Mirrors Playwright's
1077  /// `page.screenshot(options?: PageScreenshotOptions)` per
1078  /// `/tmp/playwright/packages/playwright-core/types/types.d.ts:23280`.
1079  ///
1080  /// Lowers the Playwright-shaped [`ScreenshotOptions`] bag into the
1081  /// backend-level [`ScreenshotOpts`] wire struct. Handles Rust-side
1082  /// concerns (writing `path` to disk, applying `timeout` via a
1083  /// `tokio::time::timeout` race) that don't belong in the per-backend
1084  /// dispatch path.
1085  ///
1086  /// # Errors
1087  ///
1088  /// Returns [`crate::error::FerriError::Timeout`] if the capture
1089  /// exceeds `opts.timeout` milliseconds; otherwise propagates any
1090  /// backend-specific failure.
1091  pub async fn screenshot(&self, opts: ScreenshotOptions) -> Result<Vec<u8>> {
1092    let format = match opts.format.as_deref() {
1093      Some("jpeg" | "jpg") => ImageFormat::Jpeg,
1094      Some("webp") => ImageFormat::Webp,
1095      _ => ImageFormat::Png,
1096    };
1097    let scale = match opts.scale.as_deref() {
1098      Some("css") => Some(crate::backend::ScreenshotScale::Css),
1099      Some("device") => Some(crate::backend::ScreenshotScale::Device),
1100      _ => None,
1101    };
1102    let animations = match opts.animations.as_deref() {
1103      Some("disabled") => Some(crate::backend::ScreenshotAnimations::Disabled),
1104      Some("allow") => Some(crate::backend::ScreenshotAnimations::Allow),
1105      _ => None,
1106    };
1107    let caret = match opts.caret.as_deref() {
1108      Some("hide") => Some(crate::backend::ScreenshotCaret::Hide),
1109      Some("initial") => Some(crate::backend::ScreenshotCaret::Initial),
1110      _ => None,
1111    };
1112    let wire = ScreenshotOpts {
1113      format,
1114      quality: opts.quality,
1115      full_page: opts.full_page.unwrap_or(false),
1116      clip: opts.clip,
1117      omit_background: opts.omit_background.unwrap_or(false),
1118      scale,
1119      animations,
1120      caret,
1121      mask: opts.mask.iter().map(|l| l.selector().to_string()).collect(),
1122      mask_color: opts.mask_color.clone(),
1123      style: opts.style.clone(),
1124    };
1125    let capture = async { self.inner.screenshot(wire).await };
1126    let bytes = match opts.timeout {
1127      Some(ms) if ms > 0 => {
1128        let fut = tokio::time::timeout(std::time::Duration::from_millis(ms), capture);
1129        match fut.await {
1130          Ok(res) => res?,
1131          Err(_) => {
1132            return Err(crate::error::FerriError::timeout("screenshot", ms));
1133          },
1134        }
1135      },
1136      _ => capture.await?,
1137    };
1138    if let Some(ref path) = opts.path {
1139      if let Some(parent) = path.parent() {
1140        let _ = std::fs::create_dir_all(parent);
1141      }
1142      std::fs::write(path, &bytes)
1143        .map_err(|e| crate::error::FerriError::Backend(format!("screenshot write {}: {e}", path.display())))?;
1144    }
1145    Ok(bytes)
1146  }
1147
1148  /// Take a screenshot of a specific element matching the selector.
1149  ///
1150  /// # Errors
1151  ///
1152  /// Returns an error if the element is not found or screenshot capture fails.
1153  pub async fn screenshot_element(self: &Arc<Self>, selector: &str) -> Result<Vec<u8>> {
1154    self.locator(selector, None).screenshot().await
1155  }
1156
1157  // ── PDF ─────────────────────────────────────────────────────────────────
1158
1159  /// Generate a PDF of the current page (Chrome-family backends only).
1160  ///
1161  /// Accepts the full Playwright `PDFOptions` surface via
1162  /// [`crate::options::PdfOptions`]. If `opts.path` is set, the rendered
1163  /// bytes are additionally written to that path (creating parent directories
1164  /// as needed) — mirroring Playwright's `page.pdf({ path })` behavior.
1165  ///
1166  /// # Errors
1167  ///
1168  /// Returns an error if PDF generation is not supported by the active
1169  /// backend (`WebKit` has no printToPDF analogue), if the paper format is
1170  /// unknown, if CDP rejects the parameters, or if writing to `path` fails.
1171  pub async fn pdf(&self, opts: crate::options::PdfOptions) -> Result<Vec<u8>> {
1172    let path = opts.path.clone();
1173    let bytes = self.inner.pdf(opts).await?;
1174    if let Some(path) = path {
1175      if let Some(parent) = path.parent() {
1176        if !parent.as_os_str().is_empty() {
1177          tokio::fs::create_dir_all(parent).await?;
1178        }
1179      }
1180      tokio::fs::write(&path, &bytes).await?;
1181    }
1182    Ok(bytes)
1183  }
1184
1185  // ── Snapshot ────────────────────────────────────────────────────────────
1186
1187  /// LLM-optimized accessibility snapshot with page context header, optional
1188  /// depth limiting, and incremental change tracking.
1189  ///
1190  /// Returns `SnapshotForAI`:
1191  /// - `full`: page header (URL, title, scroll, console errors) + accessibility tree
1192  /// - `incremental`: only changed/new nodes since last call with same `track` key
1193  /// - `ref_map`: element refs (e.g. "e5") to backend DOM node IDs
1194  ///
1195  /// Options:
1196  /// - `depth`: limits accessibility tree depth (-1 or None = unlimited).
1197  ///   Uses native CDP depth param on Chrome, `NSAccessibility` depth on `WebKit`.
1198  /// - `track`: enables incremental tracking per key.
1199  ///
1200  /// # Errors
1201  ///
1202  /// Returns an error if the accessibility snapshot cannot be built.
1203  pub async fn snapshot_for_ai(&self, opts: snapshot::SnapshotOptions) -> Result<snapshot::SnapshotForAI> {
1204    let mut tracker = self.snapshot_tracker.lock().await;
1205    Box::pin(snapshot::build_snapshot_for_ai(&self.inner, &opts, &mut tracker)).await
1206  }
1207
1208  /// Playwright `page.ariaSnapshot(options?): Promise<string>` — the
1209  /// full accessibility-tree text (the `full` field of the structured
1210  /// snapshot).
1211  ///
1212  /// # Errors
1213  ///
1214  /// Returns an error if the accessibility snapshot cannot be built.
1215  pub async fn aria_snapshot(&self, opts: snapshot::SnapshotOptions) -> Result<String> {
1216    Ok(Box::pin(self.snapshot_for_ai(opts)).await?.full)
1217  }
1218
1219  // ── Viewport ────────────────────────────────────────────────────────────
1220
1221  /// Set the viewport size by width and height.
1222  ///
1223  /// # Errors
1224  ///
1225  /// Returns an error if the viewport emulation fails.
1226  pub async fn set_viewport_size(&self, width: i64, height: i64) -> Result<()> {
1227    self
1228      .inner
1229      .emulate_viewport(&crate::options::ViewportConfig {
1230        width,
1231        height,
1232        ..Default::default()
1233      })
1234      .await
1235  }
1236
1237  // ── Input devices ───────────────────────────────────────────────────────
1238
1239  /// Click at specific coordinates.
1240  ///
1241  /// # Errors
1242  ///
1243  /// Returns an error if the click dispatch fails.
1244  pub async fn click_at(&self, x: f64, y: f64) -> Result<()> {
1245    self.inner.click_at(x, y).await?;
1246    *self
1247      .mouse_position
1248      .lock()
1249      .map_err(|e| crate::error::FerriError::backend(format!("mouse position lock poisoned: {e}")))? = (x, y);
1250    Ok(())
1251  }
1252
1253  /// Click at specific coordinates with button and click count options.
1254  ///
1255  /// # Errors
1256  ///
1257  /// Returns an error if the click dispatch fails.
1258  pub async fn click_at_opts(&self, x: f64, y: f64, button: &str, click_count: u32) -> Result<()> {
1259    self.inner.click_at_opts(x, y, button, click_count).await?;
1260    *self
1261      .mouse_position
1262      .lock()
1263      .map_err(|e| crate::error::FerriError::backend(format!("mouse position lock poisoned: {e}")))? = (x, y);
1264    Ok(())
1265  }
1266
1267  /// Move the mouse to specific coordinates.
1268  ///
1269  /// # Errors
1270  ///
1271  /// Returns an error if the mouse move dispatch fails.
1272  pub(crate) async fn move_mouse(&self, x: f64, y: f64) -> Result<()> {
1273    self.inner.move_mouse(x, y).await?;
1274    *self
1275      .mouse_position
1276      .lock()
1277      .map_err(|e| crate::error::FerriError::backend(format!("mouse position lock poisoned: {e}")))? = (x, y);
1278    Ok(())
1279  }
1280
1281  /// Move the mouse smoothly from one point to another over multiple steps.
1282  ///
1283  /// # Errors
1284  ///
1285  /// Returns an error if the mouse move dispatch fails.
1286  pub async fn move_mouse_smooth(&self, from_x: f64, from_y: f64, to_x: f64, to_y: f64, steps: u32) -> Result<()> {
1287    self.inner.move_mouse_smooth(from_x, from_y, to_x, to_y, steps).await?;
1288    *self
1289      .mouse_position
1290      .lock()
1291      .map_err(|e| crate::error::FerriError::backend(format!("mouse position lock poisoned: {e}")))? = (to_x, to_y);
1292    Ok(())
1293  }
1294
1295  /// Drag an element matching `source_selector` onto an element matching
1296  /// `target_selector`. Mirrors Playwright's
1297  /// `page.dragAndDrop(source, target, options)` per
1298  /// `/tmp/playwright/packages/playwright-core/types/types.d.ts:2486`.
1299  ///
1300  /// The [`crate::options::DragAndDropOptions::strict`] field, when set,
1301  /// overrides the default strict-mode of the source/target locators —
1302  /// `Some(true)` errors on multi-match, `Some(false)` allows the first
1303  /// match, `None` keeps the default (`strict = true`). All other fields
1304  /// are forwarded to [`crate::locator::Locator::drag_to`].
1305  ///
1306  /// # Errors
1307  ///
1308  /// Returns an error if either element cannot be found or the
1309  /// drag-and-drop operation fails.
1310  pub async fn drag_and_drop(
1311    self: &Arc<Self>,
1312    source_selector: &str,
1313    target_selector: &str,
1314    options: Option<crate::options::DragAndDropOptions>,
1315  ) -> Result<()> {
1316    let opts = options.unwrap_or_default();
1317    let source = self.locator(source_selector, None);
1318    let target = self.locator(target_selector, None);
1319    let (source, target) = match opts.strict {
1320      Some(s) => (source.strict(s), target.strict(s)),
1321      None => (source, target),
1322    };
1323    source.drag_to(&target, Some(opts)).await
1324  }
1325
1326  /// Dispatch a keyDown event for a single key (does NOT release it).
1327  ///
1328  /// # Errors
1329  ///
1330  /// Returns an error if the key down dispatch fails.
1331  pub(crate) async fn key_down(&self, key: &str) -> Result<()> {
1332    self.inner.key_down(key).await
1333  }
1334
1335  /// Dispatch a keyUp event for a single key.
1336  ///
1337  /// # Errors
1338  ///
1339  /// Returns an error if the key up dispatch fails.
1340  pub(crate) async fn key_up(&self, key: &str) -> Result<()> {
1341    self.inner.key_up(key).await
1342  }
1343
1344  /// Press a key or combo (e.g., "Enter", "Control+a").
1345  ///
1346  /// # Errors
1347  ///
1348  /// Returns an error if the key press dispatch fails.
1349  pub(crate) async fn press_key(&self, key: &str) -> Result<()> {
1350    self.inner.press_key(key).await
1351  }
1352
1353  /// Find element by CSS selector (raw backend access).
1354  ///
1355  /// # Errors
1356  ///
1357  /// Returns an error if the element is not found.
1358  pub async fn find_element(&self, selector: &str) -> Result<crate::backend::AnyElement> {
1359    self.inner.find_element(selector).await
1360  }
1361
1362  // ── Emulation ───────────────────────────────────────────────────────────
1363
1364  /// Apply a full [`crate::options::BrowserContextOptions`] bag to
1365  /// this page. The single entry point for context-level state —
1366  /// delegates to the backend's `apply_context_options` which fires
1367  /// every relevant protocol command in parallel and aggregates
1368  /// errors per field. Mirrors Playwright's pattern of storing the
1369  /// bag on the context and having each `FrameSession._initialize()`
1370  /// read from it on page open (see
1371  /// `/tmp/playwright/packages/playwright-core/src/server/chromium/crPage.ts:510-545`).
1372  ///
1373  /// `Box::pin`ned because the inner future composes 16 per-field
1374  /// `OptionFuture`s whose combined state machine is too large for
1375  /// an async-fn stack frame by clippy's default.
1376  ///
1377  /// # Errors
1378  ///
1379  /// Returns an aggregated error when one or more fields fail to
1380  /// apply. The aggregated message lists each failing field by name.
1381  pub async fn apply_context_options(&self, opts: &crate::options::BrowserContextOptions) -> Result<()> {
1382    Box::pin(self.inner.apply_context_options(opts)).await?;
1383    // Also stash the bag in shared state so subsequent reads (e.g.
1384    // `page.goto` resolving against the context's `baseURL`,
1385    // `request` fixture's per-request base URL) see the same values
1386    // the test runner just applied. Without this, calls like
1387    // `apply_page_config` would dispatch the CDP commands but the
1388    // bag stored at `BrowserContext` creation time stays empty,
1389    // leaving relative `page.goto('/route')` to fail with "Cannot
1390    // navigate to invalid URL".
1391    if let Some(ctx) = self.context_ref.as_ref() {
1392      let composite = ctx.key.to_composite();
1393      let state = ctx.state.read().await;
1394      let mut bag = state.get_context_options(&composite).unwrap_or_default();
1395      // Merge: keep prior fields the bag may carry; overwrite the
1396      // ones the caller specified. For now the merge is "callers
1397      // pass a fully populated bag" so a wholesale replace is fine
1398      // — keep this simple unless a real use-case needs deep merge.
1399      if opts.base_url.is_some() {
1400        bag.base_url.clone_from(&opts.base_url);
1401      }
1402      if opts.user_agent.is_some() {
1403        bag.user_agent.clone_from(&opts.user_agent);
1404      }
1405      if opts.viewport != crate::options::ViewportOption::default() {
1406        bag.viewport.clone_from(&opts.viewport);
1407      }
1408      if opts.locale.is_some() {
1409        bag.locale.clone_from(&opts.locale);
1410      }
1411      if opts.timezone_id.is_some() {
1412        bag.timezone_id.clone_from(&opts.timezone_id);
1413      }
1414      state.set_context_options(&composite, bag);
1415    }
1416    Ok(())
1417  }
1418
1419  // Context-level setters (setUserAgent, setLocale, setTimezone,
1420  // setGeolocation, setNetworkState, setBypassCSP,
1421  // setIgnoreCertificateErrors, setDownloadBehavior,
1422  // setHTTPCredentials, setServiceWorkersBlocked,
1423  // setJavaScriptEnabled, grantPermissions, resetPermissions,
1424  // setFocusEmulationEnabled, setStorageState) were removed. The
1425  // single entry point is [`Self::apply_context_options`] — matches
1426  // Playwright's public API where these are all properties of the
1427  // `BrowserContextOptions` bag, not page-level mutators. Context-
1428  // level setters (`context.setGeolocation` etc.) live on
1429  // [`crate::ContextRef`] and mutate the bag + re-apply to every
1430  // open page.
1431
1432  /// Emulate media features (color scheme, reduced motion, media type,
1433  /// forced-colors, contrast). Mirrors Playwright's
1434  /// `page.emulateMedia(options?)` — each call is a *partial update*
1435  /// applied on top of the page's persistent emulated-media state. A field
1436  /// set to `Some(value)` overrides; a field left `None` is unchanged.
1437  ///
1438  /// # Errors
1439  ///
1440  /// Returns an error if the backend rejects the media emulation.
1441  pub async fn emulate_media(&self, opts: &crate::options::EmulateMediaOptions) -> Result<()> {
1442    // Merge the incoming partial update with the page's persistent state.
1443    // An `Unchanged` field leaves the existing override alone; a `Disabled`
1444    // or `Set` field overwrites the stored state for that field.
1445    let merged = {
1446      let mut state = self
1447        .emulated_media
1448        .lock()
1449        .map_err(|e| crate::error::FerriError::Backend(format!("emulated_media lock poisoned: {e}")))?;
1450      if opts.media.is_specified() {
1451        state.media = opts.media.clone();
1452      }
1453      if opts.color_scheme.is_specified() {
1454        state.color_scheme = opts.color_scheme.clone();
1455      }
1456      if opts.reduced_motion.is_specified() {
1457        state.reduced_motion = opts.reduced_motion.clone();
1458      }
1459      if opts.forced_colors.is_specified() {
1460        state.forced_colors = opts.forced_colors.clone();
1461      }
1462      if opts.contrast.is_specified() {
1463        state.contrast = opts.contrast.clone();
1464      }
1465      state.clone()
1466    };
1467    self.inner.emulate_media(&merged).await
1468  }
1469
1470  /// Enable or disable JavaScript execution.
1471  ///
1472  /// # Errors
1473  /// Set extra HTTP headers that will be sent with every request.
1474  /// Playwright public: `page.setExtraHTTPHeaders(headers)`.
1475  ///
1476  /// # Errors
1477  ///
1478  /// Returns an error if the headers cannot be set.
1479  pub async fn set_extra_http_headers(&self, headers: &rustc_hash::FxHashMap<String, String>) -> Result<()> {
1480    self.inner.set_extra_http_headers(headers).await
1481  }
1482
1483  /// Set (or clear) the HTTP credentials answered to auth challenges.
1484  /// Backs [`crate::ContextRef::set_http_credentials`]
1485  /// (Playwright `browserContext.setHTTPCredentials(creds | null)`).
1486  ///
1487  /// # Errors
1488  ///
1489  /// Returns an error if the backend cannot apply the credentials.
1490  pub async fn set_http_credentials(&self, creds: Option<crate::options::HttpCredentials>) -> Result<()> {
1491    self.inner.set_http_credentials(creds).await
1492  }
1493
1494  // ── Tracing ─────────────────────────────────────────────────────────────
1495
1496  /// Start performance tracing.
1497  ///
1498  /// # Errors
1499  ///
1500  /// Returns an error if tracing cannot be started.
1501  pub async fn start_tracing(&self) -> Result<()> {
1502    self.inner.start_tracing().await
1503  }
1504
1505  /// Stop performance tracing.
1506  ///
1507  /// # Errors
1508  ///
1509  /// Returns an error if tracing cannot be stopped.
1510  pub async fn stop_tracing(&self) -> Result<()> {
1511    self.inner.stop_tracing().await
1512  }
1513
1514  /// Get performance metrics from the page.
1515  ///
1516  /// # Errors
1517  ///
1518  /// Returns an error if metrics cannot be retrieved.
1519  pub async fn metrics(&self) -> Result<Vec<crate::backend::MetricData>> {
1520    self.inner.metrics().await
1521  }
1522
1523  // ── Storage State ──────────────────────────────────────────────────────
1524
1525  /// Serialize the current page's storage state (cookies + localStorage) to JSON.
1526  ///
1527  /// Returns Playwright-compatible format:
1528  /// ```json
1529  /// {
1530  ///   "cookies": [{ "name": "...", "value": "...", "domain": "...", ... }],
1531  ///   "origins": [{ "origin": "https://...", "localStorage": [{ "name": "...", "value": "..." }] }]
1532  /// }
1533  /// ```
1534  ///
1535  /// Can be saved to a file and loaded later with `set_storage_state` or via
1536  /// test config `storage_state: "auth.json"`.
1537  ///
1538  /// # Errors
1539  ///
1540  /// Returns an error if cookies or localStorage cannot be retrieved.
1541  pub async fn storage_state(&self) -> Result<serde_json::Value> {
1542    let cookies = self.inner.get_cookies().await?;
1543    let cookies_json: Vec<serde_json::Value> = cookies
1544      .iter()
1545      .map(|c| {
1546        let mut obj = serde_json::json!({
1547          "name": c.name, "value": c.value, "domain": c.domain, "path": c.path,
1548          "secure": c.secure, "httpOnly": c.http_only
1549        });
1550        if let Some(expires) = c.expires {
1551          obj["expires"] = serde_json::json!(expires);
1552        }
1553        if let Some(same_site) = c.same_site {
1554          obj["sameSite"] = serde_json::json!(same_site.as_str());
1555        }
1556        obj
1557      })
1558      .collect();
1559
1560    // Get the current origin for localStorage grouping.
1561    let origin = self
1562      .inner
1563      .evaluate("location.origin")
1564      .await
1565      .ok()
1566      .flatten()
1567      .and_then(|v| v.as_str().map(str::to_string))
1568      .unwrap_or_default();
1569
1570    // Dump localStorage as array of { name, value } pairs (Playwright format).
1571    let storage_js = r"JSON.stringify(
1572      Object.keys(localStorage).map(k => ({ name: k, value: localStorage.getItem(k) }))
1573    )";
1574    let storage_r = self.inner.evaluate(storage_js).await.ok().flatten();
1575    let local_storage: Vec<serde_json::Value> = storage_r
1576      .and_then(|v| v.as_str().and_then(|s| serde_json::from_str(s).ok()))
1577      .unwrap_or_default();
1578
1579    let mut origins = Vec::new();
1580    if !local_storage.is_empty() && !origin.is_empty() {
1581      origins.push(serde_json::json!({
1582        "origin": origin,
1583        "localStorage": local_storage,
1584      }));
1585    }
1586
1587    Ok(serde_json::json!({
1588      "cookies": cookies_json,
1589      "origins": origins,
1590    }))
1591  }
1592
1593  /// Restore a previously saved storage state (cookies + localStorage).
1594  ///
1595  /// Accepts Playwright-compatible format with `origins[].localStorage[]` (name/value pairs).
1596  ///
1597  /// # Errors
1598  ///
1599  /// Returns an error if cookies or localStorage cannot be restored.
1600  pub async fn set_storage_state(&self, state: &serde_json::Value) -> Result<()> {
1601    // Restore cookies.
1602    if let Some(cookies) = state.get("cookies").and_then(|v| v.as_array()) {
1603      for c in cookies {
1604        let cookie = CookieData {
1605          name: c.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
1606          value: c.get("value").and_then(|v| v.as_str()).unwrap_or("").to_string(),
1607          domain: c.get("domain").and_then(|v| v.as_str()).unwrap_or("").to_string(),
1608          path: c.get("path").and_then(|v| v.as_str()).unwrap_or("/").to_string(),
1609          secure: c.get("secure").and_then(serde_json::Value::as_bool).unwrap_or(false),
1610          http_only: c.get("httpOnly").and_then(serde_json::Value::as_bool).unwrap_or(false),
1611          expires: c.get("expires").and_then(serde_json::Value::as_f64),
1612          same_site: c
1613            .get("sameSite")
1614            .and_then(|v| v.as_str())
1615            .and_then(|v| v.parse::<crate::backend::SameSite>().ok()),
1616          url: None,
1617        };
1618        self.inner.set_cookie(cookie).await?;
1619      }
1620    }
1621
1622    // Restore per-origin localStorage (Playwright format).
1623    if let Some(origins) = state.get("origins").and_then(|v| v.as_array()) {
1624      for origin_entry in origins {
1625        let origin = origin_entry.get("origin").and_then(|v| v.as_str()).unwrap_or("");
1626        if let Some(items) = origin_entry.get("localStorage").and_then(|v| v.as_array()) {
1627          // Navigate to the origin so localStorage.setItem works in the right scope.
1628          // Only navigate if the current page isn't already on this origin.
1629          let current_origin = self
1630            .inner
1631            .evaluate("location.origin")
1632            .await
1633            .ok()
1634            .flatten()
1635            .and_then(|v| v.as_str().map(str::to_string))
1636            .unwrap_or_default();
1637          if !origin.is_empty() && current_origin != origin {
1638            let _ = self
1639              .inner
1640              .goto(origin, crate::backend::NavLifecycle::Load, 10_000, None)
1641              .await;
1642          }
1643          for item in items {
1644            let key = item.get("name").and_then(|v| v.as_str()).unwrap_or("");
1645            let val = item.get("value").and_then(|v| v.as_str()).unwrap_or("");
1646            self
1647              .inner
1648              .evaluate(&format!(
1649                "localStorage.setItem('{}', '{}')",
1650                crate::steps::js_escape(key),
1651                crate::steps::js_escape(val)
1652              ))
1653              .await?;
1654          }
1655        }
1656      }
1657    }
1658
1659    Ok(())
1660  }
1661
1662  // ── Focus / dispatch ─────────────────────────────────────────────────
1663
1664  /// Focus an element by selector.
1665  ///
1666  /// # Errors
1667  ///
1668  /// Returns an error if the element is not found.
1669  pub async fn focus(self: &Arc<Self>, selector: &str) -> Result<()> {
1670    self.locator(selector, None).focus().await
1671  }
1672
1673  /// Dispatch an event on an element by selector.
1674  ///
1675  /// # Errors
1676  ///
1677  /// Returns an error if the element is not found or the event dispatch fails.
1678  pub async fn dispatch_event(
1679    self: &Arc<Self>,
1680    selector: &str,
1681    event_type: &str,
1682    event_init: Option<serde_json::Value>,
1683    opts: Option<crate::options::DispatchEventOptions>,
1684  ) -> Result<()> {
1685    self
1686      .locator(selector, None)
1687      .dispatch_event(event_type, event_init, opts)
1688      .await
1689  }
1690
1691  /// Check if an element is editable (not disabled, not readonly).
1692  ///
1693  /// # Errors
1694  ///
1695  /// Returns an error if the element is not found.
1696  pub async fn is_editable(self: &Arc<Self>, selector: &str) -> Result<bool> {
1697    self.locator(selector, None).is_editable().await
1698  }
1699
1700  // ── Waiting (additional) ────────────────────────────────────────────────
1701
1702  /// Wait for a JS function/expression to return a truthy value.
1703  ///
1704  /// # Errors
1705  ///
1706  /// Returns an error if the wait times out.
1707  pub async fn wait_for_function(&self, expression: &str, timeout_ms: Option<u64>) -> Result<serde_json::Value> {
1708    let timeout = timeout_ms.unwrap_or(self.default_timeout());
1709    let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(timeout);
1710    loop {
1711      if tokio::time::Instant::now() >= deadline {
1712        return Err(crate::error::FerriError::timeout(
1713          format!("waiting for function: {expression}"),
1714          timeout,
1715        ));
1716      }
1717      if let Ok(Some(val)) = self.inner.evaluate(expression).await {
1718        let truthy = match &val {
1719          serde_json::Value::Bool(b) => *b,
1720          serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0) != 0.0,
1721          serde_json::Value::String(s) => !s.is_empty(),
1722          serde_json::Value::Null => false,
1723          _ => true,
1724        };
1725        if truthy {
1726          return Ok(val);
1727        }
1728      }
1729      tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1730    }
1731  }
1732
1733  /// Wait for the page to navigate to a URL matching the pattern.
1734  ///
1735  /// # Errors
1736  ///
1737  /// Returns an error if the wait times out.
1738  pub async fn wait_for_navigation(&self, timeout_ms: Option<u64>) -> Result<()> {
1739    let timeout = timeout_ms.unwrap_or(self.default_timeout());
1740    let current = self.url();
1741    let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(timeout);
1742    loop {
1743      if tokio::time::Instant::now() >= deadline {
1744        return Err(crate::error::FerriError::timeout("waiting for navigation", timeout));
1745      }
1746      let now = self.url();
1747      if now != current {
1748        return Ok(());
1749      }
1750      tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1751    }
1752  }
1753
1754  // ── Mouse (low-level) ──────────────────────────────────────────────────
1755
1756  /// Scroll via mouse wheel event.
1757  ///
1758  /// # Errors
1759  ///
1760  /// Returns an error if the wheel event dispatch fails.
1761  pub(crate) async fn mouse_wheel(&self, delta_x: f64, delta_y: f64) -> Result<()> {
1762    self.inner.mouse_wheel(delta_x, delta_y).await
1763  }
1764
1765  /// Mouse button down (without up). For custom drag sequences.
1766  ///
1767  /// # Errors
1768  ///
1769  /// Returns an error if the mouse down dispatch fails.
1770  pub(crate) async fn mouse_down(&self, x: f64, y: f64, button: &str) -> Result<()> {
1771    self.inner.mouse_down(x, y, button).await?;
1772    *self
1773      .mouse_position
1774      .lock()
1775      .map_err(|e| crate::error::FerriError::backend(format!("mouse position lock poisoned: {e}")))? = (x, y);
1776    Ok(())
1777  }
1778
1779  /// Mouse button up.
1780  ///
1781  /// # Errors
1782  ///
1783  /// Returns an error if the mouse up dispatch fails.
1784  pub(crate) async fn mouse_up(&self, x: f64, y: f64, button: &str) -> Result<()> {
1785    self.inner.mouse_up(x, y, button).await?;
1786    *self
1787      .mouse_position
1788      .lock()
1789      .map_err(|e| crate::error::FerriError::backend(format!("mouse position lock poisoned: {e}")))? = (x, y);
1790    Ok(())
1791  }
1792
1793  /// Bring this page to front (focus).
1794  ///
1795  /// # Errors
1796  ///
1797  /// Returns an error if the page cannot be focused.
1798  pub async fn bring_to_front(&self) -> Result<()> {
1799    let _ = self.inner.evaluate("window.focus()").await;
1800    Ok(())
1801  }
1802
1803  // ── Frames (sync, Playwright parity — task 3.8) ──────────────────────
1804  //
1805  // Mirrors Playwright client/page.ts:258 (mainFrame), :273 (frames),
1806  // :262 (frame). All read from the page-owned `FrameCache` seeded by
1807  // [`Page::init_frame_cache`] and kept fresh by the listener task.
1808
1809  /// Main frame of this page. Mirrors Playwright's `page.mainFrame():
1810  /// Frame` (non-null).
1811  ///
1812  /// The cache is seeded one of three ways:
1813  /// 1. The frame listener spawned in `seed_frame_cache` picks up a
1814  ///    `FrameNavigated` event and writes `main_id`.
1815  /// 2. `goto` calls `ensure_frame_cache_seeded` after the
1816  ///    `Page.navigate` response lands, which seeds via the backend's
1817  ///    `peek_main_frame_id()` (no RTT).
1818  /// 3. Below: when this accessor is reached without (1) or (2) — for
1819  ///    example, an MCP tool that constructs a fresh `Page` wrapper
1820  ///    over an already-navigated inner page — we synchronously seed
1821  ///    from `peek_main_frame_id()` so the user-visible API never
1822  ///    panics on a live, navigated page.
1823  ///
1824  /// # Panics
1825  ///
1826  /// Panics only when the cache is empty AND the backend has never
1827  /// observed a top-level frame (i.e. no navigation has ever occurred
1828  /// on this inner page). This is genuine API misuse — Playwright
1829  /// itself can't return a `Frame` either before the first navigation.
1830  #[must_use]
1831  pub fn main_frame(self: &Arc<Self>) -> Frame {
1832    if let Some(id) = self.with_frame_cache(crate::frame_cache::FrameCache::main_frame_id) {
1833      return Frame::new(Arc::clone(self), id);
1834    }
1835    if let Some(fid) = self.inner.peek_main_frame_id() {
1836      if let Ok(mut g) = self.frame_cache.lock() {
1837        if g.main_frame_id().is_none() {
1838          g.attach(crate::backend::FrameInfo {
1839            frame_id: fid.clone(),
1840            parent_frame_id: None,
1841            name: String::new(),
1842            url: String::new(),
1843          });
1844        }
1845      }
1846      return Frame::new(Arc::clone(self), Arc::from(fid));
1847    }
1848    panic!(
1849      "Page::main_frame called before any navigation has occurred (no main frame id available from frame cache or backend)"
1850    )
1851  }
1852
1853  /// All non-detached frames attached to the page, main-frame first.
1854  /// Sync — reads the cache.
1855  #[must_use]
1856  pub fn frames(self: &Arc<Self>) -> Vec<Frame> {
1857    let ids: Vec<_> = self.with_frame_cache(|c| c.live_ids().collect());
1858    ids.into_iter().map(|id| Frame::new(Arc::clone(self), id)).collect()
1859  }
1860
1861  /// Locate a frame by name or URL. Sync — reads the cache.
1862  /// Playwright: `page.frame(string | { name?, url? })` — see
1863  /// `/tmp/playwright/packages/playwright-core/types/types.d.ts:2755`.
1864  ///
1865  /// # Panics
1866  ///
1867  /// Panics if `selector` specifies neither `name` nor `url` — matches
1868  /// Playwright's `assert(name || url, 'Either name or url matcher should be specified')`.
1869  #[must_use]
1870  pub fn frame(self: &Arc<Self>, selector: impl Into<FrameSelector>) -> Option<Frame> {
1871    let sel = selector.into();
1872    assert!(!sel.is_empty(), "Either name or url matcher should be specified");
1873    self.with_frame_cache(|c| {
1874      for id in c.live_ids() {
1875        let Some(rec) = c.record(&id) else { continue };
1876        if let Some(name) = &sel.name {
1877          if rec.info.name != *name {
1878            continue;
1879          }
1880        }
1881        if let Some(url) = &sel.url {
1882          if rec.info.url != *url {
1883            continue;
1884          }
1885        }
1886        return Some(Frame::new(Arc::clone(self), id));
1887      }
1888      None
1889    })
1890  }
1891
1892  // ── Events ────────────────────────────────────────────────────────────
1893
1894  /// Get the event emitter for subscribing to page events.
1895  #[must_use]
1896  pub fn events(&self) -> &EventEmitter {
1897    self.inner.events()
1898  }
1899
1900  /// Subscribe to page events. Calls the callback for each matching event.
1901  /// Returns a `ListenerId` for later removal with `off()`.
1902  ///
1903  /// ```ignore
1904  /// let id = page.on("response", Arc::new(|event| {
1905  ///     if let PageEvent::Response(r) = event {
1906  ///         println!("Response: {} {}", r.status, r.url);
1907  ///     }
1908  /// }));
1909  /// ```
1910  pub fn on(&self, event_name: &str, callback: crate::events::EventCallback) -> crate::events::ListenerId {
1911    self.lazy_enable_for_event(event_name);
1912    self.inner.events().on(event_name, callback)
1913  }
1914
1915  /// Subscribe to a single event, then auto-remove the listener.
1916  pub fn once(&self, event_name: &str, callback: crate::events::EventCallback) -> crate::events::ListenerId {
1917    self.lazy_enable_for_event(event_name);
1918    self.inner.events().once(event_name, callback)
1919  }
1920
1921  /// Some events depend on a backend command being fired (file
1922  /// chooser interception, download behaviour). When the user
1923  /// expresses interest, fire-and-forget the command in the
1924  /// background — best-effort; failure is silently swallowed and
1925  /// would surface via the user not getting the event. Mirrors
1926  /// Playwright's `_updateFileChooserInterception(false)` pattern
1927  /// where the command is async but fire-and-forget around listener
1928  /// registration (`crPage.ts:199`).
1929  fn lazy_enable_for_event(&self, event_name: &str) {
1930    let needs_filechooser = event_name == "filechooser";
1931    let needs_download = event_name == "download";
1932    if !needs_filechooser && !needs_download {
1933      return;
1934    }
1935    let inner_for_task: AnyPage = self.inner.clone();
1936    tokio::spawn(async move {
1937      if needs_filechooser {
1938        let _ = inner_for_task.enable_file_chooser_intercept().await;
1939      }
1940      if needs_download {
1941        let _ = inner_for_task.enable_download_behavior().await;
1942      }
1943    });
1944  }
1945
1946  /// Remove an event listener by ID.
1947  pub fn off(&self, id: crate::events::ListenerId) {
1948    self.inner.events().off(id);
1949  }
1950
1951  /// Remove all event listeners.
1952  pub fn remove_all_listeners(&self) {
1953    self.inner.events().remove_all_listeners();
1954  }
1955
1956  /// Start listening for a navigation event. Call BEFORE the action that triggers navigation.
1957  /// Returns a future that resolves when navigation completes.
1958  ///
1959  /// ```ignore
1960  /// let nav = page.expect_navigation(None);
1961  /// page.click("#link", None).await?;
1962  /// nav.await?; // resolves when navigation completes
1963  /// ```
1964  ///
1965  /// # Errors
1966  ///
1967  /// Returns an error if the navigation event does not occur within the timeout.
1968  pub fn expect_navigation(&self, timeout_ms: Option<u64>) -> impl std::future::Future<Output = Result<()>> + '_ {
1969    let timeout = timeout_ms.unwrap_or(self.default_timeout());
1970    let events = self.inner.events().clone();
1971    async move {
1972      events
1973        .wait_for(|e| matches!(e, PageEvent::Load | PageEvent::DomContentLoaded), timeout)
1974        .await?;
1975      Ok(())
1976    }
1977  }
1978
1979  /// Start listening for a response matching URL pattern. Call BEFORE the action.
1980  ///
1981  /// # Errors
1982  ///
1983  /// Returns an error if no matching response is received within the timeout.
1984  pub fn expect_response(
1985    &self,
1986    url_pattern: &str,
1987    timeout_ms: Option<u64>,
1988  ) -> impl std::future::Future<Output = Result<crate::network::Response>> + '_ {
1989    let timeout = timeout_ms.unwrap_or(self.default_timeout());
1990    let events = self.inner.events().clone();
1991    let pattern = url_pattern.to_string();
1992    async move {
1993      let event = events
1994        .wait_for(
1995          move |e| matches!(e, PageEvent::Response(r) if r.url().contains(&pattern)),
1996          timeout,
1997        )
1998        .await?;
1999      match event {
2000        PageEvent::Response(r) => Ok(r),
2001        _ => Err(crate::error::FerriError::backend(
2002          "event wait returned unexpected event type",
2003        )),
2004      }
2005    }
2006  }
2007
2008  /// Start listening for a request matching URL pattern. Call BEFORE the action.
2009  ///
2010  /// # Errors
2011  ///
2012  /// Returns an error if no matching request is received within the timeout.
2013  pub fn expect_request(
2014    &self,
2015    url_pattern: &str,
2016    timeout_ms: Option<u64>,
2017  ) -> impl std::future::Future<Output = Result<crate::network::Request>> + '_ {
2018    let timeout = timeout_ms.unwrap_or(self.default_timeout());
2019    let events = self.inner.events().clone();
2020    let pattern = url_pattern.to_string();
2021    async move {
2022      let event = events
2023        .wait_for(
2024          move |e| matches!(e, PageEvent::Request(r) if r.url().contains(&pattern)),
2025          timeout,
2026        )
2027        .await?;
2028      match event {
2029        PageEvent::Request(r) => Ok(r),
2030        _ => Err(crate::error::FerriError::backend(
2031          "event wait returned unexpected event type",
2032        )),
2033      }
2034    }
2035  }
2036
2037  /// Wait for a specific event (by name) with timeout.
2038  ///
2039  /// # Errors
2040  ///
2041  /// Returns an error if the event does not occur within the timeout.
2042  pub async fn wait_for_event(&self, event_name: &str, timeout_ms: Option<u64>) -> Result<PageEvent> {
2043    self
2044      .inner
2045      .events()
2046      .wait_for_event(event_name, timeout_ms.unwrap_or(self.default_timeout()))
2047      .await
2048  }
2049
2050  /// Wait for a network request matching a URL pattern.
2051  ///
2052  /// # Errors
2053  ///
2054  /// Returns an error if no matching request occurs within the timeout.
2055  pub async fn wait_for_request(
2056    &self,
2057    matcher: crate::url_matcher::UrlMatcher,
2058    timeout_ms: Option<u64>,
2059  ) -> Result<crate::network::Request> {
2060    let event = self
2061      .inner
2062      .events()
2063      .wait_for(
2064        move |e| matches!(e, PageEvent::Request(r) if matcher.matches(r.url())),
2065        timeout_ms.unwrap_or(self.default_timeout()),
2066      )
2067      .await?;
2068    match event {
2069      PageEvent::Request(r) => Ok(r),
2070      _ => Err(crate::error::FerriError::backend(
2071        "event wait returned unexpected event type",
2072      )),
2073    }
2074  }
2075
2076  /// Wait for a network response matching a URL pattern.
2077  ///
2078  /// # Errors
2079  ///
2080  /// Returns an error if no matching response occurs within the timeout.
2081  pub async fn wait_for_response(
2082    &self,
2083    matcher: crate::url_matcher::UrlMatcher,
2084    timeout_ms: Option<u64>,
2085  ) -> Result<crate::network::Response> {
2086    let event = self
2087      .inner
2088      .events()
2089      .wait_for(
2090        move |e| matches!(e, PageEvent::Response(r) if matcher.matches(r.url())),
2091        timeout_ms.unwrap_or(self.default_timeout()),
2092      )
2093      .await?;
2094    match event {
2095      PageEvent::Response(r) => Ok(r),
2096      _ => Err(crate::error::FerriError::backend(
2097        "event wait returned unexpected event type",
2098      )),
2099    }
2100  }
2101
2102  // ── Network Interception ────────────────────────────────────────────────
2103
2104  /// Intercept network requests matching a [`crate::url_matcher::UrlMatcher`].
2105  /// The handler receives a [`crate::route::Route`] and must call exactly one
2106  /// of `fulfill()`, `continue_route()`, or `abort()`.
2107  ///
2108  /// ```ignore
2109  /// use ferridriver::route::{Route, FulfillResponse};
2110  /// use ferridriver::url_matcher::UrlMatcher;
2111  /// use std::sync::Arc;
2112  ///
2113  /// // Mock an API endpoint
2114  /// page.route(UrlMatcher::glob("**/api/data")?, Arc::new(|route: Route| {
2115  ///     route.fulfill(FulfillResponse {
2116  ///         status: 200,
2117  ///         body: b"{\"mocked\": true}".to_vec(),
2118  ///         content_type: Some("application/json".into()),
2119  ///         ..Default::default()
2120  ///     });
2121  /// }), None).await?;
2122  ///
2123  /// // Block image loading
2124  /// page.route(UrlMatcher::glob("**/*.{png,jpg,gif}")?, Arc::new(|route: Route| {
2125  ///     route.abort("blockedbyclient");
2126  /// }), None).await?;
2127  /// ```
2128  ///
2129  /// Returns a [`crate::disposable::Disposable`] whose `dispose()` reverses
2130  /// the registration (equivalent to calling [`Page::unroute`] with the same
2131  /// matcher). Mirrors Playwright `page.route(...)` which returns a
2132  /// `DisposableStub` (`client/page.ts:535`).
2133  ///
2134  /// # Errors
2135  ///
2136  /// Returns an error if the route interception cannot be set up.
2137  pub async fn route(
2138    &self,
2139    matcher: crate::url_matcher::UrlMatcher,
2140    handler: crate::route::RouteHandler,
2141    times: Option<u32>,
2142  ) -> Result<crate::disposable::Disposable> {
2143    self.inner.route(matcher.clone(), handler, times).await?;
2144    let inner = self.inner.clone();
2145    Ok(crate::disposable::Disposable::new(move || async move {
2146      inner.unroute(&matcher).await
2147    }))
2148  }
2149
2150  /// Playwright: `page.routeFromHAR(har, options?)`. Replay recorded
2151  /// responses from a HAR file for matching requests. Replay-only; HAR
2152  /// recording (`update: true`) is not supported.
2153  ///
2154  /// # Errors
2155  ///
2156  /// Returns an error if the HAR file cannot be read/parsed or the route
2157  /// cannot be installed.
2158  pub async fn route_from_har(&self, path: &std::path::Path, options: crate::har::RouteFromHarOptions) -> Result<()> {
2159    let handler = crate::har::route_handler_from_file(path, options.not_found)?;
2160    let matcher = options.url.unwrap_or_else(crate::url_matcher::UrlMatcher::any);
2161    self.inner.route(matcher, handler, None).await
2162  }
2163
2164  /// Remove all route handlers whose matcher is
2165  /// [`crate::url_matcher::UrlMatcher::equivalent`] to the given matcher.
2166  ///
2167  /// # Errors
2168  ///
2169  /// Returns an error if the route handlers cannot be removed.
2170  pub async fn unroute(&self, matcher: &crate::url_matcher::UrlMatcher) -> Result<()> {
2171    self.inner.unroute(matcher).await
2172  }
2173
2174  /// Remove all route handlers registered via [`Page::route`].
2175  ///
2176  /// Mirrors Playwright's
2177  /// `page.unrouteAll({ behavior?: 'wait' | 'ignoreErrors' | 'default' })`.
2178  /// The `behavior` selects how to treat handlers still running when the
2179  /// call is made; ferridriver route handlers run synchronously inside the
2180  /// interception loop, so once the routes are cleared no detached handler
2181  /// task can still be in flight — every variant performs the same teardown
2182  /// (clear routes, disable interception).
2183  ///
2184  /// # Errors
2185  ///
2186  /// Returns an error if the underlying interception teardown fails.
2187  pub async fn unroute_all(&self, behavior: Option<crate::options::UnrouteBehavior>) -> Result<()> {
2188    self.inner.unroute_all(behavior.unwrap_or_default()).await
2189  }
2190
2191  // ── Interactive picker ──────────────────────────────────────────────────
2192
2193  /// Open the interactive locator picker: highlight elements under the
2194  /// cursor and resolve with a [`Locator`] for the element the user clicks.
2195  ///
2196  /// Mirrors Playwright's `page.pickLocator(): Promise<Locator>`. The picker
2197  /// generates a selector for the clicked element using the same engine that
2198  /// backs `codegen`/recording, then returns `page.locator(selector)`.
2199  ///
2200  /// Cancel an in-progress pick with [`Page::cancel_pick_locator`].
2201  ///
2202  /// # Errors
2203  ///
2204  /// Returns an error if the picker scaffolding cannot be injected, the page
2205  /// closes before a selection is made, or the returned selector is empty.
2206  pub async fn pick_locator(self: &Arc<Self>) -> Result<Locator> {
2207    self.inner.ensure_engine_injected().await?;
2208    self.inner.evaluate(Self::RECORDER_SUPPORT_JS).await?;
2209    self.inner.evaluate(Self::PICKER_JS).await?;
2210
2211    // Poll the page-side global for the picked selector. Playwright's
2212    // `pickLocator` waits indefinitely for the user to click; we mirror
2213    // that (no timeout) while honoring cancellation: when the picker is
2214    // torn down via `cancel_pick_locator`/`hide_highlight` without a
2215    // selection, `__fdPicker` flips to `false` and we surface a cancelled
2216    // error. Polling (rather than a cross-task exposed-function callback)
2217    // keeps engine teardown race-free on the QuickJS host.
2218    loop {
2219      let probe = self
2220        .inner
2221        .evaluate(
2222          "JSON.stringify({ \
2223             selector: (typeof window.__fdPickedSelector === 'string') ? window.__fdPickedSelector : null, \
2224             active: window.__fdPicker === true })",
2225        )
2226        .await?;
2227      let parsed: serde_json::Value = match probe {
2228        Some(serde_json::Value::String(s)) => serde_json::from_str(&s).unwrap_or(serde_json::Value::Null),
2229        Some(v) => v,
2230        None => serde_json::Value::Null,
2231      };
2232      if let Some(sel) = parsed.get("selector").and_then(serde_json::Value::as_str) {
2233        return Ok(self.locator(sel, None));
2234      }
2235      if !parsed
2236        .get("active")
2237        .and_then(serde_json::Value::as_bool)
2238        .unwrap_or(false)
2239      {
2240        return Err(crate::error::FerriError::interrupted(
2241          "pickLocator: cancelled before a selection was made",
2242        ));
2243      }
2244      tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2245    }
2246  }
2247
2248  /// Cancel an in-progress [`Page::pick_locator`] and hide its highlight.
2249  ///
2250  /// Mirrors Playwright's `page.cancelPickLocator()`. Any pending
2251  /// `pick_locator` future resolves with a `page closed`-style error once the
2252  /// page-side picker is torn down (the exposed callback is removed, so the
2253  /// oneshot sender drops).
2254  ///
2255  /// # Errors
2256  ///
2257  /// Returns an error if the page-side teardown evaluation fails.
2258  pub async fn cancel_pick_locator(&self) -> Result<()> {
2259    self
2260      .inner
2261      .evaluate("(function(){ if (window.__fdPickerCancel) window.__fdPickerCancel(); })()")
2262      .await?;
2263    let _ = self.inner.remove_exposed_function("__fdPickLocator").await;
2264    Ok(())
2265  }
2266
2267  /// Hide any highlight overlay currently shown by the picker or by
2268  /// `highlight`-style helpers.
2269  ///
2270  /// Mirrors Playwright's `page.hideHighlight()`.
2271  ///
2272  /// # Errors
2273  ///
2274  /// Returns an error if the page-side teardown evaluation fails.
2275  pub async fn hide_highlight(&self) -> Result<()> {
2276    self
2277      .inner
2278      .evaluate(
2279        "(function(){ var i = window.__fd && window.__fd._injected; \
2280         if (i && i.hideHighlight) i.hideHighlight(); \
2281         if (window.__fdPickerCancel) window.__fdPickerCancel(); })()",
2282      )
2283      .await?;
2284    Ok(())
2285  }
2286
2287  // ── Exposed Functions ───────────────────────────────────────────────────
2288
2289  /// Expose a Rust function to the page as `window.<name>(...)`.
2290  /// The page can call it as an async function and receive the return value.
2291  /// The exposed function persists across navigations.
2292  ///
2293  /// ```ignore
2294  /// use std::sync::Arc;
2295  ///
2296  /// page.expose_function("compute", Arc::new(|args| {
2297  ///     Box::pin(async move {
2298  ///         let x = args.first().and_then(|v| v.as_f64()).unwrap_or(0.0);
2299  ///         serde_json::json!(x * 2.0)
2300  ///     })
2301  /// })).await?;
2302  ///
2303  /// // In the page:
2304  /// // const result = await window.compute(21); // returns 42
2305  /// ```
2306  ///
2307  /// # Errors
2308  ///
2309  /// Returns an error if the function cannot be exposed to the page.
2310  pub async fn expose_function(&self, name: &str, func: crate::events::ExposedFn) -> Result<()> {
2311    self.inner.expose_function(name, func).await
2312  }
2313
2314  /// Remove a previously exposed function.
2315  ///
2316  /// # Errors
2317  ///
2318  /// Returns an error if the function cannot be removed.
2319  pub async fn remove_exposed_function(&self, name: &str) -> Result<()> {
2320    self.inner.remove_exposed_function(name).await
2321  }
2322
2323  // ── Script / Style injection ────────────────────────────────────────────
2324
2325  /// Add a `<script>` tag to the page. Provide either `url` (external) or `content` (inline).
2326  /// For URL scripts, waits for the script to load before returning.
2327  ///
2328  /// # Errors
2329  ///
2330  /// Returns an error if neither `url` nor `content` is provided, or if injection fails.
2331  pub async fn add_script_tag(
2332    &self,
2333    url: Option<&str>,
2334    content: Option<&str>,
2335    script_type: Option<&str>,
2336  ) -> Result<()> {
2337    let t = script_type.unwrap_or("text/javascript");
2338    if let Some(url) = url {
2339      self.inner.evaluate(&format!(
2340        "(function(){{return new Promise(function(r,j){{var s=document.createElement('script');\
2341         s.type='{}';s.src='{}';s.onload=r;s.onerror=function(){{j(new Error('Failed to load script'))}};document.head.appendChild(s)}})}})();",
2342        crate::steps::js_escape(t), crate::steps::js_escape(url)
2343      )).await?;
2344    } else if let Some(content) = content {
2345      self.inner.evaluate(&format!(
2346        "(function(){{var s=document.createElement('script');s.type='{}';s.text='{}';document.head.appendChild(s)}})()",
2347        crate::steps::js_escape(t), crate::steps::js_escape(content)
2348      )).await?;
2349    } else {
2350      return Err(crate::error::FerriError::invalid_argument(
2351        "url-or-content",
2352        "Provide either 'url' or 'content'",
2353      ));
2354    }
2355    Ok(())
2356  }
2357
2358  /// Add a `<style>` tag or `<link>` stylesheet to the page.
2359  /// Provide either `url` (external CSS) or `content` (inline CSS).
2360  /// For URL stylesheets, waits for the stylesheet to load before returning.
2361  ///
2362  /// # Errors
2363  ///
2364  /// Returns an error if neither `url` nor `content` is provided, or if injection fails.
2365  pub async fn add_style_tag(&self, url: Option<&str>, content: Option<&str>) -> Result<()> {
2366    if let Some(url) = url {
2367      self.inner.evaluate(&format!(
2368        "(function(){{return new Promise(function(r,j){{var l=document.createElement('link');\
2369         l.rel='stylesheet';l.href='{}';l.onload=r;l.onerror=function(){{j(new Error('Failed to load stylesheet'))}};document.head.appendChild(l)}})}})();",
2370        crate::steps::js_escape(url)
2371      )).await?;
2372    } else if let Some(content) = content {
2373      self
2374        .inner
2375        .evaluate(&format!(
2376          "(function(){{var s=document.createElement('style');s.textContent='{}';document.head.appendChild(s)}})()",
2377          crate::steps::js_escape(content)
2378        ))
2379        .await?;
2380    } else {
2381      return Err(crate::error::FerriError::invalid_argument(
2382        "url-or-content",
2383        "Provide either 'url' or 'content'",
2384      ));
2385    }
2386    Ok(())
2387  }
2388
2389  // ── Dialog handling ─────────────────────────────────────────────────────
2390  //
2391  // Dialogs (alert/confirm/prompt/beforeunload) are observed through
2392  // two equivalent surfaces:
2393  //
2394  // * [`Self::events`]`.on("dialog", cb)` — broadcast listener, live
2395  //   [`crate::dialog::Dialog`] handle delivered in the callback.
2396  //   Backed by the per-page [`crate::dialog::DialogManager`]'s
2397  //   emitter-bridge (installed once at page construction).
2398  // * [`Self::wait_for_dialog`] — one-shot async wait. Mirrors
2399  //   Playwright's `page.waitForEvent('dialog')` directly against
2400  //   the `DialogManager`; bypasses the broadcast entirely so the
2401  //   claim is synchronous with dialog open, matching Playwright's
2402  //   `addDialogHandler` semantics verbatim.
2403  //
2404  // If no handler claims a dialog at open time, the `DialogManager`
2405  // auto-closes it — accept for `beforeunload`, dismiss otherwise —
2406  // matching Playwright's `Dialog._close` branch.
2407
2408  /// Wait for the next dialog of any type, with a timeout. Returns
2409  /// the live [`crate::dialog::Dialog`] handle; the caller must then
2410  /// call `accept(...)` or `dismiss()` exactly once. Mirrors
2411  /// Playwright's `page.waitForEvent('dialog', { timeout })`.
2412  ///
2413  /// Registers a one-shot handler with the page's
2414  /// [`crate::dialog::DialogManager`] that claims the first dialog
2415  /// and delivers it here. The handler is removed automatically on
2416  /// resolve or timeout.
2417  ///
2418  /// # Errors
2419  ///
2420  /// Returns [`crate::error::FerriError::Timeout`] if no dialog opens
2421  /// within `timeout_ms`. Returns [`crate::error::FerriError::TargetClosed`]
2422  /// if the page closes before a dialog arrives.
2423  pub async fn wait_for_dialog(&self, timeout_ms: u64) -> Result<crate::dialog::Dialog> {
2424    use std::sync::Mutex;
2425    let (tx, rx) = tokio::sync::oneshot::channel::<crate::dialog::Dialog>();
2426    let tx = Arc::new(Mutex::new(Some(tx)));
2427    let tx_clone = tx.clone();
2428    let id = self.inner.dialog_manager().add_handler(Arc::new(move |dialog| {
2429      let mut guard = match tx_clone.lock() {
2430        Ok(g) => g,
2431        Err(p) => p.into_inner(),
2432      };
2433      match guard.take() {
2434        Some(sender) => {
2435          let _ = sender.send(dialog.clone());
2436          true
2437        },
2438        None => false,
2439      }
2440    }));
2441    let result = tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), rx).await;
2442    self.inner.dialog_manager().remove_handler(id);
2443    match result {
2444      Ok(Ok(dialog)) => Ok(dialog),
2445      Ok(Err(_)) => Err(crate::error::FerriError::target_closed(Some(
2446        "page closed while waiting for dialog".into(),
2447      ))),
2448      Err(_) => Err(crate::error::FerriError::timeout("waiting for dialog", timeout_ms)),
2449    }
2450  }
2451
2452  // ── File choosers (live handle, first-class) ────────────────────────────
2453  //
2454  // Symmetric with the dialog surface above:
2455  //
2456  // * [`Self::events`]`.on("filechooser", cb)` — broadcast listener,
2457  //   live [`crate::file_chooser::FileChooser`] handle delivered in
2458  //   the callback. Backed by the per-page
2459  //   [`crate::file_chooser::FileChooserManager`]'s emitter-bridge
2460  //   (installed once at `attach_listeners` time).
2461  // * [`Self::wait_for_file_chooser`] — one-shot async wait. Mirrors
2462  //   Playwright's `page.waitForEvent('filechooser')` directly against
2463  //   the `FileChooserManager`; bypasses the broadcast so the claim
2464  //   is synchronous with the chooser opening.
2465  //
2466  // If no handler claims at `did_open` time, the manager disposes the
2467  // captured `<input>` element handle — matches Playwright's
2468  // `server/page.ts::_onFileChooserOpened` no-listener branch.
2469
2470  /// Wait for the next file chooser to open, with a timeout. Returns
2471  /// the live [`crate::file_chooser::FileChooser`] handle; the caller
2472  /// may then call `set_files(...)` (or drop the handle to cancel the
2473  /// upload — the native picker was already suppressed by CDP's
2474  /// `Page.setInterceptFileChooserDialog`). Mirrors Playwright's
2475  /// `page.waitForEvent('filechooser', { timeout })`.
2476  ///
2477  /// Registers a one-shot handler with the page's
2478  /// [`crate::file_chooser::FileChooserManager`] that claims the
2479  /// first chooser and delivers it here. The handler is removed
2480  /// automatically on resolve or timeout.
2481  ///
2482  /// # Errors
2483  ///
2484  /// Returns [`crate::error::FerriError::Timeout`] if no file chooser
2485  /// opens within `timeout_ms`. Returns
2486  /// [`crate::error::FerriError::TargetClosed`] if the page closes
2487  /// before a chooser arrives.
2488  pub async fn wait_for_file_chooser(&self, timeout_ms: u64) -> Result<crate::file_chooser::FileChooser> {
2489    use std::sync::Mutex;
2490    // Lazy-enable file chooser interception. Idempotent — first
2491    // call fires `Page.setInterceptFileChooserDialog`, subsequent
2492    // are no-ops.
2493    self.inner.enable_file_chooser_intercept().await?;
2494    let (tx, rx) = tokio::sync::oneshot::channel::<crate::file_chooser::FileChooser>();
2495    let tx = Arc::new(Mutex::new(Some(tx)));
2496    let tx_clone = tx.clone();
2497    let id = self.inner.file_chooser_manager().add_handler(Arc::new(move |chooser| {
2498      let mut guard = match tx_clone.lock() {
2499        Ok(g) => g,
2500        Err(p) => p.into_inner(),
2501      };
2502      match guard.take() {
2503        Some(sender) => {
2504          let _ = sender.send(chooser.clone());
2505          true
2506        },
2507        None => false,
2508      }
2509    }));
2510    let result = tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), rx).await;
2511    self.inner.file_chooser_manager().remove_handler(id);
2512    match result {
2513      Ok(Ok(chooser)) => Ok(chooser),
2514      Ok(Err(_)) => Err(crate::error::FerriError::target_closed(Some(
2515        "page closed while waiting for filechooser".into(),
2516      ))),
2517      Err(_) => Err(crate::error::FerriError::timeout("waiting for filechooser", timeout_ms)),
2518    }
2519  }
2520
2521  // ── Downloads (live handle, first-class) ──────────────────────────────
2522  //
2523  // Symmetric with dialog / filechooser above.
2524  //
2525  // * [`Self::events`]`.on("download", cb)` — broadcast listener, live
2526  //   [`crate::download::Download`] handle delivered in the callback.
2527  //   Backed by the per-page
2528  //   [`crate::download::DownloadManager`]'s emitter-bridge (installed
2529  //   once at `attach_listeners` time).
2530  // * [`Self::wait_for_download`] — one-shot async wait. Mirrors
2531  //   Playwright's `page.waitForEvent('download')`. Registers a
2532  //   one-shot handler directly on the `DownloadManager`; the claim is
2533  //   synchronous with the backend's download-begin event, so there's
2534  //   no broadcast round-trip to race against.
2535  //
2536  // Unclaimed downloads are not auto-cancelled — Playwright's server
2537  // does the same (just emits the event and leaves the bytes in the
2538  // per-context `downloadsPath`). The per-page `downloads_dir` drop
2539  // cleans up orphan files on page close.
2540
2541  /// Wait for the next download, with a timeout. Returns the live
2542  /// [`crate::download::Download`] handle; the caller may then call
2543  /// `save_as(path)` / `path()` / `failure()` / `cancel()` / `delete()`.
2544  /// Mirrors Playwright's `page.waitForEvent('download', { timeout })`.
2545  ///
2546  /// Registers a one-shot handler with the page's
2547  /// [`crate::download::DownloadManager`]; the handler is removed
2548  /// automatically on resolve or timeout.
2549  ///
2550  /// # Errors
2551  ///
2552  /// Returns [`crate::error::FerriError::Timeout`] if no download
2553  /// begins within `timeout_ms`. Returns
2554  /// [`crate::error::FerriError::TargetClosed`] if the page closes
2555  /// before a download begins.
2556  pub async fn wait_for_download(&self, timeout_ms: u64) -> Result<crate::download::Download> {
2557    use std::sync::Mutex;
2558    // Lazy-enable download behaviour. Idempotent — first call fires
2559    // `Browser.setDownloadBehavior`, subsequent are no-ops.
2560    self.inner.enable_download_behavior().await?;
2561    let (tx, rx) = tokio::sync::oneshot::channel::<crate::download::Download>();
2562    let tx = Arc::new(Mutex::new(Some(tx)));
2563    let tx_clone = tx.clone();
2564    let id = self.inner.download_manager().add_handler(Arc::new(move |download| {
2565      let mut guard = match tx_clone.lock() {
2566        Ok(g) => g,
2567        Err(p) => p.into_inner(),
2568      };
2569      match guard.take() {
2570        Some(sender) => {
2571          let _ = sender.send(download.clone());
2572          true
2573        },
2574        None => false,
2575      }
2576    }));
2577    let result = tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), rx).await;
2578    self.inner.download_manager().remove_handler(id);
2579    match result {
2580      Ok(Ok(download)) => Ok(download),
2581      Ok(Err(_)) => Err(crate::error::FerriError::target_closed(Some(
2582        "page closed while waiting for download".into(),
2583      ))),
2584      Err(_) => Err(crate::error::FerriError::timeout("waiting for download", timeout_ms)),
2585    }
2586  }
2587
2588  // ── Init Scripts ────────────────────────────────────────────────────────
2589
2590  /// Register a script to run before any page JS on every navigation.
2591  /// Mirrors Playwright's `page.addInitScript(script, arg)` from
2592  /// `/tmp/playwright/packages/playwright-core/src/client/page.ts:520`.
2593  ///
2594  /// Accepts the full Playwright argument shape: a JS function body
2595  /// (pre-serialised via `fn.toString()` at the binding layer), a verbatim
2596  /// source string, a `{ path }`, or a `{ content }`. The optional `arg`
2597  /// is JSON-stringified and composed into a `(body)(arg)` wrapper for
2598  /// the `Function` variant; passing `arg` alongside any non-function
2599  /// variant is a Playwright-parity error (see [`crate::options::evaluation_script`]).
2600  ///
2601  /// Returns a [`crate::disposable::Disposable`] whose `dispose()` removes the
2602  /// injected script (equivalent to [`Page::remove_init_script`] with the
2603  /// generated identifier). Mirrors Playwright `page.addInitScript(...)` which
2604  /// returns a `Disposable` (`client/page.ts:532`).
2605  ///
2606  /// # Errors
2607  ///
2608  /// Returns an error if `evaluation_script` lowering fails (bad path, bad
2609  /// arg combination, JSON serialisation) or the backend injection fails.
2610  pub async fn add_init_script(
2611    &self,
2612    script: crate::options::InitScriptSource,
2613    arg: Option<serde_json::Value>,
2614  ) -> Result<crate::disposable::Disposable> {
2615    let source = crate::options::evaluation_script(script, arg.as_ref())?;
2616    let identifier = self.inner.add_init_script(&source).await?;
2617    let inner = self.inner.clone();
2618    Ok(crate::disposable::Disposable::new(move || async move {
2619      inner.remove_init_script(&identifier).await
2620    }))
2621  }
2622
2623  /// Remove a previously injected init script by identifier.
2624  ///
2625  /// # Errors
2626  ///
2627  /// Returns an error if the init script cannot be removed.
2628  pub async fn remove_init_script(&self, identifier: &str) -> Result<()> {
2629    self.inner.remove_init_script(identifier).await
2630  }
2631
2632  // ── Lifecycle ───────────────────────────────────────────────────────────
2633
2634  /// Close this page. After closing, most operations will fail.
2635  ///
2636  /// Accepts `Option<`[`crate::options::PageCloseOptions`]`>` — mirrors
2637  /// Playwright's `page.close({ runBeforeUnload, reason } = {})`.
2638  /// `runBeforeUnload=true` fires the page's `beforeunload` handlers
2639  /// before unloading. `reason`, if set, is stored on the `Page` and
2640  /// surfaces through any `TargetClosed` error returned to in-flight
2641  /// operations on this page. Pass `None` for the common no-options case.
2642  ///
2643  /// # Errors
2644  ///
2645  /// Returns an error if the page cannot be closed.
2646  #[tracing::instrument(skip(self, opts))]
2647  pub async fn close(&self, opts: Option<crate::options::PageCloseOptions>) -> Result<()> {
2648    let opts = opts.unwrap_or_default();
2649    if let Some(reason) = opts.reason.clone() {
2650      // Poisoned mutex is recoverable here — the stored reason is just
2651      // metadata for downstream `TargetClosed` errors, not a correctness-
2652      // critical invariant.
2653      if let Ok(mut guard) = self.close_reason.lock() {
2654        *guard = Some(reason);
2655      }
2656    }
2657    self.inner.close_page(opts).await?;
2658
2659    // Remove closed page from context's page list so context.pages() stays accurate.
2660    if let Some(ctx) = &self.context_ref {
2661      let mut state = ctx.state.write().await;
2662      if let Ok(browser_ctx) = state.context_mut_checked(&ctx.name) {
2663        browser_ctx.pages.retain(|p| !p.is_closed());
2664        if browser_ctx.active_page_idx >= browser_ctx.pages.len() && !browser_ctx.pages.is_empty() {
2665          browser_ctx.active_page_idx = browser_ctx.pages.len() - 1;
2666        }
2667      }
2668    }
2669
2670    Ok(())
2671  }
2672
2673  /// Reason passed to the most recent [`Page::close`] call, if any. Used by
2674  /// error-surfacing code to attach a human-readable explanation to
2675  /// `TargetClosed` errors emitted after close.
2676  #[must_use]
2677  pub fn close_reason(&self) -> Option<String> {
2678    self.close_reason.lock().ok().and_then(|g| g.clone())
2679  }
2680
2681  /// Check if this page has been closed.
2682  #[must_use]
2683  pub fn is_closed(&self) -> bool {
2684    self.inner.is_closed()
2685  }
2686
2687  /// Video handle for this page when recording is enabled on the
2688  /// owning context. Playwright:
2689  /// `/tmp/playwright/packages/playwright-core/types/types.d.ts:4756`
2690  /// — `video(): null | Video`. Returns `None` for pages in contexts
2691  /// that were not created with `recordVideo`. Recording is driven by
2692  /// `start_screencast`, which every backend (CDP, `BiDi`, Playwright
2693  /// `WebKit`) implements, so any context created with `recordVideo`
2694  /// yields a handle.
2695  #[must_use]
2696  pub fn video(&self) -> Option<Arc<crate::video::Video>> {
2697    self.video.lock().ok().and_then(|g| g.clone())
2698  }
2699
2700  /// Attach a [`crate::video::Video`] handle. Called by
2701  /// [`crate::state::BrowserState::register_opened_page`] when a page
2702  /// is opened in a `record_video`-enabled context. Silent no-op on
2703  /// mutex poisoning (non-correctness-critical; the handle simply
2704  /// won't be exposed).
2705  pub(crate) fn attach_video(&self, video: Arc<crate::video::Video>) {
2706    if let Ok(mut guard) = self.video.lock() {
2707      *guard = Some(video);
2708    }
2709  }
2710
2711  // ── Input device accessors ────────────────────────────────────────────
2712
2713  /// Get the Keyboard interface for this page.
2714  #[must_use]
2715  pub fn keyboard(&self) -> Keyboard<'_> {
2716    Keyboard { page: self }
2717  }
2718
2719  /// Get the Mouse interface for this page.
2720  #[must_use]
2721  pub fn mouse(&self) -> Mouse<'_> {
2722    Mouse { page: self }
2723  }
2724
2725  /// Get the Touchscreen interface for this page.
2726  #[must_use]
2727  pub fn touchscreen(&self) -> Touchscreen<'_> {
2728    Touchscreen { page: self }
2729  }
2730
2731  // ── Screencast (video recording) ──
2732
2733  /// Start CDP screencast. Returns a channel of `(jpeg_bytes, timestamp_secs)` pairs.
2734  /// Timestamp is Chrome's `metadata.timestamp` from the screencastFrame event.
2735  ///
2736  /// # Errors
2737  ///
2738  /// Returns an error if screencast cannot be started on the backend.
2739  pub async fn start_screencast(
2740    &self,
2741    quality: u8,
2742    max_width: u32,
2743    max_height: u32,
2744  ) -> Result<(
2745    tokio::sync::mpsc::UnboundedReceiver<(Vec<u8>, f64)>,
2746    tokio::sync::oneshot::Sender<()>,
2747  )> {
2748    self.inner.start_screencast(quality, max_width, max_height).await
2749  }
2750
2751  /// Stop CDP screencast.
2752  ///
2753  /// # Errors
2754  ///
2755  /// Returns an error if screencast cannot be stopped on the backend.
2756  pub async fn stop_screencast(&self) -> Result<()> {
2757    self.inner.stop_screencast().await
2758  }
2759}
2760
2761impl std::fmt::Debug for Page {
2762  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2763    f.debug_struct("Page").finish()
2764  }
2765}
2766
2767// ── Keyboard ──────────────────────────────────────────────────────────────
2768
2769/// Keyboard interface for a page. Mirrors Playwright's `page.keyboard`.
2770pub struct Keyboard<'a> {
2771  page: &'a Page,
2772}
2773
2774impl Keyboard<'_> {
2775  /// Dispatch a keyDown event. The key is held until `up()` is called.
2776  ///
2777  /// Supports modifier keys: "Shift", "Control", "Alt", "Meta".
2778  /// Holding Shift will type uppercase text via subsequent `press()` or `type()` calls.
2779  ///
2780  /// # Errors
2781  ///
2782  /// Returns an error if the key down dispatch fails.
2783  pub async fn down(&self, key: &str) -> Result<()> {
2784    self.page.key_down(key).await
2785  }
2786
2787  /// Dispatch a keyUp event for a previously held key.
2788  ///
2789  /// # Errors
2790  ///
2791  /// Returns an error if the key up dispatch fails.
2792  pub async fn up(&self, key: &str) -> Result<()> {
2793    self.page.key_up(key).await
2794  }
2795
2796  /// Press a key or key combination (e.g., "Enter", "Control+a", "Shift+ArrowDown").
2797  ///
2798  /// Shortcut for `down(key)` followed by `up(key)`. Supports `+` combinator for
2799  /// modifier combinations.
2800  ///
2801  /// # Errors
2802  ///
2803  /// Returns an error if the key press dispatch fails.
2804  pub async fn press(&self, key: &str, opts: Option<KeyboardPressOptions>) -> Result<()> {
2805    match opts.and_then(|o| o.delay) {
2806      // Playwright `delay` waits between keydown and keyup. Combos
2807      // ("Control+a") keep the atomic `press_key` path.
2808      Some(ms) if !key.contains('+') => {
2809        self.page.key_down(key).await?;
2810        tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
2811        self.page.key_up(key).await
2812      },
2813      _ => self.page.press_key(key).await,
2814    }
2815  }
2816
2817  /// Type text character by character with full keyboard events.
2818  ///
2819  /// Sends `keydown`, `keypress`/`input`, and `keyup` events for each character
2820  /// in the text. For characters not representable as single key presses,
2821  /// falls back to `insert_text` for that character.
2822  ///
2823  /// When `named_keys` is set, `{Name}` / `{Mod+Key}` sequences are parsed out
2824  /// of the text and dispatched as key presses (same format as `press`); `{{`
2825  /// types a literal `{`. Mirrors Playwright `keyboard.type({ namedKeys: true })`.
2826  ///
2827  /// # Errors
2828  ///
2829  /// Returns an error if the typing dispatch fails.
2830  pub async fn r#type(&self, text: &str, opts: Option<KeyboardTypeOptions>) -> Result<()> {
2831    let opts = opts.unwrap_or_default();
2832    let delay = opts.delay;
2833    let named_keys = opts.named_keys.unwrap_or(false);
2834    let mut first = true;
2835    for token in parse_named_keys(text, named_keys) {
2836      if let (false, Some(ms)) = (first, delay) {
2837        tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
2838      }
2839      first = false;
2840      match token {
2841        TypeToken::Key(key) => self.page.press_key(&key).await?,
2842        TypeToken::Char(ch) => self.page.press_key(&ch.to_string()).await?,
2843      }
2844    }
2845    Ok(())
2846  }
2847
2848  /// Insert text directly without emitting keyboard events.
2849  ///
2850  /// Only dispatches an `input` event. Modifier keys do NOT affect `insert_text`.
2851  /// Useful for inserting characters not available on a US keyboard layout.
2852  ///
2853  /// # Errors
2854  ///
2855  /// Returns an error if the text insertion fails.
2856  pub async fn insert_text(&self, text: &str) -> Result<()> {
2857    self.page.inner.insert_text(text).await
2858  }
2859}
2860
2861// ── Mouse ─────────────────────────────────────────────────────────────────
2862
2863/// Mouse interface for a page. Mirrors Playwright's `page.mouse`.
2864pub struct Mouse<'a> {
2865  page: &'a Page,
2866}
2867
2868impl Mouse<'_> {
2869  /// Click at coordinates.
2870  ///
2871  /// # Errors
2872  ///
2873  /// Returns an error if the click dispatch fails.
2874  pub async fn click(&self, x: f64, y: f64, opts: Option<MouseClickOptions>) -> Result<()> {
2875    let button = opts.as_ref().and_then(|o| o.button.as_deref()).unwrap_or("left");
2876    let count = opts.as_ref().and_then(|o| o.click_count).unwrap_or(1);
2877    match opts.as_ref().and_then(|o| o.delay) {
2878      Some(ms) => {
2879        self.page.move_mouse(x, y).await?;
2880        for _ in 0..count {
2881          self.page.mouse_down(x, y, button).await?;
2882          tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
2883          self.page.mouse_up(x, y, button).await?;
2884        }
2885        Ok(())
2886      },
2887      None => self.page.click_at_opts(x, y, button, count).await,
2888    }
2889  }
2890
2891  /// Move mouse to coordinates.
2892  ///
2893  /// # Errors
2894  ///
2895  /// Returns an error if the mouse move dispatch fails.
2896  pub async fn r#move(&self, x: f64, y: f64, steps: Option<u32>) -> Result<()> {
2897    match steps {
2898      Some(step_count) => {
2899        let (from_x, from_y) = *self
2900          .page
2901          .mouse_position
2902          .lock()
2903          .map_err(|e| crate::error::FerriError::backend(format!("mouse position lock poisoned: {e}")))?;
2904        self.page.move_mouse_smooth(from_x, from_y, x, y, step_count).await
2905      },
2906      None => self.page.move_mouse(x, y).await,
2907    }
2908  }
2909
2910  /// Double-click at coordinates.
2911  ///
2912  /// # Errors
2913  ///
2914  /// Returns an error if the click dispatch fails.
2915  pub async fn dblclick(&self, x: f64, y: f64, opts: Option<MouseClickOptions>) -> Result<()> {
2916    let button = opts.as_ref().and_then(|o| o.button.as_deref()).unwrap_or("left");
2917    self.page.move_mouse(x, y).await?;
2918    self.page.mouse_down(x, y, button).await?;
2919    self.page.mouse_up(x, y, button).await?;
2920    if let Some(ms) = opts.as_ref().and_then(|o| o.delay) {
2921      tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
2922    }
2923    self.page.mouse_down(x, y, button).await?;
2924    self.page.mouse_up(x, y, button).await?;
2925    Ok(())
2926  }
2927
2928  /// Press mouse button down at the current cursor position.
2929  ///
2930  /// # Errors
2931  ///
2932  /// Returns an error if the mouse down dispatch fails.
2933  pub async fn down(&self, opts: Option<MouseDownOptions>) -> Result<()> {
2934    let button = opts.as_ref().and_then(|o| o.button.as_deref()).unwrap_or("left");
2935    let (x, y) = *self
2936      .page
2937      .mouse_position
2938      .lock()
2939      .map_err(|e| crate::error::FerriError::backend(format!("mouse position lock poisoned: {e}")))?;
2940    self.page.mouse_down(x, y, button).await
2941  }
2942
2943  /// Release mouse button at the current cursor position.
2944  ///
2945  /// # Errors
2946  ///
2947  /// Returns an error if the mouse up dispatch fails.
2948  pub async fn up(&self, opts: Option<MouseUpOptions>) -> Result<()> {
2949    let button = opts.as_ref().and_then(|o| o.button.as_deref()).unwrap_or("left");
2950    let (x, y) = *self
2951      .page
2952      .mouse_position
2953      .lock()
2954      .map_err(|e| crate::error::FerriError::backend(format!("mouse position lock poisoned: {e}")))?;
2955    self.page.mouse_up(x, y, button).await
2956  }
2957
2958  /// Scroll via mouse wheel.
2959  ///
2960  /// # Errors
2961  ///
2962  /// Returns an error if the wheel event dispatch fails.
2963  pub async fn wheel(&self, delta_x: f64, delta_y: f64) -> Result<()> {
2964    self.page.mouse_wheel(delta_x, delta_y).await
2965  }
2966}
2967
2968/// Options for `Mouse.click()`.
2969#[derive(Debug, Clone, Default)]
2970pub struct MouseClickOptions {
2971  /// Mouse button: "left", "right", "middle"
2972  pub button: Option<String>,
2973  /// Click count (1=single, 2=double, 3=triple)
2974  pub click_count: Option<u32>,
2975  /// Milliseconds to wait between `mousedown` and `mouseup`
2976  /// (Playwright `delay`).
2977  pub delay: Option<u64>,
2978}
2979
2980/// Options for `Keyboard.press()` — Playwright `{ delay? }`.
2981#[derive(Debug, Clone, Default)]
2982pub struct KeyboardPressOptions {
2983  /// Milliseconds to wait between `keydown` and `keyup`.
2984  pub delay: Option<u64>,
2985}
2986
2987/// Options for `Keyboard.type()` — Playwright `{ delay?, namedKeys? }`.
2988#[derive(Debug, Clone, Default)]
2989pub struct KeyboardTypeOptions {
2990  /// Milliseconds to wait between key presses.
2991  pub delay: Option<u64>,
2992  /// When true, `{Name}` / `{Mod+Key}` sequences in the text are treated as key
2993  /// presses (same format as `Keyboard::press`). `{{` types a literal `{`.
2994  pub named_keys: Option<bool>,
2995}
2996
2997/// A single token produced by parsing `keyboard.type` text with `namedKeys`.
2998#[derive(Debug, Clone, PartialEq, Eq)]
2999enum TypeToken {
3000  /// A key name or combination to press (e.g. `Enter`, `Control+a`).
3001  Key(String),
3002  /// A literal character to type.
3003  Char(char),
3004}
3005
3006/// Tokenize `keyboard.type` text. When `named_keys` is false every character is
3007/// a `Char`. When true, `{Name}` becomes a `Key`, `{{` becomes a literal `{`,
3008/// and an unterminated `{` is treated as a literal `{`.
3009///
3010/// Mirrors Playwright `packages/playwright-core/src/server/input.ts::parseNamedKeys`.
3011fn parse_named_keys(text: &str, named_keys: bool) -> Vec<TypeToken> {
3012  if !named_keys {
3013    return text.chars().map(TypeToken::Char).collect();
3014  }
3015  let chars: Vec<char> = text.chars().collect();
3016  let mut result = Vec::new();
3017  let mut i = 0;
3018  while i < chars.len() {
3019    if chars[i] == '{' {
3020      if i + 1 < chars.len() && chars[i + 1] == '{' {
3021        result.push(TypeToken::Char('{'));
3022        i += 2;
3023      } else if let Some(offset) = chars[i + 1..].iter().position(|&c| c == '}') {
3024        let end = i + 1 + offset;
3025        let name: String = chars[i + 1..end].iter().collect();
3026        result.push(TypeToken::Key(name));
3027        i = end + 1;
3028      } else {
3029        result.push(TypeToken::Char('{'));
3030        i += 1;
3031      }
3032    } else {
3033      result.push(TypeToken::Char(chars[i]));
3034      i += 1;
3035    }
3036  }
3037  result
3038}
3039
3040/// Options for `Mouse.down()`.
3041#[derive(Debug, Clone, Default)]
3042pub struct MouseDownOptions {
3043  /// Mouse button: "left", "right", "middle"
3044  pub button: Option<String>,
3045  /// Click count for the event
3046  pub click_count: Option<u32>,
3047}
3048
3049/// Options for `Mouse.up()`.
3050#[derive(Debug, Clone, Default)]
3051pub struct MouseUpOptions {
3052  /// Mouse button: "left", "right", "middle"
3053  pub button: Option<String>,
3054  /// Click count for the event
3055  pub click_count: Option<u32>,
3056}
3057
3058// ── Touchscreen ───────────────────────────────────────────────────────────
3059
3060/// Touchscreen interface for a page. Mirrors Playwright's `page.touchscreen`.
3061pub struct Touchscreen<'a> {
3062  page: &'a Page,
3063}
3064
3065impl Touchscreen<'_> {
3066  /// Tap at coordinates. Uses Touch/TouchEvent on platforms that support them,
3067  /// falls back to `PointerEvent` + click on desktop (e.g. Playwright `WebKit`).
3068  ///
3069  /// # Errors
3070  ///
3071  /// Returns an error if the tap event dispatch fails.
3072  pub async fn tap(&self, x: f64, y: f64) -> Result<()> {
3073    // Playwright WebKit exposes `Touch` and `TouchEvent` as
3074    // constructors but throws "Illegal constructor" when JS tries to
3075    // instantiate them — they're internal-only on both Linux and
3076    // macOS. `typeof X !== 'undefined'` isn't enough; try the
3077    // actual construction in a try/catch and fall through on throw.
3078    self.page.inner.evaluate(&format!(
3079      "(function(){{var el=document.elementFromPoint({x},{y})||document.body;\
3080       var dispatched=false;\
3081       try{{\
3082         if(typeof Touch!=='undefined'&&typeof TouchEvent!=='undefined'){{\
3083           var t=new Touch({{identifier:1,target:el,clientX:{x},clientY:{y}}});\
3084           el.dispatchEvent(new TouchEvent('touchstart',{{touches:[t],changedTouches:[t],bubbles:true}}));\
3085           el.dispatchEvent(new TouchEvent('touchend',{{touches:[],changedTouches:[t],bubbles:true}}));\
3086           dispatched=true;\
3087         }}\
3088       }}catch(e){{}}\
3089       if(!dispatched){{\
3090         el.dispatchEvent(new PointerEvent('pointerdown',{{clientX:{x},clientY:{y},bubbles:true,isPrimary:true,pointerType:'touch'}}));\
3091         el.dispatchEvent(new PointerEvent('pointerup',{{clientX:{x},clientY:{y},bubbles:true,isPrimary:true,pointerType:'touch'}}));\
3092         el.click();\
3093       }}}})()"
3094    )).await?;
3095    Ok(())
3096  }
3097}
3098
3099/// Pattern-match the backend's "selector did not match any element"
3100/// error String so [`Page::query_selector`] can surface `Ok(None)` for
3101/// the missing-element case. Each backend uses a different message:
3102///
3103/// * CDP (`crates/ferridriver/src/backend/cdp/mod.rs`): `"'{selector}' not found"`
3104/// * `WebKit` (`crates/ferridriver/src/backend/webkit/mod.rs`): `"'{selector}' not found"`
3105/// * `BiDi` (`crates/ferridriver/src/backend/bidi/page.rs`): `"No element found for selector: {selector}"`
3106///
3107/// Other backend errors (protocol detach, target closed, invalid
3108/// selector) bubble up unmodified.
3109fn is_element_not_found(err: &crate::error::FerriError) -> bool {
3110  if let crate::error::FerriError::InvalidSelector { .. } = err {
3111    return true;
3112  }
3113  let lower = err.to_string().to_ascii_lowercase();
3114  lower.contains("not found") || lower.contains("no element found")
3115}
3116
3117#[cfg(test)]
3118mod tests {
3119  use super::*;
3120
3121  #[test]
3122  fn is_element_not_found_matches_every_backend_message() {
3123    use crate::error::FerriError;
3124    // CDP + WebKit message shape — typed InvalidSelector.
3125    assert!(is_element_not_found(&FerriError::invalid_selector(
3126      "button#primary",
3127      "not found"
3128    )));
3129    // BiDi message shape.
3130    assert!(is_element_not_found(&FerriError::invalid_selector(
3131      "button#primary",
3132      "no element found"
3133    )));
3134    // Free-form backend strings still classify if message matches.
3135    assert!(is_element_not_found(&FerriError::backend(
3136      "NO ELEMENT FOUND FOR SELECTOR: x"
3137    )));
3138    // Other errors bubble up unchanged.
3139    assert!(!is_element_not_found(&FerriError::backend("session detached")));
3140    assert!(!is_element_not_found(&FerriError::timeout_plain(30_000)));
3141  }
3142
3143  #[test]
3144  fn parse_named_keys_disabled_yields_only_chars() {
3145    assert_eq!(
3146      parse_named_keys("a{Enter}b", false),
3147      vec![
3148        TypeToken::Char('a'),
3149        TypeToken::Char('{'),
3150        TypeToken::Char('E'),
3151        TypeToken::Char('n'),
3152        TypeToken::Char('t'),
3153        TypeToken::Char('e'),
3154        TypeToken::Char('r'),
3155        TypeToken::Char('}'),
3156        TypeToken::Char('b'),
3157      ]
3158    );
3159  }
3160
3161  #[test]
3162  fn parse_named_keys_extracts_single_key() {
3163    assert_eq!(
3164      parse_named_keys("Hello{Enter}World", true),
3165      vec![
3166        TypeToken::Char('H'),
3167        TypeToken::Char('e'),
3168        TypeToken::Char('l'),
3169        TypeToken::Char('l'),
3170        TypeToken::Char('o'),
3171        TypeToken::Key("Enter".to_string()),
3172        TypeToken::Char('W'),
3173        TypeToken::Char('o'),
3174        TypeToken::Char('r'),
3175        TypeToken::Char('l'),
3176        TypeToken::Char('d'),
3177      ]
3178    );
3179  }
3180
3181  #[test]
3182  fn parse_named_keys_extracts_modifier_combo() {
3183    assert_eq!(
3184      parse_named_keys("{Control+a}x", true),
3185      vec![TypeToken::Key("Control+a".to_string()), TypeToken::Char('x')]
3186    );
3187  }
3188
3189  #[test]
3190  fn parse_named_keys_double_brace_is_literal() {
3191    assert_eq!(
3192      parse_named_keys("a{{b", true),
3193      vec![TypeToken::Char('a'), TypeToken::Char('{'), TypeToken::Char('b')]
3194    );
3195  }
3196
3197  #[test]
3198  fn parse_named_keys_double_brace_then_key() {
3199    // `{{` -> literal `{`, then `Enter}` is a plain char run (no opening brace).
3200    assert_eq!(
3201      parse_named_keys("{{Enter}", true),
3202      vec![
3203        TypeToken::Char('{'),
3204        TypeToken::Char('E'),
3205        TypeToken::Char('n'),
3206        TypeToken::Char('t'),
3207        TypeToken::Char('e'),
3208        TypeToken::Char('r'),
3209        TypeToken::Char('}'),
3210      ]
3211    );
3212  }
3213
3214  #[test]
3215  fn parse_named_keys_unterminated_brace_is_literal() {
3216    assert_eq!(
3217      parse_named_keys("a{bc", true),
3218      vec![
3219        TypeToken::Char('a'),
3220        TypeToken::Char('{'),
3221        TypeToken::Char('b'),
3222        TypeToken::Char('c'),
3223      ]
3224    );
3225  }
3226
3227  #[test]
3228  fn parse_named_keys_adjacent_keys() {
3229    assert_eq!(
3230      parse_named_keys("{Tab}{Enter}", true),
3231      vec![TypeToken::Key("Tab".to_string()), TypeToken::Key("Enter".to_string())]
3232    );
3233  }
3234}