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