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, BinaryExpression, CallExpression, Expression, ImportDeclarationSpecifier,
12    ImportExpression, ImportOrExportKind, Program, Statement, StaticMemberExpression,
13};
14use oxc_ast_visit::{Visit, walk};
15use oxc_parser::Parser;
16use oxc_span::SourceType;
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    pub playwright_module: String,
83    pub playwright_test_export: String,
84    pub playwright_exports: Vec<String>,
85    pub build_adapter: BuildAdapter,
86    pub build_command: Vec<String>,
87    pub build_environment: BTreeMap<String, String>,
88}
89
90#[derive(Debug)]
91pub enum ProjectDiscoveryError {
92    Source(SourceDiscoveryError),
93    Io { path: PathBuf, source: io::Error },
94    NoSourceFiles,
95}
96
97impl From<SourceDiscoveryError> for ProjectDiscoveryError {
98    fn from(value: SourceDiscoveryError) -> Self {
99        Self::Source(value)
100    }
101}
102
103impl std::fmt::Display for ProjectDiscoveryError {
104    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        match self {
106            Self::Source(error) => write!(formatter, "{error}"),
107            Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
108            Self::NoSourceFiles => write!(
109                formatter,
110                "No application source files were discovered. Set SUPERCOV_SOURCE_ROOTS=src,app."
111            ),
112        }
113    }
114}
115
116impl std::error::Error for ProjectDiscoveryError {}
117
118fn package_json(root: &Path) -> Value {
119    fs::read(root.join("package.json"))
120        .ok()
121        .and_then(|contents| serde_json::from_slice(&contents).ok())
122        .unwrap_or(Value::Null)
123}
124
125fn script<'a>(manifest: &'a Value, name: &str) -> Option<&'a str> {
126    manifest.get("scripts")?.get(name)?.as_str()
127}
128
129fn regular_file(path: &Path) -> bool {
130    fs::symlink_metadata(path)
131        .map(|metadata| metadata.file_type().is_file())
132        .unwrap_or(false)
133}
134
135fn source_file(path: &Path) -> bool {
136    let name = path
137        .file_name()
138        .and_then(|name| name.to_str())
139        .unwrap_or("");
140    let lower = name.to_ascii_lowercase();
141    [
142        ".js", ".jsx", ".ts", ".tsx", ".cjs", ".cjsx", ".cts", ".ctsx", ".mjs", ".mjsx", ".mts",
143        ".mtsx",
144    ]
145    .iter()
146    .any(|extension| lower.ends_with(extension))
147}
148
149fn read_directory(path: &Path) -> Result<Vec<fs::DirEntry>, ProjectDiscoveryError> {
150    let mut entries = fs::read_dir(path)
151        .map_err(|source| ProjectDiscoveryError::Io {
152            path: path.to_owned(),
153            source,
154        })?
155        .collect::<Result<Vec<_>, _>>()
156        .map_err(|source| ProjectDiscoveryError::Io {
157            path: path.to_owned(),
158            source,
159        })?;
160    entries.sort_by_key(fs::DirEntry::file_name);
161    Ok(entries)
162}
163
164fn parse_program<'a>(
165    allocator: &'a Allocator,
166    path: &Path,
167    source: &'a str,
168) -> Option<Program<'a>> {
169    let source_type = SourceType::from_path(path).ok()?;
170    let parsed = Parser::new(allocator, source, source_type).parse();
171    parsed.errors.is_empty().then_some(parsed.program)
172}
173
174#[derive(Debug, Clone)]
175struct TestApiCandidate {
176    module: String,
177    score: usize,
178    test_export: Option<String>,
179    exports: Vec<String>,
180}
181
182fn imported_test_apis(path: &Path, source: &str) -> Vec<TestApiCandidate> {
183    let allocator = Allocator::default();
184    let Some(program) = parse_program(&allocator, path, source) else {
185        return Vec::new();
186    };
187    let mut candidates = Vec::new();
188    for statement in &program.body {
189        let Statement::ImportDeclaration(declaration) = statement else {
190            continue;
191        };
192        if declaration.import_kind == ImportOrExportKind::Type {
193            continue;
194        }
195        let module = declaration.source.value.to_string();
196        let mut score = 0;
197        let mut test_export = None;
198        let mut exports = Vec::new();
199        for specifier in declaration.specifiers.iter().flatten() {
200            let ImportDeclarationSpecifier::ImportSpecifier(specifier) = specifier else {
201                continue;
202            };
203            if specifier.import_kind == ImportOrExportKind::Type {
204                continue;
205            }
206            let imported = specifier.imported.name().to_string();
207            let local = specifier.local.name.as_str();
208            exports.push(imported.clone());
209            if local == "test" {
210                score += 20;
211                test_export = Some(imported.clone());
212            } else if imported.to_ascii_lowercase().ends_with("test") {
213                score += 8;
214            }
215            if local == "expect" || imported == "expect" {
216                score += 10;
217            }
218        }
219        if score > 0 {
220            if module == "@playwright/test" {
221                score += 100;
222            } else if module.to_ascii_lowercase().contains("playwright") {
223                score += 5;
224            }
225            candidates.push(TestApiCandidate {
226                module,
227                score,
228                test_export,
229                exports,
230            });
231        }
232    }
233    candidates
234}
235
236fn test_api_candidates(directory: &Path, output: &mut Vec<TestApiCandidate>) {
237    let Ok(entries) = read_directory(directory) else {
238        return;
239    };
240    for entry in entries {
241        let name = entry.file_name();
242        let Some(name) = name.to_str() else { continue };
243        if name == "node_modules" || name == "results" || name.starts_with('.') {
244            continue;
245        }
246        let path = entry.path();
247        let Ok(file_type) = entry.file_type() else {
248            continue;
249        };
250        if file_type.is_symlink() {
251            continue;
252        }
253        if file_type.is_dir() {
254            test_api_candidates(&path, output);
255        } else if file_type.is_file()
256            && source_file(&path)
257            && let Ok(source) = fs::read_to_string(&path)
258        {
259            output.extend(imported_test_apis(&path, &source));
260        }
261    }
262}
263
264#[derive(Debug, Clone)]
265struct PlaywrightAdapter {
266    module: String,
267    test_export: String,
268    exports: Vec<String>,
269}
270
271fn discover_playwright_adapter(root: &Path) -> PlaywrightAdapter {
272    let mut candidates = Vec::new();
273    for directory in TEST_DIRECTORIES {
274        test_api_candidates(&root.join(directory), &mut candidates);
275    }
276    let mut scores = HashMap::<String, usize>::new();
277    for candidate in &candidates {
278        *scores.entry(candidate.module.clone()).or_default() += candidate.score;
279    }
280    let module = scores
281        .into_iter()
282        .min_by(|(left_module, left_score), (right_module, right_score)| {
283            right_score
284                .cmp(left_score)
285                .then_with(|| left_module.cmp(right_module))
286        })
287        .map(|(module, _)| module)
288        .unwrap_or_else(|| "@playwright/test".into());
289    let matching = candidates
290        .iter()
291        .filter(|candidate| candidate.module == module)
292        .collect::<Vec<_>>();
293    let test_export = matching
294        .iter()
295        .filter_map(|candidate| {
296            candidate
297                .test_export
298                .as_ref()
299                .map(|export| (candidate.score, export))
300        })
301        .max_by_key(|(score, _)| *score)
302        .map(|(_, export)| export.clone())
303        .unwrap_or_else(|| "test".into());
304    let exports = matching
305        .iter()
306        .flat_map(|candidate| candidate.exports.iter().cloned())
307        .collect::<BTreeSet<_>>()
308        .into_iter()
309        .collect();
310    PlaywrightAdapter {
311        module,
312        test_export,
313        exports,
314    }
315}
316
317fn nested_playwright_configs(root: &Path) -> Vec<PathBuf> {
318    fn visit(directory: &Path, depth: usize, found: &mut Vec<PathBuf>) {
319        if depth > 4 {
320            return;
321        }
322        let Ok(entries) = read_directory(directory) else {
323            return;
324        };
325        for entry in entries {
326            let name = entry.file_name();
327            let Some(name) = name.to_str() else { continue };
328            if name.starts_with('.') || name == "node_modules" {
329                continue;
330            }
331            let path = entry.path();
332            let Ok(file_type) = entry.file_type() else {
333                continue;
334            };
335            if file_type.is_symlink() {
336                continue;
337            }
338            if file_type.is_dir() {
339                visit(&path, depth + 1, found);
340            } else if file_type.is_file() && playwright_config_name(name) {
341                found.push(path);
342            }
343        }
344    }
345
346    let mut found = Vec::new();
347    for directory in ["test", "tests", "e2e"] {
348        visit(&root.join(directory), 0, &mut found);
349    }
350    found.sort();
351    found
352}
353
354fn playwright_config_name(name: &str) -> bool {
355    let lower = name.to_ascii_lowercase();
356    if !lower.starts_with("playwright") || !lower.contains(".config.") {
357        return false;
358    }
359    source_file(Path::new(name))
360        && lower[..lower.find(".config.").unwrap_or(0)]
361            .chars()
362            .all(|character| {
363                character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_')
364            })
365}
366
367pub fn expanded_command(root: &Path, command: &[String]) -> String {
368    let manifest = package_json(root);
369    let executable = command
370        .first()
371        .and_then(|value| Path::new(value).file_name())
372        .and_then(|value| value.to_str())
373        .unwrap_or("")
374        .trim_end_matches(".cmd")
375        .trim_end_matches(".exe")
376        .to_ascii_lowercase();
377    let run_index = command.iter().position(|argument| argument == "run");
378    let script_name = run_index
379        .and_then(|index| command.get(index + 1))
380        .or_else(|| {
381            (["npm", "pnpm", "yarn", "bun"].contains(&executable.as_str()) && run_index.is_none())
382                .then(|| command.get(1))
383                .flatten()
384        });
385    let joined = command.join(" ");
386    if ["npm", "pnpm", "yarn", "bun"].contains(&executable.as_str())
387        && let Some(script_name) = script_name
388    {
389        return format!("{joined} {}", script(&manifest, script_name).unwrap_or(""));
390    }
391    joined
392}
393
394fn words(value: &str) -> BTreeSet<String> {
395    value
396        .to_ascii_lowercase()
397        .split(|character: char| !character.is_ascii_alphanumeric())
398        .filter(|word| word.len() > 1 && !GENERIC_COMMAND_TERMS.contains(word))
399        .map(str::to_owned)
400        .collect()
401}
402
403fn relative_build_output(source: &str) -> bool {
404    let mut rest = source;
405    let mut relative = false;
406    while let Some(stripped) = rest.strip_prefix("../").or_else(|| rest.strip_prefix("./")) {
407        relative = true;
408        rest = stripped;
409    }
410    relative
411        && matches!(
412            rest.split('/').next(),
413            Some("dist" | "build" | "out" | "output")
414        )
415}
416
417fn string_expression<'a>(expression: &'a Expression<'_>) -> Option<&'a str> {
418    let Expression::StringLiteral(literal) = expression else {
419        return None;
420    };
421    Some(literal.value.as_str())
422}
423
424#[derive(Default)]
425struct BuildOutputScanner {
426    found: bool,
427}
428
429impl<'a> Visit<'a> for BuildOutputScanner {
430    fn visit_import_declaration(&mut self, declaration: &oxc_ast::ast::ImportDeclaration<'a>) {
431        self.found |= relative_build_output(declaration.source.value.as_str());
432        walk::walk_import_declaration(self, declaration);
433    }
434
435    fn visit_import_expression(&mut self, expression: &ImportExpression<'a>) {
436        if let Some(source) = string_expression(&expression.source) {
437            self.found |= relative_build_output(source);
438        }
439        walk::walk_import_expression(self, expression);
440    }
441
442    fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
443        if matches!(&call.callee, Expression::Identifier(identifier) if identifier.name == "require")
444            && let Some(Argument::StringLiteral(source)) = call.arguments.first()
445        {
446            self.found |= relative_build_output(source.value.as_str());
447        }
448        walk::walk_call_expression(self, call);
449    }
450}
451
452fn tests_import_build_output(root: &Path) -> bool {
453    fn visit(directory: &Path) -> bool {
454        let Ok(entries) = read_directory(directory) else {
455            return false;
456        };
457        for entry in entries {
458            let name = entry.file_name();
459            let Some(name) = name.to_str() else { continue };
460            if name == "node_modules" || name.starts_with('.') {
461                continue;
462            }
463            let path = entry.path();
464            let Ok(file_type) = entry.file_type() else {
465                continue;
466            };
467            if file_type.is_symlink() {
468                continue;
469            }
470            if file_type.is_dir() && visit(&path) {
471                return true;
472            }
473            if file_type.is_file()
474                && source_file(&path)
475                && let Ok(source) = fs::read_to_string(&path)
476            {
477                let allocator = Allocator::default();
478                if let Some(program) = parse_program(&allocator, &path, &source) {
479                    let mut scanner = BuildOutputScanner::default();
480                    scanner.visit_program(&program);
481                    if scanner.found {
482                        return true;
483                    }
484                }
485            }
486        }
487        false
488    }
489
490    ["test", "tests", "spec", "specs", "e2e", "__tests__"]
491        .iter()
492        .any(|directory| visit(&root.join(directory)))
493}
494
495fn identifier(expression: &Expression<'_>, name: &str) -> bool {
496    matches!(expression, Expression::Identifier(identifier) if identifier.name == name)
497}
498
499fn static_process_env(member: &StaticMemberExpression<'_>) -> bool {
500    member.property.name == "env" && identifier(&member.object, "process")
501}
502
503fn environment_reference(expression: &Expression<'_>) -> Option<String> {
504    match expression {
505        Expression::StaticMemberExpression(member) => {
506            let Expression::StaticMemberExpression(object) = &member.object else {
507                return None;
508            };
509            static_process_env(object).then(|| member.property.name.to_string())
510        }
511        Expression::ComputedMemberExpression(member) => {
512            let Expression::StaticMemberExpression(object) = &member.object else {
513                return None;
514            };
515            let Expression::StringLiteral(property) = &member.expression else {
516                return None;
517            };
518            static_process_env(object).then(|| property.value.to_string())
519        }
520        _ => None,
521    }
522}
523
524#[derive(Default)]
525struct BuildEnvironmentScanner {
526    values: BTreeMap<String, String>,
527}
528
529impl<'a> Visit<'a> for BuildEnvironmentScanner {
530    fn visit_binary_expression(&mut self, expression: &BinaryExpression<'a>) {
531        if matches!(
532            expression.operator,
533            BinaryOperator::Equality | BinaryOperator::StrictEquality
534        ) && let Some(name) = environment_reference(&expression.left)
535            && name
536                .bytes()
537                .next()
538                .is_some_and(|byte| byte.is_ascii_uppercase())
539            && name
540                .bytes()
541                .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
542            && let Some(value) = string_expression(&expression.right)
543        {
544            self.values.insert(name, value.into());
545        }
546        walk::walk_binary_expression(self, expression);
547    }
548}
549
550fn referenced_build_environment(root: &Path) -> BTreeMap<String, String> {
551    let Ok(entries) = read_directory(root) else {
552        return BTreeMap::new();
553    };
554    let mut values = BTreeMap::new();
555    for entry in entries {
556        let path = entry.path();
557        let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
558            continue;
559        };
560        if !entry.file_type().is_ok_and(|file_type| file_type.is_file())
561            || !["vite", "webpack", "rollup", "remix", "next", "nuxt"]
562                .iter()
563                .any(|tool| name.starts_with(&format!("{tool}.config.")))
564            || !source_file(&path)
565        {
566            continue;
567        }
568        let Ok(source) = fs::read_to_string(&path) else {
569            continue;
570        };
571        let allocator = Allocator::default();
572        let Some(program) = parse_program(&allocator, &path, &source) else {
573            continue;
574        };
575        let mut scanner = BuildEnvironmentScanner::default();
576        scanner.visit_program(&program);
577        values.extend(scanner.values);
578    }
579    values
580}
581
582fn infer_build_environment(
583    root: &Path,
584    command: &[String],
585    environment: &BTreeMap<String, String>,
586) -> BTreeMap<String, String> {
587    let command_words = words(&expanded_command(root, command));
588    if command_words.is_empty() {
589        return BTreeMap::new();
590    }
591    referenced_build_environment(root)
592        .into_iter()
593        .filter(|(name, _)| {
594            !environment.contains_key(name)
595                && words(name).iter().any(|word| command_words.contains(word))
596        })
597        .collect()
598}
599
600fn command_tokens(value: &str) -> Vec<String> {
601    value
602        .split_whitespace()
603        .map(|token| {
604            token
605                .trim_matches(|character: char| matches!(character, '\'' | '"' | '(' | ')'))
606                .trim_end_matches([';', ','])
607                .to_ascii_lowercase()
608        })
609        .collect()
610}
611
612fn has_tool(tokens: &[String], tool: &str) -> bool {
613    tokens.iter().any(|token| {
614        let file = token.rsplit('/').next().unwrap_or(token);
615        file == tool
616            || file.strip_suffix(".cmd") == Some(tool)
617            || file.strip_suffix(".exe") == Some(tool)
618    })
619}
620
621/// Resolve npm/pnpm/yarn script indirection before identifying a runner. This
622/// is shared by discovery and the Rust-owned execution frontend so `npm test`
623/// receives exactly the same adapter decision as an explicit runner command.
624pub fn command_uses_tool(root: &Path, command: &[String], tool: &str) -> bool {
625    has_tool(&command_tokens(&expanded_command(root, command)), tool)
626}
627
628fn configured_path(
629    root: &Path,
630    environment: &BTreeMap<String, String>,
631    key: &str,
632) -> Option<PathBuf> {
633    environment.get(key).map(|value| root.join(value))
634}
635
636fn first_config(root: &Path, candidates: &[&str]) -> Option<PathBuf> {
637    candidates
638        .iter()
639        .map(|candidate| root.join(candidate))
640        .find(|path| regular_file(path))
641}
642
643pub fn discover_coverage_project(
644    root: &Path,
645    environment: &BTreeMap<String, String>,
646    command: &[String],
647) -> Result<CoverageProject, ProjectDiscoveryError> {
648    let manifest = package_json(root);
649    let configured_roots = environment.get("SUPERCOV_SOURCE_ROOTS").map(|roots| {
650        roots
651            .split(',')
652            .map(str::trim)
653            .filter(|root| !root.is_empty())
654            .map(str::to_owned)
655            .collect::<Vec<_>>()
656    });
657    let DiscoveredSourceScope {
658        source_files,
659        source_roots,
660        scope,
661        limitations,
662    } = discover_source_scope(root, configured_roots.as_deref())?;
663    if source_files.is_empty() {
664        return Err(ProjectDiscoveryError::NoSourceFiles);
665    }
666    let playwright_config = configured_path(root, environment, "SUPERCOV_PLAYWRIGHT_CONFIG")
667        .or_else(|| first_config(root, PLAYWRIGHT_CONFIGS))
668        .or_else(|| nested_playwright_configs(root).into_iter().next());
669    let vitest_config = configured_path(root, environment, "SUPERCOV_VITEST_CONFIG")
670        .or_else(|| first_config(root, VITEST_CONFIGS));
671    let jest_config = configured_path(root, environment, "SUPERCOV_JEST_CONFIG")
672        .or_else(|| first_config(root, JEST_CONFIGS));
673    let discovered_playwright = discover_playwright_adapter(root);
674    let playwright_module = environment
675        .get("SUPERCOV_PLAYWRIGHT_MODULE")
676        .cloned()
677        .unwrap_or_else(|| discovered_playwright.module.clone());
678    let playwright_test_export = environment
679        .get("SUPERCOV_PLAYWRIGHT_TEST_EXPORT")
680        .cloned()
681        .unwrap_or_else(|| {
682            if playwright_module == discovered_playwright.module {
683                discovered_playwright.test_export.clone()
684            } else {
685                "test".into()
686            }
687        });
688    let expanded_test_command = expanded_command(root, command);
689    let tokens = command_tokens(&expanded_test_command);
690    let uses_jest =
691        jest_config.is_some() || has_tool(&tokens, "jest") || manifest.get("jest").is_some();
692    let source_transforming_runner = has_tool(&tokens, "jest") || has_tool(&tokens, "vitest");
693    let node_test = has_tool(&tokens, "node") && tokens.iter().any(|token| token == "--test");
694    let typescript_test = tokens.iter().any(|token| {
695        [".ts", ".tsx", ".cts", ".mts"]
696            .iter()
697            .any(|extension| token.ends_with(extension) || token.contains(&format!("{extension}*")))
698    });
699    let owns_build = ["vite", "tsc", "webpack", "rollup", "next", "remix"]
700        .iter()
701        .any(|tool| has_tool(&tokens, tool));
702    let executes_source_directly = (source_transforming_runner && !tests_import_build_output(root))
703        || (node_test && typescript_test && !owns_build);
704    let build_command = if script(&manifest, "build").is_some() && !executes_source_directly {
705        vec!["npm".into(), "run".into(), "build".into()]
706    } else {
707        Vec::new()
708    };
709    let build_tokens = command_tokens(&expanded_command(root, &build_command));
710    let uses_vite_build = has_tool(&build_tokens, "vite") || has_tool(&build_tokens, "vite-node");
711    let playwright_exports = if playwright_module == discovered_playwright.module {
712        discovered_playwright.exports
713    } else {
714        vec![playwright_test_export.clone(), "expect".into()]
715    };
716    Ok(CoverageProject {
717        root: root.to_owned(),
718        source_roots,
719        source_files,
720        source_scope: scope,
721        source_limitations: limitations,
722        playwright_config,
723        vitest_config,
724        jest_config,
725        uses_jest,
726        playwright_module,
727        playwright_test_export,
728        playwright_exports,
729        build_adapter: if build_command.is_empty() {
730            BuildAdapter::Direct
731        } else if uses_vite_build {
732            BuildAdapter::Vite
733        } else {
734            BuildAdapter::Generic
735        },
736        build_command,
737        build_environment: infer_build_environment(root, command, environment),
738    })
739}
740
741#[cfg(test)]
742mod tests {
743    use std::time::{SystemTime, UNIX_EPOCH};
744
745    use super::*;
746
747    fn project(label: &str, files: &[(&str, &str)]) -> PathBuf {
748        let nonce = SystemTime::now()
749            .duration_since(UNIX_EPOCH)
750            .unwrap()
751            .as_nanos();
752        let root = std::env::temp_dir().join(format!(
753            "supercov-project-{label}-{}-{nonce}",
754            std::process::id()
755        ));
756        fs::create_dir_all(&root).unwrap();
757        for (file, contents) in files {
758            let path = root.join(file);
759            fs::create_dir_all(path.parent().unwrap()).unwrap();
760            fs::write(path, contents).unwrap();
761        }
762        root
763    }
764
765    fn command(values: &[&str]) -> Vec<String> {
766        values.iter().map(|value| (*value).into()).collect()
767    }
768
769    #[test]
770    fn discovers_conventional_vite_playwright_and_vitest_configuration() {
771        let root = project(
772            "vite",
773            &[
774                (
775                    "package.json",
776                    r#"{"scripts":{"build":"vite build"},"devDependencies":{"vite":"1"}}"#,
777                ),
778                ("src/main.ts", "export const ready = true"),
779                ("playwright.config.ts", "export default {}"),
780                ("vitest.config.ts", "export default {}"),
781                (
782                    "tests/example.spec.ts",
783                    "import { test } from '@playwright/test'",
784                ),
785            ],
786        );
787        let discovered = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
788        assert_eq!(discovered.source_roots, ["src"]);
789        assert_eq!(
790            discovered.playwright_config,
791            Some(root.join("playwright.config.ts"))
792        );
793        assert_eq!(
794            discovered.vitest_config,
795            Some(root.join("vitest.config.ts"))
796        );
797        assert_eq!(discovered.playwright_module, "@playwright/test");
798        assert_eq!(discovered.playwright_test_export, "test");
799        assert_eq!(discovered.playwright_exports, ["test"]);
800        assert_eq!(discovered.build_adapter, BuildAdapter::Vite);
801        assert_eq!(discovered.build_command, command(&["npm", "run", "build"]));
802        fs::remove_dir_all(root).unwrap();
803    }
804
805    #[test]
806    fn skips_unrelated_builds_for_source_transforming_and_node_test_suites() {
807        for (label, test_script, test_file) in [
808            ("jest", "jest", "require('../src/index.ts')"),
809            ("vitest", "vitest run", "import '../src/index.ts'"),
810            (
811                "node",
812                "node --test tests/*.test.ts",
813                "import '../src/index.ts'",
814            ),
815        ] {
816            let root = project(
817                label,
818                &[
819                    (
820                        "package.json",
821                        &format!(
822                            r#"{{"scripts":{{"build":"node build","test":"{test_script}"}}}}"#
823                        ),
824                    ),
825                    ("src/index.ts", "export const ready = true"),
826                    ("tests/index.test.ts", test_file),
827                ],
828            );
829            let discovered =
830                discover_coverage_project(&root, &BTreeMap::new(), &command(&["npm", "test"]))
831                    .unwrap();
832            assert_eq!(discovered.build_adapter, BuildAdapter::Direct, "{label}");
833            assert!(discovered.build_command.is_empty(), "{label}");
834            fs::remove_dir_all(root).unwrap();
835        }
836    }
837
838    #[test]
839    fn retains_the_build_when_tests_import_compiled_output() {
840        let root = project(
841            "compiled",
842            &[
843                (
844                    "package.json",
845                    r#"{"scripts":{"build":"tsc","test":"jest --runInBand"}}"#,
846                ),
847                ("src/index.ts", "export const ready = true"),
848                ("test/index.test.js", "require('../dist/index.js')"),
849            ],
850        );
851        let discovered =
852            discover_coverage_project(&root, &BTreeMap::new(), &command(&["npm", "test"])).unwrap();
853        assert_eq!(discovered.build_adapter, BuildAdapter::Generic);
854        assert!(discovered.uses_jest);
855        fs::remove_dir_all(root).unwrap();
856    }
857
858    #[test]
859    fn discovers_a_project_owned_playwright_fixture_via_the_ast() {
860        let root = project(
861            "fixture",
862            &[
863                ("package.json", r#"{"scripts":{"build":"vite build"}}"#),
864                ("app/root.tsx", "export default null"),
865                (
866                    "tests/nested/playwright.browser.config.ts",
867                    "export default {}",
868                ),
869                (
870                    "tests/example.spec.ts",
871                    "import { type Ignored, browserTest as test, expect, fixtureValue } from '@acme/browser-fixtures'",
872                ),
873            ],
874        );
875        let discovered = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
876        assert_eq!(
877            discovered.playwright_config,
878            Some(root.join("tests/nested/playwright.browser.config.ts"))
879        );
880        assert_eq!(discovered.playwright_module, "@acme/browser-fixtures");
881        assert_eq!(discovered.playwright_test_export, "browserTest");
882        assert_eq!(
883            discovered.playwright_exports,
884            ["browserTest", "expect", "fixtureValue"]
885        );
886        fs::remove_dir_all(root).unwrap();
887    }
888
889    #[test]
890    fn infers_only_unset_build_flags_referenced_by_the_project_ast() {
891        let root = project(
892            "environment",
893            &[
894                (
895                    "package.json",
896                    r#"{"scripts":{"build":"vite build","test:isolated":"node tools/run.js"}}"#,
897                ),
898                ("app/root.ts", "export const ready = true"),
899                (
900                    "vite.config.ts",
901                    "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 }",
902                ),
903            ],
904        );
905        let discovered = discover_coverage_project(
906            &root,
907            &BTreeMap::new(),
908            &command(&["npm", "run", "test:isolated"]),
909        )
910        .unwrap();
911        assert_eq!(
912            discovered.build_environment,
913            BTreeMap::from([("TEST_ISOLATED".into(), "true".into())])
914        );
915        fs::remove_dir_all(root).unwrap();
916    }
917
918    #[test]
919    fn environment_overrides_are_authoritative() {
920        let root = project(
921            "override",
922            &[
923                ("package.json", r#"{"scripts":{"build":"vite build"}}"#),
924                ("custom/main.ts", "main"),
925                ("configs/browser.ts", "config"),
926                (
927                    "tests/example.spec.ts",
928                    "import { test } from '@playwright/test'",
929                ),
930            ],
931        );
932        let environment = BTreeMap::from([
933            ("SUPERCOV_SOURCE_ROOTS".into(), "custom".into()),
934            (
935                "SUPERCOV_PLAYWRIGHT_CONFIG".into(),
936                "configs/browser.ts".into(),
937            ),
938            ("SUPERCOV_PLAYWRIGHT_MODULE".into(), "@custom/test".into()),
939            ("SUPERCOV_PLAYWRIGHT_TEST_EXPORT".into(), "scenario".into()),
940        ]);
941        let discovered = discover_coverage_project(&root, &environment, &[]).unwrap();
942        assert_eq!(discovered.source_roots, ["custom"]);
943        assert_eq!(
944            discovered.playwright_config,
945            Some(root.join("configs/browser.ts"))
946        );
947        assert_eq!(discovered.playwright_module, "@custom/test");
948        assert_eq!(discovered.playwright_test_export, "scenario");
949        assert_eq!(discovered.playwright_exports, ["scenario", "expect"]);
950        fs::remove_dir_all(root).unwrap();
951    }
952}