1use 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 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
456fn 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 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
562fn 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 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
749pub 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 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 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 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}