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