1use std::{
14 cell::RefCell,
15 collections::{BTreeMap, BTreeSet, VecDeque},
16 fs,
17 path::{Component, Path, PathBuf},
18};
19
20use serde_json::Value;
21
22#[derive(Debug, Clone, Default, PartialEq, Eq)]
25pub struct Loads {
26 pub files: BTreeSet<String>,
27 pub open: Option<String>,
29}
30
31#[derive(Debug, Clone)]
32enum Specifier {
33 Literal(String),
34 Computed,
36}
37
38const EXTENSIONS: [&str; 9] = [
39 ".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".json",
40];
41
42pub fn reads_imports(file: &str) -> bool {
44 file.ends_with(".py")
45 || EXTENSIONS[..8]
46 .iter()
47 .any(|extension| file.ends_with(extension))
48}
49
50pub struct Imports {
52 root: PathBuf,
53 packages: BTreeSet<String>,
54 aliases: Vec<(String, Vec<PathBuf>)>,
56 subpaths: BTreeMap<String, Vec<String>>,
58 parsed: RefCell<BTreeMap<String, Option<Vec<Specifier>>>>,
59 python: RefCell<BTreeMap<String, Option<Vec<PythonImport>>>>,
60}
61
62impl Imports {
63 pub fn new(root: &Path) -> Self {
64 let manifest = read_json(&root.join("package.json"));
65 let mut packages = BTreeSet::new();
66 let mut subpaths = BTreeMap::new();
67 if let Some(manifest) = &manifest {
68 for field in [
69 "dependencies",
70 "devDependencies",
71 "peerDependencies",
72 "optionalDependencies",
73 ] {
74 if let Some(names) = manifest.get(field).and_then(Value::as_object) {
75 packages.extend(names.keys().cloned());
76 }
77 }
78 if let Some(name) = manifest.get("name").and_then(Value::as_str) {
79 packages.insert(name.to_owned());
80 }
81 if let Some(imports) = manifest.get("imports").and_then(Value::as_object) {
82 for (name, target) in imports {
83 let mut targets = Vec::new();
84 collect_strings(target, &mut targets);
85 subpaths.insert(name.clone(), targets);
86 }
87 }
88 }
89 Self {
90 root: root.to_owned(),
91 packages,
92 aliases: aliases(root),
93 subpaths,
94 parsed: RefCell::new(BTreeMap::new()),
95 python: RefCell::new(BTreeMap::new()),
96 }
97 }
98
99 pub fn loads<'e>(&self, entries: impl IntoIterator<Item = &'e str>) -> Loads {
101 let mut loads = Loads::default();
102 let mut queue = entries
103 .into_iter()
104 .map(str::to_owned)
105 .collect::<VecDeque<_>>();
106 while let Some(file) = queue.pop_front() {
107 if !loads.files.insert(file.clone()) || !reads_imports(&file) {
108 continue;
109 }
110 if file.ends_with(".py") {
111 self.python_loads(&file, &mut loads, &mut queue);
112 continue;
113 }
114 let Some(specifiers) = self.specifiers(&file) else {
115 loads
116 .open
117 .get_or_insert_with(|| format!("{file} could not be read"));
118 continue;
119 };
120 for specifier in specifiers {
121 match specifier {
122 Specifier::Computed => {
123 loads
124 .open
125 .get_or_insert_with(|| format!("{file} imports a computed path"));
126 }
127 Specifier::Literal(specifier) => match self.resolve(&file, &specifier) {
128 Resolved::File(next) => queue.push_back(next),
129 Resolved::Outside => {}
130 Resolved::Unknown => {
131 loads.open.get_or_insert_with(|| {
132 format!("{file} imports '{specifier}', which Supercov cannot find")
133 });
134 }
135 },
136 }
137 }
138 }
139 loads
140 }
141
142 pub fn named_by(&self, configuration: &str) -> Vec<String> {
146 let Ok(source) = fs::read_to_string(self.root.join(configuration)) else {
147 return Vec::new();
148 };
149 let directory = Path::new(configuration).parent().unwrap_or(Path::new(""));
150 let mut named = Vec::new();
151 for text in strings(configuration, &source) {
152 if !(text.starts_with('.') || text.contains('/')) || text.contains(['*', '{', '?']) {
153 continue;
154 }
155 for candidate in [directory.join(&text), PathBuf::from(&text)] {
156 if let Resolved::File(file) = self.file(&candidate)
157 && !named.contains(&file)
158 {
159 named.push(file);
160 break;
161 }
162 }
163 }
164 named
165 }
166
167 fn python_loads(&self, file: &str, loads: &mut Loads, queue: &mut VecDeque<String>) {
174 for directory in Path::new(file).ancestors().skip(1) {
175 if let Some(conftest) = normal(&directory.join("conftest.py"))
176 && self.root.join(&conftest).is_file()
177 {
178 queue.push_back(conftest);
179 }
180 }
181 let known = self.python.borrow().get(file).cloned();
182 let imports = known.unwrap_or_else(|| {
183 let parsed = fs::read_to_string(self.root.join(file))
184 .ok()
185 .and_then(|source| python_imports(&source));
186 self.python
187 .borrow_mut()
188 .insert(file.to_owned(), parsed.clone());
189 parsed
190 });
191 let Some(imports) = imports else {
192 loads
193 .open
194 .get_or_insert_with(|| format!("{file} could not be read"));
195 return;
196 };
197 for import in imports {
198 match import {
199 PythonImport::Computed(why) => {
200 loads.open.get_or_insert_with(|| format!("{file} {why}"));
201 }
202 PythonImport::Module {
203 module,
204 level,
205 names,
206 } => {
207 queue.extend(self.python_resolve(file, &module, level, &names));
208 }
209 }
210 }
211 }
212
213 fn python_resolve(
215 &self,
216 file: &str,
217 module: &str,
218 level: u32,
219 names: &[String],
220 ) -> Vec<String> {
221 let exists = |path: &Path| normal(path).filter(|path| self.root.join(path).is_file());
222 let parts = module
223 .split('.')
224 .filter(|part| !part.is_empty())
225 .collect::<Vec<_>>();
226 let directory = Path::new(file).parent().unwrap_or(Path::new(""));
227 let bases = if level > 0 {
228 let mut base = directory.to_owned();
229 for _ in 1..level {
230 base = base.parent().unwrap_or(Path::new("")).to_owned();
231 }
232 vec![base]
233 } else {
234 let mut bases = directory
237 .ancestors()
238 .map(Path::to_owned)
239 .collect::<Vec<_>>();
240 bases.push(PathBuf::from("src"));
241 bases
242 };
243 for base in bases {
244 let mut found = Vec::new();
245 let mut package = base.clone();
246 let mut resolved = parts.is_empty();
247 for (index, part) in parts.iter().enumerate() {
248 package = package.join(part);
249 if let Some(init) = exists(&package.join("__init__.py")) {
250 found.push(init);
251 resolved = index + 1 == parts.len();
252 } else if index + 1 == parts.len()
253 && let Some(module) = exists(&package.with_extension("py"))
254 {
255 found.push(module);
256 resolved = true;
257 } else {
258 break;
259 }
260 }
261 if !resolved {
262 continue;
263 }
264 for name in names {
266 let submodule = package.join(name);
267 if let Some(file) = exists(&submodule.with_extension("py"))
268 .or_else(|| exists(&submodule.join("__init__.py")))
269 {
270 found.push(file);
271 }
272 }
273 return found;
274 }
275 Vec::new()
276 }
277
278 fn specifiers(&self, file: &str) -> Option<Vec<Specifier>> {
279 if let Some(known) = self.parsed.borrow().get(file) {
280 return known.clone();
281 }
282 let parsed = fs::read_to_string(self.root.join(file))
283 .ok()
284 .and_then(|source| parse(file, &source));
285 self.parsed
286 .borrow_mut()
287 .insert(file.to_owned(), parsed.clone());
288 parsed
289 }
290
291 fn resolve(&self, from: &str, specifier: &str) -> Resolved {
292 let specifier = specifier.split(['?', '#']).next().unwrap_or(specifier);
293 if specifier.starts_with("./") || specifier.starts_with("../") || specifier == "." {
294 let base = Path::new(from).parent().unwrap_or(Path::new(""));
295 return self.file(&base.join(specifier));
296 }
297 if specifier.starts_with('/') {
298 return Resolved::Unknown;
299 }
300 if let Some(name) = specifier.strip_prefix("node:") {
301 let _ = name;
302 return Resolved::Outside;
303 }
304 if specifier.starts_with('#') {
305 return self.subpath(specifier);
306 }
307 for (pattern, targets) in &self.aliases {
308 let rest = match pattern.split_once('*') {
309 Some((prefix, suffix)) => specifier
310 .strip_prefix(prefix)
311 .and_then(|rest| rest.strip_suffix(suffix)),
312 None => (specifier == pattern).then_some(""),
313 };
314 let Some(rest) = rest else { continue };
315 for target in targets {
316 let target = target.to_string_lossy().replacen('*', rest, 1);
317 if let Resolved::File(file) = self.file(Path::new(&target)) {
318 return Resolved::File(file);
319 }
320 }
321 }
322 let package = package_name(specifier);
323 if BUILTINS.contains(&package)
325 || self.packages.contains(package)
326 || self
327 .root
328 .ancestors()
329 .any(|directory| directory.join("node_modules").join(package).exists())
330 {
331 return Resolved::Outside;
332 }
333 Resolved::Unknown
334 }
335
336 fn subpath(&self, specifier: &str) -> Resolved {
337 let targets = self.subpaths.get(specifier).cloned().or_else(|| {
338 self.subpaths.iter().find_map(|(pattern, targets)| {
339 let (prefix, suffix) = pattern.split_once('*')?;
340 let rest = specifier.strip_prefix(prefix)?.strip_suffix(suffix)?;
341 Some(
342 targets
343 .iter()
344 .map(|target| target.replacen('*', rest, 1))
345 .collect(),
346 )
347 })
348 });
349 let Some(targets) = targets else {
350 return Resolved::Unknown;
351 };
352 for target in targets {
353 if !target.starts_with("./") {
354 return Resolved::Outside;
355 }
356 if let Resolved::File(file) = self.file(Path::new(&target)) {
357 return Resolved::File(file);
358 }
359 }
360 Resolved::Unknown
361 }
362
363 fn file(&self, path: &Path) -> Resolved {
367 let Some(path) = normal(path) else {
368 return Resolved::Unknown;
369 };
370 if path.split('/').any(|part| part == "node_modules") {
371 return Resolved::Outside;
372 }
373 let exists = |candidate: &str| self.root.join(candidate).is_file();
374 let mut candidates = vec![path.clone()];
375 for (javascript, typescript) in [
376 (".js", [".ts", ".tsx"]),
377 (".jsx", [".tsx", ".tsx"]),
378 (".mjs", [".mts", ".mts"]),
379 (".cjs", [".cts", ".cts"]),
380 ] {
381 if let Some(stem) = path.strip_suffix(javascript) {
382 candidates.extend(typescript.iter().map(|ts| format!("{stem}{ts}")));
383 }
384 }
385 candidates.extend(
386 EXTENSIONS
387 .iter()
388 .map(|extension| format!("{path}{extension}")),
389 );
390 candidates.extend(
391 EXTENSIONS
392 .iter()
393 .map(|extension| format!("{path}/index{extension}")),
394 );
395 match candidates.into_iter().find(|candidate| exists(candidate)) {
396 Some(found) => Resolved::File(found),
397 None => Resolved::Unknown,
398 }
399 }
400}
401
402enum Resolved {
403 File(String),
404 Outside,
406 Unknown,
407}
408
409const BUILTINS: [&str; 40] = [
410 "assert",
411 "async_hooks",
412 "buffer",
413 "child_process",
414 "cluster",
415 "console",
416 "constants",
417 "crypto",
418 "dgram",
419 "diagnostics_channel",
420 "dns",
421 "domain",
422 "events",
423 "fs",
424 "http",
425 "http2",
426 "https",
427 "inspector",
428 "module",
429 "net",
430 "os",
431 "path",
432 "perf_hooks",
433 "process",
434 "punycode",
435 "querystring",
436 "readline",
437 "repl",
438 "stream",
439 "string_decoder",
440 "timers",
441 "tls",
442 "tty",
443 "url",
444 "util",
445 "v8",
446 "vm",
447 "worker_threads",
448 "zlib",
449 "test",
450];
451
452fn package_name(specifier: &str) -> &str {
453 let mut parts = specifier.splitn(3, '/');
454 let first = parts.next().unwrap_or(specifier);
455 if first.starts_with('@')
456 && let Some(second) = parts.next()
457 {
458 return &specifier[..first.len() + 1 + second.len()];
459 }
460 first
461}
462
463fn normal(path: &Path) -> Option<String> {
465 let mut parts = Vec::new();
466 for component in path.components() {
467 match component {
468 Component::Normal(part) => parts.push(part.to_str()?.to_owned()),
469 Component::CurDir => {}
470 Component::ParentDir => {
471 parts.pop()?;
472 }
473 _ => return None,
474 }
475 }
476 Some(parts.join("/"))
477}
478
479fn read_json(path: &Path) -> Option<Value> {
480 let text = fs::read_to_string(path).ok()?;
481 serde_json::from_str(&crate::source_discovery::strip_trailing_commas(
482 &crate::source_discovery::strip_jsonc_comments(&text),
483 ))
484 .ok()
485}
486
487fn collect_strings(value: &Value, into: &mut Vec<String>) {
488 match value {
489 Value::String(text) => into.push(text.clone()),
490 Value::Object(map) => map.values().for_each(|value| collect_strings(value, into)),
491 Value::Array(items) => items.iter().for_each(|value| collect_strings(value, into)),
492 _ => {}
493 }
494}
495
496fn aliases(root: &Path) -> Vec<(String, Vec<PathBuf>)> {
499 let mut found = Vec::new();
500 let mut config = root.join("tsconfig.json");
501 let mut seen = BTreeSet::new();
502 while seen.insert(config.clone()) && seen.len() < 16 {
503 let Some(json) = read_json(&config) else {
504 break;
505 };
506 let directory = config.parent().unwrap_or(root).to_owned();
507 let options = json.get("compilerOptions");
508 let base = options
509 .and_then(|options| options.get("baseUrl"))
510 .and_then(Value::as_str)
511 .map_or(directory.clone(), |base| directory.join(base));
512 if found.is_empty()
513 && let Some(paths) = options
514 .and_then(|options| options.get("paths"))
515 .and_then(Value::as_object)
516 {
517 for (pattern, targets) in paths {
518 let targets = targets
519 .as_array()
520 .into_iter()
521 .flatten()
522 .filter_map(Value::as_str)
523 .filter_map(|target| {
524 base.join(target)
525 .strip_prefix(root)
526 .ok()
527 .map(Path::to_owned)
528 })
529 .collect();
530 found.push((pattern.clone(), targets));
531 }
532 }
533 match json.get("extends").and_then(Value::as_str) {
534 Some(parent) if parent.starts_with('.') => {
535 config = directory.join(parent);
536 if config.extension().is_none() {
537 config.set_extension("json");
538 }
539 }
540 _ => break,
541 }
542 }
543 found
544}
545
546fn parse(file: &str, source: &str) -> Option<Vec<Specifier>> {
550 use oxc_ast::ast::*;
551 use oxc_ast_visit::{Visit, walk};
552
553 let source_type = crate::js_instrumenter::project_source_type(Path::new(file)).ok()?;
554 let allocator = oxc_allocator::Allocator::default();
555 let parsed = oxc_parser::Parser::new(&allocator, source, source_type).parse();
556 if parsed.panicked {
557 return None;
558 }
559 struct Collector {
560 found: Vec<Specifier>,
561 }
562 fn literal(expression: &Expression<'_>) -> Specifier {
563 match expression {
564 Expression::StringLiteral(text) => Specifier::Literal(text.value.to_string()),
565 Expression::TemplateLiteral(template) if template.expressions.is_empty() => template
566 .quasis
567 .first()
568 .and_then(|quasi| quasi.value.cooked.as_ref())
569 .map_or(Specifier::Computed, |text| {
570 Specifier::Literal(text.to_string())
571 }),
572 _ => Specifier::Computed,
573 }
574 }
575 impl<'a> Visit<'a> for Collector {
576 fn visit_import_declaration(&mut self, it: &ImportDeclaration<'a>) {
577 if !it.import_kind.is_type() {
578 self.found
579 .push(Specifier::Literal(it.source.value.to_string()));
580 }
581 }
582 fn visit_export_all_declaration(&mut self, it: &ExportAllDeclaration<'a>) {
583 if !it.export_kind.is_type() {
584 self.found
585 .push(Specifier::Literal(it.source.value.to_string()));
586 }
587 }
588 fn visit_export_named_declaration(&mut self, it: &ExportNamedDeclaration<'a>) {
589 if let Some(source) = &it.source
590 && !it.export_kind.is_type()
591 {
592 self.found
593 .push(Specifier::Literal(source.value.to_string()));
594 }
595 walk::walk_export_named_declaration(self, it);
596 }
597 fn visit_ts_import_equals_declaration(&mut self, it: &TSImportEqualsDeclaration<'a>) {
598 if let TSModuleReference::ExternalModuleReference(reference) = &it.module_reference
599 && !it.import_kind.is_type()
600 {
601 self.found
602 .push(Specifier::Literal(reference.expression.value.to_string()));
603 }
604 }
605 fn visit_import_expression(&mut self, it: &ImportExpression<'a>) {
606 self.found.push(literal(&it.source));
607 walk::walk_import_expression(self, it);
608 }
609 fn visit_call_expression(&mut self, it: &CallExpression<'a>) {
610 let loads = match &it.callee {
611 Expression::Identifier(name) => name.name == "require",
612 Expression::StaticMemberExpression(member) => {
613 let object = match &member.object {
614 Expression::Identifier(name) => name.name.as_str(),
615 _ => "",
616 };
617 let property = member.property.name.as_str();
618 matches!(
619 (object, property),
620 ("require", "resolve")
621 | ("jest", "mock" | "requireActual" | "doMock")
622 | ("vi", "mock" | "importActual" | "doMock")
623 )
624 }
625 _ => false,
626 };
627 if loads && let Some(first) = it.arguments.first() {
628 self.found.push(match first.as_expression() {
629 Some(expression) => literal(expression),
630 None => Specifier::Computed,
631 });
632 }
633 walk::walk_call_expression(self, it);
634 }
635 }
636 let mut collector = Collector { found: Vec::new() };
637 collector.visit_program(&parsed.program);
638 Some(collector.found)
639}
640
641#[derive(Debug, Clone)]
642enum PythonImport {
643 Module {
644 module: String,
645 level: u32,
647 names: Vec<String>,
648 },
649 Computed(&'static str),
651}
652
653fn python_imports(source: &str) -> Option<Vec<PythonImport>> {
656 use ruff_python_ast::{
657 Expr, Stmt,
658 visitor::{Visitor, walk_expr, walk_stmt},
659 };
660 let parsed = ruff_python_parser::parse_module(source).ok()?;
661 struct Collector(Vec<PythonImport>);
662 fn module(name: &str) -> PythonImport {
663 PythonImport::Module {
664 module: name.to_owned(),
665 level: 0,
666 names: Vec::new(),
667 }
668 }
669 fn strings(expression: &Expr) -> Option<Vec<String>> {
670 match expression {
671 Expr::StringLiteral(text) => Some(vec![text.value.to_str().to_owned()]),
672 Expr::List(list) => list
673 .elts
674 .iter()
675 .map(|item| strings(item).map(|mut s| s.remove(0)))
676 .collect(),
677 Expr::Tuple(tuple) => tuple
678 .elts
679 .iter()
680 .map(|item| strings(item).map(|mut s| s.remove(0)))
681 .collect(),
682 _ => None,
683 }
684 }
685 fn is_sys_path(expression: &Expr) -> bool {
686 matches!(expression, Expr::Attribute(attribute)
687 if attribute.attr.as_str() == "path"
688 && matches!(&*attribute.value, Expr::Name(name) if name.id.as_str() == "sys"))
689 }
690 impl<'a> Visitor<'a> for Collector {
691 fn visit_stmt(&mut self, stmt: &'a Stmt) {
692 match stmt {
693 Stmt::Import(import) => {
694 for alias in &import.names {
695 self.0.push(module(alias.name.as_str()));
696 }
697 }
698 Stmt::ImportFrom(import) => self.0.push(PythonImport::Module {
699 module: import
700 .module
701 .as_ref()
702 .map(|m| m.to_string())
703 .unwrap_or_default(),
704 level: import.level,
705 names: import
706 .names
707 .iter()
708 .map(|alias| alias.name.to_string())
709 .collect(),
710 }),
711 Stmt::Assign(assign) => {
712 for target in &assign.targets {
713 if matches!(target, Expr::Name(name) if name.id.as_str() == "pytest_plugins")
714 {
715 match strings(&assign.value) {
716 Some(plugins) => self.0.extend(plugins.iter().map(|p| module(p))),
717 None => self.0.push(PythonImport::Computed(
718 "names its pytest plugins by computation",
719 )),
720 }
721 }
722 if is_sys_path(target) {
723 self.0.push(PythonImport::Computed("changes sys.path"));
724 }
725 }
726 }
727 _ => {}
728 }
729 walk_stmt(self, stmt);
730 }
731 fn visit_expr(&mut self, expr: &'a Expr) {
732 if let Expr::Call(call) = expr {
733 let first = call.arguments.args.first();
734 match &*call.func {
735 Expr::Attribute(attribute)
736 if is_sys_path(&attribute.value)
737 && matches!(
738 attribute.attr.as_str(),
739 "insert" | "append" | "extend"
740 ) =>
741 {
742 self.0.push(PythonImport::Computed("changes sys.path"));
743 }
744 Expr::Attribute(attribute) if attribute.attr.as_str() == "import_module" => {
745 self.0.push(match first.and_then(strings) {
746 Some(mut names) => module(&names.remove(0)),
747 None => PythonImport::Computed("imports a module named by computation"),
748 });
749 }
750 Expr::Name(name)
751 if matches!(name.id.as_str(), "__import__" | "import_module") =>
752 {
753 self.0.push(match first.and_then(strings) {
754 Some(mut names) => module(&names.remove(0)),
755 None => PythonImport::Computed("imports a module named by computation"),
756 });
757 }
758 _ => {}
759 }
760 }
761 walk_expr(self, expr);
762 }
763 }
764 let mut collector = Collector(Vec::new());
765 collector.visit_body(&parsed.syntax().body);
766 Some(collector.0)
767}
768
769pub fn configuration_runner(file: &str) -> Option<&'static str> {
772 let name = file.rsplit('/').next()?.to_ascii_lowercase();
773 if !name.contains(".config.") && !name.contains(".workspace.") {
774 return None;
775 }
776 let stem = name.split('.').next()?;
777 match stem {
778 "playwright" | "playwright-ct" => Some("playwright"),
779 "vitest" => Some("vitest"),
780 "jest" => Some("jest"),
781 "ava" => Some("ava"),
782 _ => None,
783 }
784}
785
786fn strings(file: &str, source: &str) -> Vec<String> {
787 use oxc_ast::ast::*;
788 use oxc_ast_visit::Visit;
789 let Ok(source_type) = crate::js_instrumenter::project_source_type(Path::new(file)) else {
790 return Vec::new();
791 };
792 let allocator = oxc_allocator::Allocator::default();
793 let parsed = oxc_parser::Parser::new(&allocator, source, source_type).parse();
794 struct Collector(Vec<String>);
795 impl<'a> Visit<'a> for Collector {
796 fn visit_string_literal(&mut self, it: &StringLiteral<'a>) {
797 self.0.push(it.value.to_string());
798 }
799 }
800 let mut collector = Collector(Vec::new());
801 collector.visit_program(&parsed.program);
802 collector.0
803}
804
805#[cfg(test)]
806mod tests {
807 use super::*;
808
809 fn project(files: &[(&str, &str)]) -> PathBuf {
810 static UNIQUE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
811 let root = std::env::temp_dir().join(format!(
812 "supercov-test-loads-{}-{}-{}",
813 std::process::id(),
814 std::time::SystemTime::now()
815 .duration_since(std::time::UNIX_EPOCH)
816 .unwrap()
817 .as_nanos(),
818 UNIQUE.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
819 ));
820 fs::create_dir(&root).unwrap();
821 for (file, text) in files {
822 let path = root.join(file);
823 fs::create_dir_all(path.parent().unwrap()).unwrap();
824 fs::write(path, text).unwrap();
825 }
826 root
827 }
828
829 #[test]
830 fn a_test_loads_its_helpers_and_not_another_suites() {
831 let root = project(&[
832 (
833 "package.json",
834 r#"{"devDependencies":{"@playwright/test":"1"}}"#,
835 ),
836 (
837 "tsconfig.json",
838 r#"{"compilerOptions":{"baseUrl":".","paths":{"@/*":["src/*"]}}}"#,
839 ),
840 (
841 "tests/e2e/helpers.ts",
842 "import { x } from '@/lib/x';\nexport const get = () => x;\n",
843 ),
844 (
845 "tests/e2e/app.spec.ts",
846 "import { test } from '@playwright/test';\nimport { get } from './helpers';\nimport type { T } from './types';\n",
847 ),
848 ("tests/e2e/types.ts", "export type T = 1;\n"),
849 (
850 "tests/unit/a.test.ts",
851 "import { y } from '../support/y.js';\n",
852 ),
853 ("tests/support/y.ts", "export const y = 1;\n"),
854 ("src/lib/x.ts", "export const x = 1;\n"),
855 ]);
856 let imports = Imports::new(&root);
857 let e2e = imports.loads(["tests/e2e/app.spec.ts"]);
858 assert_eq!(e2e.open, None);
859 assert!(e2e.files.contains("tests/e2e/helpers.ts"));
860 assert!(e2e.files.contains("src/lib/x.ts"), "{e2e:?}");
861 assert!(
862 !e2e.files.contains("tests/e2e/types.ts"),
863 "a type import loads nothing"
864 );
865 assert!(!e2e.files.contains("tests/support/y.ts"));
866 let unit = imports.loads(["tests/unit/a.test.ts"]);
867 assert!(unit.files.contains("tests/support/y.ts"), "{unit:?}");
868 assert!(!unit.files.contains("tests/e2e/helpers.ts"));
869 fs::remove_dir_all(root).unwrap();
870 }
871
872 #[test]
873 fn what_cannot_be_followed_leaves_the_answer_open() {
874 let root = project(&[
875 ("package.json", "{}"),
876 ("a.test.js", "const name = 'b';\nrequire(`./${name}`);\n"),
877 ("c.test.js", "import 'unknown-alias/thing';\n"),
878 (
879 "d.test.js",
880 "import fs from 'node:fs';\nimport path from 'path';\nimport './e.js';\n",
881 ),
882 ("e.js", "export {};\n"),
883 ]);
884 let imports = Imports::new(&root);
885 assert!(imports.loads(["a.test.js"]).open.is_some());
886 assert!(imports.loads(["c.test.js"]).open.is_some());
887 let closed = imports.loads(["d.test.js"]);
888 assert_eq!(closed.open, None);
889 assert!(closed.files.contains("e.js"));
890 fs::remove_dir_all(root).unwrap();
891 }
892
893 #[test]
894 fn a_python_test_loads_its_conftest_and_what_it_imports() {
895 let root = project(&[
896 (
897 "tests/conftest.py",
898 "import pytest\nfrom tests.support import factory\n",
899 ),
900 ("tests/__init__.py", ""),
901 ("tests/support/__init__.py", ""),
902 ("tests/support/factory.py", "import json\n"),
903 (
904 "tests/unit/test_a.py",
905 "from .helpers import make\nfrom shop import cart\n",
906 ),
907 ("tests/unit/__init__.py", ""),
908 ("tests/unit/helpers.py", "def make():\n return 1\n"),
909 (
910 "tests/e2e/test_b.py",
911 "import requests\nfrom tests.e2e import pages\n",
912 ),
913 ("tests/e2e/__init__.py", ""),
914 ("tests/e2e/pages.py", ""),
915 ("src/shop/__init__.py", ""),
916 ("src/shop/cart.py", ""),
917 (
918 "tests/odd/test_c.py",
919 "import sys\nsys.path.insert(0, 'x')\n",
920 ),
921 ]);
922 let imports = Imports::new(&root);
923 let unit = imports.loads(["tests/unit/test_a.py"]);
924 assert_eq!(unit.open, None, "{unit:?}");
925 for file in [
926 "tests/conftest.py",
927 "tests/support/factory.py",
928 "tests/unit/helpers.py",
929 "src/shop/cart.py",
930 ] {
931 assert!(unit.files.contains(file), "{file}: {unit:?}");
932 }
933 assert!(!unit.files.contains("tests/e2e/pages.py"));
934 let e2e = imports.loads(["tests/e2e/test_b.py"]);
935 assert!(
936 e2e.files.contains("tests/e2e/pages.py")
937 && !e2e.files.contains("tests/unit/helpers.py"),
938 "{e2e:?}"
939 );
940 assert!(imports.loads(["tests/odd/test_c.py"]).open.is_some());
941 fs::remove_dir_all(root).unwrap();
942 }
943
944 #[test]
945 fn a_configuration_belongs_to_its_runner() {
946 assert_eq!(
947 configuration_runner("tests/e2e/playwright.config.ts"),
948 Some("playwright")
949 );
950 assert_eq!(configuration_runner("vitest.config.mts"), Some("vitest"));
951 assert_eq!(configuration_runner("jest.config.js"), Some("jest"));
952 assert_eq!(
953 configuration_runner("vite.config.ts"),
954 None,
955 "a build shared with the app"
956 );
957 assert_eq!(configuration_runner("tsconfig.json"), None);
958 assert_eq!(configuration_runner("next.config.ts"), None);
959 }
960
961 #[test]
962 fn a_configuration_names_its_setup_files() {
963 let root = project(&[
964 (
965 "vitest.config.ts",
966 "export default { test: { setupFiles: ['tests/unit/setup.ts'], include: ['tests/**/*.test.ts'] } };\n",
967 ),
968 ("tests/unit/setup.ts", "export {};\n"),
969 (
970 "e2e/playwright.config.ts",
971 "export default { globalSetup: './global-setup', testMatch: '*.spec.ts' };\n",
972 ),
973 ("e2e/global-setup.ts", "export default () => {};\n"),
974 ]);
975 let imports = Imports::new(&root);
976 assert_eq!(
977 imports.named_by("vitest.config.ts"),
978 ["tests/unit/setup.ts"]
979 );
980 assert_eq!(
981 imports.named_by("e2e/playwright.config.ts"),
982 ["e2e/global-setup.ts"]
983 );
984 fs::remove_dir_all(root).unwrap();
985 }
986}