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