Skip to main content

supercov_engine/
project_discovery.rs

1//! Runner/build/project discovery for zero-configuration JavaScript suites.
2
3use std::{
4    collections::{BTreeMap, BTreeSet, HashMap},
5    fs, io,
6    path::{Path, PathBuf},
7};
8
9use oxc_allocator::Allocator;
10use oxc_ast::ast::{
11    Argument, ArrayExpressionElement, CallExpression, Expression, ImportDeclarationSpecifier,
12    ImportExpression, ImportOrExportKind, Program, Statement,
13};
14use oxc_ast_visit::{Visit, walk};
15use oxc_parser::Parser;
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19use crate::source_discovery::{
20    DiscoveredSourceScope, SourceDiscoveryError, SourceLimitation, SourceScope,
21    discover_source_scope,
22};
23
24const PLAYWRIGHT_CONFIGS: &[&str] = &[
25    "playwright.config.ts",
26    "playwright.config.mts",
27    "playwright.config.js",
28    "playwright.config.mjs",
29    "playwright.config.cts",
30    "playwright.config.cjs",
31];
32const VITEST_CONFIGS: &[&str] = &[
33    "vitest.config.ts",
34    "vitest.config.mts",
35    "vitest.config.js",
36    "vitest.config.mjs",
37    "vitest.config.cts",
38    "vitest.config.cjs",
39    "vite.config.ts",
40    "vite.config.mts",
41    "vite.config.js",
42    "vite.config.mjs",
43];
44const JEST_CONFIGS: &[&str] = &[
45    "jest.config.ts",
46    "jest.config.mts",
47    "jest.config.js",
48    "jest.config.mjs",
49    "jest.config.cts",
50    "jest.config.cjs",
51];
52const TEST_DIRECTORIES: &[&str] = &["test", "tests", "e2e", "spec", "specs"];
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "camelCase", deny_unknown_fields)]
56pub struct CoverageProject {
57    pub root: PathBuf,
58    pub source_roots: Vec<String>,
59    pub source_files: Vec<String>,
60    pub source_scope: SourceScope,
61    pub source_limitations: Vec<SourceLimitation>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub playwright_config: Option<PathBuf>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub vitest_config: Option<PathBuf>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub jest_config: Option<PathBuf>,
68    pub uses_jest: bool,
69    /// The tests bundle sources for a browser through a runner Supercov has
70    /// no adapter for -- karma, testem, airtap, zuul, mochify -- so each
71    /// script-style file carries the runtime the page otherwise lacks.
72    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
73    pub browser_runner: bool,
74    pub playwright_module: String,
75    pub playwright_test_export: String,
76    pub playwright_exports: Vec<String>,
77    /// The project's build, when the tests may need its output and the
78    /// command does not run it: what a failed run names. Supercov runs the
79    /// command it is given and no build of its own.
80    #[serde(default, skip_serializing_if = "Vec::is_empty")]
81    pub unrun_build: Vec<String>,
82}
83
84#[derive(Debug)]
85pub enum ProjectDiscoveryError {
86    Source(SourceDiscoveryError),
87    Io { path: PathBuf, source: io::Error },
88    NoSourceFiles,
89}
90
91impl From<SourceDiscoveryError> for ProjectDiscoveryError {
92    fn from(value: SourceDiscoveryError) -> Self {
93        Self::Source(value)
94    }
95}
96
97impl std::fmt::Display for ProjectDiscoveryError {
98    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        match self {
100            Self::Source(error) => write!(formatter, "{error}"),
101            Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
102            Self::NoSourceFiles => write!(
103                formatter,
104                "No application source files were discovered. If your sources live somewhere unusual, set SUPERCOV_SOURCE_ROOTS=src,app. Alternatively, Supercov may not support your project layout or test framework yet — if so, please open an issue or PR: https://github.com/supercorp-ai/supercov"
105            ),
106        }
107    }
108}
109
110impl std::error::Error for ProjectDiscoveryError {}
111
112fn package_json(root: &Path) -> Value {
113    fs::read(root.join("package.json"))
114        .ok()
115        .and_then(|contents| serde_json::from_slice(&contents).ok())
116        .unwrap_or(Value::Null)
117}
118
119fn script<'a>(manifest: &'a Value, name: &str) -> Option<&'a str> {
120    manifest.get("scripts")?.get(name)?.as_str()
121}
122
123fn regular_file(path: &Path) -> bool {
124    fs::symlink_metadata(path)
125        .map(|metadata| metadata.file_type().is_file())
126        .unwrap_or(false)
127}
128
129fn source_file(path: &Path) -> bool {
130    let name = path
131        .file_name()
132        .and_then(|name| name.to_str())
133        .unwrap_or("");
134    let lower = name.to_ascii_lowercase();
135    [
136        ".js", ".jsx", ".ts", ".tsx", ".cjs", ".cjsx", ".cts", ".ctsx", ".mjs", ".mjsx", ".mts",
137        ".mtsx",
138    ]
139    .iter()
140    .any(|extension| lower.ends_with(extension))
141}
142
143fn read_directory(path: &Path) -> Result<Vec<fs::DirEntry>, ProjectDiscoveryError> {
144    let mut entries = fs::read_dir(path)
145        .map_err(|source| ProjectDiscoveryError::Io {
146            path: path.to_owned(),
147            source,
148        })?
149        .collect::<Result<Vec<_>, _>>()
150        .map_err(|source| ProjectDiscoveryError::Io {
151            path: path.to_owned(),
152            source,
153        })?;
154    entries.sort_by_key(fs::DirEntry::file_name);
155    Ok(entries)
156}
157
158fn parse_program<'a>(
159    allocator: &'a Allocator,
160    path: &Path,
161    source: &'a str,
162) -> Option<Program<'a>> {
163    let source_type = crate::js_instrumenter::project_source_type(path).ok()?;
164    let parsed = Parser::new(allocator, source, source_type).parse();
165    parsed.errors.is_empty().then_some(parsed.program)
166}
167
168#[derive(Debug, Clone)]
169struct TestApiCandidate {
170    module: String,
171    score: usize,
172    test_export: Option<String>,
173    exports: Vec<String>,
174}
175
176fn imported_test_apis(path: &Path, source: &str) -> Vec<TestApiCandidate> {
177    let allocator = Allocator::default();
178    let Some(program) = parse_program(&allocator, path, source) else {
179        return Vec::new();
180    };
181    // Aggregate per module across the whole file: a facade's helpers are
182    // often imported in their own statement (`import { createTestProduct }
183    // from "@acme/fixtures"`), and those names must reach the generated shim
184    // even though that statement alone carries no test-API signal.
185    let mut by_module = BTreeMap::<String, TestApiCandidate>::new();
186    for statement in &program.body {
187        let Statement::ImportDeclaration(declaration) = statement else {
188            continue;
189        };
190        if declaration.import_kind == ImportOrExportKind::Type {
191            continue;
192        }
193        let module = declaration.source.value.to_string();
194        let candidate = by_module
195            .entry(module.clone())
196            .or_insert_with(|| TestApiCandidate {
197                module,
198                score: 0,
199                test_export: None,
200                exports: Vec::new(),
201            });
202        for specifier in declaration.specifiers.iter().flatten() {
203            let ImportDeclarationSpecifier::ImportSpecifier(specifier) = specifier else {
204                continue;
205            };
206            if specifier.import_kind == ImportOrExportKind::Type {
207                continue;
208            }
209            let imported = specifier.imported.name().to_string();
210            let local = specifier.local.name.as_str();
211            candidate.exports.push(imported.clone());
212            if local == "test" {
213                candidate.score += 20;
214                candidate.test_export = Some(imported.clone());
215            } else if imported.to_ascii_lowercase().ends_with("test") {
216                candidate.score += 8;
217            }
218            if local == "expect" || imported == "expect" {
219                candidate.score += 10;
220            }
221        }
222    }
223    by_module
224        .into_values()
225        .filter(|candidate| candidate.score > 0)
226        .map(|mut candidate| {
227            if candidate.module == "@playwright/test" {
228                candidate.score += 100;
229            } else if candidate.module.to_ascii_lowercase().contains("playwright") {
230                candidate.score += 5;
231            }
232            candidate
233        })
234        .collect()
235}
236
237fn test_api_candidates(directory: &Path, output: &mut Vec<TestApiCandidate>) {
238    let Ok(entries) = read_directory(directory) else {
239        return;
240    };
241    for entry in entries {
242        let name = entry.file_name();
243        let Some(name) = name.to_str() else { continue };
244        if name == "node_modules" || name == "results" || name.starts_with('.') {
245            continue;
246        }
247        let path = entry.path();
248        let Ok(file_type) = entry.file_type() else {
249            continue;
250        };
251        if file_type.is_symlink() {
252            continue;
253        }
254        if file_type.is_dir() {
255            test_api_candidates(&path, output);
256        } else if file_type.is_file()
257            && source_file(&path)
258            && let Ok(source) = fs::read_to_string(&path)
259        {
260            output.extend(imported_test_apis(&path, &source));
261        }
262    }
263}
264
265#[derive(Debug, Clone)]
266struct PlaywrightAdapter {
267    module: String,
268    test_export: String,
269    exports: Vec<String>,
270}
271
272fn discover_playwright_adapter(root: &Path) -> PlaywrightAdapter {
273    let mut candidates = Vec::new();
274    for directory in TEST_DIRECTORIES {
275        test_api_candidates(&root.join(directory), &mut candidates);
276    }
277    let mut scores = HashMap::<String, usize>::new();
278    for candidate in &candidates {
279        *scores.entry(candidate.module.clone()).or_default() += candidate.score;
280    }
281    let module = scores
282        .into_iter()
283        .min_by(|(left_module, left_score), (right_module, right_score)| {
284            right_score
285                .cmp(left_score)
286                .then_with(|| left_module.cmp(right_module))
287        })
288        .map(|(module, _)| module)
289        .unwrap_or_else(|| "@playwright/test".into());
290    let matching = candidates
291        .iter()
292        .filter(|candidate| candidate.module == module)
293        .collect::<Vec<_>>();
294    let test_export = matching
295        .iter()
296        .filter_map(|candidate| {
297            candidate
298                .test_export
299                .as_ref()
300                .map(|export| (candidate.score, export))
301        })
302        .max_by_key(|(score, _)| *score)
303        .map(|(_, export)| export.clone())
304        .unwrap_or_else(|| "test".into());
305    let exports = matching
306        .iter()
307        .flat_map(|candidate| candidate.exports.iter().cloned())
308        .collect::<BTreeSet<_>>()
309        .into_iter()
310        .collect();
311    PlaywrightAdapter {
312        module,
313        test_export,
314        exports,
315    }
316}
317
318fn nested_playwright_configs(root: &Path) -> Vec<PathBuf> {
319    fn visit(directory: &Path, depth: usize, found: &mut Vec<PathBuf>) {
320        if depth > 4 {
321            return;
322        }
323        let Ok(entries) = read_directory(directory) else {
324            return;
325        };
326        for entry in entries {
327            let name = entry.file_name();
328            let Some(name) = name.to_str() else { continue };
329            if name.starts_with('.') || name == "node_modules" {
330                continue;
331            }
332            let path = entry.path();
333            let Ok(file_type) = entry.file_type() else {
334                continue;
335            };
336            if file_type.is_symlink() {
337                continue;
338            }
339            if file_type.is_dir() {
340                visit(&path, depth + 1, found);
341            } else if file_type.is_file() && playwright_config_name(name) {
342                found.push(path);
343            }
344        }
345    }
346
347    let mut found = Vec::new();
348    for directory in ["test", "tests", "e2e"] {
349        visit(&root.join(directory), 0, &mut found);
350    }
351    found.sort();
352    found
353}
354
355fn playwright_config_name(name: &str) -> bool {
356    let lower = name.to_ascii_lowercase();
357    if !lower.starts_with("playwright") || !lower.contains(".config.") {
358        return false;
359    }
360    source_file(Path::new(name))
361        && lower[..lower.find(".config.").unwrap_or(0)]
362            .chars()
363            .all(|character| {
364                character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_')
365            })
366}
367
368pub fn expanded_command(root: &Path, command: &[String]) -> String {
369    let manifest = package_json(root);
370    let executable = command
371        .first()
372        .and_then(|value| Path::new(value).file_name())
373        .and_then(|value| value.to_str())
374        .unwrap_or("")
375        .trim_end_matches(".cmd")
376        .trim_end_matches(".exe")
377        .to_ascii_lowercase();
378    let run_index = command.iter().position(|argument| argument == "run");
379    let script_name = run_index
380        .and_then(|index| command.get(index + 1))
381        .or_else(|| {
382            (["npm", "pnpm", "yarn", "bun"].contains(&executable.as_str()) && run_index.is_none())
383                .then(|| command.get(1))
384                .flatten()
385        });
386    let joined = command.join(" ");
387    if ["npm", "pnpm", "yarn", "bun"].contains(&executable.as_str())
388        && let Some(script_name) = script_name
389    {
390        return format!("{joined} {}", script(&manifest, script_name).unwrap_or(""));
391    }
392    joined
393}
394
395/// The command a package-manager script runs, with the arguments given after
396/// the script's name where the package manager puts them, after the script
397/// (`npm test -- adds` with `"test": "cargo test"` runs `cargo test adds`).
398/// Each argument is quoted, so splitting the result as `sh` does gives it
399/// back whole. None when the command runs no script of the package.
400pub fn script_command(root: &Path, command: &[String]) -> Option<String> {
401    let executable = command
402        .first()
403        .and_then(|value| Path::new(value).file_name())
404        .and_then(|value| value.to_str())?
405        .trim_end_matches(".cmd")
406        .trim_end_matches(".exe")
407        .to_ascii_lowercase();
408    if !["npm", "pnpm", "yarn", "bun"].contains(&executable.as_str()) {
409        return None;
410    }
411    let name = match command.iter().position(|argument| argument == "run") {
412        Some(run) => run + 1,
413        None => 1,
414    };
415    let manifest = package_json(root);
416    let script = script(&manifest, command.get(name)?)?;
417    let mut extra = &command[name + 1..];
418    // The package manager's own separator is not the script's.
419    if extra.first().is_some_and(|argument| argument == "--") {
420        extra = &extra[1..];
421    }
422    let quote = |argument: &String| {
423        let plain = !argument.is_empty()
424            && argument
425                .chars()
426                .all(|c| c.is_ascii_alphanumeric() || "_./:=,+@%-".contains(c));
427        if plain {
428            argument.clone()
429        } else {
430            format!("'{}'", argument.replace('\'', r"'\''"))
431        }
432    };
433    Some(
434        std::iter::once(script.to_owned())
435            .chain(extra.iter().map(quote))
436            .collect::<Vec<_>>()
437            .join(" "),
438    )
439}
440
441fn relative_build_output(source: &str) -> bool {
442    let mut rest = source;
443    let mut relative = false;
444    while let Some(stripped) = rest.strip_prefix("../").or_else(|| rest.strip_prefix("./")) {
445        relative = true;
446        rest = stripped;
447    }
448    relative
449        && matches!(
450            rest.split('/').next(),
451            Some("dist" | "build" | "out" | "output")
452        )
453}
454
455fn string_expression<'a>(expression: &'a Expression<'_>) -> Option<&'a str> {
456    let Expression::StringLiteral(literal) = expression else {
457        return None;
458    };
459    Some(literal.value.as_str())
460}
461
462struct BuildOutputScanner<'a> {
463    found: bool,
464    build_output_scripts: &'a BTreeSet<String>,
465}
466
467fn child_process_launcher(expression: &Expression<'_>) -> bool {
468    let name = match expression {
469        Expression::Identifier(identifier) => Some(identifier.name.as_str()),
470        Expression::StaticMemberExpression(member) => Some(member.property.name.as_str()),
471        _ => None,
472    };
473    name.is_some_and(|name| {
474        [
475            "spawn",
476            "spawnSync",
477            "exec",
478            "execSync",
479            "execFile",
480            "execFileSync",
481        ]
482        .contains(&name)
483    })
484}
485
486/// The package script a launch runs, whether the call names a program and an
487/// argument array (`spawn("npm", ["run", "start"])`) or hands the shell one
488/// command string (`execSync("npm run start")`, or `spawn` with `shell`). The
489/// string form is how most suites start a server, and it is the form the
490/// array-only match missed.
491fn launched_package_script<'a>(
492    program: &'a str,
493    arguments: Option<&'a oxc_ast::ast::ArrayExpression<'a>>,
494) -> Option<String> {
495    let words: Vec<String> = match arguments {
496        Some(arguments) => {
497            if !package_manager(program) {
498                return None;
499            }
500            arguments
501                .elements
502                .iter()
503                .map(|element| match element {
504                    ArrayExpressionElement::StringLiteral(value) => Some(value.value.to_string()),
505                    _ => None,
506                })
507                .collect::<Option<Vec<_>>>()?
508        }
509        None => {
510            let mut tokens = command_tokens(program);
511            if tokens.is_empty() || !package_manager(&tokens.remove(0)) {
512                return None;
513            }
514            tokens
515        }
516    };
517    match words.first().map(String::as_str) {
518        Some("run") => words.get(1).cloned(),
519        Some(_) => words.first().cloned(),
520        None => None,
521    }
522}
523
524fn package_manager(value: &str) -> bool {
525    let executable = value
526        .rsplit(['/', '\\'])
527        .next()
528        .unwrap_or(value)
529        .trim_end_matches(".cmd")
530        .trim_end_matches(".exe");
531    ["npm", "pnpm", "yarn", "bun"].contains(&executable)
532}
533
534fn script_references_build_output(command: &str) -> bool {
535    command_tokens(command).iter().any(|token| {
536        let token = token.trim_start_matches("../").trim_start_matches("./");
537        let mut segments = token.split(['/', '\\']);
538        let directory = segments.next();
539        // A bare `build` is a subcommand -- `vite build` produces output, it
540        // does not consume any -- so only a path reaching INTO the directory
541        // means the script runs something already compiled.
542        segments.next().is_some() && matches!(directory, Some("dist" | "build" | "out" | "output"))
543    })
544}
545
546fn build_output_scripts(manifest: &Value) -> BTreeSet<String> {
547    manifest
548        .get("scripts")
549        .and_then(Value::as_object)
550        .into_iter()
551        .flatten()
552        .filter_map(|(name, command)| match command.as_str() {
553            Some(command) if script_references_build_output(command) => Some(name.clone()),
554            _ => None,
555        })
556        .collect()
557}
558
559impl<'a> Visit<'a> for BuildOutputScanner<'_> {
560    fn visit_import_declaration(&mut self, declaration: &oxc_ast::ast::ImportDeclaration<'a>) {
561        self.found |= relative_build_output(declaration.source.value.as_str());
562        walk::walk_import_declaration(self, declaration);
563    }
564
565    fn visit_import_expression(&mut self, expression: &ImportExpression<'a>) {
566        if let Some(source) = string_expression(&expression.source) {
567            self.found |= relative_build_output(source);
568        }
569        walk::walk_import_expression(self, expression);
570    }
571
572    fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
573        if matches!(&call.callee, Expression::Identifier(identifier) if identifier.name == "require")
574            && let Some(Argument::StringLiteral(source)) = call.arguments.first()
575        {
576            self.found |= relative_build_output(source.value.as_str());
577        }
578        if child_process_launcher(&call.callee)
579            && let Some(Argument::StringLiteral(program)) = call.arguments.first()
580        {
581            let arguments = match call.arguments.get(1) {
582                Some(Argument::ArrayExpression(arguments)) => Some(&**arguments),
583                _ => None,
584            };
585            self.found |= launched_package_script(program.value.as_str(), arguments)
586                .is_some_and(|script| self.build_output_scripts.contains(&script));
587        }
588        walk::walk_call_expression(self, call);
589    }
590}
591
592/// Whether tests consume compiled output directly or launch a package script
593/// that does. Either form requires the instrumented build before the runner.
594fn tests_require_build_output(root: &Path, manifest: &Value) -> bool {
595    fn visit(directory: &Path, build_output_scripts: &BTreeSet<String>) -> bool {
596        let Ok(entries) = read_directory(directory) else {
597            return false;
598        };
599        for entry in entries {
600            let name = entry.file_name();
601            let Some(name) = name.to_str() else { continue };
602            if name == "node_modules" || name.starts_with('.') {
603                continue;
604            }
605            let path = entry.path();
606            let Ok(file_type) = entry.file_type() else {
607                continue;
608            };
609            if file_type.is_symlink() {
610                continue;
611            }
612            if file_type.is_dir() && visit(&path, build_output_scripts) {
613                return true;
614            }
615            if file_type.is_file()
616                && source_file(&path)
617                && let Ok(source) = fs::read_to_string(&path)
618            {
619                let allocator = Allocator::default();
620                if let Some(program) = parse_program(&allocator, &path, &source) {
621                    let mut scanner = BuildOutputScanner {
622                        found: false,
623                        build_output_scripts,
624                    };
625                    scanner.visit_program(&program);
626                    if scanner.found {
627                        return true;
628                    }
629                }
630            }
631        }
632        false
633    }
634
635    let build_output_scripts = build_output_scripts(manifest);
636    ["test", "tests", "spec", "specs", "e2e", "__tests__"]
637        .iter()
638        .any(|directory| visit(&root.join(directory), &build_output_scripts))
639}
640
641fn command_tokens(value: &str) -> Vec<String> {
642    value
643        .split_whitespace()
644        .map(|token| {
645            token
646                .trim_matches(|character: char| matches!(character, '\'' | '"' | '(' | ')'))
647                .trim_end_matches([';', ','])
648                .to_ascii_lowercase()
649        })
650        .collect()
651}
652
653/// The configuration file a runner is given in the command: `--config
654/// <file>`, `--config=<file>` or `-c <file>` after the runner's name, before
655/// the next command of a chain. Case is kept: it is a path.
656fn configured_by_command(root: &Path, command: &str, tool: &str) -> Option<PathBuf> {
657    let tokens = command
658        .split_whitespace()
659        .map(|token| token.trim_matches(|character: char| matches!(character, '\'' | '"')))
660        .collect::<Vec<_>>();
661    let mut in_tool = false;
662    for (index, token) in tokens.iter().enumerate() {
663        if matches!(*token, "&&" | "||" | ";" | "|") {
664            in_tool = false;
665            continue;
666        }
667        let file = token.rsplit(['/', '\\']).next().unwrap_or(token);
668        let file = file.trim_end_matches(".cmd").trim_end_matches(".exe");
669        if file == tool {
670            in_tool = true;
671            continue;
672        }
673        if !in_tool {
674            continue;
675        }
676        let value = match token.strip_prefix("--config=") {
677            Some(value) => Some(value),
678            None if matches!(*token, "--config" | "-c") => tokens.get(index + 1).copied(),
679            None => None,
680        };
681        if let Some(value) = value
682            .map(|value| root.join(value))
683            .filter(|path| path.is_file())
684        {
685            return Some(value);
686        }
687    }
688    None
689}
690
691fn has_tool(tokens: &[String], tool: &str) -> bool {
692    tokens.iter().any(|token| {
693        let file = token.rsplit('/').next().unwrap_or(token);
694        file == tool
695            || file.strip_suffix(".cmd") == Some(tool)
696            || file.strip_suffix(".exe") == Some(tool)
697    })
698}
699
700/// `tokens` and the tokens of every package script they run, however deep:
701/// debug's `npm test` runs `npm run test:browser`, which runs karma.
702fn reachable_script_tokens(manifest: &Value, tokens: &[String]) -> Vec<String> {
703    let mut all = tokens.to_vec();
704    let mut visited = BTreeSet::new();
705    let mut index = 0;
706    while index < all.len() {
707        let runner = all[index]
708            .rsplit('/')
709            .next()
710            .unwrap_or(&all[index])
711            .to_owned();
712        let named = match runner.as_str() {
713            "npm" | "pnpm" | "yarn" | "bun" => {
714                let next = all.get(index + 1).map(String::as_str);
715                match next {
716                    Some("run" | "run-script") => all.get(index + 2).cloned(),
717                    Some("test" | "t") => Some("test".to_owned()),
718                    Some(name) if runner != "npm" => Some(name.to_owned()),
719                    _ => None,
720                }
721            }
722            _ => None,
723        };
724        if let Some(name) = named
725            && visited.insert(name.clone())
726            && let Some(body) = script(manifest, &name)
727        {
728            all.extend(command_tokens(body));
729        }
730        index += 1;
731    }
732    all
733}
734
735fn configured_path(
736    root: &Path,
737    environment: &BTreeMap<String, String>,
738    key: &str,
739) -> Option<PathBuf> {
740    environment.get(key).map(|value| root.join(value))
741}
742
743fn first_config(root: &Path, candidates: &[&str]) -> Option<PathBuf> {
744    candidates
745        .iter()
746        .map(|candidate| root.join(candidate))
747        .find(|path| regular_file(path))
748}
749
750/// The package manager the project's tests were started with, to run its
751/// build the same way. A Yarn or pnpm workspace's scripts call the manager
752/// that runs them; building through npm changed that for no reason.
753fn build_package_manager(command: &[String]) -> String {
754    command
755        .first()
756        .filter(|program| package_manager(program))
757        .cloned()
758        .unwrap_or_else(|| "npm".into())
759}
760
761pub fn discover_coverage_project(
762    root: &Path,
763    environment: &BTreeMap<String, String>,
764    command: &[String],
765) -> Result<CoverageProject, ProjectDiscoveryError> {
766    let project = discover_coverage_project_of_any_size(root, environment, command)?;
767    if project.source_files.is_empty() {
768        return Err(ProjectDiscoveryError::NoSourceFiles);
769    }
770    Ok(project)
771}
772
773/// `discover_coverage_project` for a suite whose application may hold no
774/// JavaScript at all: browser tests of a Python server.
775pub fn discover_coverage_project_of_any_size(
776    root: &Path,
777    environment: &BTreeMap<String, String>,
778    command: &[String],
779) -> Result<CoverageProject, ProjectDiscoveryError> {
780    let manifest = package_json(root);
781    let configured_roots = crate::source_discovery::configured_source_roots(environment);
782    let DiscoveredSourceScope {
783        source_files,
784        source_roots,
785        scope,
786        limitations,
787    } = discover_source_scope(root, configured_roots.as_deref())?;
788    // The configuration the command names comes before any found by name: a
789    // root `playwright.config.ts` beside `--config e2e/playwright.config.ts`
790    // made the run load the root one, and run other tests than the command.
791    let expanded = expanded_command(root, command);
792    let playwright_config = configured_path(root, environment, "SUPERCOV_PLAYWRIGHT_CONFIG")
793        .or_else(|| configured_by_command(root, &expanded, "playwright"))
794        .or_else(|| first_config(root, PLAYWRIGHT_CONFIGS))
795        .or_else(|| nested_playwright_configs(root).into_iter().next());
796    let vitest_config = configured_path(root, environment, "SUPERCOV_VITEST_CONFIG")
797        .or_else(|| configured_by_command(root, &expanded, "vitest"))
798        .or_else(|| first_config(root, VITEST_CONFIGS));
799    let jest_config = configured_path(root, environment, "SUPERCOV_JEST_CONFIG")
800        .or_else(|| configured_by_command(root, &expanded, "jest"))
801        .or_else(|| first_config(root, JEST_CONFIGS));
802    let discovered_playwright = discover_playwright_adapter(root);
803    let playwright_module = environment
804        .get("SUPERCOV_PLAYWRIGHT_MODULE")
805        .cloned()
806        .unwrap_or_else(|| discovered_playwright.module.clone());
807    let playwright_test_export = environment
808        .get("SUPERCOV_PLAYWRIGHT_TEST_EXPORT")
809        .cloned()
810        .unwrap_or_else(|| {
811            if playwright_module == discovered_playwright.module {
812                discovered_playwright.test_export.clone()
813            } else {
814                "test".into()
815            }
816        });
817    let expanded_test_command = expanded;
818    let tokens = command_tokens(&expanded_test_command);
819    let uses_jest =
820        jest_config.is_some() || has_tool(&tokens, "jest") || manifest.get("jest").is_some();
821    let script_tokens = reachable_script_tokens(&manifest, &tokens);
822    let browser_runner = ["karma", "testem", "airtap", "zuul", "mochify"]
823        .iter()
824        .any(|tool| has_tool(&script_tokens, tool));
825    let source_transforming_runner = has_tool(&tokens, "jest") || has_tool(&tokens, "vitest");
826    let node_test = has_tool(&tokens, "node") && tokens.iter().any(|token| token == "--test");
827    let typescript_test = tokens.iter().any(|token| {
828        [".ts", ".tsx", ".cts", ".mts"]
829            .iter()
830            .any(|extension| token.ends_with(extension) || token.contains(&format!("{extension}*")))
831    });
832    let owns_build = ["vite", "tsc", "webpack", "rollup", "next", "remix"]
833        .iter()
834        .any(|tool| has_tool(&tokens, tool));
835    // Reading the suite is the expensive half of this question, so it stays on
836    // the right of `&&`: a command that never executes source directly answers
837    // it without parsing a single test file.
838    let executes_source_directly = (source_transforming_runner
839        || (node_test && typescript_test && !owns_build))
840        && !tests_require_build_output(root, &manifest);
841    // Supercov runs the command it is given and no build of its own. It used
842    // to run the project's `build` script first whenever the tests might
843    // need its output: twenty seconds nobody asked for before a suite that
844    // builds the application itself, in two of five popular repositories,
845    // and a run cancelled when that build failed. The build is only what a
846    // failed run names, when the command does not already reach it.
847    let build_script = script(&manifest, "build")
848        .map(command_tokens)
849        .unwrap_or_default();
850    let command_builds = !build_script.is_empty()
851        && build_script
852            .iter()
853            .all(|token| script_tokens.contains(token));
854    let unrun_build = if !build_script.is_empty() && !command_builds && !executes_source_directly {
855        vec![build_package_manager(command), "run".into(), "build".into()]
856    } else {
857        Vec::new()
858    };
859    let playwright_exports = if playwright_module == discovered_playwright.module {
860        discovered_playwright.exports
861    } else {
862        vec![playwright_test_export.clone(), "expect".into()]
863    };
864    Ok(CoverageProject {
865        root: root.to_owned(),
866        source_roots,
867        source_files,
868        source_scope: scope,
869        source_limitations: limitations,
870        playwright_config,
871        vitest_config,
872        jest_config,
873        uses_jest,
874        browser_runner,
875        playwright_module,
876        playwright_test_export,
877        playwright_exports,
878        unrun_build,
879    })
880}
881
882#[cfg(test)]
883mod tests {
884    use std::time::{SystemTime, UNIX_EPOCH};
885
886    use super::*;
887
888    /// A script runs with the arguments after its name appended, the package
889    /// manager's own `--` left out and each argument kept whole.
890    #[test]
891    fn a_script_command_carries_the_arguments_after_the_script_name() {
892        static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
893        let root = std::env::temp_dir().join(format!(
894            "supercov-script-command-{}-{}",
895            std::process::id(),
896            COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
897        ));
898        std::fs::create_dir(&root).unwrap();
899        std::fs::write(
900            root.join("package.json"),
901            r#"{ "scripts": { "test": "cargo test", "unit": "cargo test --lib" } }"#,
902        )
903        .unwrap();
904        let words = |line: &str| line.split(' ').map(str::to_owned).collect::<Vec<_>>();
905        let command = |line: &str| script_command(&root, &words(line));
906        assert_eq!(
907            command("npm test -- adds").as_deref(),
908            Some("cargo test adds")
909        );
910        assert_eq!(command("npm run unit").as_deref(), Some("cargo test --lib"));
911        assert_eq!(
912            command("pnpm run unit adds --release").as_deref(),
913            Some("cargo test --lib adds --release")
914        );
915        let mut quoted = words("npm test --");
916        quoted.extend(["two words".into(), "it's".into(), String::new()]);
917        assert_eq!(
918            script_command(&root, &quoted).as_deref(),
919            Some(r"cargo test 'two words' 'it'\''s' ''")
920        );
921        assert_eq!(command("cargo test"), None);
922        assert_eq!(command("npm run missing"), None);
923        std::fs::remove_dir_all(&root).unwrap();
924    }
925
926    fn project(label: &str, files: &[(&str, &str)]) -> crate::test_directory::TestDirectory {
927        // The clock ticks once per microsecond: the counter is what keeps two
928        // tests that start together apart.
929        static UNIQUE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
930        let nonce = SystemTime::now()
931            .duration_since(UNIX_EPOCH)
932            .unwrap()
933            .as_nanos();
934        let root = std::env::temp_dir().join(format!(
935            "supercov-project-{label}-{}-{nonce}-{}",
936            std::process::id(),
937            UNIQUE.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
938        ));
939        fs::create_dir(&root).unwrap();
940        let root = crate::test_directory::TestDirectory::adopt(root);
941        for (file, contents) in files {
942            let path = root.join(file);
943            fs::create_dir_all(path.parent().unwrap()).unwrap();
944            fs::write(path, contents).unwrap();
945        }
946        root
947    }
948
949    fn command(values: &[&str]) -> Vec<String> {
950        values.iter().map(|value| (*value).into()).collect()
951    }
952
953    #[test]
954    fn the_configuration_the_command_names_comes_first() {
955        // A root configuration beside the one the command names: the run
956        // loaded the root one and ran other tests than the command.
957        let root = project(
958            "named-config",
959            &[
960                (
961                    "package.json",
962                    r#"{"scripts":{"test:e2e":"playwright test --config e2e/Playwright.config.ts","test:unit":"vitest run -c config/vitest.unit.ts"}}"#,
963                ),
964                ("src/main.ts", "export const ready = true"),
965                ("playwright.config.ts", "export default {}"),
966                ("e2e/Playwright.config.ts", "export default {}"),
967                ("vitest.config.ts", "export default {}"),
968                ("config/vitest.unit.ts", "export default {}"),
969            ],
970        );
971        let discover = |args: &[&str]| {
972            discover_coverage_project(&root, &BTreeMap::new(), &command(args)).unwrap()
973        };
974        assert_eq!(
975            discover(&["npm", "run", "test:e2e"]).playwright_config,
976            Some(root.join("e2e/Playwright.config.ts"))
977        );
978        assert_eq!(
979            discover(&[
980                "npx",
981                "playwright",
982                "test",
983                "--config=e2e/Playwright.config.ts"
984            ])
985            .playwright_config,
986            Some(root.join("e2e/Playwright.config.ts"))
987        );
988        assert_eq!(
989            discover(&["npm", "run", "test:unit"]).vitest_config,
990            Some(root.join("config/vitest.unit.ts"))
991        );
992        // Named for another tool, or not at all: found by name as before.
993        assert_eq!(
994            discover(&["npx", "playwright", "test"]).playwright_config,
995            Some(root.join("playwright.config.ts"))
996        );
997        assert_eq!(
998            discover(&["npx", "vitest", "run", "--config", "missing.ts"]).vitest_config,
999            Some(root.join("vitest.config.ts"))
1000        );
1001        fs::remove_dir_all(root).unwrap();
1002    }
1003
1004    #[test]
1005    fn discovers_conventional_vite_playwright_and_vitest_configuration() {
1006        let root = project(
1007            "vite",
1008            &[
1009                (
1010                    "package.json",
1011                    r#"{"scripts":{"build":"vite build"},"devDependencies":{"vite":"1"}}"#,
1012                ),
1013                ("src/main.ts", "export const ready = true"),
1014                ("playwright.config.ts", "export default {}"),
1015                ("vitest.config.ts", "export default {}"),
1016                (
1017                    "tests/example.spec.ts",
1018                    "import { test } from '@playwright/test'",
1019                ),
1020            ],
1021        );
1022        let discovered = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
1023        assert_eq!(discovered.source_roots, ["src"]);
1024        assert_eq!(
1025            discovered.playwright_config,
1026            Some(root.join("playwright.config.ts"))
1027        );
1028        assert_eq!(
1029            discovered.vitest_config,
1030            Some(root.join("vitest.config.ts"))
1031        );
1032        assert_eq!(discovered.playwright_module, "@playwright/test");
1033        assert_eq!(discovered.playwright_test_export, "test");
1034        assert_eq!(discovered.playwright_exports, ["test"]);
1035        // The command runs as given: the project\'s build is only what a
1036        // failed run names.
1037        assert_eq!(discovered.unrun_build, command(&["npm", "run", "build"]));
1038        fs::remove_dir_all(root).unwrap();
1039    }
1040
1041    #[test]
1042    fn a_browser_runner_the_tests_reach_through_nested_scripts_is_found() {
1043        // debug's `npm test` runs `npm run test:browser`, which runs karma.
1044        let root = project(
1045            "karma",
1046            &[
1047                (
1048                    "package.json",
1049                    r#"{"scripts":{"test":"npm run test:node && npm run test:browser","test:node":"mocha test.js","test:browser":"karma start --single-run"}}"#,
1050                ),
1051                ("src/index.js", "module.exports = 1"),
1052            ],
1053        );
1054        let command = ["npm".to_owned(), "test".to_owned()];
1055        let discovered = discover_coverage_project(&root, &BTreeMap::new(), &command).unwrap();
1056        assert!(discovered.browser_runner);
1057        let node_only = ["npm".to_owned(), "run".to_owned(), "test:node".to_owned()];
1058        let discovered = discover_coverage_project(&root, &BTreeMap::new(), &node_only).unwrap();
1059        assert!(!discovered.browser_runner);
1060    }
1061
1062    #[test]
1063    fn skips_unrelated_builds_for_source_transforming_and_node_test_suites() {
1064        for (label, test_script, test_file) in [
1065            ("jest", "jest", "require('../src/index.ts')"),
1066            ("vitest", "vitest run", "import '../src/index.ts'"),
1067            (
1068                "node",
1069                "node --test tests/*.test.ts",
1070                "import '../src/index.ts'",
1071            ),
1072        ] {
1073            let root = project(
1074                label,
1075                &[
1076                    (
1077                        "package.json",
1078                        &format!(
1079                            r#"{{"scripts":{{"build":"node build","test":"{test_script}"}}}}"#
1080                        ),
1081                    ),
1082                    ("src/index.ts", "export const ready = true"),
1083                    ("tests/index.test.ts", test_file),
1084                ],
1085            );
1086            let discovered =
1087                discover_coverage_project(&root, &BTreeMap::new(), &command(&["npm", "test"]))
1088                    .unwrap();
1089            assert!(discovered.unrun_build.is_empty(), "{label}");
1090            fs::remove_dir_all(root).unwrap();
1091        }
1092    }
1093
1094    #[test]
1095    fn names_the_build_with_the_package_manager_the_tests_were_started_with() {
1096        let root = project(
1097            "yarn-build",
1098            &[
1099                (
1100                    "package.json",
1101                    r#"{"scripts":{"build":"tsc","test":"jest --runInBand"}}"#,
1102                ),
1103                ("src/index.ts", "export const ready = true"),
1104                ("test/index.test.js", "require('../dist/index.js')"),
1105            ],
1106        );
1107        let discovered =
1108            discover_coverage_project(&root, &BTreeMap::new(), &command(&["yarn", "test"]))
1109                .unwrap();
1110        assert_eq!(discovered.unrun_build, command(&["yarn", "run", "build"]));
1111        fs::remove_dir_all(root).unwrap();
1112    }
1113
1114    #[test]
1115    fn names_the_build_when_tests_import_compiled_output() {
1116        let root = project(
1117            "compiled",
1118            &[
1119                (
1120                    "package.json",
1121                    r#"{"scripts":{"build":"tsc","test":"jest --runInBand"}}"#,
1122                ),
1123                ("src/index.ts", "export const ready = true"),
1124                ("test/index.test.js", "require('../dist/index.js')"),
1125            ],
1126        );
1127        let discovered =
1128            discover_coverage_project(&root, &BTreeMap::new(), &command(&["npm", "test"])).unwrap();
1129        assert_eq!(discovered.unrun_build, command(&["npm", "run", "build"]));
1130        assert!(discovered.uses_jest);
1131        fs::remove_dir_all(root).unwrap();
1132    }
1133
1134    #[test]
1135    fn names_the_build_when_source_direct_tests_spawn_a_compiled_package_script() {
1136        let root = project(
1137            "spawned-compiled-script",
1138            &[
1139                (
1140                    "package.json",
1141                    r#"{"scripts":{"build":"tsc","start":"node dist/index.js","test":"node --test tests/*.test.ts"}}"#,
1142                ),
1143                ("src/index.ts", "export const ready = true"),
1144                (
1145                    "tests/index.test.ts",
1146                    "import { spawn } from 'node:child_process';\nspawn('npm', ['run', 'start']);\n",
1147                ),
1148            ],
1149        );
1150        let discovered =
1151            discover_coverage_project(&root, &BTreeMap::new(), &command(&["npm", "test"])).unwrap();
1152        assert_eq!(discovered.unrun_build, command(&["npm", "run", "build"]));
1153        fs::remove_dir_all(root).unwrap();
1154    }
1155
1156    #[test]
1157    fn names_the_build_when_tests_exec_a_compiled_package_script_as_one_string() {
1158        // `execSync("npm run start")` hands the shell a single string. It is
1159        // how most suites start the server they test against, and the
1160        // array-only match let it through to the same hang as an unbuilt
1161        // gateway.
1162        let root = project(
1163            "exec-compiled-script",
1164            &[
1165                (
1166                    "package.json",
1167                    r#"{"scripts":{"build":"tsc","start":"node dist/index.js","test":"node --test tests/*.test.ts"}}"#,
1168                ),
1169                ("src/index.ts", "export const ready = true"),
1170                (
1171                    "tests/index.test.ts",
1172                    "import { execSync } from 'node:child_process';\nexecSync('npm run start');\n",
1173                ),
1174            ],
1175        );
1176        let discovered =
1177            discover_coverage_project(&root, &BTreeMap::new(), &command(&["npm", "test"])).unwrap();
1178        assert_eq!(discovered.unrun_build, command(&["npm", "run", "build"]));
1179        fs::remove_dir_all(root).unwrap();
1180    }
1181
1182    #[test]
1183    fn keeps_source_direct_when_a_spawned_script_only_runs_a_build_subcommand() {
1184        let root = project(
1185            "spawned-build-subcommand",
1186            &[
1187                (
1188                    "package.json",
1189                    r#"{"scripts":{"build":"tsc","dev":"vite build","test":"node --test tests/*.test.ts"}}"#,
1190                ),
1191                ("src/index.ts", "export const ready = true"),
1192                (
1193                    "tests/index.test.ts",
1194                    "import { spawn } from 'node:child_process';\nspawn('npm', ['run', 'dev']);\n",
1195                ),
1196            ],
1197        );
1198        let discovered =
1199            discover_coverage_project(&root, &BTreeMap::new(), &command(&["npm", "test"])).unwrap();
1200        assert!(discovered.unrun_build.is_empty());
1201        fs::remove_dir_all(root).unwrap();
1202    }
1203
1204    #[test]
1205    fn discovers_a_project_owned_playwright_fixture_via_the_ast() {
1206        let root = project(
1207            "fixture",
1208            &[
1209                ("package.json", r#"{"scripts":{"build":"vite build"}}"#),
1210                ("app/root.tsx", "export default null"),
1211                (
1212                    "tests/nested/playwright.browser.config.ts",
1213                    "export default {}",
1214                ),
1215                (
1216                    "tests/example.spec.ts",
1217                    "import { type Ignored, browserTest as test, expect, fixtureValue } from '@acme/browser-fixtures'\n\
1218                     import { createFixtureProduct } from '@acme/browser-fixtures'",
1219                ),
1220            ],
1221        );
1222        let discovered = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
1223        assert_eq!(
1224            discovered.playwright_config,
1225            Some(root.join("tests/nested/playwright.browser.config.ts"))
1226        );
1227        assert_eq!(discovered.playwright_module, "@acme/browser-fixtures");
1228        assert_eq!(discovered.playwright_test_export, "browserTest");
1229        // The helper imported in its own statement must reach the shim: a
1230        // facade's non-test exports vanish otherwise, and every spec importing
1231        // one fails to link.
1232        assert_eq!(
1233            discovered.playwright_exports,
1234            [
1235                "browserTest",
1236                "createFixtureProduct",
1237                "expect",
1238                "fixtureValue"
1239            ]
1240        );
1241        fs::remove_dir_all(root).unwrap();
1242    }
1243
1244    #[test]
1245    fn environment_overrides_are_authoritative() {
1246        let root = project(
1247            "override",
1248            &[
1249                ("package.json", r#"{"scripts":{"build":"vite build"}}"#),
1250                ("custom/main.ts", "main"),
1251                ("configs/browser.ts", "config"),
1252                (
1253                    "tests/example.spec.ts",
1254                    "import { test } from '@playwright/test'",
1255                ),
1256            ],
1257        );
1258        let environment = BTreeMap::from([
1259            ("SUPERCOV_SOURCE_ROOTS".into(), "custom".into()),
1260            (
1261                "SUPERCOV_PLAYWRIGHT_CONFIG".into(),
1262                "configs/browser.ts".into(),
1263            ),
1264            ("SUPERCOV_PLAYWRIGHT_MODULE".into(), "@custom/test".into()),
1265            ("SUPERCOV_PLAYWRIGHT_TEST_EXPORT".into(), "scenario".into()),
1266        ]);
1267        let discovered = discover_coverage_project(&root, &environment, &[]).unwrap();
1268        assert_eq!(discovered.source_roots, ["custom"]);
1269        assert_eq!(
1270            discovered.playwright_config,
1271            Some(root.join("configs/browser.ts"))
1272        );
1273        assert_eq!(discovered.playwright_module, "@custom/test");
1274        assert_eq!(discovered.playwright_test_export, "scenario");
1275        assert_eq!(discovered.playwright_exports, ["scenario", "expect"]);
1276        fs::remove_dir_all(root).unwrap();
1277    }
1278}