Skip to main content

testing_conventions/
ts.rs

1//! TypeScript isolation analysis (issue #43), parsed with `oxc`.
2//!
3//! This is the TypeScript counterpart to the Python [`crate::lint`] module. The
4//! *integration direction* (#75) lands first: an integration test runs
5//! first-party code for real, so it may mock third-party packages and Node
6//! built-ins but **never** a 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 (#76, #77) 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/// Scan the unit test files under `root` and return every isolation violation —
134/// a runtime import that isn't `vi.mock()`-ed (#76) — sorted by `(file, line)`.
135/// The TypeScript arm of `unit isolation`
136/// ([`crate::isolation::Language::TypeScript`]).
137///
138/// A *TypeScript unit test* is `*.test.{ts,tsx,mts,cts}`. Each is parsed and
139/// walked; a file that cannot be read or parsed is an error.
140pub fn find_unit_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
141    let root = root.as_ref();
142    let mut files = Vec::new();
143    collect_ts_test_files(root, &mut files)?;
144    files.sort();
145
146    let mut violations = Vec::new();
147    for file in &files {
148        let source = std::fs::read_to_string(file)
149            .with_context(|| format!("reading test file `{}`", file.display()))?;
150        violations.extend(unit_violations_in(file, &source)?);
151    }
152
153    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
154    Ok(violations)
155}
156
157/// Parse one unit test file and collect its `unmocked-collaborator` violations:
158/// every runtime import that isn't the unit under test, the test runner, or
159/// `vi.mock()`-ed.
160fn unit_violations_in(file: &Path, source: &str) -> Result<Vec<Violation>> {
161    let allocator = Allocator::default();
162    let source_type = SourceType::from_path(file).map_err(|err| {
163        anyhow!(
164            "unsupported TypeScript extension `{}`: {err}",
165            file.display()
166        )
167    })?;
168    let ret = Parser::new(&allocator, source, source_type).parse();
169    if ret.panicked || !ret.diagnostics.is_empty() {
170        let detail = ret
171            .diagnostics
172            .iter()
173            .map(|d| d.to_string())
174            .collect::<Vec<_>>()
175            .join("; ");
176        bail!("parsing `{}` failed: {detail}", file.display());
177    }
178
179    let mut collector = UnitCollector {
180        source,
181        imports: Vec::new(),
182        mocked: BTreeSet::new(),
183        untyped: Vec::new(),
184    };
185    collector.visit_program(&ret.program);
186
187    let unit = unit_under_test_specifier(file);
188    let mut violations = Vec::new();
189    for (spec, line) in &collector.imports {
190        if is_unit_under_test(spec, &unit)
191            || is_test_runner(spec)
192            || collector.mocked.contains(spec)
193        {
194            continue;
195        }
196        violations.push(Violation {
197            file: file.to_path_buf(),
198            line: *line,
199            rule: "unmocked-collaborator",
200            message: format!(
201                "unit test imports `{spec}` without mocking it — a unit test isolates the \
202                 unit under test, so every collaborator must be `vi.mock()`-ed"
203            ),
204        });
205    }
206    for (spec, line) in &collector.untyped {
207        violations.push(Violation {
208            file: file.to_path_buf(),
209            line: *line,
210            rule: "untyped-mock",
211            message: format!(
212                "`vi.mock('{spec}', …)` has an untyped factory — anchor it to the real module \
213                 with `vi.importActual<typeof import('{spec}')>()` so the double can't drift \
214                 from the source"
215            ),
216        });
217    }
218    violations.sort_by_key(|v| v.line);
219    Ok(violations)
220}
221
222/// Collects a unit test's runtime imports (specifier + line), its `vi.mock()`
223/// targets, and any `vi.mock()` with an untyped factory in one AST pass.
224struct UnitCollector<'s> {
225    source: &'s str,
226    imports: Vec<(String, usize)>,
227    mocked: BTreeSet<String>,
228    untyped: Vec<(String, usize)>,
229}
230
231impl<'a> Visit<'a> for UnitCollector<'_> {
232    fn visit_import_declaration(&mut self, decl: &ImportDeclaration<'a>) {
233        // `import type …` is erased at compile time — not a runtime dependency.
234        if matches!(decl.import_kind, ImportOrExportKind::Type) {
235            return;
236        }
237        self.imports.push((
238            decl.source.value.to_string(),
239            line_of(self.source, decl.span.start),
240        ));
241    }
242
243    fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
244        if let Some(spec) = vi_mock_target(call) {
245            // A factory (2nd arg) that doesn't anchor to the real module's type via
246            // `vi.importActual<…>()` lets the double drift from the source (#77). A
247            // bare `vi.mock(spec)` is an auto-mock — typed from the real module.
248            if let Some(factory) = call.arguments.get(1) {
249                if !factory_is_typed(factory) {
250                    self.untyped
251                        .push((spec.clone(), line_of(self.source, call.span.start)));
252                }
253            }
254            self.mocked.insert(spec);
255        }
256        walk::walk_call_expression(self, call);
257    }
258}
259
260/// The unit-under-test specifier for a test file: `pkg/widget.test.ts` → `./widget`.
261fn unit_under_test_specifier(file: &Path) -> String {
262    let name = file
263        .file_name()
264        .and_then(|n| n.to_str())
265        .unwrap_or_default();
266    let stem = name.split(".test.").next().unwrap_or(name);
267    format!("./{stem}")
268}
269
270/// `true` when `spec` resolves to the unit under test, ignoring an explicit
271/// module extension (`./widget` and `./widget.js` both match `./widget`).
272fn is_unit_under_test(spec: &str, unit: &str) -> bool {
273    strip_module_ext(spec) == unit
274}
275
276/// `spec` without a trailing JS/TS module extension.
277fn strip_module_ext(spec: &str) -> &str {
278    for ext in [".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"] {
279        if let Some(base) = spec.strip_suffix(ext) {
280            return base;
281        }
282    }
283    spec
284}
285
286/// `true` for the Vitest test runner itself (`vitest`, `vitest/*`, `@vitest/*`) —
287/// the harness, never a collaborator to mock.
288fn is_test_runner(spec: &str) -> bool {
289    spec == "vitest" || spec.starts_with("vitest/") || spec.starts_with("@vitest/")
290}
291
292/// `true` when a `vi.mock` factory anchors to the real module's type — i.e. its
293/// body contains a `vi.importActual<…>()` call carrying a type argument (#77).
294/// The conventional form is `vi.importActual<typeof import('<spec>')>()`.
295fn factory_is_typed(factory: &Argument) -> bool {
296    let mut finder = ImportActualFinder { typed: false };
297    finder.visit_argument(factory);
298    finder.typed
299}
300
301/// Walks a `vi.mock` factory looking for a typed `vi.importActual<…>()` call.
302struct ImportActualFinder {
303    typed: bool,
304}
305
306impl<'a> Visit<'a> for ImportActualFinder {
307    fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
308        if is_typed_import_actual(call) {
309            self.typed = true;
310        }
311        walk::walk_call_expression(self, call);
312    }
313}
314
315/// `true` for `vi.importActual<…>(…)` — a call to `vi.importActual` that carries a
316/// type argument (an untyped `vi.importActual(…)` returns `unknown`).
317fn is_typed_import_actual(call: &CallExpression) -> bool {
318    let Expression::StaticMemberExpression(member) = &call.callee else {
319        return false;
320    };
321    let is_vi = matches!(&member.object, Expression::Identifier(id) if id.name == "vi");
322    is_vi && member.property.name.as_str() == "importActual" && call.type_arguments.is_some()
323}
324
325/// Parse one TypeScript test file and collect its `no-first-party-mock`
326/// violations. A parse failure is an error — a malformed test file is never a
327/// silent pass.
328fn integration_violations_in(file: &Path, source: &str) -> Result<Vec<Violation>> {
329    let allocator = Allocator::default();
330    let source_type = SourceType::from_path(file).map_err(|err| {
331        anyhow!(
332            "unsupported TypeScript extension `{}`: {err}",
333            file.display()
334        )
335    })?;
336    let ret = Parser::new(&allocator, source, source_type).parse();
337    if ret.panicked || !ret.diagnostics.is_empty() {
338        let detail = ret
339            .diagnostics
340            .iter()
341            .map(|d| d.to_string())
342            .collect::<Vec<_>>()
343            .join("; ");
344        bail!("parsing `{}` failed: {detail}", file.display());
345    }
346
347    let mut visitor = MockVisitor {
348        file,
349        source,
350        violations: Vec::new(),
351    };
352    visitor.visit_program(&ret.program);
353    Ok(visitor.violations)
354}
355
356/// Walks one parsed test file, flagging every `vi.mock()` / `vi.doMock()` of a
357/// first-party module.
358struct MockVisitor<'s> {
359    file: &'s Path,
360    source: &'s str,
361    violations: Vec<Violation>,
362}
363
364impl MockVisitor<'_> {
365    fn report(&mut self, span: Span, spec: &str) {
366        self.violations.push(Violation {
367            file: self.file.to_path_buf(),
368            line: line_of(self.source, span.start),
369            rule: "no-first-party-mock",
370            message: format!(
371                "integration test mocks first-party module `{spec}` — an integration test \
372                 runs first-party code for real; only third-party packages and Node built-ins \
373                 may be mocked"
374            ),
375        });
376    }
377}
378
379impl<'a> Visit<'a> for MockVisitor<'_> {
380    fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
381        if let Some(spec) = vi_mock_target(call) {
382            if classify(&spec) == Origin::FirstParty {
383                self.report(call.span, &spec);
384            }
385        }
386        walk::walk_call_expression(self, call);
387    }
388}
389
390/// If `call` is `vi.mock("spec", …)` or `vi.doMock("spec", …)` with a string
391/// literal first argument, return that specifier; otherwise `None`.
392///
393/// A non-literal target (`vi.mock(name)`) can't be classified deterministically,
394/// so it is skipped rather than guessed at.
395fn vi_mock_target(call: &CallExpression) -> Option<String> {
396    let Expression::StaticMemberExpression(member) = &call.callee else {
397        return None;
398    };
399    let is_vi = matches!(&member.object, Expression::Identifier(id) if id.name == "vi");
400    if !is_vi {
401        return None;
402    }
403    let method = member.property.name.as_str();
404    if method != "mock" && method != "doMock" {
405        return None;
406    }
407    match call.arguments.first() {
408        Some(Argument::StringLiteral(lit)) => Some(lit.value.to_string()),
409        _ => None,
410    }
411}
412
413/// The 1-based line containing byte `offset` in `source`.
414fn line_of(source: &str, offset: u32) -> usize {
415    let offset = (offset as usize).min(source.len());
416    source.as_bytes()[..offset]
417        .iter()
418        .filter(|&&byte| byte == b'\n')
419        .count()
420        + 1
421}
422
423/// Recursively collect every TypeScript test file under `dir` into `out`.
424fn collect_ts_test_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
425    let entries =
426        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
427    for entry in entries {
428        let path = entry
429            .with_context(|| format!("reading an entry under `{}`", dir.display()))?
430            .path();
431        if path.is_dir() {
432            collect_ts_test_files(&path, out)?;
433        } else if is_ts_test_file(&path) {
434            out.push(path);
435        }
436    }
437    Ok(())
438}
439
440/// `true` for a TypeScript test file: `*.test.{ts,tsx,mts,cts}`.
441fn is_ts_test_file(path: &Path) -> bool {
442    let name = path
443        .file_name()
444        .and_then(|n| n.to_str())
445        .unwrap_or_default();
446    name.ends_with(".test.ts")
447        || name.ends_with(".test.tsx")
448        || name.ends_with(".test.mts")
449        || name.ends_with(".test.cts")
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    /// Parse `source` as `name` and return its integration violations.
457    fn violations(name: &str, source: &str) -> Vec<Violation> {
458        integration_violations_in(Path::new(name), source).expect("source should parse")
459    }
460
461    /// Parse `source` as `name` and return its unit-isolation violations.
462    fn unit_violations(name: &str, source: &str) -> Vec<Violation> {
463        unit_violations_in(Path::new(name), source).expect("source should parse")
464    }
465
466    #[test]
467    fn unit_flags_unmocked_first_party_and_external() {
468        let found = unit_violations(
469            "widget.test.ts",
470            "import { makeWidget } from './widget';\n\
471             import { format } from './formatter';\n\
472             import { chunk } from 'lodash';\n",
473        );
474        // The unit under test (`./widget`) is not a collaborator; the other two are
475        // imported but not mocked.
476        assert_eq!(found.len(), 2, "got: {found:?}");
477        assert!(found.iter().all(|v| v.rule == "unmocked-collaborator"));
478        assert!(found.iter().any(|v| v.message.contains("./formatter")));
479        assert!(found.iter().any(|v| v.message.contains("lodash")));
480    }
481
482    #[test]
483    fn unit_mocked_collaborator_is_clean() {
484        let found = unit_violations(
485            "widget.test.ts",
486            "import { format } from './formatter';\nvi.mock('./formatter');\n",
487        );
488        assert!(found.is_empty(), "got: {found:?}");
489    }
490
491    #[test]
492    fn unit_under_test_and_runner_are_not_flagged() {
493        let found = unit_violations(
494            "widget.test.ts",
495            "import { vi } from 'vitest';\n\
496             import { makeWidget } from './widget.js';\n",
497        );
498        // `vitest` is the runner; `./widget.js` is the unit under test (extension ignored).
499        assert!(found.is_empty(), "got: {found:?}");
500    }
501
502    #[test]
503    fn unit_type_only_import_is_not_flagged() {
504        let found = unit_violations(
505            "widget.test.ts",
506            "import type { Opts } from './opts';\nimport { x } from './x';\nvi.mock('./x');\n",
507        );
508        assert!(found.is_empty(), "got: {found:?}");
509    }
510
511    #[test]
512    fn unit_under_test_specifier_strips_test_suffix() {
513        assert_eq!(
514            unit_under_test_specifier(Path::new("pkg/widget.test.ts")),
515            "./widget"
516        );
517        assert_eq!(
518            unit_under_test_specifier(Path::new("button.test.tsx")),
519            "./button"
520        );
521    }
522
523    #[test]
524    fn strip_module_ext_drops_known_extensions_only() {
525        assert_eq!(strip_module_ext("./widget.js"), "./widget");
526        assert_eq!(strip_module_ext("./widget.mts"), "./widget");
527        assert_eq!(strip_module_ext("./widget"), "./widget");
528        assert_eq!(strip_module_ext("lodash"), "lodash");
529    }
530
531    #[test]
532    fn recognizes_the_test_runner() {
533        assert!(is_test_runner("vitest"));
534        assert!(is_test_runner("vitest/config"));
535        assert!(is_test_runner("@vitest/spy"));
536        assert!(!is_test_runner("./vitest-helpers"));
537        assert!(!is_test_runner("lodash"));
538    }
539
540    #[test]
541    fn unit_flags_untyped_factory_mock() {
542        let found = unit_violations(
543            "widget.test.ts",
544            "import { x } from './x';\nvi.mock('./x', () => ({ x: vi.fn() }));\n",
545        );
546        // Mocked, so not an `unmocked-collaborator`; but the factory has no
547        // `vi.importActual<…>` anchor.
548        assert_eq!(found.len(), 1, "got: {found:?}");
549        assert_eq!(found[0].rule, "untyped-mock");
550        assert!(found[0].message.contains("./x"));
551    }
552
553    #[test]
554    fn unit_typed_factory_mock_is_clean() {
555        let found = unit_violations(
556            "widget.test.ts",
557            "import { x } from './x';\n\
558             vi.mock('./x', async () => {\n\
559             \x20 const actual = await vi.importActual<typeof import('./x')>('./x');\n\
560             \x20 return { ...actual, x: vi.fn() };\n\
561             });\n",
562        );
563        assert!(found.is_empty(), "got: {found:?}");
564    }
565
566    #[test]
567    fn unit_untyped_import_actual_is_still_untyped() {
568        // `vi.importActual` without a type argument returns `unknown` — not a type anchor.
569        let found = unit_violations(
570            "widget.test.ts",
571            "import { x } from './x';\n\
572             vi.mock('./x', async () => {\n\
573             \x20 const actual = await vi.importActual('./x');\n\
574             \x20 return { ...(actual as object), x: vi.fn() };\n\
575             });\n",
576        );
577        assert_eq!(found.len(), 1, "got: {found:?}");
578        assert_eq!(found[0].rule, "untyped-mock");
579    }
580
581    #[test]
582    fn classify_relative_is_first_party() {
583        assert_eq!(classify("./service"), Origin::FirstParty);
584        assert_eq!(classify("../pkg/util"), Origin::FirstParty);
585        assert_eq!(classify("/abs/path"), Origin::FirstParty);
586    }
587
588    #[test]
589    fn classify_node_builtins() {
590        assert_eq!(classify("fs"), Origin::Builtin);
591        assert_eq!(classify("node:fs"), Origin::Builtin);
592        assert_eq!(classify("fs/promises"), Origin::Builtin);
593        assert_eq!(classify("node:test"), Origin::Builtin);
594        assert_eq!(classify("child_process"), Origin::Builtin);
595        assert_eq!(classify("node:some-future-builtin"), Origin::Builtin);
596    }
597
598    #[test]
599    fn classify_third_party() {
600        assert_eq!(classify("lodash"), Origin::ThirdParty);
601        assert_eq!(classify("@scope/pkg"), Origin::ThirdParty);
602        assert_eq!(classify("stripe/lib/client"), Origin::ThirdParty);
603        // A bare `test` is too ambiguous to assume the built-in; only `node:test`
604        // is treated as a built-in.
605        assert_eq!(classify("test"), Origin::ThirdParty);
606    }
607
608    #[test]
609    fn recognizes_ts_test_files() {
610        assert!(is_ts_test_file(Path::new("widget.test.ts")));
611        assert!(is_ts_test_file(Path::new("pkg/button.test.tsx")));
612        assert!(is_ts_test_file(Path::new("service.test.mts")));
613        assert!(is_ts_test_file(Path::new("legacy.test.cts")));
614        assert!(!is_ts_test_file(Path::new("widget.ts")));
615        assert!(!is_ts_test_file(Path::new("types.d.ts")));
616        assert!(!is_ts_test_file(Path::new("README.md")));
617    }
618
619    #[test]
620    fn line_of_counts_newlines() {
621        let src = "a\nb\nc\n";
622        assert_eq!(line_of(src, 0), 1);
623        assert_eq!(line_of(src, 2), 2);
624        assert_eq!(line_of(src, 4), 3);
625    }
626
627    #[test]
628    fn flags_mock_of_relative_module() {
629        let found = violations("a.test.ts", "vi.mock('./service');\n");
630        assert_eq!(found.len(), 1);
631        assert_eq!(found[0].rule, "no-first-party-mock");
632        assert_eq!(found[0].line, 1);
633    }
634
635    #[test]
636    fn flags_mock_with_factory_and_parent_path() {
637        let found = violations(
638            "a.test.ts",
639            "import { x } from './x';\nvi.mock('../src/ledger', () => ({ record: vi.fn() }));\n",
640        );
641        assert_eq!(found.len(), 1);
642        assert!(found[0].message.contains("../src/ledger"));
643    }
644
645    #[test]
646    fn flags_domock_of_relative_module() {
647        let found = violations("a.test.mts", "vi.doMock('./mailer');\n");
648        assert_eq!(found.len(), 1);
649    }
650
651    #[test]
652    fn allows_mock_of_third_party_and_builtins() {
653        let found = violations(
654            "a.test.ts",
655            "vi.mock('stripe');\nvi.mock('node:fs');\nvi.mock('fs/promises');\nvi.mock('@scope/pkg');\n",
656        );
657        assert!(found.is_empty(), "got: {found:?}");
658    }
659
660    #[test]
661    fn ignores_non_vi_and_non_mock_calls() {
662        // `describe(...)` (plain call), `vi.fn()` (vi, not mock), and a method
663        // call whose receiver isn't `vi` must all be left alone.
664        let found = violations(
665            "a.test.ts",
666            "describe('s', () => {});\nvi.fn();\nexpect(1).toBe(1);\nother.mock('./x');\n",
667        );
668        assert!(found.is_empty(), "got: {found:?}");
669    }
670
671    #[test]
672    fn ignores_dynamic_mock_target() {
673        // A non-literal specifier can't be classified deterministically.
674        let found = violations("a.test.ts", "const m = './x';\nvi.mock(m);\n");
675        assert!(found.is_empty(), "got: {found:?}");
676    }
677
678    #[test]
679    fn finds_mocks_nested_in_blocks() {
680        // `vi.mock` is normally hoisted to the top level, but a nested call is
681        // still reached by the walk.
682        let found = violations(
683            "a.test.ts",
684            "describe('s', () => {\n  vi.mock('./inner');\n});\n",
685        );
686        assert_eq!(found.len(), 1);
687        assert_eq!(found[0].line, 2);
688    }
689
690    #[test]
691    fn parse_error_is_reported() {
692        let err = integration_violations_in(Path::new("bad.test.ts"), "const x = ;\n").unwrap_err();
693        assert!(err.to_string().contains("parsing"), "got: {err}");
694    }
695
696    #[test]
697    fn unsupported_extension_is_reported() {
698        let err = integration_violations_in(Path::new("weird.test.bogus"), "vi.mock('./x');\n")
699            .unwrap_err();
700        assert!(err.to_string().contains("unsupported"), "got: {err}");
701    }
702}