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(¤t) {
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}