Skip to main content

ferridriver/
options.rs

1//! Option structs for the Page and Locator API.
2
3/// A string or a regular expression — the `string | RegExp` union accepted
4/// by every `getBy*` matcher, `page.waitForURL`, and similar selector
5/// inputs.
6///
7/// Construction:
8/// * `StringOrRegex::from("foo")` — literal string (substring match
9///   case-insensitive by default, matched verbatim when `exact: true`).
10/// * `StringOrRegex::regex("foo\\d+", "i")` — regex with pattern +
11///   `ECMAScript` flags. At the binding boundary NAPI accepts a real
12///   JS `RegExp` instance; `QuickJS` similarly reads `source`/`flags`
13///   getters off a `RegExp` via prototype-chain access. Wire-shape
14///   inputs like `{ regexSource, regexFlags }` are never exposed to
15///   the user.
16#[derive(Debug, Clone)]
17pub enum StringOrRegex {
18  String(String),
19  Regex { source: String, flags: String },
20}
21
22impl StringOrRegex {
23  /// Return the literal string if this is a `String` variant. Used by
24  /// backends that only accept a literal (no regex support).
25  #[must_use]
26  pub fn as_str(&self) -> Option<&str> {
27    match self {
28      Self::String(s) => Some(s),
29      Self::Regex { .. } => None,
30    }
31  }
32
33  /// Convenience — construct the regex variant from source + flags.
34  #[must_use]
35  pub fn regex(source: impl Into<String>, flags: impl Into<String>) -> Self {
36    Self::Regex {
37      source: source.into(),
38      flags: flags.into(),
39    }
40  }
41}
42
43impl From<&str> for StringOrRegex {
44  fn from(s: &str) -> Self {
45    Self::String(s.to_string())
46  }
47}
48
49impl From<String> for StringOrRegex {
50  fn from(s: String) -> Self {
51    Self::String(s)
52  }
53}
54
55/// Options for role-based locators (getByRole).
56#[derive(Debug, Clone, Default)]
57pub struct RoleOptions {
58  /// `string | RegExp` — matches the element's accessible name.
59  pub name: Option<StringOrRegex>,
60  pub exact: Option<bool>,
61  pub checked: Option<bool>,
62  pub disabled: Option<bool>,
63  pub expanded: Option<bool>,
64  pub level: Option<i32>,
65  pub pressed: Option<bool>,
66  pub selected: Option<bool>,
67  pub include_hidden: Option<bool>,
68}
69
70/// Options for text-based locators (getByText, getByLabel, etc.).
71#[derive(Debug, Clone, Default)]
72pub struct TextOptions {
73  pub exact: Option<bool>,
74}
75
76/// Inner-locator reference for [`FilterOptions::has`] / [`FilterOptions::has_not`].
77///
78/// Accepts either a full [`crate::locator::Locator`] (Rust callers
79/// constructing options programmatically) or a raw selector string
80/// (NAPI/BDD callers that have already extracted the inner selector). Both
81/// variants produce the same encoded `internal:has=` clause —
82/// [`crate::locator::Locator`] additionally enables frame-equality checking at filter
83/// construction time.
84#[derive(Debug, Clone)]
85pub enum LocatorLike {
86  /// Full locator — preferred form for Rust callers. Enables same-page
87  /// checks in [`crate::locator::Locator::filter`].
88  Locator(crate::locator::Locator),
89  /// Inner selector string verbatim. Used by NAPI/BDD where a full
90  /// [`crate::locator::Locator`] cannot be materialized across the binding boundary.
91  Selector(String),
92}
93
94impl LocatorLike {
95  /// The selector string the filter encoder embeds into `internal:has=...`.
96  #[must_use]
97  pub fn as_selector(&self) -> &str {
98    match self {
99      Self::Locator(l) => l.selector(),
100      Self::Selector(s) => s.as_str(),
101    }
102  }
103
104  /// Full [`crate::locator::Locator`] if the caller supplied one, for
105  /// frame-equality checks. Returns `None` for the `Selector` variant.
106  #[must_use]
107  pub fn as_locator(&self) -> Option<&crate::locator::Locator> {
108    match self {
109      Self::Locator(l) => Some(l),
110      Self::Selector(_) => None,
111    }
112  }
113}
114
115impl From<crate::locator::Locator> for LocatorLike {
116  fn from(l: crate::locator::Locator) -> Self {
117    Self::Locator(l)
118  }
119}
120
121impl From<String> for LocatorLike {
122  fn from(s: String) -> Self {
123    Self::Selector(s)
124  }
125}
126
127impl From<&str> for LocatorLike {
128  fn from(s: &str) -> Self {
129    Self::Selector(s.to_string())
130  }
131}
132
133impl From<&String> for LocatorLike {
134  fn from(s: &String) -> Self {
135    Self::Selector(s.clone())
136  }
137}
138
139/// Script argument shape for [`crate::page::Page::add_init_script`] and
140/// [`crate::context::ContextRef::add_init_script`]. Accepts the
141/// `Function | string | { path?, content? }` union.
142///
143/// The binding layer (NAPI / `QuickJS`) is responsible for the engine-local
144/// step of extracting a JS function's source via `.toString()`; everything
145/// else — reading file paths, JSON-serialising `arg`, composing the
146/// `(body)(arg)` wrapper, the "cannot evaluate a string with arguments"
147/// invariant — runs here in core.
148#[derive(Debug, Clone)]
149pub enum InitScriptSource {
150  /// Pre-serialised JS function body (the result of `fn.toString()` on
151  /// the caller's function). Rendered as `(body)(arg)` by
152  /// [`evaluation_script`]; `arg` is JSON-stringified, missing `arg`
153  /// renders as the literal `undefined`.
154  Function { body: String },
155  /// Script source code used verbatim. Passing `arg` alongside this form
156  /// errors with "Cannot evaluate a string with arguments".
157  Source(String),
158  /// Path to an on-disk script file. Read at [`evaluation_script`] call
159  /// time; a `//# sourceURL=<path>` comment is appended. Passing `arg`
160  /// alongside errors.
161  Path(std::path::PathBuf),
162  /// Literal script content (from the `{ content }` bag variant).
163  /// Semantically equivalent to [`Self::Source`]; kept as a distinct
164  /// variant so callers can route the object shape losslessly.
165  /// Passing `arg` alongside errors.
166  Content(String),
167}
168
169impl From<String> for InitScriptSource {
170  fn from(s: String) -> Self {
171    Self::Source(s)
172  }
173}
174
175impl From<&str> for InitScriptSource {
176  fn from(s: &str) -> Self {
177    Self::Source(s.to_string())
178  }
179}
180
181impl From<std::path::PathBuf> for InitScriptSource {
182  fn from(p: std::path::PathBuf) -> Self {
183    Self::Path(p)
184  }
185}
186
187/// Lower an [`InitScriptSource`] + optional JSON argument into the
188/// wire-level source string the backend receives.
189///
190/// Semantics:
191/// - [`InitScriptSource::Function`] + `arg` → `(body)(JSON.stringify(arg))`.
192///   Absent `arg` renders as `undefined` (matches `Object.is(arg, undefined)`).
193///   `null` passes through as `"null"`.
194/// - [`InitScriptSource::Source`] / [`InitScriptSource::Content`] →
195///   the raw source; `arg.is_some()` rejects with
196///   `FerriError::InvalidArgument` ("Cannot evaluate a string with
197///   arguments").
198/// - [`InitScriptSource::Path`] → file contents followed by
199///   `//# sourceURL=<path>` (newlines in the path are stripped so the
200///   pragma stays on one line). Same `arg.is_some()` rejection.
201///
202/// # Errors
203///
204/// - `arg` is `Some` while `script` is `Source`, `Content`, or `Path`.
205/// - `Path` refers to a file that cannot be read.
206/// - `Function` + `arg` whose JSON serialisation fails.
207pub fn evaluation_script(
208  script: InitScriptSource,
209  arg: Option<&serde_json::Value>,
210) -> Result<String, crate::error::FerriError> {
211  match script {
212    InitScriptSource::Function { body } => {
213      let arg_str = match arg {
214        None => "undefined".to_string(),
215        Some(v) => serde_json::to_string(v)?,
216      };
217      Ok(format!("({body})({arg_str})"))
218    },
219    InitScriptSource::Source(s) | InitScriptSource::Content(s) => {
220      if arg.is_some() {
221        return Err(crate::error::FerriError::invalid_argument(
222          "arg",
223          "Cannot evaluate a string with arguments",
224        ));
225      }
226      Ok(s)
227    },
228    InitScriptSource::Path(p) => {
229      if arg.is_some() {
230        return Err(crate::error::FerriError::invalid_argument(
231          "arg",
232          "Cannot evaluate a string with arguments",
233        ));
234      }
235      let source = std::fs::read_to_string(&p)?;
236      let safe_path = p.display().to_string().replace('\n', "");
237      Ok(format!("{source}\n//# sourceURL={safe_path}"))
238    },
239  }
240}
241
242/// Options for filtering locators — used by both
243/// `Locator::filter(options)` and the `Locator` constructor. Every field
244/// maps directly to a corresponding injected-selector clause:
245///
246/// * `has_text` → ` >> internal:has-text=<escaped>`
247/// * `has_not_text` → ` >> internal:has-not-text=<escaped>`
248/// * `has` → ` >> internal:has=<JSON-encoded inner selector>`
249/// * `has_not` → ` >> internal:has-not=<JSON-encoded inner selector>`
250/// * `visible` → ` >> visible=true|false`
251#[derive(Debug, Clone, Default)]
252pub struct FilterOptions {
253  pub has_text: Option<String>,
254  pub has_not_text: Option<String>,
255  pub has: Option<LocatorLike>,
256  pub has_not: Option<LocatorLike>,
257  /// When `Some(true)`, narrow to visible elements only. When `Some(false)`,
258  /// narrow to non-visible elements. `None` means no visibility filter.
259  pub visible: Option<bool>,
260}
261
262/// Options for waiting operations.
263#[derive(Debug, Clone, Default)]
264pub struct WaitOptions {
265  /// "visible", "hidden", "attached", "stable"
266  pub state: Option<String>,
267  pub timeout: Option<u64>,
268}
269
270/// `LocatorEvaluateOptions` — only the `timeout` field today.
271#[derive(Debug, Clone, Default)]
272pub struct EvaluateOptions {
273  pub timeout: Option<u64>,
274}
275
276/// Rendering mode for `locator.ariaSnapshot` / `page.ariaSnapshot`.
277/// Mirrors Playwright's `mode?: 'ai' | 'default'`
278/// (`/tmp/playwright/packages/playwright-core/src/client/locator.ts:327`).
279/// The vendored injected `AriaTreeOptions` also has `codegen` /
280/// `autoexpect`, but those are not part of the public client surface.
281#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
282pub enum AriaSnapshotMode {
283  /// Playwright default — stable YAML without volatile refs.
284  #[default]
285  Default,
286  /// AI-optimized — includes `[ref=eN]` labels + generic roles.
287  Ai,
288}
289
290impl AriaSnapshotMode {
291  #[must_use]
292  pub fn as_str(self) -> &'static str {
293    match self {
294      AriaSnapshotMode::Default => "default",
295      AriaSnapshotMode::Ai => "ai",
296    }
297  }
298
299  /// Parse the public mode string; unknown values fall back to `default`
300  /// (Playwright server uses `mode ?? 'default'`).
301  #[must_use]
302  pub fn from_opt_str(s: Option<&str>) -> Self {
303    match s {
304      Some("ai") => AriaSnapshotMode::Ai,
305      _ => AriaSnapshotMode::Default,
306    }
307  }
308}
309
310/// Options for `locator.ariaSnapshot(options?)`. Mirrors Playwright's
311/// `TimeoutOptions & { mode?: 'ai' | 'default', depth?: number }`
312/// (`/tmp/playwright/packages/playwright-core/src/client/locator.ts:327`;
313/// the vendored injected `AriaTreeOptions` has no `boxes`, so it is not
314/// exposed — it would be a silent no-op).
315#[derive(Debug, Clone, Default)]
316pub struct AriaSnapshotOptions {
317  pub mode: Option<AriaSnapshotMode>,
318  /// Subtree depth limit passed to the injected `generateAriaTree`.
319  pub depth: Option<i32>,
320  /// Actionability/resolution timeout (ms). `None` = page default.
321  pub timeout: Option<u64>,
322}
323
324/// Full `PageScreenshotOptions` surface — 13 fields. Includes the
325/// `LocatorScreenshotOptions` subset for `Locator.screenshot()` (which
326/// omits `full_page` and `clip` — the locator takes the screenshot of its
327/// own element, not a pixel rectangle).
328///
329/// Semantics per field:
330/// - `animations` — `"disabled"` pauses CSS/Web Animations during capture;
331///   `"allow"` (default) leaves them untouched. Finite animations are
332///   fast-forwarded to completion (fires `transitionend`); infinite ones
333///   revert to initial state.
334/// - `caret` — `"hide"` (default) hides the text caret; `"initial"`
335///   keeps it visible.
336/// - `clip` — pixel rectangle relative to the viewport (not full page).
337///   Only meaningful for `Page::screenshot`; ignored by `Locator`.
338/// - `full_page` — capture the entire scrollable page. Only meaningful
339///   for `Page::screenshot`.
340/// - `mask` — locators whose matches are overlaid with `mask_color`
341///   before capture. Mirrors Playwright's `mask?: Locator[]`; the
342///   selector string is extracted from each locator before backend
343///   dispatch.
344/// - `mask_color` — CSS color for the mask overlay. Defaults to pink
345///   `#FF00FF`.
346/// - `omit_background` — transparent background when `true`. Ignored for
347///   `jpeg` / `jpg` (no alpha channel).
348/// - `path` — if set, the captured bytes are also written to disk.
349/// - `quality` — 0–100 for `jpeg` / `webp`. Ignored for `png`.
350/// - `scale` — `"css"` for 1 pixel per CSS pixel; `"device"` (default)
351///   for 1 pixel per device pixel (Retina captures are 2× bigger).
352/// - `style` — raw CSS injected via `addStyleTag` before capture and
353///   removed afterwards. Pierces shadow DOM and applies to subframes.
354/// - `timeout` — max ms for the capture. `0` = no timeout.
355/// - `format` — `"png"` (default), `"jpeg"`, or `"webp"`. Renamed from
356///   the JS `type` field because `type` is reserved in Rust. For CDP
357///   both `jpeg` and `webp` honour `quality`; `webp` additionally
358///   supports transparency.
359#[derive(Debug, Clone, Default)]
360pub struct ScreenshotOptions {
361  pub animations: Option<String>,
362  pub caret: Option<String>,
363  pub clip: Option<ClipRect>,
364  pub full_page: Option<bool>,
365  pub format: Option<String>,
366  pub mask: Vec<crate::locator::Locator>,
367  pub mask_color: Option<String>,
368  pub omit_background: Option<bool>,
369  pub path: Option<std::path::PathBuf>,
370  pub quality: Option<i64>,
371  pub scale: Option<String>,
372  pub style: Option<String>,
373  pub timeout: Option<u64>,
374}
375
376/// Pixel-rectangle clip for [`ScreenshotOptions::clip`]. All values are in
377/// CSS pixels relative to the viewport's top-left corner.
378#[derive(Debug, Clone, Copy, PartialEq)]
379pub struct ClipRect {
380  pub x: f64,
381  pub y: f64,
382  pub width: f64,
383  pub height: f64,
384}
385
386/// Element bounding box in viewport coordinates.
387#[derive(Debug, Clone, Copy)]
388pub struct BoundingBox {
389  pub x: f64,
390  pub y: f64,
391  pub width: f64,
392  pub height: f64,
393}
394
395/// A 2D point, relative to the top-left corner of an element's padding box
396/// (when used as [`DragAndDropOptions::source_position`] or
397/// [`DragAndDropOptions::target_position`]), or in viewport coordinates in
398/// other contexts. Used by `sourcePosition`, `targetPosition`, and click
399/// `position`.
400#[derive(Debug, Clone, Copy, Default, PartialEq)]
401pub struct Point {
402  pub x: f64,
403  pub y: f64,
404}
405
406/// Mouse button for click/dblclick/mousedown/mouseup. The `"left" |
407/// "right" | "middle"` union.
408#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
409pub enum MouseButton {
410  /// Primary button (default).
411  #[default]
412  Left,
413  /// Context-menu button.
414  Right,
415  /// Scroll wheel button.
416  Middle,
417}
418
419impl MouseButton {
420  /// CDP `Input.dispatchMouseEvent.button` wire value.
421  #[must_use]
422  pub fn as_cdp(self) -> &'static str {
423    match self {
424      Self::Left => "left",
425      Self::Right => "right",
426      Self::Middle => "middle",
427    }
428  }
429
430  /// `BiDi` `input.performActions.pointerDown.button` integer: `0=left`,
431  /// `1=middle`, `2=right` (per W3C `WebDriver BiDi` pointer input spec).
432  #[must_use]
433  pub fn as_bidi(self) -> u8 {
434    match self {
435      Self::Left => 0,
436      Self::Middle => 1,
437      Self::Right => 2,
438    }
439  }
440
441  /// Legacy native-`WebKit` mouse-button byte ordering: `0=left,
442  /// 1=right, 2=middle` — distinct from the CDP / `BiDi` pointer spec.
443  /// The current Playwright `WebKit` backend dispatches mouse events with
444  /// CDP-style string buttons (`backend/webkit/input.rs` uses
445  /// `MouseButton::as_cdp`), so this numeric accessor is no longer on a
446  /// live dispatch path; retained for the documented ordering and its
447  /// regression test.
448  #[must_use]
449  pub fn as_webkit(self) -> u8 {
450    match self {
451      Self::Left => 0,
452      Self::Right => 1,
453      Self::Middle => 2,
454    }
455  }
456
457  /// Parse from string form. Returns `None` on unknown input so callers
458  /// can raise a typed `FerriError::InvalidArgument` at the binding
459  /// boundary.
460  #[must_use]
461  pub fn parse(s: &str) -> Option<Self> {
462    match s {
463      "left" => Some(Self::Left),
464      "right" => Some(Self::Right),
465      "middle" => Some(Self::Middle),
466      _ => None,
467    }
468  }
469}
470
471/// Single keyboard modifier. The `Alt | Control | ControlOrMeta | Meta |
472/// Shift` union. `ControlOrMeta` resolves at call time — see
473/// [`Self::cdp_bit`].
474#[derive(Debug, Clone, Copy, PartialEq, Eq)]
475pub enum Modifier {
476  Alt,
477  Control,
478  ControlOrMeta,
479  Meta,
480  Shift,
481}
482
483impl Modifier {
484  /// Parse from string form. Returns `None` on unknown input.
485  #[must_use]
486  pub fn parse(s: &str) -> Option<Self> {
487    match s {
488      "Alt" => Some(Self::Alt),
489      "Control" => Some(Self::Control),
490      "ControlOrMeta" => Some(Self::ControlOrMeta),
491      "Meta" => Some(Self::Meta),
492      "Shift" => Some(Self::Shift),
493      _ => None,
494    }
495  }
496
497  /// CDP `Input.dispatchMouseEvent.modifiers` bitmask bit.
498  /// `Alt=1`, `Control=2`, `Meta=4`, `Shift=8` (per CDP docs).
499  /// `ControlOrMeta` collapses to `Meta` on macOS, `Control` elsewhere.
500  #[must_use]
501  pub fn cdp_bit(self) -> u8 {
502    match self {
503      Self::Alt => 1,
504      Self::Control => 2,
505      Self::Meta => 4,
506      Self::Shift => 8,
507      Self::ControlOrMeta => {
508        if cfg!(target_os = "macos") {
509          4
510        } else {
511          2
512        }
513      },
514    }
515  }
516
517  /// Platform-resolved key name for keydown/keyup events when pressing
518  /// modifiers around an action. `ControlOrMeta` collapses to `Meta` on
519  /// macOS, `Control` elsewhere.
520  #[must_use]
521  pub fn key_name(self) -> &'static str {
522    match self {
523      Self::Alt => "Alt",
524      Self::Control => "Control",
525      Self::Meta => "Meta",
526      Self::Shift => "Shift",
527      Self::ControlOrMeta => {
528        if cfg!(target_os = "macos") {
529          "Meta"
530        } else {
531          "Control"
532        }
533      },
534    }
535  }
536
537  /// DOM `KeyboardEvent.code` for this modifier's left variant — used
538  /// when we synthesize `Input.dispatchKeyEvent` in CDP to satisfy the
539  /// `code` parameter. Right variants have different codes but the
540  /// outward JS observability is identical.
541  #[must_use]
542  pub fn key_code(self) -> &'static str {
543    match self {
544      Self::Alt => "AltLeft",
545      Self::Control => "ControlLeft",
546      Self::Shift => "ShiftLeft",
547      Self::Meta => "MetaLeft",
548      Self::ControlOrMeta => {
549        if cfg!(target_os = "macos") {
550          "MetaLeft"
551        } else {
552          "ControlLeft"
553        }
554      },
555    }
556  }
557}
558
559/// Fold a list of modifiers into the CDP bitmask expected by
560/// `Input.dispatchMouseEvent.modifiers`.
561#[must_use]
562pub fn modifiers_bitmask(mods: &[Modifier]) -> u32 {
563  let mut m = 0u32;
564  for md in mods {
565    m |= u32::from(md.cdp_bit());
566  }
567  m
568}
569
570/// Full click option bag shared by Page/Locator/Frame click methods.
571/// All three expose the same 10-field surface.
572///
573/// Every option is `Option<T>`: callers omit fields and the backend
574/// applies the documented defaults (`button: Left`, `click_count: 1`,
575/// `delay: 0`, `steps: 1`, etc.).
576#[derive(Debug, Clone, Default)]
577pub struct ClickOptions {
578  /// Mouse button. Default: [`MouseButton::Left`].
579  pub button: Option<MouseButton>,
580  /// Number of consecutive clicks (`UIEvent.detail`). Default: `1`.
581  pub click_count: Option<u32>,
582  /// Wait in ms between `mousedown` and `mouseup`. Default: `0`.
583  pub delay: Option<u64>,
584  /// Bypass actionability (visibility/attached/enabled/stable) checks.
585  /// Default: `false`.
586  pub force: Option<bool>,
587  /// Modifier keys held during the click. Pressed before the mouse
588  /// events and released after, regardless of `trial`.
589  pub modifiers: Vec<Modifier>,
590  /// Deprecated — accepted for signature parity; no effect in
591  /// ferridriver (we don't implicitly wait for navigation after click).
592  pub no_wait_after: Option<bool>,
593  /// Click position relative to the element's padding-box top-left.
594  /// `None` → element's visible center.
595  pub position: Option<Point>,
596  /// Interpolated `mousemove` events between the current cursor and
597  /// the click point. Default: `1` (single move at dest).
598  pub steps: Option<u32>,
599  /// Maximum time in ms for the operation (actionability + click).
600  /// `0` means "no timeout". `None` means "use page/context default".
601  pub timeout: Option<u64>,
602  /// Run actionability checks only; skip the mouse events. Modifiers
603  /// are still pressed/released around the no-op.
604  pub trial: Option<bool>,
605}
606
607impl ClickOptions {
608  /// [`Self::button`] with the `Left` default applied.
609  #[must_use]
610  pub fn resolved_button(&self) -> MouseButton {
611    self.button.unwrap_or(MouseButton::Left)
612  }
613
614  /// [`Self::click_count`] with the `1` default applied.
615  #[must_use]
616  pub fn resolved_click_count(&self) -> u32 {
617    self.click_count.unwrap_or(1)
618  }
619
620  /// [`Self::delay`] with the `0ms` default applied.
621  #[must_use]
622  pub fn resolved_delay_ms(&self) -> u64 {
623    self.delay.unwrap_or(0)
624  }
625
626  /// [`Self::steps`] with the `1` default applied.
627  #[must_use]
628  pub fn resolved_steps(&self) -> u32 {
629    self.steps.unwrap_or(1).max(1)
630  }
631
632  /// `true` when the caller asked to bypass actionability checks.
633  #[must_use]
634  pub fn is_force(&self) -> bool {
635    self.force.unwrap_or(false)
636  }
637
638  /// `true` when the caller asked to run checks only (no click).
639  #[must_use]
640  pub fn is_trial(&self) -> bool {
641    self.trial.unwrap_or(false)
642  }
643}
644
645/// Options for `fill` (set an input's value). Three fields.
646/// `no_wait_after` is accepted for signature parity; `force` skips the
647/// fillable / editable actionability check.
648#[derive(Debug, Clone, Default)]
649pub struct FillOptions {
650  pub force: Option<bool>,
651  pub no_wait_after: Option<bool>,
652  pub timeout: Option<u64>,
653}
654
655impl FillOptions {
656  #[must_use]
657  pub fn is_force(&self) -> bool {
658    self.force.unwrap_or(false)
659  }
660}
661
662/// Options for `press` (single key press).
663/// `LocatorPressOptions` — three fields.
664#[derive(Debug, Clone, Default)]
665pub struct PressOptions {
666  /// Milliseconds to hold the key down between `keydown` and `keyup`.
667  pub delay: Option<u64>,
668  pub no_wait_after: Option<bool>,
669  pub timeout: Option<u64>,
670}
671
672impl PressOptions {
673  #[must_use]
674  pub fn resolved_delay_ms(&self) -> u64 {
675    self.delay.unwrap_or(0)
676  }
677}
678
679/// Options for `type` / `press_sequentially` (type text character-by-
680/// character). `LocatorTypeOptions` — three fields.
681#[derive(Debug, Clone, Default)]
682pub struct TypeOptions {
683  /// Milliseconds between consecutive `keydown` + `keyup` pairs.
684  pub delay: Option<u64>,
685  pub no_wait_after: Option<bool>,
686  pub timeout: Option<u64>,
687}
688
689impl TypeOptions {
690  #[must_use]
691  pub fn resolved_delay_ms(&self) -> u64 {
692    self.delay.unwrap_or(0)
693  }
694}
695
696/// Options for `check` / `uncheck` / `setChecked`.
697/// `LocatorCheckOptions` / `LocatorSetCheckedOptions` — five fields.
698/// Internally a check is a click on a checkbox/radio; these options
699/// mirror [`ClickOptions`] minus `button`, `click_count`, `delay`,
700/// `modifiers`, `steps`.
701#[derive(Debug, Clone, Default)]
702pub struct CheckOptions {
703  pub force: Option<bool>,
704  pub no_wait_after: Option<bool>,
705  pub position: Option<Point>,
706  pub timeout: Option<u64>,
707  pub trial: Option<bool>,
708}
709
710impl CheckOptions {
711  #[must_use]
712  pub fn is_force(&self) -> bool {
713    self.force.unwrap_or(false)
714  }
715
716  #[must_use]
717  pub fn is_trial(&self) -> bool {
718    self.trial.unwrap_or(false)
719  }
720
721  /// Lower to [`ClickOptions`] for the shared click dispatch path.
722  /// Check/uncheck/setChecked all internally click the element; the
723  /// caller-facing options only cover the click-invariant subset.
724  #[must_use]
725  pub fn into_click_options(self) -> ClickOptions {
726    ClickOptions {
727      button: None,
728      click_count: None,
729      delay: None,
730      force: self.force,
731      modifiers: Vec::new(),
732      no_wait_after: self.no_wait_after,
733      position: self.position,
734      steps: None,
735      timeout: self.timeout,
736      trial: self.trial,
737    }
738  }
739}
740
741/// A single descriptor used by `selectOption`. At least one of `value`,
742/// `label`, or `index` must be set. An array of these descriptors
743/// selects every `<option>` matching any descriptor (multi-select).
744#[derive(Debug, Clone, Default, serde::Serialize)]
745pub struct SelectOptionValue {
746  #[serde(skip_serializing_if = "Option::is_none")]
747  pub value: Option<String>,
748  #[serde(skip_serializing_if = "Option::is_none")]
749  pub label: Option<String>,
750  #[serde(skip_serializing_if = "Option::is_none")]
751  pub index: Option<u32>,
752}
753
754impl SelectOptionValue {
755  /// Shortcut for `{ value: Some(s), ... }` — the most common form.
756  #[must_use]
757  pub fn by_value(s: impl Into<String>) -> Self {
758    Self {
759      value: Some(s.into()),
760      ..Self::default()
761    }
762  }
763
764  /// Shortcut for `{ label: Some(s), ... }` — selects by the option's
765  /// visible text.
766  #[must_use]
767  pub fn by_label(s: impl Into<String>) -> Self {
768    Self {
769      label: Some(s.into()),
770      ..Self::default()
771    }
772  }
773
774  /// Shortcut for `{ index: Some(i), ... }`.
775  #[must_use]
776  pub fn by_index(i: u32) -> Self {
777    Self {
778      index: Some(i),
779      ..Self::default()
780    }
781  }
782}
783
784/// Options for `selectOption`.
785/// `LocatorSelectOptionOptions` — three fields.
786#[derive(Debug, Clone, Default)]
787pub struct SelectOptionOptions {
788  pub force: Option<bool>,
789  pub no_wait_after: Option<bool>,
790  pub timeout: Option<u64>,
791}
792
793/// Options for `setInputFiles`.
794/// `LocatorSetInputFilesOptions` — two fields.
795#[derive(Debug, Clone, Default)]
796pub struct SetInputFilesOptions {
797  pub no_wait_after: Option<bool>,
798  pub timeout: Option<u64>,
799}
800
801/// File payload for `setInputFiles`.
802/// `FilePayload` — caller supplies raw bytes plus the filename and MIME
803/// type that the page should see, avoiding any on-disk write.
804#[derive(Debug, Clone)]
805pub struct FilePayload {
806  pub name: String,
807  pub mime_type: String,
808  pub buffer: Vec<u8>,
809}
810
811/// Input-file argument for `setInputFiles`.
812/// `string | string[] | FilePayload | FilePayload[]` union from
813/// `types.d.ts` under `setInputFiles`.
814#[derive(Debug, Clone)]
815pub enum InputFiles {
816  /// Paths on disk — read and uploaded as-is.
817  Paths(Vec<std::path::PathBuf>),
818  /// In-memory payloads — uploaded without touching disk.
819  Payloads(Vec<FilePayload>),
820}
821
822/// Options for `dispatchEvent`.
823/// `LocatorDispatchEventOptions` — single field (`timeout`).
824#[derive(Debug, Clone, Default)]
825pub struct DispatchEventOptions {
826  pub timeout: Option<u64>,
827}
828
829/// Options for hover actions. Shape is [`ClickOptions`] minus `button`,
830/// `click_count`, `delay` (no press/release — just a `mousemove` at the
831/// target).
832#[derive(Debug, Clone, Default)]
833pub struct HoverOptions {
834  pub force: Option<bool>,
835  pub modifiers: Vec<Modifier>,
836  pub no_wait_after: Option<bool>,
837  pub position: Option<Point>,
838  pub timeout: Option<u64>,
839  pub trial: Option<bool>,
840}
841
842impl HoverOptions {
843  /// `true` when the caller asked to bypass actionability checks.
844  #[must_use]
845  pub fn is_force(&self) -> bool {
846    self.force.unwrap_or(false)
847  }
848
849  /// `true` when the caller asked to run checks only (no mousemove).
850  #[must_use]
851  pub fn is_trial(&self) -> bool {
852    self.trial.unwrap_or(false)
853  }
854}
855
856/// Options for tap actions (touch input). Distinct from [`HoverOptions`]
857/// so future tap-only divergence (e.g. native touch options) has a stable
858/// home.
859#[derive(Debug, Clone, Default)]
860pub struct TapOptions {
861  pub force: Option<bool>,
862  pub modifiers: Vec<Modifier>,
863  pub no_wait_after: Option<bool>,
864  pub position: Option<Point>,
865  pub timeout: Option<u64>,
866  pub trial: Option<bool>,
867}
868
869impl TapOptions {
870  /// `true` when the caller asked to bypass actionability checks.
871  #[must_use]
872  pub fn is_force(&self) -> bool {
873    self.force.unwrap_or(false)
874  }
875
876  /// `true` when the caller asked to run checks only (no touch dispatch).
877  #[must_use]
878  pub fn is_trial(&self) -> bool {
879    self.trial.unwrap_or(false)
880  }
881}
882
883/// Options for double-click actions. Identical to [`ClickOptions`] minus
884/// `click_count` (which is forced to `2` at dispatch time).
885#[derive(Debug, Clone, Default)]
886pub struct DblClickOptions {
887  pub button: Option<MouseButton>,
888  pub delay: Option<u64>,
889  pub force: Option<bool>,
890  pub modifiers: Vec<Modifier>,
891  pub no_wait_after: Option<bool>,
892  pub position: Option<Point>,
893  pub steps: Option<u32>,
894  pub timeout: Option<u64>,
895  pub trial: Option<bool>,
896}
897
898impl DblClickOptions {
899  /// Lower to [`ClickOptions`] with `click_count` forced to `2`. The
900  /// shared click dispatch path then emits two `mousedown`/`mouseup`
901  /// pairs with `clickCount=1` then `clickCount=2`.
902  #[must_use]
903  pub fn into_click_options(self) -> ClickOptions {
904    ClickOptions {
905      button: self.button,
906      click_count: Some(2),
907      delay: self.delay,
908      force: self.force,
909      modifiers: self.modifiers,
910      no_wait_after: self.no_wait_after,
911      position: self.position,
912      steps: self.steps,
913      timeout: self.timeout,
914      trial: self.trial,
915    }
916  }
917}
918
919/// Options for [`crate::page::Page::drag_and_drop`] and
920/// [`crate::locator::Locator::drag_to`].
921///
922/// `strict` is meaningful only on [`crate::page::Page::drag_and_drop`] (which
923/// accepts bare selectors); [`crate::locator::Locator::drag_to`] ignores it
924/// because the source locator already carries its own strict flag.
925///
926/// `no_wait_after` is accepted for signature parity but has no effect.
927#[derive(Debug, Clone, Default)]
928pub struct DragAndDropOptions {
929  /// Bypass actionability checks.
930  pub force: Option<bool>,
931  /// Deprecated — no effect. Accepted for signature parity.
932  pub no_wait_after: Option<bool>,
933  /// Press point relative to the source element's padding-box top-left.
934  /// When absent, the source element's center is used.
935  pub source_position: Option<Point>,
936  /// Release point relative to the target element's padding-box top-left.
937  /// When absent, the target element's center is used.
938  pub target_position: Option<Point>,
939  /// Number of interpolated `mousemove` events between press and release.
940  /// default is `1` (a single move at the destination).
941  pub steps: Option<u32>,
942  /// Strict-mode override for resolving the source/target selector.
943  /// Meaningful only on `page.drag_and_drop`; ignored by `locator.drag_to`.
944  pub strict: Option<bool>,
945  /// Maximum time in ms. `0` means no timeout. Default is inherited from
946  /// the context's default action timeout.
947  pub timeout: Option<u64>,
948  /// Perform actionability checks only; skip the actual mouse press/move/release.
949  pub trial: Option<bool>,
950}
951
952/// Options for `Locator.drop`.
953/// Mirrors Playwright's `Locator.drop(payload, options)` per
954/// `client/locator.ts` — the option bag omits `payloads`, `localPaths`,
955/// `streams`, `data`, `force`, and `trial` (those are folded into the
956/// `DropPayload` or are unsupported), leaving the actionability +
957/// positioning fields shared with the other pointer actions.
958#[derive(Debug, Clone, Default)]
959pub struct DropOptions {
960  /// Modifier keys held during the drop's pointer events.
961  pub modifiers: Vec<Modifier>,
962  /// Drop point relative to the target element's padding-box top-left.
963  /// When absent, the target element's center is used.
964  pub position: Option<Point>,
965  /// Maximum time in ms. `0` means no timeout. Default is inherited from
966  /// the context's default action timeout.
967  pub timeout: Option<u64>,
968}
969
970/// Drop payload for `Locator.drop`.
971/// Mirrors Playwright's `DropPayload` (`client/types.ts`):
972/// `{ files?: string | FilePayload | string[] | FilePayload[], data?: { [mimeType: string]: string } }`.
973/// Both fields are optional; an empty payload still dispatches the
974/// drag/drop event sequence with an empty `DataTransfer`.
975///
976/// Native shape only — file payloads are `FilePayload { name, mimeType,
977/// buffer }`, never the `{ buffer: base64 }` wire form, and `data` is a
978/// list of `(mimeType, value)` pairs lowered from the JS object/map.
979#[derive(Debug, Clone, Default)]
980pub struct DropPayload {
981  /// Files dragged onto the target. `None` means no files; an empty list
982  /// behaves the same as `None` (no `File` objects added to the transfer).
983  pub files: Option<InputFiles>,
984  /// `(mimeType, value)` entries set on the transfer via `DataTransfer.setData`.
985  pub data: Vec<(String, String)>,
986}
987
988/// Viewport configuration -- consistent across all backends.
989/// Matches viewport options.
990#[derive(Debug, Clone, PartialEq)]
991pub struct ViewportConfig {
992  /// CSS pixel width of the viewport.
993  pub width: i64,
994  /// CSS pixel height of the viewport.
995  pub height: i64,
996  /// Device scale factor (DPR). 1 for standard, 2 for Retina.
997  pub device_scale_factor: f64,
998  /// Simulate mobile device.
999  pub is_mobile: bool,
1000  /// Enable touch events.
1001  pub has_touch: bool,
1002  /// Landscape orientation.
1003  pub is_landscape: bool,
1004}
1005
1006/// Three-state override for a single media-emulation field. Mirrors the
1007/// TS shape `T | null | undefined`:
1008///
1009/// * [`MediaOverride::Unchanged`] — the caller omitted this field; leave
1010///   the page's existing override (if any) in place.
1011/// * [`MediaOverride::Disabled`] — the caller passed `null`; clear this
1012///   specific override so the page falls back to the platform default.
1013/// * [`MediaOverride::Set`] — the caller passed a value; apply it.
1014#[derive(Debug, Clone, Default, PartialEq, Eq)]
1015pub enum MediaOverride {
1016  /// Field absent from the caller's options bag.
1017  #[default]
1018  Unchanged,
1019  /// Field explicitly set to `null` — disables the override.
1020  Disabled,
1021  /// Field set to a concrete value.
1022  Set(String),
1023}
1024
1025impl MediaOverride {
1026  /// Borrow the set value, or `None` for `Unchanged` / `Disabled`.
1027  #[must_use]
1028  pub fn as_value(&self) -> Option<&str> {
1029    match self {
1030      Self::Set(v) => Some(v.as_str()),
1031      _ => None,
1032    }
1033  }
1034
1035  /// `true` when the caller is overriding the field (set-or-disable).
1036  #[must_use]
1037  pub fn is_specified(&self) -> bool {
1038    !matches!(self, Self::Unchanged)
1039  }
1040}
1041
1042impl From<Option<String>> for MediaOverride {
1043  fn from(o: Option<String>) -> Self {
1044    o.map_or(Self::Unchanged, Self::Set)
1045  }
1046}
1047
1048/// Media emulation options — matches `page.emulateMedia()`. Each field
1049/// uses [`MediaOverride`] to distinguish *unspecified* (leave current
1050/// state alone) from *null* (clear any existing override) from a
1051/// *concrete value*.
1052#[derive(Debug, Clone, Default)]
1053pub struct EmulateMediaOptions {
1054  /// CSS media type: `"screen"` or `"print"`.
1055  pub media: MediaOverride,
1056  /// Prefers-color-scheme: `"light"`, `"dark"`, or `"no-preference"`.
1057  pub color_scheme: MediaOverride,
1058  /// Prefers-reduced-motion: `"reduce"` or `"no-preference"`.
1059  pub reduced_motion: MediaOverride,
1060  /// Forced-colors: `"active"` or `"none"`.
1061  pub forced_colors: MediaOverride,
1062  /// Prefers-contrast: `"more"`, `"less"`, or `"no-preference"`.
1063  pub contrast: MediaOverride,
1064}
1065
1066/// PDF page-size dimension as accepted by `PDFOptions.width`,
1067/// `PDFOptions.height`, and `PDFOptions.margin.*` fields.
1068///
1069/// TS accepts `string | number`. A bare number is interpreted as CSS
1070/// pixels; a string must end with one of the unit suffixes `px`, `in`,
1071/// `cm`, `mm`.
1072#[derive(Debug, Clone, PartialEq)]
1073pub enum PdfSize {
1074  /// Pixels — either from a bare numeric input or from a `"Npx"` string.
1075  Pixels(f64),
1076  /// `"Nin"` — inches.
1077  Inches(f64),
1078  /// `"Ncm"` — centimeters.
1079  Centimeters(f64),
1080  /// `"Nmm"` — millimeters.
1081  Millimeters(f64),
1082}
1083
1084impl PdfSize {
1085  /// Convert to inches. CDP `Page.printToPDF` expects inches
1086  /// for `paperWidth` / `paperHeight` / `marginTop` / etc.
1087  ///
1088  /// Conversion constants: `px÷96`, `in`, `cm·37.8/96`, `mm·3.78/96`.
1089  #[must_use]
1090  pub fn to_inches(&self) -> f64 {
1091    match *self {
1092      Self::Pixels(v) => v / 96.0,
1093      Self::Inches(v) => v,
1094      Self::Centimeters(v) => v * 37.8 / 96.0,
1095      Self::Millimeters(v) => v * 3.78 / 96.0,
1096    }
1097  }
1098
1099  /// Parse a size string (`"10px"`, `"2in"`, `"5cm"`, `"15mm"`). Unknown
1100  /// suffix — or no suffix — is treated as bare pixels.
1101  ///
1102  /// # Errors
1103  ///
1104  /// Returns [`crate::error::FerriError::InvalidArgument`] if the numeric
1105  /// portion cannot be parsed.
1106  pub fn parse(text: &str) -> crate::error::Result<Self> {
1107    let trimmed = text.trim();
1108    let (num_str, unit) = if trimmed.len() >= 2 {
1109      let (head, tail) = trimmed.split_at(trimmed.len() - 2);
1110      match tail.to_ascii_lowercase().as_str() {
1111        "px" => (head, "px"),
1112        "in" => (head, "in"),
1113        "cm" => (head, "cm"),
1114        "mm" => (head, "mm"),
1115        _ => (trimmed, "px"),
1116      }
1117    } else {
1118      (trimmed, "px")
1119    };
1120    let value: f64 = num_str.trim().parse().map_err(|_| {
1121      crate::error::FerriError::invalid_argument("pdf size", format!("cannot parse numeric portion of {text:?}"))
1122    })?;
1123    Ok(match unit {
1124      "px" => Self::Pixels(value),
1125      "in" => Self::Inches(value),
1126      "cm" => Self::Centimeters(value),
1127      "mm" => Self::Millimeters(value),
1128      _ => unreachable!("unit matched above"),
1129    })
1130  }
1131}
1132
1133/// Per-side PDF margins. Each side may be `Some(PdfSize)` or `None` (zero).
1134#[derive(Debug, Clone, Default)]
1135pub struct PdfMargin {
1136  pub top: Option<PdfSize>,
1137  pub right: Option<PdfSize>,
1138  pub bottom: Option<PdfSize>,
1139  pub left: Option<PdfSize>,
1140}
1141
1142/// Full `PDFOptions` surface (15 fields). Routes through the CDP
1143/// `Page.printToPDF` plumbing.
1144///
1145/// Defaults: every field is `None`/empty; the CDP layer applies its own
1146/// defaults (`scale = 1`, `landscape = false`, `pageRanges = ""`, ...).
1147/// Only `path` is Rust-side: if set, the generated PDF bytes are written
1148/// there by `Page::pdf` (the bytes are also returned).
1149#[derive(Debug, Clone, Default)]
1150pub struct PdfOptions {
1151  /// Paper format keyword. Case-insensitive match against
1152  /// [`pdf_paper_format_size`]: `Letter`, `Legal`, `Tabloid`, `Ledger`,
1153  /// `A0`..`A6`. When set, overrides `width`/`height`.
1154  pub format: Option<String>,
1155  /// Filesystem path to additionally write the generated PDF to.
1156  pub path: Option<std::path::PathBuf>,
1157  /// Scale factor. default is `1.0` (applied by CDP backend
1158  /// when `None`). Valid range per Chrome: `0.1..=2.0`.
1159  pub scale: Option<f64>,
1160  /// Render header/footer.
1161  pub display_header_footer: Option<bool>,
1162  /// HTML template for the header (uses CSS print media).
1163  pub header_template: Option<String>,
1164  /// HTML template for the footer.
1165  pub footer_template: Option<String>,
1166  /// Include CSS `background`s in the rendering.
1167  pub print_background: Option<bool>,
1168  /// Rotate the page 90° for landscape orientation.
1169  pub landscape: Option<bool>,
1170  /// Page-range filter, e.g. `"1-5, 8, 11-13"`. Empty string = all pages.
1171  pub page_ranges: Option<String>,
1172  /// Page width (ignored if `format` is set).
1173  pub width: Option<PdfSize>,
1174  /// Page height (ignored if `format` is set).
1175  pub height: Option<PdfSize>,
1176  /// Per-side margins.
1177  pub margin: Option<PdfMargin>,
1178  /// Prefer the CSS `@page` size declared in the document over `format` /
1179  /// `width` / `height`.
1180  pub prefer_css_page_size: Option<bool>,
1181  /// Embed a document outline (Chrome's `generateDocumentOutline`).
1182  pub outline: Option<bool>,
1183  /// Emit a tagged (structured / accessible) PDF.
1184  pub tagged: Option<bool>,
1185}
1186
1187/// Paper-format size lookup. Case-insensitive. Sizes are in inches.
1188///
1189/// Returns `(width, height)` in inches, or `None` if the format is unknown.
1190#[must_use]
1191pub fn pdf_paper_format_size(format: &str) -> Option<(f64, f64)> {
1192  match format.to_ascii_lowercase().as_str() {
1193    "letter" => Some((8.5, 11.0)),
1194    "legal" => Some((8.5, 14.0)),
1195    "tabloid" => Some((11.0, 17.0)),
1196    "ledger" => Some((17.0, 11.0)),
1197    "a0" => Some((33.1, 46.8)),
1198    "a1" => Some((23.4, 33.1)),
1199    "a2" => Some((16.54, 23.4)),
1200    "a3" => Some((11.7, 16.54)),
1201    "a4" => Some((8.27, 11.7)),
1202    "a5" => Some((5.83, 8.27)),
1203    "a6" => Some((4.13, 5.83)),
1204    _ => None,
1205  }
1206}
1207
1208/// Options for [`crate::page::Page::close`].
1209/// `page.close({ runBeforeUnload, reason })`.
1210#[derive(Debug, Clone, Default)]
1211pub struct PageCloseOptions {
1212  /// When `true`, the page's `beforeunload` handlers fire before close.
1213  /// Chromium mapping: switches the CDP method from `Target.closeTarget`
1214  /// (force-close) to `Page.close` (fires `beforeunload`).
1215  pub run_before_unload: Option<bool>,
1216  /// Human-readable reason attached to the resulting `TargetClosed` error
1217  /// that any in-flight operation on this page will receive.
1218  pub reason: Option<String>,
1219}
1220
1221/// Behavior for [`crate::page::Page::unroute_all`].
1222/// Mirrors Playwright's `page.unrouteAll({ behavior })` union
1223/// `'wait' | 'ignoreErrors' | 'default'`.
1224///
1225/// In Playwright this controls whether to wait for currently-running
1226/// route handlers to finish (`wait`), wait but swallow their errors
1227/// (`ignoreErrors`), or not wait at all (`default`). In ferridriver
1228/// route handlers run synchronously inside the interception loop, so no
1229/// detached handler task can still be in flight after the routes are
1230/// cleared; the variant is accepted for API parity and selects the same
1231/// post-clear teardown in every case.
1232#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1233pub enum UnrouteBehavior {
1234  /// Do not wait for active handlers (Playwright default).
1235  #[default]
1236  Default,
1237  /// Wait for active handlers to finish.
1238  Wait,
1239  /// Wait for active handlers and ignore any errors they raise.
1240  IgnoreErrors,
1241}
1242
1243/// Options for [`crate::browser::Browser::close`].
1244/// `browser.close({ reason })`.
1245#[derive(Debug, Clone, Default)]
1246pub struct BrowserCloseOptions {
1247  /// Human-readable reason surfaced to `TargetClosed` errors emitted by any
1248  /// in-flight operation on pages/contexts from this browser.
1249  pub reason: Option<String>,
1250}
1251
1252/// Navigation options for goto/reload/goBack/goForward.
1253#[derive(Debug, Clone, Default)]
1254pub struct GotoOptions {
1255  /// When to consider navigation complete:
1256  /// "load" (default), "domcontentloaded", "networkidle", "commit"
1257  pub wait_until: Option<String>,
1258  /// Maximum navigation timeout in milliseconds.
1259  pub timeout: Option<u64>,
1260  /// HTTP `Referer` header to send with the navigation request. Mirrors
1261  /// `page.goto(url, { referer })`. If both this and
1262  /// `extraHTTPHeaders.referer` are set, this wins.
1263  pub referer: Option<String>,
1264}
1265
1266/// Which browser product. Three `BrowserType` instances exposed as
1267/// `chromium`, `firefox`, and `webkit` on the top-level module.
1268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1269pub enum BrowserKind {
1270  /// Google Chrome / Chromium
1271  Chromium,
1272  /// Mozilla Firefox
1273  Firefox,
1274  /// Apple `WebKit` (macOS only)
1275  WebKit,
1276}
1277
1278impl BrowserKind {
1279  /// Product name string: `"chromium"` / `"firefox"` / `"webkit"`.
1280  /// Matches `BrowserType.name()`.
1281  #[must_use]
1282  pub fn name(self) -> &'static str {
1283    match self {
1284      Self::Chromium => "chromium",
1285      Self::Firefox => "firefox",
1286      Self::WebKit => "webkit",
1287    }
1288  }
1289
1290  /// Default backend protocol for this product. Chromium runs over the
1291  /// CDP pipe transport (lowest latency), Firefox over `WebDriver`
1292  /// `BiDi`, and `WebKit` over the native macOS host IPC.
1293  #[must_use]
1294  pub fn default_backend(self) -> crate::backend::BackendKind {
1295    match self {
1296      Self::Chromium => crate::backend::BackendKind::CdpPipe,
1297      Self::Firefox => crate::backend::BackendKind::Bidi,
1298      Self::WebKit => crate::backend::BackendKind::WebKit,
1299    }
1300  }
1301}
1302
1303/// Public launch options bag, the `browserType.launch(options)`
1304/// parameter. Selection of which browser to launch happens via the
1305/// `BrowserType` instance you call `.launch(...)` on (`chromium()`,
1306/// `firefox()`, `webkit()`); this bag only carries the per-launch knobs.
1307#[derive(Debug, Clone, Default)]
1308pub struct LaunchOptions {
1309  /// Run in headless mode. Defaults to `true` (default).
1310  pub headless: Option<bool>,
1311  /// Path to a browser executable to run instead of the bundled one.
1312  pub executable_path: Option<String>,
1313  /// Extra command-line arguments to pass to the browser.
1314  pub args: Vec<String>,
1315  /// Browser distribution channel (e.g. `"chrome"`, `"chrome-beta"`,
1316  /// `"msedge"`). Currently surface-only — the bundled-browser resolver
1317  /// reads this when selecting between the headless shell and a
1318  /// channel-specific Chrome install.
1319  pub channel: Option<String>,
1320  /// Environment variables to set when spawning the browser process.
1321  pub env: Option<rustc_hash::FxHashMap<String, String>>,
1322  /// Slow down operations by this many ms (debugging).
1323  pub slow_mo: Option<u64>,
1324  /// Maximum time to wait for the browser to start. `0` means no
1325  /// timeout. Defaults to `30_000`.
1326  pub timeout: Option<u64>,
1327  /// Directory to use for downloads (per-context override is on
1328  /// [`BrowserContextOptions`] / persistent-context launch).
1329  pub downloads_path: Option<std::path::PathBuf>,
1330  /// If `true`, do not pass the bundled "default args"; if a list of
1331  /// strings, filter out the named default args. Currently surface-only
1332  /// — wired through to [`LaunchPlan`] for future filtering work.
1333  pub ignore_default_args: Option<IgnoreDefaultArgs>,
1334  /// Per-process signal handling — defaults all three to
1335  /// `true` (close the browser on SIGHUP / SIGINT / SIGTERM).
1336  pub handle_sighup: Option<bool>,
1337  pub handle_sigint: Option<bool>,
1338  pub handle_sigterm: Option<bool>,
1339  /// Enable Chromium sandboxing. Defaults to `false`.
1340  pub chromium_sandbox: Option<bool>,
1341  /// Firefox `about:config` user-prefs map.
1342  pub firefox_user_prefs: Option<rustc_hash::FxHashMap<String, serde_json::Value>>,
1343  /// Network proxy applied at the browser level.
1344  pub proxy: Option<ProxyConfig>,
1345  /// Tracing artifact directory.
1346  pub traces_dir: Option<std::path::PathBuf>,
1347}
1348
1349/// `ignoreDefaultArgs?: boolean | string[]` shape.
1350#[derive(Debug, Clone, PartialEq, Eq)]
1351pub enum IgnoreDefaultArgs {
1352  /// `true` — drop ALL default args.
1353  All,
1354  /// String list — drop just these.
1355  Some(Vec<String>),
1356}
1357
1358/// Connect-to-server options bag for `browserType.connect(wsEndpoint, options)`.
1359#[derive(Debug, Clone, Default)]
1360pub struct ConnectOptions {
1361  pub headers: Option<rustc_hash::FxHashMap<String, String>>,
1362  pub slow_mo: Option<u64>,
1363  pub timeout: Option<u64>,
1364  pub expose_network: Option<String>,
1365}
1366
1367/// Connect-over-CDP options bag for `browserType.connectOverCDP(endpointURL, options)`.
1368/// Chromium-only.
1369#[derive(Debug, Clone, Default)]
1370pub struct ConnectOverCdpOptions {
1371  pub headers: Option<rustc_hash::FxHashMap<String, String>>,
1372  pub slow_mo: Option<u64>,
1373  pub timeout: Option<u64>,
1374}
1375
1376/// Persistent-context launch options bag for
1377/// `browserType.launchPersistentContext(userDataDir, options)`. Composed
1378/// of the launch knobs plus a full [`BrowserContextOptions`] applied to
1379/// the implicit default context.
1380#[derive(Debug, Clone, Default)]
1381pub struct LaunchPersistentContextOptions {
1382  /// Per-launch knobs (mirror [`LaunchOptions`] exactly).
1383  pub launch: LaunchOptions,
1384  /// Per-context knobs applied to the default context that ships with
1385  /// the persistent profile.
1386  pub context: BrowserContextOptions,
1387}
1388
1389/// Per-`BrowserType` instance configuration carried by `chromium()` /
1390/// `firefox()` / `webkit()` factories. The single field that varies
1391/// today is `transport` for Chromium — `chromium` is
1392/// always pipe-only; ferridriver lets callers override to the
1393/// `WebSocket` transport (`CdpRaw`) for backend-coverage testing.
1394#[derive(Debug, Clone, Default)]
1395pub struct BrowserTypeOptions {
1396  /// Transport override for Chromium. Ignored for Firefox / `WebKit`.
1397  pub transport: Option<ChromiumTransport>,
1398}
1399
1400/// Wire transport for the Chromium backend.
1401#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1402pub enum ChromiumTransport {
1403  /// CDP over Unix pipe (fd 3/4). Default — lowest latency.
1404  Pipe,
1405  /// CDP over WebSocket. Used by `connectOverCDP` and explicitly
1406  /// selectable via `chromium({ transport: 'ws' })`.
1407  Ws,
1408}
1409
1410/// Internal launch plan. Carries fields that are NOT exposed on the
1411/// public [`LaunchOptions`] (which mirrors verbatim) but
1412/// that the runtime needs in order to launch / connect to the right
1413/// backend. Constructed by `BrowserType` from the public options bag
1414/// and the per-instance kind/transport, then handed to
1415/// [`crate::state::BrowserState::with_plan`].
1416#[derive(Debug, Clone)]
1417pub struct LaunchPlan {
1418  pub backend: crate::backend::BackendKind,
1419  pub kind: BrowserKind,
1420  pub headless: bool,
1421  pub executable_path: Option<String>,
1422  pub args: Vec<String>,
1423  pub channel: Option<String>,
1424  pub env: Option<rustc_hash::FxHashMap<String, String>>,
1425  pub user_data_dir: Option<String>,
1426  pub ws_endpoint: Option<String>,
1427  pub auto_connect: Option<AutoConnectOptions>,
1428  pub default_viewport: Option<ViewportConfig>,
1429  pub slow_mo: Option<u64>,
1430  pub timeout: Option<u64>,
1431  pub downloads_path: Option<std::path::PathBuf>,
1432  pub ignore_default_args: Option<IgnoreDefaultArgs>,
1433  pub handle_sighup: Option<bool>,
1434  pub handle_sigint: Option<bool>,
1435  pub handle_sigterm: Option<bool>,
1436  pub chromium_sandbox: Option<bool>,
1437  pub firefox_user_prefs: Option<rustc_hash::FxHashMap<String, serde_json::Value>>,
1438  pub proxy: Option<ProxyConfig>,
1439  pub traces_dir: Option<std::path::PathBuf>,
1440}
1441
1442#[derive(Debug, Clone)]
1443pub struct AutoConnectOptions {
1444  pub channel: String,
1445  pub user_data_dir: Option<String>,
1446}
1447
1448impl Default for LaunchPlan {
1449  fn default() -> Self {
1450    Self {
1451      backend: crate::backend::BackendKind::CdpPipe,
1452      kind: BrowserKind::Chromium,
1453      headless: true,
1454      executable_path: None,
1455      args: Vec::new(),
1456      channel: None,
1457      env: None,
1458      user_data_dir: None,
1459      ws_endpoint: None,
1460      auto_connect: None,
1461      default_viewport: Some(ViewportConfig::default()),
1462      slow_mo: None,
1463      timeout: None,
1464      downloads_path: None,
1465      ignore_default_args: None,
1466      handle_sighup: None,
1467      handle_sigint: None,
1468      handle_sigterm: None,
1469      chromium_sandbox: None,
1470      firefox_user_prefs: None,
1471      proxy: None,
1472      traces_dir: None,
1473    }
1474  }
1475}
1476
1477impl LaunchPlan {
1478  /// Build a launch plan from the public [`LaunchOptions`] plus the
1479  /// per-`BrowserType` selection (`kind` + optional Chromium transport
1480  /// override).
1481  #[must_use]
1482  pub fn from_public(kind: BrowserKind, transport: Option<ChromiumTransport>, opts: LaunchOptions) -> Self {
1483    let backend = match (kind, transport) {
1484      (BrowserKind::Chromium, Some(ChromiumTransport::Ws)) => crate::backend::BackendKind::CdpRaw,
1485      _ => kind.default_backend(),
1486    };
1487    Self {
1488      backend,
1489      kind,
1490      headless: opts.headless.unwrap_or(true),
1491      executable_path: opts.executable_path,
1492      args: opts.args,
1493      channel: opts.channel,
1494      env: opts.env,
1495      user_data_dir: None,
1496      ws_endpoint: None,
1497      auto_connect: None,
1498      default_viewport: Some(ViewportConfig::default()),
1499      slow_mo: opts.slow_mo,
1500      timeout: opts.timeout,
1501      downloads_path: opts.downloads_path,
1502      ignore_default_args: opts.ignore_default_args,
1503      handle_sighup: opts.handle_sighup,
1504      handle_sigint: opts.handle_sigint,
1505      handle_sigterm: opts.handle_sigterm,
1506      chromium_sandbox: opts.chromium_sandbox,
1507      firefox_user_prefs: opts.firefox_user_prefs,
1508      proxy: opts.proxy,
1509      traces_dir: opts.traces_dir,
1510    }
1511  }
1512}
1513
1514impl Default for ViewportConfig {
1515  fn default() -> Self {
1516    Self {
1517      width: 1280,
1518      height: 720,
1519      device_scale_factor: 1.0,
1520      is_mobile: false,
1521      has_touch: false,
1522      is_landscape: false,
1523    }
1524  }
1525}
1526
1527/// Per-context video-recording configuration: `recordVideo: { dir,
1528/// size? }` option on `browserType.launch` + `browser.newContext`.
1529/// Enabling it starts `CDP` screencast / `BiDi` polyfill recording on
1530/// every new page in the context; the file is finalised when the page
1531/// closes and surfaced via `page.video().path()`.
1532#[derive(Debug, Clone)]
1533pub struct RecordVideoOptions {
1534  /// Directory where the video file is written. One file per page.
1535  /// Filenames are derived from the page's created-at timestamp.
1536  pub dir: std::path::PathBuf,
1537  /// Optional explicit video dimensions. When `None`, ferridriver
1538  /// defaults to `800x450` (fallback when no viewport is set) unless the
1539  /// caller supplies a size. Values are forced to an even number of
1540  /// pixels so `libx264` accepts them without `yuv420p`-conversion
1541  /// warnings.
1542  pub size: Option<VideoSize>,
1543}
1544
1545/// Video frame dimensions for [`RecordVideoOptions::size`]. Matches
1546/// `recordVideo.size: { width, height }` shape.
1547#[derive(Debug, Clone, Copy)]
1548pub struct VideoSize {
1549  pub width: u32,
1550  pub height: u32,
1551}
1552
1553impl Default for VideoSize {
1554  fn default() -> Self {
1555    Self {
1556      width: 800,
1557      height: 450,
1558    }
1559  }
1560}
1561
1562/// Resolve a user-supplied URL against an optional base URL. Delegates
1563/// to the standard URL `new URL(given, base)` resolution rule.
1564///
1565/// - Absolute URLs (with scheme) are returned verbatim.
1566/// - Relative paths (`/foo`, `./foo`, `foo`) resolve against `base`.
1567/// - Invalid inputs fall through to the given URL unchanged —
1568///   matches try/catch fallback.
1569#[must_use]
1570pub fn construct_url_with_base(base: Option<&str>, given: &str) -> String {
1571  // No base, or already absolute (scheme present) → passthrough.
1572  if base.is_none() || given.contains("://") || given.starts_with("data:") || given.starts_with("about:") {
1573    return given.to_string();
1574  }
1575  let base = base.unwrap_or("");
1576  // Minimal URL-join: strip trailing slash from base (keep the root
1577  // slash only), handle given-has-leading-slash vs not. This is a
1578  // pragmatic subset — covers the common `baseURL + /path` and
1579  // `baseURL + path` cases. Absolute-URL / query / fragment rules
1580  // match `new URL(given, base)` for the common patterns.
1581  let (base_origin, base_path) = split_origin_and_path(base);
1582  if given.starts_with('/') {
1583    // Root-relative: replace the base's path entirely.
1584    return format!("{base_origin}{given}");
1585  }
1586  // Path-relative: strip the last segment of base_path (everything
1587  // after the final `/`) then append `given`.
1588  let cut = base_path.rfind('/').map_or(0, |i| i + 1);
1589  let kept = &base_path[..cut];
1590  format!("{base_origin}{kept}{given}")
1591}
1592
1593fn split_origin_and_path(url: &str) -> (&str, &str) {
1594  // Locate the `://` separator; if missing, treat the whole thing
1595  // as a path (no origin).
1596  let Some(scheme_end) = url.find("://") else {
1597    return ("", url);
1598  };
1599  let rest_start = scheme_end + 3;
1600  let rest = &url[rest_start..];
1601  // The path starts at the first `/` after the host (+optional port).
1602  match rest.find('/') {
1603    Some(path_start) => (&url[..rest_start + path_start], &rest[path_start..]),
1604    None => (url, "/"),
1605  }
1606}
1607
1608// ── BrowserContextOptions ──────────────────────────────────────────────────
1609
1610/// Geographic location emulation: `Geolocation { latitude, longitude,
1611/// accuracy? }`.
1612#[derive(Debug, Clone, Copy, PartialEq)]
1613pub struct Geolocation {
1614  /// Latitude between -90 and 90.
1615  pub latitude: f64,
1616  /// Longitude between -180 and 180.
1617  pub longitude: f64,
1618  /// Non-negative accuracy value. defaults to 0.
1619  pub accuracy: f64,
1620}
1621
1622impl Default for Geolocation {
1623  fn default() -> Self {
1624    Self {
1625      latitude: 0.0,
1626      longitude: 0.0,
1627      accuracy: 0.0,
1628    }
1629  }
1630}
1631
1632/// HTTP basic/digest credentials: `HTTPCredentials { username, password,
1633/// origin?, send? }`.
1634#[derive(Debug, Clone, Default, PartialEq, Eq)]
1635pub struct HttpCredentials {
1636  pub username: String,
1637  pub password: String,
1638  /// Scheme + host + optional port — restrict credential send to this
1639  /// origin. `None` sends on any 401 response.
1640  pub origin: Option<String>,
1641  /// `"always"` sends credentials on every `APIRequest`; `"unauthorized"`
1642  /// (default) waits for a 401. default: unauthorized.
1643  pub send: Option<HttpCredentialsSend>,
1644}
1645
1646/// Send policy for [`HttpCredentials`].
1647#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1648pub enum HttpCredentialsSend {
1649  /// Only on 401 responses (default).
1650  #[default]
1651  Unauthorized,
1652  /// On every request (`HttpClient` only).
1653  Always,
1654}
1655
1656/// Network proxy configuration.
1657/// `{ server, bypass?, username?, password? }` — types.d.ts:22412.
1658#[derive(Debug, Clone, Default, PartialEq, Eq)]
1659pub struct ProxyConfig {
1660  pub server: String,
1661  /// Comma-separated domain list (e.g. `".com, chromium.org"`).
1662  pub bypass: Option<String>,
1663  pub username: Option<String>,
1664  pub password: Option<String>,
1665}
1666
1667/// `recordHar` options bag. `recordHar` shape —
1668/// types.d.ts:22441.
1669#[derive(Debug, Clone)]
1670pub struct RecordHarOptions {
1671  pub path: std::path::PathBuf,
1672  /// `omit`/`embed`/`attach`. Default derived from `.path` extension.
1673  pub content: Option<RecordHarContent>,
1674  /// `full`/`minimal`. Default: full.
1675  pub mode: Option<RecordHarMode>,
1676  /// Legacy alias for `content: "omit"`. flags this deprecated
1677  /// but still accepts it.
1678  pub omit_content: Option<bool>,
1679  /// Glob/regex filter for stored requests.
1680  pub url_filter: Option<crate::url_matcher::UrlMatcher>,
1681}
1682
1683#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1684pub enum RecordHarContent {
1685  Omit,
1686  Embed,
1687  Attach,
1688}
1689
1690#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1691pub enum RecordHarMode {
1692  Full,
1693  Minimal,
1694}
1695
1696/// Logical viewport dimensions for [`BrowserContextOptions::viewport`].
1697/// Three states: `Default` (omit → browser default), `Null` (explicit
1698/// null → opt out of viewport emulation), or `Size(w,h)`.
1699#[derive(Debug, Clone, Default, PartialEq, Eq)]
1700pub enum ViewportOption {
1701  /// Field absent — default 1280x720.
1702  #[default]
1703  Default,
1704  /// Field explicitly `null` — opt out of fixed viewport.
1705  Null,
1706  /// Concrete viewport size.
1707  Size { width: i64, height: i64 },
1708}
1709
1710/// `window.screen` size emulation (when viewport is set). Mirrors
1711/// `screen: { width, height }` — types.d.ts:22539.
1712#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1713pub struct ScreenSize {
1714  pub width: i64,
1715  pub height: i64,
1716}
1717
1718/// A single `localStorage` entry — `{ name, value }`.
1719/// Mirrors Playwright's `NameValue` (protocol channels.d.ts:5399).
1720#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1721pub struct NameValue {
1722  pub name: String,
1723  pub value: String,
1724}
1725
1726/// Per-origin storage snapshot — `{ origin, localStorage }`.
1727/// Mirrors Playwright's `OriginStorage` (protocol channels.d.ts:5158),
1728/// minus `indexedDB` (not yet collected — see [`StorageState`]).
1729#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1730#[serde(rename_all = "camelCase")]
1731pub struct OriginState {
1732  pub origin: String,
1733  pub local_storage: Vec<NameValue>,
1734}
1735
1736/// The state EXPORTED by `context.storageState()` — `{ cookies, origins }`.
1737/// Mirrors Playwright's `StorageState`
1738/// (`/tmp/playwright/packages/playwright-core/src/client/types.ts:42`).
1739/// Serializes to the same JSON shape consumed by [`StorageStateInput`], so a
1740/// round-trip (export -> `new_context` `storageState`) preserves state.
1741///
1742/// `indexedDB` is intentionally NOT collected yet; Playwright gates it behind
1743/// `storageState({ indexedDB: true })`. We accept the option for signature
1744/// parity but always emit an empty `IndexedDB` set.
1745#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1746pub struct StorageState {
1747  pub cookies: Vec<crate::backend::CookieData>,
1748  pub origins: Vec<OriginState>,
1749}
1750
1751/// Options for `context.storageState(options?)`.
1752/// `{ path?: string, indexedDB?: boolean }` —
1753/// `/tmp/playwright/packages/playwright-core/src/client/browserContext.ts:460`.
1754#[derive(Debug, Clone, Default)]
1755pub struct StorageStateOptions {
1756  /// Write the JSON-serialized state to this file (pretty-printed, 2-space).
1757  pub path: Option<std::path::PathBuf>,
1758  /// Collect `IndexedDB` databases as well. Not yet implemented; accepted for
1759  /// signature parity. When `true`, the export is unaffected (empty set).
1760  pub indexed_db: Option<bool>,
1761}
1762
1763/// Storage state bag — cookies + per-origin localStorage snapshot.
1764/// `storageState: string | { cookies, origins }` —
1765/// types.d.ts:22566.
1766#[derive(Debug, Clone)]
1767pub enum StorageStateInput {
1768  /// Path to a JSON file written by `context.storageState({ path })`.
1769  Path(std::path::PathBuf),
1770  /// Inline state object.
1771  Inline(serde_json::Value),
1772}
1773
1774/// Service-worker policy.
1775/// `serviceWorkers: "allow" | "block"` — types.d.ts:22557.
1776#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1777pub enum ServiceWorkerPolicy {
1778  #[default]
1779  Allow,
1780  Block,
1781}
1782
1783/// `BrowserContextOptions` — the option bag accepted by
1784/// `Browser::new_context`. Full 28-field shape.
1785///
1786/// Every field is optional. `None` means "browser default"; an explicit
1787/// value applies the corresponding emulation at every page opened in
1788/// the context. Several fields have sub-options that distinguish
1789/// explicit `null` from absent (e.g. `viewport: null` to disable
1790/// viewport emulation vs. omitted).
1791///
1792/// Construction: use [`BrowserContextOptions::default`] and set fields
1793/// field-by-field, or use any of the dedicated builder helpers defined
1794/// inline.
1795#[derive(Debug, Clone, Default)]
1796pub struct BrowserContextOptions {
1797  pub accept_downloads: Option<bool>,
1798  pub base_url: Option<String>,
1799  pub bypass_csp: Option<bool>,
1800  /// `null` → disable media emulation; `Some(value)` → apply; absent →
1801  /// leave backend default. Use [`MediaOverride`] for the null/value
1802  /// distinction.
1803  pub color_scheme: MediaOverride,
1804  pub contrast: MediaOverride,
1805  pub device_scale_factor: Option<f64>,
1806  pub extra_http_headers: Option<rustc_hash::FxHashMap<String, String>>,
1807  pub forced_colors: MediaOverride,
1808  pub geolocation: Option<Geolocation>,
1809  pub has_touch: Option<bool>,
1810  pub http_credentials: Option<HttpCredentials>,
1811  pub ignore_https_errors: Option<bool>,
1812  pub is_mobile: Option<bool>,
1813  pub java_script_enabled: Option<bool>,
1814  pub locale: Option<String>,
1815  pub offline: Option<bool>,
1816  pub permissions: Option<Vec<String>>,
1817  pub proxy: Option<ProxyConfig>,
1818  pub record_har: Option<RecordHarOptions>,
1819  pub record_video: Option<RecordVideoOptions>,
1820  pub reduced_motion: MediaOverride,
1821  pub screen: Option<ScreenSize>,
1822  pub service_workers: Option<ServiceWorkerPolicy>,
1823  pub storage_state: Option<StorageStateInput>,
1824  pub strict_selectors: Option<bool>,
1825  pub timezone_id: Option<String>,
1826  pub user_agent: Option<String>,
1827  pub viewport: ViewportOption,
1828}
1829
1830impl BrowserContextOptions {
1831  /// Resolve [`Self::viewport`] to the [`ViewportConfig`] a freshly
1832  /// opened page should be emulated with. Returns `None` when the
1833  /// caller passed `viewport: null` — the page inherits the backend's
1834  /// native window size. `ViewportOption::Default` folds in
1835  /// `device_scale_factor`, `is_mobile`, and `has_touch` into the
1836  /// default 1280x720; explicit `Size(w,h)` likewise.
1837  #[must_use]
1838  pub fn resolved_viewport(&self) -> Option<ViewportConfig> {
1839    let (width, height) = match self.viewport {
1840      ViewportOption::Null => return None,
1841      ViewportOption::Default => (1280, 720),
1842      ViewportOption::Size { width, height } => (width, height),
1843    };
1844    Some(ViewportConfig {
1845      width,
1846      height,
1847      device_scale_factor: self.device_scale_factor.unwrap_or(1.0),
1848      is_mobile: self.is_mobile.unwrap_or(false),
1849      has_touch: self.has_touch.unwrap_or(false),
1850      is_landscape: false,
1851    })
1852  }
1853
1854  /// `true` iff any emulated-media field needs to be applied.
1855  #[must_use]
1856  pub fn any_media_override(&self) -> bool {
1857    self.color_scheme.is_specified()
1858      || self.reduced_motion.is_specified()
1859      || self.forced_colors.is_specified()
1860      || self.contrast.is_specified()
1861  }
1862
1863  /// Collect the media fields into an [`EmulateMediaOptions`] bag for
1864  /// `page.emulate_media`.
1865  #[must_use]
1866  pub fn as_emulate_media(&self) -> EmulateMediaOptions {
1867    EmulateMediaOptions {
1868      media: MediaOverride::Unchanged,
1869      color_scheme: self.color_scheme.clone(),
1870      reduced_motion: self.reduced_motion.clone(),
1871      forced_colors: self.forced_colors.clone(),
1872      contrast: self.contrast.clone(),
1873    }
1874  }
1875}
1876
1877/// Selector for [`crate::Page::frame`]. The `page.frame(frameSelector)`
1878/// union type `string | { name?: string; url?:
1879/// string|RegExp|URLPattern|(url => bool) }`.
1880///
1881/// Today we accept the string form + `{ name, url }` with both fields
1882/// being plain strings (exact match). Future work extends `url` to the
1883/// full `StringOrRegex` matcher; matching rules will remain behind this
1884/// struct so callers don't rebind.
1885#[derive(Debug, Clone, Default, PartialEq, Eq)]
1886pub struct FrameSelector {
1887  /// Match against the frame's `name` attribute (exact).
1888  pub name: Option<String>,
1889  /// Match against the frame's URL (exact).
1890  pub url: Option<String>,
1891}
1892
1893impl FrameSelector {
1894  /// Convenience: selector that matches by frame name.
1895  #[must_use]
1896  pub fn by_name(name: impl Into<String>) -> Self {
1897    Self {
1898      name: Some(name.into()),
1899      url: None,
1900    }
1901  }
1902
1903  /// Convenience: selector that matches by frame URL.
1904  #[must_use]
1905  pub fn by_url(url: impl Into<String>) -> Self {
1906    Self {
1907      name: None,
1908      url: Some(url.into()),
1909    }
1910  }
1911
1912  /// Returns `true` when neither `name` nor `url` is set.
1913  #[must_use]
1914  pub fn is_empty(&self) -> bool {
1915    self.name.is_none() && self.url.is_none()
1916  }
1917}
1918
1919impl From<&str> for FrameSelector {
1920  fn from(name: &str) -> Self {
1921    Self::by_name(name)
1922  }
1923}
1924
1925impl From<String> for FrameSelector {
1926  fn from(name: String) -> Self {
1927    Self::by_name(name)
1928  }
1929}
1930
1931impl From<&String> for FrameSelector {
1932  fn from(name: &String) -> Self {
1933    Self::by_name(name.clone())
1934  }
1935}
1936
1937/// The `style` argument of `Locator.highlight`. Playwright:
1938/// `highlight(options: { style?: string | Record<string, string | number> })`
1939/// (`/tmp/playwright/packages/playwright-core/src/client/locator.ts:158`).
1940/// A `Css` string is forwarded verbatim; an `Object` map is collapsed to
1941/// a CSS declaration string the same way Playwright's `cssObjectToString`
1942/// does (`camelCase` -> `kebab-case`, `--custom-props` preserved,
1943/// `"k: v"` joined with `"; "`).
1944#[derive(Debug, Clone)]
1945pub enum HighlightStyle {
1946  Css(String),
1947  Object(Vec<(String, String)>),
1948}
1949
1950impl HighlightStyle {
1951  /// Resolve to the CSS string the injected highlight overlay applies.
1952  /// Mirrors Playwright's `cssObjectToString`
1953  /// (`/tmp/playwright/packages/playwright-core/src/client/locator.ts:486`).
1954  #[must_use]
1955  pub fn to_css_string(&self) -> String {
1956    match self {
1957      Self::Css(s) => s.clone(),
1958      Self::Object(entries) => entries
1959        .iter()
1960        .map(|(key, value)| {
1961          let property = if key.starts_with("--") {
1962            key.clone()
1963          } else {
1964            let mut out = String::with_capacity(key.len() + 4);
1965            for ch in key.chars() {
1966              if ch.is_ascii_uppercase() {
1967                out.push('-');
1968                out.push(ch.to_ascii_lowercase());
1969              } else {
1970                out.push(ch);
1971              }
1972            }
1973            out
1974          };
1975          format!("{property}: {value}")
1976        })
1977        .collect::<Vec<_>>()
1978        .join("; "),
1979    }
1980  }
1981}
1982
1983impl From<&str> for HighlightStyle {
1984  fn from(s: &str) -> Self {
1985    Self::Css(s.to_string())
1986  }
1987}
1988
1989impl From<String> for HighlightStyle {
1990  fn from(s: String) -> Self {
1991    Self::Css(s)
1992  }
1993}
1994
1995#[cfg(test)]
1996mod highlight_style_tests {
1997  use super::*;
1998
1999  #[test]
2000  fn css_string_passes_through_verbatim() {
2001    let s = HighlightStyle::Css("outline: 2px solid red".to_string());
2002    assert_eq!(s.to_css_string(), "outline: 2px solid red");
2003  }
2004
2005  #[test]
2006  fn object_camel_case_becomes_kebab_case() {
2007    let s = HighlightStyle::Object(vec![("backgroundColor".to_string(), "red".to_string())]);
2008    assert_eq!(s.to_css_string(), "background-color: red");
2009  }
2010
2011  #[test]
2012  fn object_custom_property_preserved() {
2013    let s = HighlightStyle::Object(vec![("--my-var".to_string(), "10".to_string())]);
2014    assert_eq!(s.to_css_string(), "--my-var: 10");
2015  }
2016
2017  #[test]
2018  fn object_multiple_entries_joined_with_semicolon() {
2019    let s = HighlightStyle::Object(vec![
2020      ("border".to_string(), "1px".to_string()),
2021      ("zIndex".to_string(), "5".to_string()),
2022    ]);
2023    assert_eq!(s.to_css_string(), "border: 1px; z-index: 5");
2024  }
2025
2026  #[test]
2027  fn from_str_is_css_variant() {
2028    assert!(matches!(HighlightStyle::from("a: b"), HighlightStyle::Css(_)));
2029  }
2030}
2031
2032#[cfg(test)]
2033mod pdf_option_tests {
2034  use super::*;
2035
2036  // ── PdfSize parsing ──────────────────────────────────────────────────
2037
2038  #[test]
2039  fn parses_pixel_suffix() {
2040    assert_eq!(PdfSize::parse("100px").unwrap(), PdfSize::Pixels(100.0));
2041  }
2042
2043  #[test]
2044  fn parses_inch_suffix() {
2045    assert_eq!(PdfSize::parse("8.5in").unwrap(), PdfSize::Inches(8.5));
2046  }
2047
2048  #[test]
2049  fn parses_cm_and_mm_suffixes() {
2050    assert_eq!(PdfSize::parse("10cm").unwrap(), PdfSize::Centimeters(10.0));
2051    assert_eq!(PdfSize::parse("5.5mm").unwrap(), PdfSize::Millimeters(5.5));
2052  }
2053
2054  #[test]
2055  fn parses_suffix_case_insensitively() {
2056    assert_eq!(PdfSize::parse("8.5IN").unwrap(), PdfSize::Inches(8.5));
2057    assert_eq!(PdfSize::parse("100Px").unwrap(), PdfSize::Pixels(100.0));
2058  }
2059
2060  #[test]
2061  fn bare_number_falls_back_to_pixels() {
2062    // parity: Phantom-compatible fallback to px if no known unit.
2063    assert_eq!(PdfSize::parse("42").unwrap(), PdfSize::Pixels(42.0));
2064  }
2065
2066  #[test]
2067  fn unknown_suffix_falls_back_to_pixels() {
2068    // `em` is not in the table — treats the whole string as px.
2069    // The numeric value here is "42" (with "em" treated as suffix but then
2070    // falling through to the default "px" branch). The parser slices the
2071    // last 2 chars, sees "em" (unknown), then parses the WHOLE original
2072    // string as a number. "42em" isn't a number → error. Unknown suffix
2073    // + non-numeric body ⇒ InvalidArgument.
2074    assert!(PdfSize::parse("42em").is_err());
2075  }
2076
2077  #[test]
2078  fn invalid_number_is_rejected() {
2079    assert!(PdfSize::parse("abcpx").is_err());
2080  }
2081
2082  #[test]
2083  fn short_input_takes_pixel_fallback() {
2084    // "5" is shorter than 2 chars, so the parser skips suffix detection
2085    // and uses the bare-pixels path.
2086    assert_eq!(PdfSize::parse("5").unwrap(), PdfSize::Pixels(5.0));
2087  }
2088
2089  // ── PdfSize::to_inches conversion constants ──────────────────────────
2090
2091  #[test]
2092  fn pixels_convert_using_96_dpi() {
2093    assert!((PdfSize::Pixels(96.0).to_inches() - 1.0).abs() < 1e-9);
2094  }
2095
2096  #[test]
2097  fn inches_are_identity() {
2098    assert!((PdfSize::Inches(2.5).to_inches() - 2.5).abs() < 1e-9);
2099  }
2100
2101  #[test]
2102  fn centimeters_convert_using_table_constants() {
2103    // 37.8 / 96 (exact constant).
2104    let expected = 10.0 * 37.8 / 96.0;
2105    assert!((PdfSize::Centimeters(10.0).to_inches() - expected).abs() < 1e-9);
2106  }
2107
2108  #[test]
2109  fn millimeters_convert_using_table_constants() {
2110    let expected = 25.0 * 3.78 / 96.0;
2111    assert!((PdfSize::Millimeters(25.0).to_inches() - expected).abs() < 1e-9);
2112  }
2113
2114  // ── Paper format lookup ──────────────────────────────────────────────
2115
2116  #[test]
2117  fn paper_formats_return_canonical_sizes() {
2118    assert_eq!(pdf_paper_format_size("Letter"), Some((8.5, 11.0)));
2119    assert_eq!(pdf_paper_format_size("A4"), Some((8.27, 11.7)));
2120    assert_eq!(pdf_paper_format_size("tabloid"), Some((11.0, 17.0)));
2121    assert_eq!(pdf_paper_format_size("LEDGER"), Some((17.0, 11.0)));
2122  }
2123
2124  #[test]
2125  fn unknown_paper_format_returns_none() {
2126    assert_eq!(pdf_paper_format_size("A99"), None);
2127    assert_eq!(pdf_paper_format_size(""), None);
2128  }
2129
2130  // ── PdfOptions default is fully empty (CDP-defaults-apply) ───────────
2131
2132  #[test]
2133  fn default_pdf_options_has_no_overrides() {
2134    let opts = PdfOptions::default();
2135    assert!(opts.format.is_none());
2136    assert!(opts.path.is_none());
2137    assert!(opts.scale.is_none());
2138    assert!(opts.display_header_footer.is_none());
2139    assert!(opts.header_template.is_none());
2140    assert!(opts.footer_template.is_none());
2141    assert!(opts.print_background.is_none());
2142    assert!(opts.landscape.is_none());
2143    assert!(opts.page_ranges.is_none());
2144    assert!(opts.width.is_none());
2145    assert!(opts.height.is_none());
2146    assert!(opts.margin.is_none());
2147    assert!(opts.prefer_css_page_size.is_none());
2148    assert!(opts.outline.is_none());
2149    assert!(opts.tagged.is_none());
2150  }
2151}
2152
2153#[cfg(test)]
2154mod media_override_tests {
2155  use super::*;
2156
2157  #[test]
2158  fn default_is_unchanged() {
2159    let o: MediaOverride = MediaOverride::default();
2160    assert_eq!(o, MediaOverride::Unchanged);
2161    assert!(!o.is_specified());
2162    assert_eq!(o.as_value(), None);
2163  }
2164
2165  #[test]
2166  fn set_reports_value() {
2167    let o = MediaOverride::Set("dark".into());
2168    assert!(o.is_specified());
2169    assert_eq!(o.as_value(), Some("dark"));
2170  }
2171
2172  #[test]
2173  fn disabled_is_specified_without_value() {
2174    let o = MediaOverride::Disabled;
2175    assert!(o.is_specified());
2176    assert_eq!(o.as_value(), None);
2177  }
2178
2179  #[test]
2180  fn from_option_string_maps_some_to_set_and_none_to_unchanged() {
2181    let set: MediaOverride = Some("dark".to_string()).into();
2182    assert_eq!(set, MediaOverride::Set("dark".into()));
2183    let unch: MediaOverride = None.into();
2184    assert_eq!(unch, MediaOverride::Unchanged);
2185  }
2186
2187  #[test]
2188  fn default_emulate_media_is_all_unchanged() {
2189    let o = EmulateMediaOptions::default();
2190    assert_eq!(o.media, MediaOverride::Unchanged);
2191    assert_eq!(o.color_scheme, MediaOverride::Unchanged);
2192    assert_eq!(o.reduced_motion, MediaOverride::Unchanged);
2193    assert_eq!(o.forced_colors, MediaOverride::Unchanged);
2194    assert_eq!(o.contrast, MediaOverride::Unchanged);
2195  }
2196}
2197
2198#[cfg(test)]
2199mod drag_option_tests {
2200  use super::*;
2201
2202  #[test]
2203  fn default_drag_options_has_no_overrides() {
2204    let opts = DragAndDropOptions::default();
2205    assert!(opts.force.is_none());
2206    assert!(opts.no_wait_after.is_none());
2207    assert!(opts.source_position.is_none());
2208    assert!(opts.target_position.is_none());
2209    assert!(opts.steps.is_none());
2210    assert!(opts.strict.is_none());
2211    assert!(opts.timeout.is_none());
2212    assert!(opts.trial.is_none());
2213  }
2214
2215  #[test]
2216  fn drag_options_carry_every_field() {
2217    let opts = DragAndDropOptions {
2218      force: Some(true),
2219      no_wait_after: Some(false),
2220      source_position: Some(Point { x: 10.0, y: 20.0 }),
2221      target_position: Some(Point { x: 30.0, y: 40.0 }),
2222      steps: Some(5),
2223      strict: Some(true),
2224      timeout: Some(2_000),
2225      trial: Some(true),
2226    };
2227    assert_eq!(opts.force, Some(true));
2228    assert_eq!(opts.no_wait_after, Some(false));
2229    assert_eq!(opts.source_position, Some(Point { x: 10.0, y: 20.0 }));
2230    assert_eq!(opts.target_position, Some(Point { x: 30.0, y: 40.0 }));
2231    assert_eq!(opts.steps, Some(5));
2232    assert_eq!(opts.strict, Some(true));
2233    assert_eq!(opts.timeout, Some(2_000));
2234    assert_eq!(opts.trial, Some(true));
2235  }
2236
2237  #[test]
2238  fn point_default_is_origin() {
2239    assert_eq!(Point::default(), Point { x: 0.0, y: 0.0 });
2240  }
2241
2242  #[test]
2243  fn default_drop_options_and_payload_are_empty() {
2244    let opts = DropOptions::default();
2245    assert!(opts.modifiers.is_empty());
2246    assert!(opts.position.is_none());
2247    assert!(opts.timeout.is_none());
2248
2249    let payload = DropPayload::default();
2250    assert!(payload.files.is_none());
2251    assert!(payload.data.is_empty());
2252  }
2253
2254  #[test]
2255  fn drop_payload_carries_files_and_data() {
2256    let payload = DropPayload {
2257      files: Some(InputFiles::Payloads(vec![FilePayload {
2258        name: "card.png".into(),
2259        mime_type: "image/png".into(),
2260        buffer: vec![1, 2, 3],
2261      }])),
2262      data: vec![("text/plain".into(), "dropped".into())],
2263    };
2264    match payload.files {
2265      Some(InputFiles::Payloads(p)) => {
2266        assert_eq!(p.len(), 1);
2267        assert_eq!(p[0].name, "card.png");
2268        assert_eq!(p[0].mime_type, "image/png");
2269        assert_eq!(p[0].buffer, vec![1, 2, 3]);
2270      },
2271      _ => panic!("expected payloads"),
2272    }
2273    assert_eq!(payload.data, vec![("text/plain".to_string(), "dropped".to_string())]);
2274  }
2275
2276  #[test]
2277  fn drop_options_carry_modifiers_position_timeout() {
2278    let opts = DropOptions {
2279      modifiers: vec![Modifier::Shift, Modifier::ControlOrMeta],
2280      position: Some(Point { x: 5.0, y: 7.0 }),
2281      timeout: Some(1_500),
2282    };
2283    assert_eq!(opts.modifiers, vec![Modifier::Shift, Modifier::ControlOrMeta]);
2284    assert_eq!(opts.position, Some(Point { x: 5.0, y: 7.0 }));
2285    assert_eq!(opts.timeout, Some(1_500));
2286  }
2287}
2288
2289#[cfg(test)]
2290mod init_script_tests {
2291  use super::*;
2292  use serde_json::json;
2293
2294  #[test]
2295  fn function_with_undefined_arg_renders_literal_undefined() {
2296    // `Object.is(arg, undefined) ? 'undefined' : JSON.stringify(arg)`.
2297    let src = evaluation_script(
2298      InitScriptSource::Function {
2299        body: "(x) => x + 1".to_string(),
2300      },
2301      None,
2302    )
2303    .unwrap();
2304    assert_eq!(src, "((x) => x + 1)(undefined)");
2305  }
2306
2307  #[test]
2308  fn function_with_null_arg_renders_literal_null() {
2309    // `Object.is(null, undefined)` is false — null goes through JSON.stringify.
2310    let src = evaluation_script(
2311      InitScriptSource::Function {
2312        body: "(x) => x".to_string(),
2313      },
2314      Some(&serde_json::Value::Null),
2315    )
2316    .unwrap();
2317    assert_eq!(src, "((x) => x)(null)");
2318  }
2319
2320  #[test]
2321  fn function_with_object_arg_renders_json() {
2322    let arg = json!({ "answer": 42, "nested": [1, 2, 3] });
2323    let src = evaluation_script(
2324      InitScriptSource::Function {
2325        body: "function (o) { window.x = o; }".to_string(),
2326      },
2327      Some(&arg),
2328    )
2329    .unwrap();
2330    assert_eq!(
2331      src,
2332      r#"(function (o) { window.x = o; })({"answer":42,"nested":[1,2,3]})"#
2333    );
2334  }
2335
2336  #[test]
2337  fn source_without_arg_passes_through_verbatim() {
2338    let src = evaluation_script(InitScriptSource::Source("window.x = 1".into()), None).unwrap();
2339    assert_eq!(src, "window.x = 1");
2340  }
2341
2342  #[test]
2343  fn source_with_arg_errors() {
2344    let err = evaluation_script(InitScriptSource::Source("window.x = 1".into()), Some(&json!(42))).unwrap_err();
2345    assert!(
2346      err.to_string().contains("Cannot evaluate a string with arguments"),
2347      "unexpected error: {err}"
2348    );
2349  }
2350
2351  #[test]
2352  fn content_with_arg_errors() {
2353    let err = evaluation_script(InitScriptSource::Content("1".into()), Some(&json!(0))).unwrap_err();
2354    assert!(err.to_string().contains("Cannot evaluate a string with arguments"));
2355  }
2356
2357  #[test]
2358  fn path_with_arg_errors() {
2359    let err = evaluation_script(
2360      InitScriptSource::Path(std::path::PathBuf::from("/nope")),
2361      Some(&json!(0)),
2362    )
2363    .unwrap_err();
2364    assert!(err.to_string().contains("Cannot evaluate a string with arguments"));
2365  }
2366
2367  #[test]
2368  fn path_reads_file_and_appends_source_url() {
2369    let dir = std::env::temp_dir();
2370    let path = dir.join(format!("fd-init-script-{}.js", std::process::id()));
2371    std::fs::write(&path, "window.__fromFile = 7;").unwrap();
2372    let src = evaluation_script(InitScriptSource::Path(path.clone()), None).unwrap();
2373    let expected = format!("window.__fromFile = 7;\n//# sourceURL={}", path.display());
2374    assert_eq!(src, expected);
2375    let _ = std::fs::remove_file(path);
2376  }
2377
2378  #[test]
2379  fn path_missing_errors() {
2380    let missing = std::path::PathBuf::from("/definitely/not/a/real/path/x.js");
2381    let err = evaluation_script(InitScriptSource::Path(missing), None).unwrap_err();
2382    // Surfaces as Io(_) via FerriError::From<io::Error>.
2383    assert!(matches!(err, crate::error::FerriError::Io(_)), "unexpected: {err}");
2384  }
2385
2386  #[test]
2387  fn content_passes_through_verbatim() {
2388    let src = evaluation_script(InitScriptSource::Content("let z = 2;".into()), None).unwrap();
2389    assert_eq!(src, "let z = 2;");
2390  }
2391}
2392
2393#[cfg(test)]
2394mod click_option_tests {
2395  use super::*;
2396
2397  #[test]
2398  fn mouse_button_parse_round_trip() {
2399    assert_eq!(MouseButton::parse("left"), Some(MouseButton::Left));
2400    assert_eq!(MouseButton::parse("right"), Some(MouseButton::Right));
2401    assert_eq!(MouseButton::parse("middle"), Some(MouseButton::Middle));
2402    assert_eq!(MouseButton::parse("garbage"), None);
2403    assert_eq!(MouseButton::Left.as_cdp(), "left");
2404    assert_eq!(MouseButton::Right.as_cdp(), "right");
2405    assert_eq!(MouseButton::Middle.as_cdp(), "middle");
2406    assert_eq!(MouseButton::Left.as_bidi(), 0);
2407    assert_eq!(MouseButton::Middle.as_bidi(), 1);
2408    assert_eq!(MouseButton::Right.as_bidi(), 2);
2409    // Legacy native-WebKit ordering differs from CDP/BiDi: 0=left,
2410    // 1=right, 2=middle.
2411    assert_eq!(MouseButton::Left.as_webkit(), 0);
2412    assert_eq!(MouseButton::Right.as_webkit(), 1);
2413    assert_eq!(MouseButton::Middle.as_webkit(), 2);
2414  }
2415
2416  #[test]
2417  fn modifier_parse_and_bits() {
2418    assert_eq!(Modifier::parse("Alt"), Some(Modifier::Alt));
2419    assert_eq!(Modifier::parse("Control"), Some(Modifier::Control));
2420    assert_eq!(Modifier::parse("Meta"), Some(Modifier::Meta));
2421    assert_eq!(Modifier::parse("Shift"), Some(Modifier::Shift));
2422    assert_eq!(Modifier::parse("ControlOrMeta"), Some(Modifier::ControlOrMeta));
2423    assert_eq!(Modifier::parse("garbage"), None);
2424
2425    assert_eq!(Modifier::Alt.cdp_bit(), 1);
2426    assert_eq!(Modifier::Control.cdp_bit(), 2);
2427    assert_eq!(Modifier::Meta.cdp_bit(), 4);
2428    assert_eq!(Modifier::Shift.cdp_bit(), 8);
2429
2430    // Platform-aware ControlOrMeta
2431    if cfg!(target_os = "macos") {
2432      assert_eq!(Modifier::ControlOrMeta.cdp_bit(), 4);
2433      assert_eq!(Modifier::ControlOrMeta.key_name(), "Meta");
2434      assert_eq!(Modifier::ControlOrMeta.key_code(), "MetaLeft");
2435    } else {
2436      assert_eq!(Modifier::ControlOrMeta.cdp_bit(), 2);
2437      assert_eq!(Modifier::ControlOrMeta.key_name(), "Control");
2438      assert_eq!(Modifier::ControlOrMeta.key_code(), "ControlLeft");
2439    }
2440  }
2441
2442  #[test]
2443  fn modifiers_bitmask_folds_multiple() {
2444    assert_eq!(modifiers_bitmask(&[]), 0);
2445    assert_eq!(modifiers_bitmask(&[Modifier::Shift]), 8);
2446    // Alt|Control|Meta|Shift = 1|2|4|8 = 15
2447    assert_eq!(
2448      modifiers_bitmask(&[Modifier::Alt, Modifier::Control, Modifier::Meta, Modifier::Shift]),
2449      15
2450    );
2451    // Dedup via bitwise OR — duplicates don't double-count.
2452    assert_eq!(modifiers_bitmask(&[Modifier::Shift, Modifier::Shift]), 8);
2453  }
2454
2455  #[test]
2456  fn click_options_default_values() {
2457    let opts = ClickOptions::default();
2458    assert_eq!(opts.resolved_button(), MouseButton::Left);
2459    assert_eq!(opts.resolved_click_count(), 1);
2460    assert_eq!(opts.resolved_delay_ms(), 0);
2461    assert_eq!(opts.resolved_steps(), 1);
2462    assert!(!opts.is_force());
2463    assert!(!opts.is_trial());
2464    assert!(opts.modifiers.is_empty());
2465    assert!(opts.position.is_none());
2466    assert!(opts.timeout.is_none());
2467    assert!(opts.no_wait_after.is_none());
2468  }
2469
2470  #[test]
2471  fn click_options_resolved_helpers_use_overrides() {
2472    let opts = ClickOptions {
2473      button: Some(MouseButton::Right),
2474      click_count: Some(2),
2475      delay: Some(150),
2476      steps: Some(5),
2477      force: Some(true),
2478      trial: Some(true),
2479      ..Default::default()
2480    };
2481    assert_eq!(opts.resolved_button(), MouseButton::Right);
2482    assert_eq!(opts.resolved_click_count(), 2);
2483    assert_eq!(opts.resolved_delay_ms(), 150);
2484    assert_eq!(opts.resolved_steps(), 5);
2485    assert!(opts.is_force());
2486    assert!(opts.is_trial());
2487  }
2488
2489  #[test]
2490  fn click_options_steps_coerces_zero_to_one() {
2491    // defaults to 1 and uses `Math.max(1, steps)`; mirror
2492    // the clamp so callers passing `0` still emit one mousemove.
2493    let opts = ClickOptions {
2494      steps: Some(0),
2495      ..Default::default()
2496    };
2497    assert_eq!(opts.resolved_steps(), 1);
2498  }
2499}
2500
2501#[cfg(test)]
2502mod storage_state_tests {
2503  use super::*;
2504
2505  /// The exported `StorageState` must serialize to Playwright's exact wire
2506  /// shape: `{ cookies: [...], origins: [{ origin, localStorage: [{name,
2507  /// value}] }] }` (client/types.ts:42, protocol channels.d.ts:5158/5399).
2508  #[test]
2509  fn storage_state_serializes_to_playwright_shape() {
2510    let state = StorageState {
2511      cookies: vec![crate::backend::CookieData {
2512        name: "sid".into(),
2513        value: "abc".into(),
2514        domain: "example.com".into(),
2515        path: "/".into(),
2516        secure: false,
2517        http_only: false,
2518        expires: None,
2519        same_site: None,
2520        url: None,
2521      }],
2522      origins: vec![OriginState {
2523        origin: "https://example.com".into(),
2524        local_storage: vec![NameValue {
2525          name: "token".into(),
2526          value: "t1".into(),
2527        }],
2528      }],
2529    };
2530    let v = serde_json::to_value(&state).unwrap();
2531    assert_eq!(v["cookies"][0]["name"], "sid");
2532    assert_eq!(v["cookies"][0]["value"], "abc");
2533    // camelCase on the wire — `localStorage`, not `local_storage`.
2534    assert_eq!(v["origins"][0]["origin"], "https://example.com");
2535    assert_eq!(v["origins"][0]["localStorage"][0]["name"], "token");
2536    assert_eq!(v["origins"][0]["localStorage"][0]["value"], "t1");
2537    assert!(v["origins"][0].get("local_storage").is_none());
2538  }
2539
2540  /// A round-trip through the EXPORT shape must be re-consumable as
2541  /// [`StorageStateInput::Inline`] (same JSON), so saved auth state hydrates.
2542  #[test]
2543  fn exported_state_is_valid_storage_state_input() {
2544    let json = serde_json::json!({
2545      "cookies": [],
2546      "origins": [{ "origin": "https://e.com", "localStorage": [{ "name": "k", "value": "v" }] }]
2547    });
2548    let parsed: StorageState = serde_json::from_value(json.clone()).unwrap();
2549    assert_eq!(parsed.origins[0].origin, "https://e.com");
2550    assert_eq!(parsed.origins[0].local_storage[0].name, "k");
2551    // The same value is accepted as inline hydration input.
2552    assert!(matches!(StorageStateInput::Inline(json), StorageStateInput::Inline(_)));
2553  }
2554}