Skip to main content

testing_conventions/
ts.rs

1//! TypeScript isolation analysis, parsed with `oxc`.
2//!
3//! This is the TypeScript counterpart to the Python [`crate::lint`] module. In
4//! the *integration direction*, an integration test runs first-party code for
5//! real, so it may mock third-party packages and Node built-ins but **never** a
6//! first-party module.
7//!
8//! Detection is AST-based — each `*.test.{ts,tsx,mts,cts}` file is parsed with
9//! `oxc_parser` and walked for `vi.mock()` / `vi.doMock()` calls whose target
10//! specifier is first-party. The specifier [`classify`]-ication (first-party /
11//! Node-builtin / third-party) is the shared foundation the unit-direction
12//! slices build on.
13
14use std::collections::BTreeSet;
15use std::path::{Path, PathBuf};
16
17use anyhow::{anyhow, bail, Context, Result};
18use oxc::allocator::Allocator;
19use oxc::ast::ast::{Argument, CallExpression, Expression, ImportDeclaration, ImportOrExportKind};
20use oxc::ast_visit::{walk, Visit};
21use oxc::parser::Parser;
22use oxc::span::{SourceType, Span};
23
24use crate::lint::Violation;
25
26/// Where a module specifier resolves, for isolation purposes.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Origin {
29    /// A relative or absolute path (`./x`, `../x`, `/abs`) — first-party code.
30    FirstParty,
31    /// A Node.js built-in (`node:fs`, `fs`, `fs/promises`, `path`, …).
32    Builtin,
33    /// Any other bare specifier — a third-party package (`lodash`, `@scope/x`).
34    ThirdParty,
35}
36
37/// Classify a module specifier as first-party, Node-builtin, or third-party.
38///
39/// Deterministic and resolution-free — the bright-line rule the README's
40/// isolation checks rest on:
41/// - a **relative or absolute** path (`./`, `../`, `/`) is first-party;
42/// - a `node:`-prefixed specifier, or one whose first path segment is a known
43///   Node built-in (so `fs` and `fs/promises` both match), is a built-in;
44/// - every other (bare) specifier is a third-party package.
45pub fn classify(specifier: &str) -> Origin {
46    if specifier.starts_with('.') || specifier.starts_with('/') {
47        return Origin::FirstParty;
48    }
49    if specifier.starts_with("node:") || is_node_builtin(specifier) {
50        return Origin::Builtin;
51    }
52    Origin::ThirdParty
53}
54
55/// `true` when `specifier`'s first path segment is a Node.js built-in module —
56/// so a subpath export like `fs/promises` matches on its `fs` head.
57fn is_node_builtin(specifier: &str) -> bool {
58    let head = specifier.split('/').next().unwrap_or(specifier);
59    NODE_BUILTINS.contains(&head)
60}
61
62/// The Node.js built-in module names (the stable set). The explicit `node:`
63/// prefix is handled separately in [`classify`], so future built-ins stay
64/// recognized when written `node:<name>`.
65const NODE_BUILTINS: &[&str] = &[
66    "assert",
67    "async_hooks",
68    "buffer",
69    "child_process",
70    "cluster",
71    "console",
72    "constants",
73    "crypto",
74    "dgram",
75    "diagnostics_channel",
76    "dns",
77    "domain",
78    "events",
79    "fs",
80    "http",
81    "http2",
82    "https",
83    "inspector",
84    "module",
85    "net",
86    "os",
87    "path",
88    "perf_hooks",
89    "process",
90    "punycode",
91    "querystring",
92    "readline",
93    "repl",
94    "stream",
95    "string_decoder",
96    "sys",
97    "timers",
98    "tls",
99    "trace_events",
100    "tty",
101    "url",
102    "util",
103    "v8",
104    "vm",
105    "wasi",
106    "worker_threads",
107    "zlib",
108];
109
110/// Scan the TypeScript test files under `root` and return every
111/// integration-isolation violation, sorted by `(file, line)` for deterministic
112/// output.
113///
114/// A *TypeScript test file* is `*.test.{ts,tsx,mts,cts}`. Each is parsed and
115/// walked; a file that cannot be read or parsed is an error.
116pub fn find_integration_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
117    let root = root.as_ref();
118    let mut files = Vec::new();
119    collect_ts_test_files(root, &mut files)?;
120    files.sort();
121
122    let mut violations = Vec::new();
123    for file in &files {
124        let source = std::fs::read_to_string(file)
125            .with_context(|| format!("reading test file `{}`", file.display()))?;
126        violations.extend(integration_violations_in(file, &source)?);
127    }
128
129    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
130    Ok(violations)
131}
132
133/// Message for a test file under `<package root>/tests/` outside a standard
134/// suite tier.
135const UNKNOWN_TIER_MSG: &str = "test file sits under `tests/` outside the standard suite tiers; \
136     a suite lives in `tests/integration/` or `tests/e2e/`";
137
138/// Scan `package_root`'s suite tiers and return every integration-isolation
139/// violation, sorted by `(file, line)` for deterministic output.
140///
141/// The subjects are the standard suite directories — `tests/integration/` and
142/// `tests/e2e/`, both of which run first-party code for real and so are held to
143/// the integration rules ([`find_integration_violations`]). A test file under
144/// `<package root>/tests/` outside a standard tier is flagged as `unknown-tier`:
145/// the layout is part of the standard, so a suite the scan would silently miss
146/// is an error instead.
147pub fn find_suite_violations(package_root: &Path) -> Result<Vec<Violation>> {
148    let tests = package_root.join("tests");
149    let mut violations = Vec::new();
150    let tiers = ["integration", "e2e"].map(|tier| tests.join(tier));
151    for tier in &tiers {
152        if tier.is_dir() {
153            violations.extend(find_integration_violations(tier)?);
154        }
155    }
156    if tests.is_dir() {
157        let mut strays = Vec::new();
158        collect_ts_test_files(&tests, &mut strays)?;
159        strays.retain(|file| !tiers.iter().any(|tier| file.starts_with(tier)));
160        for file in strays {
161            violations.push(Violation {
162                file,
163                line: 1,
164                rule: "unknown-tier",
165                message: UNKNOWN_TIER_MSG.to_string(),
166            });
167        }
168    }
169    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
170    Ok(violations)
171}
172
173/// Scan the unit test files under `root` and return every isolation violation —
174/// a runtime import that isn't `vi.mock()`-ed — sorted by `(file, line)`.
175/// The TypeScript arm of `unit lint`
176/// ([`crate::isolation::Language::TypeScript`]).
177///
178/// A *TypeScript unit test* is `*.test.{ts,tsx,mts,cts}`. Each is parsed and
179/// walked; a file that cannot be read or parsed is an error.
180pub fn find_unit_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
181    let root = root.as_ref();
182    let mut files = Vec::new();
183    collect_ts_test_files(root, &mut files)?;
184    // `<package root>/tests/` belongs to the suite tiers (integration / e2e run
185    // first-party code for real), so its files are never unit subjects.
186    if let Some(tests) = crate::tiers::suite_tests_dir(root, "package.json") {
187        files.retain(|file| !file.starts_with(&tests));
188    }
189    files.sort();
190
191    let mut violations = Vec::new();
192    for file in &files {
193        let source = std::fs::read_to_string(file)
194            .with_context(|| format!("reading test file `{}`", file.display()))?;
195        violations.extend(unit_violations_in(file, &source)?);
196    }
197
198    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
199    Ok(violations)
200}
201
202/// Parse one unit test file and collect its `unmocked-collaborator` violations:
203/// every runtime import that isn't the unit under test, the test runner, or
204/// `vi.mock()`-ed.
205fn unit_violations_in(file: &Path, source: &str) -> Result<Vec<Violation>> {
206    let allocator = Allocator::default();
207    let source_type = SourceType::from_path(file).map_err(|err| {
208        anyhow!(
209            "unsupported TypeScript extension `{}`: {err}",
210            file.display()
211        )
212    })?;
213    let ret = Parser::new(&allocator, source, source_type).parse();
214    if ret.panicked || !ret.diagnostics.is_empty() {
215        let detail = ret
216            .diagnostics
217            .iter()
218            .map(|d| d.to_string())
219            .collect::<Vec<_>>()
220            .join("; ");
221        bail!("parsing `{}` failed: {detail}", file.display());
222    }
223
224    let mut collector = UnitCollector {
225        source,
226        imports: Vec::new(),
227        mocked: BTreeSet::new(),
228        untyped: Vec::new(),
229    };
230    collector.visit_program(&ret.program);
231
232    let unit = unit_under_test_specifier(file);
233    // Vitest resolves `./formatter` and `./formatter.js` to the same module, so a mock
234    // matches its import by resolved module — the extension may differ between the two.
235    // Normalize the module extension on both sides of the comparison.
236    let mocked_modules: BTreeSet<&str> = collector
237        .mocked
238        .iter()
239        .map(|m| strip_module_ext(m))
240        .collect();
241    let mut violations = Vec::new();
242    for (spec, line) in &collector.imports {
243        if is_unit_under_test(spec, &unit)
244            || is_test_runner(spec)
245            || mocked_modules.contains(strip_module_ext(spec))
246        {
247            continue;
248        }
249        violations.push(Violation {
250            file: file.to_path_buf(),
251            line: *line,
252            rule: "unmocked-collaborator",
253            message: format!(
254                "unit test imports `{spec}` without mocking it — a unit test isolates the \
255                 unit under test, so every collaborator must be `vi.mock()`-ed"
256            ),
257        });
258    }
259    for (spec, line) in &collector.untyped {
260        violations.push(Violation {
261            file: file.to_path_buf(),
262            line: *line,
263            rule: "untyped-mock",
264            message: format!(
265                "`vi.mock('{spec}', …)` has an untyped factory — anchor it to the real module \
266                 with `vi.importActual<typeof import('{spec}')>()` so the double can't drift \
267                 from the source"
268            ),
269        });
270    }
271    violations.sort_by_key(|v| v.line);
272    Ok(violations)
273}
274
275/// Collects a unit test's runtime imports (specifier + line), its `vi.mock()`
276/// targets, and any `vi.mock()` with an untyped factory in one AST pass.
277struct UnitCollector<'s> {
278    source: &'s str,
279    imports: Vec<(String, usize)>,
280    mocked: BTreeSet<String>,
281    untyped: Vec<(String, usize)>,
282}
283
284impl<'a> Visit<'a> for UnitCollector<'_> {
285    fn visit_import_declaration(&mut self, decl: &ImportDeclaration<'a>) {
286        // `import type …` is erased at compile time — not a runtime dependency.
287        if matches!(decl.import_kind, ImportOrExportKind::Type) {
288            return;
289        }
290        self.imports.push((
291            decl.source.value.to_string(),
292            line_of(self.source, decl.span.start),
293        ));
294    }
295
296    fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
297        if let Some(spec) = vi_mock_target(call) {
298            // A factory *function* (2nd arg) that doesn't anchor to the real
299            // module's type via `vi.importActual<…>()` lets the double drift from
300            // the source. The 2nd arg is only a factory when it's a function:
301            // a bare `vi.mock(spec)` is an auto-mock (typed from the real module),
302            // and so is the options form `vi.mock(spec, { spy: true })`, which spies
303            // on the real module and can't drift — neither is flagged.
304            if let Some(factory) = call.arguments.get(1) {
305                if is_factory(factory) && !factory_is_typed(factory) {
306                    self.untyped
307                        .push((spec.clone(), line_of(self.source, call.span.start)));
308                }
309            }
310            self.mocked.insert(spec);
311        }
312        walk::walk_call_expression(self, call);
313    }
314}
315
316/// The unit-under-test specifier for a test file: `pkg/widget.test.ts` → `./widget`.
317fn unit_under_test_specifier(file: &Path) -> String {
318    let name = file
319        .file_name()
320        .and_then(|n| n.to_str())
321        .unwrap_or_default();
322    let stem = name.split(".test.").next().unwrap_or(name);
323    format!("./{stem}")
324}
325
326/// `true` when `spec` resolves to the unit under test, ignoring an explicit
327/// module extension (`./widget` and `./widget.js` both match `./widget`).
328fn is_unit_under_test(spec: &str, unit: &str) -> bool {
329    strip_module_ext(spec) == unit
330}
331
332/// `spec` without a trailing JS/TS module extension.
333fn strip_module_ext(spec: &str) -> &str {
334    for ext in [".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"] {
335        if let Some(base) = spec.strip_suffix(ext) {
336            return base;
337        }
338    }
339    spec
340}
341
342/// `true` for the Vitest test runner itself (`vitest`, `vitest/*`, `@vitest/*`) —
343/// the harness, never a collaborator to mock.
344fn is_test_runner(spec: &str) -> bool {
345    spec == "vitest" || spec.starts_with("vitest/") || spec.starts_with("@vitest/")
346}
347
348/// `true` when a `vi.mock` second argument is a factory *function* — an arrow or
349/// `function` expression. Vitest's other 2nd-arg form is an options object
350/// (`vi.mock(spec, { spy: true })`), which is **not** a factory: it spies on the
351/// real module, so the double can't drift, exactly like a bare `vi.mock(spec)`
352/// auto-mock. Only a function factory can return a hand-built double that
353/// needs a `vi.importActual<…>` type anchor.
354fn is_factory(arg: &Argument) -> bool {
355    matches!(
356        arg.as_expression(),
357        Some(Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_))
358    )
359}
360
361/// `true` when a `vi.mock` factory anchors to the real module's type — i.e. its
362/// body contains a `vi.importActual<…>()` call carrying a type argument.
363/// The conventional form is `vi.importActual<typeof import('<spec>')>()`.
364fn factory_is_typed(factory: &Argument) -> bool {
365    let mut finder = ImportActualFinder { typed: false };
366    finder.visit_argument(factory);
367    finder.typed
368}
369
370/// Walks a `vi.mock` factory looking for a typed `vi.importActual<…>()` call.
371struct ImportActualFinder {
372    typed: bool,
373}
374
375impl<'a> Visit<'a> for ImportActualFinder {
376    fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
377        if is_typed_import_actual(call) {
378            self.typed = true;
379        }
380        walk::walk_call_expression(self, call);
381    }
382}
383
384/// `true` for `vi.importActual<…>(…)` — a call to `vi.importActual` that carries a
385/// type argument (an untyped `vi.importActual(…)` returns `unknown`).
386fn is_typed_import_actual(call: &CallExpression) -> bool {
387    let Expression::StaticMemberExpression(member) = &call.callee else {
388        return false;
389    };
390    let is_vi = matches!(&member.object, Expression::Identifier(id) if id.name == "vi");
391    is_vi && member.property.name.as_str() == "importActual" && call.type_arguments.is_some()
392}
393
394/// Parse one TypeScript test file and collect its `no-first-party-mock`
395/// violations. A parse failure is an error — a malformed test file is never a
396/// silent pass.
397fn integration_violations_in(file: &Path, source: &str) -> Result<Vec<Violation>> {
398    let allocator = Allocator::default();
399    let source_type = SourceType::from_path(file).map_err(|err| {
400        anyhow!(
401            "unsupported TypeScript extension `{}`: {err}",
402            file.display()
403        )
404    })?;
405    let ret = Parser::new(&allocator, source, source_type).parse();
406    if ret.panicked || !ret.diagnostics.is_empty() {
407        let detail = ret
408            .diagnostics
409            .iter()
410            .map(|d| d.to_string())
411            .collect::<Vec<_>>()
412            .join("; ");
413        bail!("parsing `{}` failed: {detail}", file.display());
414    }
415
416    let mut visitor = MockVisitor {
417        file,
418        source,
419        violations: Vec::new(),
420    };
421    visitor.visit_program(&ret.program);
422    Ok(visitor.violations)
423}
424
425/// Walks one parsed test file, flagging every `vi.mock()` / `vi.doMock()` of a
426/// first-party module.
427struct MockVisitor<'s> {
428    file: &'s Path,
429    source: &'s str,
430    violations: Vec<Violation>,
431}
432
433impl MockVisitor<'_> {
434    fn report(&mut self, span: Span, spec: &str) {
435        self.violations.push(Violation {
436            file: self.file.to_path_buf(),
437            line: line_of(self.source, span.start),
438            rule: "no-first-party-mock",
439            message: format!(
440                "integration test mocks first-party module `{spec}` — an integration test \
441                 runs first-party code for real; only third-party packages and Node built-ins \
442                 may be mocked"
443            ),
444        });
445    }
446}
447
448impl<'a> Visit<'a> for MockVisitor<'_> {
449    fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
450        if let Some(spec) = vi_mock_target(call) {
451            if classify(&spec) == Origin::FirstParty {
452                self.report(call.span, &spec);
453            }
454        }
455        walk::walk_call_expression(self, call);
456    }
457}
458
459/// If `call` is `vi.mock("spec", …)` or `vi.doMock("spec", …)` with a string
460/// literal first argument, return that specifier; otherwise `None`.
461///
462/// A non-literal target (`vi.mock(name)`) can't be classified deterministically,
463/// so it is skipped rather than guessed at.
464fn vi_mock_target(call: &CallExpression) -> Option<String> {
465    let Expression::StaticMemberExpression(member) = &call.callee else {
466        return None;
467    };
468    let is_vi = matches!(&member.object, Expression::Identifier(id) if id.name == "vi");
469    if !is_vi {
470        return None;
471    }
472    let method = member.property.name.as_str();
473    if method != "mock" && method != "doMock" {
474        return None;
475    }
476    match call.arguments.first() {
477        Some(Argument::StringLiteral(lit)) => Some(lit.value.to_string()),
478        _ => None,
479    }
480}
481
482/// The 1-based line containing byte `offset` in `source`.
483fn line_of(source: &str, offset: u32) -> usize {
484    let offset = (offset as usize).min(source.len());
485    source.as_bytes()[..offset]
486        .iter()
487        .filter(|&&byte| byte == b'\n')
488        .count()
489        + 1
490}
491
492/// Recursively collect every TypeScript test file under `dir` into `out`.
493fn collect_ts_test_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
494    let entries =
495        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
496    for entry in entries {
497        let path = entry
498            .with_context(|| format!("reading an entry under `{}`", dir.display()))?
499            .path();
500        if path.is_dir() {
501            collect_ts_test_files(&path, out)?;
502        } else if is_ts_test_file(&path) {
503            out.push(path);
504        }
505    }
506    Ok(())
507}
508
509/// `true` for a TypeScript test file: `*.test.{ts,tsx,mts,cts}`.
510fn is_ts_test_file(path: &Path) -> bool {
511    let name = path
512        .file_name()
513        .and_then(|n| n.to_str())
514        .unwrap_or_default();
515    name.ends_with(".test.ts")
516        || name.ends_with(".test.tsx")
517        || name.ends_with(".test.mts")
518        || name.ends_with(".test.cts")
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    /// Parse `source` as `name` and return its integration violations.
526    fn violations(name: &str, source: &str) -> Vec<Violation> {
527        integration_violations_in(Path::new(name), source).expect("source should parse")
528    }
529
530    /// Parse `source` as `name` and return its unit-isolation violations.
531    fn unit_violations(name: &str, source: &str) -> Vec<Violation> {
532        unit_violations_in(Path::new(name), source).expect("source should parse")
533    }
534
535    #[test]
536    fn unit_flags_unmocked_first_party_and_external() {
537        let found = unit_violations(
538            "widget.test.ts",
539            "import { makeWidget } from './widget';\n\
540             import { format } from './formatter';\n\
541             import { chunk } from 'lodash';\n",
542        );
543        // The unit under test (`./widget`) is not a collaborator; the other two are
544        // imported but not mocked.
545        assert_eq!(found.len(), 2, "got: {found:?}");
546        assert!(found.iter().all(|v| v.rule == "unmocked-collaborator"));
547        assert!(found.iter().any(|v| v.message.contains("./formatter")));
548        assert!(found.iter().any(|v| v.message.contains("lodash")));
549    }
550
551    #[test]
552    fn unit_mocked_collaborator_is_clean() {
553        let found = unit_violations(
554            "widget.test.ts",
555            "import { format } from './formatter';\nvi.mock('./formatter');\n",
556        );
557        assert!(found.is_empty(), "got: {found:?}");
558    }
559
560    #[test]
561    fn unit_under_test_and_runner_are_not_flagged() {
562        let found = unit_violations(
563            "widget.test.ts",
564            "import { vi } from 'vitest';\n\
565             import { makeWidget } from './widget.js';\n",
566        );
567        // `vitest` is the runner; `./widget.js` is the unit under test (extension ignored).
568        assert!(found.is_empty(), "got: {found:?}");
569    }
570
571    #[test]
572    fn unit_type_only_import_is_not_flagged() {
573        let found = unit_violations(
574            "widget.test.ts",
575            "import type { Opts } from './opts';\nimport { x } from './x';\nvi.mock('./x');\n",
576        );
577        assert!(found.is_empty(), "got: {found:?}");
578    }
579
580    #[test]
581    fn unit_under_test_specifier_strips_test_suffix() {
582        assert_eq!(
583            unit_under_test_specifier(Path::new("pkg/widget.test.ts")),
584            "./widget"
585        );
586        assert_eq!(
587            unit_under_test_specifier(Path::new("button.test.tsx")),
588            "./button"
589        );
590    }
591
592    #[test]
593    fn strip_module_ext_drops_known_extensions_only() {
594        assert_eq!(strip_module_ext("./widget.js"), "./widget");
595        assert_eq!(strip_module_ext("./widget.mts"), "./widget");
596        assert_eq!(strip_module_ext("./widget"), "./widget");
597        assert_eq!(strip_module_ext("lodash"), "lodash");
598    }
599
600    #[test]
601    fn recognizes_the_test_runner() {
602        assert!(is_test_runner("vitest"));
603        assert!(is_test_runner("vitest/config"));
604        assert!(is_test_runner("@vitest/spy"));
605        assert!(!is_test_runner("./vitest-helpers"));
606        assert!(!is_test_runner("lodash"));
607    }
608
609    #[test]
610    fn unit_flags_untyped_factory_mock() {
611        let found = unit_violations(
612            "widget.test.ts",
613            "import { x } from './x';\nvi.mock('./x', () => ({ x: vi.fn() }));\n",
614        );
615        // Mocked, so not an `unmocked-collaborator`; but the factory has no
616        // `vi.importActual<…>` anchor.
617        assert_eq!(found.len(), 1, "got: {found:?}");
618        assert_eq!(found[0].rule, "untyped-mock");
619        assert!(found[0].message.contains("./x"));
620    }
621
622    #[test]
623    fn unit_typed_factory_mock_is_clean() {
624        let found = unit_violations(
625            "widget.test.ts",
626            "import { x } from './x';\n\
627             vi.mock('./x', async () => {\n\
628             \x20 const actual = await vi.importActual<typeof import('./x')>('./x');\n\
629             \x20 return { ...actual, x: vi.fn() };\n\
630             });\n",
631        );
632        assert!(found.is_empty(), "got: {found:?}");
633    }
634
635    #[test]
636    fn unit_options_object_mock_is_not_a_factory() {
637        // Vitest's options form `vi.mock(spec, { spy: true })` is not a factory —
638        // it spies on the real module (can't drift), like a bare auto-mock — so it
639        // must not be flagged `untyped-mock`.
640        let found = unit_violations(
641            "widget.test.ts",
642            "import { x } from './x';\nvi.mock('./x', { spy: true });\n",
643        );
644        assert!(found.is_empty(), "got: {found:?}");
645    }
646
647    #[test]
648    fn unit_untyped_import_actual_is_still_untyped() {
649        // `vi.importActual` without a type argument returns `unknown` — not a type anchor.
650        let found = unit_violations(
651            "widget.test.ts",
652            "import { x } from './x';\n\
653             vi.mock('./x', async () => {\n\
654             \x20 const actual = await vi.importActual('./x');\n\
655             \x20 return { ...(actual as object), x: vi.fn() };\n\
656             });\n",
657        );
658        assert_eq!(found.len(), 1, "got: {found:?}");
659        assert_eq!(found[0].rule, "untyped-mock");
660    }
661
662    #[test]
663    fn classify_relative_is_first_party() {
664        assert_eq!(classify("./service"), Origin::FirstParty);
665        assert_eq!(classify("../pkg/util"), Origin::FirstParty);
666        assert_eq!(classify("/abs/path"), Origin::FirstParty);
667    }
668
669    #[test]
670    fn classify_node_builtins() {
671        assert_eq!(classify("fs"), Origin::Builtin);
672        assert_eq!(classify("node:fs"), Origin::Builtin);
673        assert_eq!(classify("fs/promises"), Origin::Builtin);
674        assert_eq!(classify("node:test"), Origin::Builtin);
675        assert_eq!(classify("child_process"), Origin::Builtin);
676        assert_eq!(classify("node:some-future-builtin"), Origin::Builtin);
677    }
678
679    #[test]
680    fn classify_third_party() {
681        assert_eq!(classify("lodash"), Origin::ThirdParty);
682        assert_eq!(classify("@scope/pkg"), Origin::ThirdParty);
683        assert_eq!(classify("stripe/lib/client"), Origin::ThirdParty);
684        // A bare `test` is too ambiguous to assume the built-in; only `node:test`
685        // is treated as a built-in.
686        assert_eq!(classify("test"), Origin::ThirdParty);
687    }
688
689    #[test]
690    fn recognizes_ts_test_files() {
691        assert!(is_ts_test_file(Path::new("widget.test.ts")));
692        assert!(is_ts_test_file(Path::new("pkg/button.test.tsx")));
693        assert!(is_ts_test_file(Path::new("service.test.mts")));
694        assert!(is_ts_test_file(Path::new("legacy.test.cts")));
695        assert!(!is_ts_test_file(Path::new("widget.ts")));
696        assert!(!is_ts_test_file(Path::new("types.d.ts")));
697        assert!(!is_ts_test_file(Path::new("README.md")));
698    }
699
700    #[test]
701    fn line_of_counts_newlines() {
702        let src = "a\nb\nc\n";
703        assert_eq!(line_of(src, 0), 1);
704        assert_eq!(line_of(src, 2), 2);
705        assert_eq!(line_of(src, 4), 3);
706    }
707
708    #[test]
709    fn flags_mock_of_relative_module() {
710        let found = violations("a.test.ts", "vi.mock('./service');\n");
711        assert_eq!(found.len(), 1);
712        assert_eq!(found[0].rule, "no-first-party-mock");
713        assert_eq!(found[0].line, 1);
714    }
715
716    #[test]
717    fn flags_mock_with_factory_and_parent_path() {
718        let found = violations(
719            "a.test.ts",
720            "import { x } from './x';\nvi.mock('../src/ledger', () => ({ record: vi.fn() }));\n",
721        );
722        assert_eq!(found.len(), 1);
723        assert!(found[0].message.contains("../src/ledger"));
724    }
725
726    #[test]
727    fn flags_domock_of_relative_module() {
728        let found = violations("a.test.mts", "vi.doMock('./mailer');\n");
729        assert_eq!(found.len(), 1);
730    }
731
732    #[test]
733    fn allows_mock_of_third_party_and_builtins() {
734        let found = violations(
735            "a.test.ts",
736            "vi.mock('stripe');\nvi.mock('node:fs');\nvi.mock('fs/promises');\nvi.mock('@scope/pkg');\n",
737        );
738        assert!(found.is_empty(), "got: {found:?}");
739    }
740
741    #[test]
742    fn ignores_non_vi_and_non_mock_calls() {
743        // `describe(...)` (plain call), `vi.fn()` (vi, not mock), and a method
744        // call whose receiver isn't `vi` must all be left alone.
745        let found = violations(
746            "a.test.ts",
747            "describe('s', () => {});\nvi.fn();\nexpect(1).toBe(1);\nother.mock('./x');\n",
748        );
749        assert!(found.is_empty(), "got: {found:?}");
750    }
751
752    #[test]
753    fn ignores_dynamic_mock_target() {
754        // A non-literal specifier can't be classified deterministically.
755        let found = violations("a.test.ts", "const m = './x';\nvi.mock(m);\n");
756        assert!(found.is_empty(), "got: {found:?}");
757    }
758
759    #[test]
760    fn finds_mocks_nested_in_blocks() {
761        // `vi.mock` is normally hoisted to the top level, but a nested call is
762        // still reached by the walk.
763        let found = violations(
764            "a.test.ts",
765            "describe('s', () => {\n  vi.mock('./inner');\n});\n",
766        );
767        assert_eq!(found.len(), 1);
768        assert_eq!(found[0].line, 2);
769    }
770
771    #[test]
772    fn parse_error_is_reported() {
773        let err = integration_violations_in(Path::new("bad.test.ts"), "const x = ;\n").unwrap_err();
774        assert!(err.to_string().contains("parsing"), "got: {err}");
775    }
776
777    #[test]
778    fn unsupported_extension_is_reported() {
779        let err = integration_violations_in(Path::new("weird.test.bogus"), "vi.mock('./x');\n")
780            .unwrap_err();
781        assert!(err.to_string().contains("unsupported"), "got: {err}");
782    }
783}