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    EXTENSIONS[..8]
45        .iter()
46        .any(|extension| file.ends_with(extension))
47}
48
49/// Imports of the files of one checkout, read once each.
50pub struct Imports {
51    root: PathBuf,
52    packages: BTreeSet<String>,
53    /// tsconfig `paths`: a pattern with at most one `*`, and where it maps.
54    aliases: Vec<(String, Vec<PathBuf>)>,
55    /// package.json `imports`, the `#name` specifiers.
56    subpaths: BTreeMap<String, Vec<String>>,
57    parsed: RefCell<BTreeMap<String, Option<Vec<Specifier>>>>,
58}
59
60impl Imports {
61    pub fn new(root: &Path) -> Self {
62        let manifest = read_json(&root.join("package.json"));
63        let mut packages = BTreeSet::new();
64        let mut subpaths = BTreeMap::new();
65        if let Some(manifest) = &manifest {
66            for field in [
67                "dependencies",
68                "devDependencies",
69                "peerDependencies",
70                "optionalDependencies",
71            ] {
72                if let Some(names) = manifest.get(field).and_then(Value::as_object) {
73                    packages.extend(names.keys().cloned());
74                }
75            }
76            if let Some(name) = manifest.get("name").and_then(Value::as_str) {
77                packages.insert(name.to_owned());
78            }
79            if let Some(imports) = manifest.get("imports").and_then(Value::as_object) {
80                for (name, target) in imports {
81                    let mut targets = Vec::new();
82                    collect_strings(target, &mut targets);
83                    subpaths.insert(name.clone(), targets);
84                }
85            }
86        }
87        Self {
88            root: root.to_owned(),
89            packages,
90            aliases: aliases(root),
91            subpaths,
92            parsed: RefCell::new(BTreeMap::new()),
93        }
94    }
95
96    /// Everything `entries` load, followed through first-party files.
97    pub fn loads<'e>(&self, entries: impl IntoIterator<Item = &'e str>) -> Loads {
98        let mut loads = Loads::default();
99        let mut queue = entries
100            .into_iter()
101            .map(str::to_owned)
102            .collect::<VecDeque<_>>();
103        while let Some(file) = queue.pop_front() {
104            if !loads.files.insert(file.clone()) || !reads_imports(&file) {
105                continue;
106            }
107            let Some(specifiers) = self.specifiers(&file) else {
108                loads
109                    .open
110                    .get_or_insert_with(|| format!("{file} could not be read"));
111                continue;
112            };
113            for specifier in specifiers {
114                match specifier {
115                    Specifier::Computed => {
116                        loads
117                            .open
118                            .get_or_insert_with(|| format!("{file} imports a computed path"));
119                    }
120                    Specifier::Literal(specifier) => match self.resolve(&file, &specifier) {
121                        Resolved::File(next) => queue.push_back(next),
122                        Resolved::Outside => {}
123                        Resolved::Unknown => {
124                            loads.open.get_or_insert_with(|| {
125                                format!("{file} imports '{specifier}', which Supercov cannot find")
126                            });
127                        }
128                    },
129                }
130            }
131        }
132        loads
133    }
134
135    /// Files a runner's configuration names by path, such as a setup file or
136    /// a global setup, where one exists: they load before every test. A
137    /// pattern names test files, which each run's record already holds.
138    pub fn named_by(&self, configuration: &str) -> Vec<String> {
139        let Ok(source) = fs::read_to_string(self.root.join(configuration)) else {
140            return Vec::new();
141        };
142        let directory = Path::new(configuration).parent().unwrap_or(Path::new(""));
143        let mut named = Vec::new();
144        for text in strings(configuration, &source) {
145            if !(text.starts_with('.') || text.contains('/')) || text.contains(['*', '{', '?']) {
146                continue;
147            }
148            for candidate in [directory.join(&text), PathBuf::from(&text)] {
149                if let Resolved::File(file) = self.file(&candidate)
150                    && !named.contains(&file)
151                {
152                    named.push(file);
153                    break;
154                }
155            }
156        }
157        named
158    }
159
160    fn specifiers(&self, file: &str) -> Option<Vec<Specifier>> {
161        if let Some(known) = self.parsed.borrow().get(file) {
162            return known.clone();
163        }
164        let parsed = fs::read_to_string(self.root.join(file))
165            .ok()
166            .and_then(|source| parse(file, &source));
167        self.parsed
168            .borrow_mut()
169            .insert(file.to_owned(), parsed.clone());
170        parsed
171    }
172
173    fn resolve(&self, from: &str, specifier: &str) -> Resolved {
174        let specifier = specifier.split(['?', '#']).next().unwrap_or(specifier);
175        if specifier.starts_with("./") || specifier.starts_with("../") || specifier == "." {
176            let base = Path::new(from).parent().unwrap_or(Path::new(""));
177            return self.file(&base.join(specifier));
178        }
179        if specifier.starts_with('/') {
180            return Resolved::Unknown;
181        }
182        if let Some(name) = specifier.strip_prefix("node:") {
183            let _ = name;
184            return Resolved::Outside;
185        }
186        if specifier.starts_with('#') {
187            return self.subpath(specifier);
188        }
189        for (pattern, targets) in &self.aliases {
190            let rest = match pattern.split_once('*') {
191                Some((prefix, suffix)) => specifier
192                    .strip_prefix(prefix)
193                    .and_then(|rest| rest.strip_suffix(suffix)),
194                None => (specifier == pattern).then_some(""),
195            };
196            let Some(rest) = rest else { continue };
197            for target in targets {
198                let target = target.to_string_lossy().replacen('*', rest, 1);
199                if let Resolved::File(file) = self.file(Path::new(&target)) {
200                    return Resolved::File(file);
201                }
202            }
203        }
204        let package = package_name(specifier);
205        // Installed where Node looks: beside the project or above it.
206        if BUILTINS.contains(&package)
207            || self.packages.contains(package)
208            || self
209                .root
210                .ancestors()
211                .any(|directory| directory.join("node_modules").join(package).exists())
212        {
213            return Resolved::Outside;
214        }
215        Resolved::Unknown
216    }
217
218    fn subpath(&self, specifier: &str) -> Resolved {
219        let targets = self.subpaths.get(specifier).cloned().or_else(|| {
220            self.subpaths.iter().find_map(|(pattern, targets)| {
221                let (prefix, suffix) = pattern.split_once('*')?;
222                let rest = specifier.strip_prefix(prefix)?.strip_suffix(suffix)?;
223                Some(
224                    targets
225                        .iter()
226                        .map(|target| target.replacen('*', rest, 1))
227                        .collect(),
228                )
229            })
230        });
231        let Some(targets) = targets else {
232            return Resolved::Unknown;
233        };
234        for target in targets {
235            if !target.starts_with("./") {
236                return Resolved::Outside;
237            }
238            if let Resolved::File(file) = self.file(Path::new(&target)) {
239                return Resolved::File(file);
240            }
241        }
242        Resolved::Unknown
243    }
244
245    /// A first-party file at `path` (relative to the root), the way Node,
246    /// TypeScript and bundlers find it: as named, with an extension, as a
247    /// directory's index, and a `.js` that names its TypeScript source.
248    fn file(&self, path: &Path) -> Resolved {
249        let Some(path) = normal(path) else {
250            return Resolved::Unknown;
251        };
252        if path.split('/').any(|part| part == "node_modules") {
253            return Resolved::Outside;
254        }
255        let exists = |candidate: &str| self.root.join(candidate).is_file();
256        let mut candidates = vec![path.clone()];
257        for (javascript, typescript) in [
258            (".js", [".ts", ".tsx"]),
259            (".jsx", [".tsx", ".tsx"]),
260            (".mjs", [".mts", ".mts"]),
261            (".cjs", [".cts", ".cts"]),
262        ] {
263            if let Some(stem) = path.strip_suffix(javascript) {
264                candidates.extend(typescript.iter().map(|ts| format!("{stem}{ts}")));
265            }
266        }
267        candidates.extend(
268            EXTENSIONS
269                .iter()
270                .map(|extension| format!("{path}{extension}")),
271        );
272        candidates.extend(
273            EXTENSIONS
274                .iter()
275                .map(|extension| format!("{path}/index{extension}")),
276        );
277        match candidates.into_iter().find(|candidate| exists(candidate)) {
278            Some(found) => Resolved::File(found),
279            None => Resolved::Unknown,
280        }
281    }
282}
283
284enum Resolved {
285    File(String),
286    /// A package or a built-in: not the project's own code.
287    Outside,
288    Unknown,
289}
290
291const BUILTINS: [&str; 40] = [
292    "assert",
293    "async_hooks",
294    "buffer",
295    "child_process",
296    "cluster",
297    "console",
298    "constants",
299    "crypto",
300    "dgram",
301    "diagnostics_channel",
302    "dns",
303    "domain",
304    "events",
305    "fs",
306    "http",
307    "http2",
308    "https",
309    "inspector",
310    "module",
311    "net",
312    "os",
313    "path",
314    "perf_hooks",
315    "process",
316    "punycode",
317    "querystring",
318    "readline",
319    "repl",
320    "stream",
321    "string_decoder",
322    "timers",
323    "tls",
324    "tty",
325    "url",
326    "util",
327    "v8",
328    "vm",
329    "worker_threads",
330    "zlib",
331    "test",
332];
333
334fn package_name(specifier: &str) -> &str {
335    let mut parts = specifier.splitn(3, '/');
336    let first = parts.next().unwrap_or(specifier);
337    if first.starts_with('@')
338        && let Some(second) = parts.next()
339    {
340        return &specifier[..first.len() + 1 + second.len()];
341    }
342    first
343}
344
345/// `a/./b/../c` as `a/c`, refusing a path that climbs out of the root.
346fn normal(path: &Path) -> Option<String> {
347    let mut parts = Vec::new();
348    for component in path.components() {
349        match component {
350            Component::Normal(part) => parts.push(part.to_str()?.to_owned()),
351            Component::CurDir => {}
352            Component::ParentDir => {
353                parts.pop()?;
354            }
355            _ => return None,
356        }
357    }
358    Some(parts.join("/"))
359}
360
361fn read_json(path: &Path) -> Option<Value> {
362    let text = fs::read_to_string(path).ok()?;
363    serde_json::from_str(&crate::source_discovery::strip_trailing_commas(
364        &crate::source_discovery::strip_jsonc_comments(&text),
365    ))
366    .ok()
367}
368
369fn collect_strings(value: &Value, into: &mut Vec<String>) {
370    match value {
371        Value::String(text) => into.push(text.clone()),
372        Value::Object(map) => map.values().for_each(|value| collect_strings(value, into)),
373        Value::Array(items) => items.iter().for_each(|value| collect_strings(value, into)),
374        _ => {}
375    }
376}
377
378/// The root tsconfig's `paths`, through its `extends` chain, each target
379/// relative to the root.
380fn aliases(root: &Path) -> Vec<(String, Vec<PathBuf>)> {
381    let mut found = Vec::new();
382    let mut config = root.join("tsconfig.json");
383    let mut seen = BTreeSet::new();
384    while seen.insert(config.clone()) && seen.len() < 16 {
385        let Some(json) = read_json(&config) else {
386            break;
387        };
388        let directory = config.parent().unwrap_or(root).to_owned();
389        let options = json.get("compilerOptions");
390        let base = options
391            .and_then(|options| options.get("baseUrl"))
392            .and_then(Value::as_str)
393            .map_or(directory.clone(), |base| directory.join(base));
394        if found.is_empty()
395            && let Some(paths) = options
396                .and_then(|options| options.get("paths"))
397                .and_then(Value::as_object)
398        {
399            for (pattern, targets) in paths {
400                let targets = targets
401                    .as_array()
402                    .into_iter()
403                    .flatten()
404                    .filter_map(Value::as_str)
405                    .filter_map(|target| {
406                        base.join(target)
407                            .strip_prefix(root)
408                            .ok()
409                            .map(Path::to_owned)
410                    })
411                    .collect();
412                found.push((pattern.clone(), targets));
413            }
414        }
415        match json.get("extends").and_then(Value::as_str) {
416            Some(parent) if parent.starts_with('.') => {
417                config = directory.join(parent);
418                if config.extension().is_none() {
419                    config.set_extension("json");
420                }
421            }
422            _ => break,
423        }
424    }
425    found
426}
427
428/// What a file imports: static imports and re-exports, `import()`, `require`
429/// and the module mocks of Jest and Vitest, which name a module by its path.
430/// Imports of types only are erased before anything runs.
431fn parse(file: &str, source: &str) -> Option<Vec<Specifier>> {
432    use oxc_ast::ast::*;
433    use oxc_ast_visit::{Visit, walk};
434
435    let source_type = crate::js_instrumenter::project_source_type(Path::new(file)).ok()?;
436    let allocator = oxc_allocator::Allocator::default();
437    let parsed = oxc_parser::Parser::new(&allocator, source, source_type).parse();
438    if parsed.panicked {
439        return None;
440    }
441    struct Collector {
442        found: Vec<Specifier>,
443    }
444    fn literal(expression: &Expression<'_>) -> Specifier {
445        match expression {
446            Expression::StringLiteral(text) => Specifier::Literal(text.value.to_string()),
447            Expression::TemplateLiteral(template) if template.expressions.is_empty() => template
448                .quasis
449                .first()
450                .and_then(|quasi| quasi.value.cooked.as_ref())
451                .map_or(Specifier::Computed, |text| {
452                    Specifier::Literal(text.to_string())
453                }),
454            _ => Specifier::Computed,
455        }
456    }
457    impl<'a> Visit<'a> for Collector {
458        fn visit_import_declaration(&mut self, it: &ImportDeclaration<'a>) {
459            if !it.import_kind.is_type() {
460                self.found
461                    .push(Specifier::Literal(it.source.value.to_string()));
462            }
463        }
464        fn visit_export_all_declaration(&mut self, it: &ExportAllDeclaration<'a>) {
465            if !it.export_kind.is_type() {
466                self.found
467                    .push(Specifier::Literal(it.source.value.to_string()));
468            }
469        }
470        fn visit_export_named_declaration(&mut self, it: &ExportNamedDeclaration<'a>) {
471            if let Some(source) = &it.source
472                && !it.export_kind.is_type()
473            {
474                self.found
475                    .push(Specifier::Literal(source.value.to_string()));
476            }
477            walk::walk_export_named_declaration(self, it);
478        }
479        fn visit_ts_import_equals_declaration(&mut self, it: &TSImportEqualsDeclaration<'a>) {
480            if let TSModuleReference::ExternalModuleReference(reference) = &it.module_reference
481                && !it.import_kind.is_type()
482            {
483                self.found
484                    .push(Specifier::Literal(reference.expression.value.to_string()));
485            }
486        }
487        fn visit_import_expression(&mut self, it: &ImportExpression<'a>) {
488            self.found.push(literal(&it.source));
489            walk::walk_import_expression(self, it);
490        }
491        fn visit_call_expression(&mut self, it: &CallExpression<'a>) {
492            let loads = match &it.callee {
493                Expression::Identifier(name) => name.name == "require",
494                Expression::StaticMemberExpression(member) => {
495                    let object = match &member.object {
496                        Expression::Identifier(name) => name.name.as_str(),
497                        _ => "",
498                    };
499                    let property = member.property.name.as_str();
500                    matches!(
501                        (object, property),
502                        ("require", "resolve")
503                            | ("jest", "mock" | "requireActual" | "doMock")
504                            | ("vi", "mock" | "importActual" | "doMock")
505                    )
506                }
507                _ => false,
508            };
509            if loads && let Some(first) = it.arguments.first() {
510                self.found.push(match first.as_expression() {
511                    Some(expression) => literal(expression),
512                    None => Specifier::Computed,
513                });
514            }
515            walk::walk_call_expression(self, it);
516        }
517    }
518    let mut collector = Collector { found: Vec::new() };
519    collector.visit_program(&parsed.program);
520    Some(collector.found)
521}
522
523/// The test runner a configuration file belongs to, by its name. A change to
524/// one runner's configuration changes nothing another runner does.
525pub fn configuration_runner(file: &str) -> Option<&'static str> {
526    let name = file.rsplit('/').next()?.to_ascii_lowercase();
527    if !name.contains(".config.") && !name.contains(".workspace.") {
528        return None;
529    }
530    let stem = name.split('.').next()?;
531    match stem {
532        "playwright" | "playwright-ct" => Some("playwright"),
533        "vitest" => Some("vitest"),
534        "jest" => Some("jest"),
535        "ava" => Some("ava"),
536        _ => None,
537    }
538}
539
540fn strings(file: &str, source: &str) -> Vec<String> {
541    use oxc_ast::ast::*;
542    use oxc_ast_visit::Visit;
543    let Ok(source_type) = crate::js_instrumenter::project_source_type(Path::new(file)) else {
544        return Vec::new();
545    };
546    let allocator = oxc_allocator::Allocator::default();
547    let parsed = oxc_parser::Parser::new(&allocator, source, source_type).parse();
548    struct Collector(Vec<String>);
549    impl<'a> Visit<'a> for Collector {
550        fn visit_string_literal(&mut self, it: &StringLiteral<'a>) {
551            self.0.push(it.value.to_string());
552        }
553    }
554    let mut collector = Collector(Vec::new());
555    collector.visit_program(&parsed.program);
556    collector.0
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562
563    fn project(files: &[(&str, &str)]) -> PathBuf {
564        static UNIQUE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
565        let root = std::env::temp_dir().join(format!(
566            "supercov-test-loads-{}-{}-{}",
567            std::process::id(),
568            std::time::SystemTime::now()
569                .duration_since(std::time::UNIX_EPOCH)
570                .unwrap()
571                .as_nanos(),
572            UNIQUE.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
573        ));
574        fs::create_dir(&root).unwrap();
575        for (file, text) in files {
576            let path = root.join(file);
577            fs::create_dir_all(path.parent().unwrap()).unwrap();
578            fs::write(path, text).unwrap();
579        }
580        root
581    }
582
583    #[test]
584    fn a_test_loads_its_helpers_and_not_another_suites() {
585        let root = project(&[
586            (
587                "package.json",
588                r#"{"devDependencies":{"@playwright/test":"1"}}"#,
589            ),
590            (
591                "tsconfig.json",
592                r#"{"compilerOptions":{"baseUrl":".","paths":{"@/*":["src/*"]}}}"#,
593            ),
594            (
595                "tests/e2e/helpers.ts",
596                "import { x } from '@/lib/x';\nexport const get = () => x;\n",
597            ),
598            (
599                "tests/e2e/app.spec.ts",
600                "import { test } from '@playwright/test';\nimport { get } from './helpers';\nimport type { T } from './types';\n",
601            ),
602            ("tests/e2e/types.ts", "export type T = 1;\n"),
603            (
604                "tests/unit/a.test.ts",
605                "import { y } from '../support/y.js';\n",
606            ),
607            ("tests/support/y.ts", "export const y = 1;\n"),
608            ("src/lib/x.ts", "export const x = 1;\n"),
609        ]);
610        let imports = Imports::new(&root);
611        let e2e = imports.loads(["tests/e2e/app.spec.ts"]);
612        assert_eq!(e2e.open, None);
613        assert!(e2e.files.contains("tests/e2e/helpers.ts"));
614        assert!(e2e.files.contains("src/lib/x.ts"), "{e2e:?}");
615        assert!(
616            !e2e.files.contains("tests/e2e/types.ts"),
617            "a type import loads nothing"
618        );
619        assert!(!e2e.files.contains("tests/support/y.ts"));
620        let unit = imports.loads(["tests/unit/a.test.ts"]);
621        assert!(unit.files.contains("tests/support/y.ts"), "{unit:?}");
622        assert!(!unit.files.contains("tests/e2e/helpers.ts"));
623        fs::remove_dir_all(root).unwrap();
624    }
625
626    #[test]
627    fn what_cannot_be_followed_leaves_the_answer_open() {
628        let root = project(&[
629            ("package.json", "{}"),
630            ("a.test.js", "const name = 'b';\nrequire(`./${name}`);\n"),
631            ("c.test.js", "import 'unknown-alias/thing';\n"),
632            (
633                "d.test.js",
634                "import fs from 'node:fs';\nimport path from 'path';\nimport './e.js';\n",
635            ),
636            ("e.js", "export {};\n"),
637        ]);
638        let imports = Imports::new(&root);
639        assert!(imports.loads(["a.test.js"]).open.is_some());
640        assert!(imports.loads(["c.test.js"]).open.is_some());
641        let closed = imports.loads(["d.test.js"]);
642        assert_eq!(closed.open, None);
643        assert!(closed.files.contains("e.js"));
644        fs::remove_dir_all(root).unwrap();
645    }
646
647    #[test]
648    fn a_configuration_belongs_to_its_runner() {
649        assert_eq!(
650            configuration_runner("tests/e2e/playwright.config.ts"),
651            Some("playwright")
652        );
653        assert_eq!(configuration_runner("vitest.config.mts"), Some("vitest"));
654        assert_eq!(configuration_runner("jest.config.js"), Some("jest"));
655        assert_eq!(
656            configuration_runner("vite.config.ts"),
657            None,
658            "a build shared with the app"
659        );
660        assert_eq!(configuration_runner("tsconfig.json"), None);
661        assert_eq!(configuration_runner("next.config.ts"), None);
662    }
663
664    #[test]
665    fn a_configuration_names_its_setup_files() {
666        let root = project(&[
667            (
668                "vitest.config.ts",
669                "export default { test: { setupFiles: ['tests/unit/setup.ts'], include: ['tests/**/*.test.ts'] } };\n",
670            ),
671            ("tests/unit/setup.ts", "export {};\n"),
672            (
673                "e2e/playwright.config.ts",
674                "export default { globalSetup: './global-setup', testMatch: '*.spec.ts' };\n",
675            ),
676            ("e2e/global-setup.ts", "export default () => {};\n"),
677        ]);
678        let imports = Imports::new(&root);
679        assert_eq!(
680            imports.named_by("vitest.config.ts"),
681            ["tests/unit/setup.ts"]
682        );
683        assert_eq!(
684            imports.named_by("e2e/playwright.config.ts"),
685            ["e2e/global-setup.ts"]
686        );
687        fs::remove_dir_all(root).unwrap();
688    }
689}