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_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 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
86 pub browser_runner: bool,
87 #[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 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
465fn 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 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
571fn 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 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
758fn 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
808fn 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
819pub 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 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 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 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 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 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 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}