Skip to main content

ferridriver_test/
config.rs

1//! Test runner configuration: re-exports the data types from `ferridriver-config`
2//! and adds runtime-only helpers (CLI argument parsing, override merging,
3//! environment-variable resolution).
4//!
5//! Programmatic suite hooks live on [`crate::model::TestHooks`] -- they cannot
6//! be part of `TestConfig` because their type closes over runtime fixture
7//! and failure types defined in this crate.
8
9pub use ferridriver_config::test::{
10  BrowserConfig, CliOverrides, ContextConfig, GeolocationConfig, GracefulShutdown, HttpCredentialsConfig,
11  ProjectConfig, ProxyConfig, ReportSlowTestsConfig, ReporterConfig, ShardArg, TestConfig, TraceMode,
12  UpdateSnapshotsMode, VideoConfig, VideoMode, ViewportConfig, WebServerConfig,
13};
14
15use std::path::Path;
16use std::path::PathBuf;
17
18// ── CLI parsing ─────────────────────────────────────────────────────────────
19
20/// Parse common CLI args from `std::env::args()` into [`CliOverrides`].
21///
22/// Handles all flags shared between E2E tests and BDD tests, plus BDD-specific
23/// flags (`--tags`, `--dry-run`, `--strict`, `--fail-fast`, `--step-timeout`,
24/// `--order`, `--language`). BDD flags are silently ignored when running
25/// non-BDD test runs.
26pub fn parse_common_cli_args() -> CliOverrides {
27  let args: Vec<String> = std::env::args().collect();
28  let mut overrides = CliOverrides::default();
29  let mut i = 1;
30  while i < args.len() {
31    match args[i].as_str() {
32      "--headless" => overrides.headless = true,
33      "--workers" | "-j" => {
34        i += 1;
35        overrides.workers = args.get(i).and_then(|v| v.parse().ok());
36      },
37      "--retries" => {
38        i += 1;
39        overrides.retries = args.get(i).and_then(|v| v.parse().ok());
40      },
41      "--timeout" => {
42        i += 1;
43        overrides.timeout = args.get(i).and_then(|v| v.parse().ok());
44      },
45      "--backend" => {
46        i += 1;
47        overrides.backend = args.get(i).cloned();
48      },
49      "--grep" | "-g" => {
50        i += 1;
51        overrides.grep = args.get(i).cloned();
52      },
53      "--tag" => {
54        i += 1;
55        overrides.tag = args.get(i).cloned();
56      },
57      "--list" => overrides.list_only = true,
58      "--update-snapshots" | "-u" => {
59        let mode = match args.get(i + 1).map(String::as_str) {
60          Some("all") => {
61            i += 1;
62            UpdateSnapshotsMode::All
63          },
64          Some("changed") => {
65            i += 1;
66            UpdateSnapshotsMode::Changed
67          },
68          Some("missing") => {
69            i += 1;
70            UpdateSnapshotsMode::Missing
71          },
72          Some("none") => {
73            i += 1;
74            UpdateSnapshotsMode::None
75          },
76          _ => UpdateSnapshotsMode::All,
77        };
78        overrides.update_snapshots = Some(mode);
79      },
80      "--forbid-only" => overrides.forbid_only = true,
81      "--last-failed" => overrides.last_failed = true,
82      "--max-failures" => {
83        i += 1;
84        overrides.max_failures = args.get(i).and_then(|v| v.parse().ok());
85      },
86      "--repeat-each" => {
87        i += 1;
88        overrides.repeat_each = args.get(i).and_then(|v| v.parse().ok());
89      },
90      "--global-timeout" => {
91        i += 1;
92        overrides.global_timeout = args.get(i).and_then(|v| v.parse().ok());
93      },
94      "-x" => overrides.fail_fast = true,
95      "--pass-with-no-tests" => overrides.pass_with_no_tests = true,
96      "--ignore-snapshots" => overrides.ignore_snapshots = true,
97      "--tsconfig" => {
98        i += 1;
99        overrides.tsconfig = args.get(i).cloned();
100      },
101      "--fully-parallel" => overrides.fully_parallel = Some(true),
102      "--project" => {
103        i += 1;
104        if let Some(name) = args.get(i) {
105          overrides.project_filter.push(name.clone());
106        }
107      },
108      "--no-deps" => overrides.no_deps = true,
109      "--teardown" => {
110        i += 1;
111        overrides.teardown = args.get(i).cloned();
112      },
113      "--only-changed" => {
114        let next = args.get(i + 1).cloned();
115        match next {
116          Some(value) if !value.starts_with('-') => {
117            i += 1;
118            overrides.only_changed = Some(value);
119          },
120          _ => overrides.only_changed = Some(String::new()),
121        }
122      },
123      "--fail-on-flaky-tests" => overrides.fail_on_flaky_tests = true,
124      "--profile" => {
125        i += 1;
126        overrides.profile = args.get(i).cloned();
127      },
128      "--tags" | "-t" => {
129        i += 1;
130        overrides.bdd_tags = args.get(i).cloned();
131      },
132      "--dry-run" => overrides.bdd_dry_run = true,
133      "--strict" => overrides.bdd_strict = true,
134      "--fail-fast" => overrides.bdd_fail_fast = true,
135      "--step-timeout" => {
136        i += 1;
137        overrides.bdd_step_timeout = args.get(i).and_then(|v| v.parse().ok());
138      },
139      "--order" => {
140        i += 1;
141        overrides.bdd_order = args.get(i).cloned();
142      },
143      "--language" => {
144        i += 1;
145        overrides.bdd_language = args.get(i).cloned();
146      },
147      _ => {},
148    }
149    i += 1;
150  }
151  overrides
152}
153
154// ── Config resolution ───────────────────────────────────────────────────────
155
156/// Resolve the final test config by merging: defaults < config file < env vars
157/// < CLI overrides.
158///
159/// `overrides.config_path`, when set, points at a unified `ferridriver.toml`;
160/// otherwise the standard search paths are tried via
161/// [`ferridriver_config::FerridriverConfig::load`].
162///
163/// # Errors
164///
165/// Returns an error if a config file is found but cannot be read or parsed.
166pub fn resolve_config(overrides: &CliOverrides) -> ferridriver::error::Result<TestConfig> {
167  use ferridriver::FerriError;
168  let cfg = if let Some(path) = &overrides.config_path {
169    ferridriver_config::FerridriverConfig::load_from(Path::new(path)).map_err(|e| FerriError::backend(e.to_string()))?
170  } else {
171    ferridriver_config::FerridriverConfig::load(None).map_err(|e| FerriError::backend(e.to_string()))?
172  };
173  resolve_config_from(cfg.test, overrides)
174}
175
176/// Apply profile, env, and CLI overrides to an already-loaded `TestConfig`.
177///
178/// Useful when the caller (e.g. the unified CLI) loads
179/// [`ferridriver_config::FerridriverConfig`] up front and only wants to layer
180/// runtime overrides on top of `cfg.test` without re-reading the config file.
181///
182/// # Errors
183///
184/// Returns an error if the named profile cannot be applied.
185pub fn resolve_config_from(mut config: TestConfig, overrides: &CliOverrides) -> ferridriver::error::Result<TestConfig> {
186  use ferridriver::FerriError;
187  // Apply profile overrides.
188  if let Some(profile_name) = &overrides.profile {
189    if let Some(profile_value) = config.profiles.get(profile_name) {
190      let mut base = serde_json::to_value(&config)?;
191      json_merge(&mut base, profile_value);
192      config = serde_json::from_value(base)?;
193    } else {
194      return Err(FerriError::invalid_argument(
195        "profile",
196        format!("profile '{profile_name}' not found in config"),
197      ));
198    }
199  }
200
201  // Environment variable overrides.
202  if let Ok(w) = std::env::var("FERRIDRIVER_WORKERS") {
203    if let Ok(v) = w.parse() {
204      config.workers = v;
205    }
206  }
207  if let Ok(r) = std::env::var("FERRIDRIVER_RETRIES") {
208    if let Ok(v) = r.parse() {
209      config.retries = v;
210    }
211  }
212  if let Ok(t) = std::env::var("FERRIDRIVER_TIMEOUT") {
213    if let Ok(v) = t.parse() {
214      config.timeout = v;
215    }
216  }
217  if let Ok(b) = std::env::var("FERRIDRIVER_BACKEND") {
218    config.browser.backend = b;
219  }
220
221  // CLI overrides (highest priority).
222  if let Some(w) = overrides.workers {
223    config.workers = w;
224  }
225  if let Some(t) = overrides.timeout {
226    config.timeout = t;
227  }
228  if let Some(r) = overrides.retries {
229    config.retries = r;
230  }
231  if !overrides.reporter.is_empty() {
232    config.reporter = overrides
233      .reporter
234      .iter()
235      .map(|name| ReporterConfig {
236        name: name.clone(),
237        options: std::collections::BTreeMap::new(),
238      })
239      .collect();
240  }
241  if overrides.headless {
242    config.browser.headless = true;
243  }
244  if let Some(ref b) = overrides.browser {
245    config.browser.browser.clone_from(b);
246  }
247  if let Some(ref b) = overrides.backend {
248    config.browser.backend.clone_from(b);
249  }
250  if let Some(ref ch) = overrides.channel {
251    config.browser.channel = Some(ch.clone());
252  }
253  if let Some(ref p) = overrides.executable_path {
254    config.browser.executable_path = Some(p.clone());
255  }
256  if !overrides.browser_args.is_empty() {
257    config.browser.args.clone_from(&overrides.browser_args);
258  }
259  if let Some(ref url) = overrides.base_url {
260    config.base_url = Some(url.clone());
261  }
262  if let Some(w) = overrides.viewport_width {
263    if let Some(ref mut vp) = config.browser.viewport {
264      vp.width = w;
265    }
266  }
267  if let Some(h) = overrides.viewport_height {
268    if let Some(ref mut vp) = config.browser.viewport {
269      vp.height = h;
270    }
271  }
272  if let Some(m) = overrides.is_mobile {
273    config.browser.use_options.is_mobile = m;
274  }
275  if let Some(t) = overrides.has_touch {
276    config.browser.use_options.has_touch = t;
277  }
278  if let Some(ref cs) = overrides.color_scheme {
279    config.browser.use_options.color_scheme = Some(cs.clone());
280  }
281  if let Some(ref l) = overrides.locale {
282    config.browser.use_options.locale = Some(l.clone());
283  }
284  if let Some(o) = overrides.offline {
285    config.browser.use_options.offline = o;
286  }
287  if let Some(b) = overrides.bypass_csp {
288    config.browser.use_options.bypass_csp = b;
289  }
290  if let Some(dir) = &overrides.output_dir {
291    config.output_dir = PathBuf::from(dir);
292  }
293  if let Some(ref patterns) = overrides.test_match {
294    config.test_match.clone_from(patterns);
295  }
296  if overrides.forbid_only {
297    config.forbid_only = true;
298  }
299  if let Some(video) = &overrides.video {
300    config.video.mode = VideoMode::parse_label(video);
301  }
302  if let Some(trace) = &overrides.trace {
303    config.trace = TraceMode::parse_label(trace);
304  }
305  if let Some(ref ss) = overrides.storage_state {
306    config.storage_state = Some(ss.clone());
307  }
308  if let Some(mode) = overrides.update_snapshots {
309    config.update_snapshots = mode;
310  }
311  if let Some(n) = overrides.max_failures {
312    config.max_failures = n;
313  }
314  if let Some(n) = overrides.repeat_each {
315    config.repeat_each = n;
316  }
317  if overrides.fail_fast {
318    config.fail_fast = true;
319  }
320  if let Some(t) = overrides.global_timeout {
321    config.global_timeout = t;
322  }
323  if overrides.ignore_snapshots {
324    config.ignore_snapshots = true;
325  }
326  if overrides.pass_with_no_tests {
327    config.pass_with_no_tests = true;
328  }
329  if let Some(ref ts) = overrides.tsconfig {
330    config.tsconfig = Some(ts.clone());
331  }
332  if let Some(ref n) = overrides.name {
333    config.name = Some(n.clone());
334  }
335  if let Some(p) = overrides.fully_parallel {
336    config.fully_parallel = p;
337  }
338  if overrides.fail_on_flaky_tests {
339    config.fail_on_flaky_tests = true;
340  }
341  if let Ok(t) = std::env::var("FERRIDRIVER_GLOBAL_TIMEOUT") {
342    if let Ok(v) = t.parse() {
343      config.global_timeout = v;
344    }
345  }
346  if let Ok(v) = std::env::var("FERRIDRIVER_VIDEO") {
347    config.video.mode = VideoMode::parse_label(&v);
348  }
349  if let Ok(t) = std::env::var("FERRIDRIVER_TRACE") {
350    config.trace = TraceMode::parse_label(&t);
351  }
352
353  // Auto-detect worker count.
354  if config.workers == 0 {
355    let cpus = std::thread::available_parallelism()
356      .map(|n| n.get() as u32)
357      .unwrap_or(4);
358    config.workers = (cpus / 2).max(1);
359  }
360
361  // Normalize browser↔backend consistency after all overrides are applied.
362  config.browser.normalize();
363
364  Ok(config)
365}
366
367fn json_merge(base: &mut serde_json::Value, overlay: &serde_json::Value) {
368  match (base, overlay) {
369    (serde_json::Value::Object(base_map), serde_json::Value::Object(overlay_map)) => {
370      for (key, value) in overlay_map {
371        if let Some(existing) = base_map.get_mut(key) {
372          json_merge(existing, value);
373        } else {
374          base_map.insert(key.clone(), value.clone());
375        }
376      }
377    },
378    (base, _) => {
379      *base = overlay.clone();
380    },
381  }
382}