Skip to main content

ferridriver_script/bindings/
convert.rs

1//! Conversion helpers between ferridriver core types and `rquickjs` values.
2
3use ferridriver::FerriError;
4use rquickjs::object::Property;
5use rquickjs::{Ctx, Function, Object, Value};
6use serde::Serialize;
7use serde::de::DeserializeOwned;
8
9/// Convert a [`FerriError`] into an `rquickjs::Error` suitable for throwing
10/// out of a binding method.
11///
12/// The JS-visible `message` is the `Display` output of the core error, which
13/// already matches Playwright's phrasing for the variants that have a
14/// Playwright analogue (see `ferridriver::error`). The `from` / `to` labels
15/// are static strings used by `rquickjs` for its own error rendering.
16pub fn to_rq_error(err: &FerriError) -> rquickjs::Error {
17  rquickjs::Error::new_from_js_message("ferridriver", err.name(), err.to_string())
18}
19
20/// Convert a JS millisecond value (`f64`) into a `u64`, clamping
21/// negatives to `0`. Single home for the otherwise-repeated f64->u64
22/// timeout cast so call sites stay lint-clean.
23#[must_use]
24pub fn ms_f64_to_u64(ms: f64) -> u64 {
25  if ms <= 0.0 {
26    return 0;
27  }
28  // `ms` is now strictly positive and finite-or-inf; `f64::min` against
29  // `u64::MAX` keeps the cast in range, and the fractional part is
30  // truncated (sub-millisecond precision is irrelevant for timeouts).
31  #[allow(
32    clippy::cast_possible_truncation,
33    clippy::cast_sign_loss,
34    clippy::cast_precision_loss
35  )]
36  {
37    ms.min(u64::MAX as f64) as u64
38  }
39}
40
41/// Adapter: `Result<T, FerriError>` into `rquickjs::Result<T>`.
42pub trait FerriResultExt<T> {
43  fn into_js(self) -> rquickjs::Result<T>;
44}
45
46impl<T> FerriResultExt<T> for Result<T, FerriError> {
47  fn into_js(self) -> rquickjs::Result<T> {
48    self.map_err(|e| to_rq_error(&e))
49  }
50}
51
52/// Convert any `serde::Serialize` value into a JS value via
53/// `rquickjs-serde` — direct `T` -> `rquickjs::Value`, no JSON string
54/// and no `serde_json::Value` middle allocation. Used for binding
55/// returns (cookies, storage state, parsed JSON bodies).
56pub fn serde_to_js<'js, T: Serialize>(ctx: &Ctx<'js>, value: &T) -> rquickjs::Result<Value<'js>> {
57  rquickjs_serde::to_value(ctx.clone(), value)
58    .map_err(|e| rquickjs::Error::new_from_js_message("serde", "serialize", e.to_string()))
59}
60
61/// Build a JS `Array<{ name, value }>` straight from name/value pairs
62/// via `rquickjs-serde` — no `serde_json::json!` / `serde_json::Value`
63/// middle allocation. Used by `request`/`response`/`apiResponse`
64/// `headersArray()`.
65pub fn name_value_array_to_js<'js, S: AsRef<str>>(ctx: &Ctx<'js>, pairs: &[(S, S)]) -> rquickjs::Result<Value<'js>> {
66  #[derive(Serialize)]
67  struct NameValue<'a> {
68    name: &'a str,
69    value: &'a str,
70  }
71  let view: Vec<NameValue<'_>> = pairs
72    .iter()
73    .map(|(n, v)| NameValue {
74      name: n.as_ref(),
75      value: v.as_ref(),
76    })
77    .collect();
78  serde_to_js(ctx, &view)
79}
80
81/// Inverse of [`serde_to_js`] — deserialize a JS value into a Rust type
82/// via `rquickjs-serde` (direct `Value` -> `T`). Integral-float ->
83/// integer coercion, `undefined`/function-property drop, Proxy and
84/// cycle handling all hold (covered by the rquickjs-serde test suite),
85/// so the option-bag call sites keep their prior semantics without our
86/// own hand-rolled walker.
87pub fn serde_from_js<'js, T: DeserializeOwned>(_ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<T> {
88  rquickjs_serde::from_value(value)
89    .map_err(|e| rquickjs::Error::new_from_js_message("serde", "deserialize", e.to_string()))
90}
91
92/// Define `key` as an own data property (writable/enumerable/
93/// configurable, like a normal JS literal field) on `obj`.
94///
95/// Untrusted input — page-controlled `evaluate` results, script args —
96/// can contain a `__proto__` (or other accessor) key. `Object::set`
97/// routes through `[[Set]]`, so such a key would invoke the
98/// `__proto__` setter (retargeting the object's prototype) or any
99/// inherited setter. `Object::prop` lowers to `JS_DefineProperty`,
100/// which always creates an own data property and never triggers a
101/// setter — the value lands exactly where a JSON consumer expects.
102fn define_own<'js, V: rquickjs::IntoJs<'js>>(obj: &Object<'js>, key: &str, value: V) -> rquickjs::Result<()> {
103  obj.prop(key, Property::from(value).writable().enumerable().configurable())
104}
105
106/// Build an `rquickjs::Value` from a `serde_json::Value`. Thin wrapper
107/// over [`serde_to_js`], kept for the script-args call site in
108/// `engine.rs`.
109pub(crate) fn json_to_js<'js>(ctx: &Ctx<'js>, v: &serde_json::Value) -> rquickjs::Result<Value<'js>> {
110  // A transitive dep force-enables `serde_json/arbitrary_precision`
111  // workspace-wide. Under that feature `serde_json::Value::Number`'s
112  // `Serialize` emits a private one-key map, so routing through
113  // `serde_to_js` (rquickjs-serde) would inject numbers into JS as
114  // `{"$serde_json::private::Number": "..."}` objects. Walk the value
115  // explicitly with the AP-safe `as_*` accessors instead.
116  match v {
117    serde_json::Value::Null => Ok(Value::new_null(ctx.clone())),
118    serde_json::Value::Bool(b) => Ok(Value::new_bool(ctx.clone(), *b)),
119    serde_json::Value::Number(n) => {
120      let f = n.as_f64().unwrap_or(f64::NAN);
121      if let Some(i) = f64_as_exact_i32(f) {
122        Ok(Value::new_int(ctx.clone(), i))
123      } else {
124        Ok(Value::new_number(ctx.clone(), f))
125      }
126    },
127    serde_json::Value::String(s) => Ok(rquickjs::String::from_str(ctx.clone(), s)?.into_value()),
128    serde_json::Value::Array(items) => {
129      let arr = rquickjs::Array::new(ctx.clone())?;
130      for (i, item) in items.iter().enumerate() {
131        arr.set(i, json_to_js(ctx, item)?)?;
132      }
133      Ok(arr.into_value())
134    },
135    serde_json::Value::Object(map) => {
136      let obj = Object::new(ctx.clone())?;
137      for (k, val) in map {
138        define_own(&obj, k.as_str(), json_to_js(ctx, val)?)?;
139      }
140      Ok(obj.into_value())
141    },
142  }
143}
144
145// ── evaluate(fn, arg) wire bridge (Phase D) ───────────────────────────
146
147/// Lower a QuickJS JS argument into a
148/// [`ferridriver::protocol::SerializedArgument`] ready for
149/// `page.evaluate(fn, arg)` / `page.evaluateHandle(fn, arg)` etc.
150///
151/// Covers JSON-expressible values (primitives, plain arrays, plain
152/// objects) plus top-level `JSHandle` / `ElementHandle` class
153/// instances. `undefined` / absent maps to the utility script's
154/// `{v: "undefined"}` sentinel; `null` maps to `{v: "null"}`.
155///
156/// Class-instance detection: a `JSHandleJs` or `ElementHandleJs`
157/// value is emitted as `SerializedValue::Handle(index)` with its
158/// backend [`ferridriver::protocol::HandleId`] pushed into the shared
159/// handle table. Detection is recursive, so handles nested inside
160/// object / array user args keep their page-side identity.
161pub fn quickjs_arg_to_serialized<'js>(
162  _ctx: &Ctx<'js>,
163  value: Option<Value<'js>>,
164) -> rquickjs::Result<ferridriver::protocol::SerializedArgument> {
165  use ferridriver::protocol::{SerializationContext, SerializedArgument, SerializedValue, SpecialValue};
166
167  let v = match value {
168    Some(v) if !v.is_undefined() => v,
169    _ => {
170      return Ok(SerializedArgument {
171        value: SerializedValue::Special(SpecialValue::Undefined),
172        handles: Vec::new(),
173      });
174    },
175  };
176
177  if v.is_null() {
178    return Ok(SerializedArgument {
179      value: SerializedValue::Special(SpecialValue::Null),
180      handles: Vec::new(),
181    });
182  }
183
184  // Direct JS -> SerializedValue. The old path went
185  // JS -> serde_json::Value -> SerializedValue, allocating the whole
186  // argument tree twice on every `page.evaluate(fn, arg)` /
187  // `locator.evaluate`. Walk once. Semantics match the prior
188  // JSON-expressible contract (`JSON.stringify` rules: drop
189  // undefined/function/symbol properties, array holes -> null,
190  // non-finite -> null) — `toJSON()` was not honoured before either.
191  let mut alloc = SerializationContext::default();
192  let mut handles = Vec::new();
193  Ok(SerializedArgument {
194    value: js_value_to_serialized(&v, &mut alloc, &mut handles, 0)?,
195    handles,
196  })
197}
198
199/// Recursion cap. A cyclic / pathologically deep argument previously
200/// errored out of `serde_from_js` (serde can't represent a cycle); keep
201/// that behaviour with an explicit bound instead of overflowing.
202const MAX_ARG_DEPTH: u32 = 512;
203
204/// Walk a JS value into a wire [`SerializedValue`] following
205/// `JSON.stringify` rules (the documented JSON-expressible subset).
206fn js_value_to_serialized(
207  v: &Value<'_>,
208  alloc: &mut ferridriver::protocol::SerializationContext,
209  handles: &mut Vec<ferridriver::protocol::HandleId>,
210  depth: u32,
211) -> rquickjs::Result<ferridriver::protocol::SerializedValue> {
212  use ferridriver::protocol::{SerializedValue, SpecialValue};
213
214  if depth > MAX_ARG_DEPTH {
215    return Err(rquickjs::Error::new_from_js_message(
216      "serde",
217      "serialize",
218      "argument too deeply nested or cyclic".to_string(),
219    ));
220  }
221
222  if v.is_undefined() {
223    return Ok(SerializedValue::Special(SpecialValue::Undefined));
224  }
225  if v.is_null() {
226    return Ok(SerializedValue::Special(SpecialValue::Null));
227  }
228  if let Some(b) = v.as_bool() {
229    return Ok(SerializedValue::Bool(b));
230  }
231  if let Some(i) = v.as_int() {
232    return Ok(SerializedValue::from_f64(f64::from(i)));
233  }
234  if let Some(f) = v.as_float() {
235    // JSON.stringify renders non-finite as null.
236    return Ok(if f.is_finite() {
237      SerializedValue::from_f64(f)
238    } else {
239      SerializedValue::Special(SpecialValue::Null)
240    });
241  }
242  if let Some(s) = v.as_string() {
243    return Ok(SerializedValue::Str(s.to_string()?));
244  }
245  if let Some(bi) = v.as_big_int() {
246    // Wire BigInt is a decimal string. `to_i64` covers the common
247    // range; a value outside it errors (the old serde_json path could
248    // not represent BigInt at all, so erroring is not a regression).
249    return match bi.clone().to_i64() {
250      Ok(n) => Ok(SerializedValue::BigInt(n.to_string())),
251      Err(_) => Err(rquickjs::Error::new_from_js_message(
252        "serde",
253        "serialize",
254        "BigInt argument out of i64 range".to_string(),
255      )),
256    };
257  }
258  if let Some(arr) = v.as_array() {
259    let id = alloc.alloc_id();
260    let mut items = Vec::with_capacity(arr.len());
261    for idx in 0..arr.len() {
262      let el: Value<'_> = arr.get(idx)?;
263      // Array holes / undefined / functions serialise as null.
264      items.push(if el.is_undefined() || el.is_function() {
265        SerializedValue::Special(SpecialValue::Null)
266      } else {
267        js_value_to_serialized(&el, alloc, handles, depth + 1)?
268      });
269    }
270    return Ok(SerializedValue::Array { id, items });
271  }
272  if let Some(obj) = v.as_object() {
273    // Plain object: own enumerable string keys, dropping
274    // undefined/function/symbol-valued props (JSON.stringify rules).
275    let id = alloc.alloc_id();
276    let mut entries = Vec::new();
277    for key in obj.keys::<String>() {
278      let key = key?;
279      let val: Value<'_> = obj.get(&key)?;
280      if val.is_undefined() || val.is_function() || val.type_of() == rquickjs::Type::Symbol {
281        continue;
282      }
283      entries.push(ferridriver::protocol::PropertyEntry {
284        k: key,
285        v: js_value_to_serialized(&val, alloc, handles, depth + 1)?,
286      });
287    }
288    if entries.is_empty() {
289      if let Some(handle) = handle_value_to_serialized(v, handles)? {
290        return Ok(handle);
291      }
292    }
293    return Ok(SerializedValue::Object { id, entries });
294  }
295
296  // Symbol / function / other non-JSON value at this position:
297  // JSON.stringify treats it as undefined.
298  Ok(SerializedValue::Special(SpecialValue::Undefined))
299}
300
301fn handle_value_to_serialized(
302  v: &Value<'_>,
303  handles: &mut Vec<ferridriver::protocol::HandleId>,
304) -> rquickjs::Result<Option<ferridriver::protocol::SerializedValue>> {
305  if let Ok(class) = rquickjs::Class::<crate::bindings::js_handle::JSHandleJs>::from_value(v) {
306    let inner = class.borrow();
307    return Ok(Some(handle_backing_to_serialized(inner.inner().backing(), handles)?));
308  }
309  if let Ok(class) = rquickjs::Class::<crate::bindings::element_handle::ElementHandleJs>::from_value(v) {
310    let inner = class.borrow();
311    let handle = inner.inner().as_js_handle();
312    return Ok(Some(handle_backing_to_serialized(handle.backing(), handles)?));
313  }
314  Ok(None)
315}
316
317fn handle_backing_to_serialized(
318  backing: &ferridriver::js_handle::JSHandleBacking,
319  handles: &mut Vec<ferridriver::protocol::HandleId>,
320) -> rquickjs::Result<ferridriver::protocol::SerializedValue> {
321  match backing {
322    ferridriver::js_handle::JSHandleBacking::Remote(remote) => {
323      let idx = u32::try_from(handles.len())
324        .map_err(|_| rquickjs::Error::new_from_js_message("serde", "serialize", "too many JS handles"))?;
325      handles.push(remote.to_handle_id());
326      Ok(ferridriver::protocol::SerializedValue::Handle(idx))
327    },
328    ferridriver::js_handle::JSHandleBacking::Value(value) => Ok(value.clone()),
329  }
330}
331
332/// Convert a [`ferridriver::protocol::SerializedValue`] into a native
333/// QuickJS JS value — `Date` / `RegExp` / `BigInt` / `URL` / `Error` /
334/// typed arrays / `NaN` / `±Infinity` / `undefined` / `-0` all round-trip
335/// as their native JS form. Mirrors Playwright's `parseSerializedValue`
336/// at `/tmp/playwright/packages/playwright-core/src/protocol/serializers.ts:19`.
337pub fn serialized_value_to_quickjs<'js>(
338  ctx: &Ctx<'js>,
339  value: &ferridriver::protocol::SerializedValue,
340) -> rquickjs::Result<Value<'js>> {
341  let mut refs: rustc_hash::FxHashMap<u32, Value<'js>> = rustc_hash::FxHashMap::default();
342  rehydrate(ctx, value, &mut refs)
343}
344
345fn rehydrate<'js>(
346  ctx: &Ctx<'js>,
347  value: &ferridriver::protocol::SerializedValue,
348  refs: &mut rustc_hash::FxHashMap<u32, Value<'js>>,
349) -> rquickjs::Result<Value<'js>> {
350  use ferridriver::protocol::{ErrorValue, RegExpValue, SerializedValue, SpecialValue};
351
352  match value {
353    SerializedValue::Bool(b) => Ok(Value::new_bool(ctx.clone(), *b)),
354    SerializedValue::Number(n) => {
355      if let Some(i) = f64_as_exact_i32(*n) {
356        Ok(Value::new_int(ctx.clone(), i))
357      } else {
358        Ok(Value::new_number(ctx.clone(), *n))
359      }
360    },
361    SerializedValue::Str(s) => {
362      let js = rquickjs::String::from_str(ctx.clone(), s)?;
363      Ok(js.into_value())
364    },
365    SerializedValue::Special(SpecialValue::Null) => Ok(Value::new_null(ctx.clone())),
366    SerializedValue::Special(SpecialValue::Undefined) => Ok(Value::new_undefined(ctx.clone())),
367    SerializedValue::Special(SpecialValue::NaN) => Ok(Value::new_number(ctx.clone(), f64::NAN)),
368    SerializedValue::Special(SpecialValue::Infinity) => Ok(Value::new_number(ctx.clone(), f64::INFINITY)),
369    SerializedValue::Special(SpecialValue::NegInfinity) => Ok(Value::new_number(ctx.clone(), f64::NEG_INFINITY)),
370    SerializedValue::Special(SpecialValue::NegZero) => Ok(Value::new_number(ctx.clone(), -0.0)),
371    SerializedValue::Date(iso) => construct_global(ctx, "Date", (iso.clone(),)),
372    SerializedValue::Url(url) => construct_global(ctx, "URL", (url.clone(),)),
373    SerializedValue::BigInt(s) => {
374      // BigInt(value) — must be called as function, not constructor.
375      let func: Function<'js> = ctx.globals().get("BigInt")?;
376      func.call((s.clone(),))
377    },
378    SerializedValue::RegExp(RegExpValue { p, f }) => construct_global(ctx, "RegExp", (p.clone(), f.clone())),
379    SerializedValue::Error(ErrorValue { m, n, s }) => {
380      let err: Value<'js> = construct_global(ctx, "Error", (m.clone(),))?;
381      let obj = err
382        .as_object()
383        .ok_or_else(|| rquickjs::Error::new_from_js_message("Error", "", "not an object"))?;
384      obj.set("name", n.clone())?;
385      obj.set("stack", s.clone())?;
386      Ok(err)
387    },
388    SerializedValue::TypedArray(ta) => rehydrate_typed_array(ctx, ta.k, &ta.b),
389    SerializedValue::ArrayBuffer(ab) => {
390      let len = u32::try_from(ab.b.len())
391        .map_err(|_| rquickjs::Error::new_from_js_message("rehydrate", "ArrayBuffer", "length exceeds u32"))?;
392      let buf: Value<'js> = construct_global(ctx, "ArrayBuffer", (len,))?;
393      let view: Value<'js> = construct_global(ctx, "Uint8Array", (buf.clone(),))?;
394      let view_obj = view
395        .as_object()
396        .ok_or_else(|| rquickjs::Error::new_from_js_message("ArrayBuffer", "", "view not an object"))?;
397      for (i, byte) in ab.b.iter().enumerate() {
398        view_obj.set(u32::try_from(i).unwrap_or(u32::MAX), *byte)?;
399      }
400      Ok(buf)
401    },
402    SerializedValue::Array { id, items } => {
403      // `Array::new` already owns one `Ctx` dup (stored inside `arr`).
404      // The back-ref map needs a `Value` handle to the same array while
405      // `arr` stays alive for `set`; clone the borrowed underlying value
406      // (one dup) rather than cloning the whole `Array` (which would dup
407      // the `Ctx` a second time). `into_value` at the end is a move.
408      let arr = rquickjs::Array::new(ctx.clone())?;
409      refs.insert(*id, arr.as_value().clone());
410      for (i, item) in items.iter().enumerate() {
411        let v = rehydrate(ctx, item, refs)?;
412        arr.set(i, v)?;
413      }
414      Ok(arr.into_value())
415    },
416    SerializedValue::Object { id, entries } => {
417      let obj = Object::new(ctx.clone())?;
418      refs.insert(*id, obj.as_value().clone());
419      for entry in entries {
420        let v = rehydrate(ctx, &entry.v, refs)?;
421        define_own(&obj, &entry.k, v)?;
422      }
423      Ok(obj.into_value())
424    },
425    SerializedValue::Reference(id) => refs
426      .get(id)
427      .cloned()
428      .ok_or_else(|| rquickjs::Error::new_from_js_message("rehydrate", "ref", format!("unknown back-ref id {id}"))),
429    SerializedValue::Handle(_) => Err(rquickjs::Error::new_from_js_message(
430      "rehydrate",
431      "handle",
432      "bare Handle in return value — use evaluateHandle()",
433    )),
434  }
435}
436
437fn f64_as_exact_i32(n: f64) -> Option<i32> {
438  if n.is_finite() && n.fract() == 0.0 && n >= f64::from(i32::MIN) && n <= f64::from(i32::MAX) {
439    // SAFETY: bounds-checked above. Direct cast preserves value for integers in i32 range.
440    let trunc = n.trunc();
441    i32::try_from(trunc as i64).ok()
442  } else {
443    None
444  }
445}
446
447fn construct_global<'js, Args>(ctx: &Ctx<'js>, ctor_name: &'static str, args: Args) -> rquickjs::Result<Value<'js>>
448where
449  Args: rquickjs::function::IntoArgs<'js>,
450{
451  let raw: Value<'js> = ctx.globals().get(ctor_name)?;
452  let ctor = raw
453    .try_into_constructor()
454    .map_err(|_| rquickjs::Error::new_from_js_message("construct", ctor_name, "global is not a constructor"))?;
455  ctor.construct(args)
456}
457
458fn rehydrate_typed_array<'js>(
459  ctx: &Ctx<'js>,
460  kind: ferridriver::protocol::TypedArrayKind,
461  bytes: &[u8],
462) -> rquickjs::Result<Value<'js>> {
463  use ferridriver::protocol::TypedArrayKind;
464  // Build the backing ArrayBuffer first (as bytes), then construct the
465  // typed-array view via `new <Kind>Array(buffer)` so each variant
466  // reuses one code path.
467  let len = u32::try_from(bytes.len())
468    .map_err(|_| rquickjs::Error::new_from_js_message("rehydrate", "TypedArray", "length exceeds u32"))?;
469  let ab: Value<'js> = construct_global(ctx, "ArrayBuffer", (len,))?;
470  let view: Value<'js> = construct_global(ctx, "Uint8Array", (ab.clone(),))?;
471  let view_obj = view
472    .as_object()
473    .ok_or_else(|| rquickjs::Error::new_from_js_message("TypedArray", "", "view not an object"))?;
474  for (i, byte) in bytes.iter().enumerate() {
475    view_obj.set(u32::try_from(i).unwrap_or(u32::MAX), *byte)?;
476  }
477  let ctor_name = match kind {
478    TypedArrayKind::I8 => "Int8Array",
479    TypedArrayKind::U8 => "Uint8Array",
480    TypedArrayKind::U8Clamped => "Uint8ClampedArray",
481    TypedArrayKind::I16 => "Int16Array",
482    TypedArrayKind::U16 => "Uint16Array",
483    TypedArrayKind::I32 => "Int32Array",
484    TypedArrayKind::U32 => "Uint32Array",
485    TypedArrayKind::F32 => "Float32Array",
486    TypedArrayKind::F64 => "Float64Array",
487    TypedArrayKind::BI64 => "BigInt64Array",
488    TypedArrayKind::BUI64 => "BigUint64Array",
489  };
490  construct_global(ctx, ctor_name, (ab,))
491}
492
493/// Extract `(fn_source, is_function_hint)` from an evaluate `pageFunction`
494/// arg that can be a JS string or a JS function — matches Playwright's
495/// `String(pageFunction)` + `typeof pageFunction === 'function'` check.
496/// For functions, invokes the engine's `Function.prototype.toString()`
497/// via global `String(...)`.
498pub fn extract_page_function<'js>(ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<(String, Option<bool>)> {
499  let is_fn = value.is_function();
500  let s: String = if let Some(str_val) = value.clone().into_string() {
501    str_val.to_string()?
502  } else {
503    // For Function / other object: invoke global String(v) to run
504    // ECMA ToString, which calls Function.prototype.toString for
505    // functions (matching Playwright's `String(pageFunction)`).
506    let string_fn: Function<'js> = ctx.globals().get("String")?;
507    string_fn.call((value,))?
508  };
509  Ok((s, Some(is_fn)))
510}
511
512/// Shape of a JS `{ x, y }` point passed as `position` in click-family
513/// options. Deserialised out of the raw `ClickOptions` JS object.
514#[derive(serde::Deserialize, Debug, Default, Clone, Copy)]
515struct JsClickPosition {
516  x: f64,
517  y: f64,
518}
519
520impl From<JsClickPosition> for ferridriver::options::Point {
521  fn from(p: JsClickPosition) -> Self {
522    Self { x: p.x, y: p.y }
523  }
524}
525
526/// Raw JS shape of Playwright's `ClickOptions` — deserialised via
527/// `serde_from_js` and then lowered to
528/// [`ferridriver::options::ClickOptions`] by [`parse_click_options`].
529/// Strings (`button`, `modifiers`) are validated at lowering time so
530/// typos surface as `FerriError::InvalidArgument` rather than silent
531/// defaults.
532#[derive(serde::Deserialize, Debug, Default)]
533#[serde(rename_all = "camelCase", default)]
534struct JsClickOptions {
535  button: Option<String>,
536  click_count: Option<u32>,
537  delay: Option<u64>,
538  force: Option<bool>,
539  modifiers: Option<Vec<String>>,
540  no_wait_after: Option<bool>,
541  position: Option<JsClickPosition>,
542  steps: Option<u32>,
543  timeout: Option<u64>,
544  trial: Option<bool>,
545}
546
547/// Raw JS shape of Playwright's `DispatchEventOptions` — single field.
548#[derive(serde::Deserialize, Debug, Default)]
549#[serde(rename_all = "camelCase", default)]
550struct JsDispatchEventOptions {
551  timeout: Option<u64>,
552}
553
554/// Parse Playwright's `DispatchEventOptions` JS bag into the core struct.
555pub fn parse_dispatch_event_options<'js>(
556  ctx: &Ctx<'js>,
557  value: rquickjs::function::Opt<Value<'js>>,
558) -> rquickjs::Result<Option<ferridriver::options::DispatchEventOptions>> {
559  let raw = match value.0 {
560    Some(v) if !v.is_undefined() && !v.is_null() => v,
561    _ => return Ok(None),
562  };
563  let js: JsDispatchEventOptions = serde_from_js(ctx, raw)?;
564  Ok(Some(ferridriver::options::DispatchEventOptions { timeout: js.timeout }))
565}
566
567/// Raw JS shape of Playwright's `FilePayload`.
568#[derive(serde::Deserialize, Debug)]
569#[serde(rename_all = "camelCase")]
570struct JsFilePayload {
571  name: String,
572  mime_type: String,
573  /// JS `Buffer`/`Uint8Array`/array-of-numbers all deserialize to a
574  /// `Vec<u8>` via serde_json::from_js. rquickjs `Buffer` types round
575  /// through JSON as arrays of small numbers, which serde handles.
576  buffer: Vec<u8>,
577}
578
579impl From<JsFilePayload> for ferridriver::options::FilePayload {
580  fn from(p: JsFilePayload) -> Self {
581    Self {
582      name: p.name,
583      mime_type: p.mime_type,
584      buffer: p.buffer,
585    }
586  }
587}
588
589/// Raw JS shape of Playwright's `SetInputFilesOptions` — two fields.
590#[derive(serde::Deserialize, Debug, Default)]
591#[serde(rename_all = "camelCase", default)]
592struct JsSetInputFilesOptions {
593  no_wait_after: Option<bool>,
594  timeout: Option<u64>,
595}
596
597/// Parse the polymorphic `files` arg for `setInputFiles`:
598/// `string | string[] | FilePayload | FilePayload[]`.
599pub fn parse_input_files<'js>(ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result<ferridriver::options::InputFiles> {
600  if value.is_string() {
601    let s: String = value.get()?;
602    return Ok(ferridriver::options::InputFiles::Paths(vec![s.into()]));
603  }
604  if let Some(arr) = value.as_array() {
605    let len = arr.len();
606    if len == 0 {
607      return Ok(ferridriver::options::InputFiles::Paths(Vec::new()));
608    }
609    // Probe the first element directly on the JS value (no
610    // serde_json::Value middle-hop): all-strings -> paths, else
611    // FilePayload objects.
612    let first: Value<'js> = arr.get(0)?;
613    if first.is_string() {
614      let mut paths = Vec::with_capacity(len);
615      for idx in 0..len {
616        let el: Value<'js> = arr.get(idx)?;
617        let s: String = el.into_string().map_or_else(
618          || {
619            Err(rquickjs::Error::new_from_js_message(
620              "ferridriver",
621              "setInputFiles",
622              "array elements must be strings",
623            ))
624          },
625          |s| s.to_string(),
626        )?;
627        paths.push(std::path::PathBuf::from(s));
628      }
629      return Ok(ferridriver::options::InputFiles::Paths(paths));
630    }
631    let mut payloads = Vec::with_capacity(len);
632    for idx in 0..len {
633      let el: Value<'js> = arr.get(idx)?;
634      let p: JsFilePayload = serde_from_js(ctx, el)?;
635      payloads.push(p.into());
636    }
637    return Ok(ferridriver::options::InputFiles::Payloads(payloads));
638  }
639  if value.is_object() {
640    let p: JsFilePayload = serde_from_js(ctx, value)?;
641    return Ok(ferridriver::options::InputFiles::Payloads(vec![p.into()]));
642  }
643  Err(rquickjs::Error::new_from_js_message(
644    "ferridriver",
645    "setInputFiles",
646    "files must be string | string[] | FilePayload | FilePayload[]",
647  ))
648}
649
650/// Parse Playwright's `SetInputFilesOptions` JS bag into the core struct.
651pub fn parse_set_input_files_options<'js>(
652  ctx: &Ctx<'js>,
653  value: rquickjs::function::Opt<Value<'js>>,
654) -> rquickjs::Result<Option<ferridriver::options::SetInputFilesOptions>> {
655  let raw = match value.0 {
656    Some(v) if !v.is_undefined() && !v.is_null() => v,
657    _ => return Ok(None),
658  };
659  let js: JsSetInputFilesOptions = serde_from_js(ctx, raw)?;
660  Ok(Some(ferridriver::options::SetInputFilesOptions {
661    no_wait_after: js.no_wait_after,
662    timeout: js.timeout,
663  }))
664}
665
666/// Raw JS shape of Playwright's `DropOptions` (the trimmed
667/// `FrameDropOptions` bag from `client/locator.ts`): `modifiers`,
668/// `position`, `timeout`.
669#[derive(serde::Deserialize, Debug, Default)]
670#[serde(rename_all = "camelCase", default)]
671struct JsDropOptions {
672  modifiers: Option<Vec<String>>,
673  position: Option<JsClickPosition>,
674  timeout: Option<u64>,
675}
676
677/// Parse Playwright's `DropOptions` JS bag into the core struct.
678pub fn parse_drop_options<'js>(
679  ctx: &Ctx<'js>,
680  value: rquickjs::function::Opt<Value<'js>>,
681) -> rquickjs::Result<Option<ferridriver::options::DropOptions>> {
682  let raw = match value.0 {
683    Some(v) if !v.is_undefined() && !v.is_null() => v,
684    _ => return Ok(None),
685  };
686  let js: JsDropOptions = serde_from_js(ctx, raw)?;
687  let mut modifiers = Vec::new();
688  if let Some(list) = js.modifiers {
689    for name in list {
690      let m = ferridriver::options::Modifier::parse(&name).ok_or_else(|| {
691        rquickjs::Error::new_from_js_message("ferridriver", "drop", format!("Unknown modifier: {name}"))
692      })?;
693      modifiers.push(m);
694    }
695  }
696  Ok(Some(ferridriver::options::DropOptions {
697    modifiers,
698    position: js.position.map(Into::into),
699    timeout: js.timeout,
700  }))
701}
702
703/// Parse Playwright's native `DropPayload`
704/// (`{ files?: string | string[] | FilePayload | FilePayload[], data?: { [mimeType: string]: string } }`)
705/// into the core struct. Both fields are optional.
706pub fn parse_drop_payload<'js>(
707  ctx: &Ctx<'js>,
708  value: Value<'js>,
709) -> rquickjs::Result<ferridriver::options::DropPayload> {
710  let obj = value
711    .into_object()
712    .ok_or_else(|| rquickjs::Error::new_from_js_message("ferridriver", "drop", "payload must be an object"))?;
713
714  let files = match obj.get::<_, Value<'js>>("files") {
715    Ok(v) if !v.is_undefined() && !v.is_null() => Some(parse_input_files(ctx, v)?),
716    _ => None,
717  };
718
719  let data = match obj.get::<_, Value<'js>>("data") {
720    Ok(v) if !v.is_undefined() && !v.is_null() => {
721      let map: std::collections::BTreeMap<String, String> = serde_from_js(ctx, v)?;
722      map.into_iter().collect()
723    },
724    _ => Vec::new(),
725  };
726
727  Ok(ferridriver::options::DropPayload { files, data })
728}
729
730/// Raw JS shape of a `selectOption` descriptor — mirrors Playwright's
731/// `{ value?, label?, index? }`.
732#[derive(serde::Deserialize, Debug, Default)]
733#[serde(rename_all = "camelCase", default)]
734struct JsSelectOptionValue {
735  value: Option<String>,
736  label: Option<String>,
737  index: Option<u32>,
738}
739
740impl From<JsSelectOptionValue> for ferridriver::options::SelectOptionValue {
741  fn from(v: JsSelectOptionValue) -> Self {
742    Self {
743      value: v.value,
744      label: v.label,
745      index: v.index,
746    }
747  }
748}
749
750/// Raw JS shape of Playwright's `SelectOptionOptions` — three fields.
751#[derive(serde::Deserialize, Debug, Default)]
752#[serde(rename_all = "camelCase", default)]
753struct JsSelectOptionOptions {
754  force: Option<bool>,
755  no_wait_after: Option<bool>,
756  timeout: Option<u64>,
757}
758
759/// Parse a polymorphic `selectOption` `values` JS argument:
760/// `string | string[] | { value?, label?, index? } | Array<...>`.
761pub fn parse_select_option_values<'js>(
762  ctx: &Ctx<'js>,
763  value: Value<'js>,
764) -> rquickjs::Result<Vec<ferridriver::options::SelectOptionValue>> {
765  if value.is_string() {
766    let s: String = value.get()?;
767    return Ok(vec![ferridriver::options::SelectOptionValue::by_value(s)]);
768  }
769  if let Some(arr) = value.as_array() {
770    let len = arr.len();
771    let mut out = Vec::with_capacity(len);
772    for idx in 0..len {
773      let el: Value<'js> = arr.get(idx)?;
774      if el.is_string() {
775        let s: String = el.get()?;
776        out.push(ferridriver::options::SelectOptionValue::by_value(s));
777      } else if el.is_object() {
778        // Direct rquickjs-serde (no serde_json::Value middle-hop).
779        let desc: JsSelectOptionValue = serde_from_js(ctx, el)?;
780        out.push(desc.into());
781      } else {
782        return Err(rquickjs::Error::new_from_js_message(
783          "ferridriver",
784          "selectOption",
785          "array entries must be string or { value?, label?, index? } object",
786        ));
787      }
788    }
789    return Ok(out);
790  }
791  if value.is_object() {
792    let desc: JsSelectOptionValue = serde_from_js(ctx, value)?;
793    return Ok(vec![desc.into()]);
794  }
795  Err(rquickjs::Error::new_from_js_message(
796    "ferridriver",
797    "selectOption",
798    "values must be string | string[] | object | object[]",
799  ))
800}
801
802/// Parse Playwright's `SelectOptionOptions` JS bag into the core struct.
803pub fn parse_select_option_options<'js>(
804  ctx: &Ctx<'js>,
805  value: rquickjs::function::Opt<Value<'js>>,
806) -> rquickjs::Result<Option<ferridriver::options::SelectOptionOptions>> {
807  let raw = match value.0 {
808    Some(v) if !v.is_undefined() && !v.is_null() => v,
809    _ => return Ok(None),
810  };
811  let js: JsSelectOptionOptions = serde_from_js(ctx, raw)?;
812  Ok(Some(ferridriver::options::SelectOptionOptions {
813    force: js.force,
814    no_wait_after: js.no_wait_after,
815    timeout: js.timeout,
816  }))
817}
818
819/// Raw JS shape of Playwright's `FillOptions` — three fields.
820#[derive(serde::Deserialize, Debug, Default)]
821#[serde(rename_all = "camelCase", default)]
822struct JsFillOptions {
823  force: Option<bool>,
824  no_wait_after: Option<bool>,
825  timeout: Option<u64>,
826}
827
828/// Parse Playwright's `FillOptions` JS bag into the core struct.
829pub fn parse_fill_options<'js>(
830  ctx: &Ctx<'js>,
831  value: rquickjs::function::Opt<Value<'js>>,
832) -> rquickjs::Result<Option<ferridriver::options::FillOptions>> {
833  let raw = match value.0 {
834    Some(v) if !v.is_undefined() && !v.is_null() => v,
835    _ => return Ok(None),
836  };
837  let js: JsFillOptions = serde_from_js(ctx, raw)?;
838  Ok(Some(ferridriver::options::FillOptions {
839    force: js.force,
840    no_wait_after: js.no_wait_after,
841    timeout: js.timeout,
842  }))
843}
844
845/// Raw JS shape of Playwright's `PressOptions` / `TypeOptions` — same shape.
846#[derive(serde::Deserialize, Debug, Default)]
847#[serde(rename_all = "camelCase", default)]
848struct JsPressOptions {
849  delay: Option<u64>,
850  no_wait_after: Option<bool>,
851  timeout: Option<u64>,
852}
853
854/// Parse Playwright's `PressOptions` JS bag into the core struct.
855pub fn parse_press_options<'js>(
856  ctx: &Ctx<'js>,
857  value: rquickjs::function::Opt<Value<'js>>,
858) -> rquickjs::Result<Option<ferridriver::options::PressOptions>> {
859  let raw = match value.0 {
860    Some(v) if !v.is_undefined() && !v.is_null() => v,
861    _ => return Ok(None),
862  };
863  let js: JsPressOptions = serde_from_js(ctx, raw)?;
864  Ok(Some(ferridriver::options::PressOptions {
865    delay: js.delay,
866    no_wait_after: js.no_wait_after,
867    timeout: js.timeout,
868  }))
869}
870
871/// Parse Playwright's `TypeOptions` JS bag — same shape as `PressOptions`.
872pub fn parse_type_options<'js>(
873  ctx: &Ctx<'js>,
874  value: rquickjs::function::Opt<Value<'js>>,
875) -> rquickjs::Result<Option<ferridriver::options::TypeOptions>> {
876  let raw = match value.0 {
877    Some(v) if !v.is_undefined() && !v.is_null() => v,
878    _ => return Ok(None),
879  };
880  let js: JsPressOptions = serde_from_js(ctx, raw)?;
881  Ok(Some(ferridriver::options::TypeOptions {
882    delay: js.delay,
883    no_wait_after: js.no_wait_after,
884    timeout: js.timeout,
885  }))
886}
887
888/// Raw JS shape of Playwright's `CheckOptions` / `UncheckOptions` /
889/// `SetCheckedOptions` — five fields (force, noWaitAfter, position,
890/// timeout, trial).
891#[derive(serde::Deserialize, Debug, Default)]
892#[serde(rename_all = "camelCase", default)]
893struct JsCheckOptions {
894  force: Option<bool>,
895  no_wait_after: Option<bool>,
896  position: Option<JsClickPosition>,
897  timeout: Option<u64>,
898  trial: Option<bool>,
899}
900
901/// Parse Playwright's `CheckOptions` / `UncheckOptions` /
902/// `SetCheckedOptions` JS bag into the core struct.
903pub fn parse_check_options<'js>(
904  ctx: &Ctx<'js>,
905  value: rquickjs::function::Opt<Value<'js>>,
906) -> rquickjs::Result<Option<ferridriver::options::CheckOptions>> {
907  let raw = match value.0 {
908    Some(v) if !v.is_undefined() && !v.is_null() => v,
909    _ => return Ok(None),
910  };
911  let js: JsCheckOptions = serde_from_js(ctx, raw)?;
912  Ok(Some(ferridriver::options::CheckOptions {
913    force: js.force,
914    no_wait_after: js.no_wait_after,
915    position: js.position.map(Into::into),
916    timeout: js.timeout,
917    trial: js.trial,
918  }))
919}
920
921/// Raw JS shape of Playwright's `HoverOptions` — mirrors
922/// `/tmp/playwright/packages/playwright-core/types/types.d.ts` under
923/// `locator.hover(options?)`. No `steps` — hover does a single move.
924#[derive(serde::Deserialize, Debug, Default)]
925#[serde(rename_all = "camelCase", default)]
926struct JsHoverOptions {
927  force: Option<bool>,
928  modifiers: Option<Vec<String>>,
929  no_wait_after: Option<bool>,
930  position: Option<JsClickPosition>,
931  timeout: Option<u64>,
932  trial: Option<bool>,
933}
934
935/// Parse Playwright's `HoverOptions` JS bag into the core struct.
936pub fn parse_hover_options<'js>(
937  ctx: &Ctx<'js>,
938  value: rquickjs::function::Opt<Value<'js>>,
939) -> rquickjs::Result<Option<ferridriver::options::HoverOptions>> {
940  let raw = match value.0 {
941    Some(v) if !v.is_undefined() && !v.is_null() => v,
942    _ => return Ok(None),
943  };
944  let js: JsHoverOptions = serde_from_js(ctx, raw)?;
945  let mut modifiers = Vec::new();
946  if let Some(list) = js.modifiers {
947    for name in list {
948      let m = ferridriver::options::Modifier::parse(&name).ok_or_else(|| {
949        rquickjs::Error::new_from_js_message("ferridriver", "hover", format!("Unknown modifier: {name}"))
950      })?;
951      modifiers.push(m);
952    }
953  }
954  Ok(Some(ferridriver::options::HoverOptions {
955    force: js.force,
956    modifiers,
957    no_wait_after: js.no_wait_after,
958    position: js.position.map(Into::into),
959    timeout: js.timeout,
960    trial: js.trial,
961  }))
962}
963
964/// Raw JS shape of Playwright's `TapOptions` — mirrors
965/// `/tmp/playwright/packages/playwright-core/types/types.d.ts` under
966/// `locator.tap(options?)`. Same fields as hover (no `steps`).
967#[derive(serde::Deserialize, Debug, Default)]
968#[serde(rename_all = "camelCase", default)]
969struct JsTapOptions {
970  force: Option<bool>,
971  modifiers: Option<Vec<String>>,
972  no_wait_after: Option<bool>,
973  position: Option<JsClickPosition>,
974  timeout: Option<u64>,
975  trial: Option<bool>,
976}
977
978/// Parse Playwright's `TapOptions` JS bag into the core struct.
979pub fn parse_tap_options<'js>(
980  ctx: &Ctx<'js>,
981  value: rquickjs::function::Opt<Value<'js>>,
982) -> rquickjs::Result<Option<ferridriver::options::TapOptions>> {
983  let raw = match value.0 {
984    Some(v) if !v.is_undefined() && !v.is_null() => v,
985    _ => return Ok(None),
986  };
987  let js: JsTapOptions = serde_from_js(ctx, raw)?;
988  let mut modifiers = Vec::new();
989  if let Some(list) = js.modifiers {
990    for name in list {
991      let m = ferridriver::options::Modifier::parse(&name).ok_or_else(|| {
992        rquickjs::Error::new_from_js_message("ferridriver", "tap", format!("Unknown modifier: {name}"))
993      })?;
994      modifiers.push(m);
995    }
996  }
997  Ok(Some(ferridriver::options::TapOptions {
998    force: js.force,
999    modifiers,
1000    no_wait_after: js.no_wait_after,
1001    position: js.position.map(Into::into),
1002    timeout: js.timeout,
1003    trial: js.trial,
1004  }))
1005}
1006
1007/// Raw JS shape of Playwright's `DblClickOptions` — same fields as
1008/// `ClickOptions` minus `click_count`. See
1009/// `/tmp/playwright/packages/playwright-core/types/types.d.ts:13116`.
1010#[derive(serde::Deserialize, Debug, Default)]
1011#[serde(rename_all = "camelCase", default)]
1012struct JsDblClickOptions {
1013  button: Option<String>,
1014  delay: Option<u64>,
1015  force: Option<bool>,
1016  modifiers: Option<Vec<String>>,
1017  no_wait_after: Option<bool>,
1018  position: Option<JsClickPosition>,
1019  steps: Option<u32>,
1020  timeout: Option<u64>,
1021  trial: Option<bool>,
1022}
1023
1024/// Parse Playwright's `DblClickOptions` JS bag into the core struct.
1025pub fn parse_dblclick_options<'js>(
1026  ctx: &Ctx<'js>,
1027  value: rquickjs::function::Opt<Value<'js>>,
1028) -> rquickjs::Result<Option<ferridriver::options::DblClickOptions>> {
1029  let raw = match value.0 {
1030    Some(v) if !v.is_undefined() && !v.is_null() => v,
1031    _ => return Ok(None),
1032  };
1033  let js: JsDblClickOptions = serde_from_js(ctx, raw)?;
1034  let button = match js.button.as_deref() {
1035    None => None,
1036    Some(s) => Some(ferridriver::options::MouseButton::parse(s).ok_or_else(|| {
1037      rquickjs::Error::new_from_js_message("ferridriver", "dblclick", format!("Unknown mouse button: {s}"))
1038    })?),
1039  };
1040  let mut modifiers = Vec::new();
1041  if let Some(list) = js.modifiers {
1042    for name in list {
1043      let m = ferridriver::options::Modifier::parse(&name).ok_or_else(|| {
1044        rquickjs::Error::new_from_js_message("ferridriver", "dblclick", format!("Unknown modifier: {name}"))
1045      })?;
1046      modifiers.push(m);
1047    }
1048  }
1049  Ok(Some(ferridriver::options::DblClickOptions {
1050    button,
1051    delay: js.delay,
1052    force: js.force,
1053    modifiers,
1054    no_wait_after: js.no_wait_after,
1055    position: js.position.map(Into::into),
1056    steps: js.steps,
1057    timeout: js.timeout,
1058    trial: js.trial,
1059  }))
1060}
1061
1062/// Parse Playwright's `ClickOptions` JS bag into the core struct.
1063/// Accepts `Opt<Value>` so callers pass `Opt(options)` verbatim; `None`,
1064/// `undefined`, or `null` → `Ok(None)`. Unknown `button` / `modifier`
1065/// strings raise a typed `rquickjs::Error` with the exact Playwright
1066/// message so JS-side assertions see `/Unknown (button|modifier)/` for
1067/// drift detection.
1068pub fn parse_click_options<'js>(
1069  ctx: &Ctx<'js>,
1070  value: rquickjs::function::Opt<Value<'js>>,
1071) -> rquickjs::Result<Option<ferridriver::options::ClickOptions>> {
1072  let raw = match value.0 {
1073    Some(v) if !v.is_undefined() && !v.is_null() => v,
1074    _ => return Ok(None),
1075  };
1076  let js: JsClickOptions = serde_from_js(ctx, raw)?;
1077  let button = match js.button.as_deref() {
1078    None => None,
1079    Some(s) => Some(ferridriver::options::MouseButton::parse(s).ok_or_else(|| {
1080      rquickjs::Error::new_from_js_message("ferridriver", "click", format!("Unknown mouse button: {s}"))
1081    })?),
1082  };
1083  let mut modifiers = Vec::new();
1084  if let Some(list) = js.modifiers {
1085    for name in list {
1086      let m = ferridriver::options::Modifier::parse(&name).ok_or_else(|| {
1087        rquickjs::Error::new_from_js_message("ferridriver", "click", format!("Unknown modifier: {name}"))
1088      })?;
1089      modifiers.push(m);
1090    }
1091  }
1092  Ok(Some(ferridriver::options::ClickOptions {
1093    button,
1094    click_count: js.click_count,
1095    delay: js.delay,
1096    force: js.force,
1097    modifiers,
1098    no_wait_after: js.no_wait_after,
1099    position: js.position.map(Into::into),
1100    steps: js.steps,
1101    timeout: js.timeout,
1102    trial: js.trial,
1103  }))
1104}
1105
1106/// Lower an `addInitScript`-style JS argument into
1107/// [`ferridriver::options::InitScriptSource`] plus an optional JSON arg.
1108/// Mirrors Playwright's
1109/// `Function | string | { path?: string, content?: string }` union from
1110/// `/tmp/playwright/packages/playwright-core/src/client/page.ts:520` — all
1111/// semantic lowering (function body via `.toString()`, path/content
1112/// precedence, `null`-vs-`undefined` preservation for `arg`) happens here
1113/// synchronously so the async binding method can immediately hand owned,
1114/// `Send`-safe values to Rust core.
1115///
1116/// Returns an error for non-matching `script` shapes or for a missing
1117/// `{ path, content }` entry. The (source|path|content) + arg rejection is
1118/// left to [`ferridriver::options::evaluation_script`] so both binding
1119/// layers share the exact error text Playwright ships.
1120pub fn init_script_from_js<'js>(
1121  ctx: &Ctx<'js>,
1122  script: Value<'js>,
1123  arg: Option<Value<'js>>,
1124) -> rquickjs::Result<(ferridriver::options::InitScriptSource, Option<serde_json::Value>)> {
1125  let arg_json = match arg {
1126    None => None,
1127    Some(v) if v.is_undefined() => None,
1128    Some(v) if v.is_null() => Some(serde_json::Value::Null),
1129    Some(v) => Some(serde_from_js::<serde_json::Value>(ctx, v)?),
1130  };
1131
1132  let init = if script.is_function() {
1133    // `String(fn)` invokes `Function.prototype.toString` — the same
1134    // primitive Playwright's client uses via `fun.toString()`.
1135    let string_global: Function<'js> = ctx.globals().get("String")?;
1136    let body: String = string_global.call((script,))?;
1137    ferridriver::options::InitScriptSource::Function { body }
1138  } else if script.is_string() {
1139    let s: String = script.get()?;
1140    ferridriver::options::InitScriptSource::Source(s)
1141  } else if script.is_object() {
1142    let obj = script
1143      .as_object()
1144      .ok_or_else(|| rquickjs::Error::new_from_js_message("ferridriver", "addInitScript", "expected object"))?;
1145    if let Ok(content) = obj.get::<_, String>("content") {
1146      ferridriver::options::InitScriptSource::Content(content)
1147    } else if let Ok(path) = obj.get::<_, String>("path") {
1148      ferridriver::options::InitScriptSource::Path(path.into())
1149    } else {
1150      return Err(rquickjs::Error::new_from_js_message(
1151        "ferridriver",
1152        "addInitScript",
1153        "Either path or content property must be present",
1154      ));
1155    }
1156  } else {
1157    return Err(rquickjs::Error::new_from_js_message(
1158      "ferridriver",
1159      "addInitScript",
1160      "script must be Function | string | { path?, content? }",
1161    ));
1162  };
1163
1164  Ok((init, arg_json))
1165}