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