1#[derive(Debug, Clone)]
17pub enum StringOrRegex {
18 String(String),
19 Regex { source: String, flags: String },
20}
21
22impl StringOrRegex {
23 #[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 #[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#[derive(Debug, Clone, Default)]
57pub struct RoleOptions {
58 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#[derive(Debug, Clone, Default)]
72pub struct TextOptions {
73 pub exact: Option<bool>,
74}
75
76#[derive(Debug, Clone)]
85pub enum LocatorLike {
86 Locator(crate::locator::Locator),
89 Selector(String),
92}
93
94impl LocatorLike {
95 #[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 #[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#[derive(Debug, Clone)]
149pub enum InitScriptSource {
150 Function { body: String },
155 Source(String),
158 Path(std::path::PathBuf),
162 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
187pub 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#[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 pub visible: Option<bool>,
260}
261
262#[derive(Debug, Clone, Default)]
264pub struct WaitOptions {
265 pub state: Option<String>,
267 pub timeout: Option<u64>,
268}
269
270#[derive(Debug, Clone, Default)]
272pub struct EvaluateOptions {
273 pub timeout: Option<u64>,
274}
275
276#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
282pub enum AriaSnapshotMode {
283 #[default]
285 Default,
286 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 #[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#[derive(Debug, Clone, Default)]
316pub struct AriaSnapshotOptions {
317 pub mode: Option<AriaSnapshotMode>,
318 pub depth: Option<i32>,
320 pub timeout: Option<u64>,
322}
323
324#[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#[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#[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#[derive(Debug, Clone, Copy, Default, PartialEq)]
401pub struct Point {
402 pub x: f64,
403 pub y: f64,
404}
405
406#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
409pub enum MouseButton {
410 #[default]
412 Left,
413 Right,
415 Middle,
417}
418
419impl MouseButton {
420 #[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 #[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 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
475pub enum Modifier {
476 Alt,
477 Control,
478 ControlOrMeta,
479 Meta,
480 Shift,
481}
482
483impl Modifier {
484 #[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 #[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 #[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 #[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#[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#[derive(Debug, Clone, Default)]
577pub struct ClickOptions {
578 pub button: Option<MouseButton>,
580 pub click_count: Option<u32>,
582 pub delay: Option<u64>,
584 pub force: Option<bool>,
587 pub modifiers: Vec<Modifier>,
590 pub no_wait_after: Option<bool>,
593 pub position: Option<Point>,
596 pub steps: Option<u32>,
599 pub timeout: Option<u64>,
602 pub trial: Option<bool>,
605}
606
607impl ClickOptions {
608 #[must_use]
610 pub fn resolved_button(&self) -> MouseButton {
611 self.button.unwrap_or(MouseButton::Left)
612 }
613
614 #[must_use]
616 pub fn resolved_click_count(&self) -> u32 {
617 self.click_count.unwrap_or(1)
618 }
619
620 #[must_use]
622 pub fn resolved_delay_ms(&self) -> u64 {
623 self.delay.unwrap_or(0)
624 }
625
626 #[must_use]
628 pub fn resolved_steps(&self) -> u32 {
629 self.steps.unwrap_or(1).max(1)
630 }
631
632 #[must_use]
634 pub fn is_force(&self) -> bool {
635 self.force.unwrap_or(false)
636 }
637
638 #[must_use]
640 pub fn is_trial(&self) -> bool {
641 self.trial.unwrap_or(false)
642 }
643}
644
645#[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#[derive(Debug, Clone, Default)]
665pub struct PressOptions {
666 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#[derive(Debug, Clone, Default)]
682pub struct TypeOptions {
683 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#[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 #[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#[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 #[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 #[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 #[must_use]
776 pub fn by_index(i: u32) -> Self {
777 Self {
778 index: Some(i),
779 ..Self::default()
780 }
781 }
782}
783
784#[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#[derive(Debug, Clone, Default)]
796pub struct SetInputFilesOptions {
797 pub no_wait_after: Option<bool>,
798 pub timeout: Option<u64>,
799}
800
801#[derive(Debug, Clone)]
805pub struct FilePayload {
806 pub name: String,
807 pub mime_type: String,
808 pub buffer: Vec<u8>,
809}
810
811#[derive(Debug, Clone)]
815pub enum InputFiles {
816 Paths(Vec<std::path::PathBuf>),
818 Payloads(Vec<FilePayload>),
820}
821
822#[derive(Debug, Clone, Default)]
825pub struct DispatchEventOptions {
826 pub timeout: Option<u64>,
827}
828
829#[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 #[must_use]
845 pub fn is_force(&self) -> bool {
846 self.force.unwrap_or(false)
847 }
848
849 #[must_use]
851 pub fn is_trial(&self) -> bool {
852 self.trial.unwrap_or(false)
853 }
854}
855
856#[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 #[must_use]
872 pub fn is_force(&self) -> bool {
873 self.force.unwrap_or(false)
874 }
875
876 #[must_use]
878 pub fn is_trial(&self) -> bool {
879 self.trial.unwrap_or(false)
880 }
881}
882
883#[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 #[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#[derive(Debug, Clone, Default)]
928pub struct DragAndDropOptions {
929 pub force: Option<bool>,
931 pub no_wait_after: Option<bool>,
933 pub source_position: Option<Point>,
936 pub target_position: Option<Point>,
939 pub steps: Option<u32>,
942 pub strict: Option<bool>,
945 pub timeout: Option<u64>,
948 pub trial: Option<bool>,
950}
951
952#[derive(Debug, Clone, Default)]
959pub struct DropOptions {
960 pub modifiers: Vec<Modifier>,
962 pub position: Option<Point>,
965 pub timeout: Option<u64>,
968}
969
970#[derive(Debug, Clone, Default)]
980pub struct DropPayload {
981 pub files: Option<InputFiles>,
984 pub data: Vec<(String, String)>,
986}
987
988#[derive(Debug, Clone, PartialEq)]
991pub struct ViewportConfig {
992 pub width: i64,
994 pub height: i64,
996 pub device_scale_factor: f64,
998 pub is_mobile: bool,
1000 pub has_touch: bool,
1002 pub is_landscape: bool,
1004}
1005
1006#[derive(Debug, Clone, Default, PartialEq, Eq)]
1015pub enum MediaOverride {
1016 #[default]
1018 Unchanged,
1019 Disabled,
1021 Set(String),
1023}
1024
1025impl MediaOverride {
1026 #[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 #[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#[derive(Debug, Clone, Default)]
1053pub struct EmulateMediaOptions {
1054 pub media: MediaOverride,
1056 pub color_scheme: MediaOverride,
1058 pub reduced_motion: MediaOverride,
1060 pub forced_colors: MediaOverride,
1062 pub contrast: MediaOverride,
1064}
1065
1066#[derive(Debug, Clone, PartialEq)]
1073pub enum PdfSize {
1074 Pixels(f64),
1076 Inches(f64),
1078 Centimeters(f64),
1080 Millimeters(f64),
1082}
1083
1084impl PdfSize {
1085 #[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 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#[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#[derive(Debug, Clone, Default)]
1150pub struct PdfOptions {
1151 pub format: Option<String>,
1155 pub path: Option<std::path::PathBuf>,
1157 pub scale: Option<f64>,
1160 pub display_header_footer: Option<bool>,
1162 pub header_template: Option<String>,
1164 pub footer_template: Option<String>,
1166 pub print_background: Option<bool>,
1168 pub landscape: Option<bool>,
1170 pub page_ranges: Option<String>,
1172 pub width: Option<PdfSize>,
1174 pub height: Option<PdfSize>,
1176 pub margin: Option<PdfMargin>,
1178 pub prefer_css_page_size: Option<bool>,
1181 pub outline: Option<bool>,
1183 pub tagged: Option<bool>,
1185}
1186
1187#[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#[derive(Debug, Clone, Default)]
1211pub struct PageCloseOptions {
1212 pub run_before_unload: Option<bool>,
1216 pub reason: Option<String>,
1219}
1220
1221#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1233pub enum UnrouteBehavior {
1234 #[default]
1236 Default,
1237 Wait,
1239 IgnoreErrors,
1241}
1242
1243#[derive(Debug, Clone, Default)]
1246pub struct BrowserCloseOptions {
1247 pub reason: Option<String>,
1250}
1251
1252#[derive(Debug, Clone, Default)]
1254pub struct GotoOptions {
1255 pub wait_until: Option<String>,
1258 pub timeout: Option<u64>,
1260 pub referer: Option<String>,
1264}
1265
1266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1269pub enum BrowserKind {
1270 Chromium,
1272 Firefox,
1274 WebKit,
1276}
1277
1278impl BrowserKind {
1279 #[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 #[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#[derive(Debug, Clone, Default)]
1308pub struct LaunchOptions {
1309 pub headless: Option<bool>,
1311 pub executable_path: Option<String>,
1313 pub args: Vec<String>,
1315 pub channel: Option<String>,
1320 pub env: Option<rustc_hash::FxHashMap<String, String>>,
1322 pub slow_mo: Option<u64>,
1324 pub timeout: Option<u64>,
1327 pub downloads_path: Option<std::path::PathBuf>,
1330 pub ignore_default_args: Option<IgnoreDefaultArgs>,
1334 pub handle_sighup: Option<bool>,
1337 pub handle_sigint: Option<bool>,
1338 pub handle_sigterm: Option<bool>,
1339 pub chromium_sandbox: Option<bool>,
1341 pub firefox_user_prefs: Option<rustc_hash::FxHashMap<String, serde_json::Value>>,
1343 pub proxy: Option<ProxyConfig>,
1345 pub traces_dir: Option<std::path::PathBuf>,
1347}
1348
1349#[derive(Debug, Clone, PartialEq, Eq)]
1351pub enum IgnoreDefaultArgs {
1352 All,
1354 Some(Vec<String>),
1356}
1357
1358#[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#[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#[derive(Debug, Clone, Default)]
1381pub struct LaunchPersistentContextOptions {
1382 pub launch: LaunchOptions,
1384 pub context: BrowserContextOptions,
1387}
1388
1389#[derive(Debug, Clone, Default)]
1395pub struct BrowserTypeOptions {
1396 pub transport: Option<ChromiumTransport>,
1398}
1399
1400#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1402pub enum ChromiumTransport {
1403 Pipe,
1405 Ws,
1408}
1409
1410#[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 #[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#[derive(Debug, Clone)]
1533pub struct RecordVideoOptions {
1534 pub dir: std::path::PathBuf,
1537 pub size: Option<VideoSize>,
1543}
1544
1545#[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#[must_use]
1570pub fn construct_url_with_base(base: Option<&str>, given: &str) -> String {
1571 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 let (base_origin, base_path) = split_origin_and_path(base);
1582 if given.starts_with('/') {
1583 return format!("{base_origin}{given}");
1585 }
1586 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 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 match rest.find('/') {
1603 Some(path_start) => (&url[..rest_start + path_start], &rest[path_start..]),
1604 None => (url, "/"),
1605 }
1606}
1607
1608#[derive(Debug, Clone, Copy, PartialEq)]
1613pub struct Geolocation {
1614 pub latitude: f64,
1616 pub longitude: f64,
1618 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
1635pub struct HttpCredentials {
1636 pub username: String,
1637 pub password: String,
1638 pub origin: Option<String>,
1641 pub send: Option<HttpCredentialsSend>,
1644}
1645
1646#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1648pub enum HttpCredentialsSend {
1649 #[default]
1651 Unauthorized,
1652 Always,
1654}
1655
1656#[derive(Debug, Clone, Default, PartialEq, Eq)]
1659pub struct ProxyConfig {
1660 pub server: String,
1661 pub bypass: Option<String>,
1663 pub username: Option<String>,
1664 pub password: Option<String>,
1665}
1666
1667#[derive(Debug, Clone)]
1670pub struct RecordHarOptions {
1671 pub path: std::path::PathBuf,
1672 pub content: Option<RecordHarContent>,
1674 pub mode: Option<RecordHarMode>,
1676 pub omit_content: Option<bool>,
1679 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
1700pub enum ViewportOption {
1701 #[default]
1703 Default,
1704 Null,
1706 Size { width: i64, height: i64 },
1708}
1709
1710#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1713pub struct ScreenSize {
1714 pub width: i64,
1715 pub height: i64,
1716}
1717
1718#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1721pub struct NameValue {
1722 pub name: String,
1723 pub value: String,
1724}
1725
1726#[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#[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#[derive(Debug, Clone, Default)]
1755pub struct StorageStateOptions {
1756 pub path: Option<std::path::PathBuf>,
1758 pub indexed_db: Option<bool>,
1761}
1762
1763#[derive(Debug, Clone)]
1767pub enum StorageStateInput {
1768 Path(std::path::PathBuf),
1770 Inline(serde_json::Value),
1772}
1773
1774#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1777pub enum ServiceWorkerPolicy {
1778 #[default]
1779 Allow,
1780 Block,
1781}
1782
1783#[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 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 #[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 #[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 #[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
1886pub struct FrameSelector {
1887 pub name: Option<String>,
1889 pub url: Option<String>,
1891}
1892
1893impl FrameSelector {
1894 #[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 #[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 #[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#[derive(Debug, Clone)]
1945pub enum HighlightStyle {
1946 Css(String),
1947 Object(Vec<(String, String)>),
1948}
1949
1950impl HighlightStyle {
1951 #[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 #[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 assert_eq!(PdfSize::parse("42").unwrap(), PdfSize::Pixels(42.0));
2064 }
2065
2066 #[test]
2067 fn unknown_suffix_falls_back_to_pixels() {
2068 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 assert_eq!(PdfSize::parse("5").unwrap(), PdfSize::Pixels(5.0));
2087 }
2088
2089 #[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 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 #[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 #[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 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 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 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 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 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 assert_eq!(
2448 modifiers_bitmask(&[Modifier::Alt, Modifier::Control, Modifier::Meta, Modifier::Shift]),
2449 15
2450 );
2451 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 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 #[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 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 #[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 assert!(matches!(StorageStateInput::Inline(json), StorageStateInput::Inline(_)));
2553 }
2554}