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