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