1use serde::{Deserialize, Serialize};
12use std::collections::BTreeMap;
13use std::path::PathBuf;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
19#[serde(rename_all = "kebab-case")]
20pub enum TraceMode {
21 #[default]
22 Off,
23 On,
24 RetainOnFailure,
25 OnFirstRetry,
26}
27
28impl TraceMode {
29 #[must_use]
31 pub fn parse_label(s: &str) -> Self {
32 match s {
33 "on" => Self::On,
34 "retain-on-failure" => Self::RetainOnFailure,
35 "on-first-retry" => Self::OnFirstRetry,
36 _ => Self::Off,
37 }
38 }
39
40 #[must_use]
42 pub fn should_record(self, attempt: u32, _failed: bool) -> bool {
43 match self {
44 Self::Off => false,
45 Self::On | Self::RetainOnFailure => true,
46 Self::OnFirstRetry => attempt == 2,
47 }
48 }
49
50 #[must_use]
52 pub fn should_retain(self, failed: bool) -> bool {
53 match self {
54 Self::Off => false,
55 Self::On | Self::OnFirstRetry => true,
56 Self::RetainOnFailure => failed,
57 }
58 }
59
60 #[must_use]
62 pub fn should_write(self, attempt: u32, failed: bool) -> bool {
63 self.should_record(attempt, failed) && self.should_retain(failed)
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
71#[serde(rename_all = "kebab-case")]
72pub enum VideoMode {
73 #[default]
74 Off,
75 On,
76 RetainOnFailure,
77}
78
79impl VideoMode {
80 #[must_use]
82 pub fn parse_label(s: &str) -> Self {
83 match s {
84 "on" => Self::On,
85 "retain-on-failure" => Self::RetainOnFailure,
86 _ => Self::Off,
87 }
88 }
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
93#[serde(default, rename_all = "camelCase")]
94pub struct VideoConfig {
95 pub mode: VideoMode,
96 pub width: u32,
97 pub height: u32,
98}
99
100impl Default for VideoConfig {
101 fn default() -> Self {
102 Self {
103 mode: VideoMode::Off,
104 width: 1280,
105 height: 720,
106 }
107 }
108}
109
110#[allow(clippy::struct_excessive_bools)]
116#[derive(Clone, Serialize, Deserialize)]
117#[serde(default, rename_all = "camelCase")]
118pub struct TestConfig {
119 pub test_match: Vec<String>,
120 pub test_dir: Option<String>,
121 pub test_ignore: Vec<String>,
122 pub timeout: u64,
123 pub expect_timeout: u64,
124 pub workers: u32,
125 pub retries: u32,
126 pub reporter: Vec<ReporterConfig>,
127 pub output_dir: PathBuf,
128 pub browser: BrowserConfig,
129 pub base_url: Option<String>,
130 pub projects: Vec<ProjectConfig>,
131 #[serde(default)]
136 pub max_parallel_projects: u32,
137 pub global_setup: Vec<String>,
138 pub global_teardown: Vec<String>,
139 pub repeat_each: u32,
140 pub forbid_only: bool,
141 pub fully_parallel: bool,
142 pub features: Vec<String>,
143 pub steps: Vec<String>,
146 pub tags: Option<String>,
147 pub dry_run: bool,
148 pub fail_fast: bool,
149 pub screenshot_on_failure: bool,
150 #[serde(default)]
151 pub video: VideoConfig,
152 #[serde(default)]
153 pub trace: TraceMode,
154 #[serde(default)]
155 pub storage_state: Option<String>,
156 #[serde(default)]
157 pub web_server: Vec<WebServerConfig>,
158 pub max_failures: u32,
159 pub global_timeout: u64,
160 pub ignore_snapshots: bool,
161 pub pass_with_no_tests: bool,
162 pub tsconfig: Option<String>,
163 pub name: Option<String>,
164 pub fail_on_flaky_tests: bool,
165 pub capture_git_info: bool,
166 pub snapshot_dir: Option<String>,
167 pub snapshot_path_template: Option<String>,
168 #[serde(default)]
169 pub update_snapshots: UpdateSnapshotsMode,
170 pub preserve_output: String,
171 #[serde(default)]
172 pub report_slow_tests: Option<ReportSlowTestsConfig>,
173 pub quiet: bool,
174 pub config_grep: Option<String>,
175 pub config_grep_invert: Option<String>,
176 #[serde(default)]
177 pub metadata: serde_json::Value,
178 pub strict: bool,
179 pub order: String,
180 pub language: Option<String>,
181 #[serde(default)]
185 pub world_parameters: serde_json::Value,
186 pub profiles: BTreeMap<String, serde_json::Value>,
187 #[serde(default)]
188 pub has_bdd: bool,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
192#[serde(default, rename_all = "camelCase")]
193pub struct BrowserConfig {
194 pub browser: String,
195 pub backend: String,
196 pub channel: Option<String>,
197 pub headless: bool,
198 pub executable_path: Option<String>,
199 pub args: Vec<String>,
200 pub viewport: Option<ViewportConfig>,
201 pub slow_mo: Option<u64>,
202 #[serde(default, rename = "use")]
204 pub use_options: ContextConfig,
205}
206
207#[allow(clippy::struct_excessive_bools)]
210#[derive(Debug, Clone, Serialize, Deserialize)]
211#[serde(default, rename_all = "camelCase")]
212pub struct ContextConfig {
213 pub is_mobile: bool,
214 pub has_touch: bool,
215 pub color_scheme: Option<String>,
216 pub locale: Option<String>,
217 pub device_scale_factor: Option<f64>,
218 pub offline: bool,
219 pub java_script_enabled: bool,
220 pub bypass_csp: bool,
221 pub accept_downloads: bool,
222 pub user_agent: Option<String>,
223 pub timezone_id: Option<String>,
224 pub geolocation: Option<GeolocationConfig>,
225 #[serde(default)]
226 pub permissions: Vec<String>,
227 #[serde(default)]
228 pub extra_http_headers: BTreeMap<String, String>,
229 pub http_credentials: Option<HttpCredentialsConfig>,
230 pub ignore_https_errors: bool,
231 pub proxy: Option<ProxyConfig>,
232 pub service_workers: Option<String>,
233 pub storage_state: Option<String>,
234 pub reduced_motion: Option<String>,
235 pub forced_colors: Option<String>,
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
239#[serde(rename_all = "camelCase")]
240pub struct HttpCredentialsConfig {
241 pub username: String,
242 pub password: String,
243 pub origin: Option<String>,
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
247#[serde(rename_all = "camelCase")]
248pub struct ProxyConfig {
249 pub server: String,
250 pub bypass: Option<String>,
251 pub username: Option<String>,
252 pub password: Option<String>,
253}
254
255impl Default for ContextConfig {
256 fn default() -> Self {
257 Self {
258 is_mobile: false,
259 has_touch: false,
260 color_scheme: None,
261 locale: None,
262 device_scale_factor: None,
263 offline: false,
264 java_script_enabled: true,
265 bypass_csp: false,
266 accept_downloads: true,
267 user_agent: None,
268 timezone_id: None,
269 geolocation: None,
270 permissions: Vec::new(),
271 extra_http_headers: BTreeMap::new(),
272 http_credentials: None,
273 ignore_https_errors: false,
274 proxy: None,
275 service_workers: None,
276 storage_state: None,
277 reduced_motion: None,
278 forced_colors: None,
279 }
280 }
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
284#[serde(rename_all = "camelCase")]
285pub struct GeolocationConfig {
286 pub latitude: f64,
287 pub longitude: f64,
288 pub accuracy: Option<f64>,
289}
290
291impl BrowserConfig {
292 pub fn normalize(&mut self) {
303 match self.backend.as_str() {
304 "bidi" => {
305 self.browser = "firefox".into();
306 },
307 "webkit" => {
308 self.browser = "webkit".into();
309 },
310 _ => match self.browser.as_str() {
311 "firefox" => self.backend = "bidi".into(),
312 #[cfg(target_os = "macos")]
313 "webkit" => self.backend = "webkit".into(),
314 _ => {},
315 },
316 }
317 }
318}
319
320impl Default for BrowserConfig {
321 fn default() -> Self {
322 Self {
323 browser: "chromium".into(),
324 backend: "cdp-pipe".into(),
325 channel: None,
326 headless: false,
331 executable_path: None,
332 args: Vec::new(),
333 viewport: Some(ViewportConfig::default()),
334 slow_mo: None,
335 use_options: ContextConfig::default(),
336 }
337 }
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize)]
341#[serde(rename_all = "camelCase")]
342pub struct ViewportConfig {
343 pub width: i64,
344 pub height: i64,
345}
346
347impl Default for ViewportConfig {
348 fn default() -> Self {
349 Self {
350 width: 1280,
351 height: 720,
352 }
353 }
354}
355
356#[derive(Debug, Clone, Serialize, Deserialize)]
357#[serde(rename_all = "camelCase")]
358pub struct ReporterConfig {
359 pub name: String,
360 #[serde(default)]
361 pub options: BTreeMap<String, serde_json::Value>,
362}
363
364#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
366#[serde(rename_all = "lowercase")]
367pub enum UpdateSnapshotsMode {
368 All,
369 Changed,
370 #[default]
371 Missing,
372 None,
373}
374
375#[derive(Debug, Clone, Serialize, Deserialize)]
377#[serde(default, rename_all = "camelCase")]
378pub struct ReportSlowTestsConfig {
379 pub max: usize,
380 pub threshold: u64,
381}
382
383impl Default for ReportSlowTestsConfig {
384 fn default() -> Self {
385 Self {
386 max: 5,
387 threshold: 15_000,
388 }
389 }
390}
391
392#[derive(Debug, Clone, Serialize, Deserialize)]
394#[serde(default, rename_all = "camelCase")]
395pub struct ProjectConfig {
396 pub name: String,
397 pub test_match: Option<Vec<String>>,
398 pub test_ignore: Option<Vec<String>>,
399 pub test_dir: Option<String>,
400 pub browser: Option<BrowserConfig>,
401 pub output_dir: Option<String>,
402 pub snapshot_dir: Option<String>,
403 pub retries: Option<u32>,
404 pub timeout: Option<u64>,
405 pub repeat_each: Option<u32>,
406 pub fully_parallel: Option<bool>,
407 pub grep: Option<String>,
408 pub grep_invert: Option<String>,
409 pub dependencies: Vec<String>,
410 pub teardown: Option<String>,
411 #[serde(default)]
412 pub metadata: serde_json::Value,
413 pub tag: Option<Vec<String>>,
414}
415
416impl Default for ProjectConfig {
417 fn default() -> Self {
418 Self {
419 name: String::new(),
420 test_match: None,
421 test_ignore: None,
422 test_dir: None,
423 browser: None,
424 output_dir: None,
425 snapshot_dir: None,
426 retries: None,
427 timeout: None,
428 repeat_each: None,
429 fully_parallel: None,
430 grep: None,
431 grep_invert: None,
432 dependencies: Vec::new(),
433 teardown: None,
434 metadata: serde_json::Value::Null,
435 tag: None,
436 }
437 }
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize)]
442#[serde(default, rename_all = "camelCase")]
443pub struct WebServerConfig {
444 pub command: Option<String>,
445 pub static_dir: Option<String>,
446 pub url: Option<String>,
447 pub port: u16,
448 pub reuse_existing_server: bool,
449 pub timeout: u64,
450 pub cwd: Option<String>,
451 #[serde(default)]
452 pub env: BTreeMap<String, String>,
453 pub spa: bool,
454 pub stdout: Option<String>,
455 pub stderr: Option<String>,
456 pub ignore_https_errors: bool,
457 pub name: Option<String>,
458 pub graceful_shutdown: Option<GracefulShutdown>,
459}
460
461#[derive(Debug, Clone, Serialize, Deserialize)]
462#[serde(rename_all = "camelCase")]
463pub struct GracefulShutdown {
464 pub signal: String,
465 pub timeout: u64,
466}
467
468impl Default for WebServerConfig {
469 fn default() -> Self {
470 Self {
471 command: None,
472 static_dir: None,
473 url: None,
474 port: 0,
475 reuse_existing_server: false,
476 timeout: 30_000,
477 cwd: None,
478 env: BTreeMap::new(),
479 spa: false,
480 stdout: None,
481 stderr: None,
482 ignore_https_errors: false,
483 name: None,
484 graceful_shutdown: None,
485 }
486 }
487}
488
489#[derive(Debug, Clone)]
490pub struct ShardArg {
491 pub current: u32,
492 pub total: u32,
493}
494
495impl ShardArg {
496 pub fn parse(s: &str) -> ferridriver::error::Result<Self> {
504 use ferridriver::FerriError;
505 let parts: Vec<&str> = s.split('/').collect();
506 if parts.len() != 2 {
507 return Err(FerriError::invalid_argument(
508 "shard",
509 format!("invalid shard format: {s:?} (expected X/N)"),
510 ));
511 }
512 let current: u32 = parts[0]
513 .parse()
514 .map_err(|e| FerriError::invalid_argument("shard", format!("invalid shard current: {e}")))?;
515 let total: u32 = parts[1]
516 .parse()
517 .map_err(|e| FerriError::invalid_argument("shard", format!("invalid shard total: {e}")))?;
518 if current == 0 || current > total {
519 return Err(FerriError::invalid_argument(
520 "shard",
521 format!("shard {current}/{total}: current must be 1..={total}"),
522 ));
523 }
524 Ok(Self { current, total })
525 }
526}
527
528#[allow(clippy::struct_excessive_bools)]
532#[derive(Debug, Clone, Default)]
533pub struct CliOverrides {
534 pub workers: Option<u32>,
535 pub retries: Option<u32>,
536 pub timeout: Option<u64>,
537 pub reporter: Vec<String>,
538 pub grep: Option<String>,
539 pub grep_invert: Option<String>,
540 pub tag: Option<String>,
541 pub headless: bool,
544 pub shard: Option<ShardArg>,
545 pub config_path: Option<String>,
546 pub output_dir: Option<String>,
547 pub test_files: Vec<String>,
548 pub test_match: Option<Vec<String>>,
549 pub list_only: bool,
550 pub update_snapshots: Option<UpdateSnapshotsMode>,
551 pub profile: Option<String>,
552 pub forbid_only: bool,
553 pub last_failed: bool,
554 pub video: Option<String>,
555 pub trace: Option<String>,
556 pub storage_state: Option<String>,
557 pub max_failures: Option<u32>,
558 pub repeat_each: Option<u32>,
559 pub fail_fast: bool,
560 pub global_timeout: Option<u64>,
561 pub ignore_snapshots: bool,
562 pub pass_with_no_tests: bool,
563 pub tsconfig: Option<String>,
564 pub name: Option<String>,
565 pub fully_parallel: Option<bool>,
566 pub project_filter: Vec<String>,
567 pub no_deps: bool,
568 pub teardown: Option<String>,
569 pub only_changed: Option<String>,
570 pub fail_on_flaky_tests: bool,
571 pub browser: Option<String>,
572 pub backend: Option<String>,
573 pub channel: Option<String>,
574 pub executable_path: Option<String>,
575 pub browser_args: Vec<String>,
576 pub base_url: Option<String>,
577 pub viewport_width: Option<i64>,
578 pub viewport_height: Option<i64>,
579 pub is_mobile: Option<bool>,
580 pub has_touch: Option<bool>,
581 pub color_scheme: Option<String>,
582 pub locale: Option<String>,
583 pub offline: Option<bool>,
584 pub bypass_csp: Option<bool>,
585 pub bdd_tags: Option<String>,
586 pub bdd_dry_run: bool,
587 pub bdd_strict: bool,
588 pub bdd_fail_fast: bool,
589 pub bdd_step_timeout: Option<u64>,
590 pub bdd_order: Option<String>,
591 pub bdd_language: Option<String>,
592 pub bdd_steps: Vec<String>,
594 pub extensions: Vec<String>,
598 pub world_parameters: Option<String>,
601}
602
603impl Default for TestConfig {
604 fn default() -> Self {
605 Self {
606 test_match: Vec::new(),
610 test_dir: None,
611 test_ignore: vec!["**/node_modules/**".into(), "**/target/**".into()],
612 timeout: 30_000,
613 expect_timeout: 5_000,
614 workers: 0,
615 retries: 0,
616 reporter: vec![ReporterConfig {
617 name: "terminal".into(),
618 options: BTreeMap::new(),
619 }],
620 output_dir: PathBuf::from("test-results"),
621 browser: BrowserConfig::default(),
622 base_url: None,
623 projects: Vec::new(),
624 max_parallel_projects: 0,
625 global_setup: Vec::new(),
626 global_teardown: Vec::new(),
627 repeat_each: 1,
628 forbid_only: false,
629 fully_parallel: false,
630 features: Vec::new(),
631 steps: Vec::new(),
632 tags: None,
633 dry_run: false,
634 fail_fast: false,
635 screenshot_on_failure: true,
636 video: VideoConfig::default(),
637 trace: TraceMode::Off,
638 storage_state: None,
639 web_server: Vec::new(),
640 max_failures: 0,
641 global_timeout: 0,
642 ignore_snapshots: false,
643 pass_with_no_tests: false,
644 tsconfig: None,
645 name: None,
646 fail_on_flaky_tests: false,
647 capture_git_info: false,
648 report_slow_tests: Some(ReportSlowTestsConfig::default()),
649 snapshot_dir: None,
650 snapshot_path_template: None,
651 update_snapshots: UpdateSnapshotsMode::default(),
652 preserve_output: "always".into(),
653 quiet: false,
654 config_grep: None,
655 config_grep_invert: None,
656 metadata: serde_json::Value::Null,
657 strict: false,
658 order: "defined".into(),
659 language: None,
660 world_parameters: serde_json::Value::Null,
661 profiles: BTreeMap::new(),
662 has_bdd: false,
663 }
664 }
665}
666
667impl std::fmt::Debug for TestConfig {
668 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
669 f.debug_struct("TestConfig")
670 .field("workers", &self.workers)
671 .field("timeout", &self.timeout)
672 .field("retries", &self.retries)
673 .field("browser", &self.browser)
674 .finish_non_exhaustive()
675 }
676}
677
678impl TestConfig {
679 #[must_use]
685 pub fn merge_project(&self, project: &ProjectConfig) -> Self {
686 let mut merged = self.clone();
687
688 if !project.name.is_empty() {
689 if let serde_json::Value::Object(ref mut map) = merged.metadata {
690 map.insert("project".into(), serde_json::Value::String(project.name.clone()));
691 } else {
692 merged.metadata = serde_json::json!({ "project": project.name });
693 }
694 }
695
696 if let Some(ref patterns) = project.test_match {
697 merged.test_match.clone_from(patterns);
698 }
699 if let Some(ref patterns) = project.test_ignore {
700 merged.test_ignore.clone_from(patterns);
701 }
702 if let Some(ref dir) = project.test_dir {
703 merged.test_dir = Some(dir.clone());
704 }
705
706 if let Some(retries) = project.retries {
707 merged.retries = retries;
708 }
709 if let Some(timeout) = project.timeout {
710 merged.timeout = timeout;
711 }
712 if let Some(repeat_each) = project.repeat_each {
713 merged.repeat_each = repeat_each;
714 }
715 if let Some(fully_parallel) = project.fully_parallel {
716 merged.fully_parallel = fully_parallel;
717 }
718
719 if let Some(ref grep) = project.grep {
720 merged.config_grep = Some(grep.clone());
721 }
722 if let Some(ref grep_inv) = project.grep_invert {
723 merged.config_grep_invert = Some(grep_inv.clone());
724 }
725
726 if let Some(ref dir) = project.output_dir {
727 merged.output_dir = PathBuf::from(dir);
728 }
729 if let Some(ref dir) = project.snapshot_dir {
730 merged.snapshot_dir = Some(dir.clone());
731 }
732
733 if let Some(ref pb) = project.browser {
734 if pb.browser != "chromium" || pb.backend != "cdp-pipe" {
735 merged.browser.browser.clone_from(&pb.browser);
736 merged.browser.backend.clone_from(&pb.backend);
737 }
738 if let Some(ref ch) = pb.channel {
739 merged.browser.channel = Some(ch.clone());
740 }
741 if !pb.headless {
742 merged.browser.headless = false;
743 }
744 if let Some(ref ep) = pb.executable_path {
745 merged.browser.executable_path = Some(ep.clone());
746 }
747 if !pb.args.is_empty() {
748 merged.browser.args.clone_from(&pb.args);
749 }
750 if let Some(ref vp) = pb.viewport {
751 merged.browser.viewport = Some(vp.clone());
752 }
753 if let Some(slow_mo) = pb.slow_mo {
754 merged.browser.slow_mo = Some(slow_mo);
755 }
756 merge_context(&mut merged.browser.use_options, &pb.use_options);
757 }
758
759 merged.browser.normalize();
760 merged.projects = Vec::new();
761
762 merged
763 }
764}
765
766fn merge_context(base: &mut ContextConfig, overlay: &ContextConfig) {
768 let defaults = ContextConfig::default();
769
770 if overlay.is_mobile != defaults.is_mobile {
771 base.is_mobile = overlay.is_mobile;
772 }
773 if overlay.has_touch != defaults.has_touch {
774 base.has_touch = overlay.has_touch;
775 }
776 if overlay.color_scheme != defaults.color_scheme {
777 base.color_scheme.clone_from(&overlay.color_scheme);
778 }
779 if overlay.locale != defaults.locale {
780 base.locale.clone_from(&overlay.locale);
781 }
782 if overlay.device_scale_factor != defaults.device_scale_factor {
783 base.device_scale_factor = overlay.device_scale_factor;
784 }
785 if overlay.offline != defaults.offline {
786 base.offline = overlay.offline;
787 }
788 if overlay.java_script_enabled != defaults.java_script_enabled {
789 base.java_script_enabled = overlay.java_script_enabled;
790 }
791 if overlay.bypass_csp != defaults.bypass_csp {
792 base.bypass_csp = overlay.bypass_csp;
793 }
794 if overlay.accept_downloads != defaults.accept_downloads {
795 base.accept_downloads = overlay.accept_downloads;
796 }
797 if overlay.user_agent.is_some() {
798 base.user_agent.clone_from(&overlay.user_agent);
799 }
800 if overlay.timezone_id.is_some() {
801 base.timezone_id.clone_from(&overlay.timezone_id);
802 }
803 if overlay.geolocation.is_some() {
804 base.geolocation.clone_from(&overlay.geolocation);
805 }
806 if !overlay.permissions.is_empty() {
807 base.permissions.clone_from(&overlay.permissions);
808 }
809 if !overlay.extra_http_headers.is_empty() {
810 base.extra_http_headers.clone_from(&overlay.extra_http_headers);
811 }
812 if overlay.http_credentials.is_some() {
813 base.http_credentials.clone_from(&overlay.http_credentials);
814 }
815 if overlay.ignore_https_errors != defaults.ignore_https_errors {
816 base.ignore_https_errors = overlay.ignore_https_errors;
817 }
818 if overlay.proxy.is_some() {
819 base.proxy.clone_from(&overlay.proxy);
820 }
821 if overlay.service_workers.is_some() {
822 base.service_workers.clone_from(&overlay.service_workers);
823 }
824 if overlay.storage_state.is_some() {
825 base.storage_state.clone_from(&overlay.storage_state);
826 }
827 if overlay.reduced_motion.is_some() {
828 base.reduced_motion.clone_from(&overlay.reduced_motion);
829 }
830 if overlay.forced_colors.is_some() {
831 base.forced_colors.clone_from(&overlay.forced_colors);
832 }
833}