Skip to main content

supercov_engine/
test_loads.rs

1//! Which first-party files a JavaScript or TypeScript test file loads, read
2//! from its imports, and which test runner a configuration file belongs to.
3//!
4//! A test's record says which source it ran, never which helpers it loaded:
5//! helpers sit beside the tests, outside what is measured. Without this a
6//! helper of the end-to-end suite changing made every unit run out of date,
7//! and a helper a test did call changing left `tests affected` saying no test
8//! was affected.
9//!
10//! What cannot be read is said: an import whose path is computed, or one that
11//! resolves to nothing Supercov can name, leaves the answer open, and an open
12//! answer is "it may load anything".
13use std::{
14    cell::RefCell,
15    collections::{BTreeMap, BTreeSet, VecDeque},
16    fs,
17    path::{Component, Path, PathBuf},
18};
19
20use serde_json::Value;
21
22/// The files a set of entry files loads, themselves included, or why that
23/// cannot be known.
24#[derive(Debug, Clone, Default, PartialEq, Eq)]
25pub struct Loads {
26    pub files: BTreeSet<String>,
27    /// The first import that could not be followed, where one could not.
28    pub open: Option<String>,
29}
30
31#[derive(Debug, Clone)]
32enum Specifier {
33    Literal(String),
34    /// `import(name)`, `require(path.join(...))`: anything at all.
35    Computed,
36}
37
38const EXTENSIONS: [&str; 9] = [
39    ".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".json",
40];
41
42/// A file Supercov can read imports from.
43pub fn reads_imports(file: &str) -> bool {
44    file.ends_with(".py")
45        || EXTENSIONS[..8]
46            .iter()
47            .any(|extension| file.ends_with(extension))
48}
49
50/// Imports of the files of one checkout, read once each.
51pub struct Imports {
52    root: PathBuf,
53    packages: BTreeSet<String>,
54    /// tsconfig `paths`: a pattern with at most one `*`, and where it maps.
55    aliases: Vec<(String, Vec<PathBuf>)>,
56    /// package.json `imports`, the `#name` specifiers.
57    subpaths: BTreeMap<String, Vec<String>>,
58    parsed: RefCell<BTreeMap<String, Option<Vec<Specifier>>>>,
59    python: RefCell<BTreeMap<String, Option<Vec<PythonImport>>>>,
60}
61
62impl Imports {
63    pub fn new(root: &Path) -> Self {
64        let manifest = read_json(&root.join("package.json"));
65        let mut packages = BTreeSet::new();
66        let mut subpaths = BTreeMap::new();
67        if let Some(manifest) = &manifest {
68            for field in [
69                "dependencies",
70                "devDependencies",
71                "peerDependencies",
72                "optionalDependencies",
73            ] {
74                if let Some(names) = manifest.get(field).and_then(Value::as_object) {
75                    packages.extend(names.keys().cloned());
76                }
77            }
78            if let Some(name) = manifest.get("name").and_then(Value::as_str) {
79                packages.insert(name.to_owned());
80            }
81            if let Some(imports) = manifest.get("imports").and_then(Value::as_object) {
82                for (name, target) in imports {
83                    let mut targets = Vec::new();
84                    collect_strings(target, &mut targets);
85                    subpaths.insert(name.clone(), targets);
86                }
87            }
88        }
89        Self {
90            root: root.to_owned(),
91            packages,
92            aliases: aliases(root),
93            subpaths,
94            parsed: RefCell::new(BTreeMap::new()),
95            python: RefCell::new(BTreeMap::new()),
96        }
97    }
98
99    /// Everything `entries` load, followed through first-party files.
100    pub fn loads<'e>(&self, entries: impl IntoIterator<Item = &'e str>) -> Loads {
101        let mut loads = Loads::default();
102        let mut queue = entries
103            .into_iter()
104            .map(str::to_owned)
105            .collect::<VecDeque<_>>();
106        while let Some(file) = queue.pop_front() {
107            if !loads.files.insert(file.clone()) || !reads_imports(&file) {
108                continue;
109            }
110            if file.ends_with(".py") {
111                self.python_loads(&file, &mut loads, &mut queue);
112                continue;
113            }
114            let Some(specifiers) = self.specifiers(&file) else {
115                loads
116                    .open
117                    .get_or_insert_with(|| format!("{file} could not be read"));
118                continue;
119            };
120            for specifier in specifiers {
121                match specifier {
122                    Specifier::Computed => {
123                        loads
124                            .open
125                            .get_or_insert_with(|| format!("{file} imports a computed path"));
126                    }
127                    Specifier::Literal(specifier) => match self.resolve(&file, &specifier) {
128                        Resolved::File(next) => queue.push_back(next),
129                        Resolved::Outside => {}
130                        Resolved::Unknown => {
131                            loads.open.get_or_insert_with(|| {
132                                format!("{file} imports '{specifier}', which Supercov cannot find")
133                            });
134                        }
135                    },
136                }
137            }
138        }
139        loads
140    }
141
142    /// Files a runner's configuration names by path, such as a setup file or
143    /// a global setup, where one exists: they load before every test. A
144    /// pattern names test files, which each run's record already holds.
145    pub fn named_by(&self, configuration: &str) -> Vec<String> {
146        let Ok(source) = fs::read_to_string(self.root.join(configuration)) else {
147            return Vec::new();
148        };
149        let directory = Path::new(configuration).parent().unwrap_or(Path::new(""));
150        let mut named = Vec::new();
151        for text in strings(configuration, &source) {
152            if !(text.starts_with('.') || text.contains('/')) || text.contains(['*', '{', '?']) {
153                continue;
154            }
155            for candidate in [directory.join(&text), PathBuf::from(&text)] {
156                if let Resolved::File(file) = self.file(&candidate)
157                    && !named.contains(&file)
158                {
159                    named.push(file);
160                    break;
161                }
162            }
163        }
164        named
165    }
166
167    /// What a Python file loads: the modules it imports, the packages they
168    /// sit in (whose `__init__.py` runs first), pytest's `conftest.py` in its
169    /// directory and every directory above, and the plugins they name. A
170    /// module found nowhere in the project is the standard library's or an
171    /// installed package's; a computed import, or a change to `sys.path`,
172    /// leaves the answer open.
173    fn python_loads(&self, file: &str, loads: &mut Loads, queue: &mut VecDeque<String>) {
174        for directory in Path::new(file).ancestors().skip(1) {
175            if let Some(conftest) = normal(&directory.join("conftest.py"))
176                && self.root.join(&conftest).is_file()
177            {
178                queue.push_back(conftest);
179            }
180        }
181        let known = self.python.borrow().get(file).cloned();
182        let imports = known.unwrap_or_else(|| {
183            let parsed = fs::read_to_string(self.root.join(file))
184                .ok()
185                .and_then(|source| python_imports(&source));
186            self.python
187                .borrow_mut()
188                .insert(file.to_owned(), parsed.clone());
189            parsed
190        });
191        let Some(imports) = imports else {
192            loads
193                .open
194                .get_or_insert_with(|| format!("{file} could not be read"));
195            return;
196        };
197        for import in imports {
198            match import {
199                PythonImport::Computed(why) => {
200                    loads.open.get_or_insert_with(|| format!("{file} {why}"));
201                }
202                PythonImport::Module {
203                    module,
204                    level,
205                    names,
206                } => {
207                    queue.extend(self.python_resolve(file, &module, level, &names));
208                }
209            }
210        }
211    }
212
213    /// The project files an import runs, or none for a module outside it.
214    fn python_resolve(
215        &self,
216        file: &str,
217        module: &str,
218        level: u32,
219        names: &[String],
220    ) -> Vec<String> {
221        let exists = |path: &Path| normal(path).filter(|path| self.root.join(path).is_file());
222        let parts = module
223            .split('.')
224            .filter(|part| !part.is_empty())
225            .collect::<Vec<_>>();
226        let directory = Path::new(file).parent().unwrap_or(Path::new(""));
227        let bases = if level > 0 {
228            let mut base = directory.to_owned();
229            for _ in 1..level {
230                base = base.parent().unwrap_or(Path::new("")).to_owned();
231            }
232            vec![base]
233        } else {
234            // Where a test's imports are found: beside it and above (pytest
235            // puts a test's root directory on the path), and a src layout.
236            let mut bases = directory
237                .ancestors()
238                .map(Path::to_owned)
239                .collect::<Vec<_>>();
240            bases.push(PathBuf::from("src"));
241            bases
242        };
243        for base in bases {
244            let mut found = Vec::new();
245            let mut package = base.clone();
246            let mut resolved = parts.is_empty();
247            for (index, part) in parts.iter().enumerate() {
248                package = package.join(part);
249                if let Some(init) = exists(&package.join("__init__.py")) {
250                    found.push(init);
251                    resolved = index + 1 == parts.len();
252                } else if index + 1 == parts.len()
253                    && let Some(module) = exists(&package.with_extension("py"))
254                {
255                    found.push(module);
256                    resolved = true;
257                } else {
258                    break;
259                }
260            }
261            if !resolved {
262                continue;
263            }
264            // `from package import name` may name a submodule.
265            for name in names {
266                let submodule = package.join(name);
267                if let Some(file) = exists(&submodule.with_extension("py"))
268                    .or_else(|| exists(&submodule.join("__init__.py")))
269                {
270                    found.push(file);
271                }
272            }
273            return found;
274        }
275        Vec::new()
276    }
277
278    fn specifiers(&self, file: &str) -> Option<Vec<Specifier>> {
279        if let Some(known) = self.parsed.borrow().get(file) {
280            return known.clone();
281        }
282        let parsed = fs::read_to_string(self.root.join(file))
283            .ok()
284            .and_then(|source| parse(file, &source));
285        self.parsed
286            .borrow_mut()
287            .insert(file.to_owned(), parsed.clone());
288        parsed
289    }
290
291    fn resolve(&self, from: &str, specifier: &str) -> Resolved {
292        let specifier = specifier.split(['?', '#']).next().unwrap_or(specifier);
293        if specifier.starts_with("./") || specifier.starts_with("../") || specifier == "." {
294            let base = Path::new(from).parent().unwrap_or(Path::new(""));
295            return self.file(&base.join(specifier));
296        }
297        if specifier.starts_with('/') {
298            return Resolved::Unknown;
299        }
300        if let Some(name) = specifier.strip_prefix("node:") {
301            let _ = name;
302            return Resolved::Outside;
303        }
304        if specifier.starts_with('#') {
305            return self.subpath(specifier);
306        }
307        for (pattern, targets) in &self.aliases {
308            let rest = match pattern.split_once('*') {
309                Some((prefix, suffix)) => specifier
310                    .strip_prefix(prefix)
311                    .and_then(|rest| rest.strip_suffix(suffix)),
312                None => (specifier == pattern).then_some(""),
313            };
314            let Some(rest) = rest else { continue };
315            for target in targets {
316                let target = target.to_string_lossy().replacen('*', rest, 1);
317                if let Resolved::File(file) = self.file(Path::new(&target)) {
318                    return Resolved::File(file);
319                }
320            }
321        }
322        let package = package_name(specifier);
323        // Installed where Node looks: beside the project or above it.
324        if BUILTINS.contains(&package)
325            || self.packages.contains(package)
326            || self
327                .root
328                .ancestors()
329                .any(|directory| directory.join("node_modules").join(package).exists())
330        {
331            return Resolved::Outside;
332        }
333        Resolved::Unknown
334    }
335
336    fn subpath(&self, specifier: &str) -> Resolved {
337        let targets = self.subpaths.get(specifier).cloned().or_else(|| {
338            self.subpaths.iter().find_map(|(pattern, targets)| {
339                let (prefix, suffix) = pattern.split_once('*')?;
340                let rest = specifier.strip_prefix(prefix)?.strip_suffix(suffix)?;
341                Some(
342                    targets
343                        .iter()
344                        .map(|target| target.replacen('*', rest, 1))
345                        .collect(),
346                )
347            })
348        });
349        let Some(targets) = targets else {
350            return Resolved::Unknown;
351        };
352        for target in targets {
353            if !target.starts_with("./") {
354                return Resolved::Outside;
355            }
356            if let Resolved::File(file) = self.file(Path::new(&target)) {
357                return Resolved::File(file);
358            }
359        }
360        Resolved::Unknown
361    }
362
363    /// A first-party file at `path` (relative to the root), the way Node,
364    /// TypeScript and bundlers find it: as named, with an extension, as a
365    /// directory's index, and a `.js` that names its TypeScript source.
366    fn file(&self, path: &Path) -> Resolved {
367        let Some(path) = normal(path) else {
368            return Resolved::Unknown;
369        };
370        if path.split('/').any(|part| part == "node_modules") {
371            return Resolved::Outside;
372        }
373        let exists = |candidate: &str| self.root.join(candidate).is_file();
374        let mut candidates = vec![path.clone()];
375        for (javascript, typescript) in [
376            (".js", [".ts", ".tsx"]),
377            (".jsx", [".tsx", ".tsx"]),
378            (".mjs", [".mts", ".mts"]),
379            (".cjs", [".cts", ".cts"]),
380        ] {
381            if let Some(stem) = path.strip_suffix(javascript) {
382                candidates.extend(typescript.iter().map(|ts| format!("{stem}{ts}")));
383            }
384        }
385        candidates.extend(
386            EXTENSIONS
387                .iter()
388                .map(|extension| format!("{path}{extension}")),
389        );
390        candidates.extend(
391            EXTENSIONS
392                .iter()
393                .map(|extension| format!("{path}/index{extension}")),
394        );
395        match candidates.into_iter().find(|candidate| exists(candidate)) {
396            Some(found) => Resolved::File(found),
397            None => Resolved::Unknown,
398        }
399    }
400}
401
402enum Resolved {
403    File(String),
404    /// A package or a built-in: not the project's own code.
405    Outside,
406    Unknown,
407}
408
409const BUILTINS: [&str; 40] = [
410    "assert",
411    "async_hooks",
412    "buffer",
413    "child_process",
414    "cluster",
415    "console",
416    "constants",
417    "crypto",
418    "dgram",
419    "diagnostics_channel",
420    "dns",
421    "domain",
422    "events",
423    "fs",
424    "http",
425    "http2",
426    "https",
427    "inspector",
428    "module",
429    "net",
430    "os",
431    "path",
432    "perf_hooks",
433    "process",
434    "punycode",
435    "querystring",
436    "readline",
437    "repl",
438    "stream",
439    "string_decoder",
440    "timers",
441    "tls",
442    "tty",
443    "url",
444    "util",
445    "v8",
446    "vm",
447    "worker_threads",
448    "zlib",
449    "test",
450];
451
452fn package_name(specifier: &str) -> &str {
453    let mut parts = specifier.splitn(3, '/');
454    let first = parts.next().unwrap_or(specifier);
455    if first.starts_with('@')
456        && let Some(second) = parts.next()
457    {
458        return &specifier[..first.len() + 1 + second.len()];
459    }
460    first
461}
462
463/// `a/./b/../c` as `a/c`, refusing a path that climbs out of the root.
464fn normal(path: &Path) -> Option<String> {
465    let mut parts = Vec::new();
466    for component in path.components() {
467        match component {
468            Component::Normal(part) => parts.push(part.to_str()?.to_owned()),
469            Component::CurDir => {}
470            Component::ParentDir => {
471                parts.pop()?;
472            }
473            _ => return None,
474        }
475    }
476    Some(parts.join("/"))
477}
478
479fn read_json(path: &Path) -> Option<Value> {
480    let text = fs::read_to_string(path).ok()?;
481    serde_json::from_str(&crate::source_discovery::strip_trailing_commas(
482        &crate::source_discovery::strip_jsonc_comments(&text),
483    ))
484    .ok()
485}
486
487fn collect_strings(value: &Value, into: &mut Vec<String>) {
488    match value {
489        Value::String(text) => into.push(text.clone()),
490        Value::Object(map) => map.values().for_each(|value| collect_strings(value, into)),
491        Value::Array(items) => items.iter().for_each(|value| collect_strings(value, into)),
492        _ => {}
493    }
494}
495
496/// The root tsconfig's `paths`, through its `extends` chain, each target
497/// relative to the root.
498fn aliases(root: &Path) -> Vec<(String, Vec<PathBuf>)> {
499    let mut found = Vec::new();
500    let mut config = root.join("tsconfig.json");
501    let mut seen = BTreeSet::new();
502    while seen.insert(config.clone()) && seen.len() < 16 {
503        let Some(json) = read_json(&config) else {
504            break;
505        };
506        let directory = config.parent().unwrap_or(root).to_owned();
507        let options = json.get("compilerOptions");
508        let base = options
509            .and_then(|options| options.get("baseUrl"))
510            .and_then(Value::as_str)
511            .map_or(directory.clone(), |base| directory.join(base));
512        if found.is_empty()
513            && let Some(paths) = options
514                .and_then(|options| options.get("paths"))
515                .and_then(Value::as_object)
516        {
517            for (pattern, targets) in paths {
518                let targets = targets
519                    .as_array()
520                    .into_iter()
521                    .flatten()
522                    .filter_map(Value::as_str)
523                    .filter_map(|target| {
524                        base.join(target)
525                            .strip_prefix(root)
526                            .ok()
527                            .map(Path::to_owned)
528                    })
529                    .collect();
530                found.push((pattern.clone(), targets));
531            }
532        }
533        match json.get("extends").and_then(Value::as_str) {
534            Some(parent) if parent.starts_with('.') => {
535                config = directory.join(parent);
536                if config.extension().is_none() {
537                    config.set_extension("json");
538                }
539            }
540            _ => break,
541        }
542    }
543    found
544}
545
546/// What a file imports: static imports and re-exports, `import()`, `require`
547/// and the module mocks of Jest and Vitest, which name a module by its path.
548/// Imports of types only are erased before anything runs.
549fn parse(file: &str, source: &str) -> Option<Vec<Specifier>> {
550    use oxc_ast::ast::*;
551    use oxc_ast_visit::{Visit, walk};
552
553    let source_type = crate::js_instrumenter::project_source_type(Path::new(file)).ok()?;
554    let allocator = oxc_allocator::Allocator::default();
555    let parsed = oxc_parser::Parser::new(&allocator, source, source_type).parse();
556    if parsed.panicked {
557        return None;
558    }
559    struct Collector {
560        found: Vec<Specifier>,
561    }
562    fn literal(expression: &Expression<'_>) -> Specifier {
563        match expression {
564            Expression::StringLiteral(text) => Specifier::Literal(text.value.to_string()),
565            Expression::TemplateLiteral(template) if template.expressions.is_empty() => template
566                .quasis
567                .first()
568                .and_then(|quasi| quasi.value.cooked.as_ref())
569                .map_or(Specifier::Computed, |text| {
570                    Specifier::Literal(text.to_string())
571                }),
572            _ => Specifier::Computed,
573        }
574    }
575    impl<'a> Visit<'a> for Collector {
576        fn visit_import_declaration(&mut self, it: &ImportDeclaration<'a>) {
577            if !it.import_kind.is_type() {
578                self.found
579                    .push(Specifier::Literal(it.source.value.to_string()));
580            }
581        }
582        fn visit_export_all_declaration(&mut self, it: &ExportAllDeclaration<'a>) {
583            if !it.export_kind.is_type() {
584                self.found
585                    .push(Specifier::Literal(it.source.value.to_string()));
586            }
587        }
588        fn visit_export_named_declaration(&mut self, it: &ExportNamedDeclaration<'a>) {
589            if let Some(source) = &it.source
590                && !it.export_kind.is_type()
591            {
592                self.found
593                    .push(Specifier::Literal(source.value.to_string()));
594            }
595            walk::walk_export_named_declaration(self, it);
596        }
597        fn visit_ts_import_equals_declaration(&mut self, it: &TSImportEqualsDeclaration<'a>) {
598            if let TSModuleReference::ExternalModuleReference(reference) = &it.module_reference
599                && !it.import_kind.is_type()
600            {
601                self.found
602                    .push(Specifier::Literal(reference.expression.value.to_string()));
603            }
604        }
605        fn visit_import_expression(&mut self, it: &ImportExpression<'a>) {
606            self.found.push(literal(&it.source));
607            walk::walk_import_expression(self, it);
608        }
609        fn visit_call_expression(&mut self, it: &CallExpression<'a>) {
610            let loads = match &it.callee {
611                Expression::Identifier(name) => name.name == "require",
612                Expression::StaticMemberExpression(member) => {
613                    let object = match &member.object {
614                        Expression::Identifier(name) => name.name.as_str(),
615                        _ => "",
616                    };
617                    let property = member.property.name.as_str();
618                    matches!(
619                        (object, property),
620                        ("require", "resolve")
621                            | ("jest", "mock" | "requireActual" | "doMock")
622                            | ("vi", "mock" | "importActual" | "doMock")
623                    )
624                }
625                _ => false,
626            };
627            if loads && let Some(first) = it.arguments.first() {
628                self.found.push(match first.as_expression() {
629                    Some(expression) => literal(expression),
630                    None => Specifier::Computed,
631                });
632            }
633            walk::walk_call_expression(self, it);
634        }
635    }
636    let mut collector = Collector { found: Vec::new() };
637    collector.visit_program(&parsed.program);
638    Some(collector.found)
639}
640
641#[derive(Debug, Clone)]
642enum PythonImport {
643    Module {
644        module: String,
645        /// Leading dots of a relative import.
646        level: u32,
647        names: Vec<String>,
648    },
649    /// Why what it loads cannot be read.
650    Computed(&'static str),
651}
652
653/// Every import in a Python file, at any depth, with `importlib` calls that
654/// name a module literally and pytest's `pytest_plugins`.
655fn python_imports(source: &str) -> Option<Vec<PythonImport>> {
656    use ruff_python_ast::{
657        Expr, Stmt,
658        visitor::{Visitor, walk_expr, walk_stmt},
659    };
660    let parsed = ruff_python_parser::parse_module(source).ok()?;
661    struct Collector(Vec<PythonImport>);
662    fn module(name: &str) -> PythonImport {
663        PythonImport::Module {
664            module: name.to_owned(),
665            level: 0,
666            names: Vec::new(),
667        }
668    }
669    fn strings(expression: &Expr) -> Option<Vec<String>> {
670        match expression {
671            Expr::StringLiteral(text) => Some(vec![text.value.to_str().to_owned()]),
672            Expr::List(list) => list
673                .elts
674                .iter()
675                .map(|item| strings(item).map(|mut s| s.remove(0)))
676                .collect(),
677            Expr::Tuple(tuple) => tuple
678                .elts
679                .iter()
680                .map(|item| strings(item).map(|mut s| s.remove(0)))
681                .collect(),
682            _ => None,
683        }
684    }
685    fn is_sys_path(expression: &Expr) -> bool {
686        matches!(expression, Expr::Attribute(attribute)
687            if attribute.attr.as_str() == "path"
688                && matches!(&*attribute.value, Expr::Name(name) if name.id.as_str() == "sys"))
689    }
690    impl<'a> Visitor<'a> for Collector {
691        fn visit_stmt(&mut self, stmt: &'a Stmt) {
692            match stmt {
693                Stmt::Import(import) => {
694                    for alias in &import.names {
695                        self.0.push(module(alias.name.as_str()));
696                    }
697                }
698                Stmt::ImportFrom(import) => self.0.push(PythonImport::Module {
699                    module: import
700                        .module
701                        .as_ref()
702                        .map(|m| m.to_string())
703                        .unwrap_or_default(),
704                    level: import.level,
705                    names: import
706                        .names
707                        .iter()
708                        .map(|alias| alias.name.to_string())
709                        .collect(),
710                }),
711                Stmt::Assign(assign) => {
712                    for target in &assign.targets {
713                        if matches!(target, Expr::Name(name) if name.id.as_str() == "pytest_plugins")
714                        {
715                            match strings(&assign.value) {
716                                Some(plugins) => self.0.extend(plugins.iter().map(|p| module(p))),
717                                None => self.0.push(PythonImport::Computed(
718                                    "names its pytest plugins by computation",
719                                )),
720                            }
721                        }
722                        if is_sys_path(target) {
723                            self.0.push(PythonImport::Computed("changes sys.path"));
724                        }
725                    }
726                }
727                _ => {}
728            }
729            walk_stmt(self, stmt);
730        }
731        fn visit_expr(&mut self, expr: &'a Expr) {
732            if let Expr::Call(call) = expr {
733                let first = call.arguments.args.first();
734                match &*call.func {
735                    Expr::Attribute(attribute)
736                        if is_sys_path(&attribute.value)
737                            && matches!(
738                                attribute.attr.as_str(),
739                                "insert" | "append" | "extend"
740                            ) =>
741                    {
742                        self.0.push(PythonImport::Computed("changes sys.path"));
743                    }
744                    Expr::Attribute(attribute) if attribute.attr.as_str() == "import_module" => {
745                        self.0.push(match first.and_then(strings) {
746                            Some(mut names) => module(&names.remove(0)),
747                            None => PythonImport::Computed("imports a module named by computation"),
748                        });
749                    }
750                    Expr::Name(name)
751                        if matches!(name.id.as_str(), "__import__" | "import_module") =>
752                    {
753                        self.0.push(match first.and_then(strings) {
754                            Some(mut names) => module(&names.remove(0)),
755                            None => PythonImport::Computed("imports a module named by computation"),
756                        });
757                    }
758                    _ => {}
759                }
760            }
761            walk_expr(self, expr);
762        }
763    }
764    let mut collector = Collector(Vec::new());
765    collector.visit_body(&parsed.syntax().body);
766    Some(collector.0)
767}
768
769/// The test runner a configuration file belongs to, by its name. A change to
770/// one runner's configuration changes nothing another runner does.
771pub fn configuration_runner(file: &str) -> Option<&'static str> {
772    let name = file.rsplit('/').next()?.to_ascii_lowercase();
773    if !name.contains(".config.") && !name.contains(".workspace.") {
774        return None;
775    }
776    let stem = name.split('.').next()?;
777    match stem {
778        "playwright" | "playwright-ct" => Some("playwright"),
779        "vitest" => Some("vitest"),
780        "jest" => Some("jest"),
781        "ava" => Some("ava"),
782        _ => None,
783    }
784}
785
786fn strings(file: &str, source: &str) -> Vec<String> {
787    use oxc_ast::ast::*;
788    use oxc_ast_visit::Visit;
789    let Ok(source_type) = crate::js_instrumenter::project_source_type(Path::new(file)) else {
790        return Vec::new();
791    };
792    let allocator = oxc_allocator::Allocator::default();
793    let parsed = oxc_parser::Parser::new(&allocator, source, source_type).parse();
794    struct Collector(Vec<String>);
795    impl<'a> Visit<'a> for Collector {
796        fn visit_string_literal(&mut self, it: &StringLiteral<'a>) {
797            self.0.push(it.value.to_string());
798        }
799    }
800    let mut collector = Collector(Vec::new());
801    collector.visit_program(&parsed.program);
802    collector.0
803}
804
805#[cfg(test)]
806mod tests {
807    use super::*;
808
809    fn project(files: &[(&str, &str)]) -> PathBuf {
810        static UNIQUE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
811        let root = std::env::temp_dir().join(format!(
812            "supercov-test-loads-{}-{}-{}",
813            std::process::id(),
814            std::time::SystemTime::now()
815                .duration_since(std::time::UNIX_EPOCH)
816                .unwrap()
817                .as_nanos(),
818            UNIQUE.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
819        ));
820        fs::create_dir(&root).unwrap();
821        for (file, text) in files {
822            let path = root.join(file);
823            fs::create_dir_all(path.parent().unwrap()).unwrap();
824            fs::write(path, text).unwrap();
825        }
826        root
827    }
828
829    #[test]
830    fn a_test_loads_its_helpers_and_not_another_suites() {
831        let root = project(&[
832            (
833                "package.json",
834                r#"{"devDependencies":{"@playwright/test":"1"}}"#,
835            ),
836            (
837                "tsconfig.json",
838                r#"{"compilerOptions":{"baseUrl":".","paths":{"@/*":["src/*"]}}}"#,
839            ),
840            (
841                "tests/e2e/helpers.ts",
842                "import { x } from '@/lib/x';\nexport const get = () => x;\n",
843            ),
844            (
845                "tests/e2e/app.spec.ts",
846                "import { test } from '@playwright/test';\nimport { get } from './helpers';\nimport type { T } from './types';\n",
847            ),
848            ("tests/e2e/types.ts", "export type T = 1;\n"),
849            (
850                "tests/unit/a.test.ts",
851                "import { y } from '../support/y.js';\n",
852            ),
853            ("tests/support/y.ts", "export const y = 1;\n"),
854            ("src/lib/x.ts", "export const x = 1;\n"),
855        ]);
856        let imports = Imports::new(&root);
857        let e2e = imports.loads(["tests/e2e/app.spec.ts"]);
858        assert_eq!(e2e.open, None);
859        assert!(e2e.files.contains("tests/e2e/helpers.ts"));
860        assert!(e2e.files.contains("src/lib/x.ts"), "{e2e:?}");
861        assert!(
862            !e2e.files.contains("tests/e2e/types.ts"),
863            "a type import loads nothing"
864        );
865        assert!(!e2e.files.contains("tests/support/y.ts"));
866        let unit = imports.loads(["tests/unit/a.test.ts"]);
867        assert!(unit.files.contains("tests/support/y.ts"), "{unit:?}");
868        assert!(!unit.files.contains("tests/e2e/helpers.ts"));
869        fs::remove_dir_all(root).unwrap();
870    }
871
872    #[test]
873    fn what_cannot_be_followed_leaves_the_answer_open() {
874        let root = project(&[
875            ("package.json", "{}"),
876            ("a.test.js", "const name = 'b';\nrequire(`./${name}`);\n"),
877            ("c.test.js", "import 'unknown-alias/thing';\n"),
878            (
879                "d.test.js",
880                "import fs from 'node:fs';\nimport path from 'path';\nimport './e.js';\n",
881            ),
882            ("e.js", "export {};\n"),
883        ]);
884        let imports = Imports::new(&root);
885        assert!(imports.loads(["a.test.js"]).open.is_some());
886        assert!(imports.loads(["c.test.js"]).open.is_some());
887        let closed = imports.loads(["d.test.js"]);
888        assert_eq!(closed.open, None);
889        assert!(closed.files.contains("e.js"));
890        fs::remove_dir_all(root).unwrap();
891    }
892
893    #[test]
894    fn a_python_test_loads_its_conftest_and_what_it_imports() {
895        let root = project(&[
896            (
897                "tests/conftest.py",
898                "import pytest\nfrom tests.support import factory\n",
899            ),
900            ("tests/__init__.py", ""),
901            ("tests/support/__init__.py", ""),
902            ("tests/support/factory.py", "import json\n"),
903            (
904                "tests/unit/test_a.py",
905                "from .helpers import make\nfrom shop import cart\n",
906            ),
907            ("tests/unit/__init__.py", ""),
908            ("tests/unit/helpers.py", "def make():\n    return 1\n"),
909            (
910                "tests/e2e/test_b.py",
911                "import requests\nfrom tests.e2e import pages\n",
912            ),
913            ("tests/e2e/__init__.py", ""),
914            ("tests/e2e/pages.py", ""),
915            ("src/shop/__init__.py", ""),
916            ("src/shop/cart.py", ""),
917            (
918                "tests/odd/test_c.py",
919                "import sys\nsys.path.insert(0, 'x')\n",
920            ),
921        ]);
922        let imports = Imports::new(&root);
923        let unit = imports.loads(["tests/unit/test_a.py"]);
924        assert_eq!(unit.open, None, "{unit:?}");
925        for file in [
926            "tests/conftest.py",
927            "tests/support/factory.py",
928            "tests/unit/helpers.py",
929            "src/shop/cart.py",
930        ] {
931            assert!(unit.files.contains(file), "{file}: {unit:?}");
932        }
933        assert!(!unit.files.contains("tests/e2e/pages.py"));
934        let e2e = imports.loads(["tests/e2e/test_b.py"]);
935        assert!(
936            e2e.files.contains("tests/e2e/pages.py")
937                && !e2e.files.contains("tests/unit/helpers.py"),
938            "{e2e:?}"
939        );
940        assert!(imports.loads(["tests/odd/test_c.py"]).open.is_some());
941        fs::remove_dir_all(root).unwrap();
942    }
943
944    #[test]
945    fn a_configuration_belongs_to_its_runner() {
946        assert_eq!(
947            configuration_runner("tests/e2e/playwright.config.ts"),
948            Some("playwright")
949        );
950        assert_eq!(configuration_runner("vitest.config.mts"), Some("vitest"));
951        assert_eq!(configuration_runner("jest.config.js"), Some("jest"));
952        assert_eq!(
953            configuration_runner("vite.config.ts"),
954            None,
955            "a build shared with the app"
956        );
957        assert_eq!(configuration_runner("tsconfig.json"), None);
958        assert_eq!(configuration_runner("next.config.ts"), None);
959    }
960
961    #[test]
962    fn a_configuration_names_its_setup_files() {
963        let root = project(&[
964            (
965                "vitest.config.ts",
966                "export default { test: { setupFiles: ['tests/unit/setup.ts'], include: ['tests/**/*.test.ts'] } };\n",
967            ),
968            ("tests/unit/setup.ts", "export {};\n"),
969            (
970                "e2e/playwright.config.ts",
971                "export default { globalSetup: './global-setup', testMatch: '*.spec.ts' };\n",
972            ),
973            ("e2e/global-setup.ts", "export default () => {};\n"),
974        ]);
975        let imports = Imports::new(&root);
976        assert_eq!(
977            imports.named_by("vitest.config.ts"),
978            ["tests/unit/setup.ts"]
979        );
980        assert_eq!(
981            imports.named_by("e2e/playwright.config.ts"),
982            ["e2e/global-setup.ts"]
983        );
984        fs::remove_dir_all(root).unwrap();
985    }
986}