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, Program, 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 program = parse_program(&allocator, file, source)?;
180 let mut collector = UnitCollector {
181 source,
182 imports: Vec::new(),
183 mocked: BTreeSet::new(),
184 untyped: Vec::new(),
185 };
186 collector.visit_program(&program);
187 let mut violations = unmocked_violations(file, &collector);
188 violations.extend(
189 collector
190 .untyped
191 .iter()
192 .map(|(spec, line)| untyped_mock_violation(file, *line, spec)),
193 );
194 violations.sort_by_key(|v| v.line);
195 Ok(violations)
196}
197
198fn unmocked_violations(file: &Path, collector: &UnitCollector<'_>) -> Vec<Violation> {
201 let unit = unit_under_test_specifier(file);
202 let mocked: BTreeSet<&str> = collector
203 .mocked
204 .iter()
205 .map(|m| strip_module_ext(m))
206 .collect();
207 collector
208 .imports
209 .iter()
210 .filter(|(spec, _, pure)| !is_isolated_import(spec, *pure, &unit, &mocked))
211 .map(|(spec, line, _)| unmocked_violation(file, *line, spec))
212 .collect()
213}
214
215fn is_isolated_import(spec: &str, pure: bool, unit: &str, mocked: &BTreeSet<&str>) -> bool {
219 is_unit_under_test(spec, unit)
220 || is_test_runner(spec)
221 || pure
222 || mocked.contains(strip_module_ext(spec))
223}
224
225fn unmocked_violation(file: &Path, line: usize, spec: &str) -> Violation {
227 Violation {
228 file: file.to_path_buf(),
229 line,
230 rule: "unmocked-collaborator",
231 message: format!(
232 "unit test imports `{spec}` without mocking it — a unit test isolates the \
233 unit under test, so every collaborator must be `vi.mock()`-ed"
234 ),
235 }
236}
237
238fn untyped_mock_violation(file: &Path, line: usize, spec: &str) -> Violation {
240 Violation {
241 file: file.to_path_buf(),
242 line,
243 rule: "untyped-mock",
244 message: format!(
245 "`vi.mock('{spec}', …)` has an untyped factory — anchor it to the real module \
246 with `vi.importActual<typeof import('{spec}')>()` so the double can't drift \
247 from the source"
248 ),
249 }
250}
251
252fn parse_program<'a>(
255 allocator: &'a Allocator,
256 file: &Path,
257 source: &'a str,
258) -> Result<Program<'a>> {
259 let ret = Parser::new(allocator, source, source_type_of(file)?).parse();
260 if ret.panicked || !ret.diagnostics.is_empty() {
261 bail!(
262 "parsing `{}` failed: {}",
263 file.display(),
264 diagnostic_detail(&ret.diagnostics)
265 );
266 }
267 Ok(ret.program)
268}
269
270fn source_type_of(file: &Path) -> Result<SourceType> {
272 SourceType::from_path(file).map_err(|err| {
273 anyhow!(
274 "unsupported TypeScript extension `{}`: {err}",
275 file.display()
276 )
277 })
278}
279
280fn diagnostic_detail(diagnostics: &[impl std::fmt::Display]) -> String {
282 diagnostics
283 .iter()
284 .map(ToString::to_string)
285 .collect::<Vec<_>>()
286 .join("; ")
287}
288
289struct UnitCollector<'s> {
291 source: &'s str,
292 imports: Vec<(String, usize, bool)>,
293 mocked: BTreeSet<String>,
294 untyped: Vec<(String, usize)>,
295}
296
297impl<'a> Visit<'a> for UnitCollector<'_> {
298 fn visit_import_declaration(&mut self, decl: &ImportDeclaration<'a>) {
299 if matches!(decl.import_kind, ImportOrExportKind::Type) {
301 return;
302 }
303 self.imports.push((
304 decl.source.value.to_string(),
305 line_of(self.source, decl.span.start),
306 is_pure_named_import(decl),
307 ));
308 }
309
310 fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
311 if let Some(spec) = vi_mock_target(call) {
312 if let Some(factory) = call.arguments.get(1) {
313 if is_factory(factory) && !factory_is_typed(factory) {
314 self.untyped
315 .push((spec.clone(), line_of(self.source, call.span.start)));
316 }
317 }
318 self.mocked.insert(spec);
319 }
320 walk::walk_call_expression(self, call);
321 }
322}
323
324fn is_pure_named_import(decl: &ImportDeclaration<'_>) -> bool {
325 let Some(specifiers) = decl.specifiers.as_ref() else {
326 return false;
327 };
328 let module = decl.source.value.as_str();
329 !specifiers.is_empty()
330 && specifiers
331 .iter()
332 .all(|specifier| is_pure_specifier(specifier, module))
333}
334
335fn is_pure_specifier(specifier: &ImportDeclarationSpecifier<'_>, module: &str) -> bool {
338 let ImportDeclarationSpecifier::ImportSpecifier(named) = specifier else {
339 return false;
340 };
341 matches!(named.import_kind, ImportOrExportKind::Type)
342 || is_pure_function(module, named.imported.name().as_str())
343}
344
345fn is_pure_function(module: &str, name: &str) -> bool {
348 match module {
349 "json5" | "yaml" => name == "parse",
350 "node:path" | "path" => matches!(
351 name,
352 "basename" | "dirname" | "extname" | "join" | "normalize"
353 ),
354 _ => false,
355 }
356}
357
358fn unit_under_test_specifier(file: &Path) -> String {
360 let name = file
361 .file_name()
362 .and_then(|n| n.to_str())
363 .unwrap_or_default();
364 let stem = name.split(".test.").next().unwrap_or(name);
365 format!("./{stem}")
366}
367
368fn is_unit_under_test(spec: &str, unit: &str) -> bool {
370 strip_module_ext(spec) == unit
371}
372
373pub(crate) fn strip_module_ext(spec: &str) -> &str {
375 for ext in [".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"] {
376 if let Some(base) = spec.strip_suffix(ext) {
377 return base;
378 }
379 }
380 spec
381}
382
383fn is_test_runner(spec: &str) -> bool {
385 spec == "vitest" || spec.starts_with("vitest/") || spec.starts_with("@vitest/")
386}
387
388fn is_factory(arg: &Argument) -> bool {
392 matches!(
393 arg.as_expression(),
394 Some(Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_))
395 )
396}
397
398fn factory_is_typed(factory: &Argument) -> bool {
401 let mut finder = ImportActualFinder { typed: false };
402 finder.visit_argument(factory);
403 finder.typed
404}
405
406struct ImportActualFinder {
408 typed: bool,
409}
410
411impl<'a> Visit<'a> for ImportActualFinder {
412 fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
413 if is_typed_import_actual(call) {
414 self.typed = true;
415 }
416 walk::walk_call_expression(self, call);
417 }
418}
419
420fn is_typed_import_actual(call: &CallExpression) -> bool {
423 let Expression::StaticMemberExpression(member) = &call.callee else {
424 return false;
425 };
426 let is_vi = matches!(&member.object, Expression::Identifier(id) if id.name == "vi");
427 is_vi && member.property.name.as_str() == "importActual" && call.type_arguments.is_some()
428}
429
430fn integration_violations_in(file: &Path, source: &str) -> Result<Vec<Violation>> {
433 let allocator = Allocator::default();
434 let program = parse_program(&allocator, file, source)?;
435 let mut visitor = MockVisitor {
436 file,
437 source,
438 violations: Vec::new(),
439 };
440 visitor.visit_program(&program);
441 Ok(visitor.violations)
442}
443
444struct MockVisitor<'s> {
446 file: &'s Path,
447 source: &'s str,
448 violations: Vec<Violation>,
449}
450
451impl MockVisitor<'_> {
452 fn report(&mut self, span: Span, spec: &str) {
453 self.violations.push(Violation {
454 file: self.file.to_path_buf(),
455 line: line_of(self.source, span.start),
456 rule: "no-first-party-mock",
457 message: format!(
458 "integration test mocks first-party module `{spec}` — an integration test \
459 runs first-party code for real; only third-party packages and Node built-ins \
460 may be mocked"
461 ),
462 });
463 }
464}
465
466impl<'a> Visit<'a> for MockVisitor<'_> {
467 fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
468 if let Some(spec) = vi_mock_target(call) {
469 if classify(&spec) == Origin::FirstParty {
470 self.report(call.span, &spec);
471 }
472 }
473 walk::walk_call_expression(self, call);
474 }
475}
476
477fn vi_mock_target(call: &CallExpression) -> Option<String> {
481 let Expression::StaticMemberExpression(member) = &call.callee else {
482 return None;
483 };
484 let is_vi = matches!(&member.object, Expression::Identifier(id) if id.name == "vi");
485 if !is_vi {
486 return None;
487 }
488 let method = member.property.name.as_str();
489 if method != "mock" && method != "doMock" {
490 return None;
491 }
492 match call.arguments.first() {
493 Some(Argument::StringLiteral(lit)) => Some(lit.value.to_string()),
494 _ => None,
495 }
496}
497
498fn line_of(source: &str, offset: u32) -> usize {
500 let offset = (offset as usize).min(source.len());
501 source.as_bytes()[..offset]
502 .iter()
503 .filter(|&&byte| byte == b'\n')
504 .count()
505 + 1
506}
507
508pub fn has_behavior(source: &str, path: &Path) -> bool {
512 let allocator = Allocator::default();
513 let Ok(source_type) = SourceType::from_path(path) else {
514 return true;
515 };
516 let ret = Parser::new(&allocator, source, source_type).parse();
517 if ret.panicked || !ret.diagnostics.is_empty() {
518 return true;
519 }
520 let mut visitor = BehaviorVisitor { found: false };
521 visitor.visit_program(&ret.program);
522 visitor.found
523}
524
525struct BehaviorVisitor {
527 found: bool,
528}
529
530impl<'a> Visit<'a> for BehaviorVisitor {
531 fn visit_function(&mut self, it: &Function<'a>, _flags: ScopeFlags) {
532 self.found |= it.body.is_some();
534 }
535
536 fn visit_arrow_function_expression(&mut self, _: &ArrowFunctionExpression<'a>) {
537 self.found = true;
538 }
539
540 fn visit_if_statement(&mut self, _: &IfStatement<'a>) {
541 self.found = true;
542 }
543
544 fn visit_for_statement(&mut self, _: &ForStatement<'a>) {
545 self.found = true;
546 }
547
548 fn visit_for_in_statement(&mut self, _: &ForInStatement<'a>) {
549 self.found = true;
550 }
551
552 fn visit_for_of_statement(&mut self, _: &ForOfStatement<'a>) {
553 self.found = true;
554 }
555
556 fn visit_while_statement(&mut self, _: &WhileStatement<'a>) {
557 self.found = true;
558 }
559
560 fn visit_do_while_statement(&mut self, _: &DoWhileStatement<'a>) {
561 self.found = true;
562 }
563
564 fn visit_switch_statement(&mut self, _: &SwitchStatement<'a>) {
565 self.found = true;
566 }
567
568 fn visit_try_statement(&mut self, _: &TryStatement<'a>) {
569 self.found = true;
570 }
571
572 fn visit_conditional_expression(&mut self, _: &ConditionalExpression<'a>) {
573 self.found = true;
574 }
575}
576
577pub fn same_code(base: &str, head: &str, path: &Path) -> bool {
581 match (
582 emit_without_comments(base, path),
583 emit_without_comments(head, path),
584 ) {
585 (Some(base), Some(head)) => base == head,
586 _ => false,
587 }
588}
589
590pub(crate) fn emit_without_comments(source: &str, path: &Path) -> Option<String> {
592 let allocator = Allocator::default();
593 let source_type = SourceType::from_path(path).ok()?;
594 let ret = Parser::new(&allocator, source, source_type).parse();
595 if ret.panicked || !ret.diagnostics.is_empty() {
596 return None;
597 }
598 Some(
599 Codegen::new()
600 .with_options(CodegenOptions {
601 comments: CommentOptions::disabled(),
602 ..CodegenOptions::default()
603 })
604 .build(&ret.program)
605 .code,
606 )
607}
608
609fn collect_ts_test_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
610 let entries =
611 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
612 for entry in entries {
613 let path = crate::walk::dir_entry(entry, dir)?.path();
614 if path.is_dir() {
615 collect_ts_test_files(&path, out)?;
616 } else if is_ts_test_file(&path) {
617 out.push(path);
618 }
619 }
620 Ok(())
621}
622
623fn is_ts_test_file(path: &Path) -> bool {
625 let name = path
626 .file_name()
627 .and_then(|n| n.to_str())
628 .unwrap_or_default();
629 name.ends_with(".test.ts")
630 || name.ends_with(".test.tsx")
631 || name.ends_with(".test.mts")
632 || name.ends_with(".test.cts")
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638
639 fn violations(name: &str, source: &str) -> Vec<Violation> {
641 integration_violations_in(Path::new(name), source).expect("source should parse")
642 }
643
644 fn unit_violations(name: &str, source: &str) -> Vec<Violation> {
646 unit_violations_in(Path::new(name), source).expect("source should parse")
647 }
648
649 #[test]
650 fn unit_flags_unmocked_first_party_and_external() {
651 let found = unit_violations(
652 "widget.test.ts",
653 "import { makeWidget } from './widget';\n\
654 import { format } from './formatter';\n\
655 import { chunk } from 'lodash';\n",
656 );
657 assert_eq!(found.len(), 2, "got: {found:?}");
659 assert!(found.iter().all(|v| v.rule == "unmocked-collaborator"));
660 assert!(found.iter().any(|v| v.message.contains("./formatter")));
661 assert!(found.iter().any(|v| v.message.contains("lodash")));
662 }
663
664 #[test]
665 fn a_named_import_of_a_pure_function_needs_no_mock() {
666 assert!(unit_violations(
669 "widget.test.ts",
670 "import { join } from 'node:path';\n\
671 import { parse } from 'yaml';\n",
672 )
673 .is_empty());
674
675 let found = unit_violations("widget.test.ts", "import { stringify } from 'yaml';\n");
676 assert_eq!(found.len(), 1, "got: {found:?}");
677 assert!(found[0].message.contains("yaml"));
678 }
679
680 #[test]
681 fn a_diagnostic_alone_fails_the_parse_even_though_oxc_did_not_panic() {
682 let err = unit_violations_in(Path::new("widget.test.ts"), "return 1;\n")
686 .expect_err("a diagnostic should fail the parse");
687 assert!(err.to_string().contains("parsing"), "got: {err}");
688 }
689
690 #[test]
691 fn unit_mocked_collaborator_is_clean() {
692 let found = unit_violations(
693 "widget.test.ts",
694 "import { format } from './formatter';\nvi.mock('./formatter');\n",
695 );
696 assert!(found.is_empty(), "got: {found:?}");
697 }
698
699 #[test]
700 fn unit_under_test_and_runner_are_not_flagged() {
701 let found = unit_violations(
702 "widget.test.ts",
703 "import { vi } from 'vitest';\n\
704 import { makeWidget } from './widget.js';\n",
705 );
706 assert!(found.is_empty(), "got: {found:?}");
708 }
709
710 fn behavior(source: &str) -> bool {
712 has_behavior(source, Path::new("foo.ts"))
713 }
714
715 #[test]
716 fn behavior_is_absent_from_a_type_only_module() {
717 assert!(!behavior(
718 "export interface Shape { kind: string }\nexport type Id = string;\n"
719 ));
720 assert!(!behavior(
721 "import type { Shape } from './shape';\nexport type Wrapped = Shape;\n"
722 ));
723 assert!(!behavior("export type { Id } from './shape';\n"));
724 assert!(!behavior("export type * from './shapes';\n"));
725 assert!(!behavior(""));
726 assert!(!behavior("// just a comment\n"));
727 }
728
729 #[test]
730 fn behavior_is_absent_from_a_declaration_only_module() {
731 assert!(!behavior(
732 "export type T = number;\nexport const version: T = 1;\n"
733 ));
734 assert!(!behavior("export * from './widget';\n"));
735 assert!(!behavior("export { thing } from './thing';\n"));
736 assert!(!behavior("export enum Color { Red, Green }\n"));
737 assert!(!behavior("export namespace N { export const x = 1; }\n"));
738 assert!(!behavior(
739 "export const config = { retries: 3 } as const;\n"
740 ));
741 assert!(!behavior("export const now = Date.now();\n"));
742 assert!(!behavior("export const name = `${a}-${b}`;\n"));
743 }
744
745 #[test]
746 fn behavior_is_absent_from_a_bodiless_function() {
747 assert!(!behavior("declare function f(): void;\n"));
748 assert!(!behavior(
749 "export abstract class A { abstract run(): void; }\n"
750 ));
751 assert!(!behavior("declare class B { run(): void; }\n"));
752 }
753
754 #[test]
755 fn behavior_short_circuit_operators_are_not_control_flow() {
756 assert!(!behavior("export const PORT = env.PORT ?? 3000;\n"));
757 assert!(!behavior("export const HOST = env.HOST || 'localhost';\n"));
758 assert!(!behavior("export const READY = a && b;\n"));
759 }
760
761 #[test]
762 fn behavior_every_function_form_is_present() {
763 assert!(behavior("export function f() { return 1; }\n"));
764 assert!(behavior("export const f = function () { return 1; };\n"));
765 assert!(behavior("export const f = () => 1;\n"));
766 assert!(behavior("export class W { run() { return 1; } }\n"));
767 assert!(behavior("export const o = { run() { return 1; } };\n"));
768 assert!(behavior(
769 "export function f(a: string): string;\nexport function f(a: number): number;\n\
770 export function f(a: unknown) { return a; }\n"
771 ));
772 assert!(behavior("export const x = { on: () => 1 } as const;\n"));
773 }
774
775 #[test]
776 fn behavior_every_control_flow_form_is_present() {
777 assert!(behavior("if (a) { b = 1; }\n"));
778 assert!(behavior("for (let i = 0; i < 3; i++) { b = i; }\n"));
779 assert!(behavior("for (const k in o) { b = k; }\n"));
780 assert!(behavior("for (const v of xs) { b = v; }\n"));
781 assert!(behavior("while (a) { a = false; }\n"));
782 assert!(behavior("do { a = false; } while (a);\n"));
783 assert!(behavior("switch (a) { case 1: b = 1; }\n"));
784 assert!(behavior("try { b = 1; } catch { b = 2; }\n"));
785 assert!(behavior("export const LEVEL = DEBUG ? 10 : 20;\n"));
786 }
787
788 #[test]
789 fn behavior_is_assumed_for_unparsable_content() {
790 assert!(behavior("export type T = ;;;\nconst {{{ = \n"));
791 }
792
793 fn same(base: &str, head: &str) -> bool {
795 same_code(base, head, Path::new("foo.ts"))
796 }
797
798 #[test]
799 fn same_code_drops_comments_and_formatting() {
800 assert!(same(
801 "// widget factory\nexport const widget = () => 1;\n",
802 "// widget builder\nexport const widget = () => 1;\n"
803 ));
804 assert!(same(
805 "/* widget factory\n used by the CLI */\nexport const widget = () => 1;\n",
806 "export const widget = () => 1;\n"
807 ));
808 assert!(same(
809 "/** A widget. */\nexport const widget = () => 1;\n",
810 "export const widget = () => 1;\n"
811 ));
812 assert!(same(
813 "export const widget = () => 1;\n",
814 "\n\nexport const widget = () => 1;\n\n"
815 ));
816 assert!(same(
817 "export function widget() { return 1; }\n",
818 "export function widget() {\n return 1;\n}\n"
819 ));
820 }
821
822 #[test]
823 fn same_code_keeps_everything_the_module_emits() {
824 assert!(!same(
825 "export const widget = () => 1;\n",
826 "export const widget = () => 2;\n"
827 ));
828 assert!(!same(
829 "export const widget = () => 'one';\n",
830 "export const widget = () => 'two';\n"
831 ));
832 assert!(!same(
833 "export const widget = () => `one`;\n",
834 "export const widget = () => `two`;\n"
835 ));
836 assert!(!same(
837 "export const widget = (n: number): number => n;\n",
838 "export const widget = (n: string): string => n;\n"
839 ));
840 assert!(!same(
841 "// widget factory\nexport const widget = () => 1;\n",
842 "// widget builder\nexport const widget = () => 2;\n"
843 ));
844 }
845
846 #[test]
847 fn same_code_holds_apart_what_it_cannot_read() {
848 assert!(!same(
849 "export const widget = (() => 1;\n",
850 "// still broken\nexport const widget = (() => 1;\n"
851 ));
852 assert!(!same(
853 "export const widget = () => 1;\n",
854 "export const widget = (() => 1;\n"
855 ));
856 assert!(!same(
857 "export const widget = (() => 1;\n",
858 "export const widget = () => 1;\n"
859 ));
860 let source = "export const widget = () => 1;\n";
861 assert!(!same_code(source, source, Path::new("widget.txt")));
862 }
863
864 #[test]
865 fn unit_type_only_import_is_not_flagged() {
866 let found = unit_violations(
867 "widget.test.ts",
868 "import type { Opts } from './opts';\nimport { x } from './x';\nvi.mock('./x');\n",
869 );
870 assert!(found.is_empty(), "got: {found:?}");
871 }
872
873 #[test]
874 fn unit_under_test_specifier_strips_test_suffix() {
875 assert_eq!(
876 unit_under_test_specifier(Path::new("pkg/widget.test.ts")),
877 "./widget"
878 );
879 assert_eq!(
880 unit_under_test_specifier(Path::new("button.test.tsx")),
881 "./button"
882 );
883 }
884
885 #[test]
886 fn strip_module_ext_drops_known_extensions_only() {
887 assert_eq!(strip_module_ext("./widget.js"), "./widget");
888 assert_eq!(strip_module_ext("./widget.mts"), "./widget");
889 assert_eq!(strip_module_ext("./widget"), "./widget");
890 assert_eq!(strip_module_ext("lodash"), "lodash");
891 }
892
893 #[test]
894 fn recognizes_the_test_runner() {
895 assert!(is_test_runner("vitest"));
896 assert!(is_test_runner("vitest/config"));
897 assert!(is_test_runner("@vitest/spy"));
898 assert!(!is_test_runner("./vitest-helpers"));
899 assert!(!is_test_runner("lodash"));
900 }
901
902 #[test]
903 fn unit_flags_untyped_factory_mock() {
904 let found = unit_violations(
905 "widget.test.ts",
906 "import { x } from './x';\nvi.mock('./x', () => ({ x: vi.fn() }));\n",
907 );
908 assert_eq!(found.len(), 1, "got: {found:?}");
910 assert_eq!(found[0].rule, "untyped-mock");
911 assert!(found[0].message.contains("./x"));
912 }
913
914 #[test]
915 fn unit_typed_factory_mock_is_clean() {
916 let found = unit_violations(
917 "widget.test.ts",
918 "import { x } from './x';\n\
919 vi.mock('./x', async () => {\n\
920 \x20 const actual = await vi.importActual<typeof import('./x')>('./x');\n\
921 \x20 return { ...actual, x: vi.fn() };\n\
922 });\n",
923 );
924 assert!(found.is_empty(), "got: {found:?}");
925 }
926
927 #[test]
928 fn unit_options_object_mock_is_not_a_factory() {
929 let found = unit_violations(
930 "widget.test.ts",
931 "import { x } from './x';\nvi.mock('./x', { spy: true });\n",
932 );
933 assert!(found.is_empty(), "got: {found:?}");
934 }
935
936 #[test]
937 fn unit_untyped_import_actual_is_still_untyped() {
938 let found = unit_violations(
940 "widget.test.ts",
941 "import { x } from './x';\n\
942 vi.mock('./x', async () => {\n\
943 \x20 const actual = await vi.importActual('./x');\n\
944 \x20 return { ...(actual as object), x: vi.fn() };\n\
945 });\n",
946 );
947 assert_eq!(found.len(), 1, "got: {found:?}");
948 assert_eq!(found[0].rule, "untyped-mock");
949 }
950
951 #[test]
952 fn classify_relative_is_first_party() {
953 assert_eq!(classify("./service"), Origin::FirstParty);
954 assert_eq!(classify("../pkg/util"), Origin::FirstParty);
955 assert_eq!(classify("/abs/path"), Origin::FirstParty);
956 }
957
958 #[test]
959 fn classify_node_builtins() {
960 assert_eq!(classify("fs"), Origin::Builtin);
961 assert_eq!(classify("node:fs"), Origin::Builtin);
962 assert_eq!(classify("fs/promises"), Origin::Builtin);
963 assert_eq!(classify("node:test"), Origin::Builtin);
964 assert_eq!(classify("child_process"), Origin::Builtin);
965 assert_eq!(classify("node:some-future-builtin"), Origin::Builtin);
966 }
967
968 #[test]
969 fn classify_third_party() {
970 assert_eq!(classify("lodash"), Origin::ThirdParty);
971 assert_eq!(classify("@scope/pkg"), Origin::ThirdParty);
972 assert_eq!(classify("stripe/lib/client"), Origin::ThirdParty);
973 assert_eq!(classify("test"), Origin::ThirdParty);
975 }
976
977 #[test]
978 fn recognizes_ts_test_files() {
979 assert!(is_ts_test_file(Path::new("widget.test.ts")));
980 assert!(is_ts_test_file(Path::new("pkg/button.test.tsx")));
981 assert!(is_ts_test_file(Path::new("service.test.mts")));
982 assert!(is_ts_test_file(Path::new("legacy.test.cts")));
983 assert!(!is_ts_test_file(Path::new("widget.ts")));
984 assert!(!is_ts_test_file(Path::new("types.d.ts")));
985 assert!(!is_ts_test_file(Path::new("README.md")));
986 }
987
988 #[test]
989 fn line_of_counts_newlines() {
990 let src = "a\nb\nc\n";
991 assert_eq!(line_of(src, 0), 1);
992 assert_eq!(line_of(src, 2), 2);
993 assert_eq!(line_of(src, 4), 3);
994 }
995
996 #[test]
997 fn flags_mock_of_relative_module() {
998 let found = violations("a.test.ts", "vi.mock('./service');\n");
999 assert_eq!(found.len(), 1);
1000 assert_eq!(found[0].rule, "no-first-party-mock");
1001 assert_eq!(found[0].line, 1);
1002 }
1003
1004 #[test]
1005 fn flags_mock_with_factory_and_parent_path() {
1006 let found = violations(
1007 "a.test.ts",
1008 "import { x } from './x';\nvi.mock('../src/ledger', () => ({ record: vi.fn() }));\n",
1009 );
1010 assert_eq!(found.len(), 1);
1011 assert!(found[0].message.contains("../src/ledger"));
1012 }
1013
1014 #[test]
1015 fn flags_domock_of_relative_module() {
1016 let found = violations("a.test.mts", "vi.doMock('./mailer');\n");
1017 assert_eq!(found.len(), 1);
1018 }
1019
1020 #[test]
1021 fn allows_mock_of_third_party_and_builtins() {
1022 let found = violations(
1023 "a.test.ts",
1024 "vi.mock('stripe');\nvi.mock('node:fs');\nvi.mock('fs/promises');\nvi.mock('@scope/pkg');\n",
1025 );
1026 assert!(found.is_empty(), "got: {found:?}");
1027 }
1028
1029 #[test]
1030 fn ignores_non_vi_and_non_mock_calls() {
1031 let found = violations(
1032 "a.test.ts",
1033 "describe('s', () => {});\nvi.fn();\nexpect(1).toBe(1);\nother.mock('./x');\n",
1034 );
1035 assert!(found.is_empty(), "got: {found:?}");
1036 }
1037
1038 #[test]
1039 fn ignores_dynamic_mock_target() {
1040 let found = violations("a.test.ts", "const m = './x';\nvi.mock(m);\n");
1041 assert!(found.is_empty(), "got: {found:?}");
1042 }
1043
1044 #[test]
1045 fn finds_mocks_nested_in_blocks() {
1046 let found = violations(
1048 "a.test.ts",
1049 "describe('s', () => {\n vi.mock('./inner');\n});\n",
1050 );
1051 assert_eq!(found.len(), 1);
1052 assert_eq!(found[0].line, 2);
1053 }
1054
1055 #[test]
1056 fn parse_error_is_reported() {
1057 let err = integration_violations_in(Path::new("bad.test.ts"), "const x = ;\n").unwrap_err();
1058 assert!(err.to_string().contains("parsing"), "got: {err}");
1059 }
1060
1061 #[test]
1062 fn diagnostic_detail_joins_every_diagnostic_a_parse_reported() {
1063 let none: &[&str] = &[];
1064 assert_eq!(diagnostic_detail(none), "");
1065 assert_eq!(diagnostic_detail(&["first"]), "first");
1066 assert_eq!(diagnostic_detail(&["first", "second"]), "first; second");
1068 }
1069
1070 #[test]
1071 fn unsupported_extension_is_reported() {
1072 let err = integration_violations_in(Path::new("weird.test.bogus"), "vi.mock('./x');\n")
1073 .unwrap_err();
1074 assert!(err.to_string().contains("unsupported"), "got: {err}");
1075 }
1076
1077 #[test]
1078 fn unit_parse_error_is_reported() {
1079 let err = unit_violations_in(Path::new("bad.test.ts"), "const x = ;\n").unwrap_err();
1080 assert!(err.to_string().contains("parsing"), "got: {err}");
1081 }
1082
1083 #[test]
1084 fn unit_unsupported_extension_is_reported() {
1085 let err =
1086 unit_violations_in(Path::new("weird.test.bogus"), "vi.mock('./x');\n").unwrap_err();
1087 assert!(err.to_string().contains("unsupported"), "got: {err}");
1088 }
1089
1090 #[test]
1091 fn behavior_is_assumed_for_an_unsupported_extension() {
1092 assert!(has_behavior(
1093 "export type T = number;\n",
1094 Path::new("foo.txt")
1095 ));
1096 }
1097
1098 #[test]
1099 fn a_factory_calling_a_plain_helper_is_untyped() {
1100 let found = unit_violations(
1101 "widget.test.ts",
1102 "import { x } from './x';\nvi.mock('./x', () => makeDouble());\n",
1103 );
1104 assert_eq!(found.len(), 1, "got: {found:?}");
1105 assert_eq!(found[0].rule, "untyped-mock");
1106 }
1107
1108 #[test]
1109 fn a_package_without_a_tests_dir_has_no_suite_violations() {
1110 let dir = unique_tmp("suite-test");
1111 let found = find_suite_violations(&dir).expect("an empty package scans clean");
1112 assert!(found.is_empty(), "got: {found:?}");
1113 }
1114
1115 fn unique_tmp(slug: &str) -> PathBuf {
1116 use std::sync::atomic::{AtomicU64, Ordering};
1117 static COUNTER: AtomicU64 = AtomicU64::new(0);
1118 let dir = std::env::temp_dir().join(format!(
1119 "tc-ts-{slug}-{}-{}",
1120 std::process::id(),
1121 COUNTER.fetch_add(1, Ordering::Relaxed)
1122 ));
1123 std::fs::create_dir_all(&dir).unwrap();
1124 dir
1125 }
1126
1127 #[test]
1128 fn suite_violations_cover_tiers_and_sort_stray_files() {
1129 let dir = unique_tmp("suite-busy");
1130 std::fs::create_dir_all(dir.join("tests/integration")).unwrap();
1131 std::fs::write(
1132 dir.join("tests/integration/flow.test.ts"),
1133 "import { x } from './x';\n",
1134 )
1135 .unwrap();
1136 std::fs::write(dir.join("tests/stray_b.test.ts"), "").unwrap();
1137 std::fs::write(dir.join("tests/stray_a.test.ts"), "").unwrap();
1138 let found = find_suite_violations(&dir).unwrap();
1139 let _ = std::fs::remove_dir_all(&dir);
1140 assert_eq!(found.len(), 2, "got: {found:?}");
1141 assert!(found.iter().all(|v| v.rule == "unknown-tier"));
1142 assert!(found[0].file < found[1].file);
1143 }
1144
1145 #[test]
1146 fn an_unreadable_integration_test_file_names_the_file() {
1147 let dir = unique_tmp("nonutf8-int");
1148 std::fs::write(dir.join("flow.test.ts"), [0xFF, 0xFE]).unwrap();
1149 let err = find_integration_violations(&dir).unwrap_err();
1150 let _ = std::fs::remove_dir_all(&dir);
1151 assert!(
1152 format!("{err:#}").contains("reading test file"),
1153 "got: {err:#}"
1154 );
1155 }
1156
1157 #[test]
1158 fn an_unreadable_unit_test_file_names_the_file() {
1159 let dir = unique_tmp("nonutf8-unit");
1160 std::fs::write(dir.join("widget.test.ts"), [0xFF, 0xFE]).unwrap();
1161 let err = find_unit_violations(&dir).unwrap_err();
1162 let _ = std::fs::remove_dir_all(&dir);
1163 assert!(
1164 format!("{err:#}").contains("reading test file"),
1165 "got: {err:#}"
1166 );
1167 }
1168
1169 #[test]
1170 fn a_missing_root_is_an_error() {
1171 let err = find_integration_violations(Path::new("/nonexistent-tc-ts")).unwrap_err();
1172 assert!(
1173 format!("{err:#}").contains("reading directory"),
1174 "got: {err:#}"
1175 );
1176 }
1177}