1use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
9#[serde(rename_all = "kebab-case")]
10pub enum VideoMode {
11 #[default]
12 Off,
13 On,
14 RetainOnFailure,
15}
16
17impl VideoMode {
18 pub fn from_str(s: &str) -> Self {
20 match s {
21 "on" => Self::On,
22 "retain-on-failure" => Self::RetainOnFailure,
23 _ => Self::Off,
24 }
25 }
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(default)]
31pub struct VideoConfig {
32 pub mode: VideoMode,
33 pub width: u32,
35 pub height: u32,
37}
38
39impl Default for VideoConfig {
40 fn default() -> Self {
41 Self {
42 mode: VideoMode::Off,
43 width: 1280,
44 height: 720,
45 }
46 }
47}
48
49#[derive(Clone, Serialize, Deserialize)]
51#[serde(default)]
52pub struct TestConfig {
53 pub test_match: Vec<String>,
55
56 pub test_dir: Option<String>,
58
59 pub test_ignore: Vec<String>,
61
62 pub timeout: u64,
64
65 pub expect_timeout: u64,
67
68 pub workers: u32,
70
71 pub retries: u32,
73
74 pub reporter: Vec<ReporterConfig>,
76
77 pub output_dir: PathBuf,
79
80 pub browser: BrowserConfig,
82
83 pub base_url: Option<String>,
85
86 pub projects: Vec<ProjectConfig>,
88
89 pub global_setup: Vec<String>,
91
92 pub global_teardown: Vec<String>,
94
95 pub repeat_each: u32,
97
98 pub forbid_only: bool,
100
101 pub fully_parallel: bool,
103
104 pub features: Vec<String>,
106
107 pub tags: Option<String>,
109
110 pub dry_run: bool,
112
113 pub fail_fast: bool,
115
116 pub screenshot_on_failure: bool,
118
119 #[serde(default)]
121 pub video: VideoConfig,
122
123 #[serde(default)]
125 pub trace: crate::tracing::TraceMode,
126
127 #[serde(default)]
130 pub storage_state: Option<String>,
131
132 #[serde(default)]
135 pub web_server: Vec<WebServerConfig>,
136
137 pub max_failures: u32,
139
140 pub snapshot_dir: Option<String>,
142
143 pub snapshot_path_template: Option<String>,
145
146 #[serde(default)]
148 pub update_snapshots: UpdateSnapshotsMode,
149
150 pub preserve_output: String,
152
153 #[serde(default)]
155 pub report_slow_tests: Option<ReportSlowTestsConfig>,
156
157 pub quiet: bool,
159
160 pub config_grep: Option<String>,
162 pub config_grep_invert: Option<String>,
163
164 #[serde(default)]
166 pub metadata: serde_json::Value,
167
168 pub strict: bool,
170
171 pub order: String,
173
174 pub language: Option<String>,
177
178 pub profiles: BTreeMap<String, serde_json::Value>,
180
181 #[serde(default)]
183 pub has_bdd: bool,
184
185 #[serde(skip)]
188 pub global_setup_fns: Vec<crate::model::SuiteHookFn>,
189
190 #[serde(skip)]
192 pub global_teardown_fns: Vec<crate::model::SuiteHookFn>,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
196#[serde(default)]
197pub struct BrowserConfig {
198 pub browser: String,
201 pub backend: String,
204 pub channel: Option<String>,
206 pub headless: bool,
208 pub executable_path: Option<String>,
210 pub args: Vec<String>,
212 pub viewport: Option<ViewportConfig>,
214 pub slow_mo: Option<u64>,
216 #[serde(default)]
218 pub context: ContextConfig,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
225#[serde(default)]
226pub struct ContextConfig {
227 pub is_mobile: bool,
229 pub has_touch: bool,
231 pub color_scheme: Option<String>,
233 pub locale: Option<String>,
235 pub device_scale_factor: Option<f64>,
237 pub offline: bool,
239 pub java_script_enabled: bool,
241 pub bypass_csp: bool,
243 pub accept_downloads: bool,
245 pub user_agent: Option<String>,
247 pub timezone_id: Option<String>,
249 pub geolocation: Option<GeolocationConfig>,
251 #[serde(default)]
253 pub permissions: Vec<String>,
254 #[serde(default)]
256 pub extra_http_headers: std::collections::BTreeMap<String, String>,
257 pub http_credentials: Option<HttpCredentialsConfig>,
259 pub ignore_https_errors: bool,
261 pub proxy: Option<ProxyConfig>,
263 pub service_workers: Option<String>,
265 pub storage_state: Option<String>,
267 pub reduced_motion: Option<String>,
269 pub forced_colors: Option<String>,
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct HttpCredentialsConfig {
275 pub username: String,
276 pub password: String,
277 pub origin: Option<String>,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct ProxyConfig {
282 pub server: String,
283 pub bypass: Option<String>,
284 pub username: Option<String>,
285 pub password: Option<String>,
286}
287
288impl Default for ContextConfig {
289 fn default() -> Self {
290 Self {
291 is_mobile: false,
292 has_touch: false,
293 color_scheme: None,
294 locale: None,
295 device_scale_factor: None,
296 offline: false,
297 java_script_enabled: true,
298 bypass_csp: false,
299 accept_downloads: true,
300 user_agent: None,
301 timezone_id: None,
302 geolocation: None,
303 permissions: Vec::new(),
304 extra_http_headers: std::collections::BTreeMap::new(),
305 http_credentials: None,
306 ignore_https_errors: false,
307 proxy: None,
308 service_workers: None,
309 storage_state: None,
310 reduced_motion: None,
311 forced_colors: None,
312 }
313 }
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize)]
317pub struct GeolocationConfig {
318 pub latitude: f64,
319 pub longitude: f64,
320 pub accuracy: Option<f64>,
321}
322
323impl BrowserConfig {
324 pub fn normalize(&mut self) {
335 match self.backend.as_str() {
336 "bidi" => {
337 self.browser = "firefox".into();
339 },
340 "webkit" => {
341 self.browser = "webkit".into();
342 },
343 _ => {
344 match self.browser.as_str() {
346 "firefox" => self.backend = "bidi".into(),
347 #[cfg(target_os = "macos")]
348 "webkit" => self.backend = "webkit".into(),
349 _ => {
350 },
352 }
353 },
354 }
355 }
356}
357
358impl Default for BrowserConfig {
359 fn default() -> Self {
360 Self {
361 browser: "chromium".into(),
362 backend: "cdp-pipe".into(),
363 channel: None,
364 headless: true,
365 executable_path: None,
366 args: Vec::new(),
367 viewport: Some(ViewportConfig::default()),
368 slow_mo: None,
369 context: ContextConfig::default(),
370 }
371 }
372}
373
374#[derive(Debug, Clone, Serialize, Deserialize)]
375pub struct ViewportConfig {
376 pub width: i64,
377 pub height: i64,
378}
379
380impl Default for ViewportConfig {
381 fn default() -> Self {
382 Self {
383 width: 1280,
384 height: 720,
385 }
386 }
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize)]
390pub struct ReporterConfig {
391 pub name: String,
392 #[serde(default)]
393 pub options: BTreeMap<String, serde_json::Value>,
394}
395
396#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
398#[serde(rename_all = "lowercase")]
399pub enum UpdateSnapshotsMode {
400 All,
402 Changed,
404 #[default]
406 Missing,
407 None,
409}
410
411#[derive(Debug, Clone, Serialize, Deserialize)]
413#[serde(default)]
414pub struct ReportSlowTestsConfig {
415 pub max: usize,
417 pub threshold: u64,
419}
420
421impl Default for ReportSlowTestsConfig {
422 fn default() -> Self {
423 Self {
424 max: 5,
425 threshold: 15_000,
426 }
427 }
428}
429
430#[derive(Debug, Clone, Serialize, Deserialize)]
432#[serde(default)]
433pub struct ProjectConfig {
434 pub name: String,
435 pub test_match: Option<Vec<String>>,
437 pub test_ignore: Option<Vec<String>>,
439 pub test_dir: Option<String>,
441 pub browser: Option<BrowserConfig>,
443 pub output_dir: Option<String>,
445 pub snapshot_dir: Option<String>,
447 pub retries: Option<u32>,
448 pub timeout: Option<u64>,
449 pub repeat_each: Option<u32>,
450 pub fully_parallel: Option<bool>,
452 pub grep: Option<String>,
454 pub grep_invert: Option<String>,
455 pub dependencies: Vec<String>,
457 pub teardown: Option<String>,
459 #[serde(default)]
461 pub metadata: serde_json::Value,
462 pub tag: Option<Vec<String>>,
464}
465
466impl Default for ProjectConfig {
467 fn default() -> Self {
468 Self {
469 name: String::new(),
470 test_match: None,
471 test_ignore: None,
472 test_dir: None,
473 browser: None,
474 output_dir: None,
475 snapshot_dir: None,
476 retries: None,
477 timeout: None,
478 repeat_each: None,
479 fully_parallel: None,
480 grep: None,
481 grep_invert: None,
482 dependencies: Vec::new(),
483 teardown: None,
484 metadata: serde_json::Value::Null,
485 tag: None,
486 }
487 }
488}
489
490#[derive(Debug, Clone, Serialize, Deserialize)]
498#[serde(default)]
499pub struct WebServerConfig {
500 pub command: Option<String>,
503
504 pub static_dir: Option<String>,
506
507 pub url: Option<String>,
510
511 pub port: u16,
513
514 pub reuse_existing_server: bool,
516
517 pub timeout: u64,
519
520 pub cwd: Option<String>,
522
523 #[serde(default)]
525 pub env: std::collections::BTreeMap<String, String>,
526
527 pub spa: bool,
529
530 pub stdout: Option<String>,
532
533 pub stderr: Option<String>,
535}
536
537impl Default for WebServerConfig {
538 fn default() -> Self {
539 Self {
540 command: None,
541 static_dir: None,
542 url: None,
543 port: 0,
544 reuse_existing_server: false,
545 timeout: 30_000,
546 cwd: None,
547 env: std::collections::BTreeMap::new(),
548 spa: false,
549 stdout: None,
550 stderr: None,
551 }
552 }
553}
554
555#[derive(Debug, Clone, Default)]
562pub struct CliOverrides {
563 pub workers: Option<u32>,
564 pub retries: Option<u32>,
565 pub timeout: Option<u64>,
566 pub reporter: Vec<String>,
567 pub grep: Option<String>,
568 pub grep_invert: Option<String>,
569 pub tag: Option<String>,
570 pub headed: bool,
571 pub shard: Option<ShardArg>,
572 pub config_path: Option<String>,
573 pub output_dir: Option<String>,
574 pub test_files: Vec<String>,
575 pub test_match: Option<Vec<String>>,
577 pub list_only: bool,
578 pub update_snapshots: Option<UpdateSnapshotsMode>,
579 pub profile: Option<String>,
580 pub forbid_only: bool,
581 pub last_failed: bool,
582 pub video: Option<String>,
583 pub trace: Option<String>,
584 pub storage_state: Option<String>,
585 pub browser: Option<String>,
588 pub backend: Option<String>,
590 pub channel: Option<String>,
592 pub executable_path: Option<String>,
594 pub browser_args: Vec<String>,
596 pub base_url: Option<String>,
598 pub viewport_width: Option<i64>,
600 pub viewport_height: Option<i64>,
602 pub is_mobile: Option<bool>,
604 pub has_touch: Option<bool>,
605 pub color_scheme: Option<String>,
606 pub locale: Option<String>,
607 pub offline: Option<bool>,
608 pub bypass_csp: Option<bool>,
609 pub bdd_tags: Option<String>,
612 pub bdd_dry_run: bool,
614 pub bdd_strict: bool,
616 pub bdd_fail_fast: bool,
618 pub bdd_step_timeout: Option<u64>,
620 pub bdd_order: Option<String>,
622 pub bdd_language: Option<String>,
624}
625
626pub fn parse_common_cli_args() -> CliOverrides {
632 let args: Vec<String> = std::env::args().collect();
633 let mut overrides = CliOverrides::default();
634 let mut i = 1;
635 while i < args.len() {
636 match args[i].as_str() {
637 "--headed" => overrides.headed = true,
638 "--workers" | "-j" => {
639 i += 1;
640 overrides.workers = args.get(i).and_then(|v| v.parse().ok());
641 },
642 "--retries" => {
643 i += 1;
644 overrides.retries = args.get(i).and_then(|v| v.parse().ok());
645 },
646 "--timeout" => {
647 i += 1;
648 overrides.timeout = args.get(i).and_then(|v| v.parse().ok());
649 },
650 "--backend" => {
651 i += 1;
652 overrides.backend = args.get(i).cloned();
653 },
654 "--grep" | "-g" => {
655 i += 1;
656 overrides.grep = args.get(i).cloned();
657 },
658 "--tag" => {
659 i += 1;
660 overrides.tag = args.get(i).cloned();
661 },
662 "--list" => overrides.list_only = true,
663 "--update-snapshots" | "-u" => overrides.update_snapshots = Some(UpdateSnapshotsMode::All),
664 "--forbid-only" => overrides.forbid_only = true,
665 "--last-failed" => overrides.last_failed = true,
666 "--profile" => {
667 i += 1;
668 overrides.profile = args.get(i).cloned();
669 },
670 "--tags" | "-t" => {
672 i += 1;
673 overrides.bdd_tags = args.get(i).cloned();
674 },
675 "--dry-run" => overrides.bdd_dry_run = true,
676 "--strict" => overrides.bdd_strict = true,
677 "--fail-fast" => overrides.bdd_fail_fast = true,
678 "--step-timeout" => {
679 i += 1;
680 overrides.bdd_step_timeout = args.get(i).and_then(|v| v.parse().ok());
681 },
682 "--order" => {
683 i += 1;
684 overrides.bdd_order = args.get(i).cloned();
685 },
686 "--language" => {
687 i += 1;
688 overrides.bdd_language = args.get(i).cloned();
689 },
690 _ => {},
691 }
692 i += 1;
693 }
694 overrides
695}
696
697#[derive(Debug, Clone)]
698pub struct ShardArg {
699 pub current: u32,
700 pub total: u32,
701}
702
703impl ShardArg {
704 pub fn parse(s: &str) -> Result<Self, String> {
706 let parts: Vec<&str> = s.split('/').collect();
707 if parts.len() != 2 {
708 return Err(format!("invalid shard format: {s:?} (expected X/N)"));
709 }
710 let current: u32 = parts[0].parse().map_err(|e| format!("invalid shard current: {e}"))?;
711 let total: u32 = parts[1].parse().map_err(|e| format!("invalid shard total: {e}"))?;
712 if current == 0 || current > total {
713 return Err(format!("shard {current}/{total}: current must be 1..={total}"));
714 }
715 Ok(Self { current, total })
716 }
717}
718
719impl Default for TestConfig {
720 fn default() -> Self {
721 Self {
722 test_match: vec!["**/*.spec.rs".into(), "**/*.test.rs".into()],
723 test_dir: None,
724 test_ignore: vec!["**/node_modules/**".into(), "**/target/**".into()],
725 timeout: 30_000,
726 expect_timeout: 5_000,
727 workers: 0,
728 retries: 0,
729 reporter: vec![ReporterConfig {
730 name: "terminal".into(),
731 options: BTreeMap::new(),
732 }],
733 output_dir: PathBuf::from("test-results"),
734 browser: BrowserConfig::default(),
735 base_url: None,
736 projects: Vec::new(),
737 global_setup: Vec::new(),
738 global_teardown: Vec::new(),
739 repeat_each: 1,
740 forbid_only: false,
741 fully_parallel: false,
742 features: Vec::new(),
743 tags: None,
744 dry_run: false,
745 fail_fast: false,
746 screenshot_on_failure: true,
747 video: VideoConfig::default(),
748 trace: crate::tracing::TraceMode::Off,
749 storage_state: None,
750 web_server: Vec::new(),
751 max_failures: 0,
752 report_slow_tests: Some(ReportSlowTestsConfig::default()),
753 snapshot_dir: None,
754 snapshot_path_template: None,
755 update_snapshots: UpdateSnapshotsMode::default(),
756 preserve_output: "always".into(),
757 quiet: false,
758 config_grep: None,
759 config_grep_invert: None,
760 metadata: serde_json::Value::Null,
761 strict: false,
762 order: "defined".into(),
763 language: None,
764 profiles: BTreeMap::new(),
765 has_bdd: false,
766 global_setup_fns: Vec::new(),
767 global_teardown_fns: Vec::new(),
768 }
769 }
770}
771
772impl std::fmt::Debug for TestConfig {
773 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
774 f.debug_struct("TestConfig")
775 .field("workers", &self.workers)
776 .field("timeout", &self.timeout)
777 .field("retries", &self.retries)
778 .field("browser", &self.browser)
779 .field("global_setup_fns", &format!("[{} fn(s)]", self.global_setup_fns.len()))
780 .field(
781 "global_teardown_fns",
782 &format!("[{} fn(s)]", self.global_teardown_fns.len()),
783 )
784 .finish_non_exhaustive()
785 }
786}
787
788pub fn resolve_config(overrides: &CliOverrides) -> Result<TestConfig, String> {
794 let mut config = if let Some(path) = &overrides.config_path {
795 load_config_file(Path::new(path))?
796 } else {
797 find_and_load_config()?
798 };
799
800 if let Some(profile_name) = &overrides.profile {
802 if let Some(profile_value) = config.profiles.get(profile_name) {
803 let mut base = serde_json::to_value(&config).map_err(|e| format!("serialize config: {e}"))?;
805 json_merge(&mut base, profile_value);
806 config = serde_json::from_value(base).map_err(|e| format!("apply profile '{profile_name}': {e}"))?;
807 } else {
808 return Err(format!("profile '{profile_name}' not found in config"));
809 }
810 }
811
812 if let Ok(w) = std::env::var("FERRIDRIVER_WORKERS") {
814 if let Ok(v) = w.parse() {
815 config.workers = v;
816 }
817 }
818 if let Ok(r) = std::env::var("FERRIDRIVER_RETRIES") {
819 if let Ok(v) = r.parse() {
820 config.retries = v;
821 }
822 }
823 if let Ok(t) = std::env::var("FERRIDRIVER_TIMEOUT") {
824 if let Ok(v) = t.parse() {
825 config.timeout = v;
826 }
827 }
828 if let Ok(b) = std::env::var("FERRIDRIVER_BACKEND") {
829 config.browser.backend = b;
830 }
831
832 if let Some(w) = overrides.workers {
834 config.workers = w;
835 }
836 if let Some(t) = overrides.timeout {
837 config.timeout = t;
838 }
839 if let Some(r) = overrides.retries {
840 config.retries = r;
841 }
842 if !overrides.reporter.is_empty() {
843 config.reporter = overrides
844 .reporter
845 .iter()
846 .map(|name| ReporterConfig {
847 name: name.clone(),
848 options: BTreeMap::new(),
849 })
850 .collect();
851 }
852 if overrides.headed {
853 config.browser.headless = false;
854 }
855 if let Some(ref b) = overrides.browser {
856 config.browser.browser.clone_from(b);
857 }
858 if let Some(ref b) = overrides.backend {
859 config.browser.backend.clone_from(b);
860 }
861 if let Some(ref ch) = overrides.channel {
862 config.browser.channel = Some(ch.clone());
863 }
864 if let Some(ref p) = overrides.executable_path {
865 config.browser.executable_path = Some(p.clone());
866 }
867 if !overrides.browser_args.is_empty() {
868 config.browser.args.clone_from(&overrides.browser_args);
869 }
870 if let Some(ref url) = overrides.base_url {
871 config.base_url = Some(url.clone());
872 }
873 if let Some(w) = overrides.viewport_width {
874 if let Some(ref mut vp) = config.browser.viewport {
875 vp.width = w;
876 }
877 }
878 if let Some(h) = overrides.viewport_height {
879 if let Some(ref mut vp) = config.browser.viewport {
880 vp.height = h;
881 }
882 }
883 if let Some(m) = overrides.is_mobile {
885 config.browser.context.is_mobile = m;
886 }
887 if let Some(t) = overrides.has_touch {
888 config.browser.context.has_touch = t;
889 }
890 if let Some(ref cs) = overrides.color_scheme {
891 config.browser.context.color_scheme = Some(cs.clone());
892 }
893 if let Some(ref l) = overrides.locale {
894 config.browser.context.locale = Some(l.clone());
895 }
896 if let Some(o) = overrides.offline {
897 config.browser.context.offline = o;
898 }
899 if let Some(b) = overrides.bypass_csp {
900 config.browser.context.bypass_csp = b;
901 }
902 if let Some(dir) = &overrides.output_dir {
903 config.output_dir = PathBuf::from(dir);
904 }
905 if let Some(ref patterns) = overrides.test_match {
906 config.test_match.clone_from(patterns);
907 }
908 if overrides.forbid_only {
909 config.forbid_only = true;
910 }
911 if let Some(video) = &overrides.video {
912 config.video.mode = VideoMode::from_str(video);
913 }
914 if let Some(trace) = &overrides.trace {
915 config.trace = crate::tracing::TraceMode::from_str(trace);
916 }
917 if let Some(ref ss) = overrides.storage_state {
918 config.storage_state = Some(ss.clone());
919 }
920 if let Some(mode) = overrides.update_snapshots {
921 config.update_snapshots = mode;
922 }
923 if let Ok(v) = std::env::var("FERRIDRIVER_VIDEO") {
925 config.video.mode = VideoMode::from_str(&v);
926 }
927 if let Ok(t) = std::env::var("FERRIDRIVER_TRACE") {
929 config.trace = crate::tracing::TraceMode::from_str(&t);
930 }
931
932 if config.workers == 0 {
934 let cpus = std::thread::available_parallelism()
935 .map(|n| n.get() as u32)
936 .unwrap_or(4);
937 config.workers = (cpus / 2).max(1);
938 }
939
940 config.browser.normalize();
942
943 Ok(config)
944}
945
946fn find_and_load_config() -> Result<TestConfig, String> {
947 let cwd = std::env::current_dir().map_err(|e| format!("cannot get cwd: {e}"))?;
948 let names = ["ferridriver.config.toml", "ferridriver.config.json"];
949
950 let mut dir = Some(cwd.as_path());
951 while let Some(d) = dir {
952 for name in &names {
953 let path = d.join(name);
954 if path.exists() {
955 return load_config_file(&path);
956 }
957 }
958 dir = d.parent();
959 }
960
961 Ok(TestConfig::default())
963}
964
965fn load_config_file(path: &Path) -> Result<TestConfig, String> {
966 let content = std::fs::read_to_string(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
967
968 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
969 match ext {
970 "toml" => toml::from_str(&content).map_err(|e| format!("invalid TOML config: {e}")),
971 "json" => serde_json::from_str(&content).map_err(|e| format!("invalid JSON config: {e}")),
972 _ => Err(format!("unsupported config format: {ext}")),
973 }
974}
975
976impl TestConfig {
977 #[must_use]
983 pub fn merge_project(&self, project: &ProjectConfig) -> Self {
984 let mut merged = self.clone();
985
986 if !project.name.is_empty() {
988 if let serde_json::Value::Object(ref mut map) = merged.metadata {
989 map.insert("project".into(), serde_json::Value::String(project.name.clone()));
990 } else {
991 merged.metadata = serde_json::json!({ "project": project.name });
992 }
993 }
994
995 if let Some(ref patterns) = project.test_match {
997 merged.test_match.clone_from(patterns);
998 }
999 if let Some(ref patterns) = project.test_ignore {
1000 merged.test_ignore.clone_from(patterns);
1001 }
1002 if let Some(ref dir) = project.test_dir {
1003 merged.test_dir = Some(dir.clone());
1004 }
1005
1006 if let Some(retries) = project.retries {
1008 merged.retries = retries;
1009 }
1010 if let Some(timeout) = project.timeout {
1011 merged.timeout = timeout;
1012 }
1013 if let Some(repeat_each) = project.repeat_each {
1014 merged.repeat_each = repeat_each;
1015 }
1016 if let Some(fully_parallel) = project.fully_parallel {
1017 merged.fully_parallel = fully_parallel;
1018 }
1019
1020 if let Some(ref grep) = project.grep {
1022 merged.config_grep = Some(grep.clone());
1023 }
1024 if let Some(ref grep_inv) = project.grep_invert {
1025 merged.config_grep_invert = Some(grep_inv.clone());
1026 }
1027
1028 if let Some(ref dir) = project.output_dir {
1030 merged.output_dir = PathBuf::from(dir);
1031 }
1032 if let Some(ref dir) = project.snapshot_dir {
1033 merged.snapshot_dir = Some(dir.clone());
1034 }
1035
1036 if let Some(ref pb) = project.browser {
1038 if pb.browser != "chromium" || pb.backend != "cdp-pipe" {
1039 merged.browser.browser.clone_from(&pb.browser);
1041 merged.browser.backend.clone_from(&pb.backend);
1042 }
1043 if let Some(ref ch) = pb.channel {
1044 merged.browser.channel = Some(ch.clone());
1045 }
1046 if !pb.headless {
1047 merged.browser.headless = false;
1048 }
1049 if let Some(ref ep) = pb.executable_path {
1050 merged.browser.executable_path = Some(ep.clone());
1051 }
1052 if !pb.args.is_empty() {
1053 merged.browser.args.clone_from(&pb.args);
1054 }
1055 if let Some(ref vp) = pb.viewport {
1056 merged.browser.viewport = Some(vp.clone());
1057 }
1058 if let Some(slow_mo) = pb.slow_mo {
1059 merged.browser.slow_mo = Some(slow_mo);
1060 }
1061 merge_context(&mut merged.browser.context, &pb.context);
1063 }
1064
1065 merged.browser.normalize();
1067
1068 merged.projects = Vec::new();
1070
1071 merged
1072 }
1073}
1074
1075fn merge_context(base: &mut ContextConfig, overlay: &ContextConfig) {
1077 let defaults = ContextConfig::default();
1078
1079 if overlay.is_mobile != defaults.is_mobile {
1080 base.is_mobile = overlay.is_mobile;
1081 }
1082 if overlay.has_touch != defaults.has_touch {
1083 base.has_touch = overlay.has_touch;
1084 }
1085 if overlay.color_scheme != defaults.color_scheme {
1086 base.color_scheme.clone_from(&overlay.color_scheme);
1087 }
1088 if overlay.locale != defaults.locale {
1089 base.locale.clone_from(&overlay.locale);
1090 }
1091 if overlay.device_scale_factor != defaults.device_scale_factor {
1092 base.device_scale_factor = overlay.device_scale_factor;
1093 }
1094 if overlay.offline != defaults.offline {
1095 base.offline = overlay.offline;
1096 }
1097 if overlay.java_script_enabled != defaults.java_script_enabled {
1098 base.java_script_enabled = overlay.java_script_enabled;
1099 }
1100 if overlay.bypass_csp != defaults.bypass_csp {
1101 base.bypass_csp = overlay.bypass_csp;
1102 }
1103 if overlay.accept_downloads != defaults.accept_downloads {
1104 base.accept_downloads = overlay.accept_downloads;
1105 }
1106 if overlay.user_agent.is_some() {
1107 base.user_agent.clone_from(&overlay.user_agent);
1108 }
1109 if overlay.timezone_id.is_some() {
1110 base.timezone_id.clone_from(&overlay.timezone_id);
1111 }
1112 if overlay.geolocation.is_some() {
1113 base.geolocation.clone_from(&overlay.geolocation);
1114 }
1115 if !overlay.permissions.is_empty() {
1116 base.permissions.clone_from(&overlay.permissions);
1117 }
1118 if !overlay.extra_http_headers.is_empty() {
1119 base.extra_http_headers.clone_from(&overlay.extra_http_headers);
1120 }
1121 if overlay.http_credentials.is_some() {
1122 base.http_credentials.clone_from(&overlay.http_credentials);
1123 }
1124 if overlay.ignore_https_errors != defaults.ignore_https_errors {
1125 base.ignore_https_errors = overlay.ignore_https_errors;
1126 }
1127 if overlay.proxy.is_some() {
1128 base.proxy.clone_from(&overlay.proxy);
1129 }
1130 if overlay.service_workers.is_some() {
1131 base.service_workers.clone_from(&overlay.service_workers);
1132 }
1133 if overlay.storage_state.is_some() {
1134 base.storage_state.clone_from(&overlay.storage_state);
1135 }
1136 if overlay.reduced_motion.is_some() {
1137 base.reduced_motion.clone_from(&overlay.reduced_motion);
1138 }
1139 if overlay.forced_colors.is_some() {
1140 base.forced_colors.clone_from(&overlay.forced_colors);
1141 }
1142}
1143
1144fn json_merge(base: &mut serde_json::Value, overlay: &serde_json::Value) {
1145 match (base, overlay) {
1146 (serde_json::Value::Object(base_map), serde_json::Value::Object(overlay_map)) => {
1147 for (key, value) in overlay_map {
1148 if let Some(existing) = base_map.get_mut(key) {
1149 json_merge(existing, value);
1150 } else {
1151 base_map.insert(key.clone(), value.clone());
1152 }
1153 }
1154 },
1155 (base, _) => {
1156 *base = overlay.clone();
1157 },
1158 }
1159}