1use std::collections::BTreeSet;
15use std::path::{Path, PathBuf};
16
17use anyhow::{anyhow, bail, Context, Result};
18use oxc::allocator::Allocator;
19use oxc::ast::ast::{
20 Argument, CallExpression, Declaration, Expression, ImportDeclaration, ImportOrExportKind,
21 Statement,
22};
23use oxc::ast_visit::{walk, Visit};
24use oxc::parser::Parser;
25use oxc::span::{SourceType, Span};
26
27use crate::lint::Violation;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Origin {
32 FirstParty,
34 Builtin,
36 ThirdParty,
38}
39
40pub fn classify(specifier: &str) -> Origin {
49 if specifier.starts_with('.') || specifier.starts_with('/') {
50 return Origin::FirstParty;
51 }
52 if specifier.starts_with("node:") || is_node_builtin(specifier) {
53 return Origin::Builtin;
54 }
55 Origin::ThirdParty
56}
57
58fn is_node_builtin(specifier: &str) -> bool {
61 let head = specifier.split('/').next().unwrap_or(specifier);
62 NODE_BUILTINS.contains(&head)
63}
64
65const NODE_BUILTINS: &[&str] = &[
69 "assert",
70 "async_hooks",
71 "buffer",
72 "child_process",
73 "cluster",
74 "console",
75 "constants",
76 "crypto",
77 "dgram",
78 "diagnostics_channel",
79 "dns",
80 "domain",
81 "events",
82 "fs",
83 "http",
84 "http2",
85 "https",
86 "inspector",
87 "module",
88 "net",
89 "os",
90 "path",
91 "perf_hooks",
92 "process",
93 "punycode",
94 "querystring",
95 "readline",
96 "repl",
97 "stream",
98 "string_decoder",
99 "sys",
100 "timers",
101 "tls",
102 "trace_events",
103 "tty",
104 "url",
105 "util",
106 "v8",
107 "vm",
108 "wasi",
109 "worker_threads",
110 "zlib",
111];
112
113pub fn find_integration_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
120 let root = root.as_ref();
121 let mut files = Vec::new();
122 collect_ts_test_files(root, &mut files)?;
123 files.sort();
124
125 let mut violations = Vec::new();
126 for file in &files {
127 let source = std::fs::read_to_string(file)
128 .with_context(|| format!("reading test file `{}`", file.display()))?;
129 violations.extend(integration_violations_in(file, &source)?);
130 }
131
132 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
133 Ok(violations)
134}
135
136const UNKNOWN_TIER_MSG: &str = "test file sits under `tests/` outside the standard suite tiers; \
139 a suite lives in `tests/integration/` or `tests/e2e/`";
140
141pub fn find_suite_violations(package_root: &Path) -> Result<Vec<Violation>> {
151 let tests = package_root.join("tests");
152 let mut violations = Vec::new();
153 let tiers = ["integration", "e2e"].map(|tier| tests.join(tier));
154 for tier in &tiers {
155 if tier.is_dir() {
156 violations.extend(find_integration_violations(tier)?);
157 }
158 }
159 if tests.is_dir() {
160 let mut strays = Vec::new();
161 collect_ts_test_files(&tests, &mut strays)?;
162 strays.retain(|file| !tiers.iter().any(|tier| file.starts_with(tier)));
163 for file in strays {
164 violations.push(Violation {
165 file,
166 line: 1,
167 rule: "unknown-tier",
168 message: UNKNOWN_TIER_MSG.to_string(),
169 });
170 }
171 }
172 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
173 Ok(violations)
174}
175
176pub fn find_unit_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
184 let root = root.as_ref();
185 let mut files = Vec::new();
186 collect_ts_test_files(root, &mut files)?;
187 if let Some(tests) = crate::tiers::suite_tests_dir(root, "package.json") {
190 files.retain(|file| !file.starts_with(&tests));
191 }
192 files.sort();
193
194 let mut violations = Vec::new();
195 for file in &files {
196 let source = std::fs::read_to_string(file)
197 .with_context(|| format!("reading test file `{}`", file.display()))?;
198 violations.extend(unit_violations_in(file, &source)?);
199 }
200
201 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
202 Ok(violations)
203}
204
205fn unit_violations_in(file: &Path, source: &str) -> Result<Vec<Violation>> {
209 let allocator = Allocator::default();
210 let source_type = SourceType::from_path(file).map_err(|err| {
211 anyhow!(
212 "unsupported TypeScript extension `{}`: {err}",
213 file.display()
214 )
215 })?;
216 let ret = Parser::new(&allocator, source, source_type).parse();
217 if ret.panicked || !ret.diagnostics.is_empty() {
218 let detail = ret
219 .diagnostics
220 .iter()
221 .map(|d| d.to_string())
222 .collect::<Vec<_>>()
223 .join("; ");
224 bail!("parsing `{}` failed: {detail}", file.display());
225 }
226
227 let mut collector = UnitCollector {
228 source,
229 imports: Vec::new(),
230 mocked: BTreeSet::new(),
231 untyped: Vec::new(),
232 };
233 collector.visit_program(&ret.program);
234
235 let unit = unit_under_test_specifier(file);
236 let mocked_modules: BTreeSet<&str> = collector
240 .mocked
241 .iter()
242 .map(|m| strip_module_ext(m))
243 .collect();
244 let mut violations = Vec::new();
245 for (spec, line) in &collector.imports {
246 if is_unit_under_test(spec, &unit)
247 || is_test_runner(spec)
248 || mocked_modules.contains(strip_module_ext(spec))
249 {
250 continue;
251 }
252 violations.push(Violation {
253 file: file.to_path_buf(),
254 line: *line,
255 rule: "unmocked-collaborator",
256 message: format!(
257 "unit test imports `{spec}` without mocking it — a unit test isolates the \
258 unit under test, so every collaborator must be `vi.mock()`-ed"
259 ),
260 });
261 }
262 for (spec, line) in &collector.untyped {
263 violations.push(Violation {
264 file: file.to_path_buf(),
265 line: *line,
266 rule: "untyped-mock",
267 message: format!(
268 "`vi.mock('{spec}', …)` has an untyped factory — anchor it to the real module \
269 with `vi.importActual<typeof import('{spec}')>()` so the double can't drift \
270 from the source"
271 ),
272 });
273 }
274 violations.sort_by_key(|v| v.line);
275 Ok(violations)
276}
277
278struct UnitCollector<'s> {
281 source: &'s str,
282 imports: Vec<(String, usize)>,
283 mocked: BTreeSet<String>,
284 untyped: Vec<(String, usize)>,
285}
286
287impl<'a> Visit<'a> for UnitCollector<'_> {
288 fn visit_import_declaration(&mut self, decl: &ImportDeclaration<'a>) {
289 if matches!(decl.import_kind, ImportOrExportKind::Type) {
291 return;
292 }
293 self.imports.push((
294 decl.source.value.to_string(),
295 line_of(self.source, decl.span.start),
296 ));
297 }
298
299 fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
300 if let Some(spec) = vi_mock_target(call) {
301 if let Some(factory) = call.arguments.get(1) {
308 if is_factory(factory) && !factory_is_typed(factory) {
309 self.untyped
310 .push((spec.clone(), line_of(self.source, call.span.start)));
311 }
312 }
313 self.mocked.insert(spec);
314 }
315 walk::walk_call_expression(self, call);
316 }
317}
318
319fn unit_under_test_specifier(file: &Path) -> String {
321 let name = file
322 .file_name()
323 .and_then(|n| n.to_str())
324 .unwrap_or_default();
325 let stem = name.split(".test.").next().unwrap_or(name);
326 format!("./{stem}")
327}
328
329fn is_unit_under_test(spec: &str, unit: &str) -> bool {
332 strip_module_ext(spec) == unit
333}
334
335fn strip_module_ext(spec: &str) -> &str {
337 for ext in [".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"] {
338 if let Some(base) = spec.strip_suffix(ext) {
339 return base;
340 }
341 }
342 spec
343}
344
345fn is_test_runner(spec: &str) -> bool {
348 spec == "vitest" || spec.starts_with("vitest/") || spec.starts_with("@vitest/")
349}
350
351fn is_factory(arg: &Argument) -> bool {
358 matches!(
359 arg.as_expression(),
360 Some(Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_))
361 )
362}
363
364fn factory_is_typed(factory: &Argument) -> bool {
368 let mut finder = ImportActualFinder { typed: false };
369 finder.visit_argument(factory);
370 finder.typed
371}
372
373struct ImportActualFinder {
375 typed: bool,
376}
377
378impl<'a> Visit<'a> for ImportActualFinder {
379 fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
380 if is_typed_import_actual(call) {
381 self.typed = true;
382 }
383 walk::walk_call_expression(self, call);
384 }
385}
386
387fn is_typed_import_actual(call: &CallExpression) -> bool {
390 let Expression::StaticMemberExpression(member) = &call.callee else {
391 return false;
392 };
393 let is_vi = matches!(&member.object, Expression::Identifier(id) if id.name == "vi");
394 is_vi && member.property.name.as_str() == "importActual" && call.type_arguments.is_some()
395}
396
397fn integration_violations_in(file: &Path, source: &str) -> Result<Vec<Violation>> {
401 let allocator = Allocator::default();
402 let source_type = SourceType::from_path(file).map_err(|err| {
403 anyhow!(
404 "unsupported TypeScript extension `{}`: {err}",
405 file.display()
406 )
407 })?;
408 let ret = Parser::new(&allocator, source, source_type).parse();
409 if ret.panicked || !ret.diagnostics.is_empty() {
410 let detail = ret
411 .diagnostics
412 .iter()
413 .map(|d| d.to_string())
414 .collect::<Vec<_>>()
415 .join("; ");
416 bail!("parsing `{}` failed: {detail}", file.display());
417 }
418
419 let mut visitor = MockVisitor {
420 file,
421 source,
422 violations: Vec::new(),
423 };
424 visitor.visit_program(&ret.program);
425 Ok(visitor.violations)
426}
427
428struct MockVisitor<'s> {
431 file: &'s Path,
432 source: &'s str,
433 violations: Vec<Violation>,
434}
435
436impl MockVisitor<'_> {
437 fn report(&mut self, span: Span, spec: &str) {
438 self.violations.push(Violation {
439 file: self.file.to_path_buf(),
440 line: line_of(self.source, span.start),
441 rule: "no-first-party-mock",
442 message: format!(
443 "integration test mocks first-party module `{spec}` — an integration test \
444 runs first-party code for real; only third-party packages and Node built-ins \
445 may be mocked"
446 ),
447 });
448 }
449}
450
451impl<'a> Visit<'a> for MockVisitor<'_> {
452 fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
453 if let Some(spec) = vi_mock_target(call) {
454 if classify(&spec) == Origin::FirstParty {
455 self.report(call.span, &spec);
456 }
457 }
458 walk::walk_call_expression(self, call);
459 }
460}
461
462fn vi_mock_target(call: &CallExpression) -> Option<String> {
468 let Expression::StaticMemberExpression(member) = &call.callee else {
469 return None;
470 };
471 let is_vi = matches!(&member.object, Expression::Identifier(id) if id.name == "vi");
472 if !is_vi {
473 return None;
474 }
475 let method = member.property.name.as_str();
476 if method != "mock" && method != "doMock" {
477 return None;
478 }
479 match call.arguments.first() {
480 Some(Argument::StringLiteral(lit)) => Some(lit.value.to_string()),
481 _ => None,
482 }
483}
484
485fn line_of(source: &str, offset: u32) -> usize {
487 let offset = (offset as usize).min(source.len());
488 source.as_bytes()[..offset]
489 .iter()
490 .filter(|&&byte| byte == b'\n')
491 .count()
492 + 1
493}
494
495pub fn is_type_only_module(source: &str, path: &Path) -> bool {
511 let allocator = Allocator::default();
512 let Ok(source_type) = SourceType::from_path(path) else {
513 return false;
514 };
515 let ret = Parser::new(&allocator, source, source_type).parse();
516 if ret.panicked || !ret.diagnostics.is_empty() {
517 return false;
518 }
519 let body = &ret.program.body;
520 !body.is_empty() && body.iter().all(is_type_only_statement)
521}
522
523fn is_type_only_statement(statement: &Statement) -> bool {
525 match statement {
526 Statement::TSTypeAliasDeclaration(_) | Statement::TSInterfaceDeclaration(_) => true,
527 Statement::ImportDeclaration(decl) => decl.import_kind.is_type(),
528 Statement::ExportAllDeclaration(decl) => decl.export_kind.is_type(),
529 Statement::ExportNamedDeclaration(decl) => {
530 if decl.export_kind.is_type() {
534 return true;
535 }
536 match &decl.declaration {
537 Some(Declaration::TSTypeAliasDeclaration(_))
538 | Some(Declaration::TSInterfaceDeclaration(_)) => true,
539 Some(_) => false,
540 None => false,
542 }
543 }
544 _ => false,
545 }
546}
547
548fn collect_ts_test_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
550 let entries =
551 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
552 for entry in entries {
553 let path = entry
554 .with_context(|| format!("reading an entry under `{}`", dir.display()))?
555 .path();
556 if path.is_dir() {
557 collect_ts_test_files(&path, out)?;
558 } else if is_ts_test_file(&path) {
559 out.push(path);
560 }
561 }
562 Ok(())
563}
564
565fn is_ts_test_file(path: &Path) -> bool {
567 let name = path
568 .file_name()
569 .and_then(|n| n.to_str())
570 .unwrap_or_default();
571 name.ends_with(".test.ts")
572 || name.ends_with(".test.tsx")
573 || name.ends_with(".test.mts")
574 || name.ends_with(".test.cts")
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580
581 fn violations(name: &str, source: &str) -> Vec<Violation> {
583 integration_violations_in(Path::new(name), source).expect("source should parse")
584 }
585
586 fn unit_violations(name: &str, source: &str) -> Vec<Violation> {
588 unit_violations_in(Path::new(name), source).expect("source should parse")
589 }
590
591 #[test]
592 fn unit_flags_unmocked_first_party_and_external() {
593 let found = unit_violations(
594 "widget.test.ts",
595 "import { makeWidget } from './widget';\n\
596 import { format } from './formatter';\n\
597 import { chunk } from 'lodash';\n",
598 );
599 assert_eq!(found.len(), 2, "got: {found:?}");
602 assert!(found.iter().all(|v| v.rule == "unmocked-collaborator"));
603 assert!(found.iter().any(|v| v.message.contains("./formatter")));
604 assert!(found.iter().any(|v| v.message.contains("lodash")));
605 }
606
607 #[test]
608 fn unit_mocked_collaborator_is_clean() {
609 let found = unit_violations(
610 "widget.test.ts",
611 "import { format } from './formatter';\nvi.mock('./formatter');\n",
612 );
613 assert!(found.is_empty(), "got: {found:?}");
614 }
615
616 #[test]
617 fn unit_under_test_and_runner_are_not_flagged() {
618 let found = unit_violations(
619 "widget.test.ts",
620 "import { vi } from 'vitest';\n\
621 import { makeWidget } from './widget.js';\n",
622 );
623 assert!(found.is_empty(), "got: {found:?}");
625 }
626
627 fn type_only(source: &str) -> bool {
629 is_type_only_module(source, Path::new("foo.ts"))
630 }
631
632 #[test]
633 fn type_only_recognizes_a_pure_type_module() {
634 assert!(type_only(
635 "export interface Shape { kind: string }\nexport type Id = string;\n"
636 ));
637 assert!(type_only(
638 "import type { Shape } from './shape';\nexport type Wrapped = Shape;\n"
639 ));
640 assert!(type_only("export type { Id } from './shape';\n"));
641 assert!(type_only(
642 "type Local = number;\ninterface Bare { x: Local }\n"
643 ));
644 assert!(type_only("export type * from './shapes';\n"));
645 }
646
647 #[test]
648 fn type_only_rejects_any_runtime_construct() {
649 assert!(!type_only(
651 "export type T = number;\nexport const version: T = 1;\n"
652 ));
653 assert!(!type_only(
654 "export interface I {}\nexport function make(): I { return {}; }\n"
655 ));
656 assert!(!type_only(
658 "import { x } from './x';\nexport type T = typeof x;\n"
659 ));
660 assert!(!type_only("export * from './widget';\n"));
661 assert!(!type_only("export { thing } from './thing';\n"));
662 assert!(!type_only("export enum Color { Red, Green }\n"));
664 assert!(!type_only("export namespace N { export const x = 1; }\n"));
665 }
666
667 #[test]
668 fn type_only_is_false_for_empty_or_unparsable() {
669 assert!(!type_only(""));
671 assert!(!type_only("// just a comment\n"));
672 assert!(!type_only("export type T = ;;;\nconst {{{ = \n"));
674 }
675
676 #[test]
677 fn unit_type_only_import_is_not_flagged() {
678 let found = unit_violations(
679 "widget.test.ts",
680 "import type { Opts } from './opts';\nimport { x } from './x';\nvi.mock('./x');\n",
681 );
682 assert!(found.is_empty(), "got: {found:?}");
683 }
684
685 #[test]
686 fn unit_under_test_specifier_strips_test_suffix() {
687 assert_eq!(
688 unit_under_test_specifier(Path::new("pkg/widget.test.ts")),
689 "./widget"
690 );
691 assert_eq!(
692 unit_under_test_specifier(Path::new("button.test.tsx")),
693 "./button"
694 );
695 }
696
697 #[test]
698 fn strip_module_ext_drops_known_extensions_only() {
699 assert_eq!(strip_module_ext("./widget.js"), "./widget");
700 assert_eq!(strip_module_ext("./widget.mts"), "./widget");
701 assert_eq!(strip_module_ext("./widget"), "./widget");
702 assert_eq!(strip_module_ext("lodash"), "lodash");
703 }
704
705 #[test]
706 fn recognizes_the_test_runner() {
707 assert!(is_test_runner("vitest"));
708 assert!(is_test_runner("vitest/config"));
709 assert!(is_test_runner("@vitest/spy"));
710 assert!(!is_test_runner("./vitest-helpers"));
711 assert!(!is_test_runner("lodash"));
712 }
713
714 #[test]
715 fn unit_flags_untyped_factory_mock() {
716 let found = unit_violations(
717 "widget.test.ts",
718 "import { x } from './x';\nvi.mock('./x', () => ({ x: vi.fn() }));\n",
719 );
720 assert_eq!(found.len(), 1, "got: {found:?}");
723 assert_eq!(found[0].rule, "untyped-mock");
724 assert!(found[0].message.contains("./x"));
725 }
726
727 #[test]
728 fn unit_typed_factory_mock_is_clean() {
729 let found = unit_violations(
730 "widget.test.ts",
731 "import { x } from './x';\n\
732 vi.mock('./x', async () => {\n\
733 \x20 const actual = await vi.importActual<typeof import('./x')>('./x');\n\
734 \x20 return { ...actual, x: vi.fn() };\n\
735 });\n",
736 );
737 assert!(found.is_empty(), "got: {found:?}");
738 }
739
740 #[test]
741 fn unit_options_object_mock_is_not_a_factory() {
742 let found = unit_violations(
746 "widget.test.ts",
747 "import { x } from './x';\nvi.mock('./x', { spy: true });\n",
748 );
749 assert!(found.is_empty(), "got: {found:?}");
750 }
751
752 #[test]
753 fn unit_untyped_import_actual_is_still_untyped() {
754 let found = unit_violations(
756 "widget.test.ts",
757 "import { x } from './x';\n\
758 vi.mock('./x', async () => {\n\
759 \x20 const actual = await vi.importActual('./x');\n\
760 \x20 return { ...(actual as object), x: vi.fn() };\n\
761 });\n",
762 );
763 assert_eq!(found.len(), 1, "got: {found:?}");
764 assert_eq!(found[0].rule, "untyped-mock");
765 }
766
767 #[test]
768 fn classify_relative_is_first_party() {
769 assert_eq!(classify("./service"), Origin::FirstParty);
770 assert_eq!(classify("../pkg/util"), Origin::FirstParty);
771 assert_eq!(classify("/abs/path"), Origin::FirstParty);
772 }
773
774 #[test]
775 fn classify_node_builtins() {
776 assert_eq!(classify("fs"), Origin::Builtin);
777 assert_eq!(classify("node:fs"), Origin::Builtin);
778 assert_eq!(classify("fs/promises"), Origin::Builtin);
779 assert_eq!(classify("node:test"), Origin::Builtin);
780 assert_eq!(classify("child_process"), Origin::Builtin);
781 assert_eq!(classify("node:some-future-builtin"), Origin::Builtin);
782 }
783
784 #[test]
785 fn classify_third_party() {
786 assert_eq!(classify("lodash"), Origin::ThirdParty);
787 assert_eq!(classify("@scope/pkg"), Origin::ThirdParty);
788 assert_eq!(classify("stripe/lib/client"), Origin::ThirdParty);
789 assert_eq!(classify("test"), Origin::ThirdParty);
792 }
793
794 #[test]
795 fn recognizes_ts_test_files() {
796 assert!(is_ts_test_file(Path::new("widget.test.ts")));
797 assert!(is_ts_test_file(Path::new("pkg/button.test.tsx")));
798 assert!(is_ts_test_file(Path::new("service.test.mts")));
799 assert!(is_ts_test_file(Path::new("legacy.test.cts")));
800 assert!(!is_ts_test_file(Path::new("widget.ts")));
801 assert!(!is_ts_test_file(Path::new("types.d.ts")));
802 assert!(!is_ts_test_file(Path::new("README.md")));
803 }
804
805 #[test]
806 fn line_of_counts_newlines() {
807 let src = "a\nb\nc\n";
808 assert_eq!(line_of(src, 0), 1);
809 assert_eq!(line_of(src, 2), 2);
810 assert_eq!(line_of(src, 4), 3);
811 }
812
813 #[test]
814 fn flags_mock_of_relative_module() {
815 let found = violations("a.test.ts", "vi.mock('./service');\n");
816 assert_eq!(found.len(), 1);
817 assert_eq!(found[0].rule, "no-first-party-mock");
818 assert_eq!(found[0].line, 1);
819 }
820
821 #[test]
822 fn flags_mock_with_factory_and_parent_path() {
823 let found = violations(
824 "a.test.ts",
825 "import { x } from './x';\nvi.mock('../src/ledger', () => ({ record: vi.fn() }));\n",
826 );
827 assert_eq!(found.len(), 1);
828 assert!(found[0].message.contains("../src/ledger"));
829 }
830
831 #[test]
832 fn flags_domock_of_relative_module() {
833 let found = violations("a.test.mts", "vi.doMock('./mailer');\n");
834 assert_eq!(found.len(), 1);
835 }
836
837 #[test]
838 fn allows_mock_of_third_party_and_builtins() {
839 let found = violations(
840 "a.test.ts",
841 "vi.mock('stripe');\nvi.mock('node:fs');\nvi.mock('fs/promises');\nvi.mock('@scope/pkg');\n",
842 );
843 assert!(found.is_empty(), "got: {found:?}");
844 }
845
846 #[test]
847 fn ignores_non_vi_and_non_mock_calls() {
848 let found = violations(
851 "a.test.ts",
852 "describe('s', () => {});\nvi.fn();\nexpect(1).toBe(1);\nother.mock('./x');\n",
853 );
854 assert!(found.is_empty(), "got: {found:?}");
855 }
856
857 #[test]
858 fn ignores_dynamic_mock_target() {
859 let found = violations("a.test.ts", "const m = './x';\nvi.mock(m);\n");
861 assert!(found.is_empty(), "got: {found:?}");
862 }
863
864 #[test]
865 fn finds_mocks_nested_in_blocks() {
866 let found = violations(
869 "a.test.ts",
870 "describe('s', () => {\n vi.mock('./inner');\n});\n",
871 );
872 assert_eq!(found.len(), 1);
873 assert_eq!(found[0].line, 2);
874 }
875
876 #[test]
877 fn parse_error_is_reported() {
878 let err = integration_violations_in(Path::new("bad.test.ts"), "const x = ;\n").unwrap_err();
879 assert!(err.to_string().contains("parsing"), "got: {err}");
880 }
881
882 #[test]
883 fn unsupported_extension_is_reported() {
884 let err = integration_violations_in(Path::new("weird.test.bogus"), "vi.mock('./x');\n")
885 .unwrap_err();
886 assert!(err.to_string().contains("unsupported"), "got: {err}");
887 }
888}