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, CallExpression, Expression, ImportDeclarationSpecifier,
12 ImportExpression, ImportOrExportKind, Program, Statement,
13};
14use oxc_ast_visit::{Visit, walk};
15use oxc_parser::Parser;
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19use crate::source_discovery::{
20 DiscoveredSourceScope, SourceDiscoveryError, SourceLimitation, SourceScope,
21 discover_source_scope,
22};
23
24const PLAYWRIGHT_CONFIGS: &[&str] = &[
25 "playwright.config.ts",
26 "playwright.config.mts",
27 "playwright.config.js",
28 "playwright.config.mjs",
29 "playwright.config.cts",
30 "playwright.config.cjs",
31];
32const VITEST_CONFIGS: &[&str] = &[
33 "vitest.config.ts",
34 "vitest.config.mts",
35 "vitest.config.js",
36 "vitest.config.mjs",
37 "vitest.config.cts",
38 "vitest.config.cjs",
39 "vite.config.ts",
40 "vite.config.mts",
41 "vite.config.js",
42 "vite.config.mjs",
43];
44const JEST_CONFIGS: &[&str] = &[
45 "jest.config.ts",
46 "jest.config.mts",
47 "jest.config.js",
48 "jest.config.mjs",
49 "jest.config.cts",
50 "jest.config.cjs",
51];
52const TEST_DIRECTORIES: &[&str] = &["test", "tests", "e2e", "spec", "specs"];
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "camelCase", deny_unknown_fields)]
56pub struct CoverageProject {
57 pub root: PathBuf,
58 pub source_roots: Vec<String>,
59 pub source_files: Vec<String>,
60 pub source_scope: SourceScope,
61 pub source_limitations: Vec<SourceLimitation>,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub playwright_config: Option<PathBuf>,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub vitest_config: Option<PathBuf>,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub jest_config: Option<PathBuf>,
68 pub uses_jest: bool,
69 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
73 pub browser_runner: bool,
74 pub playwright_module: String,
75 pub playwright_test_export: String,
76 pub playwright_exports: Vec<String>,
77 #[serde(default, skip_serializing_if = "Vec::is_empty")]
81 pub unrun_build: Vec<String>,
82}
83
84#[derive(Debug)]
85pub enum ProjectDiscoveryError {
86 Source(SourceDiscoveryError),
87 Io { path: PathBuf, source: io::Error },
88 NoSourceFiles,
89}
90
91impl From<SourceDiscoveryError> for ProjectDiscoveryError {
92 fn from(value: SourceDiscoveryError) -> Self {
93 Self::Source(value)
94 }
95}
96
97impl std::fmt::Display for ProjectDiscoveryError {
98 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99 match self {
100 Self::Source(error) => write!(formatter, "{error}"),
101 Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
102 Self::NoSourceFiles => write!(
103 formatter,
104 "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"
105 ),
106 }
107 }
108}
109
110impl std::error::Error for ProjectDiscoveryError {}
111
112fn package_json(root: &Path) -> Value {
113 fs::read(root.join("package.json"))
114 .ok()
115 .and_then(|contents| serde_json::from_slice(&contents).ok())
116 .unwrap_or(Value::Null)
117}
118
119fn script<'a>(manifest: &'a Value, name: &str) -> Option<&'a str> {
120 manifest.get("scripts")?.get(name)?.as_str()
121}
122
123fn regular_file(path: &Path) -> bool {
124 fs::symlink_metadata(path)
125 .map(|metadata| metadata.file_type().is_file())
126 .unwrap_or(false)
127}
128
129fn source_file(path: &Path) -> bool {
130 let name = path
131 .file_name()
132 .and_then(|name| name.to_str())
133 .unwrap_or("");
134 let lower = name.to_ascii_lowercase();
135 [
136 ".js", ".jsx", ".ts", ".tsx", ".cjs", ".cjsx", ".cts", ".ctsx", ".mjs", ".mjsx", ".mts",
137 ".mtsx",
138 ]
139 .iter()
140 .any(|extension| lower.ends_with(extension))
141}
142
143fn read_directory(path: &Path) -> Result<Vec<fs::DirEntry>, ProjectDiscoveryError> {
144 let mut entries = fs::read_dir(path)
145 .map_err(|source| ProjectDiscoveryError::Io {
146 path: path.to_owned(),
147 source,
148 })?
149 .collect::<Result<Vec<_>, _>>()
150 .map_err(|source| ProjectDiscoveryError::Io {
151 path: path.to_owned(),
152 source,
153 })?;
154 entries.sort_by_key(fs::DirEntry::file_name);
155 Ok(entries)
156}
157
158fn parse_program<'a>(
159 allocator: &'a Allocator,
160 path: &Path,
161 source: &'a str,
162) -> Option<Program<'a>> {
163 let source_type = crate::js_instrumenter::project_source_type(path).ok()?;
164 let parsed = Parser::new(allocator, source, source_type).parse();
165 parsed.errors.is_empty().then_some(parsed.program)
166}
167
168#[derive(Debug, Clone)]
169struct TestApiCandidate {
170 module: String,
171 score: usize,
172 test_export: Option<String>,
173 exports: Vec<String>,
174}
175
176fn imported_test_apis(path: &Path, source: &str) -> Vec<TestApiCandidate> {
177 let allocator = Allocator::default();
178 let Some(program) = parse_program(&allocator, path, source) else {
179 return Vec::new();
180 };
181 let mut by_module = BTreeMap::<String, TestApiCandidate>::new();
186 for statement in &program.body {
187 let Statement::ImportDeclaration(declaration) = statement else {
188 continue;
189 };
190 if declaration.import_kind == ImportOrExportKind::Type {
191 continue;
192 }
193 let module = declaration.source.value.to_string();
194 let candidate = by_module
195 .entry(module.clone())
196 .or_insert_with(|| TestApiCandidate {
197 module,
198 score: 0,
199 test_export: None,
200 exports: Vec::new(),
201 });
202 for specifier in declaration.specifiers.iter().flatten() {
203 let ImportDeclarationSpecifier::ImportSpecifier(specifier) = specifier else {
204 continue;
205 };
206 if specifier.import_kind == ImportOrExportKind::Type {
207 continue;
208 }
209 let imported = specifier.imported.name().to_string();
210 let local = specifier.local.name.as_str();
211 candidate.exports.push(imported.clone());
212 if local == "test" {
213 candidate.score += 20;
214 candidate.test_export = Some(imported.clone());
215 } else if imported.to_ascii_lowercase().ends_with("test") {
216 candidate.score += 8;
217 }
218 if local == "expect" || imported == "expect" {
219 candidate.score += 10;
220 }
221 }
222 }
223 by_module
224 .into_values()
225 .filter(|candidate| candidate.score > 0)
226 .map(|mut candidate| {
227 if candidate.module == "@playwright/test" {
228 candidate.score += 100;
229 } else if candidate.module.to_ascii_lowercase().contains("playwright") {
230 candidate.score += 5;
231 }
232 candidate
233 })
234 .collect()
235}
236
237fn test_api_candidates(directory: &Path, output: &mut Vec<TestApiCandidate>) {
238 let Ok(entries) = read_directory(directory) else {
239 return;
240 };
241 for entry in entries {
242 let name = entry.file_name();
243 let Some(name) = name.to_str() else { continue };
244 if name == "node_modules" || name == "results" || name.starts_with('.') {
245 continue;
246 }
247 let path = entry.path();
248 let Ok(file_type) = entry.file_type() else {
249 continue;
250 };
251 if file_type.is_symlink() {
252 continue;
253 }
254 if file_type.is_dir() {
255 test_api_candidates(&path, output);
256 } else if file_type.is_file()
257 && source_file(&path)
258 && let Ok(source) = fs::read_to_string(&path)
259 {
260 output.extend(imported_test_apis(&path, &source));
261 }
262 }
263}
264
265#[derive(Debug, Clone)]
266struct PlaywrightAdapter {
267 module: String,
268 test_export: String,
269 exports: Vec<String>,
270}
271
272fn discover_playwright_adapter(root: &Path) -> PlaywrightAdapter {
273 let mut candidates = Vec::new();
274 for directory in TEST_DIRECTORIES {
275 test_api_candidates(&root.join(directory), &mut candidates);
276 }
277 let mut scores = HashMap::<String, usize>::new();
278 for candidate in &candidates {
279 *scores.entry(candidate.module.clone()).or_default() += candidate.score;
280 }
281 let module = scores
282 .into_iter()
283 .min_by(|(left_module, left_score), (right_module, right_score)| {
284 right_score
285 .cmp(left_score)
286 .then_with(|| left_module.cmp(right_module))
287 })
288 .map(|(module, _)| module)
289 .unwrap_or_else(|| "@playwright/test".into());
290 let matching = candidates
291 .iter()
292 .filter(|candidate| candidate.module == module)
293 .collect::<Vec<_>>();
294 let test_export = matching
295 .iter()
296 .filter_map(|candidate| {
297 candidate
298 .test_export
299 .as_ref()
300 .map(|export| (candidate.score, export))
301 })
302 .max_by_key(|(score, _)| *score)
303 .map(|(_, export)| export.clone())
304 .unwrap_or_else(|| "test".into());
305 let exports = matching
306 .iter()
307 .flat_map(|candidate| candidate.exports.iter().cloned())
308 .collect::<BTreeSet<_>>()
309 .into_iter()
310 .collect();
311 PlaywrightAdapter {
312 module,
313 test_export,
314 exports,
315 }
316}
317
318fn nested_playwright_configs(root: &Path) -> Vec<PathBuf> {
319 fn visit(directory: &Path, depth: usize, found: &mut Vec<PathBuf>) {
320 if depth > 4 {
321 return;
322 }
323 let Ok(entries) = read_directory(directory) else {
324 return;
325 };
326 for entry in entries {
327 let name = entry.file_name();
328 let Some(name) = name.to_str() else { continue };
329 if name.starts_with('.') || name == "node_modules" {
330 continue;
331 }
332 let path = entry.path();
333 let Ok(file_type) = entry.file_type() else {
334 continue;
335 };
336 if file_type.is_symlink() {
337 continue;
338 }
339 if file_type.is_dir() {
340 visit(&path, depth + 1, found);
341 } else if file_type.is_file() && playwright_config_name(name) {
342 found.push(path);
343 }
344 }
345 }
346
347 let mut found = Vec::new();
348 for directory in ["test", "tests", "e2e"] {
349 visit(&root.join(directory), 0, &mut found);
350 }
351 found.sort();
352 found
353}
354
355fn playwright_config_name(name: &str) -> bool {
356 let lower = name.to_ascii_lowercase();
357 if !lower.starts_with("playwright") || !lower.contains(".config.") {
358 return false;
359 }
360 source_file(Path::new(name))
361 && lower[..lower.find(".config.").unwrap_or(0)]
362 .chars()
363 .all(|character| {
364 character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_')
365 })
366}
367
368pub fn expanded_command(root: &Path, command: &[String]) -> String {
369 let manifest = package_json(root);
370 let executable = command
371 .first()
372 .and_then(|value| Path::new(value).file_name())
373 .and_then(|value| value.to_str())
374 .unwrap_or("")
375 .trim_end_matches(".cmd")
376 .trim_end_matches(".exe")
377 .to_ascii_lowercase();
378 let run_index = command.iter().position(|argument| argument == "run");
379 let script_name = run_index
380 .and_then(|index| command.get(index + 1))
381 .or_else(|| {
382 (["npm", "pnpm", "yarn", "bun"].contains(&executable.as_str()) && run_index.is_none())
383 .then(|| command.get(1))
384 .flatten()
385 });
386 let joined = command.join(" ");
387 if ["npm", "pnpm", "yarn", "bun"].contains(&executable.as_str())
388 && let Some(script_name) = script_name
389 {
390 return format!("{joined} {}", script(&manifest, script_name).unwrap_or(""));
391 }
392 joined
393}
394
395pub fn script_command(root: &Path, command: &[String]) -> Option<String> {
401 let executable = command
402 .first()
403 .and_then(|value| Path::new(value).file_name())
404 .and_then(|value| value.to_str())?
405 .trim_end_matches(".cmd")
406 .trim_end_matches(".exe")
407 .to_ascii_lowercase();
408 if !["npm", "pnpm", "yarn", "bun"].contains(&executable.as_str()) {
409 return None;
410 }
411 let name = match command.iter().position(|argument| argument == "run") {
412 Some(run) => run + 1,
413 None => 1,
414 };
415 let manifest = package_json(root);
416 let script = script(&manifest, command.get(name)?)?;
417 let mut extra = &command[name + 1..];
418 if extra.first().is_some_and(|argument| argument == "--") {
420 extra = &extra[1..];
421 }
422 let quote = |argument: &String| {
423 let plain = !argument.is_empty()
424 && argument
425 .chars()
426 .all(|c| c.is_ascii_alphanumeric() || "_./:=,+@%-".contains(c));
427 if plain {
428 argument.clone()
429 } else {
430 format!("'{}'", argument.replace('\'', r"'\''"))
431 }
432 };
433 Some(
434 std::iter::once(script.to_owned())
435 .chain(extra.iter().map(quote))
436 .collect::<Vec<_>>()
437 .join(" "),
438 )
439}
440
441fn relative_build_output(source: &str) -> bool {
442 let mut rest = source;
443 let mut relative = false;
444 while let Some(stripped) = rest.strip_prefix("../").or_else(|| rest.strip_prefix("./")) {
445 relative = true;
446 rest = stripped;
447 }
448 relative
449 && matches!(
450 rest.split('/').next(),
451 Some("dist" | "build" | "out" | "output")
452 )
453}
454
455fn string_expression<'a>(expression: &'a Expression<'_>) -> Option<&'a str> {
456 let Expression::StringLiteral(literal) = expression else {
457 return None;
458 };
459 Some(literal.value.as_str())
460}
461
462struct BuildOutputScanner<'a> {
463 found: bool,
464 build_output_scripts: &'a BTreeSet<String>,
465}
466
467fn child_process_launcher(expression: &Expression<'_>) -> bool {
468 let name = match expression {
469 Expression::Identifier(identifier) => Some(identifier.name.as_str()),
470 Expression::StaticMemberExpression(member) => Some(member.property.name.as_str()),
471 _ => None,
472 };
473 name.is_some_and(|name| {
474 [
475 "spawn",
476 "spawnSync",
477 "exec",
478 "execSync",
479 "execFile",
480 "execFileSync",
481 ]
482 .contains(&name)
483 })
484}
485
486fn launched_package_script<'a>(
492 program: &'a str,
493 arguments: Option<&'a oxc_ast::ast::ArrayExpression<'a>>,
494) -> Option<String> {
495 let words: Vec<String> = match arguments {
496 Some(arguments) => {
497 if !package_manager(program) {
498 return None;
499 }
500 arguments
501 .elements
502 .iter()
503 .map(|element| match element {
504 ArrayExpressionElement::StringLiteral(value) => Some(value.value.to_string()),
505 _ => None,
506 })
507 .collect::<Option<Vec<_>>>()?
508 }
509 None => {
510 let mut tokens = command_tokens(program);
511 if tokens.is_empty() || !package_manager(&tokens.remove(0)) {
512 return None;
513 }
514 tokens
515 }
516 };
517 match words.first().map(String::as_str) {
518 Some("run") => words.get(1).cloned(),
519 Some(_) => words.first().cloned(),
520 None => None,
521 }
522}
523
524fn package_manager(value: &str) -> bool {
525 let executable = value
526 .rsplit(['/', '\\'])
527 .next()
528 .unwrap_or(value)
529 .trim_end_matches(".cmd")
530 .trim_end_matches(".exe");
531 ["npm", "pnpm", "yarn", "bun"].contains(&executable)
532}
533
534fn script_references_build_output(command: &str) -> bool {
535 command_tokens(command).iter().any(|token| {
536 let token = token.trim_start_matches("../").trim_start_matches("./");
537 let mut segments = token.split(['/', '\\']);
538 let directory = segments.next();
539 segments.next().is_some() && matches!(directory, Some("dist" | "build" | "out" | "output"))
543 })
544}
545
546fn build_output_scripts(manifest: &Value) -> BTreeSet<String> {
547 manifest
548 .get("scripts")
549 .and_then(Value::as_object)
550 .into_iter()
551 .flatten()
552 .filter_map(|(name, command)| match command.as_str() {
553 Some(command) if script_references_build_output(command) => Some(name.clone()),
554 _ => None,
555 })
556 .collect()
557}
558
559impl<'a> Visit<'a> for BuildOutputScanner<'_> {
560 fn visit_import_declaration(&mut self, declaration: &oxc_ast::ast::ImportDeclaration<'a>) {
561 self.found |= relative_build_output(declaration.source.value.as_str());
562 walk::walk_import_declaration(self, declaration);
563 }
564
565 fn visit_import_expression(&mut self, expression: &ImportExpression<'a>) {
566 if let Some(source) = string_expression(&expression.source) {
567 self.found |= relative_build_output(source);
568 }
569 walk::walk_import_expression(self, expression);
570 }
571
572 fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
573 if matches!(&call.callee, Expression::Identifier(identifier) if identifier.name == "require")
574 && let Some(Argument::StringLiteral(source)) = call.arguments.first()
575 {
576 self.found |= relative_build_output(source.value.as_str());
577 }
578 if child_process_launcher(&call.callee)
579 && let Some(Argument::StringLiteral(program)) = call.arguments.first()
580 {
581 let arguments = match call.arguments.get(1) {
582 Some(Argument::ArrayExpression(arguments)) => Some(&**arguments),
583 _ => None,
584 };
585 self.found |= launched_package_script(program.value.as_str(), arguments)
586 .is_some_and(|script| self.build_output_scripts.contains(&script));
587 }
588 walk::walk_call_expression(self, call);
589 }
590}
591
592fn tests_require_build_output(root: &Path, manifest: &Value) -> bool {
595 fn visit(directory: &Path, build_output_scripts: &BTreeSet<String>) -> bool {
596 let Ok(entries) = read_directory(directory) else {
597 return false;
598 };
599 for entry in entries {
600 let name = entry.file_name();
601 let Some(name) = name.to_str() else { continue };
602 if name == "node_modules" || name.starts_with('.') {
603 continue;
604 }
605 let path = entry.path();
606 let Ok(file_type) = entry.file_type() else {
607 continue;
608 };
609 if file_type.is_symlink() {
610 continue;
611 }
612 if file_type.is_dir() && visit(&path, build_output_scripts) {
613 return true;
614 }
615 if file_type.is_file()
616 && source_file(&path)
617 && let Ok(source) = fs::read_to_string(&path)
618 {
619 let allocator = Allocator::default();
620 if let Some(program) = parse_program(&allocator, &path, &source) {
621 let mut scanner = BuildOutputScanner {
622 found: false,
623 build_output_scripts,
624 };
625 scanner.visit_program(&program);
626 if scanner.found {
627 return true;
628 }
629 }
630 }
631 }
632 false
633 }
634
635 let build_output_scripts = build_output_scripts(manifest);
636 ["test", "tests", "spec", "specs", "e2e", "__tests__"]
637 .iter()
638 .any(|directory| visit(&root.join(directory), &build_output_scripts))
639}
640
641fn command_tokens(value: &str) -> Vec<String> {
642 value
643 .split_whitespace()
644 .map(|token| {
645 token
646 .trim_matches(|character: char| matches!(character, '\'' | '"' | '(' | ')'))
647 .trim_end_matches([';', ','])
648 .to_ascii_lowercase()
649 })
650 .collect()
651}
652
653fn configured_by_command(root: &Path, command: &str, tool: &str) -> Option<PathBuf> {
657 let tokens = command
658 .split_whitespace()
659 .map(|token| token.trim_matches(|character: char| matches!(character, '\'' | '"')))
660 .collect::<Vec<_>>();
661 let mut in_tool = false;
662 for (index, token) in tokens.iter().enumerate() {
663 if matches!(*token, "&&" | "||" | ";" | "|") {
664 in_tool = false;
665 continue;
666 }
667 let file = token.rsplit(['/', '\\']).next().unwrap_or(token);
668 let file = file.trim_end_matches(".cmd").trim_end_matches(".exe");
669 if file == tool {
670 in_tool = true;
671 continue;
672 }
673 if !in_tool {
674 continue;
675 }
676 let value = match token.strip_prefix("--config=") {
677 Some(value) => Some(value),
678 None if matches!(*token, "--config" | "-c") => tokens.get(index + 1).copied(),
679 None => None,
680 };
681 if let Some(value) = value
682 .map(|value| root.join(value))
683 .filter(|path| path.is_file())
684 {
685 return Some(value);
686 }
687 }
688 None
689}
690
691fn has_tool(tokens: &[String], tool: &str) -> bool {
692 tokens.iter().any(|token| {
693 let file = token.rsplit('/').next().unwrap_or(token);
694 file == tool
695 || file.strip_suffix(".cmd") == Some(tool)
696 || file.strip_suffix(".exe") == Some(tool)
697 })
698}
699
700fn reachable_script_tokens(manifest: &Value, tokens: &[String]) -> Vec<String> {
703 let mut all = tokens.to_vec();
704 let mut visited = BTreeSet::new();
705 let mut index = 0;
706 while index < all.len() {
707 let runner = all[index]
708 .rsplit('/')
709 .next()
710 .unwrap_or(&all[index])
711 .to_owned();
712 let named = match runner.as_str() {
713 "npm" | "pnpm" | "yarn" | "bun" => {
714 let next = all.get(index + 1).map(String::as_str);
715 match next {
716 Some("run" | "run-script") => all.get(index + 2).cloned(),
717 Some("test" | "t") => Some("test".to_owned()),
718 Some(name) if runner != "npm" => Some(name.to_owned()),
719 _ => None,
720 }
721 }
722 _ => None,
723 };
724 if let Some(name) = named
725 && visited.insert(name.clone())
726 && let Some(body) = script(manifest, &name)
727 {
728 all.extend(command_tokens(body));
729 }
730 index += 1;
731 }
732 all
733}
734
735fn configured_path(
736 root: &Path,
737 environment: &BTreeMap<String, String>,
738 key: &str,
739) -> Option<PathBuf> {
740 environment.get(key).map(|value| root.join(value))
741}
742
743fn first_config(root: &Path, candidates: &[&str]) -> Option<PathBuf> {
744 candidates
745 .iter()
746 .map(|candidate| root.join(candidate))
747 .find(|path| regular_file(path))
748}
749
750fn build_package_manager(command: &[String]) -> String {
754 command
755 .first()
756 .filter(|program| package_manager(program))
757 .cloned()
758 .unwrap_or_else(|| "npm".into())
759}
760
761pub fn discover_coverage_project(
762 root: &Path,
763 environment: &BTreeMap<String, String>,
764 command: &[String],
765) -> Result<CoverageProject, ProjectDiscoveryError> {
766 let project = discover_coverage_project_of_any_size(root, environment, command)?;
767 if project.source_files.is_empty() {
768 return Err(ProjectDiscoveryError::NoSourceFiles);
769 }
770 Ok(project)
771}
772
773pub fn discover_coverage_project_of_any_size(
776 root: &Path,
777 environment: &BTreeMap<String, String>,
778 command: &[String],
779) -> Result<CoverageProject, ProjectDiscoveryError> {
780 let manifest = package_json(root);
781 let configured_roots = crate::source_discovery::configured_source_roots(environment);
782 let DiscoveredSourceScope {
783 source_files,
784 source_roots,
785 scope,
786 limitations,
787 } = discover_source_scope(root, configured_roots.as_deref())?;
788 let expanded = expanded_command(root, command);
792 let playwright_config = configured_path(root, environment, "SUPERCOV_PLAYWRIGHT_CONFIG")
793 .or_else(|| configured_by_command(root, &expanded, "playwright"))
794 .or_else(|| first_config(root, PLAYWRIGHT_CONFIGS))
795 .or_else(|| nested_playwright_configs(root).into_iter().next());
796 let vitest_config = configured_path(root, environment, "SUPERCOV_VITEST_CONFIG")
797 .or_else(|| configured_by_command(root, &expanded, "vitest"))
798 .or_else(|| first_config(root, VITEST_CONFIGS));
799 let jest_config = configured_path(root, environment, "SUPERCOV_JEST_CONFIG")
800 .or_else(|| configured_by_command(root, &expanded, "jest"))
801 .or_else(|| first_config(root, JEST_CONFIGS));
802 let discovered_playwright = discover_playwright_adapter(root);
803 let playwright_module = environment
804 .get("SUPERCOV_PLAYWRIGHT_MODULE")
805 .cloned()
806 .unwrap_or_else(|| discovered_playwright.module.clone());
807 let playwright_test_export = environment
808 .get("SUPERCOV_PLAYWRIGHT_TEST_EXPORT")
809 .cloned()
810 .unwrap_or_else(|| {
811 if playwright_module == discovered_playwright.module {
812 discovered_playwright.test_export.clone()
813 } else {
814 "test".into()
815 }
816 });
817 let expanded_test_command = expanded;
818 let tokens = command_tokens(&expanded_test_command);
819 let uses_jest =
820 jest_config.is_some() || has_tool(&tokens, "jest") || manifest.get("jest").is_some();
821 let script_tokens = reachable_script_tokens(&manifest, &tokens);
822 let browser_runner = ["karma", "testem", "airtap", "zuul", "mochify"]
823 .iter()
824 .any(|tool| has_tool(&script_tokens, tool));
825 let source_transforming_runner = has_tool(&tokens, "jest") || has_tool(&tokens, "vitest");
826 let node_test = has_tool(&tokens, "node") && tokens.iter().any(|token| token == "--test");
827 let typescript_test = tokens.iter().any(|token| {
828 [".ts", ".tsx", ".cts", ".mts"]
829 .iter()
830 .any(|extension| token.ends_with(extension) || token.contains(&format!("{extension}*")))
831 });
832 let owns_build = ["vite", "tsc", "webpack", "rollup", "next", "remix"]
833 .iter()
834 .any(|tool| has_tool(&tokens, tool));
835 let executes_source_directly = (source_transforming_runner
839 || (node_test && typescript_test && !owns_build))
840 && !tests_require_build_output(root, &manifest);
841 let build_script = script(&manifest, "build")
848 .map(command_tokens)
849 .unwrap_or_default();
850 let command_builds = !build_script.is_empty()
851 && build_script
852 .iter()
853 .all(|token| script_tokens.contains(token));
854 let unrun_build = if !build_script.is_empty() && !command_builds && !executes_source_directly {
855 vec![build_package_manager(command), "run".into(), "build".into()]
856 } else {
857 Vec::new()
858 };
859 let playwright_exports = if playwright_module == discovered_playwright.module {
860 discovered_playwright.exports
861 } else {
862 vec![playwright_test_export.clone(), "expect".into()]
863 };
864 Ok(CoverageProject {
865 root: root.to_owned(),
866 source_roots,
867 source_files,
868 source_scope: scope,
869 source_limitations: limitations,
870 playwright_config,
871 vitest_config,
872 jest_config,
873 uses_jest,
874 browser_runner,
875 playwright_module,
876 playwright_test_export,
877 playwright_exports,
878 unrun_build,
879 })
880}
881
882#[cfg(test)]
883mod tests {
884 use std::time::{SystemTime, UNIX_EPOCH};
885
886 use super::*;
887
888 #[test]
891 fn a_script_command_carries_the_arguments_after_the_script_name() {
892 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
893 let root = std::env::temp_dir().join(format!(
894 "supercov-script-command-{}-{}",
895 std::process::id(),
896 COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
897 ));
898 std::fs::create_dir(&root).unwrap();
899 std::fs::write(
900 root.join("package.json"),
901 r#"{ "scripts": { "test": "cargo test", "unit": "cargo test --lib" } }"#,
902 )
903 .unwrap();
904 let words = |line: &str| line.split(' ').map(str::to_owned).collect::<Vec<_>>();
905 let command = |line: &str| script_command(&root, &words(line));
906 assert_eq!(
907 command("npm test -- adds").as_deref(),
908 Some("cargo test adds")
909 );
910 assert_eq!(command("npm run unit").as_deref(), Some("cargo test --lib"));
911 assert_eq!(
912 command("pnpm run unit adds --release").as_deref(),
913 Some("cargo test --lib adds --release")
914 );
915 let mut quoted = words("npm test --");
916 quoted.extend(["two words".into(), "it's".into(), String::new()]);
917 assert_eq!(
918 script_command(&root, "ed).as_deref(),
919 Some(r"cargo test 'two words' 'it'\''s' ''")
920 );
921 assert_eq!(command("cargo test"), None);
922 assert_eq!(command("npm run missing"), None);
923 std::fs::remove_dir_all(&root).unwrap();
924 }
925
926 fn project(label: &str, files: &[(&str, &str)]) -> crate::test_directory::TestDirectory {
927 static UNIQUE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
930 let nonce = SystemTime::now()
931 .duration_since(UNIX_EPOCH)
932 .unwrap()
933 .as_nanos();
934 let root = std::env::temp_dir().join(format!(
935 "supercov-project-{label}-{}-{nonce}-{}",
936 std::process::id(),
937 UNIQUE.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
938 ));
939 fs::create_dir(&root).unwrap();
940 let root = crate::test_directory::TestDirectory::adopt(root);
941 for (file, contents) in files {
942 let path = root.join(file);
943 fs::create_dir_all(path.parent().unwrap()).unwrap();
944 fs::write(path, contents).unwrap();
945 }
946 root
947 }
948
949 fn command(values: &[&str]) -> Vec<String> {
950 values.iter().map(|value| (*value).into()).collect()
951 }
952
953 #[test]
954 fn the_configuration_the_command_names_comes_first() {
955 let root = project(
958 "named-config",
959 &[
960 (
961 "package.json",
962 r#"{"scripts":{"test:e2e":"playwright test --config e2e/Playwright.config.ts","test:unit":"vitest run -c config/vitest.unit.ts"}}"#,
963 ),
964 ("src/main.ts", "export const ready = true"),
965 ("playwright.config.ts", "export default {}"),
966 ("e2e/Playwright.config.ts", "export default {}"),
967 ("vitest.config.ts", "export default {}"),
968 ("config/vitest.unit.ts", "export default {}"),
969 ],
970 );
971 let discover = |args: &[&str]| {
972 discover_coverage_project(&root, &BTreeMap::new(), &command(args)).unwrap()
973 };
974 assert_eq!(
975 discover(&["npm", "run", "test:e2e"]).playwright_config,
976 Some(root.join("e2e/Playwright.config.ts"))
977 );
978 assert_eq!(
979 discover(&[
980 "npx",
981 "playwright",
982 "test",
983 "--config=e2e/Playwright.config.ts"
984 ])
985 .playwright_config,
986 Some(root.join("e2e/Playwright.config.ts"))
987 );
988 assert_eq!(
989 discover(&["npm", "run", "test:unit"]).vitest_config,
990 Some(root.join("config/vitest.unit.ts"))
991 );
992 assert_eq!(
994 discover(&["npx", "playwright", "test"]).playwright_config,
995 Some(root.join("playwright.config.ts"))
996 );
997 assert_eq!(
998 discover(&["npx", "vitest", "run", "--config", "missing.ts"]).vitest_config,
999 Some(root.join("vitest.config.ts"))
1000 );
1001 fs::remove_dir_all(root).unwrap();
1002 }
1003
1004 #[test]
1005 fn discovers_conventional_vite_playwright_and_vitest_configuration() {
1006 let root = project(
1007 "vite",
1008 &[
1009 (
1010 "package.json",
1011 r#"{"scripts":{"build":"vite build"},"devDependencies":{"vite":"1"}}"#,
1012 ),
1013 ("src/main.ts", "export const ready = true"),
1014 ("playwright.config.ts", "export default {}"),
1015 ("vitest.config.ts", "export default {}"),
1016 (
1017 "tests/example.spec.ts",
1018 "import { test } from '@playwright/test'",
1019 ),
1020 ],
1021 );
1022 let discovered = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
1023 assert_eq!(discovered.source_roots, ["src"]);
1024 assert_eq!(
1025 discovered.playwright_config,
1026 Some(root.join("playwright.config.ts"))
1027 );
1028 assert_eq!(
1029 discovered.vitest_config,
1030 Some(root.join("vitest.config.ts"))
1031 );
1032 assert_eq!(discovered.playwright_module, "@playwright/test");
1033 assert_eq!(discovered.playwright_test_export, "test");
1034 assert_eq!(discovered.playwright_exports, ["test"]);
1035 assert_eq!(discovered.unrun_build, command(&["npm", "run", "build"]));
1038 fs::remove_dir_all(root).unwrap();
1039 }
1040
1041 #[test]
1042 fn a_browser_runner_the_tests_reach_through_nested_scripts_is_found() {
1043 let root = project(
1045 "karma",
1046 &[
1047 (
1048 "package.json",
1049 r#"{"scripts":{"test":"npm run test:node && npm run test:browser","test:node":"mocha test.js","test:browser":"karma start --single-run"}}"#,
1050 ),
1051 ("src/index.js", "module.exports = 1"),
1052 ],
1053 );
1054 let command = ["npm".to_owned(), "test".to_owned()];
1055 let discovered = discover_coverage_project(&root, &BTreeMap::new(), &command).unwrap();
1056 assert!(discovered.browser_runner);
1057 let node_only = ["npm".to_owned(), "run".to_owned(), "test:node".to_owned()];
1058 let discovered = discover_coverage_project(&root, &BTreeMap::new(), &node_only).unwrap();
1059 assert!(!discovered.browser_runner);
1060 }
1061
1062 #[test]
1063 fn skips_unrelated_builds_for_source_transforming_and_node_test_suites() {
1064 for (label, test_script, test_file) in [
1065 ("jest", "jest", "require('../src/index.ts')"),
1066 ("vitest", "vitest run", "import '../src/index.ts'"),
1067 (
1068 "node",
1069 "node --test tests/*.test.ts",
1070 "import '../src/index.ts'",
1071 ),
1072 ] {
1073 let root = project(
1074 label,
1075 &[
1076 (
1077 "package.json",
1078 &format!(
1079 r#"{{"scripts":{{"build":"node build","test":"{test_script}"}}}}"#
1080 ),
1081 ),
1082 ("src/index.ts", "export const ready = true"),
1083 ("tests/index.test.ts", test_file),
1084 ],
1085 );
1086 let discovered =
1087 discover_coverage_project(&root, &BTreeMap::new(), &command(&["npm", "test"]))
1088 .unwrap();
1089 assert!(discovered.unrun_build.is_empty(), "{label}");
1090 fs::remove_dir_all(root).unwrap();
1091 }
1092 }
1093
1094 #[test]
1095 fn names_the_build_with_the_package_manager_the_tests_were_started_with() {
1096 let root = project(
1097 "yarn-build",
1098 &[
1099 (
1100 "package.json",
1101 r#"{"scripts":{"build":"tsc","test":"jest --runInBand"}}"#,
1102 ),
1103 ("src/index.ts", "export const ready = true"),
1104 ("test/index.test.js", "require('../dist/index.js')"),
1105 ],
1106 );
1107 let discovered =
1108 discover_coverage_project(&root, &BTreeMap::new(), &command(&["yarn", "test"]))
1109 .unwrap();
1110 assert_eq!(discovered.unrun_build, command(&["yarn", "run", "build"]));
1111 fs::remove_dir_all(root).unwrap();
1112 }
1113
1114 #[test]
1115 fn names_the_build_when_tests_import_compiled_output() {
1116 let root = project(
1117 "compiled",
1118 &[
1119 (
1120 "package.json",
1121 r#"{"scripts":{"build":"tsc","test":"jest --runInBand"}}"#,
1122 ),
1123 ("src/index.ts", "export const ready = true"),
1124 ("test/index.test.js", "require('../dist/index.js')"),
1125 ],
1126 );
1127 let discovered =
1128 discover_coverage_project(&root, &BTreeMap::new(), &command(&["npm", "test"])).unwrap();
1129 assert_eq!(discovered.unrun_build, command(&["npm", "run", "build"]));
1130 assert!(discovered.uses_jest);
1131 fs::remove_dir_all(root).unwrap();
1132 }
1133
1134 #[test]
1135 fn names_the_build_when_source_direct_tests_spawn_a_compiled_package_script() {
1136 let root = project(
1137 "spawned-compiled-script",
1138 &[
1139 (
1140 "package.json",
1141 r#"{"scripts":{"build":"tsc","start":"node dist/index.js","test":"node --test tests/*.test.ts"}}"#,
1142 ),
1143 ("src/index.ts", "export const ready = true"),
1144 (
1145 "tests/index.test.ts",
1146 "import { spawn } from 'node:child_process';\nspawn('npm', ['run', 'start']);\n",
1147 ),
1148 ],
1149 );
1150 let discovered =
1151 discover_coverage_project(&root, &BTreeMap::new(), &command(&["npm", "test"])).unwrap();
1152 assert_eq!(discovered.unrun_build, command(&["npm", "run", "build"]));
1153 fs::remove_dir_all(root).unwrap();
1154 }
1155
1156 #[test]
1157 fn names_the_build_when_tests_exec_a_compiled_package_script_as_one_string() {
1158 let root = project(
1163 "exec-compiled-script",
1164 &[
1165 (
1166 "package.json",
1167 r#"{"scripts":{"build":"tsc","start":"node dist/index.js","test":"node --test tests/*.test.ts"}}"#,
1168 ),
1169 ("src/index.ts", "export const ready = true"),
1170 (
1171 "tests/index.test.ts",
1172 "import { execSync } from 'node:child_process';\nexecSync('npm run start');\n",
1173 ),
1174 ],
1175 );
1176 let discovered =
1177 discover_coverage_project(&root, &BTreeMap::new(), &command(&["npm", "test"])).unwrap();
1178 assert_eq!(discovered.unrun_build, command(&["npm", "run", "build"]));
1179 fs::remove_dir_all(root).unwrap();
1180 }
1181
1182 #[test]
1183 fn keeps_source_direct_when_a_spawned_script_only_runs_a_build_subcommand() {
1184 let root = project(
1185 "spawned-build-subcommand",
1186 &[
1187 (
1188 "package.json",
1189 r#"{"scripts":{"build":"tsc","dev":"vite build","test":"node --test tests/*.test.ts"}}"#,
1190 ),
1191 ("src/index.ts", "export const ready = true"),
1192 (
1193 "tests/index.test.ts",
1194 "import { spawn } from 'node:child_process';\nspawn('npm', ['run', 'dev']);\n",
1195 ),
1196 ],
1197 );
1198 let discovered =
1199 discover_coverage_project(&root, &BTreeMap::new(), &command(&["npm", "test"])).unwrap();
1200 assert!(discovered.unrun_build.is_empty());
1201 fs::remove_dir_all(root).unwrap();
1202 }
1203
1204 #[test]
1205 fn discovers_a_project_owned_playwright_fixture_via_the_ast() {
1206 let root = project(
1207 "fixture",
1208 &[
1209 ("package.json", r#"{"scripts":{"build":"vite build"}}"#),
1210 ("app/root.tsx", "export default null"),
1211 (
1212 "tests/nested/playwright.browser.config.ts",
1213 "export default {}",
1214 ),
1215 (
1216 "tests/example.spec.ts",
1217 "import { type Ignored, browserTest as test, expect, fixtureValue } from '@acme/browser-fixtures'\n\
1218 import { createFixtureProduct } from '@acme/browser-fixtures'",
1219 ),
1220 ],
1221 );
1222 let discovered = discover_coverage_project(&root, &BTreeMap::new(), &[]).unwrap();
1223 assert_eq!(
1224 discovered.playwright_config,
1225 Some(root.join("tests/nested/playwright.browser.config.ts"))
1226 );
1227 assert_eq!(discovered.playwright_module, "@acme/browser-fixtures");
1228 assert_eq!(discovered.playwright_test_export, "browserTest");
1229 assert_eq!(
1233 discovered.playwright_exports,
1234 [
1235 "browserTest",
1236 "createFixtureProduct",
1237 "expect",
1238 "fixtureValue"
1239 ]
1240 );
1241 fs::remove_dir_all(root).unwrap();
1242 }
1243
1244 #[test]
1245 fn environment_overrides_are_authoritative() {
1246 let root = project(
1247 "override",
1248 &[
1249 ("package.json", r#"{"scripts":{"build":"vite build"}}"#),
1250 ("custom/main.ts", "main"),
1251 ("configs/browser.ts", "config"),
1252 (
1253 "tests/example.spec.ts",
1254 "import { test } from '@playwright/test'",
1255 ),
1256 ],
1257 );
1258 let environment = BTreeMap::from([
1259 ("SUPERCOV_SOURCE_ROOTS".into(), "custom".into()),
1260 (
1261 "SUPERCOV_PLAYWRIGHT_CONFIG".into(),
1262 "configs/browser.ts".into(),
1263 ),
1264 ("SUPERCOV_PLAYWRIGHT_MODULE".into(), "@custom/test".into()),
1265 ("SUPERCOV_PLAYWRIGHT_TEST_EXPORT".into(), "scenario".into()),
1266 ]);
1267 let discovered = discover_coverage_project(&root, &environment, &[]).unwrap();
1268 assert_eq!(discovered.source_roots, ["custom"]);
1269 assert_eq!(
1270 discovered.playwright_config,
1271 Some(root.join("configs/browser.ts"))
1272 );
1273 assert_eq!(discovered.playwright_module, "@custom/test");
1274 assert_eq!(discovered.playwright_test_export, "scenario");
1275 assert_eq!(discovered.playwright_exports, ["scenario", "expect"]);
1276 fs::remove_dir_all(root).unwrap();
1277 }
1278}