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