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 pub global_setup: Vec<String>,
132 pub global_teardown: Vec<String>,
133 pub repeat_each: u32,
134 pub forbid_only: bool,
135 pub fully_parallel: bool,
136 pub features: Vec<String>,
137 pub steps: Vec<String>,
140 pub tags: Option<String>,
141 pub dry_run: bool,
142 pub fail_fast: bool,
143 pub screenshot_on_failure: bool,
144 #[serde(default)]
145 pub video: VideoConfig,
146 #[serde(default)]
147 pub trace: TraceMode,
148 #[serde(default)]
149 pub storage_state: Option<String>,
150 #[serde(default)]
151 pub web_server: Vec<WebServerConfig>,
152 pub max_failures: u32,
153 pub global_timeout: u64,
154 pub ignore_snapshots: bool,
155 pub pass_with_no_tests: bool,
156 pub tsconfig: Option<String>,
157 pub name: Option<String>,
158 pub fail_on_flaky_tests: bool,
159 pub capture_git_info: bool,
160 pub snapshot_dir: Option<String>,
161 pub snapshot_path_template: Option<String>,
162 #[serde(default)]
163 pub update_snapshots: UpdateSnapshotsMode,
164 pub preserve_output: String,
165 #[serde(default)]
166 pub report_slow_tests: Option<ReportSlowTestsConfig>,
167 pub quiet: bool,
168 pub config_grep: Option<String>,
169 pub config_grep_invert: Option<String>,
170 #[serde(default)]
171 pub metadata: serde_json::Value,
172 pub strict: bool,
173 pub order: String,
174 pub language: Option<String>,
175 #[serde(default)]
179 pub world_parameters: serde_json::Value,
180 pub profiles: BTreeMap<String, serde_json::Value>,
181 #[serde(default)]
182 pub has_bdd: bool,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
186#[serde(default, rename_all = "camelCase")]
187pub struct BrowserConfig {
188 pub browser: String,
189 pub backend: String,
190 pub channel: Option<String>,
191 pub headless: bool,
192 pub executable_path: Option<String>,
193 pub args: Vec<String>,
194 pub viewport: Option<ViewportConfig>,
195 pub slow_mo: Option<u64>,
196 #[serde(default, rename = "use")]
198 pub use_options: ContextConfig,
199}
200
201#[allow(clippy::struct_excessive_bools)]
204#[derive(Debug, Clone, Serialize, Deserialize)]
205#[serde(default, rename_all = "camelCase")]
206pub struct ContextConfig {
207 pub is_mobile: bool,
208 pub has_touch: bool,
209 pub color_scheme: Option<String>,
210 pub locale: Option<String>,
211 pub device_scale_factor: Option<f64>,
212 pub offline: bool,
213 pub java_script_enabled: bool,
214 pub bypass_csp: bool,
215 pub accept_downloads: bool,
216 pub user_agent: Option<String>,
217 pub timezone_id: Option<String>,
218 pub geolocation: Option<GeolocationConfig>,
219 #[serde(default)]
220 pub permissions: Vec<String>,
221 #[serde(default)]
222 pub extra_http_headers: BTreeMap<String, String>,
223 pub http_credentials: Option<HttpCredentialsConfig>,
224 pub ignore_https_errors: bool,
225 pub proxy: Option<ProxyConfig>,
226 pub service_workers: Option<String>,
227 pub storage_state: Option<String>,
228 pub reduced_motion: Option<String>,
229 pub forced_colors: Option<String>,
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize)]
233#[serde(rename_all = "camelCase")]
234pub struct HttpCredentialsConfig {
235 pub username: String,
236 pub password: String,
237 pub origin: Option<String>,
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize)]
241#[serde(rename_all = "camelCase")]
242pub struct ProxyConfig {
243 pub server: String,
244 pub bypass: Option<String>,
245 pub username: Option<String>,
246 pub password: Option<String>,
247}
248
249impl Default for ContextConfig {
250 fn default() -> Self {
251 Self {
252 is_mobile: false,
253 has_touch: false,
254 color_scheme: None,
255 locale: None,
256 device_scale_factor: None,
257 offline: false,
258 java_script_enabled: true,
259 bypass_csp: false,
260 accept_downloads: true,
261 user_agent: None,
262 timezone_id: None,
263 geolocation: None,
264 permissions: Vec::new(),
265 extra_http_headers: BTreeMap::new(),
266 http_credentials: None,
267 ignore_https_errors: false,
268 proxy: None,
269 service_workers: None,
270 storage_state: None,
271 reduced_motion: None,
272 forced_colors: None,
273 }
274 }
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize)]
278#[serde(rename_all = "camelCase")]
279pub struct GeolocationConfig {
280 pub latitude: f64,
281 pub longitude: f64,
282 pub accuracy: Option<f64>,
283}
284
285impl BrowserConfig {
286 pub fn normalize(&mut self) {
297 match self.backend.as_str() {
298 "bidi" => {
299 self.browser = "firefox".into();
300 },
301 "webkit" => {
302 self.browser = "webkit".into();
303 },
304 _ => match self.browser.as_str() {
305 "firefox" => self.backend = "bidi".into(),
306 #[cfg(target_os = "macos")]
307 "webkit" => self.backend = "webkit".into(),
308 _ => {},
309 },
310 }
311 }
312}
313
314impl Default for BrowserConfig {
315 fn default() -> Self {
316 Self {
317 browser: "chromium".into(),
318 backend: "cdp-pipe".into(),
319 channel: None,
320 headless: false,
325 executable_path: None,
326 args: Vec::new(),
327 viewport: Some(ViewportConfig::default()),
328 slow_mo: None,
329 use_options: ContextConfig::default(),
330 }
331 }
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize)]
335#[serde(rename_all = "camelCase")]
336pub struct ViewportConfig {
337 pub width: i64,
338 pub height: i64,
339}
340
341impl Default for ViewportConfig {
342 fn default() -> Self {
343 Self {
344 width: 1280,
345 height: 720,
346 }
347 }
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize)]
351#[serde(rename_all = "camelCase")]
352pub struct ReporterConfig {
353 pub name: String,
354 #[serde(default)]
355 pub options: BTreeMap<String, serde_json::Value>,
356}
357
358#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
360#[serde(rename_all = "lowercase")]
361pub enum UpdateSnapshotsMode {
362 All,
363 Changed,
364 #[default]
365 Missing,
366 None,
367}
368
369#[derive(Debug, Clone, Serialize, Deserialize)]
371#[serde(default, rename_all = "camelCase")]
372pub struct ReportSlowTestsConfig {
373 pub max: usize,
374 pub threshold: u64,
375}
376
377impl Default for ReportSlowTestsConfig {
378 fn default() -> Self {
379 Self {
380 max: 5,
381 threshold: 15_000,
382 }
383 }
384}
385
386#[derive(Debug, Clone, Serialize, Deserialize)]
388#[serde(default, rename_all = "camelCase")]
389pub struct ProjectConfig {
390 pub name: String,
391 pub test_match: Option<Vec<String>>,
392 pub test_ignore: Option<Vec<String>>,
393 pub test_dir: Option<String>,
394 pub browser: Option<BrowserConfig>,
395 pub output_dir: Option<String>,
396 pub snapshot_dir: Option<String>,
397 pub retries: Option<u32>,
398 pub timeout: Option<u64>,
399 pub repeat_each: Option<u32>,
400 pub fully_parallel: Option<bool>,
401 pub grep: Option<String>,
402 pub grep_invert: Option<String>,
403 pub dependencies: Vec<String>,
404 pub teardown: Option<String>,
405 #[serde(default)]
406 pub metadata: serde_json::Value,
407 pub tag: Option<Vec<String>>,
408}
409
410impl Default for ProjectConfig {
411 fn default() -> Self {
412 Self {
413 name: String::new(),
414 test_match: None,
415 test_ignore: None,
416 test_dir: None,
417 browser: None,
418 output_dir: None,
419 snapshot_dir: None,
420 retries: None,
421 timeout: None,
422 repeat_each: None,
423 fully_parallel: None,
424 grep: None,
425 grep_invert: None,
426 dependencies: Vec::new(),
427 teardown: None,
428 metadata: serde_json::Value::Null,
429 tag: None,
430 }
431 }
432}
433
434#[derive(Debug, Clone, Serialize, Deserialize)]
436#[serde(default, rename_all = "camelCase")]
437pub struct WebServerConfig {
438 pub command: Option<String>,
439 pub static_dir: Option<String>,
440 pub url: Option<String>,
441 pub port: u16,
442 pub reuse_existing_server: bool,
443 pub timeout: u64,
444 pub cwd: Option<String>,
445 #[serde(default)]
446 pub env: BTreeMap<String, String>,
447 pub spa: bool,
448 pub stdout: Option<String>,
449 pub stderr: Option<String>,
450 pub ignore_https_errors: bool,
451 pub name: Option<String>,
452 pub graceful_shutdown: Option<GracefulShutdown>,
453}
454
455#[derive(Debug, Clone, Serialize, Deserialize)]
456#[serde(rename_all = "camelCase")]
457pub struct GracefulShutdown {
458 pub signal: String,
459 pub timeout: u64,
460}
461
462impl Default for WebServerConfig {
463 fn default() -> Self {
464 Self {
465 command: None,
466 static_dir: None,
467 url: None,
468 port: 0,
469 reuse_existing_server: false,
470 timeout: 30_000,
471 cwd: None,
472 env: BTreeMap::new(),
473 spa: false,
474 stdout: None,
475 stderr: None,
476 ignore_https_errors: false,
477 name: None,
478 graceful_shutdown: None,
479 }
480 }
481}
482
483#[derive(Debug, Clone)]
484pub struct ShardArg {
485 pub current: u32,
486 pub total: u32,
487}
488
489impl ShardArg {
490 pub fn parse(s: &str) -> ferridriver::error::Result<Self> {
498 use ferridriver::FerriError;
499 let parts: Vec<&str> = s.split('/').collect();
500 if parts.len() != 2 {
501 return Err(FerriError::invalid_argument(
502 "shard",
503 format!("invalid shard format: {s:?} (expected X/N)"),
504 ));
505 }
506 let current: u32 = parts[0]
507 .parse()
508 .map_err(|e| FerriError::invalid_argument("shard", format!("invalid shard current: {e}")))?;
509 let total: u32 = parts[1]
510 .parse()
511 .map_err(|e| FerriError::invalid_argument("shard", format!("invalid shard total: {e}")))?;
512 if current == 0 || current > total {
513 return Err(FerriError::invalid_argument(
514 "shard",
515 format!("shard {current}/{total}: current must be 1..={total}"),
516 ));
517 }
518 Ok(Self { current, total })
519 }
520}
521
522#[allow(clippy::struct_excessive_bools)]
526#[derive(Debug, Clone, Default)]
527pub struct CliOverrides {
528 pub workers: Option<u32>,
529 pub retries: Option<u32>,
530 pub timeout: Option<u64>,
531 pub reporter: Vec<String>,
532 pub grep: Option<String>,
533 pub grep_invert: Option<String>,
534 pub tag: Option<String>,
535 pub headless: bool,
538 pub shard: Option<ShardArg>,
539 pub config_path: Option<String>,
540 pub output_dir: Option<String>,
541 pub test_files: Vec<String>,
542 pub test_match: Option<Vec<String>>,
543 pub list_only: bool,
544 pub update_snapshots: Option<UpdateSnapshotsMode>,
545 pub profile: Option<String>,
546 pub forbid_only: bool,
547 pub last_failed: bool,
548 pub video: Option<String>,
549 pub trace: Option<String>,
550 pub storage_state: Option<String>,
551 pub max_failures: Option<u32>,
552 pub repeat_each: Option<u32>,
553 pub fail_fast: bool,
554 pub global_timeout: Option<u64>,
555 pub ignore_snapshots: bool,
556 pub pass_with_no_tests: bool,
557 pub tsconfig: Option<String>,
558 pub name: Option<String>,
559 pub fully_parallel: Option<bool>,
560 pub project_filter: Vec<String>,
561 pub no_deps: bool,
562 pub teardown: Option<String>,
563 pub only_changed: Option<String>,
564 pub fail_on_flaky_tests: bool,
565 pub browser: Option<String>,
566 pub backend: Option<String>,
567 pub channel: Option<String>,
568 pub executable_path: Option<String>,
569 pub browser_args: Vec<String>,
570 pub base_url: Option<String>,
571 pub viewport_width: Option<i64>,
572 pub viewport_height: Option<i64>,
573 pub is_mobile: Option<bool>,
574 pub has_touch: Option<bool>,
575 pub color_scheme: Option<String>,
576 pub locale: Option<String>,
577 pub offline: Option<bool>,
578 pub bypass_csp: Option<bool>,
579 pub bdd_tags: Option<String>,
580 pub bdd_dry_run: bool,
581 pub bdd_strict: bool,
582 pub bdd_fail_fast: bool,
583 pub bdd_step_timeout: Option<u64>,
584 pub bdd_order: Option<String>,
585 pub bdd_language: Option<String>,
586 pub bdd_steps: Vec<String>,
588 pub extensions: Vec<String>,
592 pub world_parameters: Option<String>,
595}
596
597impl Default for TestConfig {
598 fn default() -> Self {
599 Self {
600 test_match: Vec::new(),
604 test_dir: None,
605 test_ignore: vec!["**/node_modules/**".into(), "**/target/**".into()],
606 timeout: 30_000,
607 expect_timeout: 5_000,
608 workers: 0,
609 retries: 0,
610 reporter: vec![ReporterConfig {
611 name: "terminal".into(),
612 options: BTreeMap::new(),
613 }],
614 output_dir: PathBuf::from("test-results"),
615 browser: BrowserConfig::default(),
616 base_url: None,
617 projects: Vec::new(),
618 global_setup: Vec::new(),
619 global_teardown: Vec::new(),
620 repeat_each: 1,
621 forbid_only: false,
622 fully_parallel: false,
623 features: Vec::new(),
624 steps: Vec::new(),
625 tags: None,
626 dry_run: false,
627 fail_fast: false,
628 screenshot_on_failure: true,
629 video: VideoConfig::default(),
630 trace: TraceMode::Off,
631 storage_state: None,
632 web_server: Vec::new(),
633 max_failures: 0,
634 global_timeout: 0,
635 ignore_snapshots: false,
636 pass_with_no_tests: false,
637 tsconfig: None,
638 name: None,
639 fail_on_flaky_tests: false,
640 capture_git_info: false,
641 report_slow_tests: Some(ReportSlowTestsConfig::default()),
642 snapshot_dir: None,
643 snapshot_path_template: None,
644 update_snapshots: UpdateSnapshotsMode::default(),
645 preserve_output: "always".into(),
646 quiet: false,
647 config_grep: None,
648 config_grep_invert: None,
649 metadata: serde_json::Value::Null,
650 strict: false,
651 order: "defined".into(),
652 language: None,
653 world_parameters: serde_json::Value::Null,
654 profiles: BTreeMap::new(),
655 has_bdd: false,
656 }
657 }
658}
659
660impl std::fmt::Debug for TestConfig {
661 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
662 f.debug_struct("TestConfig")
663 .field("workers", &self.workers)
664 .field("timeout", &self.timeout)
665 .field("retries", &self.retries)
666 .field("browser", &self.browser)
667 .finish_non_exhaustive()
668 }
669}
670
671impl TestConfig {
672 #[must_use]
678 pub fn merge_project(&self, project: &ProjectConfig) -> Self {
679 let mut merged = self.clone();
680
681 if !project.name.is_empty() {
682 if let serde_json::Value::Object(ref mut map) = merged.metadata {
683 map.insert("project".into(), serde_json::Value::String(project.name.clone()));
684 } else {
685 merged.metadata = serde_json::json!({ "project": project.name });
686 }
687 }
688
689 if let Some(ref patterns) = project.test_match {
690 merged.test_match.clone_from(patterns);
691 }
692 if let Some(ref patterns) = project.test_ignore {
693 merged.test_ignore.clone_from(patterns);
694 }
695 if let Some(ref dir) = project.test_dir {
696 merged.test_dir = Some(dir.clone());
697 }
698
699 if let Some(retries) = project.retries {
700 merged.retries = retries;
701 }
702 if let Some(timeout) = project.timeout {
703 merged.timeout = timeout;
704 }
705 if let Some(repeat_each) = project.repeat_each {
706 merged.repeat_each = repeat_each;
707 }
708 if let Some(fully_parallel) = project.fully_parallel {
709 merged.fully_parallel = fully_parallel;
710 }
711
712 if let Some(ref grep) = project.grep {
713 merged.config_grep = Some(grep.clone());
714 }
715 if let Some(ref grep_inv) = project.grep_invert {
716 merged.config_grep_invert = Some(grep_inv.clone());
717 }
718
719 if let Some(ref dir) = project.output_dir {
720 merged.output_dir = PathBuf::from(dir);
721 }
722 if let Some(ref dir) = project.snapshot_dir {
723 merged.snapshot_dir = Some(dir.clone());
724 }
725
726 if let Some(ref pb) = project.browser {
727 if pb.browser != "chromium" || pb.backend != "cdp-pipe" {
728 merged.browser.browser.clone_from(&pb.browser);
729 merged.browser.backend.clone_from(&pb.backend);
730 }
731 if let Some(ref ch) = pb.channel {
732 merged.browser.channel = Some(ch.clone());
733 }
734 if !pb.headless {
735 merged.browser.headless = false;
736 }
737 if let Some(ref ep) = pb.executable_path {
738 merged.browser.executable_path = Some(ep.clone());
739 }
740 if !pb.args.is_empty() {
741 merged.browser.args.clone_from(&pb.args);
742 }
743 if let Some(ref vp) = pb.viewport {
744 merged.browser.viewport = Some(vp.clone());
745 }
746 if let Some(slow_mo) = pb.slow_mo {
747 merged.browser.slow_mo = Some(slow_mo);
748 }
749 merge_context(&mut merged.browser.use_options, &pb.use_options);
750 }
751
752 merged.browser.normalize();
753 merged.projects = Vec::new();
754
755 merged
756 }
757}
758
759fn merge_context(base: &mut ContextConfig, overlay: &ContextConfig) {
761 let defaults = ContextConfig::default();
762
763 if overlay.is_mobile != defaults.is_mobile {
764 base.is_mobile = overlay.is_mobile;
765 }
766 if overlay.has_touch != defaults.has_touch {
767 base.has_touch = overlay.has_touch;
768 }
769 if overlay.color_scheme != defaults.color_scheme {
770 base.color_scheme.clone_from(&overlay.color_scheme);
771 }
772 if overlay.locale != defaults.locale {
773 base.locale.clone_from(&overlay.locale);
774 }
775 if overlay.device_scale_factor != defaults.device_scale_factor {
776 base.device_scale_factor = overlay.device_scale_factor;
777 }
778 if overlay.offline != defaults.offline {
779 base.offline = overlay.offline;
780 }
781 if overlay.java_script_enabled != defaults.java_script_enabled {
782 base.java_script_enabled = overlay.java_script_enabled;
783 }
784 if overlay.bypass_csp != defaults.bypass_csp {
785 base.bypass_csp = overlay.bypass_csp;
786 }
787 if overlay.accept_downloads != defaults.accept_downloads {
788 base.accept_downloads = overlay.accept_downloads;
789 }
790 if overlay.user_agent.is_some() {
791 base.user_agent.clone_from(&overlay.user_agent);
792 }
793 if overlay.timezone_id.is_some() {
794 base.timezone_id.clone_from(&overlay.timezone_id);
795 }
796 if overlay.geolocation.is_some() {
797 base.geolocation.clone_from(&overlay.geolocation);
798 }
799 if !overlay.permissions.is_empty() {
800 base.permissions.clone_from(&overlay.permissions);
801 }
802 if !overlay.extra_http_headers.is_empty() {
803 base.extra_http_headers.clone_from(&overlay.extra_http_headers);
804 }
805 if overlay.http_credentials.is_some() {
806 base.http_credentials.clone_from(&overlay.http_credentials);
807 }
808 if overlay.ignore_https_errors != defaults.ignore_https_errors {
809 base.ignore_https_errors = overlay.ignore_https_errors;
810 }
811 if overlay.proxy.is_some() {
812 base.proxy.clone_from(&overlay.proxy);
813 }
814 if overlay.service_workers.is_some() {
815 base.service_workers.clone_from(&overlay.service_workers);
816 }
817 if overlay.storage_state.is_some() {
818 base.storage_state.clone_from(&overlay.storage_state);
819 }
820 if overlay.reduced_motion.is_some() {
821 base.reduced_motion.clone_from(&overlay.reduced_motion);
822 }
823 if overlay.forced_colors.is_some() {
824 base.forced_colors.clone_from(&overlay.forced_colors);
825 }
826}