Skip to main content

ferridriver_test/
discovery.rs

1//! Test discovery: inventory-based collection for Rust, glob-based file scanning.
2
3use std::future::Future;
4use std::pin::Pin;
5use std::sync::Arc;
6
7use crate::config::TestConfig;
8use crate::fixture::FixturePool;
9use std::fmt;
10use std::path::Path;
11
12use crate::model::{
13  ExpectedStatus, Hooks, TestAnnotation, TestCase, TestFailure, TestId, TestInfo, TestPlan, TestSuite,
14};
15
16// ── Inventory-based registration (populated by #[ferritest] macro) ──
17
18/// What the `#[ferritest]` proc macro submits via `inventory::submit!`.
19pub struct TestRegistration {
20  pub file: &'static str,
21  /// The `module_path!()` of the test function.
22  /// Used to derive the suite name from the Rust module structure.
23  pub module_path: &'static str,
24  pub name: &'static str,
25  pub fixture_requests: &'static [&'static str],
26  pub annotations: &'static [TestAnnotation],
27  pub timeout_ms: Option<u64>,
28  pub retries: Option<u32>,
29  /// Raw JSON string for fixture/context overrides (viewport, locale, etc.)
30  pub use_options: Option<&'static str>,
31  pub test_fn: fn(FixturePool) -> Pin<Box<dyn Future<Output = Result<(), TestFailure>> + Send>>,
32}
33
34inventory::collect!(TestRegistration);
35
36/// Hook kind tag for inventory registration (no closures — just the discriminant).
37#[derive(Debug, Clone, Copy)]
38pub enum HookKindTag {
39  BeforeAll,
40  AfterAll,
41  BeforeEach,
42  AfterEach,
43}
44
45/// What `#[before_all]` / `#[after_all]` / `#[before_each]` / `#[after_each]` submit.
46pub struct HookRegistration {
47  pub module_path: &'static str,
48  /// For before_all/after_all: `fn(FixturePool) -> Future<Result<(), TestFailure>>`
49  pub suite_hook_fn: Option<fn(FixturePool) -> Pin<Box<dyn Future<Output = Result<(), TestFailure>> + Send>>>,
50  /// For before_each/after_each: `fn(FixturePool, Arc<TestInfo>) -> Future<Result<(), TestFailure>>`
51  pub each_hook_fn:
52    Option<fn(FixturePool, Arc<TestInfo>) -> Pin<Box<dyn Future<Output = Result<(), TestFailure>> + Send>>>,
53  pub kind: HookKindTag,
54}
55
56inventory::collect!(HookRegistration);
57
58/// What the `#[fixture]` proc macro submits via `inventory::submit!`.
59///
60/// `build` is a plain fn pointer (const-storable in the inventory static)
61/// that constructs the heap-allocated [`crate::fixture::FixtureDef`] at collection time.
62pub struct FixtureRegistration {
63  pub name: &'static str,
64  pub module_path: &'static str,
65  pub build: fn() -> crate::fixture::FixtureDef,
66}
67
68inventory::collect!(FixtureRegistration);
69
70/// What the `#[ferritest_suite]` proc macro submits via `inventory::submit!`.
71/// Sets the execution mode of the suite derived from `module_path`.
72pub struct SuiteModeRegistration {
73  pub module_path: &'static str,
74  pub mode: crate::model::SuiteMode,
75}
76
77inventory::collect!(SuiteModeRegistration);
78
79/// Collect every `#[fixture]`-registered custom fixture into a defs map,
80/// keyed by fixture name. Merged into the worker fixture pool so tests and
81/// other fixtures can resolve them via `ctx.get::<T>(name)`.
82pub fn collect_rust_fixtures() -> rustc_hash::FxHashMap<String, crate::fixture::FixtureDef> {
83  let mut defs = rustc_hash::FxHashMap::default();
84  for reg in inventory::iter::<FixtureRegistration> {
85    defs.insert(reg.name.to_string(), (reg.build)());
86  }
87  defs
88}
89
90// ── Discovery ──
91
92/// Derive suite name from `module_path!()`.
93///
94/// `module_path!()` expands to the MODULE path (it never includes the
95/// function name), so `"my_crate::login_tests"` -> `"login_tests"`. We strip
96/// only the crate root; the remainder is the suite. A top-level test (no
97/// enclosing module) keeps the crate name as its suite.
98fn suite_from_module_path(mp: &str) -> &str {
99  mp.split_once("::").map_or(mp, |(_, rest)| rest)
100}
101
102/// Collect all registered tests and build a `TestPlan`.
103pub fn collect_rust_tests(config: &TestConfig) -> TestPlan {
104  let mut suites: rustc_hash::FxHashMap<String, TestSuite> = rustc_hash::FxHashMap::default();
105
106  for reg in inventory::iter::<TestRegistration> {
107    let file = reg.file.to_string();
108    // Derive suite name from module_path: strip the last segment (fn name).
109    let suite_name = suite_from_module_path(reg.module_path);
110    let suite_key = format!("{}::{}", file, suite_name);
111
112    let test_fn_ptr = reg.test_fn;
113    let test_case: TestCase = TestCase {
114      id: TestId {
115        file: file.clone(),
116        suite: Some(suite_name.to_string()),
117        name: reg.name.to_string(),
118        line: None,
119      },
120      test_fn: Arc::new(move |pool| test_fn_ptr(pool)),
121      fixture_requests: reg.fixture_requests.iter().map(|s| (*s).to_string()).collect(),
122      annotations: reg.annotations.to_vec(),
123      timeout: reg.timeout_ms.map(std::time::Duration::from_millis),
124      retries: reg.retries,
125      expected_status: ExpectedStatus::Pass,
126      use_options: reg.use_options.map(|s| serde_json::from_str(s).unwrap_or_default()),
127    };
128
129    let suite = suites.entry(suite_key).or_insert_with(|| TestSuite {
130      name: suite_name.to_string(),
131      file: file.clone(),
132      tests: Vec::new(),
133      hooks: Hooks::default(),
134      annotations: Vec::new(),
135      mode: crate::model::SuiteMode::default(),
136    });
137    suite.tests.push(test_case);
138  }
139
140  // Collect hooks and attach them to matching suites.
141  for reg in inventory::iter::<HookRegistration> {
142    let hook_suite = suite_from_module_path(reg.module_path);
143    // Find the matching suite — hooks attach to the suite derived from their module.
144    for suite in suites.values_mut() {
145      if suite.name == hook_suite {
146        match reg.kind {
147          HookKindTag::BeforeAll => {
148            if let Some(f) = reg.suite_hook_fn {
149              suite.hooks.before_all.push(Arc::new(move |pool| f(pool)));
150            }
151          },
152          HookKindTag::AfterAll => {
153            if let Some(f) = reg.suite_hook_fn {
154              suite.hooks.after_all.push(Arc::new(move |pool| f(pool)));
155            }
156          },
157          HookKindTag::BeforeEach => {
158            if let Some(f) = reg.each_hook_fn {
159              suite.hooks.before_each.push(Arc::new(move |pool, info| f(pool, info)));
160            }
161          },
162          HookKindTag::AfterEach => {
163            if let Some(f) = reg.each_hook_fn {
164              suite.hooks.after_each.push(Arc::new(move |pool, info| f(pool, info)));
165            }
166          },
167        }
168      }
169    }
170  }
171
172  // Apply explicit suite modes from `#[ferritest_suite]`, keyed by the same
173  // module-derived suite name `#[ferritest]` registrations use.
174  for reg in inventory::iter::<SuiteModeRegistration> {
175    let reg_suite = suite_from_module_path(reg.module_path);
176    for suite in suites.values_mut() {
177      if suite.name == reg_suite {
178        suite.mode = reg.mode;
179      }
180    }
181  }
182
183  let suites: Vec<TestSuite> = suites.into_values().collect();
184  let total_tests = suites.iter().map(|s| s.tests.len()).sum();
185
186  apply_filters(
187    TestPlan {
188      suites,
189      total_tests,
190      shard: None,
191    },
192    config,
193  )
194}
195
196/// Discover test files on disk using glob patterns.
197///
198/// # Errors
199///
200/// Returns an error if glob pattern is invalid.
201pub fn find_test_files(root: &str, patterns: &[String], ignore: &[String]) -> Result<Vec<String>, String> {
202  let mut files = Vec::new();
203
204  for pattern in patterns {
205    let full_pattern = if pattern.starts_with('/') || pattern.starts_with('.') {
206      pattern.clone()
207    } else {
208      format!("{root}/{pattern}")
209    };
210
211    let entries = glob::glob(&full_pattern).map_err(|e| format!("invalid glob pattern '{full_pattern}': {e}"))?;
212
213    for entry in entries {
214      let path = entry.map_err(|e| format!("glob error: {e}"))?;
215      let path_str = path.display().to_string();
216
217      // Check ignore patterns.
218      let ignored = ignore
219        .iter()
220        .any(|ig| glob::Pattern::new(ig).map(|p| p.matches(&path_str)).unwrap_or(false));
221
222      if !ignored {
223        files.push(path_str);
224      }
225    }
226  }
227
228  files.sort();
229  files.dedup();
230  Ok(files)
231}
232
233/// Apply grep, tag, and other filters to a test plan.
234fn apply_filters(mut plan: TestPlan, _config: &TestConfig) -> TestPlan {
235  // Grep is applied at runtime via CLI, not from config file typically.
236  // This is a placeholder for the runner to call with CliOverrides.
237  plan.total_tests = plan.suites.iter().map(|s| s.tests.len()).sum();
238  plan
239}
240
241/// Filter a test plan by grep pattern.
242pub fn filter_by_grep(plan: &mut TestPlan, pattern: &str, invert: bool) {
243  // Build a case-insensitive regex. If the pattern has invalid regex syntax,
244  // fall back to case-insensitive literal substring match.
245  let re = regex::RegexBuilder::new(pattern).case_insensitive(true).build().ok();
246  let pattern_lower = pattern.to_lowercase();
247
248  for suite in &mut plan.suites {
249    suite.tests.retain(|test| {
250      let full_name = test.id.full_name();
251      let matches = if let Some(ref r) = re {
252        r.is_match(&full_name)
253      } else {
254        // Fallback: case-insensitive substring search.
255        full_name.to_lowercase().contains(&pattern_lower)
256      };
257      if invert { !matches } else { matches }
258    });
259  }
260  plan.suites.retain(|s| !s.tests.is_empty());
261  plan.total_tests = plan.suites.iter().map(|s| s.tests.len()).sum();
262}
263
264/// Error returned when `--forbid-only` is set and `.only()` markers are found.
265pub struct ForbidOnlyError {
266  pub tests: Vec<String>,
267}
268
269impl fmt::Display for ForbidOnlyError {
270  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
271    writeln!(f, "Error: test.only() found in {} test(s):", self.tests.len())?;
272    for name in &self.tests {
273      writeln!(f, "  {name}")?;
274    }
275    Ok(())
276  }
277}
278
279/// Check that no tests or suites have `Only` annotations.
280/// Returns `Err` listing all offending tests if any are found.
281pub fn check_forbid_only(plan: &TestPlan) -> Result<(), ForbidOnlyError> {
282  let mut only_tests: Vec<String> = Vec::new();
283
284  for suite in &plan.suites {
285    let suite_is_only = suite.annotations.iter().any(|a| matches!(a, TestAnnotation::Only));
286    for test in &suite.tests {
287      let test_is_only = test.annotations.iter().any(|a| matches!(a, TestAnnotation::Only));
288      if suite_is_only || test_is_only {
289        only_tests.push(test.id.full_name());
290      }
291    }
292  }
293
294  if only_tests.is_empty() {
295    Ok(())
296  } else {
297    Err(ForbidOnlyError { tests: only_tests })
298  }
299}
300
301/// Filter a test plan to only `Only`-marked tests/suites.
302/// If no `Only` annotations exist, the plan is unchanged.
303pub fn filter_by_only(plan: &mut TestPlan) {
304  let has_only = plan.suites.iter().any(|suite| {
305    suite.annotations.iter().any(|a| matches!(a, TestAnnotation::Only))
306      || suite
307        .tests
308        .iter()
309        .any(|t| t.annotations.iter().any(|a| matches!(a, TestAnnotation::Only)))
310  });
311
312  if !has_only {
313    return;
314  }
315
316  for suite in &mut plan.suites {
317    let suite_is_only = suite.annotations.iter().any(|a| matches!(a, TestAnnotation::Only));
318    if !suite_is_only {
319      suite
320        .tests
321        .retain(|t| t.annotations.iter().any(|a| matches!(a, TestAnnotation::Only)));
322    }
323  }
324  plan.suites.retain(|s| !s.tests.is_empty());
325  plan.total_tests = plan.suites.iter().map(|s| s.tests.len()).sum();
326}
327
328/// Filter a test plan to only tests listed in a rerun file.
329/// The rerun file contains one `file:line` or `file > suite > name` entry per line.
330/// If the file doesn't exist or is empty, logs a warning and runs all tests.
331pub fn filter_by_rerun(plan: &mut TestPlan, rerun_path: &Path) {
332  let content = match std::fs::read_to_string(rerun_path) {
333    Ok(c) if !c.trim().is_empty() => c,
334    Ok(_) => {
335      tracing::warn!("rerun file {} is empty, running all tests", rerun_path.display());
336      return;
337    },
338    Err(_) => {
339      tracing::warn!("rerun file {} not found, running all tests", rerun_path.display());
340      return;
341    },
342  };
343
344  let rerun_set: rustc_hash::FxHashSet<String> = content
345    .lines()
346    .map(|l| l.trim().to_string())
347    .filter(|l| !l.is_empty())
348    .collect();
349
350  for suite in &mut plan.suites {
351    suite
352      .tests
353      .retain(|test| rerun_set.contains(&test.id.file_location()) || rerun_set.contains(&test.id.full_name()));
354  }
355  plan.suites.retain(|s| !s.tests.is_empty());
356  plan.total_tests = plan.suites.iter().map(|s| s.tests.len()).sum();
357}
358
359/// Filter a test plan by tag.
360pub fn filter_by_tag(plan: &mut TestPlan, tag: &str) {
361  for suite in &mut plan.suites {
362    suite.tests.retain(|test| {
363      test
364        .annotations
365        .iter()
366        .any(|a| matches!(a, TestAnnotation::Tag(t) if t == tag))
367    });
368  }
369  plan.suites.retain(|s| !s.tests.is_empty());
370  plan.total_tests = plan.suites.iter().map(|s| s.tests.len()).sum();
371}
372
373#[cfg(test)]
374mod tests {
375  use super::*;
376  use crate::model::{ExpectedStatus, Hooks, TestCase, TestPlan, TestSuite};
377
378  fn dummy_test(name: &str, annotations: Vec<TestAnnotation>) -> TestCase {
379    TestCase {
380      id: TestId {
381        file: "test.rs".into(),
382        suite: Some("suite".into()),
383        name: name.into(),
384        line: None,
385      },
386      test_fn: Arc::new(|_| Box::pin(async { Ok(()) })),
387      fixture_requests: vec![],
388      annotations,
389      timeout: None,
390      retries: None,
391      expected_status: ExpectedStatus::Pass,
392      use_options: None,
393    }
394  }
395
396  fn make_plan(tests: Vec<TestCase>, suite_annotations: Vec<TestAnnotation>) -> TestPlan {
397    let total = tests.len();
398    TestPlan {
399      suites: vec![TestSuite {
400        name: "suite".into(),
401        file: "test.rs".into(),
402        tests,
403        hooks: Hooks::default(),
404        annotations: suite_annotations,
405        mode: crate::model::SuiteMode::default(),
406      }],
407      total_tests: total,
408      shard: None,
409    }
410  }
411
412  #[test]
413  fn forbid_only_no_only_markers() {
414    let plan = make_plan(vec![dummy_test("test1", vec![]), dummy_test("test2", vec![])], vec![]);
415    assert!(check_forbid_only(&plan).is_ok());
416  }
417
418  #[test]
419  fn forbid_only_detects_test_level_only() {
420    let plan = make_plan(
421      vec![
422        dummy_test("normal", vec![]),
423        dummy_test("focused", vec![TestAnnotation::Only]),
424      ],
425      vec![],
426    );
427    let err = check_forbid_only(&plan).unwrap_err();
428    assert_eq!(err.tests.len(), 1);
429    assert!(err.tests[0].contains("focused"));
430  }
431
432  #[test]
433  fn forbid_only_detects_suite_level_only() {
434    let plan = make_plan(
435      vec![dummy_test("test1", vec![]), dummy_test("test2", vec![])],
436      vec![TestAnnotation::Only],
437    );
438    let err = check_forbid_only(&plan).unwrap_err();
439    assert_eq!(err.tests.len(), 2);
440  }
441
442  #[test]
443  fn filter_by_only_keeps_only_marked_tests() {
444    let mut plan = make_plan(
445      vec![
446        dummy_test("normal1", vec![]),
447        dummy_test("focused", vec![TestAnnotation::Only]),
448        dummy_test("normal2", vec![]),
449      ],
450      vec![],
451    );
452    filter_by_only(&mut plan);
453    assert_eq!(plan.total_tests, 1);
454    assert_eq!(plan.suites[0].tests[0].id.name, "focused");
455  }
456
457  #[test]
458  fn filter_by_only_no_only_keeps_all() {
459    let mut plan = make_plan(vec![dummy_test("test1", vec![]), dummy_test("test2", vec![])], vec![]);
460    filter_by_only(&mut plan);
461    assert_eq!(plan.total_tests, 2);
462  }
463
464  #[test]
465  fn filter_by_only_suite_level_keeps_all_in_suite() {
466    let mut plan = make_plan(
467      vec![dummy_test("test1", vec![]), dummy_test("test2", vec![])],
468      vec![TestAnnotation::Only],
469    );
470    filter_by_only(&mut plan);
471    assert_eq!(plan.total_tests, 2);
472  }
473
474  #[test]
475  fn forbid_only_error_message_format() {
476    let plan = make_plan(vec![dummy_test("focused", vec![TestAnnotation::Only])], vec![]);
477    let err = check_forbid_only(&plan).unwrap_err();
478    let msg = err.to_string();
479    assert!(msg.contains("test.only() found in 1 test(s)"));
480    assert!(msg.contains("focused"));
481  }
482
483  #[test]
484  fn filter_by_rerun_keeps_matching_tests() {
485    let dir = std::env::temp_dir().join("ferritest_rerun_test");
486    std::fs::create_dir_all(&dir).unwrap();
487    let rerun_path = dir.join("@rerun.txt");
488    std::fs::write(&rerun_path, "test.rs:10\n").unwrap();
489
490    let mut plan = make_plan(
491      vec![
492        {
493          let mut t = dummy_test("match", vec![]);
494          t.id.line = Some(10);
495          t
496        },
497        dummy_test("nomatch", vec![]),
498      ],
499      vec![],
500    );
501    filter_by_rerun(&mut plan, &rerun_path);
502    assert_eq!(plan.total_tests, 1);
503    assert_eq!(plan.suites[0].tests[0].id.name, "match");
504
505    std::fs::remove_dir_all(&dir).ok();
506  }
507
508  #[test]
509  fn filter_by_rerun_missing_file_keeps_all() {
510    let mut plan = make_plan(vec![dummy_test("test1", vec![]), dummy_test("test2", vec![])], vec![]);
511    filter_by_rerun(&mut plan, Path::new("/nonexistent/@rerun.txt"));
512    assert_eq!(plan.total_tests, 2);
513  }
514
515  #[test]
516  fn filter_by_rerun_empty_file_keeps_all() {
517    let dir = std::env::temp_dir().join("ferritest_rerun_empty");
518    std::fs::create_dir_all(&dir).unwrap();
519    let rerun_path = dir.join("@rerun.txt");
520    std::fs::write(&rerun_path, "  \n").unwrap();
521
522    let mut plan = make_plan(vec![dummy_test("test1", vec![]), dummy_test("test2", vec![])], vec![]);
523    filter_by_rerun(&mut plan, &rerun_path);
524    assert_eq!(plan.total_tests, 2);
525
526    std::fs::remove_dir_all(&dir).ok();
527  }
528
529  #[test]
530  fn filter_by_rerun_matches_full_name() {
531    let dir = std::env::temp_dir().join("ferritest_rerun_fullname");
532    std::fs::create_dir_all(&dir).unwrap();
533    let rerun_path = dir.join("@rerun.txt");
534    std::fs::write(&rerun_path, "test.rs > suite > focused\n").unwrap();
535
536    let mut plan = make_plan(vec![dummy_test("focused", vec![]), dummy_test("other", vec![])], vec![]);
537    filter_by_rerun(&mut plan, &rerun_path);
538    assert_eq!(plan.total_tests, 1);
539    assert_eq!(plan.suites[0].tests[0].id.name, "focused");
540
541    std::fs::remove_dir_all(&dir).ok();
542  }
543}