Skip to main content

testing_conventions/
ts.rs

1//! TypeScript isolation analysis (issue #43), parsed with `oxc`.
2//!
3//! This is the TypeScript counterpart to the Python [`crate::lint`] module. The
4//! *integration direction* (#75) lands first: an integration test runs
5//! first-party code for real, so it may mock third-party packages and Node
6//! built-ins but **never** a first-party module.
7//!
8//! Detection is AST-based — each `*.test.{ts,tsx,mts,cts}` file is parsed with
9//! `oxc_parser` and walked for `vi.mock()` / `vi.doMock()` calls whose target
10//! specifier is first-party. The specifier [`classify`]-ication (first-party /
11//! Node-builtin / third-party) is the shared foundation the unit-direction
12//! slices (#76, #77) build on.
13
14use std::path::{Path, PathBuf};
15
16use anyhow::{anyhow, bail, Context, Result};
17use oxc::allocator::Allocator;
18use oxc::ast::ast::{Argument, CallExpression, Expression};
19use oxc::ast_visit::{walk, Visit};
20use oxc::parser::Parser;
21use oxc::span::{SourceType, Span};
22
23use crate::lint::Violation;
24
25/// Where a module specifier resolves, for isolation purposes.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Origin {
28    /// A relative or absolute path (`./x`, `../x`, `/abs`) — first-party code.
29    FirstParty,
30    /// A Node.js built-in (`node:fs`, `fs`, `fs/promises`, `path`, …).
31    Builtin,
32    /// Any other bare specifier — a third-party package (`lodash`, `@scope/x`).
33    ThirdParty,
34}
35
36/// Classify a module specifier as first-party, Node-builtin, or third-party.
37///
38/// Deterministic and resolution-free — the bright-line rule the README's
39/// isolation checks rest on:
40/// - a **relative or absolute** path (`./`, `../`, `/`) is first-party;
41/// - a `node:`-prefixed specifier, or one whose first path segment is a known
42///   Node built-in (so `fs` and `fs/promises` both match), is a built-in;
43/// - every other (bare) specifier is a third-party package.
44pub fn classify(specifier: &str) -> Origin {
45    if specifier.starts_with('.') || specifier.starts_with('/') {
46        return Origin::FirstParty;
47    }
48    if specifier.starts_with("node:") || is_node_builtin(specifier) {
49        return Origin::Builtin;
50    }
51    Origin::ThirdParty
52}
53
54/// `true` when `specifier`'s first path segment is a Node.js built-in module —
55/// so a subpath export like `fs/promises` matches on its `fs` head.
56fn is_node_builtin(specifier: &str) -> bool {
57    let head = specifier.split('/').next().unwrap_or(specifier);
58    NODE_BUILTINS.contains(&head)
59}
60
61/// The Node.js built-in module names (the stable set). The explicit `node:`
62/// prefix is handled separately in [`classify`], so future built-ins stay
63/// recognized when written `node:<name>`.
64const NODE_BUILTINS: &[&str] = &[
65    "assert",
66    "async_hooks",
67    "buffer",
68    "child_process",
69    "cluster",
70    "console",
71    "constants",
72    "crypto",
73    "dgram",
74    "diagnostics_channel",
75    "dns",
76    "domain",
77    "events",
78    "fs",
79    "http",
80    "http2",
81    "https",
82    "inspector",
83    "module",
84    "net",
85    "os",
86    "path",
87    "perf_hooks",
88    "process",
89    "punycode",
90    "querystring",
91    "readline",
92    "repl",
93    "stream",
94    "string_decoder",
95    "sys",
96    "timers",
97    "tls",
98    "trace_events",
99    "tty",
100    "url",
101    "util",
102    "v8",
103    "vm",
104    "wasi",
105    "worker_threads",
106    "zlib",
107];
108
109/// Scan the TypeScript test files under `root` and return every
110/// integration-isolation violation, sorted by `(file, line)` for deterministic
111/// output.
112///
113/// A *TypeScript test file* is `*.test.{ts,tsx,mts,cts}`. Each is parsed and
114/// walked; a file that cannot be read or parsed is an error.
115pub fn find_integration_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
116    let root = root.as_ref();
117    let mut files = Vec::new();
118    collect_ts_test_files(root, &mut files)?;
119    files.sort();
120
121    let mut violations = Vec::new();
122    for file in &files {
123        let source = std::fs::read_to_string(file)
124            .with_context(|| format!("reading test file `{}`", file.display()))?;
125        violations.extend(integration_violations_in(file, &source)?);
126    }
127
128    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
129    Ok(violations)
130}
131
132/// Parse one TypeScript test file and collect its `no-first-party-mock`
133/// violations. A parse failure is an error — a malformed test file is never a
134/// silent pass.
135fn integration_violations_in(file: &Path, source: &str) -> Result<Vec<Violation>> {
136    let allocator = Allocator::default();
137    let source_type = SourceType::from_path(file).map_err(|err| {
138        anyhow!(
139            "unsupported TypeScript extension `{}`: {err}",
140            file.display()
141        )
142    })?;
143    let ret = Parser::new(&allocator, source, source_type).parse();
144    if ret.panicked || !ret.diagnostics.is_empty() {
145        let detail = ret
146            .diagnostics
147            .iter()
148            .map(|d| d.to_string())
149            .collect::<Vec<_>>()
150            .join("; ");
151        bail!("parsing `{}` failed: {detail}", file.display());
152    }
153
154    let mut visitor = MockVisitor {
155        file,
156        source,
157        violations: Vec::new(),
158    };
159    visitor.visit_program(&ret.program);
160    Ok(visitor.violations)
161}
162
163/// Walks one parsed test file, flagging every `vi.mock()` / `vi.doMock()` of a
164/// first-party module.
165struct MockVisitor<'s> {
166    file: &'s Path,
167    source: &'s str,
168    violations: Vec<Violation>,
169}
170
171impl MockVisitor<'_> {
172    fn report(&mut self, span: Span, spec: &str) {
173        self.violations.push(Violation {
174            file: self.file.to_path_buf(),
175            line: line_of(self.source, span.start),
176            rule: "no-first-party-mock",
177            message: format!(
178                "integration test mocks first-party module `{spec}` — an integration test \
179                 runs first-party code for real; only third-party packages and Node built-ins \
180                 may be mocked"
181            ),
182        });
183    }
184}
185
186impl<'a> Visit<'a> for MockVisitor<'_> {
187    fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
188        if let Some(spec) = vi_mock_target(call) {
189            if classify(&spec) == Origin::FirstParty {
190                self.report(call.span, &spec);
191            }
192        }
193        walk::walk_call_expression(self, call);
194    }
195}
196
197/// If `call` is `vi.mock("spec", …)` or `vi.doMock("spec", …)` with a string
198/// literal first argument, return that specifier; otherwise `None`.
199///
200/// A non-literal target (`vi.mock(name)`) can't be classified deterministically,
201/// so it is skipped rather than guessed at.
202fn vi_mock_target(call: &CallExpression) -> Option<String> {
203    let Expression::StaticMemberExpression(member) = &call.callee else {
204        return None;
205    };
206    let is_vi = matches!(&member.object, Expression::Identifier(id) if id.name == "vi");
207    if !is_vi {
208        return None;
209    }
210    let method = member.property.name.as_str();
211    if method != "mock" && method != "doMock" {
212        return None;
213    }
214    match call.arguments.first() {
215        Some(Argument::StringLiteral(lit)) => Some(lit.value.to_string()),
216        _ => None,
217    }
218}
219
220/// The 1-based line containing byte `offset` in `source`.
221fn line_of(source: &str, offset: u32) -> usize {
222    let offset = (offset as usize).min(source.len());
223    source.as_bytes()[..offset]
224        .iter()
225        .filter(|&&byte| byte == b'\n')
226        .count()
227        + 1
228}
229
230/// Recursively collect every TypeScript test file under `dir` into `out`.
231fn collect_ts_test_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
232    let entries =
233        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
234    for entry in entries {
235        let path = entry
236            .with_context(|| format!("reading an entry under `{}`", dir.display()))?
237            .path();
238        if path.is_dir() {
239            collect_ts_test_files(&path, out)?;
240        } else if is_ts_test_file(&path) {
241            out.push(path);
242        }
243    }
244    Ok(())
245}
246
247/// `true` for a TypeScript test file: `*.test.{ts,tsx,mts,cts}`.
248fn is_ts_test_file(path: &Path) -> bool {
249    let name = path
250        .file_name()
251        .and_then(|n| n.to_str())
252        .unwrap_or_default();
253    name.ends_with(".test.ts")
254        || name.ends_with(".test.tsx")
255        || name.ends_with(".test.mts")
256        || name.ends_with(".test.cts")
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    /// Parse `source` as `name` and return its integration violations.
264    fn violations(name: &str, source: &str) -> Vec<Violation> {
265        integration_violations_in(Path::new(name), source).expect("source should parse")
266    }
267
268    #[test]
269    fn classify_relative_is_first_party() {
270        assert_eq!(classify("./service"), Origin::FirstParty);
271        assert_eq!(classify("../pkg/util"), Origin::FirstParty);
272        assert_eq!(classify("/abs/path"), Origin::FirstParty);
273    }
274
275    #[test]
276    fn classify_node_builtins() {
277        assert_eq!(classify("fs"), Origin::Builtin);
278        assert_eq!(classify("node:fs"), Origin::Builtin);
279        assert_eq!(classify("fs/promises"), Origin::Builtin);
280        assert_eq!(classify("node:test"), Origin::Builtin);
281        assert_eq!(classify("child_process"), Origin::Builtin);
282        assert_eq!(classify("node:some-future-builtin"), Origin::Builtin);
283    }
284
285    #[test]
286    fn classify_third_party() {
287        assert_eq!(classify("lodash"), Origin::ThirdParty);
288        assert_eq!(classify("@scope/pkg"), Origin::ThirdParty);
289        assert_eq!(classify("stripe/lib/client"), Origin::ThirdParty);
290        // A bare `test` is too ambiguous to assume the built-in; only `node:test`
291        // is treated as a built-in.
292        assert_eq!(classify("test"), Origin::ThirdParty);
293    }
294
295    #[test]
296    fn recognizes_ts_test_files() {
297        assert!(is_ts_test_file(Path::new("widget.test.ts")));
298        assert!(is_ts_test_file(Path::new("pkg/button.test.tsx")));
299        assert!(is_ts_test_file(Path::new("service.test.mts")));
300        assert!(is_ts_test_file(Path::new("legacy.test.cts")));
301        assert!(!is_ts_test_file(Path::new("widget.ts")));
302        assert!(!is_ts_test_file(Path::new("types.d.ts")));
303        assert!(!is_ts_test_file(Path::new("README.md")));
304    }
305
306    #[test]
307    fn line_of_counts_newlines() {
308        let src = "a\nb\nc\n";
309        assert_eq!(line_of(src, 0), 1);
310        assert_eq!(line_of(src, 2), 2);
311        assert_eq!(line_of(src, 4), 3);
312    }
313
314    #[test]
315    fn flags_mock_of_relative_module() {
316        let found = violations("a.test.ts", "vi.mock('./service');\n");
317        assert_eq!(found.len(), 1);
318        assert_eq!(found[0].rule, "no-first-party-mock");
319        assert_eq!(found[0].line, 1);
320    }
321
322    #[test]
323    fn flags_mock_with_factory_and_parent_path() {
324        let found = violations(
325            "a.test.ts",
326            "import { x } from './x';\nvi.mock('../src/ledger', () => ({ record: vi.fn() }));\n",
327        );
328        assert_eq!(found.len(), 1);
329        assert!(found[0].message.contains("../src/ledger"));
330    }
331
332    #[test]
333    fn flags_domock_of_relative_module() {
334        let found = violations("a.test.mts", "vi.doMock('./mailer');\n");
335        assert_eq!(found.len(), 1);
336    }
337
338    #[test]
339    fn allows_mock_of_third_party_and_builtins() {
340        let found = violations(
341            "a.test.ts",
342            "vi.mock('stripe');\nvi.mock('node:fs');\nvi.mock('fs/promises');\nvi.mock('@scope/pkg');\n",
343        );
344        assert!(found.is_empty(), "got: {found:?}");
345    }
346
347    #[test]
348    fn ignores_non_vi_and_non_mock_calls() {
349        // `describe(...)` (plain call), `vi.fn()` (vi, not mock), and a method
350        // call whose receiver isn't `vi` must all be left alone.
351        let found = violations(
352            "a.test.ts",
353            "describe('s', () => {});\nvi.fn();\nexpect(1).toBe(1);\nother.mock('./x');\n",
354        );
355        assert!(found.is_empty(), "got: {found:?}");
356    }
357
358    #[test]
359    fn ignores_dynamic_mock_target() {
360        // A non-literal specifier can't be classified deterministically.
361        let found = violations("a.test.ts", "const m = './x';\nvi.mock(m);\n");
362        assert!(found.is_empty(), "got: {found:?}");
363    }
364
365    #[test]
366    fn finds_mocks_nested_in_blocks() {
367        // `vi.mock` is normally hoisted to the top level, but a nested call is
368        // still reached by the walk.
369        let found = violations(
370            "a.test.ts",
371            "describe('s', () => {\n  vi.mock('./inner');\n});\n",
372        );
373        assert_eq!(found.len(), 1);
374        assert_eq!(found[0].line, 2);
375    }
376
377    #[test]
378    fn parse_error_is_reported() {
379        let err = integration_violations_in(Path::new("bad.test.ts"), "const x = ;\n").unwrap_err();
380        assert!(err.to_string().contains("parsing"), "got: {err}");
381    }
382
383    #[test]
384    fn unsupported_extension_is_reported() {
385        let err = integration_violations_in(Path::new("weird.test.bogus"), "vi.mock('./x');\n")
386            .unwrap_err();
387        assert!(err.to_string().contains("unsupported"), "got: {err}");
388    }
389}