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