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