1use std::collections::BTreeSet;
6use std::path::{Path, PathBuf};
7
8use anyhow::{anyhow, bail, Context, Result};
9use oxc::allocator::Allocator;
10use oxc::ast::ast::{
11 Argument, CallExpression, Expression, ImportDeclaration, ImportOrExportKind, Statement,
12};
13use oxc::ast_visit::{walk, Visit};
14use oxc::parser::Parser;
15use oxc::span::{SourceType, Span};
16use oxc_codegen::{Codegen, CodegenOptions, CommentOptions};
17
18use crate::lint::Violation;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Origin {
23 FirstParty,
25 Builtin,
27 ThirdParty,
29}
30
31pub fn classify(specifier: &str) -> Origin {
35 if specifier.starts_with('.') || specifier.starts_with('/') {
36 return Origin::FirstParty;
37 }
38 if specifier.starts_with("node:") || is_node_builtin(specifier) {
39 return Origin::Builtin;
40 }
41 Origin::ThirdParty
42}
43
44fn is_node_builtin(specifier: &str) -> bool {
46 let head = specifier.split('/').next().unwrap_or(specifier);
47 NODE_BUILTINS.contains(&head)
48}
49
50const NODE_BUILTINS: &[&str] = &[
53 "assert",
54 "async_hooks",
55 "buffer",
56 "child_process",
57 "cluster",
58 "console",
59 "constants",
60 "crypto",
61 "dgram",
62 "diagnostics_channel",
63 "dns",
64 "domain",
65 "events",
66 "fs",
67 "http",
68 "http2",
69 "https",
70 "inspector",
71 "module",
72 "net",
73 "os",
74 "path",
75 "perf_hooks",
76 "process",
77 "punycode",
78 "querystring",
79 "readline",
80 "repl",
81 "stream",
82 "string_decoder",
83 "sys",
84 "timers",
85 "tls",
86 "trace_events",
87 "tty",
88 "url",
89 "util",
90 "v8",
91 "vm",
92 "wasi",
93 "worker_threads",
94 "zlib",
95];
96
97pub fn find_integration_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
100 let root = root.as_ref();
101 let mut files = Vec::new();
102 collect_ts_test_files(root, &mut files)?;
103 files.sort();
104
105 let mut violations = Vec::new();
106 for file in &files {
107 let source = std::fs::read_to_string(file)
108 .with_context(|| format!("reading test file `{}`", file.display()))?;
109 violations.extend(integration_violations_in(file, &source)?);
110 }
111
112 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
113 Ok(violations)
114}
115
116const UNKNOWN_TIER_MSG: &str = "test file sits under `tests/` outside the standard suite tiers; \
117 a suite lives in `tests/integration/` or `tests/e2e/`";
118
119pub fn find_suite_violations(package_root: &Path) -> Result<Vec<Violation>> {
123 let tests = package_root.join("tests");
124 let mut violations = Vec::new();
125 let tiers = ["integration", "e2e"].map(|tier| tests.join(tier));
126 for tier in &tiers {
127 if tier.is_dir() {
128 violations.extend(find_integration_violations(tier)?);
129 }
130 }
131 if tests.is_dir() {
132 let mut strays = Vec::new();
133 collect_ts_test_files(&tests, &mut strays)?;
134 strays.retain(|file| !tiers.iter().any(|tier| file.starts_with(tier)));
135 for file in strays {
136 violations.push(Violation {
137 file,
138 line: 1,
139 rule: "unknown-tier",
140 message: UNKNOWN_TIER_MSG.to_string(),
141 });
142 }
143 }
144 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
145 Ok(violations)
146}
147
148pub fn find_unit_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
151 let root = root.as_ref();
152 let mut files = Vec::new();
153 collect_ts_test_files(root, &mut files)?;
154 if let Some(tests) = crate::tiers::suite_tests_dir(root, "package.json") {
156 files.retain(|file| !file.starts_with(&tests));
157 }
158 files.sort();
159
160 let mut violations = Vec::new();
161 for file in &files {
162 let source = std::fs::read_to_string(file)
163 .with_context(|| format!("reading test file `{}`", file.display()))?;
164 violations.extend(unit_violations_in(file, &source)?);
165 }
166
167 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
168 Ok(violations)
169}
170
171fn unit_violations_in(file: &Path, source: &str) -> Result<Vec<Violation>> {
174 let allocator = Allocator::default();
175 let source_type = SourceType::from_path(file).map_err(|err| {
176 anyhow!(
177 "unsupported TypeScript extension `{}`: {err}",
178 file.display()
179 )
180 })?;
181 let ret = Parser::new(&allocator, source, source_type).parse();
182 if ret.panicked || !ret.diagnostics.is_empty() {
183 let detail = ret
184 .diagnostics
185 .iter()
186 .map(|d| d.to_string())
187 .collect::<Vec<_>>()
188 .join("; ");
189 bail!("parsing `{}` failed: {detail}", file.display());
190 }
191
192 let mut collector = UnitCollector {
193 source,
194 imports: Vec::new(),
195 mocked: BTreeSet::new(),
196 untyped: Vec::new(),
197 };
198 collector.visit_program(&ret.program);
199
200 let unit = unit_under_test_specifier(file);
201 let mocked_modules: BTreeSet<&str> = collector
204 .mocked
205 .iter()
206 .map(|m| strip_module_ext(m))
207 .collect();
208 let mut violations = Vec::new();
209 for (spec, line) in &collector.imports {
210 if is_unit_under_test(spec, &unit)
211 || is_test_runner(spec)
212 || mocked_modules.contains(strip_module_ext(spec))
213 {
214 continue;
215 }
216 violations.push(Violation {
217 file: file.to_path_buf(),
218 line: *line,
219 rule: "unmocked-collaborator",
220 message: format!(
221 "unit test imports `{spec}` without mocking it — a unit test isolates the \
222 unit under test, so every collaborator must be `vi.mock()`-ed"
223 ),
224 });
225 }
226 for (spec, line) in &collector.untyped {
227 violations.push(Violation {
228 file: file.to_path_buf(),
229 line: *line,
230 rule: "untyped-mock",
231 message: format!(
232 "`vi.mock('{spec}', …)` has an untyped factory — anchor it to the real module \
233 with `vi.importActual<typeof import('{spec}')>()` so the double can't drift \
234 from the source"
235 ),
236 });
237 }
238 violations.sort_by_key(|v| v.line);
239 Ok(violations)
240}
241
242struct UnitCollector<'s> {
244 source: &'s str,
245 imports: Vec<(String, usize)>,
246 mocked: BTreeSet<String>,
247 untyped: Vec<(String, usize)>,
248}
249
250impl<'a> Visit<'a> for UnitCollector<'_> {
251 fn visit_import_declaration(&mut self, decl: &ImportDeclaration<'a>) {
252 if matches!(decl.import_kind, ImportOrExportKind::Type) {
254 return;
255 }
256 self.imports.push((
257 decl.source.value.to_string(),
258 line_of(self.source, decl.span.start),
259 ));
260 }
261
262 fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
263 if let Some(spec) = vi_mock_target(call) {
264 if let Some(factory) = call.arguments.get(1) {
265 if is_factory(factory) && !factory_is_typed(factory) {
266 self.untyped
267 .push((spec.clone(), line_of(self.source, call.span.start)));
268 }
269 }
270 self.mocked.insert(spec);
271 }
272 walk::walk_call_expression(self, call);
273 }
274}
275
276fn unit_under_test_specifier(file: &Path) -> String {
278 let name = file
279 .file_name()
280 .and_then(|n| n.to_str())
281 .unwrap_or_default();
282 let stem = name.split(".test.").next().unwrap_or(name);
283 format!("./{stem}")
284}
285
286fn is_unit_under_test(spec: &str, unit: &str) -> bool {
288 strip_module_ext(spec) == unit
289}
290
291fn strip_module_ext(spec: &str) -> &str {
293 for ext in [".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"] {
294 if let Some(base) = spec.strip_suffix(ext) {
295 return base;
296 }
297 }
298 spec
299}
300
301fn is_test_runner(spec: &str) -> bool {
303 spec == "vitest" || spec.starts_with("vitest/") || spec.starts_with("@vitest/")
304}
305
306fn is_factory(arg: &Argument) -> bool {
310 matches!(
311 arg.as_expression(),
312 Some(Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_))
313 )
314}
315
316fn factory_is_typed(factory: &Argument) -> bool {
319 let mut finder = ImportActualFinder { typed: false };
320 finder.visit_argument(factory);
321 finder.typed
322}
323
324struct ImportActualFinder {
326 typed: bool,
327}
328
329impl<'a> Visit<'a> for ImportActualFinder {
330 fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
331 if is_typed_import_actual(call) {
332 self.typed = true;
333 }
334 walk::walk_call_expression(self, call);
335 }
336}
337
338fn is_typed_import_actual(call: &CallExpression) -> bool {
341 let Expression::StaticMemberExpression(member) = &call.callee else {
342 return false;
343 };
344 let is_vi = matches!(&member.object, Expression::Identifier(id) if id.name == "vi");
345 is_vi && member.property.name.as_str() == "importActual" && call.type_arguments.is_some()
346}
347
348fn integration_violations_in(file: &Path, source: &str) -> Result<Vec<Violation>> {
351 let allocator = Allocator::default();
352 let source_type = SourceType::from_path(file).map_err(|err| {
353 anyhow!(
354 "unsupported TypeScript extension `{}`: {err}",
355 file.display()
356 )
357 })?;
358 let ret = Parser::new(&allocator, source, source_type).parse();
359 if ret.panicked || !ret.diagnostics.is_empty() {
360 let detail = ret
361 .diagnostics
362 .iter()
363 .map(|d| d.to_string())
364 .collect::<Vec<_>>()
365 .join("; ");
366 bail!("parsing `{}` failed: {detail}", file.display());
367 }
368
369 let mut visitor = MockVisitor {
370 file,
371 source,
372 violations: Vec::new(),
373 };
374 visitor.visit_program(&ret.program);
375 Ok(visitor.violations)
376}
377
378struct MockVisitor<'s> {
380 file: &'s Path,
381 source: &'s str,
382 violations: Vec<Violation>,
383}
384
385impl MockVisitor<'_> {
386 fn report(&mut self, span: Span, spec: &str) {
387 self.violations.push(Violation {
388 file: self.file.to_path_buf(),
389 line: line_of(self.source, span.start),
390 rule: "no-first-party-mock",
391 message: format!(
392 "integration test mocks first-party module `{spec}` — an integration test \
393 runs first-party code for real; only third-party packages and Node built-ins \
394 may be mocked"
395 ),
396 });
397 }
398}
399
400impl<'a> Visit<'a> for MockVisitor<'_> {
401 fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
402 if let Some(spec) = vi_mock_target(call) {
403 if classify(&spec) == Origin::FirstParty {
404 self.report(call.span, &spec);
405 }
406 }
407 walk::walk_call_expression(self, call);
408 }
409}
410
411fn vi_mock_target(call: &CallExpression) -> Option<String> {
415 let Expression::StaticMemberExpression(member) = &call.callee else {
416 return None;
417 };
418 let is_vi = matches!(&member.object, Expression::Identifier(id) if id.name == "vi");
419 if !is_vi {
420 return None;
421 }
422 let method = member.property.name.as_str();
423 if method != "mock" && method != "doMock" {
424 return None;
425 }
426 match call.arguments.first() {
427 Some(Argument::StringLiteral(lit)) => Some(lit.value.to_string()),
428 _ => None,
429 }
430}
431
432fn line_of(source: &str, offset: u32) -> usize {
434 let offset = (offset as usize).min(source.len());
435 source.as_bytes()[..offset]
436 .iter()
437 .filter(|&&byte| byte == b'\n')
438 .count()
439 + 1
440}
441
442pub fn is_type_only_module(source: &str, path: &Path) -> bool {
446 let allocator = Allocator::default();
447 let Ok(source_type) = SourceType::from_path(path) else {
448 return false;
449 };
450 let ret = Parser::new(&allocator, source, source_type).parse();
451 if ret.panicked || !ret.diagnostics.is_empty() {
452 return false;
453 }
454 let body = &ret.program.body;
455 !body.is_empty() && body.iter().all(is_type_only_statement)
456}
457
458fn is_type_only_statement(statement: &Statement) -> bool {
460 match statement {
461 Statement::TSTypeAliasDeclaration(_) | Statement::TSInterfaceDeclaration(_) => true,
462 Statement::ImportDeclaration(decl) => decl.import_kind.is_type(),
463 Statement::ExportAllDeclaration(decl) => decl.export_kind.is_type(),
464 Statement::ExportNamedDeclaration(decl) => decl.export_kind.is_type(),
467 _ => false,
468 }
469}
470
471pub fn same_code(base: &str, head: &str, path: &Path) -> bool {
475 match (
476 emit_without_comments(base, path),
477 emit_without_comments(head, path),
478 ) {
479 (Some(base), Some(head)) => base == head,
480 _ => false,
481 }
482}
483
484fn emit_without_comments(source: &str, path: &Path) -> Option<String> {
486 let allocator = Allocator::default();
487 let source_type = SourceType::from_path(path).ok()?;
488 let ret = Parser::new(&allocator, source, source_type).parse();
489 if ret.panicked || !ret.diagnostics.is_empty() {
490 return None;
491 }
492 Some(
493 Codegen::new()
494 .with_options(CodegenOptions {
495 comments: CommentOptions::disabled(),
496 ..CodegenOptions::default()
497 })
498 .build(&ret.program)
499 .code,
500 )
501}
502
503fn collect_ts_test_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
504 let entries =
505 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
506 for entry in entries {
507 let path = crate::walk::dir_entry(entry, dir)?.path();
508 if path.is_dir() {
509 collect_ts_test_files(&path, out)?;
510 } else if is_ts_test_file(&path) {
511 out.push(path);
512 }
513 }
514 Ok(())
515}
516
517fn is_ts_test_file(path: &Path) -> bool {
519 let name = path
520 .file_name()
521 .and_then(|n| n.to_str())
522 .unwrap_or_default();
523 name.ends_with(".test.ts")
524 || name.ends_with(".test.tsx")
525 || name.ends_with(".test.mts")
526 || name.ends_with(".test.cts")
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532
533 fn violations(name: &str, source: &str) -> Vec<Violation> {
535 integration_violations_in(Path::new(name), source).expect("source should parse")
536 }
537
538 fn unit_violations(name: &str, source: &str) -> Vec<Violation> {
540 unit_violations_in(Path::new(name), source).expect("source should parse")
541 }
542
543 #[test]
544 fn unit_flags_unmocked_first_party_and_external() {
545 let found = unit_violations(
546 "widget.test.ts",
547 "import { makeWidget } from './widget';\n\
548 import { format } from './formatter';\n\
549 import { chunk } from 'lodash';\n",
550 );
551 assert_eq!(found.len(), 2, "got: {found:?}");
553 assert!(found.iter().all(|v| v.rule == "unmocked-collaborator"));
554 assert!(found.iter().any(|v| v.message.contains("./formatter")));
555 assert!(found.iter().any(|v| v.message.contains("lodash")));
556 }
557
558 #[test]
559 fn unit_mocked_collaborator_is_clean() {
560 let found = unit_violations(
561 "widget.test.ts",
562 "import { format } from './formatter';\nvi.mock('./formatter');\n",
563 );
564 assert!(found.is_empty(), "got: {found:?}");
565 }
566
567 #[test]
568 fn unit_under_test_and_runner_are_not_flagged() {
569 let found = unit_violations(
570 "widget.test.ts",
571 "import { vi } from 'vitest';\n\
572 import { makeWidget } from './widget.js';\n",
573 );
574 assert!(found.is_empty(), "got: {found:?}");
576 }
577
578 fn type_only(source: &str) -> bool {
580 is_type_only_module(source, Path::new("foo.ts"))
581 }
582
583 #[test]
584 fn type_only_recognizes_a_pure_type_module() {
585 assert!(type_only(
586 "export interface Shape { kind: string }\nexport type Id = string;\n"
587 ));
588 assert!(type_only(
589 "import type { Shape } from './shape';\nexport type Wrapped = Shape;\n"
590 ));
591 assert!(type_only("export type { Id } from './shape';\n"));
592 assert!(type_only(
593 "type Local = number;\ninterface Bare { x: Local }\n"
594 ));
595 assert!(type_only("export type * from './shapes';\n"));
596 }
597
598 #[test]
599 fn type_only_rejects_any_runtime_construct() {
600 assert!(!type_only(
601 "export type T = number;\nexport const version: T = 1;\n"
602 ));
603 assert!(!type_only(
604 "export interface I {}\nexport function make(): I { return {}; }\n"
605 ));
606 assert!(!type_only(
607 "import { x } from './x';\nexport type T = typeof x;\n"
608 ));
609 assert!(!type_only("export * from './widget';\n"));
610 assert!(!type_only("export { thing } from './thing';\n"));
611 assert!(!type_only("export enum Color { Red, Green }\n"));
612 assert!(!type_only("export namespace N { export const x = 1; }\n"));
613 }
614
615 #[test]
616 fn type_only_is_false_for_empty_or_unparsable() {
617 assert!(!type_only(""));
619 assert!(!type_only("// just a comment\n"));
620 assert!(!type_only("export type T = ;;;\nconst {{{ = \n"));
621 }
622
623 fn same(base: &str, head: &str) -> bool {
625 same_code(base, head, Path::new("foo.ts"))
626 }
627
628 #[test]
629 fn same_code_drops_comments_and_formatting() {
630 assert!(same(
631 "// widget factory\nexport const widget = () => 1;\n",
632 "// widget builder\nexport const widget = () => 1;\n"
633 ));
634 assert!(same(
635 "/* widget factory\n used by the CLI */\nexport const widget = () => 1;\n",
636 "export const widget = () => 1;\n"
637 ));
638 assert!(same(
639 "/** A widget. */\nexport const widget = () => 1;\n",
640 "export const widget = () => 1;\n"
641 ));
642 assert!(same(
643 "export const widget = () => 1;\n",
644 "\n\nexport const widget = () => 1;\n\n"
645 ));
646 assert!(same(
647 "export function widget() { return 1; }\n",
648 "export function widget() {\n return 1;\n}\n"
649 ));
650 }
651
652 #[test]
653 fn same_code_keeps_everything_the_module_emits() {
654 assert!(!same(
655 "export const widget = () => 1;\n",
656 "export const widget = () => 2;\n"
657 ));
658 assert!(!same(
659 "export const widget = () => 'one';\n",
660 "export const widget = () => 'two';\n"
661 ));
662 assert!(!same(
663 "export const widget = () => `one`;\n",
664 "export const widget = () => `two`;\n"
665 ));
666 assert!(!same(
667 "export const widget = (n: number): number => n;\n",
668 "export const widget = (n: string): string => n;\n"
669 ));
670 assert!(!same(
671 "// widget factory\nexport const widget = () => 1;\n",
672 "// widget builder\nexport const widget = () => 2;\n"
673 ));
674 }
675
676 #[test]
677 fn same_code_holds_apart_what_it_cannot_read() {
678 assert!(!same(
679 "export const widget = (() => 1;\n",
680 "// still broken\nexport const widget = (() => 1;\n"
681 ));
682 assert!(!same(
683 "export const widget = () => 1;\n",
684 "export const widget = (() => 1;\n"
685 ));
686 assert!(!same(
687 "export const widget = (() => 1;\n",
688 "export const widget = () => 1;\n"
689 ));
690 let source = "export const widget = () => 1;\n";
691 assert!(!same_code(source, source, Path::new("widget.txt")));
692 }
693
694 #[test]
695 fn unit_type_only_import_is_not_flagged() {
696 let found = unit_violations(
697 "widget.test.ts",
698 "import type { Opts } from './opts';\nimport { x } from './x';\nvi.mock('./x');\n",
699 );
700 assert!(found.is_empty(), "got: {found:?}");
701 }
702
703 #[test]
704 fn unit_under_test_specifier_strips_test_suffix() {
705 assert_eq!(
706 unit_under_test_specifier(Path::new("pkg/widget.test.ts")),
707 "./widget"
708 );
709 assert_eq!(
710 unit_under_test_specifier(Path::new("button.test.tsx")),
711 "./button"
712 );
713 }
714
715 #[test]
716 fn strip_module_ext_drops_known_extensions_only() {
717 assert_eq!(strip_module_ext("./widget.js"), "./widget");
718 assert_eq!(strip_module_ext("./widget.mts"), "./widget");
719 assert_eq!(strip_module_ext("./widget"), "./widget");
720 assert_eq!(strip_module_ext("lodash"), "lodash");
721 }
722
723 #[test]
724 fn recognizes_the_test_runner() {
725 assert!(is_test_runner("vitest"));
726 assert!(is_test_runner("vitest/config"));
727 assert!(is_test_runner("@vitest/spy"));
728 assert!(!is_test_runner("./vitest-helpers"));
729 assert!(!is_test_runner("lodash"));
730 }
731
732 #[test]
733 fn unit_flags_untyped_factory_mock() {
734 let found = unit_violations(
735 "widget.test.ts",
736 "import { x } from './x';\nvi.mock('./x', () => ({ x: vi.fn() }));\n",
737 );
738 assert_eq!(found.len(), 1, "got: {found:?}");
740 assert_eq!(found[0].rule, "untyped-mock");
741 assert!(found[0].message.contains("./x"));
742 }
743
744 #[test]
745 fn unit_typed_factory_mock_is_clean() {
746 let found = unit_violations(
747 "widget.test.ts",
748 "import { x } from './x';\n\
749 vi.mock('./x', async () => {\n\
750 \x20 const actual = await vi.importActual<typeof import('./x')>('./x');\n\
751 \x20 return { ...actual, x: vi.fn() };\n\
752 });\n",
753 );
754 assert!(found.is_empty(), "got: {found:?}");
755 }
756
757 #[test]
758 fn unit_options_object_mock_is_not_a_factory() {
759 let found = unit_violations(
760 "widget.test.ts",
761 "import { x } from './x';\nvi.mock('./x', { spy: true });\n",
762 );
763 assert!(found.is_empty(), "got: {found:?}");
764 }
765
766 #[test]
767 fn unit_untyped_import_actual_is_still_untyped() {
768 let found = unit_violations(
770 "widget.test.ts",
771 "import { x } from './x';\n\
772 vi.mock('./x', async () => {\n\
773 \x20 const actual = await vi.importActual('./x');\n\
774 \x20 return { ...(actual as object), x: vi.fn() };\n\
775 });\n",
776 );
777 assert_eq!(found.len(), 1, "got: {found:?}");
778 assert_eq!(found[0].rule, "untyped-mock");
779 }
780
781 #[test]
782 fn classify_relative_is_first_party() {
783 assert_eq!(classify("./service"), Origin::FirstParty);
784 assert_eq!(classify("../pkg/util"), Origin::FirstParty);
785 assert_eq!(classify("/abs/path"), Origin::FirstParty);
786 }
787
788 #[test]
789 fn classify_node_builtins() {
790 assert_eq!(classify("fs"), Origin::Builtin);
791 assert_eq!(classify("node:fs"), Origin::Builtin);
792 assert_eq!(classify("fs/promises"), Origin::Builtin);
793 assert_eq!(classify("node:test"), Origin::Builtin);
794 assert_eq!(classify("child_process"), Origin::Builtin);
795 assert_eq!(classify("node:some-future-builtin"), Origin::Builtin);
796 }
797
798 #[test]
799 fn classify_third_party() {
800 assert_eq!(classify("lodash"), Origin::ThirdParty);
801 assert_eq!(classify("@scope/pkg"), Origin::ThirdParty);
802 assert_eq!(classify("stripe/lib/client"), Origin::ThirdParty);
803 assert_eq!(classify("test"), Origin::ThirdParty);
805 }
806
807 #[test]
808 fn recognizes_ts_test_files() {
809 assert!(is_ts_test_file(Path::new("widget.test.ts")));
810 assert!(is_ts_test_file(Path::new("pkg/button.test.tsx")));
811 assert!(is_ts_test_file(Path::new("service.test.mts")));
812 assert!(is_ts_test_file(Path::new("legacy.test.cts")));
813 assert!(!is_ts_test_file(Path::new("widget.ts")));
814 assert!(!is_ts_test_file(Path::new("types.d.ts")));
815 assert!(!is_ts_test_file(Path::new("README.md")));
816 }
817
818 #[test]
819 fn line_of_counts_newlines() {
820 let src = "a\nb\nc\n";
821 assert_eq!(line_of(src, 0), 1);
822 assert_eq!(line_of(src, 2), 2);
823 assert_eq!(line_of(src, 4), 3);
824 }
825
826 #[test]
827 fn flags_mock_of_relative_module() {
828 let found = violations("a.test.ts", "vi.mock('./service');\n");
829 assert_eq!(found.len(), 1);
830 assert_eq!(found[0].rule, "no-first-party-mock");
831 assert_eq!(found[0].line, 1);
832 }
833
834 #[test]
835 fn flags_mock_with_factory_and_parent_path() {
836 let found = violations(
837 "a.test.ts",
838 "import { x } from './x';\nvi.mock('../src/ledger', () => ({ record: vi.fn() }));\n",
839 );
840 assert_eq!(found.len(), 1);
841 assert!(found[0].message.contains("../src/ledger"));
842 }
843
844 #[test]
845 fn flags_domock_of_relative_module() {
846 let found = violations("a.test.mts", "vi.doMock('./mailer');\n");
847 assert_eq!(found.len(), 1);
848 }
849
850 #[test]
851 fn allows_mock_of_third_party_and_builtins() {
852 let found = violations(
853 "a.test.ts",
854 "vi.mock('stripe');\nvi.mock('node:fs');\nvi.mock('fs/promises');\nvi.mock('@scope/pkg');\n",
855 );
856 assert!(found.is_empty(), "got: {found:?}");
857 }
858
859 #[test]
860 fn ignores_non_vi_and_non_mock_calls() {
861 let found = violations(
862 "a.test.ts",
863 "describe('s', () => {});\nvi.fn();\nexpect(1).toBe(1);\nother.mock('./x');\n",
864 );
865 assert!(found.is_empty(), "got: {found:?}");
866 }
867
868 #[test]
869 fn ignores_dynamic_mock_target() {
870 let found = violations("a.test.ts", "const m = './x';\nvi.mock(m);\n");
871 assert!(found.is_empty(), "got: {found:?}");
872 }
873
874 #[test]
875 fn finds_mocks_nested_in_blocks() {
876 let found = violations(
878 "a.test.ts",
879 "describe('s', () => {\n vi.mock('./inner');\n});\n",
880 );
881 assert_eq!(found.len(), 1);
882 assert_eq!(found[0].line, 2);
883 }
884
885 #[test]
886 fn parse_error_is_reported() {
887 let err = integration_violations_in(Path::new("bad.test.ts"), "const x = ;\n").unwrap_err();
888 assert!(err.to_string().contains("parsing"), "got: {err}");
889 }
890
891 #[test]
892 fn unsupported_extension_is_reported() {
893 let err = integration_violations_in(Path::new("weird.test.bogus"), "vi.mock('./x');\n")
894 .unwrap_err();
895 assert!(err.to_string().contains("unsupported"), "got: {err}");
896 }
897
898 #[test]
899 fn unit_parse_error_is_reported() {
900 let err = unit_violations_in(Path::new("bad.test.ts"), "const x = ;\n").unwrap_err();
901 assert!(err.to_string().contains("parsing"), "got: {err}");
902 }
903
904 #[test]
905 fn unit_unsupported_extension_is_reported() {
906 let err =
907 unit_violations_in(Path::new("weird.test.bogus"), "vi.mock('./x');\n").unwrap_err();
908 assert!(err.to_string().contains("unsupported"), "got: {err}");
909 }
910
911 #[test]
912 fn type_only_is_false_for_an_unsupported_extension() {
913 assert!(!is_type_only_module(
914 "export type T = number;\n",
915 Path::new("foo.txt")
916 ));
917 }
918
919 #[test]
920 fn type_only_rejects_a_plain_runtime_statement() {
921 assert!(!type_only("const x = 1;\ntype T = number;\n"));
922 }
923
924 #[test]
925 fn a_factory_calling_a_plain_helper_is_untyped() {
926 let found = unit_violations(
927 "widget.test.ts",
928 "import { x } from './x';\nvi.mock('./x', () => makeDouble());\n",
929 );
930 assert_eq!(found.len(), 1, "got: {found:?}");
931 assert_eq!(found[0].rule, "untyped-mock");
932 }
933
934 #[test]
935 fn a_package_without_a_tests_dir_has_no_suite_violations() {
936 let dir = unique_tmp("suite-test");
937 let found = find_suite_violations(&dir).expect("an empty package scans clean");
938 assert!(found.is_empty(), "got: {found:?}");
939 }
940
941 fn unique_tmp(slug: &str) -> PathBuf {
942 use std::sync::atomic::{AtomicU64, Ordering};
943 static COUNTER: AtomicU64 = AtomicU64::new(0);
944 let dir = std::env::temp_dir().join(format!(
945 "tc-ts-{slug}-{}-{}",
946 std::process::id(),
947 COUNTER.fetch_add(1, Ordering::Relaxed)
948 ));
949 std::fs::create_dir_all(&dir).unwrap();
950 dir
951 }
952
953 #[test]
954 fn suite_violations_cover_tiers_and_sort_stray_files() {
955 let dir = unique_tmp("suite-busy");
956 std::fs::create_dir_all(dir.join("tests/integration")).unwrap();
957 std::fs::write(
958 dir.join("tests/integration/flow.test.ts"),
959 "import { x } from './x';\n",
960 )
961 .unwrap();
962 std::fs::write(dir.join("tests/stray_b.test.ts"), "").unwrap();
963 std::fs::write(dir.join("tests/stray_a.test.ts"), "").unwrap();
964 let found = find_suite_violations(&dir).unwrap();
965 let _ = std::fs::remove_dir_all(&dir);
966 assert_eq!(found.len(), 2, "got: {found:?}");
967 assert!(found.iter().all(|v| v.rule == "unknown-tier"));
968 assert!(found[0].file < found[1].file);
969 }
970
971 #[test]
972 fn an_unreadable_integration_test_file_names_the_file() {
973 let dir = unique_tmp("nonutf8-int");
974 std::fs::write(dir.join("flow.test.ts"), [0xFF, 0xFE]).unwrap();
975 let err = find_integration_violations(&dir).unwrap_err();
976 let _ = std::fs::remove_dir_all(&dir);
977 assert!(
978 format!("{err:#}").contains("reading test file"),
979 "got: {err:#}"
980 );
981 }
982
983 #[test]
984 fn an_unreadable_unit_test_file_names_the_file() {
985 let dir = unique_tmp("nonutf8-unit");
986 std::fs::write(dir.join("widget.test.ts"), [0xFF, 0xFE]).unwrap();
987 let err = find_unit_violations(&dir).unwrap_err();
988 let _ = std::fs::remove_dir_all(&dir);
989 assert!(
990 format!("{err:#}").contains("reading test file"),
991 "got: {err:#}"
992 );
993 }
994
995 #[test]
996 fn a_missing_root_is_an_error() {
997 let err = find_integration_violations(Path::new("/nonexistent-tc-ts")).unwrap_err();
998 assert!(
999 format!("{err:#}").contains("reading directory"),
1000 "got: {err:#}"
1001 );
1002 }
1003}