Skip to main content

ferridriver_script/bindings/
page.rs

1//! `PageJs`: JS wrapper around `ferridriver::Page`.
2//!
3//! Methods mirror `ferridriver::Page`'s public surface one-for-one; each is a
4//! small delegation that converts `FerriError` into `rquickjs::Error` at the
5//! boundary via [`super::convert::FerriResultExt`].
6
7use std::sync::Arc;
8use std::sync::atomic::{AtomicU64, Ordering};
9
10use ferridriver::Page;
11use rquickjs::JsLifetime;
12use rquickjs::class::Trace;
13
14use ferridriver::options::WaitOptions;
15use rquickjs::function::Opt;
16use serde::Deserialize;
17
18use crate::bindings::convert::{
19  FerriResultExt, extract_page_function, init_script_from_js, quickjs_arg_to_serialized, serde_from_js,
20  serialized_value_to_quickjs,
21};
22use crate::bindings::keyboard::KeyboardJs;
23use crate::bindings::locator::LocatorJs;
24use crate::bindings::mouse::MouseJs;
25
26/// Shape of `waitForSelector` options accepted from JS.
27#[derive(Debug, Default, Deserialize)]
28#[serde(default)]
29struct JsWaitOptions {
30  state: Option<String>,
31  timeout: Option<u64>,
32}
33
34pub(crate) fn parse_wait_options<'js>(
35  ctx: &rquickjs::Ctx<'js>,
36  value: Opt<rquickjs::Value<'js>>,
37) -> rquickjs::Result<WaitOptions> {
38  match value.0 {
39    Some(v) if !v.is_undefined() && !v.is_null() => {
40      let js: JsWaitOptions = serde_from_js(ctx, v)?;
41      Ok(WaitOptions {
42        state: js.state,
43        timeout: js.timeout,
44      })
45    },
46    _ => Ok(WaitOptions::default()),
47  }
48}
49
50#[derive(serde::Deserialize, Debug, Default)]
51#[serde(rename_all = "camelCase", default)]
52struct JsGotoOptions {
53  wait_until: Option<String>,
54  timeout: Option<u64>,
55  referer: Option<String>,
56}
57
58fn parse_goto_options<'js>(
59  ctx: &rquickjs::Ctx<'js>,
60  value: Opt<rquickjs::Value<'js>>,
61) -> rquickjs::Result<Option<ferridriver::options::GotoOptions>> {
62  match value.0 {
63    Some(v) if !v.is_undefined() && !v.is_null() => {
64      let js: JsGotoOptions = serde_from_js(ctx, v)?;
65      Ok(Some(ferridriver::options::GotoOptions {
66        wait_until: js.wait_until,
67        timeout: js.timeout,
68        referer: js.referer,
69      }))
70    },
71    _ => Ok(None),
72  }
73}
74
75#[derive(serde::Deserialize, Debug, Default)]
76#[serde(rename_all = "camelCase", default)]
77struct JsPageCloseOptions {
78  run_before_unload: Option<bool>,
79  reason: Option<String>,
80}
81
82/// Shape of `page.dragAndDrop` / `locator.dragTo` options. Mirrors
83/// Playwright's `FrameDragAndDropOptions & TimeoutOptions` per
84/// `/tmp/playwright/packages/playwright-core/types/types.d.ts:2486`.
85#[derive(serde::Deserialize, Debug, Default)]
86#[serde(rename_all = "camelCase", default)]
87pub(crate) struct JsDragAndDropOptions {
88  force: Option<bool>,
89  no_wait_after: Option<bool>,
90  source_position: Option<JsPoint>,
91  target_position: Option<JsPoint>,
92  steps: Option<u32>,
93  strict: Option<bool>,
94  timeout: Option<u64>,
95  trial: Option<bool>,
96}
97
98#[derive(serde::Deserialize, Debug, Default, Clone, Copy)]
99pub(crate) struct JsPoint {
100  x: f64,
101  y: f64,
102}
103
104impl From<JsPoint> for ferridriver::options::Point {
105  fn from(p: JsPoint) -> Self {
106    Self { x: p.x, y: p.y }
107  }
108}
109
110/// Parse the Playwright-shaped `emulateMedia` options bag from a
111/// `rquickjs::Value`. Unlike `serde_from_js`, this walks the JS object
112/// manually so we can distinguish three states for every field:
113///
114/// * absent → [`MediaOverride::Unchanged`]
115/// * explicit `null` → [`MediaOverride::Disabled`]
116/// * string value → [`MediaOverride::Set`]
117///
118/// serde-based deserialization conflates `undefined` and `null` into a
119/// single `Option::None`, which breaks the Playwright null-disables-the-
120/// override contract. See `/tmp/playwright/packages/playwright-core/types/types.d.ts:2580`
121/// for the `T | null | undefined` shape we're mirroring.
122fn parse_emulate_media_field<'js>(
123  obj: &rquickjs::Object<'js>,
124  key: &str,
125) -> rquickjs::Result<ferridriver::options::MediaOverride> {
126  use ferridriver::options::MediaOverride;
127  if !obj.contains_key(key)? {
128    return Ok(MediaOverride::Unchanged);
129  }
130  let val: rquickjs::Value<'js> = obj.get(key)?;
131  if val.is_undefined() {
132    Ok(MediaOverride::Unchanged)
133  } else if val.is_null() {
134    Ok(MediaOverride::Disabled)
135  } else if let Some(s) = val.as_string() {
136    Ok(MediaOverride::Set(s.to_string()?))
137  } else {
138    Err(rquickjs::Error::new_from_js_message(
139      "emulateMedia options",
140      "field",
141      format!("{key}: expected null, undefined, or string"),
142    ))
143  }
144}
145
146pub(crate) fn parse_emulate_media_options<'js>(
147  _ctx: &rquickjs::Ctx<'js>,
148  value: Opt<rquickjs::Value<'js>>,
149) -> rquickjs::Result<ferridriver::options::EmulateMediaOptions> {
150  let Some(v) = value.0.filter(|v| !v.is_undefined() && !v.is_null()) else {
151    return Ok(ferridriver::options::EmulateMediaOptions::default());
152  };
153  let Some(obj) = v.as_object() else {
154    return Ok(ferridriver::options::EmulateMediaOptions::default());
155  };
156  Ok(ferridriver::options::EmulateMediaOptions {
157    media: parse_emulate_media_field(obj, "media")?,
158    color_scheme: parse_emulate_media_field(obj, "colorScheme")?,
159    reduced_motion: parse_emulate_media_field(obj, "reducedMotion")?,
160    forced_colors: parse_emulate_media_field(obj, "forcedColors")?,
161    contrast: parse_emulate_media_field(obj, "contrast")?,
162  })
163}
164
165pub(crate) fn parse_drag_options<'js>(
166  ctx: &rquickjs::Ctx<'js>,
167  value: Opt<rquickjs::Value<'js>>,
168) -> rquickjs::Result<Option<ferridriver::options::DragAndDropOptions>> {
169  match value.0 {
170    Some(v) if !v.is_undefined() && !v.is_null() => {
171      let js: JsDragAndDropOptions = serde_from_js(ctx, v)?;
172      Ok(Some(ferridriver::options::DragAndDropOptions {
173        force: js.force,
174        no_wait_after: js.no_wait_after,
175        source_position: js.source_position.map(Into::into),
176        target_position: js.target_position.map(Into::into),
177        steps: js.steps,
178        strict: js.strict,
179        timeout: js.timeout,
180        trial: js.trial,
181      }))
182    },
183    _ => Ok(None),
184  }
185}
186
187fn parse_page_close_options<'js>(
188  ctx: &rquickjs::Ctx<'js>,
189  value: Opt<rquickjs::Value<'js>>,
190) -> rquickjs::Result<Option<ferridriver::options::PageCloseOptions>> {
191  match value.0 {
192    Some(v) if !v.is_undefined() && !v.is_null() => {
193      let js: JsPageCloseOptions = serde_from_js(ctx, v)?;
194      Ok(Some(ferridriver::options::PageCloseOptions {
195        run_before_unload: js.run_before_unload,
196        reason: js.reason,
197      }))
198    },
199    _ => Ok(None),
200  }
201}
202
203/// Native registry for every page JS callback dispatched cross-task
204/// (outside the QuickJS context, from a backend tokio task): `page.route`
205/// handlers + URL predicates (keyed by registration id), `page.exposeFunction`
206/// callbacks (keyed by binding name), and the single `page.startScreencast`
207/// frame callback. All kept as `Persistent<Function>` in context
208/// userdata — no `globalThis.__fd*`, exactly the `Persistent`/userdata
209/// pattern the extension registry uses.
210///
211/// Single-threaded VM ⇒ `RefCell`, never `Arc`/`Mutex` (same rationale
212/// as `BddUserData`).
213#[derive(Default)]
214pub(crate) struct PageCallbacks {
215  route_handlers: rustc_hash::FxHashMap<u64, rquickjs::Persistent<rquickjs::Function<'static>>>,
216  route_preds: rustc_hash::FxHashMap<u64, rquickjs::Persistent<rquickjs::Function<'static>>>,
217  exposed: rustc_hash::FxHashMap<String, rquickjs::Persistent<rquickjs::Function<'static>>>,
218  screencast: Option<rquickjs::Persistent<rquickjs::Function<'static>>>,
219  /// `addLocatorHandler` JS callbacks, keyed by core-registry uid so the
220  /// cross-task dispatch bridge can restore the persisted function.
221  locator_handlers: rustc_hash::FxHashMap<u64, rquickjs::Persistent<rquickjs::Function<'static>>>,
222}
223
224impl PageCallbacks {
225  pub(crate) fn insert_route_handler(&mut self, id: u64, f: rquickjs::Persistent<rquickjs::Function<'static>>) {
226    self.route_handlers.insert(id, f);
227  }
228
229  pub(crate) fn insert_route_pred(&mut self, id: u64, f: rquickjs::Persistent<rquickjs::Function<'static>>) {
230    self.route_preds.insert(id, f);
231  }
232
233  pub(crate) fn get_route_handler(&self, id: u64) -> Option<rquickjs::Persistent<rquickjs::Function<'static>>> {
234    self.route_handlers.get(&id).cloned()
235  }
236
237  pub(crate) fn get_route_pred(&self, id: u64) -> Option<rquickjs::Persistent<rquickjs::Function<'static>>> {
238    self.route_preds.get(&id).cloned()
239  }
240
241  pub(crate) fn route_preds_snapshot(&self) -> Vec<(u64, rquickjs::Persistent<rquickjs::Function<'static>>)> {
242    self.route_preds.iter().map(|(k, v)| (*k, v.clone())).collect()
243  }
244
245  pub(crate) fn remove_route(&mut self, id: u64) {
246    self.route_preds.remove(&id);
247    self.route_handlers.remove(&id);
248  }
249
250  pub(crate) fn remove_locator_handler(&mut self, id: u64) {
251    self.locator_handlers.remove(&id);
252  }
253}
254
255pub(crate) struct PageCallbacksUd(std::cell::RefCell<PageCallbacks>);
256
257// SAFETY: holds only `'static` data (`Persistent<…>` handles), so
258// re-stating the unused `'js` lifetime is sound — identical rationale to
259// `BddUserData` / `SessionAsyncCtx`.
260#[allow(unsafe_code)]
261unsafe impl rquickjs::JsLifetime<'_> for PageCallbacksUd {
262  type Changed<'to> = PageCallbacksUd;
263}
264
265/// Ensure the page-callbacks userdata exists on this context.
266/// Idempotent; called at `Session::create` and defensively from the
267/// `page.route` / `exposeFunction` / `startScreencast` bindings.
268pub(crate) fn ensure_page_callbacks(ctx: &rquickjs::Ctx<'_>) {
269  if ctx.userdata::<PageCallbacksUd>().is_none() {
270    let _ = ctx.store_userdata(PageCallbacksUd(std::cell::RefCell::new(PageCallbacks::default())));
271  }
272}
273
274pub(crate) fn with_page_callbacks<R>(
275  ctx: &rquickjs::Ctx<'_>,
276  f: impl FnOnce(&mut PageCallbacks) -> R,
277) -> rquickjs::Result<R> {
278  ensure_page_callbacks(ctx);
279  let ud = ctx.userdata::<PageCallbacksUd>().ok_or_else(|| {
280    rquickjs::Error::new_from_js_message("page", "Error", "page callbacks registry missing".to_string())
281  })?;
282  let mut reg = ud.0.borrow_mut();
283  Ok(f(&mut reg))
284}
285
286/// Stash an exposed-binding JS callback keyed by binding name. Shared
287/// by `page.exposeFunction` and `context.exposeBinding` /
288/// `context.exposeFunction` — both inject `window[name]`, so a single
289/// name-keyed registry suffices. Exposed `pub(crate)` so the context
290/// binding (in a sibling module) can reuse the same userdata.
291pub(crate) fn insert_exposed_callback(
292  ctx: &rquickjs::Ctx<'_>,
293  name: String,
294  cb: rquickjs::Persistent<rquickjs::Function<'static>>,
295) -> rquickjs::Result<()> {
296  with_page_callbacks(ctx, |r| r.exposed.insert(name, cb))?;
297  Ok(())
298}
299
300/// Look up a previously stashed exposed-binding callback by name.
301pub(crate) fn get_exposed_callback(
302  ctx: &rquickjs::Ctx<'_>,
303  name: &str,
304) -> rquickjs::Result<Option<rquickjs::Persistent<rquickjs::Function<'static>>>> {
305  with_page_callbacks(ctx, |r| r.exposed.get(name).cloned())
306}
307
308fn parse_unroute_behavior(behavior: &str) -> rquickjs::Result<ferridriver::options::UnrouteBehavior> {
309  match behavior {
310    "default" => Ok(ferridriver::options::UnrouteBehavior::Default),
311    "wait" => Ok(ferridriver::options::UnrouteBehavior::Wait),
312    "ignoreErrors" => Ok(ferridriver::options::UnrouteBehavior::IgnoreErrors),
313    other => Err(rquickjs::Error::new_from_js_message(
314      "unrouteAll options",
315      "behavior",
316      format!("invalid behavior {other:?} (expected 'wait', 'ignoreErrors', or 'default')"),
317    )),
318  }
319}
320
321/// Extract the `times` field from a `route(url, handler, { times })` options
322/// bag. Absent/undefined options or a missing `times` yields `None`
323/// (unlimited). Shared by `page.route` and `context.route`.
324pub(crate) fn parse_route_times(
325  options: &rquickjs::function::Opt<rquickjs::Value<'_>>,
326) -> rquickjs::Result<Option<u32>> {
327  let Some(v) = options.0.as_ref() else { return Ok(None) };
328  if v.is_undefined() || v.is_null() {
329    return Ok(None);
330  }
331  let Some(obj) = v.as_object() else { return Ok(None) };
332  let t: rquickjs::Value<'_> = obj.get("times")?;
333  if t.is_undefined() || t.is_null() {
334    return Ok(None);
335  }
336  #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
337  Ok(t.as_number().map(|n| if n < 0.0 { 0 } else { n as u32 }))
338}
339
340/// Parse the `{ url?, notFound? }` options bag for `routeFromHAR`. Shared by
341/// `page.routeFromHAR` and `context.routeFromHAR`. `url` is a glob string.
342pub(crate) fn parse_har_options(
343  options: &rquickjs::function::Opt<rquickjs::Value<'_>>,
344) -> rquickjs::Result<ferridriver::har::RouteFromHarOptions> {
345  let mut out = ferridriver::har::RouteFromHarOptions::default();
346  let Some(v) = options.0.as_ref() else { return Ok(out) };
347  let Some(obj) = v.as_object() else { return Ok(out) };
348  let url: rquickjs::Value<'_> = obj.get("url")?;
349  if let Some(s) = url.as_string() {
350    let glob = s.to_string()?;
351    out.url = Some(
352      ferridriver::url_matcher::UrlMatcher::glob(glob)
353        .map_err(|e| rquickjs::Error::new_from_js_message("routeFromHAR", "url", format!("invalid url glob: {e}")))?,
354    );
355  }
356  let nf: rquickjs::Value<'_> = obj.get("notFound")?;
357  if let Some(s) = nf.as_string() {
358    match s.to_string()?.as_str() {
359      "fallback" => out.not_found = ferridriver::har::HarNotFound::Fallback,
360      "abort" => out.not_found = ferridriver::har::HarNotFound::Abort,
361      other => {
362        return Err(rquickjs::Error::new_from_js_message(
363          "routeFromHAR",
364          "notFound",
365          format!("invalid notFound {other:?} (expected 'abort' or 'fallback')"),
366        ));
367      },
368    }
369  }
370  Ok(out)
371}
372
373/// JS-visible wrapper around [`ferridriver::Page`].
374///
375/// Held as `Arc<Page>` so the same page can be shared with the MCP session
376/// while the script runs; dropping the wrapper does not close the page.
377#[derive(JsLifetime, Trace)]
378#[rquickjs::class(rename = "Page")]
379pub struct PageJs {
380  // rquickjs requires fields to implement Trace/JsLifetime; Arc<Page> does
381  // not, and there's nothing inside a Page that holds JS values. Mark with
382  // `#[qjs(skip_trace)]` so the macro skips tracing this field.
383  #[qjs(skip_trace)]
384  inner: Arc<Page>,
385  /// `AsyncContext` used by `page.route` to dispatch JS callbacks
386  /// from a separate tokio task back into the script's JS context.
387  /// `None` only when the wrapper was constructed directly (e.g. by
388  /// tests); the engine always installs PageJs via
389  /// `install_page` which sets this field.
390  #[qjs(skip_trace)]
391  async_ctx: Option<rquickjs::AsyncContext>,
392  /// Per-page route registration counter. Each `page.route(matcher, fn)`
393  /// gets a unique numeric ID; the handler/predicate are stored in the
394  /// native `RouteRegistry` userdata under that ID and the Rust handler
395  /// dispatches by ID via the AsyncContext.
396  #[qjs(skip_trace)]
397  next_route_id: Arc<AtomicU64>,
398  /// Core `UrlMatcher` registered for each function-predicate `page.route`,
399  /// keyed by the same id as the native `RouteRegistry` handler/predicate
400  /// entries. A predicate route registers an always-true matcher whose
401  /// `Arc` identity lets `unroute(fn)` remove exactly that registration
402  /// (core compares `UrlMatcher::Predicate` by `Arc::ptr_eq`). Shared so
403  /// `route` and `unroute` on the same `Page` wrapper see one table.
404  #[qjs(skip_trace)]
405  route_matchers: Arc<std::sync::Mutex<rustc_hash::FxHashMap<u64, ferridriver::url_matcher::UrlMatcher>>>,
406  /// Maps a handler locator's selector to the persisted-callback ids so
407  /// `removeLocatorHandler` can drop them. (QuickJS `addLocatorHandler`
408  /// itself is Unsupported -- see its binding -- so this normally stays empty.)
409  #[qjs(skip_trace)]
410  locator_handler_ids: Arc<std::sync::Mutex<rustc_hash::FxHashMap<String, Vec<u64>>>>,
411}
412
413impl PageJs {
414  #[must_use]
415  pub fn new(inner: Arc<Page>) -> Self {
416    Self {
417      inner,
418      async_ctx: None,
419      next_route_id: Arc::new(AtomicU64::new(0)),
420      route_matchers: Arc::new(std::sync::Mutex::new(rustc_hash::FxHashMap::default())),
421      locator_handler_ids: Arc::new(std::sync::Mutex::new(rustc_hash::FxHashMap::default())),
422    }
423  }
424
425  #[must_use]
426  pub fn new_with_async_ctx(inner: Arc<Page>, async_ctx: rquickjs::AsyncContext) -> Self {
427    Self {
428      inner,
429      async_ctx: Some(async_ctx),
430      next_route_id: Arc::new(AtomicU64::new(0)),
431      route_matchers: Arc::new(std::sync::Mutex::new(rustc_hash::FxHashMap::default())),
432      locator_handler_ids: Arc::new(std::sync::Mutex::new(rustc_hash::FxHashMap::default())),
433    }
434  }
435
436  /// Clone of the wrapped `Arc<Page>` for cross-binding consumers
437  /// (used by `expect()` to lift a `PageJs` into an assertion target).
438  #[must_use]
439  pub fn page_arc(&self) -> Arc<Page> {
440    self.inner.clone()
441  }
442
443  #[must_use]
444  pub fn page(&self) -> &Arc<Page> {
445    &self.inner
446  }
447}
448
449/// Build a `PageJs` for a page minted from script (`newPage`,
450/// `locator.page()`, `frame.page()`), threading the session's
451/// `AsyncContext` (stashed as userdata at `Session::create`) so
452/// `page.route` / `page.exposeFunction` cross-task dispatch works on
453/// script-launched browsers — not just the MCP-prebound page.
454pub(crate) fn pagejs_for_ctx(ctx: &rquickjs::Ctx<'_>, page: Arc<Page>) -> PageJs {
455  match ctx.userdata::<crate::engine::SessionAsyncCtx>() {
456    Some(ud) => PageJs::new_with_async_ctx(page, ud.0.clone()),
457    None => PageJs::new(page),
458  }
459}
460
461#[rquickjs::methods]
462impl PageJs {
463  // ── Navigation ────────────────────────────────────────────────────────────
464
465  /// Navigate to `url`. Accepts `{ waitUntil?, timeout?, referer? }` to
466  /// mirror Playwright's `page.goto(url, options?)`.
467  #[qjs(rename = "goto")]
468  pub async fn goto<'js>(
469    &self,
470    ctx: rquickjs::Ctx<'js>,
471    url: String,
472    options: Opt<rquickjs::Value<'js>>,
473  ) -> rquickjs::Result<Option<crate::bindings::network::ResponseJs>> {
474    let opts = parse_goto_options(&ctx, options)?;
475    let resp = self.inner.goto(&url, opts).await.into_js()?;
476    Ok(resp.map(|r| crate::bindings::network::ResponseJs::new_with_page(r, self.inner.clone())))
477  }
478
479  /// Reload the current page. Accepts the same option bag as `goto`.
480  #[qjs(rename = "reload")]
481  pub async fn reload<'js>(
482    &self,
483    ctx: rquickjs::Ctx<'js>,
484    options: Opt<rquickjs::Value<'js>>,
485  ) -> rquickjs::Result<Option<crate::bindings::network::ResponseJs>> {
486    let opts = parse_goto_options(&ctx, options)?;
487    let resp = self.inner.reload(opts).await.into_js()?;
488    Ok(resp.map(|r| crate::bindings::network::ResponseJs::new_with_page(r, self.inner.clone())))
489  }
490
491  /// Navigate back in history. Accepts the same option bag as `goto`.
492  #[qjs(rename = "goBack")]
493  pub async fn go_back<'js>(
494    &self,
495    ctx: rquickjs::Ctx<'js>,
496    options: Opt<rquickjs::Value<'js>>,
497  ) -> rquickjs::Result<Option<crate::bindings::network::ResponseJs>> {
498    let opts = parse_goto_options(&ctx, options)?;
499    let resp = self.inner.go_back(opts).await.into_js()?;
500    Ok(resp.map(|r| crate::bindings::network::ResponseJs::new_with_page(r, self.inner.clone())))
501  }
502
503  /// Navigate forward in history. Accepts the same option bag as `goto`.
504  #[qjs(rename = "goForward")]
505  pub async fn go_forward<'js>(
506    &self,
507    ctx: rquickjs::Ctx<'js>,
508    options: Opt<rquickjs::Value<'js>>,
509  ) -> rquickjs::Result<Option<crate::bindings::network::ResponseJs>> {
510    let opts = parse_goto_options(&ctx, options)?;
511    let resp = self.inner.go_forward(opts).await.into_js()?;
512    Ok(resp.map(|r| crate::bindings::network::ResponseJs::new_with_page(r, self.inner.clone())))
513  }
514
515  /// Current URL of the page.
516  /// Playwright: `page.url(): string` — synchronous.
517  #[qjs(rename = "url")]
518  pub fn url(&self) -> String {
519    self.inner.url()
520  }
521
522  /// Document title.
523  #[qjs(rename = "title")]
524  pub async fn title(&self) -> rquickjs::Result<String> {
525    self.inner.title().await.into_js()
526  }
527
528  /// Playwright: `page.video(): null | Video` —
529  /// `/tmp/playwright/packages/playwright-core/types/types.d.ts:4756`.
530  /// Returns a live `Video` handle when the owning context was
531  /// created with `recordVideo`, or `null` otherwise.
532  #[qjs(rename = "video")]
533  pub fn video<'js>(&self, ctx: rquickjs::Ctx<'js>) -> rquickjs::Result<rquickjs::Value<'js>> {
534    use rquickjs::class::Class;
535    match self.inner.video() {
536      Some(video) => {
537        let wrapper = crate::bindings::video::VideoJs::new(video);
538        let instance = Class::instance(ctx.clone(), wrapper)?;
539        rquickjs::IntoJs::into_js(instance, &ctx)
540      },
541      None => Ok(rquickjs::Value::new_null(ctx)),
542    }
543  }
544
545  /// Full HTML content of the page.
546  #[qjs(rename = "content")]
547  pub async fn content(&self) -> rquickjs::Result<String> {
548    self.inner.content().await.into_js()
549  }
550
551  /// Replace the page's HTML with `html`.
552  #[qjs(rename = "setContent")]
553  pub async fn set_content(&self, html: String) -> rquickjs::Result<()> {
554    self.inner.set_content(&html).await.into_js()
555  }
556
557  /// Register a JS snippet to run on every new document before any page
558  /// script executes. Mirrors Playwright's
559  /// `page.addInitScript(script, arg)` — see
560  /// `/tmp/playwright/packages/playwright-core/src/client/page.ts:520`.
561  /// Accepts `Function | string | { path?, content? }` + optional `arg`
562  /// exactly like the NAPI binding; all lowering runs in Rust core via
563  /// [`ferridriver::options::evaluation_script`].
564  #[qjs(rename = "addInitScript")]
565  pub async fn add_init_script<'js>(
566    &self,
567    ctx: rquickjs::Ctx<'js>,
568    script: rquickjs::Value<'js>,
569    arg: Opt<rquickjs::Value<'js>>,
570  ) -> rquickjs::Result<rquickjs::Value<'js>> {
571    let (init, arg_json) = init_script_from_js(&ctx, script, arg.0)?;
572    let disposable = self.inner.add_init_script(init, arg_json).await.into_js()?;
573    let instance =
574      rquickjs::class::Class::instance(ctx.clone(), crate::bindings::disposable::DisposableJs::new(disposable))?;
575    rquickjs::IntoJs::into_js(instance, &ctx)
576  }
577
578  /// Remove a previously-registered init script by identifier.
579  #[qjs(rename = "removeInitScript")]
580  pub async fn remove_init_script(&self, identifier: String) -> rquickjs::Result<()> {
581    self.inner.remove_init_script(&identifier).await.into_js()
582  }
583
584  /// Full page rendered as clean Markdown (headings, lists, links, tables
585  /// preserved; chrome and boilerplate stripped).
586  #[qjs(rename = "markdown")]
587  pub async fn markdown(&self) -> rquickjs::Result<String> {
588    self.inner.markdown().await.into_js()
589  }
590
591  /// Wait for an element matching `selector`. Optional `options` object
592  /// accepts `{ state?: 'visible'|'hidden'|'attached'|'stable', timeout?: ms }`.
593  /// Resolves when the condition is met; throws on timeout.
594  #[qjs(rename = "waitForSelector")]
595  pub async fn wait_for_selector<'js>(
596    &self,
597    ctx: rquickjs::Ctx<'js>,
598    selector: String,
599    options: Opt<rquickjs::Value<'js>>,
600  ) -> rquickjs::Result<()> {
601    let opts = parse_wait_options(&ctx, options)?;
602    self.inner.wait_for_selector(&selector, opts).await.into_js()
603  }
604
605  // ── Locators ──────────────────────────────────────────────────────────────
606
607  /// Playwright: `page.querySelector(selector): Promise<ElementHandle | null>`.
608  /// Mints a lifecycle [`crate::bindings::element_handle::ElementHandleJs`]
609  /// pinned to the first element matching `selector`, or `null` when no
610  /// element matches. Callers `dispose()` the handle when done to
611  /// release the backend remote.
612  #[qjs(rename = "querySelector")]
613  pub async fn query_selector(
614    &self,
615    selector: String,
616  ) -> rquickjs::Result<Option<crate::bindings::element_handle::ElementHandleJs>> {
617    let inner = self.inner.query_selector(&selector).await.into_js()?;
618    Ok(inner.map(crate::bindings::element_handle::ElementHandleJs::new))
619  }
620
621  /// Playwright `$` shortcut for [`Self::query_selector`].
622  #[qjs(rename = "$")]
623  pub async fn dollar(
624    &self,
625    selector: String,
626  ) -> rquickjs::Result<Option<crate::bindings::element_handle::ElementHandleJs>> {
627    self.query_selector(selector).await
628  }
629
630  /// Playwright: `page.querySelectorAll(selector): Promise<ElementHandle[]>`.
631  #[qjs(rename = "querySelectorAll")]
632  pub async fn query_selector_all(
633    &self,
634    selector: String,
635  ) -> rquickjs::Result<Vec<crate::bindings::element_handle::ElementHandleJs>> {
636    let inner_handles = self.inner.query_selector_all(&selector).await.into_js()?;
637    Ok(
638      inner_handles
639        .into_iter()
640        .map(crate::bindings::element_handle::ElementHandleJs::new)
641        .collect(),
642    )
643  }
644
645  /// Playwright `$$` shortcut for [`Self::query_selector_all`].
646  #[qjs(rename = "$$")]
647  pub async fn dollar_dollar(
648    &self,
649    selector: String,
650  ) -> rquickjs::Result<Vec<crate::bindings::element_handle::ElementHandleJs>> {
651    self.query_selector_all(selector).await
652  }
653
654  /// Playwright: `page.evaluate(pageFunction, arg?): Promise<R>`.
655  /// `pageFunction` accepts a string or a JS function; rich return
656  /// types (`Date` / `RegExp` / `BigInt` / `URL` / `Error` / typed
657  /// arrays / `NaN` / `±Infinity` / `undefined` / `-0`) arrive as
658  /// native JS, matching Playwright's `parseResult`.
659  #[qjs(rename = "evaluate")]
660  pub async fn evaluate<'js>(
661    &self,
662    ctx: rquickjs::Ctx<'js>,
663    page_function: rquickjs::Value<'js>,
664    arg: rquickjs::function::Opt<rquickjs::Value<'js>>,
665  ) -> rquickjs::Result<rquickjs::Value<'js>> {
666    let (source, is_fn) = extract_page_function(&ctx, page_function)?;
667    let serialized = quickjs_arg_to_serialized(&ctx, arg.0)?;
668    let result = self.inner.evaluate(&source, serialized, is_fn).await.into_js()?;
669    serialized_value_to_quickjs(&ctx, &result)
670  }
671
672  /// Playwright: `page.evaluateHandle(pageFunction, arg?): Promise<JSHandle>`.
673  #[qjs(rename = "evaluateHandle")]
674  pub async fn evaluate_handle<'js>(
675    &self,
676    ctx: rquickjs::Ctx<'js>,
677    page_function: rquickjs::Value<'js>,
678    arg: rquickjs::function::Opt<rquickjs::Value<'js>>,
679  ) -> rquickjs::Result<crate::bindings::js_handle::JSHandleJs> {
680    let (source, is_fn) = extract_page_function(&ctx, page_function)?;
681    let serialized = quickjs_arg_to_serialized(&ctx, arg.0)?;
682    let handle = self.inner.evaluate_handle(&source, serialized, is_fn).await.into_js()?;
683    Ok(crate::bindings::js_handle::JSHandleJs::new(handle))
684  }
685
686  /// Playwright: `page.locator(selector, options?: LocatorOptions): Locator`.
687  /// Thin delegator to Rust core's `Page::locator`.
688  #[qjs(rename = "locator")]
689  pub fn locator<'js>(
690    &self,
691    ctx: rquickjs::Ctx<'js>,
692    selector: String,
693    options: Opt<rquickjs::Value<'js>>,
694  ) -> rquickjs::Result<LocatorJs> {
695    let parsed = crate::bindings::locator::parse_locator_options_public(&ctx, options, true)?;
696    let opts = ferridriver::options::FilterOptions {
697      has_text: parsed.has_text,
698      has_not_text: parsed.has_not_text,
699      has: parsed.has,
700      has_not: parsed.has_not,
701      visible: parsed.visible,
702    };
703    let filter = if crate::bindings::locator::is_empty_filter(&opts) {
704      None
705    } else {
706      Some(opts)
707    };
708    Ok(LocatorJs::new(self.inner.locator(&selector, filter)))
709  }
710
711  /// Locate elements by ARIA role. Accepts `{ name: string | RegExp,
712  /// exact, checked, disabled, expanded, level, pressed, selected,
713  /// includeHidden }` via the options bag.
714  #[qjs(rename = "getByRole")]
715  pub fn get_by_role(
716    &self,
717    role: String,
718    options: rquickjs::function::Opt<rquickjs::Value<'_>>,
719  ) -> rquickjs::Result<LocatorJs> {
720    let opts = parse_role_options(options)?;
721    Ok(LocatorJs::new(self.inner.get_by_role(&role, &opts)))
722  }
723
724  /// Locate elements containing the given text. Accepts `string | RegExp`.
725  #[qjs(rename = "getByText")]
726  pub fn get_by_text(
727    &self,
728    text: rquickjs::Value<'_>,
729    options: rquickjs::function::Opt<rquickjs::Value<'_>>,
730  ) -> rquickjs::Result<LocatorJs> {
731    let t = string_or_regex_from_js(text)?;
732    let opts = parse_text_options(options);
733    Ok(LocatorJs::new(self.inner.get_by_text(&t, &opts)))
734  }
735
736  /// Locate form controls by associated label text.
737  #[qjs(rename = "getByLabel")]
738  pub fn get_by_label(
739    &self,
740    text: rquickjs::Value<'_>,
741    options: rquickjs::function::Opt<rquickjs::Value<'_>>,
742  ) -> rquickjs::Result<LocatorJs> {
743    let t = string_or_regex_from_js(text)?;
744    let opts = parse_text_options(options);
745    Ok(LocatorJs::new(self.inner.get_by_label(&t, &opts)))
746  }
747
748  /// Locate inputs by placeholder text.
749  #[qjs(rename = "getByPlaceholder")]
750  pub fn get_by_placeholder(
751    &self,
752    text: rquickjs::Value<'_>,
753    options: rquickjs::function::Opt<rquickjs::Value<'_>>,
754  ) -> rquickjs::Result<LocatorJs> {
755    let t = string_or_regex_from_js(text)?;
756    let opts = parse_text_options(options);
757    Ok(LocatorJs::new(self.inner.get_by_placeholder(&t, &opts)))
758  }
759
760  /// Locate images/media by alt text.
761  #[qjs(rename = "getByAltText")]
762  pub fn get_by_alt_text(
763    &self,
764    text: rquickjs::Value<'_>,
765    options: rquickjs::function::Opt<rquickjs::Value<'_>>,
766  ) -> rquickjs::Result<LocatorJs> {
767    let t = string_or_regex_from_js(text)?;
768    let opts = parse_text_options(options);
769    Ok(LocatorJs::new(self.inner.get_by_alt_text(&t, &opts)))
770  }
771
772  /// Locate elements by `title` attribute text.
773  #[qjs(rename = "getByTitle")]
774  pub fn get_by_title(
775    &self,
776    text: rquickjs::Value<'_>,
777    options: rquickjs::function::Opt<rquickjs::Value<'_>>,
778  ) -> rquickjs::Result<LocatorJs> {
779    let t = string_or_regex_from_js(text)?;
780    let opts = parse_text_options(options);
781    Ok(LocatorJs::new(self.inner.get_by_title(&t, &opts)))
782  }
783
784  /// Locate elements by `data-testid`. Accepts `string | RegExp`.
785  #[qjs(rename = "getByTestId")]
786  pub fn get_by_test_id(&self, test_id: rquickjs::Value<'_>) -> rquickjs::Result<LocatorJs> {
787    let t = string_or_regex_from_js(test_id)?;
788    Ok(LocatorJs::new(self.inner.get_by_test_id(&t)))
789  }
790
791  // ── Interaction ───────────────────────────────────────────────────────────
792
793  /// Click the first element matching `selector`. Accepts Playwright's
794  /// full `PageClickOptions` bag.
795  #[qjs(rename = "click")]
796  pub async fn click<'js>(
797    &self,
798    ctx: rquickjs::Ctx<'js>,
799    selector: String,
800    options: rquickjs::function::Opt<rquickjs::Value<'js>>,
801  ) -> rquickjs::Result<()> {
802    let opts = crate::bindings::convert::parse_click_options(&ctx, options)?;
803    self.inner.click(&selector, opts).await.into_js()
804  }
805
806  /// Double-click the first element matching `selector`. Accepts
807  /// Playwright's full `PageDblClickOptions` bag.
808  #[qjs(rename = "dblclick")]
809  pub async fn dblclick<'js>(
810    &self,
811    ctx: rquickjs::Ctx<'js>,
812    selector: String,
813    options: rquickjs::function::Opt<rquickjs::Value<'js>>,
814  ) -> rquickjs::Result<()> {
815    let opts = crate::bindings::convert::parse_dblclick_options(&ctx, options)?;
816    self.inner.dblclick(&selector, opts).await.into_js()
817  }
818
819  /// Fill `value` into the input matching `selector`. Accepts
820  /// Playwright's full `PageFillOptions` bag.
821  #[qjs(rename = "fill")]
822  pub async fn fill<'js>(
823    &self,
824    ctx: rquickjs::Ctx<'js>,
825    selector: String,
826    value: String,
827    options: rquickjs::function::Opt<rquickjs::Value<'js>>,
828  ) -> rquickjs::Result<()> {
829    let opts = crate::bindings::convert::parse_fill_options(&ctx, options)?;
830    self.inner.fill(&selector, &value, opts).await.into_js()
831  }
832
833  /// Type `text` into the input matching `selector`. Accepts
834  /// Playwright's full `PageTypeOptions` bag.
835  ///
836  /// Exposed as `type` in JS (matches Playwright) — Rust renames to avoid
837  /// the `type` keyword.
838  #[qjs(rename = "type")]
839  pub async fn type_<'js>(
840    &self,
841    ctx: rquickjs::Ctx<'js>,
842    selector: String,
843    text: String,
844    options: rquickjs::function::Opt<rquickjs::Value<'js>>,
845  ) -> rquickjs::Result<()> {
846    let opts = crate::bindings::convert::parse_type_options(&ctx, options)?;
847    self.inner.r#type(&selector, &text, opts).await.into_js()
848  }
849
850  /// Press `key` on the element matching `selector`. Accepts Playwright's
851  /// full `PagePressOptions` bag.
852  #[qjs(rename = "press")]
853  pub async fn press<'js>(
854    &self,
855    ctx: rquickjs::Ctx<'js>,
856    selector: String,
857    key: String,
858    options: rquickjs::function::Opt<rquickjs::Value<'js>>,
859  ) -> rquickjs::Result<()> {
860    let opts = crate::bindings::convert::parse_press_options(&ctx, options)?;
861    self.inner.press(&selector, &key, opts).await.into_js()
862  }
863
864  /// `page.focus(selector, options?)`.
865  #[qjs(rename = "focus")]
866  pub async fn focus(
867    &self,
868    selector: String,
869    _options: rquickjs::function::Opt<rquickjs::Value<'_>>,
870  ) -> rquickjs::Result<()> {
871    self.inner.focus(&selector).await.into_js()
872  }
873
874  /// Hover the first element matching `selector`. Accepts Playwright's
875  /// full `PageHoverOptions` bag.
876  #[qjs(rename = "hover")]
877  pub async fn hover<'js>(
878    &self,
879    ctx: rquickjs::Ctx<'js>,
880    selector: String,
881    options: rquickjs::function::Opt<rquickjs::Value<'js>>,
882  ) -> rquickjs::Result<()> {
883    let opts = crate::bindings::convert::parse_hover_options(&ctx, options)?;
884    self.inner.hover(&selector, opts).await.into_js()
885  }
886
887  /// Dispatch a DOM event on the first element matching `selector`.
888  /// Mirrors Playwright's `page.dispatchEvent(selector, type, eventInit?, options?)`.
889  #[qjs(rename = "dispatchEvent")]
890  pub async fn dispatch_event<'js>(
891    &self,
892    ctx: rquickjs::Ctx<'js>,
893    selector: String,
894    event_type: String,
895    event_init: rquickjs::function::Opt<rquickjs::Value<'js>>,
896    options: rquickjs::function::Opt<rquickjs::Value<'js>>,
897  ) -> rquickjs::Result<()> {
898    let init_json = match event_init.0 {
899      Some(v) if !v.is_undefined() && !v.is_null() => {
900        Some(crate::bindings::convert::serde_from_js::<serde_json::Value>(&ctx, v)?)
901      },
902      _ => None,
903    };
904    let opts = crate::bindings::convert::parse_dispatch_event_options(&ctx, options)?;
905    self
906      .inner
907      .dispatch_event(&selector, &event_type, init_json, opts)
908      .await
909      .into_js()
910  }
911
912  /// Tap (touch) the first element matching `selector`. Accepts
913  /// Playwright's full `PageTapOptions` bag.
914  #[qjs(rename = "tap")]
915  pub async fn tap<'js>(
916    &self,
917    ctx: rquickjs::Ctx<'js>,
918    selector: String,
919    options: rquickjs::function::Opt<rquickjs::Value<'js>>,
920  ) -> rquickjs::Result<()> {
921    let opts = crate::bindings::convert::parse_tap_options(&ctx, options)?;
922    self.inner.tap(&selector, opts).await.into_js()
923  }
924
925  /// Check a checkbox matching `selector`. Accepts Playwright's full
926  /// `PageCheckOptions` bag.
927  #[qjs(rename = "check")]
928  pub async fn check<'js>(
929    &self,
930    ctx: rquickjs::Ctx<'js>,
931    selector: String,
932    options: rquickjs::function::Opt<rquickjs::Value<'js>>,
933  ) -> rquickjs::Result<()> {
934    let opts = crate::bindings::convert::parse_check_options(&ctx, options)?;
935    self.inner.check(&selector, opts).await.into_js()
936  }
937
938  /// Uncheck a checkbox matching `selector`. Accepts Playwright's full
939  /// `PageUncheckOptions` bag.
940  #[qjs(rename = "uncheck")]
941  pub async fn uncheck<'js>(
942    &self,
943    ctx: rquickjs::Ctx<'js>,
944    selector: String,
945    options: rquickjs::function::Opt<rquickjs::Value<'js>>,
946  ) -> rquickjs::Result<()> {
947    let opts = crate::bindings::convert::parse_check_options(&ctx, options)?;
948    self.inner.uncheck(&selector, opts).await.into_js()
949  }
950
951  /// Set the checked state of a checkbox/radio matching `selector`.
952  /// Accepts Playwright's full `PageSetCheckedOptions` bag.
953  #[qjs(rename = "setChecked")]
954  pub async fn set_checked<'js>(
955    &self,
956    ctx: rquickjs::Ctx<'js>,
957    selector: String,
958    checked: bool,
959    options: rquickjs::function::Opt<rquickjs::Value<'js>>,
960  ) -> rquickjs::Result<()> {
961    let opts = crate::bindings::convert::parse_check_options(&ctx, options)?;
962    self.inner.set_checked(&selector, checked, opts).await.into_js()
963  }
964
965  /// Select options on the `<select>` matching `selector`. Returns the
966  /// values of the selected options. Accepts Playwright's full
967  /// `string | string[] | { value?, label?, index? } | Array<...>` union.
968  #[qjs(rename = "selectOption")]
969  pub async fn select_option<'js>(
970    &self,
971    ctx: rquickjs::Ctx<'js>,
972    selector: String,
973    values: rquickjs::Value<'js>,
974    options: rquickjs::function::Opt<rquickjs::Value<'js>>,
975  ) -> rquickjs::Result<Vec<String>> {
976    let values = crate::bindings::convert::parse_select_option_values(&ctx, values)?;
977    let opts = crate::bindings::convert::parse_select_option_options(&ctx, options)?;
978    self.inner.select_option(&selector, values, opts).await.into_js()
979  }
980
981  // ── Info ──────────────────────────────────────────────────────────────────
982
983  /// Text content of the first element matching `selector` (or `null`).
984  #[qjs(rename = "textContent")]
985  pub async fn text_content(&self, selector: String) -> rquickjs::Result<Option<String>> {
986    self.inner.text_content(&selector).await.into_js()
987  }
988
989  /// `innerText` of the first element matching `selector`.
990  #[qjs(rename = "innerText")]
991  pub async fn inner_text(&self, selector: String) -> rquickjs::Result<String> {
992    self.inner.inner_text(&selector).await.into_js()
993  }
994
995  /// `innerHTML` of the first element matching `selector`.
996  #[qjs(rename = "innerHTML")]
997  pub async fn inner_html(&self, selector: String) -> rquickjs::Result<String> {
998    self.inner.inner_html(&selector).await.into_js()
999  }
1000
1001  /// Current input value of the first element matching `selector`.
1002  #[qjs(rename = "inputValue")]
1003  pub async fn input_value(&self, selector: String) -> rquickjs::Result<String> {
1004    self.inner.input_value(&selector).await.into_js()
1005  }
1006
1007  /// Get attribute `name` on the first element matching `selector`
1008  /// (or `null` if the attribute is absent).
1009  #[qjs(rename = "getAttribute")]
1010  pub async fn get_attribute(&self, selector: String, name: String) -> rquickjs::Result<Option<String>> {
1011    self.inner.get_attribute(&selector, &name).await.into_js()
1012  }
1013
1014  /// Whether the first element matching `selector` is visible.
1015  #[qjs(rename = "isVisible")]
1016  pub async fn is_visible(&self, selector: String) -> rquickjs::Result<bool> {
1017    self.inner.is_visible(&selector).await.into_js()
1018  }
1019
1020  /// Whether the first element matching `selector` is hidden.
1021  #[qjs(rename = "isHidden")]
1022  pub async fn is_hidden(&self, selector: String) -> rquickjs::Result<bool> {
1023    self.inner.is_hidden(&selector).await.into_js()
1024  }
1025
1026  /// Whether the first element matching `selector` is enabled.
1027  #[qjs(rename = "isEnabled")]
1028  pub async fn is_enabled(&self, selector: String) -> rquickjs::Result<bool> {
1029    self.inner.is_enabled(&selector).await.into_js()
1030  }
1031
1032  /// Whether the first element matching `selector` is disabled.
1033  #[qjs(rename = "isDisabled")]
1034  pub async fn is_disabled(&self, selector: String) -> rquickjs::Result<bool> {
1035    self.inner.is_disabled(&selector).await.into_js()
1036  }
1037
1038  /// Whether the first checkbox matching `selector` is checked.
1039  #[qjs(rename = "isChecked")]
1040  pub async fn is_checked(&self, selector: String) -> rquickjs::Result<bool> {
1041    self.inner.is_checked(&selector).await.into_js()
1042  }
1043
1044  // ── Mouse / keyboard namespaces (Playwright parity) ──────────────────────
1045
1046  /// `page.mouse.*` namespace: `click`, `dblclick`, `down`, `up`, `wheel`.
1047  /// Exposed as a JS property, matching Playwright.
1048  #[qjs(get, rename = "mouse")]
1049  pub fn mouse(&self) -> MouseJs {
1050    MouseJs::new(self.inner.clone())
1051  }
1052
1053  /// `page.keyboard.*` namespace: `down`, `up`, `press` (no selector; acts on
1054  /// the currently focused element). Exposed as a JS property.
1055  #[qjs(get, rename = "keyboard")]
1056  pub fn keyboard(&self) -> KeyboardJs {
1057    KeyboardJs::new(self.inner.clone())
1058  }
1059
1060  /// ferridriver-specific (NOT Playwright): click at viewport
1061  /// coordinates without a selector. Playwright equivalent: `mouse.click(x, y)`.
1062  #[qjs(rename = "clickAt")]
1063  pub async fn click_at(&self, x: f64, y: f64) -> rquickjs::Result<()> {
1064    self.inner.click_at(x, y).await.into_js()
1065  }
1066
1067  /// ferridriver-specific (NOT Playwright): interpolated mouse move
1068  /// from `(fromX, fromY)` to `(toX, toY)` in `steps` points. Playwright
1069  /// equivalent: `mouse.move(x, y, { steps })`.
1070  #[qjs(rename = "moveMouseSmooth")]
1071  pub async fn move_mouse_smooth(
1072    &self,
1073    from_x: f64,
1074    from_y: f64,
1075    to_x: f64,
1076    to_y: f64,
1077    steps: u32,
1078  ) -> rquickjs::Result<()> {
1079    self
1080      .inner
1081      .move_mouse_smooth(from_x, from_y, to_x, to_y, steps)
1082      .await
1083      .into_js()
1084  }
1085
1086  /// Drag from the source selector to the target selector. Accepts
1087  /// Playwright's `FrameDragAndDropOptions & TimeoutOptions` bag:
1088  /// `{ force?, noWaitAfter?, sourcePosition?, targetPosition?, steps?, strict?, timeout?, trial? }`.
1089  #[qjs(rename = "dragAndDrop")]
1090  pub async fn drag_and_drop<'js>(
1091    &self,
1092    ctx: rquickjs::Ctx<'js>,
1093    source: String,
1094    target: String,
1095    options: Opt<rquickjs::Value<'js>>,
1096  ) -> rquickjs::Result<()> {
1097    let opts = parse_drag_options(&ctx, options)?;
1098    self.inner.drag_and_drop(&source, &target, opts).await.into_js()
1099  }
1100
1101  // ── File input ────────────────────────────────────────────────────────────
1102
1103  /// Attach files to a `<input type="file">` selector. Accepts
1104  /// Playwright's full `string | string[] | FilePayload | FilePayload[]`
1105  /// union plus the `PageSetInputFilesOptions` bag.
1106  #[qjs(rename = "setInputFiles")]
1107  pub async fn set_input_files<'js>(
1108    &self,
1109    ctx: rquickjs::Ctx<'js>,
1110    selector: String,
1111    files: rquickjs::Value<'js>,
1112    options: rquickjs::function::Opt<rquickjs::Value<'js>>,
1113  ) -> rquickjs::Result<()> {
1114    let files = crate::bindings::convert::parse_input_files(&ctx, files)?;
1115    let opts = crate::bindings::convert::parse_set_input_files_options(&ctx, options)?;
1116    self.inner.set_input_files(&selector, files, opts).await.into_js()
1117  }
1118
1119  // ── Emulation (page-scoped Playwright API) ───────────────────────────────
1120
1121  /// Override the viewport size for this page. Playwright public:
1122  /// `page.setViewportSize({ width, height })`.
1123  /// Playwright: `page.setViewportSize({ width, height })` — a single
1124  /// object, not two positional numbers.
1125  #[qjs(rename = "setViewportSize")]
1126  pub async fn set_viewport_size<'js>(
1127    &self,
1128    ctx: rquickjs::Ctx<'js>,
1129    size: rquickjs::Value<'js>,
1130  ) -> rquickjs::Result<()> {
1131    #[derive(serde::Deserialize)]
1132    struct Size {
1133      width: i64,
1134      height: i64,
1135    }
1136    let s: Size = crate::bindings::convert::serde_from_js(&ctx, size)?;
1137    self.inner.set_viewport_size(s.width, s.height).await.into_js()
1138  }
1139
1140  /// Emulate media features. Accepts Playwright's
1141  /// `{ media?, colorScheme?, reducedMotion?, forcedColors?, contrast? }`
1142  /// option bag — each call is a partial update layered on top of the
1143  /// page's persistent emulated-media state.
1144  #[qjs(rename = "emulateMedia")]
1145  pub async fn emulate_media<'js>(
1146    &self,
1147    ctx: rquickjs::Ctx<'js>,
1148    options: Opt<rquickjs::Value<'js>>,
1149  ) -> rquickjs::Result<()> {
1150    let opts = parse_emulate_media_options(&ctx, options)?;
1151    self.inner.emulate_media(&opts).await.into_js()
1152  }
1153
1154  // ── Screenshots / PDF (return raw bytes; pair with `artifacts.writeBytes`) ─
1155
1156  /// Capture the page as a PNG (raw bytes — Uint8Array in JS). Pair with
1157  /// `await artifacts.writeBytes('page.png', bytes)` to save to disk.
1158  /// Optional `options` accept `{ fullPage?: boolean, format?: 'png'|'jpeg'|'webp', quality?: number }`.
1159  #[qjs(rename = "screenshot")]
1160  pub async fn screenshot<'js>(
1161    &self,
1162    ctx: rquickjs::Ctx<'js>,
1163    options: Opt<rquickjs::Value<'js>>,
1164  ) -> rquickjs::Result<Vec<u8>> {
1165    let opts = parse_screenshot_options(&ctx, options)?;
1166    self.inner.screenshot(opts).await.into_js()
1167  }
1168
1169  /// Capture a single element as PNG bytes.
1170  #[qjs(rename = "screenshotElement")]
1171  pub async fn screenshot_element(&self, selector: String) -> rquickjs::Result<Vec<u8>> {
1172    self.inner.screenshot_element(&selector).await.into_js()
1173  }
1174
1175  /// Render the current page as a PDF (raw bytes). Accepts a Playwright-shape
1176  /// options object: `{ format?, landscape?, printBackground?, scale?, ... }`.
1177  /// Pair with `await artifacts.writeBytes('page.pdf', bytes)` to save.
1178  #[qjs(rename = "pdf")]
1179  pub async fn pdf<'js>(
1180    &self,
1181    ctx: rquickjs::Ctx<'js>,
1182    options: Opt<rquickjs::Value<'js>>,
1183  ) -> rquickjs::Result<Vec<u8>> {
1184    let opts = parse_pdf_options(&ctx, options)?;
1185    self.inner.pdf(opts).await.into_js()
1186  }
1187
1188  // ── Lifecycle ─────────────────────────────────────────────────────────────
1189
1190  /// Close the page. Accepts `{ runBeforeUnload?, reason? }` to mirror
1191  /// Playwright's `page.close(options?)`.
1192  #[qjs(rename = "close")]
1193  pub async fn close<'js>(&self, ctx: rquickjs::Ctx<'js>, options: Opt<rquickjs::Value<'js>>) -> rquickjs::Result<()> {
1194    let opts = parse_page_close_options(&ctx, options)?;
1195    self.inner.close(opts).await.into_js()
1196  }
1197
1198  /// Set the default timeout for all non-navigation operations
1199  /// (milliseconds). Mirrors Playwright's `page.setDefaultTimeout(timeout)`.
1200  #[qjs(rename = "setDefaultTimeout")]
1201  pub fn set_default_timeout(&self, ms: u64) {
1202    self.inner.set_default_timeout(ms);
1203  }
1204
1205  /// Set the default timeout for navigation-family operations
1206  /// (`goto`, `reload`, `goBack`, `goForward`, `waitForUrl`). Mirrors
1207  /// Playwright's `page.setDefaultNavigationTimeout(timeout)`.
1208  #[qjs(rename = "setDefaultNavigationTimeout")]
1209  pub fn set_default_navigation_timeout(&self, ms: u64) {
1210    self.inner.set_default_navigation_timeout(ms);
1211  }
1212
1213  /// Whether the page has been closed.
1214  #[qjs(rename = "isClosed")]
1215  pub fn is_closed(&self) -> bool {
1216    self.inner.is_closed()
1217  }
1218
1219  // ── Network interception ─────────────────────────────────────────────────
1220
1221  /// Mirrors Playwright `page.route(url, handler)`. Registers a JS
1222  /// callback to intercept requests matching `url` (`string | RegExp`).
1223  /// The callback receives a `Route` instance and must call exactly one
1224  /// of `route.fulfill()`, `route.continue()`, or `route.abort()` to
1225  /// resume the request.
1226  ///
1227  /// Cross-task dispatch: the Rust route handler runs inside the
1228  /// backend's network listener (a separate tokio task from the
1229  /// script's JS context). The handler stashes the JS callback in the
1230  /// native `RouteRegistry` userdata keyed by ID at registration
1231  /// time; when a request matches, the handler spawns a task that
1232  /// `async_with`s back into the script's `AsyncContext`, looks up the
1233  /// callback by ID, and invokes it with a fresh `RouteJs` wrapper.
1234  /// `rquickjs`'s scheduler serialises the dispatch against the
1235  /// script's own `await` points so JS-side state stays consistent.
1236  #[qjs(rename = "route")]
1237  pub async fn route<'js>(
1238    &self,
1239    ctx: rquickjs::Ctx<'js>,
1240    url: rquickjs::Value<'js>,
1241    handler: rquickjs::Function<'js>,
1242    options: rquickjs::function::Opt<rquickjs::Value<'js>>,
1243  ) -> rquickjs::Result<rquickjs::Value<'js>> {
1244    let times = parse_route_times(&options)?;
1245    let async_ctx = self.async_ctx.clone().ok_or_else(|| {
1246      rquickjs::Error::new_from_js_message(
1247        "page.route",
1248        "Error",
1249        "page.route requires the script engine's AsyncContext (install_page)".to_string(),
1250      )
1251    })?;
1252    let id = self.next_route_id.fetch_add(1, Ordering::Relaxed);
1253    let saved_handler = rquickjs::Persistent::save(&ctx, handler);
1254    with_page_callbacks(&ctx, |r| r.route_handlers.insert(id, saved_handler))?;
1255
1256    // A JS predicate is `!Send` and core matches on the CDP recv task,
1257    // so it can't ride `UrlMatcher::Predicate`. Register an always-true
1258    // matcher with unique `Arc` identity (lets `unroute(fn)` drop
1259    // exactly it via `Arc::ptr_eq`); evaluate the predicate in the
1260    // dispatch bridge and continue the request unmodified on falsy.
1261    let has_predicate = url.as_function().is_some();
1262    let matcher = if let Some(pred) = url.as_function() {
1263      let saved_pred = rquickjs::Persistent::save(&ctx, pred.clone());
1264      with_page_callbacks(&ctx, |r| r.route_preds.insert(id, saved_pred))?;
1265      let m = ferridriver::url_matcher::UrlMatcher::predicate(|_| true);
1266      self
1267        .route_matchers
1268        .lock()
1269        .unwrap_or_else(std::sync::PoisonError::into_inner)
1270        .insert(id, m.clone());
1271      m
1272    } else {
1273      url_value_to_matcher(&ctx, url)?
1274    };
1275
1276    // LIMITATION (persistent-session VMs): this closure captures a clone
1277    // of the session's `AsyncContext`. Core route registrations live on
1278    // the page (independent of the JS VM), so they outlive a poisoning
1279    // rebuild / LRU eviction of the session VM. After such a discard the
1280    // closure dispatches into the now-detached old context; the new VM's
1281    // scripts cannot see or `unroute` it. It stays memory-safe (the Arc
1282    // keeps the old context alive) and fail-open (the route's `Drop`
1283    // continues the request if dispatch can't reach JS), and it clears
1284    // when the page closes. Fully reconciling it needs a cross-backend
1285    // "unroute all" on VM discard — tracked, not yet implemented.
1286    let rust_handler: ferridriver::route::RouteHandler = std::sync::Arc::new(move |route| {
1287      let async_ctx = async_ctx.clone();
1288      // Cross-task dispatch: spawn a tokio task that grabs the
1289      // AsyncContext lock and calls the JS callback (restored from the
1290      // native route registry by id). Errors are swallowed because the
1291      // route's own `Drop` (fail-open continue) covers the case where
1292      // dispatch can't reach JS.
1293      tokio::spawn(async move {
1294        use rquickjs::class::Class;
1295        let _: rquickjs::Result<()> = rquickjs::async_with!(async_ctx => |ctx| {
1296          if has_predicate {
1297            let pred = with_page_callbacks(&ctx, |r| r.route_preds.get(&id).cloned())?
1298              .ok_or_else(|| rquickjs::Error::new_from_js_message("page.route", "Error", "route predicate gone".to_string()))?
1299              .restore(&ctx)?;
1300            let url_ctor: rquickjs::function::Constructor<'_> = ctx.globals().get("URL")?;
1301            let url_obj: rquickjs::Value<'_> = url_ctor.construct((route.request().url.clone(),))?;
1302            if !call_predicate_truthy(&pred, url_obj, &ctx).await? {
1303              route.continue_route(ferridriver::route::ContinueOverrides::default());
1304              return Ok(());
1305            }
1306          }
1307          let f = with_page_callbacks(&ctx, |r| r.route_handlers.get(&id).cloned())?
1308            .ok_or_else(|| rquickjs::Error::new_from_js_message("page.route", "Error", "route handler gone".to_string()))?
1309            .restore(&ctx)?;
1310          let route_class = Class::instance(ctx.clone(), crate::bindings::network::RouteJs::new(route))?;
1311          let _: rquickjs::Value<'_> = f.call((route_class,))?;
1312          Ok(())
1313        })
1314        .await;
1315      });
1316    });
1317
1318    let disposable = self.inner.route(matcher, rust_handler, times).await.into_js()?;
1319    let instance =
1320      rquickjs::class::Class::instance(ctx.clone(), crate::bindings::disposable::DisposableJs::new(disposable))?;
1321    rquickjs::IntoJs::into_js(instance, &ctx)
1322  }
1323
1324  /// Playwright: `page.routeFromHAR(har, options?)`. Replay-only.
1325  #[qjs(rename = "routeFromHAR")]
1326  pub async fn route_from_har(
1327    &self,
1328    har: String,
1329    options: rquickjs::function::Opt<rquickjs::Value<'_>>,
1330  ) -> rquickjs::Result<()> {
1331    let opts = parse_har_options(&options)?;
1332    self
1333      .inner
1334      .route_from_har(std::path::Path::new(&har), opts)
1335      .await
1336      .into_js()
1337  }
1338
1339  /// `page.unroute(string | RegExp | ((url: URL) => boolean))`. A
1340  /// predicate is matched by `===` identity against the function passed
1341  /// to `route`, then its always-true core matcher is dropped by `Arc`
1342  /// identity so sibling predicate routes survive.
1343  #[qjs(rename = "unroute")]
1344  pub async fn unroute<'js>(&self, ctx: rquickjs::Ctx<'js>, url: rquickjs::Value<'js>) -> rquickjs::Result<()> {
1345    if let Some(pred) = url.as_function() {
1346      // Find every id whose stored predicate is identical (===) to the
1347      // passed function, then drop its core registration + registry
1348      // entries. Restoring each saved predicate yields a handle to the
1349      // same underlying object, so `Value` `PartialEq` (tag + pointer)
1350      // is still strict `===` identity.
1351      let saved: Vec<(u64, rquickjs::Persistent<rquickjs::Function<'static>>)> =
1352        with_page_callbacks(&ctx, |r| r.route_preds.iter().map(|(k, v)| (*k, v.clone())).collect())?;
1353      let mut victims: Vec<u64> = Vec::new();
1354      for (id, sp) in saved {
1355        let stored = sp.restore(&ctx)?;
1356        if stored.as_value() == pred.as_value() {
1357          victims.push(id);
1358        }
1359      }
1360      for id in victims {
1361        let m = self
1362          .route_matchers
1363          .lock()
1364          .unwrap_or_else(std::sync::PoisonError::into_inner)
1365          .remove(&id);
1366        if let Some(m) = m {
1367          self.inner.unroute(&m).await.into_js()?;
1368        }
1369        with_page_callbacks(&ctx, |r| {
1370          r.route_preds.remove(&id);
1371          r.route_handlers.remove(&id);
1372        })?;
1373      }
1374      return Ok(());
1375    }
1376    let matcher = url_value_to_matcher(&ctx, url)?;
1377    self.inner.unroute(&matcher).await.into_js()
1378  }
1379
1380  /// `page.unrouteAll(options?: { behavior?: 'wait' | 'ignoreErrors' | 'default' })`.
1381  /// Removes every route registered via `page.route`, clearing the script-side
1382  /// predicate/handler tables too.
1383  #[qjs(rename = "unrouteAll")]
1384  pub async fn unroute_all<'js>(
1385    &self,
1386    ctx: rquickjs::Ctx<'js>,
1387    options: Opt<rquickjs::Value<'js>>,
1388  ) -> rquickjs::Result<()> {
1389    let behavior = match options.0.and_then(rquickjs::Value::into_object) {
1390      Some(obj) => match obj.get::<_, Option<String>>("behavior")? {
1391        Some(b) => Some(parse_unroute_behavior(&b)?),
1392        None => None,
1393      },
1394      None => None,
1395    };
1396    self.inner.unroute_all(behavior).await.into_js()?;
1397    self
1398      .route_matchers
1399      .lock()
1400      .unwrap_or_else(std::sync::PoisonError::into_inner)
1401      .clear();
1402    with_page_callbacks(&ctx, |r| {
1403      r.route_preds.clear();
1404      r.route_handlers.clear();
1405    })?;
1406    Ok(())
1407  }
1408
1409  /// `page.addLocatorHandler(locator, handler, options?: { times?, noWaitAfter? })`.
1410  /// Registers `handler` to run whenever `locator` becomes visible during an
1411  /// actionability wait (dismissing overlays/modals). Mirrors Playwright
1412  /// `client/page.ts:397`.
1413  ///
1414  /// The JS handler runs cross-task via the session `AsyncContext` (same
1415  /// bridge as `page.route`): the core checkpoint awaits a oneshot that the
1416  /// spawned dispatch task fulfils once the handler (and any returned
1417  /// promise) settles, so the original action only resumes afterwards.
1418  #[qjs(rename = "addLocatorHandler")]
1419  pub fn add_locator_handler(
1420    &self,
1421    _locator: rquickjs::Class<'_, LocatorJs>,
1422    _handler: rquickjs::Function<'_>,
1423    _options: Opt<rquickjs::Value<'_>>,
1424  ) -> rquickjs::Result<()> {
1425    // The handler must run *during* an in-progress action's actionability
1426    // wait. In the QuickJS scripting engine every action executes inside an
1427    // exclusive `async_with` over the single session VM, so a nested
1428    // handler callback can never acquire the VM until the action finishes --
1429    // invoking it would deadlock. Playwright sidesteps this with a
1430    // client/server split; ferridriver-script has none, so this is a typed
1431    // Unsupported rather than a hang. The core + NAPI layers support it fully.
1432    ferridriver::error::Result::<()>::Err(ferridriver::error::FerriError::unsupported(
1433      "page.addLocatorHandler is not available in the QuickJS scripting engine \
1434       (handlers cannot fire during an in-VM action without deadlocking the \
1435       single-threaded VM); use the NAPI/core API for locator handlers",
1436    ))
1437    .into_js()
1438  }
1439
1440  /// `page.removeLocatorHandler(locator)`. Drops every handler registered for
1441  /// `locator` (by selector) and releases the persisted JS callbacks. Mirrors
1442  /// Playwright `client/page.ts:423`.
1443  #[qjs(rename = "removeLocatorHandler")]
1444  pub fn remove_locator_handler<'js>(
1445    &self,
1446    ctx: rquickjs::Ctx<'js>,
1447    locator: rquickjs::Class<'js, LocatorJs>,
1448  ) -> rquickjs::Result<()> {
1449    let core_locator = locator.borrow().inner_ref().clone();
1450    self.inner.remove_locator_handler(&core_locator);
1451    let ids = self
1452      .locator_handler_ids
1453      .lock()
1454      .unwrap_or_else(std::sync::PoisonError::into_inner)
1455      .remove(core_locator.selector())
1456      .unwrap_or_default();
1457    with_page_callbacks(&ctx, |r| {
1458      for id in ids {
1459        r.remove_locator_handler(id);
1460      }
1461    })?;
1462    Ok(())
1463  }
1464
1465  /// `page.pickLocator(): Promise<Locator>`. Highlights elements under the
1466  /// cursor and resolves with a Locator for the element the user clicks.
1467  #[qjs(rename = "pickLocator")]
1468  pub async fn pick_locator(&self) -> rquickjs::Result<LocatorJs> {
1469    let loc = self.inner.pick_locator().await.into_js()?;
1470    Ok(LocatorJs::new(loc))
1471  }
1472
1473  /// `page.cancelPickLocator(): Promise<void>`.
1474  #[qjs(rename = "cancelPickLocator")]
1475  pub async fn cancel_pick_locator(&self) -> rquickjs::Result<()> {
1476    self.inner.cancel_pick_locator().await.into_js()
1477  }
1478
1479  /// `page.hideHighlight(): Promise<void>`.
1480  #[qjs(rename = "hideHighlight")]
1481  pub async fn hide_highlight(&self) -> rquickjs::Result<()> {
1482    self.inner.hide_highlight().await.into_js()
1483  }
1484
1485  // ── Network lifecycle waits ──────────────────────────────────────────────
1486  //
1487  // Mirror Playwright's `page.waitForRequest` / `page.waitForResponse` /
1488  // `page.waitForEvent('websocket')` — return live `RequestJs` /
1489  // `ResponseJs` / `WebSocketJs` so callers can inspect headers, body,
1490  // failure, etc.
1491
1492  /// `page.waitForRequest(string | RegExp | ((r: Request) => boolean |
1493  /// Promise<boolean>), options?)`.
1494  #[qjs(rename = "waitForRequest")]
1495  pub async fn wait_for_request<'js>(
1496    &self,
1497    ctx: rquickjs::Ctx<'js>,
1498    url: rquickjs::Value<'js>,
1499    timeout_ms: Opt<f64>,
1500  ) -> rquickjs::Result<crate::bindings::network::RequestJs> {
1501    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1502    let timeout = timeout_ms.0.map(|t| t as u64);
1503    if let Some(pred) = url.as_function() {
1504      let t = timeout.unwrap_or_else(|| self.inner.default_timeout());
1505      return wait_request_predicate(ctx.clone(), self.inner.clone(), pred.clone(), t).await;
1506    }
1507    let matcher = url_value_to_matcher(&ctx, url)?;
1508    let req = self.inner.wait_for_request(matcher, timeout).await.into_js()?;
1509    Ok(crate::bindings::network::RequestJs::new_with_page(
1510      req,
1511      self.inner.clone(),
1512    ))
1513  }
1514
1515  /// `page.waitForResponse(string | RegExp | ((r: Response) => boolean |
1516  /// Promise<boolean>), options?)`.
1517  #[qjs(rename = "waitForResponse")]
1518  pub async fn wait_for_response<'js>(
1519    &self,
1520    ctx: rquickjs::Ctx<'js>,
1521    url: rquickjs::Value<'js>,
1522    timeout_ms: Opt<f64>,
1523  ) -> rquickjs::Result<crate::bindings::network::ResponseJs> {
1524    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1525    let timeout = timeout_ms.0.map(|t| t as u64);
1526    if let Some(pred) = url.as_function() {
1527      let t = timeout.unwrap_or_else(|| self.inner.default_timeout());
1528      return wait_response_predicate(ctx.clone(), self.inner.clone(), pred.clone(), t).await;
1529    }
1530    let matcher = url_value_to_matcher(&ctx, url)?;
1531    let resp = self.inner.wait_for_response(matcher, timeout).await.into_js()?;
1532    Ok(crate::bindings::network::ResponseJs::new_with_page(
1533      resp,
1534      self.inner.clone(),
1535    ))
1536  }
1537
1538  /// Mirrors Playwright `page.waitForEvent(event, options?)`. Dispatches
1539  /// on the event name and returns the live class for the lifecycle
1540  /// events (`Request` / `Response` / `WebSocket`), or a snapshot object
1541  /// for simpler events. The overloaded return keeps the Playwright-
1542  /// canonical call shape — scripts write `await page.waitForEvent('websocket')`
1543  /// and receive a real `WebSocket` instance.
1544  /// Playwright: `page.waitForLoadState(state?: 'load' |
1545  /// 'domcontentloaded' | 'networkidle', options?)`. Defaults to
1546  /// `'load'`. Thin delegator to `Page::wait_for_load_state`.
1547  #[qjs(rename = "waitForLoadState")]
1548  pub async fn wait_for_load_state(&self, state: Opt<String>) -> rquickjs::Result<()> {
1549    use crate::bindings::convert::FerriResultExt;
1550    self.inner.wait_for_load_state(state.0.as_deref()).await.into_js()
1551  }
1552
1553  /// Playwright: `page.waitForURL(url: string | RegExp | (url:URL) =>
1554  /// boolean, options?)`. Thin delegator to `Page::wait_for_url`
1555  /// (a function predicate is reduced to an always-true matcher; the
1556  /// function check is enforced by the core polling against the
1557  /// current URL).
1558  #[qjs(rename = "waitForURL")]
1559  pub async fn wait_for_url<'js>(&self, ctx: rquickjs::Ctx<'js>, url: rquickjs::Value<'js>) -> rquickjs::Result<()> {
1560    use crate::bindings::convert::FerriResultExt;
1561    let matcher = url_value_to_matcher(&ctx, url)?;
1562    self.inner.wait_for_url(matcher).await.into_js()
1563  }
1564
1565  /// Playwright: `page.waitForFunction(pageFunction: Function|string,
1566  /// arg?, options?: { timeout?, polling? })`. Function values get
1567  /// `String(fn)` (Playwright parity) and are evaluated as IIFEs
1568  /// inside the page. Returns the truthy value the function resolved
1569  /// to.
1570  #[qjs(rename = "waitForFunction")]
1571  pub async fn wait_for_function<'js>(
1572    &self,
1573    ctx: rquickjs::Ctx<'js>,
1574    page_function: rquickjs::Value<'js>,
1575    _arg: Opt<rquickjs::Value<'js>>,
1576    options: Opt<rquickjs::Value<'js>>,
1577  ) -> rquickjs::Result<rquickjs::Value<'js>> {
1578    #[derive(serde::Deserialize, Default)]
1579    #[serde(rename_all = "camelCase", default)]
1580    struct JsOpts {
1581      timeout: Option<u64>,
1582    }
1583    let opts: JsOpts = match options.0 {
1584      Some(v) if !v.is_undefined() && !v.is_null() => crate::bindings::convert::serde_from_js(&ctx, v)?,
1585      _ => JsOpts::default(),
1586    };
1587    let (src, is_fn) = crate::bindings::convert::extract_page_function(&ctx, page_function)?;
1588    // For a function: invoke it as `(<src>)()` so the body's return is
1589    // the polled value. For a string: use as-is (the user passes an
1590    // expression string, like Playwright).
1591    let expr = if is_fn.unwrap_or(false) {
1592      format!("({src})()")
1593    } else {
1594      src
1595    };
1596    let v = self
1597      .inner
1598      .wait_for_function(&expr, opts.timeout)
1599      .await
1600      .map_err(|e| crate::bindings::convert::to_rq_error(&e))?;
1601    crate::bindings::convert::json_to_js(&ctx, &v)
1602  }
1603
1604  #[qjs(rename = "waitForEvent")]
1605  pub async fn wait_for_event<'js>(
1606    &self,
1607    ctx: rquickjs::Ctx<'js>,
1608    event: String,
1609    timeout_ms: Opt<f64>,
1610  ) -> rquickjs::Result<rquickjs::Value<'js>> {
1611    use rquickjs::class::Class;
1612    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1613    let timeout = timeout_ms.0.unwrap_or(30_000.0) as u64;
1614    let event_lc = event.to_ascii_lowercase();
1615
1616    // `dialog` bypasses the broadcast — it registers a one-shot
1617    // handler on the per-page `DialogManager` so the claim is
1618    // synchronous at `did_open` time (mirrors Playwright's
1619    // `addDialogHandler` + `dialogDidOpen` flow exactly).
1620    if event_lc == "dialog" {
1621      let dialog = self
1622        .inner
1623        .wait_for_dialog(timeout)
1624        .await
1625        .map_err(|e| rquickjs::Error::new_from_js_message("Page.waitForEvent", "Error", e.to_string()))?;
1626      let wrapper = crate::bindings::dialog::DialogJs::new(dialog);
1627      let instance = Class::instance(ctx.clone(), wrapper)?;
1628      return rquickjs::IntoJs::into_js(instance, &ctx);
1629    }
1630    // Same pattern for `filechooser` — one-shot handler on the
1631    // per-page `FileChooserManager` so the claim is synchronous with
1632    // the backend event arrival.
1633    if event_lc == "filechooser" {
1634      let chooser = self
1635        .inner
1636        .wait_for_file_chooser(timeout)
1637        .await
1638        .map_err(|e| rquickjs::Error::new_from_js_message("Page.waitForEvent", "Error", e.to_string()))?;
1639      let wrapper = crate::bindings::file_chooser::FileChooserJs::new(chooser);
1640      let instance = Class::instance(ctx.clone(), wrapper)?;
1641      return rquickjs::IntoJs::into_js(instance, &ctx);
1642    }
1643    // And for `download` — same one-shot handler pattern via the
1644    // per-page `DownloadManager`.
1645    if event_lc == "download" {
1646      let download = self
1647        .inner
1648        .wait_for_download(timeout)
1649        .await
1650        .map_err(|e| rquickjs::Error::new_from_js_message("Page.waitForEvent", "Error", e.to_string()))?;
1651      let wrapper = crate::bindings::download::DownloadJs::new(download);
1652      let instance = Class::instance(ctx.clone(), wrapper)?;
1653      return rquickjs::IntoJs::into_js(instance, &ctx);
1654    }
1655
1656    let name = event_lc.clone();
1657    let ev = self
1658      .inner
1659      .events()
1660      .wait_for(move |e| match_event_name(&name, e), timeout)
1661      .await
1662      .map_err(|e| rquickjs::Error::new_from_js_message("Page.waitForEvent", "Error", e.to_string()))?;
1663    match ev {
1664      ferridriver::events::PageEvent::WebSocket(ws) => {
1665        let wrapper = crate::bindings::network::WebSocketJs::new(ws);
1666        let instance = Class::instance(ctx.clone(), wrapper)?;
1667        rquickjs::IntoJs::into_js(instance, &ctx)
1668      },
1669      ferridriver::events::PageEvent::Request(req)
1670      | ferridriver::events::PageEvent::RequestFinished(req)
1671      | ferridriver::events::PageEvent::RequestFailed(req) => {
1672        let wrapper = crate::bindings::network::RequestJs::new_with_page(req, self.inner.clone());
1673        let instance = Class::instance(ctx.clone(), wrapper)?;
1674        rquickjs::IntoJs::into_js(instance, &ctx)
1675      },
1676      ferridriver::events::PageEvent::Response(resp) => {
1677        let wrapper = crate::bindings::network::ResponseJs::new_with_page(resp, self.inner.clone());
1678        let instance = Class::instance(ctx.clone(), wrapper)?;
1679        rquickjs::IntoJs::into_js(instance, &ctx)
1680      },
1681      ferridriver::events::PageEvent::Dialog(dialog) => {
1682        // Reached via broadcast when a `page.events().on("dialog", cb)`
1683        // listener is also present — fall through to deliver the
1684        // live handle.
1685        let wrapper = crate::bindings::dialog::DialogJs::new(dialog);
1686        let instance = Class::instance(ctx.clone(), wrapper)?;
1687        rquickjs::IntoJs::into_js(instance, &ctx)
1688      },
1689      ferridriver::events::PageEvent::FileChooser(chooser) => {
1690        let wrapper = crate::bindings::file_chooser::FileChooserJs::new(chooser);
1691        let instance = Class::instance(ctx.clone(), wrapper)?;
1692        rquickjs::IntoJs::into_js(instance, &ctx)
1693      },
1694      ferridriver::events::PageEvent::Download(download) => {
1695        let wrapper = crate::bindings::download::DownloadJs::new(download);
1696        let instance = Class::instance(ctx.clone(), wrapper)?;
1697        rquickjs::IntoJs::into_js(instance, &ctx)
1698      },
1699      ferridriver::events::PageEvent::Console(msg) => {
1700        let wrapper = crate::bindings::console_message::ConsoleMessageJs::new(msg);
1701        let instance = Class::instance(ctx.clone(), wrapper)?;
1702        rquickjs::IntoJs::into_js(instance, &ctx)
1703      },
1704      // Playwright: `page.waitForEvent('pageerror'): Promise<Error>`.
1705      // Emit a native JS `Error` (not the `WebError` wrapper — that
1706      // class only exists for the context-scoped `'weberror'` surface).
1707      ferridriver::events::PageEvent::PageError(err) => {
1708        crate::bindings::web_error::build_native_error(&ctx, err.error())
1709      },
1710      other => page_event_to_js(&ctx, &other),
1711    }
1712  }
1713
1714  // ── Frames (sync, Playwright parity — task 3.8) ─────────────────────
1715  //
1716  // Mirrors `/tmp/playwright/packages/playwright-core/src/client/page.ts:258-275`
1717  // — `mainFrame`, `frames`, `frame(selector)` are all sync and read
1718  // from the page-owned [`ferridriver::frame_cache::FrameCache`].
1719
1720  /// Main frame of this page. Playwright: `page.mainFrame(): Frame`.
1721  /// Always returns a Frame — the cache is seeded inside `Page::new` /
1722  /// `Page::with_context` before the Page is handed out.
1723  #[qjs(rename = "mainFrame")]
1724  pub fn main_frame(&self) -> crate::bindings::frame::FrameJs {
1725    crate::bindings::frame::FrameJs::new(self.inner.main_frame())
1726  }
1727
1728  /// All non-detached frames on the page. Playwright:
1729  /// `page.frames(): Frame[]`.
1730  #[qjs(rename = "frames")]
1731  pub fn frames(&self) -> Vec<crate::bindings::frame::FrameJs> {
1732    self
1733      .inner
1734      .frames()
1735      .into_iter()
1736      .map(crate::bindings::frame::FrameJs::new)
1737      .collect()
1738  }
1739
1740  /// Playwright: `page.frameLocator(selector): FrameLocator`. Targets
1741  /// an `<iframe>` matching the selector at the page's main-frame
1742  /// scope.
1743  #[qjs(rename = "frameLocator")]
1744  pub fn frame_locator(&self, selector: String) -> crate::bindings::frame_locator::FrameLocatorJs {
1745    crate::bindings::frame_locator::FrameLocatorJs::new(self.inner.frame_locator(&selector))
1746  }
1747
1748  /// Locate a frame by name or URL. Accepts Playwright's union:
1749  /// `frame(string | { name?: string; url?: string })`.
1750  ///
1751  /// Distinct null/undefined handling (like emulateMedia in task 3.24)
1752  /// is not required here — both absent and explicit-null mean "no
1753  /// filter on this field", which matches Playwright's optional-field
1754  /// semantics.
1755  #[qjs(rename = "frame")]
1756  pub fn frame<'js>(
1757    &self,
1758    ctx: rquickjs::Ctx<'js>,
1759    selector: rquickjs::Value<'js>,
1760  ) -> rquickjs::Result<Option<crate::bindings::frame::FrameJs>> {
1761    let core_sel = if let Some(s) = selector.as_string() {
1762      ferridriver::options::FrameSelector::by_name(s.to_string()?)
1763    } else if let Some(obj) = selector.as_object() {
1764      let read = |key: &str| -> rquickjs::Result<Option<String>> {
1765        let v: rquickjs::Value<'_> = obj
1766          .get(key)
1767          .unwrap_or_else(|_| rquickjs::Value::new_undefined(ctx.clone()));
1768        if v.is_undefined() || v.is_null() {
1769          Ok(None)
1770        } else if let Some(s) = v.as_string() {
1771          Ok(Some(s.to_string()?))
1772        } else {
1773          Ok(None)
1774        }
1775      };
1776      ferridriver::options::FrameSelector {
1777        name: read("name")?,
1778        url: read("url")?,
1779      }
1780    } else {
1781      return Ok(None);
1782    };
1783
1784    if core_sel.is_empty() {
1785      return Ok(None);
1786    }
1787    Ok(self.inner.frame(core_sel).map(crate::bindings::frame::FrameJs::new))
1788  }
1789
1790  /// Playwright: `page.touchscreen: Touchscreen`.
1791  #[qjs(rename = "touchscreen", get)]
1792  pub fn touchscreen(&self) -> TouchscreenJs {
1793    TouchscreenJs {
1794      page: self.inner.clone(),
1795    }
1796  }
1797
1798  /// ferridriver-specific (NOT Playwright): structured AI snapshot
1799  /// `{ full: string, incremental?: string, refMap: Record<string, number> }`.
1800  /// Playwright's public accessibility API is `ariaSnapshot` (string);
1801  /// this richer shape feeds the MCP server's incremental tracking.
1802  #[qjs(rename = "snapshotForAI")]
1803  pub async fn snapshot_for_ai<'js>(
1804    &self,
1805    ctx: rquickjs::Ctx<'js>,
1806    options: rquickjs::function::Opt<rquickjs::Value<'js>>,
1807  ) -> rquickjs::Result<rquickjs::Value<'js>> {
1808    let core_opts = match options.0 {
1809      None => ferridriver::snapshot::SnapshotOptions::default(),
1810      Some(v) if v.is_undefined() || v.is_null() => ferridriver::snapshot::SnapshotOptions::default(),
1811      Some(v) => {
1812        #[derive(serde::Deserialize, Default)]
1813        #[serde(rename_all = "camelCase", default)]
1814        struct JsSnap {
1815          depth: Option<i32>,
1816          track: Option<String>,
1817        }
1818        let parsed: JsSnap = crate::bindings::convert::serde_from_js(&ctx, v)?;
1819        ferridriver::snapshot::SnapshotOptions {
1820          depth: parsed.depth,
1821          track: parsed.track,
1822        }
1823      },
1824    };
1825    let snap = self.inner.snapshot_for_ai(core_opts).await.into_js()?;
1826    let obj = rquickjs::Object::new(ctx.clone())?;
1827    obj.set("full", snap.full)?;
1828    if let Some(inc) = snap.incremental {
1829      obj.set("incremental", inc)?;
1830    }
1831    let ref_map = rquickjs::Object::new(ctx.clone())?;
1832    for (k, v) in snap.ref_map {
1833      ref_map.set(k, v as f64)?;
1834    }
1835    obj.set("refMap", ref_map)?;
1836    rquickjs::IntoJs::into_js(obj, &ctx)
1837  }
1838
1839  /// Playwright `page.ariaSnapshot(options?): Promise<string>`.
1840  #[qjs(rename = "ariaSnapshot")]
1841  pub async fn aria_snapshot<'js>(
1842    &self,
1843    ctx: rquickjs::Ctx<'js>,
1844    options: rquickjs::function::Opt<rquickjs::Value<'js>>,
1845  ) -> rquickjs::Result<String> {
1846    let core_opts = match options.0 {
1847      Some(v) if !v.is_undefined() && !v.is_null() => {
1848        #[derive(serde::Deserialize, Default)]
1849        #[serde(rename_all = "camelCase", default)]
1850        struct JsSnap {
1851          depth: Option<i32>,
1852          track: Option<String>,
1853        }
1854        let p: JsSnap = crate::bindings::convert::serde_from_js(&ctx, v)?;
1855        ferridriver::snapshot::SnapshotOptions {
1856          depth: p.depth,
1857          track: p.track,
1858        }
1859      },
1860      _ => ferridriver::snapshot::SnapshotOptions::default(),
1861    };
1862    self.inner.aria_snapshot(core_opts).await.into_js()
1863  }
1864
1865  /// Playwright: `page.exposeFunction(name, callback)`. Binds
1866  /// `window[name]` to a page-side proxy that asynchronously invokes
1867  /// `callback(args)` in the script context.
1868  ///
1869  /// The callback receives the args as a single array. The page-side
1870  /// call resolves to `null` since the script-side callback runs
1871  /// asynchronously (Rust core's `ExposedFn` is sync + JSON-in/out;
1872  /// QuickJS dispatch is async-only).
1873  #[qjs(rename = "exposeFunction")]
1874  pub async fn expose_function<'js>(
1875    &self,
1876    ctx: rquickjs::Ctx<'js>,
1877    name: String,
1878    callback: rquickjs::Function<'js>,
1879  ) -> rquickjs::Result<()> {
1880    let async_ctx = self.async_ctx.clone().ok_or_else(|| {
1881      rquickjs::Error::new_from_js_message(
1882        "page.exposeFunction",
1883        "Error",
1884        "page.exposeFunction requires the script engine's AsyncContext (install_page)".to_string(),
1885      )
1886    })?;
1887    // Stash the JS callback in the native page-callbacks registry keyed
1888    // by binding name — cross-task dispatch (the Rust `ExposedFn` runs
1889    // outside the QuickJS context) restores it by name via `async_with!`.
1890    let saved = rquickjs::Persistent::save(&ctx, callback);
1891    with_page_callbacks(&ctx, |r| r.exposed.insert(name.clone(), saved))?;
1892
1893    let cb: ferridriver::events::ExposedFn = std::sync::Arc::new({
1894      let name = name.clone();
1895      move |args: Vec<serde_json::Value>| {
1896        let async_ctx = async_ctx.clone();
1897        let name = name.clone();
1898        // Playwright delivers the callback's return value (awaiting a
1899        // returned Promise) to the page-side caller. Run the JS
1900        // callback on the engine context via `async_with`, await it if
1901        // it returns a thenable, convert to JSON and hand it back so
1902        // the backend resolves the page binding with the REAL value —
1903        // not `null` (the previous fire-and-forget behaviour was a
1904        // Playwright incompatibility).
1905        Box::pin(async move {
1906          let out: rquickjs::Result<serde_json::Value> = rquickjs::async_with!(async_ctx => |ctx| {
1907            let f = with_page_callbacks(&ctx, |r| r.exposed.get(&name).cloned())?
1908              .ok_or_else(|| {
1909                rquickjs::Error::new_from_js_message(
1910                  "page.exposeFunction",
1911                  "Error",
1912                  "exposed callback gone".to_string(),
1913                )
1914              })?
1915              .restore(&ctx)?;
1916            // Playwright spreads the page-side call arguments into the
1917            // callback: `window.fn(a, b)` -> `callback(a, b)` (see
1918            // playwright-core client/page.ts `(...args) => callback(...args)`).
1919            // Build a spread arg list, not a single array.
1920            let mut call_args = rquickjs::function::Args::new_unsized(ctx.clone());
1921            for v in args {
1922              // `json_to_js` (NOT `serde_to_js`): a transitive dep
1923              // force-enables `serde_json/arbitrary_precision`, under
1924              // which rquickjs-serde turns every number into a
1925              // `{$serde_json::private::Number}` object. The AP-safe
1926              // walker keeps numbers as JS numbers.
1927              call_args.push_arg(crate::bindings::convert::json_to_js(&ctx, &v)?)?;
1928            }
1929            let mp: rquickjs::promise::MaybePromise<'_> = call_args.apply(&f)?;
1930            let res = mp.into_future::<rquickjs::Value<'_>>().await?;
1931            // Round-trip through QuickJS `JSON.stringify` + serde_json's
1932            // own parser — AP-safe both ways (a non-serde_json
1933            // deserializer mis-handles numbers under
1934            // `arbitrary_precision`). `undefined`/function -> null.
1935            let json = match ctx.json_stringify(res)? {
1936              Some(s) => serde_json::from_str(&s.to_string()?).unwrap_or(serde_json::Value::Null),
1937              None => serde_json::Value::Null,
1938            };
1939            Ok(json)
1940          })
1941          .await;
1942          out.unwrap_or(serde_json::Value::Null)
1943        })
1944      }
1945    });
1946    self.inner.expose_function(&name, cb).await.into_js()
1947  }
1948
1949  /// ferridriver-specific (NOT Playwright): `startScreencast(quality,
1950  /// maxWidth, maxHeight, callback)`. Callback receives `{ frame:
1951  /// Uint8Array, timestamp: number }` per frame. Backed by CDP
1952  /// `Page.startScreencast`; no Playwright client equivalent.
1953  #[qjs(rename = "startScreencast")]
1954  pub async fn start_screencast<'js>(
1955    &self,
1956    ctx: rquickjs::Ctx<'js>,
1957    quality: u8,
1958    max_width: u32,
1959    max_height: u32,
1960    callback: rquickjs::Function<'js>,
1961  ) -> rquickjs::Result<()> {
1962    let async_ctx = self.async_ctx.clone().ok_or_else(|| {
1963      rquickjs::Error::new_from_js_message(
1964        "page.startScreencast",
1965        "Error",
1966        "page.startScreencast requires the script engine's AsyncContext (install_page)".to_string(),
1967      )
1968    })?;
1969    let saved = rquickjs::Persistent::save(&ctx, callback);
1970    with_page_callbacks(&ctx, |r| r.screencast = Some(saved))?;
1971    // `start_screencast` returns `(rx, shutdown_tx)`. The QuickJS
1972    // binding doesn't expose a stop hook here; the shutdown signal is
1973    // dropped (which Chrome's stop-screencast path will subsequently
1974    // see via teardown), and we forward frames until the listener
1975    // exits on its own.
1976    let (mut rx, _shutdown) = self
1977      .inner
1978      .start_screencast(quality, max_width, max_height)
1979      .await
1980      .into_js()?;
1981    tokio::spawn(async move {
1982      while let Some((bytes, ts)) = rx.recv().await {
1983        let _: rquickjs::Result<()> = rquickjs::async_with!(async_ctx => |ctx| {
1984          let f = with_page_callbacks(&ctx, |r| r.screencast.clone())?
1985            .ok_or_else(|| rquickjs::Error::new_from_js_message("page.startScreencast", "Error", "screencast callback gone".to_string()))?
1986            .restore(&ctx)?;
1987          let payload = rquickjs::Object::new(ctx.clone())?;
1988          let buf = rquickjs::TypedArray::<u8>::new(ctx.clone(), bytes)?;
1989          payload.set("frame", buf)?;
1990          payload.set("timestamp", ts)?;
1991          let _: rquickjs::Value<'_> = f.call((payload,))?;
1992          Ok(())
1993        })
1994        .await;
1995      }
1996    });
1997    Ok(())
1998  }
1999
2000  /// ferridriver-specific (NOT Playwright): stop the screencast
2001  /// started by `startScreencast`.
2002  #[qjs(rename = "stopScreencast")]
2003  pub async fn stop_screencast(&self) -> rquickjs::Result<()> {
2004    self.inner.stop_screencast().await.into_js()
2005  }
2006}
2007
2008/// Playwright `Touchscreen`. Construct via `page.touchscreen`.
2009#[derive(rquickjs::JsLifetime, rquickjs::class::Trace)]
2010#[rquickjs::class(rename = "Touchscreen")]
2011pub struct TouchscreenJs {
2012  #[qjs(skip_trace)]
2013  page: std::sync::Arc<ferridriver::Page>,
2014}
2015
2016#[rquickjs::methods]
2017impl TouchscreenJs {
2018  /// Playwright: `touchscreen.tap(x, y)`.
2019  #[qjs(rename = "tap")]
2020  pub async fn tap(&self, x: f64, y: f64) -> rquickjs::Result<()> {
2021    self.page.touchscreen().tap(x, y).await.into_js()
2022  }
2023}
2024
2025/// Shape of `page.screenshot` options accepted from JS. Full Playwright
2026/// `PageScreenshotOptions` surface per
2027/// `/tmp/playwright/packages/playwright-core/types/types.d.ts:23280`.
2028#[derive(Debug, Default, Deserialize)]
2029#[serde(default, rename_all = "camelCase")]
2030struct JsScreenshotOptions {
2031  animations: Option<String>,
2032  caret: Option<String>,
2033  clip: Option<JsClipRect>,
2034  full_page: Option<bool>,
2035  #[serde(rename = "type")]
2036  format: Option<String>,
2037  // `mask` is NOT decoded here: Playwright takes `Locator[]`, and a
2038  // `LocatorJs` class instance is not serde-deserialisable. It is read
2039  // manually from the options object via `parse_mask_locators` before
2040  // this struct is built.
2041  #[serde(skip)]
2042  _mask_placeholder: (),
2043  mask_color: Option<String>,
2044  omit_background: Option<bool>,
2045  path: Option<String>,
2046  quality: Option<i64>,
2047  scale: Option<String>,
2048  style: Option<String>,
2049  timeout: Option<u64>,
2050}
2051
2052#[derive(Debug, Default, Deserialize, Clone, Copy)]
2053struct JsClipRect {
2054  x: f64,
2055  y: f64,
2056  width: f64,
2057  height: f64,
2058}
2059
2060impl From<JsClipRect> for ferridriver::options::ClipRect {
2061  fn from(c: JsClipRect) -> Self {
2062    Self {
2063      x: c.x,
2064      y: c.y,
2065      width: c.width,
2066      height: c.height,
2067    }
2068  }
2069}
2070
2071/// Read `mask: Locator[]` from the screenshot options object. Each entry
2072/// must be a `LocatorJs` class instance (Playwright's `mask?: Locator[]`);
2073/// the core `Locator` is cloned out so the selector string is extracted
2074/// Rust-side before backend dispatch.
2075fn parse_mask_locators<'js>(obj: &rquickjs::Object<'js>) -> rquickjs::Result<Vec<ferridriver::Locator>> {
2076  let v: rquickjs::Value<'js> = obj.get("mask")?;
2077  if v.is_undefined() || v.is_null() {
2078    return Ok(Vec::new());
2079  }
2080  let arr = v.into_array().ok_or_else(|| {
2081    rquickjs::Error::new_from_js_message("screenshot options", "mask", "expected an array of Locator")
2082  })?;
2083  let mut out = Vec::with_capacity(arr.len());
2084  for item in arr.iter::<rquickjs::Value<'js>>() {
2085    let item = item?;
2086    if let Ok(class) = rquickjs::Class::<LocatorJs>::from_value(&item) {
2087      out.push(class.borrow().inner_ref().clone());
2088    } else {
2089      return Err(rquickjs::Error::new_from_js_message(
2090        "screenshot options",
2091        "mask",
2092        "each mask entry must be a Locator instance",
2093      ));
2094    }
2095  }
2096  Ok(out)
2097}
2098
2099fn parse_screenshot_options<'js>(
2100  ctx: &rquickjs::Ctx<'js>,
2101  value: Opt<rquickjs::Value<'js>>,
2102) -> rquickjs::Result<ferridriver::options::ScreenshotOptions> {
2103  match value.0 {
2104    Some(v) if !v.is_undefined() && !v.is_null() => {
2105      let mask = match v.as_object() {
2106        Some(obj) => parse_mask_locators(obj)?,
2107        None => Vec::new(),
2108      };
2109      let js: JsScreenshotOptions = serde_from_js(ctx, v)?;
2110      Ok(ferridriver::options::ScreenshotOptions {
2111        animations: js.animations,
2112        caret: js.caret,
2113        clip: js.clip.map(Into::into),
2114        full_page: js.full_page,
2115        format: js.format,
2116        mask,
2117        mask_color: js.mask_color,
2118        omit_background: js.omit_background,
2119        path: js.path.map(std::path::PathBuf::from),
2120        quality: js.quality,
2121        scale: js.scale,
2122        style: js.style,
2123        timeout: js.timeout,
2124      })
2125    },
2126    _ => Ok(ferridriver::options::ScreenshotOptions::default()),
2127  }
2128}
2129
2130/// Subset of Playwright's `PDFOptions` exposed to scripts. Path fields and
2131/// advanced page-range/margin controls are not wired yet; users who need
2132/// those can use `page.evaluate` with `window.print` or extend here.
2133#[derive(Debug, Default, Deserialize)]
2134#[serde(default, rename_all = "camelCase")]
2135struct JsPdfOptions {
2136  format: Option<String>,
2137  landscape: Option<bool>,
2138  print_background: Option<bool>,
2139  scale: Option<f64>,
2140  display_header_footer: Option<bool>,
2141  header_template: Option<String>,
2142  footer_template: Option<String>,
2143  page_ranges: Option<String>,
2144  prefer_css_page_size: Option<bool>,
2145  outline: Option<bool>,
2146  tagged: Option<bool>,
2147}
2148
2149fn parse_pdf_options<'js>(
2150  ctx: &rquickjs::Ctx<'js>,
2151  value: Opt<rquickjs::Value<'js>>,
2152) -> rquickjs::Result<ferridriver::options::PdfOptions> {
2153  match value.0 {
2154    Some(v) if !v.is_undefined() && !v.is_null() => {
2155      let js: JsPdfOptions = serde_from_js(ctx, v)?;
2156      Ok(ferridriver::options::PdfOptions {
2157        format: js.format,
2158        path: None,
2159        scale: js.scale,
2160        display_header_footer: js.display_header_footer,
2161        header_template: js.header_template,
2162        footer_template: js.footer_template,
2163        print_background: js.print_background,
2164        landscape: js.landscape,
2165        page_ranges: js.page_ranges,
2166        width: None,
2167        height: None,
2168        margin: None,
2169        prefer_css_page_size: js.prefer_css_page_size,
2170        outline: js.outline,
2171        tagged: js.tagged,
2172      })
2173    },
2174    _ => Ok(ferridriver::options::PdfOptions::default()),
2175  }
2176}
2177
2178fn match_event_name(name: &str, ev: &ferridriver::events::PageEvent) -> bool {
2179  use ferridriver::events::PageEvent;
2180  matches!(
2181    (name, ev),
2182    ("console", PageEvent::Console(_))
2183      | ("request", PageEvent::Request(_))
2184      | ("response", PageEvent::Response(_))
2185      | ("requestfinished", PageEvent::RequestFinished(_))
2186      | ("requestfailed", PageEvent::RequestFailed(_))
2187      | ("websocket", PageEvent::WebSocket(_))
2188      | ("dialog", PageEvent::Dialog(_))
2189      | ("filechooser", PageEvent::FileChooser(_))
2190      | ("frameattached", PageEvent::FrameAttached(_))
2191      | ("framedetached", PageEvent::FrameDetached { .. })
2192      | ("framenavigated", PageEvent::FrameNavigated(_))
2193      | ("load", PageEvent::Load)
2194      | ("domcontentloaded", PageEvent::DomContentLoaded)
2195      | ("close", PageEvent::Close)
2196      | ("pageerror", PageEvent::PageError(_))
2197      | ("download", PageEvent::Download(_))
2198  )
2199}
2200
2201/// Build the `page.waitForEvent` payload JS object directly — no
2202/// serde_json::Value middle allocation. `FrameAttached`/`Navigated`
2203/// serialise their `FrameInfo` through rquickjs-serde (also direct).
2204fn page_event_to_js<'js>(
2205  ctx: &rquickjs::Ctx<'js>,
2206  ev: &ferridriver::events::PageEvent,
2207) -> rquickjs::Result<rquickjs::Value<'js>> {
2208  use ferridriver::events::PageEvent;
2209  let obj = || rquickjs::Object::new(ctx.clone());
2210  match ev {
2211    PageEvent::Console(msg) => {
2212      let loc = msg.location();
2213      let o = obj()?;
2214      o.set("type", msg.type_str())?;
2215      o.set("text", msg.text())?;
2216      let l = obj()?;
2217      l.set("url", loc.url.as_str())?;
2218      l.set("lineNumber", f64::from(loc.line_number))?;
2219      l.set("columnNumber", f64::from(loc.column_number))?;
2220      o.set("location", l)?;
2221      o.set("timestamp", msg.timestamp())?;
2222      o.set("argsCount", msg.args().len() as f64)?;
2223      Ok(o.into_value())
2224    },
2225    PageEvent::Dialog(d) => {
2226      let o = obj()?;
2227      o.set("type", d.dialog_type().as_str())?;
2228      o.set("message", d.message())?;
2229      o.set("defaultValue", d.default_value())?;
2230      Ok(o.into_value())
2231    },
2232    PageEvent::FileChooser(fc) => {
2233      let o = obj()?;
2234      o.set("isMultiple", fc.is_multiple())?;
2235      Ok(o.into_value())
2236    },
2237    PageEvent::FrameAttached(f) | PageEvent::FrameNavigated(f) => crate::bindings::convert::serde_to_js(ctx, f),
2238    PageEvent::FrameDetached { frame_id } => {
2239      let o = obj()?;
2240      o.set("frameId", frame_id.as_str())?;
2241      Ok(o.into_value())
2242    },
2243    PageEvent::Download(d) => {
2244      let o = obj()?;
2245      o.set("url", d.url())?;
2246      o.set("suggestedFilename", d.suggested_filename())?;
2247      Ok(o.into_value())
2248    },
2249    PageEvent::Load => {
2250      let o = obj()?;
2251      o.set("type", "load")?;
2252      Ok(o.into_value())
2253    },
2254    PageEvent::DomContentLoaded => {
2255      let o = obj()?;
2256      o.set("type", "domcontentloaded")?;
2257      Ok(o.into_value())
2258    },
2259    PageEvent::Close => {
2260      let o = obj()?;
2261      o.set("type", "close")?;
2262      Ok(o.into_value())
2263    },
2264    PageEvent::PageError(err) => {
2265      let details = err.error();
2266      let o = obj()?;
2267      o.set("name", details.name.as_str())?;
2268      o.set("message", details.message.as_str())?;
2269      o.set("stack", details.stack.as_str())?;
2270      Ok(o.into_value())
2271    },
2272    _ => Ok(rquickjs::Value::new_null(ctx.clone())),
2273  }
2274}
2275
2276/// ECMAScript `ToBoolean` for a predicate's return value.
2277fn js_truthy(v: &rquickjs::Value<'_>) -> bool {
2278  if v.is_undefined() || v.is_null() {
2279    return false;
2280  }
2281  if let Some(b) = v.as_bool() {
2282    return b;
2283  }
2284  if let Some(i) = v.as_int() {
2285    return i != 0;
2286  }
2287  if let Some(f) = v.as_float() {
2288    return f != 0.0 && !f.is_nan();
2289  }
2290  if let Some(s) = v.as_string() {
2291    return !s.to_string().unwrap_or_default().is_empty();
2292  }
2293  true
2294}
2295
2296/// Call a JS predicate and resolve `boolean | Promise<boolean>`.
2297pub(crate) async fn call_predicate_truthy<'js>(
2298  pred: &rquickjs::Function<'js>,
2299  arg: impl rquickjs::IntoJs<'js>,
2300  ctx: &rquickjs::Ctx<'js>,
2301) -> rquickjs::Result<bool> {
2302  let arg = arg.into_js(ctx)?;
2303  let mp: rquickjs::promise::MaybePromise<'js> = pred.call((arg,))?;
2304  let v: rquickjs::Value<'js> = mp.into_future().await?;
2305  Ok(js_truthy(&v))
2306}
2307
2308/// Binding-side wait loop for a `(Request) => boolean` predicate: the
2309/// predicate needs a live `RequestJs`, so it runs in the JS runtime
2310/// while the loop drains the page event broadcast.
2311async fn wait_request_predicate<'js>(
2312  ctx: rquickjs::Ctx<'js>,
2313  page: Arc<Page>,
2314  pred: rquickjs::Function<'js>,
2315  timeout_ms: u64,
2316) -> rquickjs::Result<crate::bindings::network::RequestJs> {
2317  use ferridriver::events::PageEvent;
2318  use rquickjs::class::Class;
2319  let mut rx = page.events().subscribe();
2320  let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
2321  loop {
2322    let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
2323    if remaining.is_zero() {
2324      return Err(rquickjs::Error::new_from_js_message(
2325        "page.waitForRequest",
2326        "TimeoutError",
2327        format!("Timeout {timeout_ms}ms exceeded while waiting for request"),
2328      ));
2329    }
2330    match tokio::time::timeout(remaining, rx.recv()).await {
2331      Ok(Ok(PageEvent::Request(req))) => {
2332        let probe = crate::bindings::network::RequestJs::new_with_page(req.clone(), page.clone());
2333        let inst = Class::instance(ctx.clone(), probe)?;
2334        if call_predicate_truthy(&pred, inst, &ctx).await? {
2335          return Ok(crate::bindings::network::RequestJs::new_with_page(req, page.clone()));
2336        }
2337      },
2338      Ok(Ok(_) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => {},
2339      Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => {
2340        return Err(rquickjs::Error::new_from_js_message(
2341          "page.waitForRequest",
2342          "Error",
2343          "page closed while waiting for request".to_string(),
2344        ));
2345      },
2346      Err(_) => {
2347        return Err(rquickjs::Error::new_from_js_message(
2348          "page.waitForRequest",
2349          "TimeoutError",
2350          format!("Timeout {timeout_ms}ms exceeded while waiting for request"),
2351        ));
2352      },
2353    }
2354  }
2355}
2356
2357/// Response-side twin of [`wait_request_predicate`].
2358async fn wait_response_predicate<'js>(
2359  ctx: rquickjs::Ctx<'js>,
2360  page: Arc<Page>,
2361  pred: rquickjs::Function<'js>,
2362  timeout_ms: u64,
2363) -> rquickjs::Result<crate::bindings::network::ResponseJs> {
2364  use ferridriver::events::PageEvent;
2365  use rquickjs::class::Class;
2366  let mut rx = page.events().subscribe();
2367  let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
2368  loop {
2369    let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
2370    if remaining.is_zero() {
2371      return Err(rquickjs::Error::new_from_js_message(
2372        "page.waitForResponse",
2373        "TimeoutError",
2374        format!("Timeout {timeout_ms}ms exceeded while waiting for response"),
2375      ));
2376    }
2377    match tokio::time::timeout(remaining, rx.recv()).await {
2378      Ok(Ok(PageEvent::Response(resp))) => {
2379        let probe = crate::bindings::network::ResponseJs::new_with_page(resp.clone(), page.clone());
2380        let inst = Class::instance(ctx.clone(), probe)?;
2381        if call_predicate_truthy(&pred, inst, &ctx).await? {
2382          return Ok(crate::bindings::network::ResponseJs::new_with_page(resp, page.clone()));
2383        }
2384      },
2385      Ok(Ok(_) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => {},
2386      Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => {
2387        return Err(rquickjs::Error::new_from_js_message(
2388          "page.waitForResponse",
2389          "Error",
2390          "page closed while waiting for response".to_string(),
2391        ));
2392      },
2393      Err(_) => {
2394        return Err(rquickjs::Error::new_from_js_message(
2395          "page.waitForResponse",
2396          "TimeoutError",
2397          format!("Timeout {timeout_ms}ms exceeded while waiting for response"),
2398        ));
2399      },
2400    }
2401  }
2402}
2403
2404/// Lower a JS `string | RegExp` value into a [`UrlMatcher`]. Mirrors
2405/// the NAPI `JsRegExpLike` shape — the JS RegExp's `source` and
2406/// `flags` getters drive `UrlMatcher::regex_from_source`. Plain
2407/// strings go through `UrlMatcher::glob`.
2408pub(crate) fn url_value_to_matcher<'js>(
2409  ctx: &rquickjs::Ctx<'js>,
2410  value: rquickjs::Value<'js>,
2411) -> rquickjs::Result<ferridriver::url_matcher::UrlMatcher> {
2412  use crate::bindings::convert::FerriResultExt;
2413  if let Some(s) = value.as_string() {
2414    let glob = s.to_string()?;
2415    return ferridriver::url_matcher::UrlMatcher::glob(glob).into_js();
2416  }
2417  if let Some(obj) = value.as_object() {
2418    // RegExp constructor.name === "RegExp" — also has `source` (string)
2419    // and `flags` (string) getters per ECMAScript spec.
2420    let source: rquickjs::Result<String> = obj.get("source");
2421    let flags: rquickjs::Result<String> = obj.get("flags");
2422    if let (Ok(source), Ok(flags)) = (source, flags) {
2423      return ferridriver::url_matcher::UrlMatcher::regex_from_source(&source, &flags).into_js();
2424    }
2425  }
2426  let _ = ctx;
2427  Err(rquickjs::Error::new_from_js_message(
2428    "Page.waitFor*",
2429    "url",
2430    "expected string | RegExp".to_string(),
2431  ))
2432}
2433
2434/// Lower a JS `string | RegExp` value into a Rust
2435/// [`ferridriver::options::StringOrRegex`] for every `getBy*` matcher
2436/// and `RoleOptions.name`. Reads `source` / `flags` via the RegExp
2437/// prototype getters (same technique as NAPI's `JsRegExpLike`), so a
2438/// real JS `RegExp` round-trips without a wire-shape escape.
2439pub(crate) fn string_or_regex_from_js(
2440  value: rquickjs::Value<'_>,
2441) -> rquickjs::Result<ferridriver::options::StringOrRegex> {
2442  if let Some(s) = value.as_string() {
2443    return Ok(ferridriver::options::StringOrRegex::String(s.to_string()?));
2444  }
2445  if let Some(obj) = value.as_object() {
2446    let source: rquickjs::Result<String> = obj.get("source");
2447    let flags: rquickjs::Result<String> = obj.get("flags");
2448    if let (Ok(source), Ok(flags)) = (source, flags) {
2449      return Ok(ferridriver::options::StringOrRegex::Regex { source, flags });
2450    }
2451  }
2452  Err(rquickjs::Error::new_from_js_message(
2453    "getBy*",
2454    "text",
2455    "expected string | RegExp".to_string(),
2456  ))
2457}
2458
2459/// Parse `{ exact?: boolean }` options for `getByText` / `getByLabel` / etc.
2460pub(crate) fn parse_text_options(
2461  value: rquickjs::function::Opt<rquickjs::Value<'_>>,
2462) -> ferridriver::options::TextOptions {
2463  let Some(v) = value.0 else {
2464    return ferridriver::options::TextOptions::default();
2465  };
2466  if v.is_undefined() || v.is_null() {
2467    return ferridriver::options::TextOptions::default();
2468  }
2469  let Some(obj) = v.as_object() else {
2470    return ferridriver::options::TextOptions::default();
2471  };
2472  let exact: Option<bool> = obj.get("exact").ok();
2473  ferridriver::options::TextOptions { exact }
2474}
2475
2476/// Parse the `getByRole` options bag. `{ name?: string | RegExp,
2477/// exact?, checked?, disabled?, expanded?, level?, pressed?,
2478/// selected?, includeHidden? }`. Mirrors Playwright's `ByRoleOptions`.
2479pub(crate) fn parse_role_options<'js>(
2480  value: rquickjs::function::Opt<rquickjs::Value<'js>>,
2481) -> rquickjs::Result<ferridriver::options::RoleOptions> {
2482  let Some(v) = value.0 else {
2483    return Ok(ferridriver::options::RoleOptions::default());
2484  };
2485  if v.is_undefined() || v.is_null() {
2486    return Ok(ferridriver::options::RoleOptions::default());
2487  }
2488  let Some(obj) = v.as_object() else {
2489    return Ok(ferridriver::options::RoleOptions::default());
2490  };
2491  let name_val: Option<rquickjs::Value<'js>> = obj.get("name").ok();
2492  let name = match name_val {
2493    Some(val) if !val.is_undefined() && !val.is_null() => Some(string_or_regex_from_js(val)?),
2494    _ => None,
2495  };
2496  let exact: Option<bool> = obj.get("exact").ok();
2497  let checked: Option<bool> = obj.get("checked").ok();
2498  let disabled: Option<bool> = obj.get("disabled").ok();
2499  let expanded: Option<bool> = obj.get("expanded").ok();
2500  let level: Option<i32> = obj.get("level").ok();
2501  let pressed: Option<bool> = obj.get("pressed").ok();
2502  let selected: Option<bool> = obj.get("selected").ok();
2503  let include_hidden: Option<bool> = obj.get("includeHidden").ok();
2504  Ok(ferridriver::options::RoleOptions {
2505    name,
2506    exact,
2507    checked,
2508    disabled,
2509    expanded,
2510    level,
2511    pressed,
2512    selected,
2513    include_hidden,
2514  })
2515}