Skip to main content

ferridriver_test/
config.rs

1//! Test configuration: file-based, CLI, and environment variable resolution.
2
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6
7/// Video recording mode.
8#[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  /// Parse from string. Mirrors `TraceMode::from_str`.
19  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/// Video recording configuration.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(default)]
31pub struct VideoConfig {
32  pub mode: VideoMode,
33  /// Video width (default 1280). Must be even for VP8.
34  pub width: u32,
35  /// Video height (default 720). Must be even for VP8.
36  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/// Configuration file schema. Loaded from `ferridriver.config.toml` (or `.json`).
50#[derive(Clone, Serialize, Deserialize)]
51#[serde(default)]
52pub struct TestConfig {
53  /// Test file glob patterns.
54  pub test_match: Vec<String>,
55
56  /// Root directory for test files (relative to config file).
57  pub test_dir: Option<String>,
58
59  /// Directories/patterns to ignore.
60  pub test_ignore: Vec<String>,
61
62  /// Default test timeout in ms.
63  pub timeout: u64,
64
65  /// Default expect timeout for auto-retrying assertions in ms.
66  pub expect_timeout: u64,
67
68  /// Number of parallel workers. 0 = auto (`num_cpus / 2`).
69  pub workers: u32,
70
71  /// Number of retries for failed tests.
72  pub retries: u32,
73
74  /// Reporter configurations.
75  pub reporter: Vec<ReporterConfig>,
76
77  /// Output directory for reports and artifacts.
78  pub output_dir: PathBuf,
79
80  /// Browser launch options.
81  pub browser: BrowserConfig,
82
83  /// Base URL for relative `page.goto()` calls.
84  pub base_url: Option<String>,
85
86  /// Projects (named config presets for different browsers/viewports).
87  pub projects: Vec<ProjectConfig>,
88
89  /// Global setup files (run once before all tests).
90  pub global_setup: Vec<String>,
91
92  /// Global teardown files (run once after all tests).
93  pub global_teardown: Vec<String>,
94
95  /// Run each test N times (for detecting flaky tests). Default: 1.
96  pub repeat_each: u32,
97
98  /// Fail the run if `test.only()` is found (for CI).
99  pub forbid_only: bool,
100
101  /// Run all tests in parallel (ignore file-level serial grouping).
102  pub fully_parallel: bool,
103
104  /// Feature file glob patterns for BDD mode (e.g., `["features/**/*.feature"]`).
105  pub features: Vec<String>,
106
107  /// Tag filter expression (e.g., `"@smoke and not @wip"`).
108  pub tags: Option<String>,
109
110  /// Dry run mode: validate without executing.
111  pub dry_run: bool,
112
113  /// Stop on first test/scenario failure.
114  pub fail_fast: bool,
115
116  /// Take screenshot on failure. Default: true.
117  pub screenshot_on_failure: bool,
118
119  /// Video recording configuration.
120  #[serde(default)]
121  pub video: VideoConfig,
122
123  /// Trace recording mode.
124  #[serde(default)]
125  pub trace: crate::tracing::TraceMode,
126
127  /// Path to storage state JSON file (cookies + localStorage).
128  /// When set, every test starts with this state pre-loaded (Playwright auth pattern).
129  #[serde(default)]
130  pub storage_state: Option<String>,
131
132  /// Web server configurations. Started before tests, stopped after.
133  /// Supports both external commands (dev servers) and static file serving.
134  #[serde(default)]
135  pub web_server: Vec<WebServerConfig>,
136
137  /// Stop after N test failures. 0 = no limit. Playwright: `maxFailures`.
138  pub max_failures: u32,
139
140  /// Snapshot directory path template.
141  pub snapshot_dir: Option<String>,
142
143  /// Snapshot path template (e.g. `{testDir}/__snapshots__/{testFilePath}/{arg}{ext}`).
144  pub snapshot_path_template: Option<String>,
145
146  /// Snapshot update mode: "all", "changed", "missing" (default), "none".
147  #[serde(default)]
148  pub update_snapshots: UpdateSnapshotsMode,
149
150  /// Whether to preserve test output: "always", "never", "failures-only".
151  pub preserve_output: String,
152
153  /// Report slow tests after the run. `null` disables. Playwright: `reportSlowTests`.
154  #[serde(default)]
155  pub report_slow_tests: Option<ReportSlowTestsConfig>,
156
157  /// Suppress stdio output from tests. Playwright: `quiet`.
158  pub quiet: bool,
159
160  /// Global grep filter at config level (in addition to CLI grep).
161  pub config_grep: Option<String>,
162  pub config_grep_invert: Option<String>,
163
164  /// Arbitrary metadata object.
165  #[serde(default)]
166  pub metadata: serde_json::Value,
167
168  /// Strict mode: treat undefined/pending steps as errors. Default: false.
169  pub strict: bool,
170
171  /// Scenario execution order: `"defined"` (default) or `"random"` / `"random:SEED"`.
172  pub order: String,
173
174  /// Default language for Gherkin keyword i18n (e.g., `"fr"`, `"de"`).
175  /// When `None`, features use `# language:` comments or default to English.
176  pub language: Option<String>,
177
178  /// Named configuration presets, merged onto the base config via `--profile NAME`.
179  pub profiles: BTreeMap<String, serde_json::Value>,
180
181  /// Whether the plan contains BDD features. Controls reporter variant selection.
182  #[serde(default)]
183  pub has_bdd: bool,
184
185  /// Programmatic global setup hooks (run before any tests).
186  /// Not serializable — set by code, not config files.
187  #[serde(skip)]
188  pub global_setup_fns: Vec<crate::model::SuiteHookFn>,
189
190  /// Programmatic global teardown hooks (run after all tests).
191  #[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  /// Browser product: "chromium" (default), "firefox", "webkit".
199  /// Determines the default backend and executable detection.
200  pub browser: String,
201  /// Backend protocol: "cdp-pipe", "cdp-raw", "webkit", "bidi".
202  /// Inferred from `browser` if not set.
203  pub backend: String,
204  /// Browser channel: "chrome", "chrome-beta", "msedge", etc.
205  pub channel: Option<String>,
206  /// Run headless. Default: true.
207  pub headless: bool,
208  /// Path to browser executable (overrides auto-detection).
209  pub executable_path: Option<String>,
210  /// Extra browser launch arguments.
211  pub args: Vec<String>,
212  /// Default viewport dimensions.
213  pub viewport: Option<ViewportConfig>,
214  /// Slow down operations by this many ms (debugging).
215  pub slow_mo: Option<u64>,
216  /// Context options (Playwright `use` block equivalents).
217  #[serde(default)]
218  pub context: ContextConfig,
219}
220
221/// Context-level options — mirrors Playwright's `use` config block.
222/// These are applied to every browser context created for tests and
223/// are available as condition predicates in annotations.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225#[serde(default)]
226pub struct ContextConfig {
227  /// Simulate mobile device. Condition: `"mobile"`.
228  pub is_mobile: bool,
229  /// Enable touch events. Condition: `"touch"`.
230  pub has_touch: bool,
231  /// Color scheme: "light", "dark", "no-preference".
232  pub color_scheme: Option<String>,
233  /// Browser locale (e.g., "en-US", "de-DE"). Condition: `"locale:de-DE"`.
234  pub locale: Option<String>,
235  /// Device scale factor (DPR).
236  pub device_scale_factor: Option<f64>,
237  /// Simulate offline mode. Condition: `"offline"`.
238  pub offline: bool,
239  /// Enable JavaScript. Condition: `"!js"` for disabled.
240  pub java_script_enabled: bool,
241  /// Bypass CSP. Condition: `"bypass-csp"`.
242  pub bypass_csp: bool,
243  /// Accept downloads automatically.
244  pub accept_downloads: bool,
245  /// Custom user agent string.
246  pub user_agent: Option<String>,
247  /// Timezone ID (e.g., "America/New_York").
248  pub timezone_id: Option<String>,
249  /// Geolocation.
250  pub geolocation: Option<GeolocationConfig>,
251  /// Permissions to grant (e.g., ["geolocation", "notifications"]).
252  #[serde(default)]
253  pub permissions: Vec<String>,
254  /// Extra HTTP headers applied to every request.
255  #[serde(default)]
256  pub extra_http_headers: std::collections::BTreeMap<String, String>,
257  /// HTTP credentials for basic auth.
258  pub http_credentials: Option<HttpCredentialsConfig>,
259  /// Ignore HTTPS errors.
260  pub ignore_https_errors: bool,
261  /// Proxy settings.
262  pub proxy: Option<ProxyConfig>,
263  /// Service workers mode: "allow" (default) or "block".
264  pub service_workers: Option<String>,
265  /// Storage state at context level (overrides top-level).
266  pub storage_state: Option<String>,
267  /// Reduced motion: "reduce" or "no-preference".
268  pub reduced_motion: Option<String>,
269  /// Forced colors: "active" or "none".
270  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  /// Normalize browser↔backend consistency after all overrides are applied.
325  ///
326  /// Ensures `browser` and `backend` agree — like Playwright where `browserName`
327  /// is the single source of truth and the protocol is implicit.
328  ///
329  /// Rules:
330  /// - `backend = "bidi"` implies `browser = "firefox"` (BiDi is Firefox-only)
331  /// - `browser = "firefox"` implies `backend = "bidi"` (Firefox only speaks BiDi)
332  /// - `browser = "webkit"` implies `backend = "webkit"` on macOS
333  /// - Everything else defaults to `browser = "chromium"`, `backend = "cdp-pipe"`
334  pub fn normalize(&mut self) {
335    match self.backend.as_str() {
336      "bidi" => {
337        // BiDi backend is Firefox-only.
338        self.browser = "firefox".into();
339      },
340      "webkit" => {
341        self.browser = "webkit".into();
342      },
343      _ => {
344        // CDP backends — infer backend from browser if browser was set to non-chromium.
345        match self.browser.as_str() {
346          "firefox" => self.backend = "bidi".into(),
347          #[cfg(target_os = "macos")]
348          "webkit" => self.backend = "webkit".into(),
349          _ => {
350            // chromium + cdp-pipe/cdp-raw — no change needed.
351          },
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/// Snapshot update mode. Playwright: `updateSnapshots`.
397#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
398#[serde(rename_all = "lowercase")]
399pub enum UpdateSnapshotsMode {
400  /// Update all snapshots unconditionally.
401  All,
402  /// Only update changed snapshots.
403  Changed,
404  /// Only create missing snapshots (default).
405  #[default]
406  Missing,
407  /// Never update snapshots — always fail on mismatch or missing.
408  None,
409}
410
411/// Configuration for slow test reporting. Playwright: `reportSlowTests`.
412#[derive(Debug, Clone, Serialize, Deserialize)]
413#[serde(default)]
414pub struct ReportSlowTestsConfig {
415  /// Maximum number of slow tests to report. 0 = unlimited.
416  pub max: usize,
417  /// Duration threshold in ms. Tests slower than this are reported.
418  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/// Project configuration — matches Playwright's `TestProject`.
431#[derive(Debug, Clone, Serialize, Deserialize)]
432#[serde(default)]
433pub struct ProjectConfig {
434  pub name: String,
435  /// Test file glob patterns (per-project override).
436  pub test_match: Option<Vec<String>>,
437  /// Test file ignore patterns.
438  pub test_ignore: Option<Vec<String>>,
439  /// Root directory for test files.
440  pub test_dir: Option<String>,
441  /// Browser/context config (Playwright's `use` block).
442  pub browser: Option<BrowserConfig>,
443  /// Output directory for project artifacts.
444  pub output_dir: Option<String>,
445  /// Snapshot directory.
446  pub snapshot_dir: Option<String>,
447  pub retries: Option<u32>,
448  pub timeout: Option<u64>,
449  pub repeat_each: Option<u32>,
450  /// Run all tests in parallel.
451  pub fully_parallel: Option<bool>,
452  /// Filter by test title regex.
453  pub grep: Option<String>,
454  pub grep_invert: Option<String>,
455  /// Projects that must run before this one (setup dependencies).
456  pub dependencies: Vec<String>,
457  /// Project to run after this one (teardown).
458  pub teardown: Option<String>,
459  /// Arbitrary metadata.
460  #[serde(default)]
461  pub metadata: serde_json::Value,
462  /// Tags for project-level filtering.
463  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/// Web server configuration — matches Playwright's `webServer` option.
491///
492/// Two modes:
493/// - **Command**: spawn a process (e.g. `npm run dev`), wait for `url` to be reachable
494/// - **Static**: serve a directory over HTTP with SPA fallback support
495///
496/// The server's URL is injected as `base_url` if `base_url` is not already set.
497#[derive(Debug, Clone, Serialize, Deserialize)]
498#[serde(default)]
499pub struct WebServerConfig {
500  /// Shell command to start the server (e.g. `"npm run dev"`).
501  /// Mutually exclusive with `static_dir`.
502  pub command: Option<String>,
503
504  /// Directory to serve as static files. Mutually exclusive with `command`.
505  pub static_dir: Option<String>,
506
507  /// URL to wait for before starting tests. Required for `command` mode.
508  /// For `static_dir` mode, auto-assigned to `http://127.0.0.1:<random>`.
509  pub url: Option<String>,
510
511  /// Port to listen on. 0 = auto-assign. Only for `static_dir` mode.
512  pub port: u16,
513
514  /// Reuse an already running server at `url` instead of starting a new one.
515  pub reuse_existing_server: bool,
516
517  /// Timeout in ms for the server to be ready. Default: 30000.
518  pub timeout: u64,
519
520  /// Working directory for the command. Default: config file directory.
521  pub cwd: Option<String>,
522
523  /// Environment variables for the command.
524  #[serde(default)]
525  pub env: std::collections::BTreeMap<String, String>,
526
527  /// Enable SPA fallback: serve `index.html` for unmatched routes.
528  pub spa: bool,
529
530  /// Stdout disposition: "pipe" (capture), "ignore", "inherit". Default: "pipe".
531  pub stdout: Option<String>,
532
533  /// Stderr disposition: "pipe" (capture), "ignore", "inherit". Default: "pipe".
534  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/// CLI overrides that take highest priority.
556///
557/// All layers (CLI, NAPI, programmatic) should map their inputs into this struct
558/// and call `resolve_config()`, which is the single place that merges
559/// defaults < config file < env vars < overrides, auto-detects workers,
560/// and normalizes browser↔backend.
561#[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  /// Override test file glob patterns.
576  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  // ── Browser overrides ──
586  /// Browser product: "chromium", "firefox", "webkit".
587  pub browser: Option<String>,
588  /// Backend protocol: "cdp-pipe", "cdp-raw", "bidi", "webkit".
589  pub backend: Option<String>,
590  /// Browser channel: "chrome", "chrome-beta", "msedge".
591  pub channel: Option<String>,
592  /// Path to browser executable.
593  pub executable_path: Option<String>,
594  /// Extra browser launch arguments.
595  pub browser_args: Vec<String>,
596  /// Base URL for relative navigation.
597  pub base_url: Option<String>,
598  /// Viewport width override.
599  pub viewport_width: Option<i64>,
600  /// Viewport height override.
601  pub viewport_height: Option<i64>,
602  // ── Context overrides (Playwright `use` block) ──
603  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  // ── BDD-specific overrides (used by bdd_main!()) ──
610  /// Tag filter expression (e.g., "@smoke and not @wip").
611  pub bdd_tags: Option<String>,
612  /// Dry run: validate step definitions without executing.
613  pub bdd_dry_run: bool,
614  /// Strict mode: treat undefined/pending steps as errors.
615  pub bdd_strict: bool,
616  /// Stop on first scenario failure.
617  pub bdd_fail_fast: bool,
618  /// Per-step timeout in milliseconds.
619  pub bdd_step_timeout: Option<u64>,
620  /// Scenario execution order: "defined" (default) or "random" / "random:SEED".
621  pub bdd_order: Option<String>,
622  /// Default language for Gherkin keyword i18n (e.g., "fr", "de").
623  pub bdd_language: Option<String>,
624}
625
626/// Parse common CLI args from `std::env::args()` into `CliOverrides`.
627///
628/// Handles all flags shared between E2E tests and BDD tests, plus BDD-specific
629/// flags (--tags, --dry-run, --strict, --fail-fast, --step-timeout, --order, --language).
630/// BDD flags are silently ignored when running E2E tests.
631pub 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      // BDD-specific flags
671      "--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  /// Parse `"X/N"` format.
705  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
788/// Resolve the final config by merging: defaults < config file < env vars < CLI overrides.
789///
790/// # Errors
791///
792/// Returns an error if the config file cannot be read or parsed.
793pub 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  // Apply profile overrides.
801  if let Some(profile_name) = &overrides.profile {
802    if let Some(profile_value) = config.profiles.get(profile_name) {
803      // Deep merge profile into config by re-serializing.
804      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  // Apply environment variable overrides.
813  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  // Apply CLI overrides (highest priority).
833  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  // Context options.
884  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  // Environment variable: FERRIDRIVER_VIDEO=on|off|retain-on-failure
924  if let Ok(v) = std::env::var("FERRIDRIVER_VIDEO") {
925    config.video.mode = VideoMode::from_str(&v);
926  }
927  // Environment variable: FERRIDRIVER_TRACE=off|on|retain-on-failure|on-first-retry
928  if let Ok(t) = std::env::var("FERRIDRIVER_TRACE") {
929    config.trace = crate::tracing::TraceMode::from_str(&t);
930  }
931
932  // Auto-detect worker count.
933  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  // Normalize browser↔backend consistency after all overrides are applied.
941  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  // No config file found, use defaults.
962  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  /// Create a new config with project overrides merged on top.
978  ///
979  /// Follows Playwright's merge semantics: project fields override base config
980  /// when present, `browser`/`use` config is deep-merged, and the project name
981  /// is stored in metadata for reporter access.
982  #[must_use]
983  pub fn merge_project(&self, project: &ProjectConfig) -> Self {
984    let mut merged = self.clone();
985
986    // Project identity — stored in metadata for reporters.
987    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    // Test discovery overrides.
996    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    // Execution overrides.
1007    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    // Grep filters.
1021    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    // Output paths.
1029    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    // Browser/context config — deep merge: project browser overrides individual fields.
1037    if let Some(ref pb) = project.browser {
1038      if pb.browser != "chromium" || pb.backend != "cdp-pipe" {
1039        // Only override if explicitly set (non-default).
1040        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      // Deep-merge context config.
1062      merge_context(&mut merged.browser.context, &pb.context);
1063    }
1064
1065    // Re-normalize browser↔backend after merge.
1066    merged.browser.normalize();
1067
1068    // Clear projects list — merged config runs as a single project.
1069    merged.projects = Vec::new();
1070
1071    merged
1072  }
1073}
1074
1075/// Deep-merge context config: only override fields that differ from defaults.
1076fn 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}