1import { defineRule, type ESTree } from "@oxlint/plugins";
2import * as Option from "effect/Option";
3
4import { getPropertyName, unwrapExpression } from "../utils.ts";
5
6const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/u;
7// Opt-in suites that drive real provider agents through whole turns; minutes are the
8// expected cost there, not a hidden wait.
9const LIVE_TEST_FILE_PATTERN = /\.live\.(?:test|spec)\.[cm]?[jt]sx?$/u;
10const TEST_FUNCTIONS = new Set(["describe", "it", "suite", "test"]);
11const HOOK_FUNCTIONS = new Set(["afterAll", "afterEach", "beforeAll", "beforeEach"]);
12const FOCUS_OR_SKIP_MODIFIERS = new Set(["only", "skip", "todo"]);
13const FOCUS_OR_SKIP_FUNCTIONS = new Set(["fdescribe", "fit", "xdescribe", "xit", "xtest"]);
14const TIMEOUT_OPTIONS = new Set(["retry", "timeout"]);
15const SET_CONFIG_OPTIONS = new Set(["hookTimeout", "retry", "testTimeout"]);
16const QUARANTINE_PATTERN =
17 /quarantine:\s*#\d+|https:\/\/github\.com\/[^\s/]+\/[^\s/]+\/issues\/\d+/u;
18
19const TIMEOUT_MESSAGE =
20 "Per-test timeouts and retries hide waits that should be event-driven; the project timeout is the ceiling. Remove the override and await the milestone.";
21const SKIP_MESSAGE =
22 "A skipped test needs a tracking issue in the comment above it: `// quarantine: #1234 <why>`.";
23
24// Walks `it.effect.each(cases)` / `describe.skipIf(x)` / `it.layer(L)` down to the
25// identifier that registers the test.
26const chainRoot = (node: unknown): Option.Option<string> => {
27 const expression = unwrapExpression(node);
28 if (Option.isNone(expression)) return Option.none();
29 const value = expression.value;
30 if (value.type === "Identifier") return Option.some(value.name);
31 if (value.type === "MemberExpression") return chainRoot(value.object);
32 if (value.type === "CallExpression") return chainRoot(value.callee);
33 return Option.none();
34};
35
36const isTestChain = (node: unknown) =>
37 Option.exists(chainRoot(node), (name) => TEST_FUNCTIONS.has(name));
38
39const hasEnabledOption = (node: unknown, names: ReadonlySet<string>) => {
40 const expression = unwrapExpression(node);
41 if (Option.isNone(expression) || expression.value.type !== "ObjectExpression") return false;
42 return expression.value.properties.some(
43 (property) =>
44 property.type === "Property" &&
45 Option.exists(getPropertyName(property.key), (name) => names.has(name)) &&
46 Option.exists(
47 unwrapExpression(property.value),
48 (value) => value.type === "Literal" && value.value === true,
49 ),
50 );
51};
52
53const hasOption = (node: unknown, names: ReadonlySet<string>) => {
54 const expression = unwrapExpression(node);
55 if (Option.isNone(expression) || expression.value.type !== "ObjectExpression") return false;
56 return expression.value.properties.some(
57 (property) =>
58 property.type === "Property" &&
59 Option.exists(getPropertyName(property.key), (name) => names.has(name)),
60 );
61};
62
63const isNumericLiteral = (node: unknown) =>
64 Option.exists(
65 unwrapExpression(node),
66 (expression) => expression.type === "Literal" && typeof expression.value === "number",
67 );
68
69const isStringLiteral = (node: unknown) =>
70 Option.exists(
71 unwrapExpression(node),
72 (expression) => expression.type === "Literal" && typeof expression.value === "string",
73 );
74
75const enclosingStatement = (node: ESTree.Node): ESTree.Node => {
76 let current: ESTree.Node = node;
77 while (current.parent && !/(?:Statement|Declaration)$/u.test(current.type)) {
78 current = current.parent;
79 }
80 return current;
81};
82
83export default defineRule({
84 meta: {
85 type: "problem",
86 docs: {
87 description:
88 "Disallow per-test timeouts and retries, and require a tracking issue for skipped or focused tests.",
89 },
90 },
91 create(context) {
92 if (!TEST_FILE_PATTERN.test(context.filename)) return {};
93 const checkTimeouts = !LIVE_TEST_FILE_PATTERN.test(context.filename);
94
95 const reportUnlessQuarantined = (node: ESTree.Node) => {
96 const comments = context.sourceCode.getCommentsBefore(enclosingStatement(node));
97 if (comments.some((comment) => QUARANTINE_PATTERN.test(comment.value))) return;
98 context.report({ node, message: SKIP_MESSAGE });
99 };
100
101 return {
102 CallExpression(node) {
103 const callee = unwrapExpression(node.callee);
104 if (Option.isNone(callee)) return;
105
106 if (
107 callee.value.type === "MemberExpression" &&
108 Option.exists(chainRoot(callee.value.object), (name) => name === "vi") &&
109 Option.exists(getPropertyName(callee.value.property), (name) => name === "setConfig")
110 ) {
111 if (checkTimeouts && hasOption(node.arguments[0], SET_CONFIG_OPTIONS)) {
112 context.report({ node, message: TIMEOUT_MESSAGE });
113 }
114 return;
115 }
116
117 if (callee.value.type === "Identifier" && HOOK_FUNCTIONS.has(callee.value.name)) {
118 if (checkTimeouts && isNumericLiteral(node.arguments[1])) {
119 context.report({ node, message: TIMEOUT_MESSAGE });
120 }
121 return;
122 }
123
124 if (!isTestChain(callee.value)) return;
125 const [, second, third] = node.arguments;
126 // `test(name, { timeout }, fn)` is the options-first spelling of the same override.
127 if (
128 checkTimeouts &&
129 (isNumericLiteral(third) ||
130 hasOption(third, TIMEOUT_OPTIONS) ||
131 hasOption(second, TIMEOUT_OPTIONS))
132 ) {
133 context.report({ node, message: TIMEOUT_MESSAGE });
134 }
135 // `test(name, { skip: true }, fn)` is the options spelling of `test.skip`. A computed value
136 // such as `{ skip: windowsHost }` is a platform gate, the same as `skipIf`, and stays allowed.
137 if (
138 hasEnabledOption(second, FOCUS_OR_SKIP_MODIFIERS) ||
139 hasEnabledOption(third, FOCUS_OR_SKIP_MODIFIERS)
140 ) {
141 reportUnlessQuarantined(node);
142 }
143 },
144 Identifier(node) {
145 if (!FOCUS_OR_SKIP_FUNCTIONS.has(node.name)) return;
146 const parent = node.parent;
147 if (parent?.type !== "CallExpression" || parent.callee !== node) return;
148 reportUnlessQuarantined(node);
149 },
150 MemberExpression(node) {
151 if (!Option.exists(getPropertyName(node.property), (n) => FOCUS_OR_SKIP_MODIFIERS.has(n))) {
152 return;
153 }
154 // `it["skip"](...)` is the same registration; only a dynamic key is unknowable here.
155 if (node.computed && !isStringLiteral(node.property)) return;
156 if (!isTestChain(node.object)) return;
157 reportUnlessQuarantined(node);
158 },
159 };
160 },
161});