1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Origin {
28 FirstParty,
30 Builtin,
32 ThirdParty,
34}
35
36pub 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
54fn is_node_builtin(specifier: &str) -> bool {
57 let head = specifier.split('/').next().unwrap_or(specifier);
58 NODE_BUILTINS.contains(&head)
59}
60
61const 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
109pub 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
132fn 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
163struct 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
197fn 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
220fn 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
230fn 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
247fn 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 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 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 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 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 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}