file_test_runner/collection/
mod.rs

1// Copyright 2018-2025 the Deno authors. MIT license.
2
3use std::path::PathBuf;
4
5use deno_terminal::colors;
6use thiserror::Error;
7
8use crate::PathedIoError;
9
10use self::strategies::TestCollectionStrategy;
11
12pub mod strategies;
13
14#[derive(Debug, Clone)]
15pub enum CollectedCategoryOrTest<T = ()> {
16  Category(CollectedTestCategory<T>),
17  Test(CollectedTest<T>),
18}
19
20#[derive(Debug, Clone)]
21pub struct CollectedTestCategory<T = ()> {
22  /// Fully resolved name of the test category.
23  pub name: String,
24  /// Path to the test category. May be a file or directory
25  /// depending on how the test strategy collects tests.
26  pub path: PathBuf,
27  /// Children of the category.
28  pub children: Vec<CollectedCategoryOrTest<T>>,
29}
30
31impl<T> CollectedTestCategory<T> {
32  pub fn test_count(&self) -> usize {
33    self
34      .children
35      .iter()
36      .map(|child| match child {
37        CollectedCategoryOrTest::Category(c) => c.test_count(),
38        CollectedCategoryOrTest::Test(_) => 1,
39      })
40      .sum()
41  }
42
43  pub fn filter_children(&mut self, filter: &str) {
44    self.children.retain_mut(|mut child| match &mut child {
45      CollectedCategoryOrTest::Category(c) => {
46        c.filter_children(filter);
47        !c.is_empty()
48      }
49      CollectedCategoryOrTest::Test(t) => t.name.contains(filter),
50    });
51  }
52
53  pub fn is_empty(&self) -> bool {
54    for child in &self.children {
55      match child {
56        CollectedCategoryOrTest::Category(category) => {
57          if !category.is_empty() {
58            return false;
59          }
60        }
61        CollectedCategoryOrTest::Test(_) => {
62          return false;
63        }
64      }
65    }
66
67    true
68  }
69
70  /// Flattens all nested categories and returns a new category containing only tests as direct children.
71  /// All subcategories are removed and their tests are moved to the top level.
72  pub fn into_flat_category(self) -> Self {
73    let mut flattened_tests = Vec::new();
74
75    fn collect_tests<T>(
76      children: Vec<CollectedCategoryOrTest<T>>,
77      output: &mut Vec<CollectedCategoryOrTest<T>>,
78    ) {
79      for child in children {
80        match child {
81          CollectedCategoryOrTest::Category(category) => {
82            collect_tests(category.children, output);
83          }
84          CollectedCategoryOrTest::Test(test) => {
85            output.push(CollectedCategoryOrTest::Test(test));
86          }
87        }
88      }
89    }
90
91    collect_tests(self.children, &mut flattened_tests);
92
93    CollectedTestCategory {
94      name: self.name,
95      path: self.path,
96      children: flattened_tests,
97    }
98  }
99
100  /// Splits this category into two separate categories based on a predicate.
101  /// The first category contains tests matching the predicate, the second contains those that don't.
102  /// Both categories preserve the same name and path as the original.
103  pub fn partition<F>(self, predicate: F) -> (Self, Self)
104  where
105    F: Fn(&CollectedTest<T>) -> bool + Copy,
106  {
107    let mut matching_children = Vec::new();
108    let mut non_matching_children = Vec::new();
109
110    for child in self.children {
111      match child {
112        CollectedCategoryOrTest::Category(category) => {
113          let (matching_cat, non_matching_cat) = category.partition(predicate);
114          if !matching_cat.is_empty() {
115            matching_children
116              .push(CollectedCategoryOrTest::Category(matching_cat));
117          }
118          if !non_matching_cat.is_empty() {
119            non_matching_children
120              .push(CollectedCategoryOrTest::Category(non_matching_cat));
121          }
122        }
123        CollectedCategoryOrTest::Test(test) => {
124          if predicate(&test) {
125            matching_children.push(CollectedCategoryOrTest::Test(test));
126          } else {
127            non_matching_children.push(CollectedCategoryOrTest::Test(test));
128          }
129        }
130      }
131    }
132
133    let matching = CollectedTestCategory {
134      name: self.name.clone(),
135      path: self.path.clone(),
136      children: matching_children,
137    };
138
139    let non_matching = CollectedTestCategory {
140      name: self.name,
141      path: self.path,
142      children: non_matching_children,
143    };
144
145    (matching, non_matching)
146  }
147}
148
149#[derive(Debug, Clone)]
150pub struct CollectedTest<T = ()> {
151  /// Fully resolved name of the test.
152  pub name: String,
153  /// Path to the test file.
154  pub path: PathBuf,
155  /// Zero-indexed line and column of the test in the file.
156  pub line_and_column: Option<(u32, u32)>,
157  /// Data associated with the test that may have been
158  /// set by the collection strategy.
159  pub data: T,
160}
161
162impl<T> CollectedTest<T> {
163  /// Helper to read the test file to a string.
164  pub fn read_to_string(&self) -> Result<String, PathedIoError> {
165    std::fs::read_to_string(&self.path)
166      .map_err(|err| PathedIoError::new(&self.path, err))
167  }
168}
169
170pub struct CollectOptions<TData> {
171  /// Base path to start from when searching for tests.
172  pub base: PathBuf,
173  /// Strategy to use for collecting tests.
174  pub strategy: Box<dyn TestCollectionStrategy<TData>>,
175  /// Override the filter provided on the command line.
176  ///
177  /// Generally, just provide `None` here.
178  pub filter_override: Option<String>,
179}
180
181/// Collect all the tests or exit if there are any errors.
182pub fn collect_tests_or_exit<TData>(
183  options: CollectOptions<TData>,
184) -> CollectedTestCategory<TData> {
185  match collect_tests(options) {
186    Ok(category) => category,
187    Err(err) => {
188      eprintln!("{}: {}", colors::red_bold("error"), err);
189      std::process::exit(1);
190    }
191  }
192}
193
194#[derive(Debug, Error)]
195pub enum CollectTestsError {
196  #[error(transparent)]
197  InvalidTestName(#[from] InvalidTestNameError),
198  #[error(transparent)]
199  Io(#[from] PathedIoError),
200  #[error("No tests found")]
201  NoTestsFound,
202  #[error(transparent)]
203  Other(#[from] anyhow::Error),
204}
205
206pub fn collect_tests<TData>(
207  options: CollectOptions<TData>,
208) -> Result<CollectedTestCategory<TData>, CollectTestsError> {
209  let mut category = options.strategy.collect_tests(&options.base)?;
210
211  // error when no tests are found before filtering
212  if category.is_empty() {
213    return Err(CollectTestsError::NoTestsFound);
214  }
215
216  // ensure all test names are valid
217  ensure_valid_test_names(&category)?;
218
219  // filter
220  let maybe_filter = options.filter_override.or_else(parse_cli_arg_filter);
221  if let Some(filter) = &maybe_filter {
222    category.filter_children(filter);
223  }
224
225  Ok(category)
226}
227
228fn ensure_valid_test_names<TData>(
229  category: &CollectedTestCategory<TData>,
230) -> Result<(), InvalidTestNameError> {
231  for child in &category.children {
232    match child {
233      CollectedCategoryOrTest::Category(category) => {
234        ensure_valid_test_names(category)?;
235      }
236      CollectedCategoryOrTest::Test(test) => {
237        // only support characters that work with filtering with `cargo test`
238        if !test
239          .name
240          .chars()
241          .all(|c| c.is_alphanumeric() || matches!(c, '_' | ':'))
242        {
243          return Err(InvalidTestNameError(test.name.clone()));
244        }
245      }
246    }
247  }
248  Ok(())
249}
250
251#[derive(Debug, Error)]
252#[error(
253  "Invalid test name ({0}). Use only alphanumeric and underscore characters so tests can be filtered via the command line."
254)]
255pub struct InvalidTestNameError(String);
256
257/// Parses the filter from the CLI args. This can be used
258/// with `category.filter_children(filter)`.
259pub fn parse_cli_arg_filter() -> Option<String> {
260  std::env::args()
261    .nth(1)
262    .filter(|s| !s.starts_with('-') && !s.is_empty())
263}
264
265#[cfg(test)]
266mod tests {
267  use super::*;
268
269  #[test]
270  fn test_partition() {
271    // Create a test category with nested structure
272    let category = CollectedTestCategory {
273      name: "root".to_string(),
274      path: PathBuf::from("/root"),
275      children: vec![
276        CollectedCategoryOrTest::Test(CollectedTest {
277          name: "test_foo".to_string(),
278          path: PathBuf::from("/root/foo.rs"),
279          line_and_column: None,
280          data: (),
281        }),
282        CollectedCategoryOrTest::Test(CollectedTest {
283          name: "test_bar".to_string(),
284          path: PathBuf::from("/root/bar.rs"),
285          line_and_column: None,
286          data: (),
287        }),
288        CollectedCategoryOrTest::Category(CollectedTestCategory {
289          name: "nested".to_string(),
290          path: PathBuf::from("/root/nested"),
291          children: vec![
292            CollectedCategoryOrTest::Test(CollectedTest {
293              name: "test_baz".to_string(),
294              path: PathBuf::from("/root/nested/baz.rs"),
295              line_and_column: None,
296              data: (),
297            }),
298            CollectedCategoryOrTest::Test(CollectedTest {
299              name: "test_qux".to_string(),
300              path: PathBuf::from("/root/nested/qux.rs"),
301              line_and_column: None,
302              data: (),
303            }),
304          ],
305        }),
306      ],
307    };
308
309    // Partition based on whether name contains "ba"
310    let (matching, non_matching) =
311      category.partition(|test| test.name.contains("ba"));
312
313    // Check matching category
314    assert_eq!(matching.name, "root");
315    assert_eq!(matching.path, PathBuf::from("/root"));
316    assert_eq!(matching.test_count(), 2);
317
318    // Check that matching contains test_bar and nested/test_baz
319    assert_eq!(matching.children.len(), 2);
320    match &matching.children[0] {
321      CollectedCategoryOrTest::Test(test) => assert_eq!(test.name, "test_bar"),
322      _ => panic!("Expected test"),
323    }
324    match &matching.children[1] {
325      CollectedCategoryOrTest::Category(cat) => {
326        assert_eq!(cat.name, "nested");
327        assert_eq!(cat.children.len(), 1);
328        match &cat.children[0] {
329          CollectedCategoryOrTest::Test(test) => {
330            assert_eq!(test.name, "test_baz")
331          }
332          _ => panic!("Expected test"),
333        }
334      }
335      _ => panic!("Expected category"),
336    }
337
338    // Check non-matching category
339    assert_eq!(non_matching.name, "root");
340    assert_eq!(non_matching.path, PathBuf::from("/root"));
341    assert_eq!(non_matching.test_count(), 2);
342
343    // Check that non-matching contains test_foo and nested/test_qux
344    assert_eq!(non_matching.children.len(), 2);
345    match &non_matching.children[0] {
346      CollectedCategoryOrTest::Test(test) => assert_eq!(test.name, "test_foo"),
347      _ => panic!("Expected test"),
348    }
349    match &non_matching.children[1] {
350      CollectedCategoryOrTest::Category(cat) => {
351        assert_eq!(cat.name, "nested");
352        assert_eq!(cat.children.len(), 1);
353        match &cat.children[0] {
354          CollectedCategoryOrTest::Test(test) => {
355            assert_eq!(test.name, "test_qux")
356          }
357          _ => panic!("Expected test"),
358        }
359      }
360      _ => panic!("Expected category"),
361    }
362  }
363
364  #[test]
365  fn test_partition_empty_categories_filtered() {
366    // Create a category where all tests in a nested category match
367    let category = CollectedTestCategory {
368      name: "root".to_string(),
369      path: PathBuf::from("/root"),
370      children: vec![
371        CollectedCategoryOrTest::Test(CollectedTest {
372          name: "test_match".to_string(),
373          path: PathBuf::from("/root/match.rs"),
374          line_and_column: None,
375          data: (),
376        }),
377        CollectedCategoryOrTest::Category(CollectedTestCategory {
378          name: "nested".to_string(),
379          path: PathBuf::from("/root/nested"),
380          children: vec![CollectedCategoryOrTest::Test(CollectedTest {
381            name: "test_match2".to_string(),
382            path: PathBuf::from("/root/nested/match2.rs"),
383            line_and_column: None,
384            data: (),
385          })],
386        }),
387      ],
388    };
389
390    let (matching, non_matching) =
391      category.partition(|test| test.name.contains("match"));
392
393    // All tests match, so matching should have everything
394    assert_eq!(matching.test_count(), 2);
395    assert_eq!(matching.children.len(), 2);
396
397    // Non-matching should be empty (no children, and nested category filtered out)
398    assert_eq!(non_matching.test_count(), 0);
399    assert_eq!(non_matching.children.len(), 0);
400    assert!(non_matching.is_empty());
401  }
402
403  #[test]
404  fn test_into_flat_category() {
405    // Create a nested category structure
406    let category = CollectedTestCategory {
407      name: "root".to_string(),
408      path: PathBuf::from("/root"),
409      children: vec![
410        CollectedCategoryOrTest::Test(CollectedTest {
411          name: "test_1".to_string(),
412          path: PathBuf::from("/root/test1.rs"),
413          line_and_column: None,
414          data: (),
415        }),
416        CollectedCategoryOrTest::Category(CollectedTestCategory {
417          name: "nested1".to_string(),
418          path: PathBuf::from("/root/nested1"),
419          children: vec![
420            CollectedCategoryOrTest::Test(CollectedTest {
421              name: "test_2".to_string(),
422              path: PathBuf::from("/root/nested1/test2.rs"),
423              line_and_column: None,
424              data: (),
425            }),
426            CollectedCategoryOrTest::Category(CollectedTestCategory {
427              name: "deeply_nested".to_string(),
428              path: PathBuf::from("/root/nested1/deeply"),
429              children: vec![CollectedCategoryOrTest::Test(CollectedTest {
430                name: "test_3".to_string(),
431                path: PathBuf::from("/root/nested1/deeply/test3.rs"),
432                line_and_column: None,
433                data: (),
434              })],
435            }),
436          ],
437        }),
438        CollectedCategoryOrTest::Category(CollectedTestCategory {
439          name: "nested2".to_string(),
440          path: PathBuf::from("/root/nested2"),
441          children: vec![CollectedCategoryOrTest::Test(CollectedTest {
442            name: "test_4".to_string(),
443            path: PathBuf::from("/root/nested2/test4.rs"),
444            line_and_column: None,
445            data: (),
446          })],
447        }),
448      ],
449    };
450
451    let flattened = category.into_flat_category();
452
453    // Should preserve root name and path
454    assert_eq!(flattened.name, "root");
455    assert_eq!(flattened.path, PathBuf::from("/root"));
456
457    // Should have 4 direct children, all tests
458    assert_eq!(flattened.children.len(), 4);
459    assert_eq!(flattened.test_count(), 4);
460
461    // All children should be tests, no categories
462    for child in &flattened.children {
463      assert!(matches!(child, CollectedCategoryOrTest::Test(_)));
464    }
465
466    // Verify test names are preserved
467    let test_names: Vec<String> = flattened
468      .children
469      .iter()
470      .filter_map(|child| match child {
471        CollectedCategoryOrTest::Test(test) => Some(test.name.clone()),
472        _ => None,
473      })
474      .collect();
475
476    assert_eq!(test_names.len(), 4);
477    assert!(test_names.contains(&"test_1".to_string()));
478    assert!(test_names.contains(&"test_2".to_string()));
479    assert!(test_names.contains(&"test_3".to_string()));
480    assert!(test_names.contains(&"test_4".to_string()));
481  }
482}