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