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  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  /// JavaScript step-definition file globs. Loaded into the shared
138  /// `QuickJS` engine (cucumber-js `import`/`require` equivalent).
139  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  /// Cucumber `--world-parameters`: JSON exposed to every scenario as
176  /// `this.parameters` (and to a `setWorldConstructor` ctor). `Null` ⇒
177  /// `{}`. CLI `--world-parameters` overrides this.
178  #[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  /// Playwright `use` block: per-project context defaults.
197  #[serde(default, rename = "use")]
198  pub use_options: ContextConfig,
199}
200
201// Each bool field is an independent feature flag set in user TOML —
202// grouping into enums would be ceremony, not a real state machine.
203#[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  /// Normalize browser↔backend consistency after all overrides are applied.
287  ///
288  /// Ensures `browser` and `backend` agree -- like Playwright where `browserName`
289  /// is the single source of truth and the protocol is implicit.
290  ///
291  /// Rules:
292  /// - `backend = "bidi"` implies `browser = "firefox"` (`BiDi` is Firefox-only)
293  /// - `browser = "firefox"` implies `backend = "bidi"` (Firefox only speaks `BiDi`)
294  /// - `browser = "webkit"` implies `backend = "webkit"` on macOS
295  /// - Everything else defaults to `browser = "chromium"`, `backend = "cdp-pipe"`
296  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      // Default headed -- matches the new ferridriver CLI convention where
321      // `--headless` opts into headless mode. Playwright defaults to
322      // headless and uses `--headed` to flip; ferridriver does the inverse
323      // so the user can watch tests run by default.
324      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/// Snapshot update mode. Playwright: `updateSnapshots`.
359#[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/// Configuration for slow test reporting. Playwright: `reportSlowTests`.
370#[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/// Project configuration -- matches Playwright's `TestProject`.
387#[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/// Web server configuration -- matches Playwright's `webServer` option.
435#[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  /// Parse `"X/N"` format.
491  ///
492  /// # Errors
493  ///
494  /// Returns `FerriError::InvalidArgument` when the input is malformed,
495  /// when either component fails to parse as `u32`, or when `current` is
496  /// outside `1..=total`.
497  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/// CLI overrides that take highest priority.
523// Independent bool flags from `clap` parse — grouping into enums adds
524// no value; each flag has its own --foo.
525#[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  /// `--headless`: force headless mode regardless of config. Default config
536  /// runs headed, so this is the only direction the CLI flag goes.
537  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  /// JavaScript step-definition file globs (overrides `[test].steps`).
587  pub bdd_steps: Vec<String>,
588  /// Top-level `extensions` paths (files or dirs). Their `Given/When/Then`
589  /// step definitions are bundled alongside `bdd_steps` so one extension
590  /// can serve both the MCP server (`defineTool`) and the test runner.
591  pub extensions: Vec<String>,
592  /// `--world-parameters <JSON>`: overrides `[test].worldParameters`;
593  /// parsed and exposed to scenarios as `this.parameters`.
594  pub world_parameters: Option<String>,
595}
596
597impl Default for TestConfig {
598  fn default() -> Self {
599    Self {
600      // Empty: the consuming CLI (TS or Rust) supplies language-appropriate
601      // defaults when the user does not. Hard-coding `.rs` here forced every
602      // TS test-runner config to redeclare `testMatch` to escape that default.
603      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  /// Create a new config with project overrides merged on top.
673  ///
674  /// Follows Playwright's merge semantics: project fields override base config
675  /// when present, `browser`/`use` config is deep-merged, and the project name
676  /// is stored in metadata for reporter access.
677  #[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
759/// Deep-merge context config: only override fields that differ from defaults.
760fn 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}