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