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, 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/// Where a module specifier resolves, for isolation purposes.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Origin {
24    /// A relative or absolute path (`./x`, `../x`, `/abs`) — first-party code.
25    FirstParty,
26    /// A Node.js built-in (`node:fs`, `fs`, `fs/promises`, `path`, …).
27    Builtin,
28    /// Any other bare specifier — a third-party package (`lodash`, `@scope/x`).
29    ThirdParty,
30}
31
32/// Classify a module specifier, resolution-free: a relative or absolute path is first-party,
33/// a `node:` prefix or a built-in head segment is a built-in, and every other bare specifier
34/// is a third-party package.
35pub 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
45/// `true` when `specifier`'s head segment is a Node built-in, so `fs/promises` matches on `fs`.
46fn is_node_builtin(specifier: &str) -> bool {
47    let head = specifier.split('/').next().unwrap_or(specifier);
48    NODE_BUILTINS.contains(&head)
49}
50
51/// The Node.js built-in module names. An explicit `node:` prefix is handled in [`classify`],
52/// so a future built-in stays recognized when written `node:<name>`.
53const 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
98/// Every integration-isolation violation in the `*.test.{ts,tsx,mts,cts}` files under
99/// `root`, sorted by `(file, line)`. A file that cannot be read or parsed is an error.
100pub 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
120/// Every integration-isolation violation in `package_root`'s suite tiers, sorted by
121/// `(file, line)`. `tests/integration/` and `tests/e2e/` both run first-party code for real;
122/// a test file under `tests/` outside them is `unknown-tier` rather than silently unscanned.
123pub 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
149/// Every unit-isolation violation under `root` — a runtime import that isn't `vi.mock()`-ed
150/// — sorted by `(file, line)`. A file that cannot be read or parsed is an error.
151pub 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    // The suite tiers run first-party code for real, so their files are never unit subjects.
156    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
172/// One unit test file's `unmocked-collaborator` violations: every runtime import that isn't
173/// the unit under test, the test runner, or `vi.mock()`-ed.
174fn 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    // Vitest resolves `./formatter` and `./formatter.js` to one module, so an extension
203    // mismatch between a mock and its import must not read as an unmocked collaborator.
204    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
243/// Collects a unit test's imports, `vi.mock()` targets, and untyped factories in one pass.
244struct 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        // `import type …` is erased at compile time — not a runtime dependency.
254        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
277/// The unit-under-test specifier for a test file: `pkg/widget.test.ts` → `./widget`.
278fn 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
287/// `true` when `spec` resolves to the unit under test, ignoring the module extension.
288fn is_unit_under_test(spec: &str, unit: &str) -> bool {
289    strip_module_ext(spec) == unit
290}
291
292/// `spec` without a trailing JS/TS module extension.
293fn 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
302/// `true` for the Vitest runner itself (`vitest`, `vitest/*`, `@vitest/*`), never a mock target.
303fn is_test_runner(spec: &str) -> bool {
304    spec == "vitest" || spec.starts_with("vitest/") || spec.starts_with("@vitest/")
305}
306
307/// `true` when a `vi.mock` second argument is a factory *function*. The other 2nd-arg form
308/// is an options object (`vi.mock(spec, { spy: true })`), which spies on the real module and
309/// so can't drift; only a function factory returns a hand-built double that can.
310fn is_factory(arg: &Argument) -> bool {
311    matches!(
312        arg.as_expression(),
313        Some(Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_))
314    )
315}
316
317/// `true` when a `vi.mock` factory anchors to the real module's type — its body contains a
318/// `vi.importActual<…>()` call carrying a type argument.
319fn factory_is_typed(factory: &Argument) -> bool {
320    let mut finder = ImportActualFinder { typed: false };
321    finder.visit_argument(factory);
322    finder.typed
323}
324
325/// Walks a `vi.mock` factory looking for a typed `vi.importActual<…>()` call.
326struct 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
339/// `true` for `vi.importActual<…>(…)` — a call to `vi.importActual` that carries a
340/// type argument (an untyped `vi.importActual(…)` returns `unknown`).
341fn 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
349/// One test file's `no-first-party-mock` violations. A parse failure is an error — a
350/// malformed test file is never a silent pass.
351fn 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
379/// Walks one test file, flagging every `vi.mock()` / `vi.doMock()` of a first-party module.
380struct 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
412/// The specifier of a `vi.mock("spec", …)` / `vi.doMock("spec", …)` call, or `None`. A
413/// non-literal target (`vi.mock(name)`) can't be classified deterministically, so it is
414/// skipped rather than guessed at.
415fn 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
433/// The 1-based line containing byte `offset` in `source`.
434fn 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
443/// `true` when `source` (a module at `path`) declares something and compiles to zero runtime
444/// JavaScript, so it has no behavior to unit-test. An empty module and one that fails to parse
445/// are both `false`: the presence rule keeps a module it couldn't read as a subject.
446pub 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
459/// `true` when a top-level statement contributes no runtime code — see [`is_type_only_module`].
460fn 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                // A specifier-only `export { … }` re-exports runtime bindings.
474                None => false,
475            }
476        }
477        _ => false,
478    }
479}
480
481/// `true` when `base` and `head` — the module at `path` before and after an edit — compile to
482/// the same JavaScript. A side that fails to parse is **not** equal: co-change then holds the
483/// file to its colocated test rather than skip a module it couldn't read.
484pub 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
494/// `source` re-emitted with every comment dropped, or `None` when it does not parse.
495fn 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
529/// `true` for a TypeScript test file: `*.test.{ts,tsx,mts,cts}`.
530fn 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    /// Parse `source` as `name` and return its integration violations.
546    fn violations(name: &str, source: &str) -> Vec<Violation> {
547        integration_violations_in(Path::new(name), source).expect("source should parse")
548    }
549
550    /// Parse `source` as `name` and return its unit-isolation violations.
551    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        // `./widget` is the unit under test; the other two are imported but not mocked.
564        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        // `vitest` is the runner; `./widget.js` is the unit under test (extension ignored).
587        assert!(found.is_empty(), "got: {found:?}");
588    }
589
590    /// Whether `source` (named `foo.ts`) is a type-only module.
591    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        // An empty/comment-only file is a non-subject on its own account, not via this path.
630        assert!(!type_only(""));
631        assert!(!type_only("// just a comment\n"));
632        assert!(!type_only("export type T = ;;;\nconst {{{ = \n"));
633    }
634
635    /// Whether `base` and `head` (a module named `foo.ts`) compile to the same JavaScript.
636    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        // Mocked, so not an `unmocked-collaborator`; the factory has no type anchor.
751        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        // `vi.importActual` without a type argument returns `unknown` — not a type anchor.
781        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        // A bare `test` is too ambiguous to assume the built-in; `node:test` is not.
816        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        // `vi.mock` is normally hoisted, but a nested call is still reached by the walk.
889        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}