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