Skip to main content

ferridriver_config/
test.rs

1//! Test runner configuration types.
2//!
3//! Loaded from the `[test]` table of the unified `ferridriver.toml`. Pure data
4//! plus inherent methods (`merge_project`). The runtime test runner in
5//! `ferridriver-test` consumes these types and supplies execution behavior.
6//!
7//! Programmatic suite-scoped hook functions (`before_all` / `after_all`) live
8//! on a separate `TestHooks` struct in `ferridriver-test::model` so this crate
9//! avoids depending on runtime fixture/test types.
10
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeMap;
13use std::path::PathBuf;
14
15// ── Trace mode ──────────────────────────────────────────────────────────────
16
17/// Trace recording mode. Mirrors Playwright's `trace`.
18#[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  /// Parse from string (config/CLI).
30  #[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  /// Should we record for this test attempt?
41  #[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  /// Should we keep the trace after the test finished?
51  #[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  /// Combined check: should we actually write a trace file?
61  #[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// ── Video ───────────────────────────────────────────────────────────────────
68
69/// Video recording mode.
70#[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  /// Parse from string (config/CLI).
81  #[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/// Video recording configuration.
92#[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// ── Test config ─────────────────────────────────────────────────────────────
111
112/// Test runner configuration.
113// Each bool field is an independent feature flag set in user TOML —
114// grouping into enums would be ceremony, not a real state machine.
115#[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  /// Maximum number of projects allowed to run concurrently. A project only
132  /// starts once all its dependencies have completed successfully, so this
133  /// caps the breadth of the ready-set scheduler. `0` means "unbounded" —
134  /// every dependency-ready project runs at once.
135  #[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  /// JavaScript step-definition file globs. Loaded into the shared
144  /// `QuickJS` engine (cucumber-js `import`/`require` equivalent).
145  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  /// Cucumber `--world-parameters`: JSON exposed to every scenario as
182  /// `this.parameters` (and to a `setWorldConstructor` ctor). `Null` ⇒
183  /// `{}`. CLI `--world-parameters` overrides this.
184  #[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  /// Playwright `use` block: per-project context defaults.
203  #[serde(default, rename = "use")]
204  pub use_options: ContextConfig,
205}
206
207// Each bool field is an independent feature flag set in user TOML —
208// grouping into enums would be ceremony, not a real state machine.
209#[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  /// Normalize browser↔backend consistency after all overrides are applied.
293  ///
294  /// Ensures `browser` and `backend` agree -- like Playwright where `browserName`
295  /// is the single source of truth and the protocol is implicit.
296  ///
297  /// Rules:
298  /// - `backend = "bidi"` implies `browser = "firefox"` (`BiDi` is Firefox-only)
299  /// - `browser = "firefox"` implies `backend = "bidi"` (Firefox only speaks `BiDi`)
300  /// - `browser = "webkit"` implies `backend = "webkit"` on macOS
301  /// - Everything else defaults to `browser = "chromium"`, `backend = "cdp-pipe"`
302  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      // Default headed -- matches the new ferridriver CLI convention where
327      // `--headless` opts into headless mode. Playwright defaults to
328      // headless and uses `--headed` to flip; ferridriver does the inverse
329      // so the user can watch tests run by default.
330      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/// Snapshot update mode. Playwright: `updateSnapshots`.
365#[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/// Configuration for slow test reporting. Playwright: `reportSlowTests`.
376#[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/// Project configuration -- matches Playwright's `TestProject`.
393#[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/// Web server configuration -- matches Playwright's `webServer` option.
441#[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  /// Parse `"X/N"` format.
497  ///
498  /// # Errors
499  ///
500  /// Returns `FerriError::InvalidArgument` when the input is malformed,
501  /// when either component fails to parse as `u32`, or when `current` is
502  /// outside `1..=total`.
503  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/// CLI overrides that take highest priority.
529// Independent bool flags from `clap` parse — grouping into enums adds
530// no value; each flag has its own --foo.
531#[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  /// `--headless`: force headless mode regardless of config. Default config
542  /// runs headed, so this is the only direction the CLI flag goes.
543  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  /// JavaScript step-definition file globs (overrides `[test].steps`).
593  pub bdd_steps: Vec<String>,
594  /// Top-level `extensions` paths (files or dirs). Their `Given/When/Then`
595  /// step definitions are bundled alongside `bdd_steps` so one extension
596  /// can serve both the MCP server (`defineTool`) and the test runner.
597  pub extensions: Vec<String>,
598  /// `--world-parameters <JSON>`: overrides `[test].worldParameters`;
599  /// parsed and exposed to scenarios as `this.parameters`.
600  pub world_parameters: Option<String>,
601}
602
603impl Default for TestConfig {
604  fn default() -> Self {
605    Self {
606      // Empty: the consuming CLI (TS or Rust) supplies language-appropriate
607      // defaults when the user does not. Hard-coding `.rs` here forced every
608      // TS test-runner config to redeclare `testMatch` to escape that default.
609      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  /// Create a new config with project overrides merged on top.
680  ///
681  /// Follows Playwright's merge semantics: project fields override base config
682  /// when present, `browser`/`use` config is deep-merged, and the project name
683  /// is stored in metadata for reporter access.
684  #[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
766/// Deep-merge context config: only override fields that differ from defaults.
767fn 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}