Skip to main content

testing_conventions/
ts.rs

1//! TypeScript isolation analysis, parsed with `oxc` — the counterpart to the Python
2//! [`crate::lint`] module. Each `*.test.{ts,tsx,mts,cts}` file is parsed and walked, and its
3//! specifiers are [`classify`]-ed first-party / Node-builtin / third-party.
4
5use 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, Expression, ImportDeclaration, ImportOrExportKind, Statement,
12};
13use oxc::ast_visit::{walk, Visit};
14use oxc::parser::Parser;
15use oxc::span::{SourceType, Span};
16use oxc_codegen::{Codegen, CodegenOptions, CommentOptions};
17
18use crate::lint::Violation;
19
20/// Where a module specifier resolves, for isolation purposes.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Origin {
23    /// A relative or absolute path (`./x`, `../x`, `/abs`) — first-party code.
24    FirstParty,
25    /// A Node.js built-in (`node:fs`, `fs`, `fs/promises`, `path`, …).
26    Builtin,
27    /// Any other bare specifier — a third-party package (`lodash`, `@scope/x`).
28    ThirdParty,
29}
30
31/// Classify a module specifier, resolution-free: a relative or absolute path is first-party,
32/// a `node:` prefix or a built-in head segment is a built-in, and every other bare specifier
33/// is a third-party package.
34pub fn classify(specifier: &str) -> Origin {
35    if specifier.starts_with('.') || specifier.starts_with('/') {
36        return Origin::FirstParty;
37    }
38    if specifier.starts_with("node:") || is_node_builtin(specifier) {
39        return Origin::Builtin;
40    }
41    Origin::ThirdParty
42}
43
44/// `true` when `specifier`'s head segment is a Node built-in, so `fs/promises` matches on `fs`.
45fn is_node_builtin(specifier: &str) -> bool {
46    let head = specifier.split('/').next().unwrap_or(specifier);
47    NODE_BUILTINS.contains(&head)
48}
49
50/// The Node.js built-in module names. An explicit `node:` prefix is handled in [`classify`],
51/// so a future built-in stays recognized when written `node:<name>`.
52const NODE_BUILTINS: &[&str] = &[
53    "assert",
54    "async_hooks",
55    "buffer",
56    "child_process",
57    "cluster",
58    "console",
59    "constants",
60    "crypto",
61    "dgram",
62    "diagnostics_channel",
63    "dns",
64    "domain",
65    "events",
66    "fs",
67    "http",
68    "http2",
69    "https",
70    "inspector",
71    "module",
72    "net",
73    "os",
74    "path",
75    "perf_hooks",
76    "process",
77    "punycode",
78    "querystring",
79    "readline",
80    "repl",
81    "stream",
82    "string_decoder",
83    "sys",
84    "timers",
85    "tls",
86    "trace_events",
87    "tty",
88    "url",
89    "util",
90    "v8",
91    "vm",
92    "wasi",
93    "worker_threads",
94    "zlib",
95];
96
97/// Every integration-isolation violation in the `*.test.{ts,tsx,mts,cts}` files under
98/// `root`, sorted by `(file, line)`. A file that cannot be read or parsed is an error.
99pub fn find_integration_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
100    let root = root.as_ref();
101    let mut files = Vec::new();
102    collect_ts_test_files(root, &mut files)?;
103    files.sort();
104
105    let mut violations = Vec::new();
106    for file in &files {
107        let source = std::fs::read_to_string(file)
108            .with_context(|| format!("reading test file `{}`", file.display()))?;
109        violations.extend(integration_violations_in(file, &source)?);
110    }
111
112    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
113    Ok(violations)
114}
115
116const UNKNOWN_TIER_MSG: &str = "test file sits under `tests/` outside the standard suite tiers; \
117     a suite lives in `tests/integration/` or `tests/e2e/`";
118
119/// Every integration-isolation violation in `package_root`'s suite tiers, sorted by
120/// `(file, line)`. `tests/integration/` and `tests/e2e/` both run first-party code for real;
121/// a test file under `tests/` outside them is `unknown-tier` rather than silently unscanned.
122pub fn find_suite_violations(package_root: &Path) -> Result<Vec<Violation>> {
123    let tests = package_root.join("tests");
124    let mut violations = Vec::new();
125    let tiers = ["integration", "e2e"].map(|tier| tests.join(tier));
126    for tier in &tiers {
127        if tier.is_dir() {
128            violations.extend(find_integration_violations(tier)?);
129        }
130    }
131    if tests.is_dir() {
132        let mut strays = Vec::new();
133        collect_ts_test_files(&tests, &mut strays)?;
134        strays.retain(|file| !tiers.iter().any(|tier| file.starts_with(tier)));
135        for file in strays {
136            violations.push(Violation {
137                file,
138                line: 1,
139                rule: "unknown-tier",
140                message: UNKNOWN_TIER_MSG.to_string(),
141            });
142        }
143    }
144    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
145    Ok(violations)
146}
147
148/// Every unit-isolation violation under `root` — a runtime import that isn't `vi.mock()`-ed
149/// — sorted by `(file, line)`. A file that cannot be read or parsed is an error.
150pub fn find_unit_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
151    let root = root.as_ref();
152    let mut files = Vec::new();
153    collect_ts_test_files(root, &mut files)?;
154    // The suite tiers run first-party code for real, so their files are never unit subjects.
155    if let Some(tests) = crate::tiers::suite_tests_dir(root, "package.json") {
156        files.retain(|file| !file.starts_with(&tests));
157    }
158    files.sort();
159
160    let mut violations = Vec::new();
161    for file in &files {
162        let source = std::fs::read_to_string(file)
163            .with_context(|| format!("reading test file `{}`", file.display()))?;
164        violations.extend(unit_violations_in(file, &source)?);
165    }
166
167    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
168    Ok(violations)
169}
170
171/// One unit test file's `unmocked-collaborator` violations: every runtime import that isn't
172/// the unit under test, the test runner, or `vi.mock()`-ed.
173fn unit_violations_in(file: &Path, source: &str) -> Result<Vec<Violation>> {
174    let allocator = Allocator::default();
175    let source_type = SourceType::from_path(file).map_err(|err| {
176        anyhow!(
177            "unsupported TypeScript extension `{}`: {err}",
178            file.display()
179        )
180    })?;
181    let ret = Parser::new(&allocator, source, source_type).parse();
182    if ret.panicked || !ret.diagnostics.is_empty() {
183        let detail = ret
184            .diagnostics
185            .iter()
186            .map(|d| d.to_string())
187            .collect::<Vec<_>>()
188            .join("; ");
189        bail!("parsing `{}` failed: {detail}", file.display());
190    }
191
192    let mut collector = UnitCollector {
193        source,
194        imports: Vec::new(),
195        mocked: BTreeSet::new(),
196        untyped: Vec::new(),
197    };
198    collector.visit_program(&ret.program);
199
200    let unit = unit_under_test_specifier(file);
201    // Vitest resolves `./formatter` and `./formatter.js` to one module, so an extension
202    // mismatch between a mock and its import must not read as an unmocked collaborator.
203    let mocked_modules: BTreeSet<&str> = collector
204        .mocked
205        .iter()
206        .map(|m| strip_module_ext(m))
207        .collect();
208    let mut violations = Vec::new();
209    for (spec, line) in &collector.imports {
210        if is_unit_under_test(spec, &unit)
211            || is_test_runner(spec)
212            || mocked_modules.contains(strip_module_ext(spec))
213        {
214            continue;
215        }
216        violations.push(Violation {
217            file: file.to_path_buf(),
218            line: *line,
219            rule: "unmocked-collaborator",
220            message: format!(
221                "unit test imports `{spec}` without mocking it — a unit test isolates the \
222                 unit under test, so every collaborator must be `vi.mock()`-ed"
223            ),
224        });
225    }
226    for (spec, line) in &collector.untyped {
227        violations.push(Violation {
228            file: file.to_path_buf(),
229            line: *line,
230            rule: "untyped-mock",
231            message: format!(
232                "`vi.mock('{spec}', …)` has an untyped factory — anchor it to the real module \
233                 with `vi.importActual<typeof import('{spec}')>()` so the double can't drift \
234                 from the source"
235            ),
236        });
237    }
238    violations.sort_by_key(|v| v.line);
239    Ok(violations)
240}
241
242/// Collects a unit test's imports, `vi.mock()` targets, and untyped factories in one pass.
243struct UnitCollector<'s> {
244    source: &'s str,
245    imports: Vec<(String, usize)>,
246    mocked: BTreeSet<String>,
247    untyped: Vec<(String, usize)>,
248}
249
250impl<'a> Visit<'a> for UnitCollector<'_> {
251    fn visit_import_declaration(&mut self, decl: &ImportDeclaration<'a>) {
252        // `import type …` is erased at compile time — not a runtime dependency.
253        if matches!(decl.import_kind, ImportOrExportKind::Type) {
254            return;
255        }
256        self.imports.push((
257            decl.source.value.to_string(),
258            line_of(self.source, decl.span.start),
259        ));
260    }
261
262    fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
263        if let Some(spec) = vi_mock_target(call) {
264            if let Some(factory) = call.arguments.get(1) {
265                if is_factory(factory) && !factory_is_typed(factory) {
266                    self.untyped
267                        .push((spec.clone(), line_of(self.source, call.span.start)));
268                }
269            }
270            self.mocked.insert(spec);
271        }
272        walk::walk_call_expression(self, call);
273    }
274}
275
276/// The unit-under-test specifier for a test file: `pkg/widget.test.ts` → `./widget`.
277fn unit_under_test_specifier(file: &Path) -> String {
278    let name = file
279        .file_name()
280        .and_then(|n| n.to_str())
281        .unwrap_or_default();
282    let stem = name.split(".test.").next().unwrap_or(name);
283    format!("./{stem}")
284}
285
286/// `true` when `spec` resolves to the unit under test, ignoring the module extension.
287fn is_unit_under_test(spec: &str, unit: &str) -> bool {
288    strip_module_ext(spec) == unit
289}
290
291/// `spec` without a trailing JS/TS module extension.
292fn strip_module_ext(spec: &str) -> &str {
293    for ext in [".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"] {
294        if let Some(base) = spec.strip_suffix(ext) {
295            return base;
296        }
297    }
298    spec
299}
300
301/// `true` for the Vitest runner itself (`vitest`, `vitest/*`, `@vitest/*`), never a mock target.
302fn is_test_runner(spec: &str) -> bool {
303    spec == "vitest" || spec.starts_with("vitest/") || spec.starts_with("@vitest/")
304}
305
306/// `true` when a `vi.mock` second argument is a factory *function*. The other 2nd-arg form
307/// is an options object (`vi.mock(spec, { spy: true })`), which spies on the real module and
308/// so can't drift; only a function factory returns a hand-built double that can.
309fn is_factory(arg: &Argument) -> bool {
310    matches!(
311        arg.as_expression(),
312        Some(Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_))
313    )
314}
315
316/// `true` when a `vi.mock` factory anchors to the real module's type — its body contains a
317/// `vi.importActual<…>()` call carrying a type argument.
318fn factory_is_typed(factory: &Argument) -> bool {
319    let mut finder = ImportActualFinder { typed: false };
320    finder.visit_argument(factory);
321    finder.typed
322}
323
324/// Walks a `vi.mock` factory looking for a typed `vi.importActual<…>()` call.
325struct ImportActualFinder {
326    typed: bool,
327}
328
329impl<'a> Visit<'a> for ImportActualFinder {
330    fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
331        if is_typed_import_actual(call) {
332            self.typed = true;
333        }
334        walk::walk_call_expression(self, call);
335    }
336}
337
338/// `true` for `vi.importActual<…>(…)` — a call to `vi.importActual` that carries a
339/// type argument (an untyped `vi.importActual(…)` returns `unknown`).
340fn is_typed_import_actual(call: &CallExpression) -> bool {
341    let Expression::StaticMemberExpression(member) = &call.callee else {
342        return false;
343    };
344    let is_vi = matches!(&member.object, Expression::Identifier(id) if id.name == "vi");
345    is_vi && member.property.name.as_str() == "importActual" && call.type_arguments.is_some()
346}
347
348/// One test file's `no-first-party-mock` violations. A parse failure is an error — a
349/// malformed test file is never a silent pass.
350fn integration_violations_in(file: &Path, source: &str) -> Result<Vec<Violation>> {
351    let allocator = Allocator::default();
352    let source_type = SourceType::from_path(file).map_err(|err| {
353        anyhow!(
354            "unsupported TypeScript extension `{}`: {err}",
355            file.display()
356        )
357    })?;
358    let ret = Parser::new(&allocator, source, source_type).parse();
359    if ret.panicked || !ret.diagnostics.is_empty() {
360        let detail = ret
361            .diagnostics
362            .iter()
363            .map(|d| d.to_string())
364            .collect::<Vec<_>>()
365            .join("; ");
366        bail!("parsing `{}` failed: {detail}", file.display());
367    }
368
369    let mut visitor = MockVisitor {
370        file,
371        source,
372        violations: Vec::new(),
373    };
374    visitor.visit_program(&ret.program);
375    Ok(visitor.violations)
376}
377
378/// Walks one test file, flagging every `vi.mock()` / `vi.doMock()` of a first-party module.
379struct MockVisitor<'s> {
380    file: &'s Path,
381    source: &'s str,
382    violations: Vec<Violation>,
383}
384
385impl MockVisitor<'_> {
386    fn report(&mut self, span: Span, spec: &str) {
387        self.violations.push(Violation {
388            file: self.file.to_path_buf(),
389            line: line_of(self.source, span.start),
390            rule: "no-first-party-mock",
391            message: format!(
392                "integration test mocks first-party module `{spec}` — an integration test \
393                 runs first-party code for real; only third-party packages and Node built-ins \
394                 may be mocked"
395            ),
396        });
397    }
398}
399
400impl<'a> Visit<'a> for MockVisitor<'_> {
401    fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
402        if let Some(spec) = vi_mock_target(call) {
403            if classify(&spec) == Origin::FirstParty {
404                self.report(call.span, &spec);
405            }
406        }
407        walk::walk_call_expression(self, call);
408    }
409}
410
411/// The specifier of a `vi.mock("spec", …)` / `vi.doMock("spec", …)` call, or `None`. A
412/// non-literal target (`vi.mock(name)`) can't be classified deterministically, so it is
413/// skipped rather than guessed at.
414fn vi_mock_target(call: &CallExpression) -> Option<String> {
415    let Expression::StaticMemberExpression(member) = &call.callee else {
416        return None;
417    };
418    let is_vi = matches!(&member.object, Expression::Identifier(id) if id.name == "vi");
419    if !is_vi {
420        return None;
421    }
422    let method = member.property.name.as_str();
423    if method != "mock" && method != "doMock" {
424        return None;
425    }
426    match call.arguments.first() {
427        Some(Argument::StringLiteral(lit)) => Some(lit.value.to_string()),
428        _ => None,
429    }
430}
431
432/// The 1-based line containing byte `offset` in `source`.
433fn line_of(source: &str, offset: u32) -> usize {
434    let offset = (offset as usize).min(source.len());
435    source.as_bytes()[..offset]
436        .iter()
437        .filter(|&&byte| byte == b'\n')
438        .count()
439        + 1
440}
441
442/// `true` when `source` (a module at `path`) declares something and compiles to zero runtime
443/// JavaScript, so it has no behavior to unit-test. An empty module and one that fails to parse
444/// are both `false`: the presence rule keeps a module it couldn't read as a subject.
445pub fn is_type_only_module(source: &str, path: &Path) -> bool {
446    let allocator = Allocator::default();
447    let Ok(source_type) = SourceType::from_path(path) else {
448        return false;
449    };
450    let ret = Parser::new(&allocator, source, source_type).parse();
451    if ret.panicked || !ret.diagnostics.is_empty() {
452        return false;
453    }
454    let body = &ret.program.body;
455    !body.is_empty() && body.iter().all(is_type_only_statement)
456}
457
458/// `true` when a top-level statement contributes no runtime code — see [`is_type_only_module`].
459fn is_type_only_statement(statement: &Statement) -> bool {
460    match statement {
461        Statement::TSTypeAliasDeclaration(_) | Statement::TSInterfaceDeclaration(_) => true,
462        Statement::ImportDeclaration(decl) => decl.import_kind.is_type(),
463        Statement::ExportAllDeclaration(decl) => decl.export_kind.is_type(),
464        // The parser marks an exported type alias or interface as a type export, so a
465        // value-kind named export always carries runtime bindings.
466        Statement::ExportNamedDeclaration(decl) => decl.export_kind.is_type(),
467        _ => false,
468    }
469}
470
471/// `true` when `base` and `head` — the module at `path` before and after an edit — compile to
472/// the same JavaScript. A side that fails to parse is **not** equal: co-change then holds the
473/// file to its colocated test rather than skip a module it couldn't read.
474pub fn same_code(base: &str, head: &str, path: &Path) -> bool {
475    match (
476        emit_without_comments(base, path),
477        emit_without_comments(head, path),
478    ) {
479        (Some(base), Some(head)) => base == head,
480        _ => false,
481    }
482}
483
484/// `source` re-emitted with every comment dropped, or `None` when it does not parse.
485fn emit_without_comments(source: &str, path: &Path) -> Option<String> {
486    let allocator = Allocator::default();
487    let source_type = SourceType::from_path(path).ok()?;
488    let ret = Parser::new(&allocator, source, source_type).parse();
489    if ret.panicked || !ret.diagnostics.is_empty() {
490        return None;
491    }
492    Some(
493        Codegen::new()
494            .with_options(CodegenOptions {
495                comments: CommentOptions::disabled(),
496                ..CodegenOptions::default()
497            })
498            .build(&ret.program)
499            .code,
500    )
501}
502
503fn collect_ts_test_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
504    let entries =
505        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
506    for entry in entries {
507        let path = crate::walk::dir_entry(entry, dir)?.path();
508        if path.is_dir() {
509            collect_ts_test_files(&path, out)?;
510        } else if is_ts_test_file(&path) {
511            out.push(path);
512        }
513    }
514    Ok(())
515}
516
517/// `true` for a TypeScript test file: `*.test.{ts,tsx,mts,cts}`.
518fn is_ts_test_file(path: &Path) -> bool {
519    let name = path
520        .file_name()
521        .and_then(|n| n.to_str())
522        .unwrap_or_default();
523    name.ends_with(".test.ts")
524        || name.ends_with(".test.tsx")
525        || name.ends_with(".test.mts")
526        || name.ends_with(".test.cts")
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    /// Parse `source` as `name` and return its integration violations.
534    fn violations(name: &str, source: &str) -> Vec<Violation> {
535        integration_violations_in(Path::new(name), source).expect("source should parse")
536    }
537
538    /// Parse `source` as `name` and return its unit-isolation violations.
539    fn unit_violations(name: &str, source: &str) -> Vec<Violation> {
540        unit_violations_in(Path::new(name), source).expect("source should parse")
541    }
542
543    #[test]
544    fn unit_flags_unmocked_first_party_and_external() {
545        let found = unit_violations(
546            "widget.test.ts",
547            "import { makeWidget } from './widget';\n\
548             import { format } from './formatter';\n\
549             import { chunk } from 'lodash';\n",
550        );
551        // `./widget` is the unit under test; the other two are imported but not mocked.
552        assert_eq!(found.len(), 2, "got: {found:?}");
553        assert!(found.iter().all(|v| v.rule == "unmocked-collaborator"));
554        assert!(found.iter().any(|v| v.message.contains("./formatter")));
555        assert!(found.iter().any(|v| v.message.contains("lodash")));
556    }
557
558    #[test]
559    fn unit_mocked_collaborator_is_clean() {
560        let found = unit_violations(
561            "widget.test.ts",
562            "import { format } from './formatter';\nvi.mock('./formatter');\n",
563        );
564        assert!(found.is_empty(), "got: {found:?}");
565    }
566
567    #[test]
568    fn unit_under_test_and_runner_are_not_flagged() {
569        let found = unit_violations(
570            "widget.test.ts",
571            "import { vi } from 'vitest';\n\
572             import { makeWidget } from './widget.js';\n",
573        );
574        // `vitest` is the runner; `./widget.js` is the unit under test (extension ignored).
575        assert!(found.is_empty(), "got: {found:?}");
576    }
577
578    /// Whether `source` (named `foo.ts`) is a type-only module.
579    fn type_only(source: &str) -> bool {
580        is_type_only_module(source, Path::new("foo.ts"))
581    }
582
583    #[test]
584    fn type_only_recognizes_a_pure_type_module() {
585        assert!(type_only(
586            "export interface Shape { kind: string }\nexport type Id = string;\n"
587        ));
588        assert!(type_only(
589            "import type { Shape } from './shape';\nexport type Wrapped = Shape;\n"
590        ));
591        assert!(type_only("export type { Id } from './shape';\n"));
592        assert!(type_only(
593            "type Local = number;\ninterface Bare { x: Local }\n"
594        ));
595        assert!(type_only("export type * from './shapes';\n"));
596    }
597
598    #[test]
599    fn type_only_rejects_any_runtime_construct() {
600        assert!(!type_only(
601            "export type T = number;\nexport const version: T = 1;\n"
602        ));
603        assert!(!type_only(
604            "export interface I {}\nexport function make(): I { return {}; }\n"
605        ));
606        assert!(!type_only(
607            "import { x } from './x';\nexport type T = typeof x;\n"
608        ));
609        assert!(!type_only("export * from './widget';\n"));
610        assert!(!type_only("export { thing } from './thing';\n"));
611        assert!(!type_only("export enum Color { Red, Green }\n"));
612        assert!(!type_only("export namespace N { export const x = 1; }\n"));
613    }
614
615    #[test]
616    fn type_only_is_false_for_empty_or_unparsable() {
617        // An empty/comment-only file is a non-subject on its own account, not via this path.
618        assert!(!type_only(""));
619        assert!(!type_only("// just a comment\n"));
620        assert!(!type_only("export type T = ;;;\nconst {{{ = \n"));
621    }
622
623    /// Whether `base` and `head` (a module named `foo.ts`) compile to the same JavaScript.
624    fn same(base: &str, head: &str) -> bool {
625        same_code(base, head, Path::new("foo.ts"))
626    }
627
628    #[test]
629    fn same_code_drops_comments_and_formatting() {
630        assert!(same(
631            "// widget factory\nexport const widget = () => 1;\n",
632            "// widget builder\nexport const widget = () => 1;\n"
633        ));
634        assert!(same(
635            "/* widget factory\n   used by the CLI */\nexport const widget = () => 1;\n",
636            "export const widget = () => 1;\n"
637        ));
638        assert!(same(
639            "/** A widget. */\nexport const widget = () => 1;\n",
640            "export const widget = () => 1;\n"
641        ));
642        assert!(same(
643            "export const widget = () => 1;\n",
644            "\n\nexport const widget = () => 1;\n\n"
645        ));
646        assert!(same(
647            "export function widget() { return 1; }\n",
648            "export function widget() {\n        return 1;\n}\n"
649        ));
650    }
651
652    #[test]
653    fn same_code_keeps_everything_the_module_emits() {
654        assert!(!same(
655            "export const widget = () => 1;\n",
656            "export const widget = () => 2;\n"
657        ));
658        assert!(!same(
659            "export const widget = () => 'one';\n",
660            "export const widget = () => 'two';\n"
661        ));
662        assert!(!same(
663            "export const widget = () => `one`;\n",
664            "export const widget = () => `two`;\n"
665        ));
666        assert!(!same(
667            "export const widget = (n: number): number => n;\n",
668            "export const widget = (n: string): string => n;\n"
669        ));
670        assert!(!same(
671            "// widget factory\nexport const widget = () => 1;\n",
672            "// widget builder\nexport const widget = () => 2;\n"
673        ));
674    }
675
676    #[test]
677    fn same_code_holds_apart_what_it_cannot_read() {
678        assert!(!same(
679            "export const widget = (() => 1;\n",
680            "// still broken\nexport const widget = (() => 1;\n"
681        ));
682        assert!(!same(
683            "export const widget = () => 1;\n",
684            "export const widget = (() => 1;\n"
685        ));
686        assert!(!same(
687            "export const widget = (() => 1;\n",
688            "export const widget = () => 1;\n"
689        ));
690        let source = "export const widget = () => 1;\n";
691        assert!(!same_code(source, source, Path::new("widget.txt")));
692    }
693
694    #[test]
695    fn unit_type_only_import_is_not_flagged() {
696        let found = unit_violations(
697            "widget.test.ts",
698            "import type { Opts } from './opts';\nimport { x } from './x';\nvi.mock('./x');\n",
699        );
700        assert!(found.is_empty(), "got: {found:?}");
701    }
702
703    #[test]
704    fn unit_under_test_specifier_strips_test_suffix() {
705        assert_eq!(
706            unit_under_test_specifier(Path::new("pkg/widget.test.ts")),
707            "./widget"
708        );
709        assert_eq!(
710            unit_under_test_specifier(Path::new("button.test.tsx")),
711            "./button"
712        );
713    }
714
715    #[test]
716    fn strip_module_ext_drops_known_extensions_only() {
717        assert_eq!(strip_module_ext("./widget.js"), "./widget");
718        assert_eq!(strip_module_ext("./widget.mts"), "./widget");
719        assert_eq!(strip_module_ext("./widget"), "./widget");
720        assert_eq!(strip_module_ext("lodash"), "lodash");
721    }
722
723    #[test]
724    fn recognizes_the_test_runner() {
725        assert!(is_test_runner("vitest"));
726        assert!(is_test_runner("vitest/config"));
727        assert!(is_test_runner("@vitest/spy"));
728        assert!(!is_test_runner("./vitest-helpers"));
729        assert!(!is_test_runner("lodash"));
730    }
731
732    #[test]
733    fn unit_flags_untyped_factory_mock() {
734        let found = unit_violations(
735            "widget.test.ts",
736            "import { x } from './x';\nvi.mock('./x', () => ({ x: vi.fn() }));\n",
737        );
738        // Mocked, so not an `unmocked-collaborator`; the factory has no type anchor.
739        assert_eq!(found.len(), 1, "got: {found:?}");
740        assert_eq!(found[0].rule, "untyped-mock");
741        assert!(found[0].message.contains("./x"));
742    }
743
744    #[test]
745    fn unit_typed_factory_mock_is_clean() {
746        let found = unit_violations(
747            "widget.test.ts",
748            "import { x } from './x';\n\
749             vi.mock('./x', async () => {\n\
750             \x20 const actual = await vi.importActual<typeof import('./x')>('./x');\n\
751             \x20 return { ...actual, x: vi.fn() };\n\
752             });\n",
753        );
754        assert!(found.is_empty(), "got: {found:?}");
755    }
756
757    #[test]
758    fn unit_options_object_mock_is_not_a_factory() {
759        let found = unit_violations(
760            "widget.test.ts",
761            "import { x } from './x';\nvi.mock('./x', { spy: true });\n",
762        );
763        assert!(found.is_empty(), "got: {found:?}");
764    }
765
766    #[test]
767    fn unit_untyped_import_actual_is_still_untyped() {
768        // `vi.importActual` without a type argument returns `unknown` — not a type anchor.
769        let found = unit_violations(
770            "widget.test.ts",
771            "import { x } from './x';\n\
772             vi.mock('./x', async () => {\n\
773             \x20 const actual = await vi.importActual('./x');\n\
774             \x20 return { ...(actual as object), x: vi.fn() };\n\
775             });\n",
776        );
777        assert_eq!(found.len(), 1, "got: {found:?}");
778        assert_eq!(found[0].rule, "untyped-mock");
779    }
780
781    #[test]
782    fn classify_relative_is_first_party() {
783        assert_eq!(classify("./service"), Origin::FirstParty);
784        assert_eq!(classify("../pkg/util"), Origin::FirstParty);
785        assert_eq!(classify("/abs/path"), Origin::FirstParty);
786    }
787
788    #[test]
789    fn classify_node_builtins() {
790        assert_eq!(classify("fs"), Origin::Builtin);
791        assert_eq!(classify("node:fs"), Origin::Builtin);
792        assert_eq!(classify("fs/promises"), Origin::Builtin);
793        assert_eq!(classify("node:test"), Origin::Builtin);
794        assert_eq!(classify("child_process"), Origin::Builtin);
795        assert_eq!(classify("node:some-future-builtin"), Origin::Builtin);
796    }
797
798    #[test]
799    fn classify_third_party() {
800        assert_eq!(classify("lodash"), Origin::ThirdParty);
801        assert_eq!(classify("@scope/pkg"), Origin::ThirdParty);
802        assert_eq!(classify("stripe/lib/client"), Origin::ThirdParty);
803        // A bare `test` is too ambiguous to assume the built-in; `node:test` is not.
804        assert_eq!(classify("test"), Origin::ThirdParty);
805    }
806
807    #[test]
808    fn recognizes_ts_test_files() {
809        assert!(is_ts_test_file(Path::new("widget.test.ts")));
810        assert!(is_ts_test_file(Path::new("pkg/button.test.tsx")));
811        assert!(is_ts_test_file(Path::new("service.test.mts")));
812        assert!(is_ts_test_file(Path::new("legacy.test.cts")));
813        assert!(!is_ts_test_file(Path::new("widget.ts")));
814        assert!(!is_ts_test_file(Path::new("types.d.ts")));
815        assert!(!is_ts_test_file(Path::new("README.md")));
816    }
817
818    #[test]
819    fn line_of_counts_newlines() {
820        let src = "a\nb\nc\n";
821        assert_eq!(line_of(src, 0), 1);
822        assert_eq!(line_of(src, 2), 2);
823        assert_eq!(line_of(src, 4), 3);
824    }
825
826    #[test]
827    fn flags_mock_of_relative_module() {
828        let found = violations("a.test.ts", "vi.mock('./service');\n");
829        assert_eq!(found.len(), 1);
830        assert_eq!(found[0].rule, "no-first-party-mock");
831        assert_eq!(found[0].line, 1);
832    }
833
834    #[test]
835    fn flags_mock_with_factory_and_parent_path() {
836        let found = violations(
837            "a.test.ts",
838            "import { x } from './x';\nvi.mock('../src/ledger', () => ({ record: vi.fn() }));\n",
839        );
840        assert_eq!(found.len(), 1);
841        assert!(found[0].message.contains("../src/ledger"));
842    }
843
844    #[test]
845    fn flags_domock_of_relative_module() {
846        let found = violations("a.test.mts", "vi.doMock('./mailer');\n");
847        assert_eq!(found.len(), 1);
848    }
849
850    #[test]
851    fn allows_mock_of_third_party_and_builtins() {
852        let found = violations(
853            "a.test.ts",
854            "vi.mock('stripe');\nvi.mock('node:fs');\nvi.mock('fs/promises');\nvi.mock('@scope/pkg');\n",
855        );
856        assert!(found.is_empty(), "got: {found:?}");
857    }
858
859    #[test]
860    fn ignores_non_vi_and_non_mock_calls() {
861        let found = violations(
862            "a.test.ts",
863            "describe('s', () => {});\nvi.fn();\nexpect(1).toBe(1);\nother.mock('./x');\n",
864        );
865        assert!(found.is_empty(), "got: {found:?}");
866    }
867
868    #[test]
869    fn ignores_dynamic_mock_target() {
870        let found = violations("a.test.ts", "const m = './x';\nvi.mock(m);\n");
871        assert!(found.is_empty(), "got: {found:?}");
872    }
873
874    #[test]
875    fn finds_mocks_nested_in_blocks() {
876        // `vi.mock` is normally hoisted, but a nested call is still reached by the walk.
877        let found = violations(
878            "a.test.ts",
879            "describe('s', () => {\n  vi.mock('./inner');\n});\n",
880        );
881        assert_eq!(found.len(), 1);
882        assert_eq!(found[0].line, 2);
883    }
884
885    #[test]
886    fn parse_error_is_reported() {
887        let err = integration_violations_in(Path::new("bad.test.ts"), "const x = ;\n").unwrap_err();
888        assert!(err.to_string().contains("parsing"), "got: {err}");
889    }
890
891    #[test]
892    fn unsupported_extension_is_reported() {
893        let err = integration_violations_in(Path::new("weird.test.bogus"), "vi.mock('./x');\n")
894            .unwrap_err();
895        assert!(err.to_string().contains("unsupported"), "got: {err}");
896    }
897
898    #[test]
899    fn unit_parse_error_is_reported() {
900        let err = unit_violations_in(Path::new("bad.test.ts"), "const x = ;\n").unwrap_err();
901        assert!(err.to_string().contains("parsing"), "got: {err}");
902    }
903
904    #[test]
905    fn unit_unsupported_extension_is_reported() {
906        let err =
907            unit_violations_in(Path::new("weird.test.bogus"), "vi.mock('./x');\n").unwrap_err();
908        assert!(err.to_string().contains("unsupported"), "got: {err}");
909    }
910
911    #[test]
912    fn type_only_is_false_for_an_unsupported_extension() {
913        assert!(!is_type_only_module(
914            "export type T = number;\n",
915            Path::new("foo.txt")
916        ));
917    }
918
919    #[test]
920    fn type_only_rejects_a_plain_runtime_statement() {
921        assert!(!type_only("const x = 1;\ntype T = number;\n"));
922    }
923
924    #[test]
925    fn a_factory_calling_a_plain_helper_is_untyped() {
926        let found = unit_violations(
927            "widget.test.ts",
928            "import { x } from './x';\nvi.mock('./x', () => makeDouble());\n",
929        );
930        assert_eq!(found.len(), 1, "got: {found:?}");
931        assert_eq!(found[0].rule, "untyped-mock");
932    }
933
934    #[test]
935    fn a_package_without_a_tests_dir_has_no_suite_violations() {
936        let dir = unique_tmp("suite-test");
937        let found = find_suite_violations(&dir).expect("an empty package scans clean");
938        assert!(found.is_empty(), "got: {found:?}");
939    }
940
941    fn unique_tmp(slug: &str) -> PathBuf {
942        use std::sync::atomic::{AtomicU64, Ordering};
943        static COUNTER: AtomicU64 = AtomicU64::new(0);
944        let dir = std::env::temp_dir().join(format!(
945            "tc-ts-{slug}-{}-{}",
946            std::process::id(),
947            COUNTER.fetch_add(1, Ordering::Relaxed)
948        ));
949        std::fs::create_dir_all(&dir).unwrap();
950        dir
951    }
952
953    #[test]
954    fn suite_violations_cover_tiers_and_sort_stray_files() {
955        let dir = unique_tmp("suite-busy");
956        std::fs::create_dir_all(dir.join("tests/integration")).unwrap();
957        std::fs::write(
958            dir.join("tests/integration/flow.test.ts"),
959            "import { x } from './x';\n",
960        )
961        .unwrap();
962        std::fs::write(dir.join("tests/stray_b.test.ts"), "").unwrap();
963        std::fs::write(dir.join("tests/stray_a.test.ts"), "").unwrap();
964        let found = find_suite_violations(&dir).unwrap();
965        let _ = std::fs::remove_dir_all(&dir);
966        assert_eq!(found.len(), 2, "got: {found:?}");
967        assert!(found.iter().all(|v| v.rule == "unknown-tier"));
968        assert!(found[0].file < found[1].file);
969    }
970
971    #[test]
972    fn an_unreadable_integration_test_file_names_the_file() {
973        let dir = unique_tmp("nonutf8-int");
974        std::fs::write(dir.join("flow.test.ts"), [0xFF, 0xFE]).unwrap();
975        let err = find_integration_violations(&dir).unwrap_err();
976        let _ = std::fs::remove_dir_all(&dir);
977        assert!(
978            format!("{err:#}").contains("reading test file"),
979            "got: {err:#}"
980        );
981    }
982
983    #[test]
984    fn an_unreadable_unit_test_file_names_the_file() {
985        let dir = unique_tmp("nonutf8-unit");
986        std::fs::write(dir.join("widget.test.ts"), [0xFF, 0xFE]).unwrap();
987        let err = find_unit_violations(&dir).unwrap_err();
988        let _ = std::fs::remove_dir_all(&dir);
989        assert!(
990            format!("{err:#}").contains("reading test file"),
991            "got: {err:#}"
992        );
993    }
994
995    #[test]
996    fn a_missing_root_is_an_error() {
997        let err = find_integration_violations(Path::new("/nonexistent-tc-ts")).unwrap_err();
998        assert!(
999            format!("{err:#}").contains("reading directory"),
1000            "got: {err:#}"
1001        );
1002    }
1003}