Skip to content

Commit 1a89494

Browse files
authored
Merge pull request #456 from lo1tuma/add-limit-timeout
Add `limit-timeout` rule
2 parents 3c47ebf + e734ac1 commit 1a89494

13 files changed

Lines changed: 831 additions & 44 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ For maintainers: the rules table below is generated, and the headers in `documen
128128
| [consistent-spacing-between-blocks](documentation/rules/consistent-spacing-between-blocks.md) | Require consistent spacing between blocks || | | 🔧 | |
129129
| [consistent-structure](documentation/rules/consistent-structure.md) | Require consistent structure for Mocha test entities || | | | |
130130
| [handle-done-callback](documentation/rules/handle-done-callback.md) | Enforces handling of callbacks for async tests in every branch || | | | |
131+
| [limit-timeout](documentation/rules/limit-timeout.md) | Enforce limits for Mocha timeouts | | || | |
131132
| [max-top-level-suites](documentation/rules/max-top-level-suites.md) | Enforce the number of top-level suites in a single file || | | | |
132133
| [no-async-and-done](documentation/rules/no-async-and-done.md) | Disallow async functions that also use a Mocha callback || | | | |
133134
| [no-async-in-sync-tests](documentation/rules/no-async-in-sync-tests.md) | Disallow async operations in synchronous tests or hooks | | || | |

benchmarks/runtime.bench.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ const iterations = 50;
8181

8282
describe('runtime', function () {
8383
it('should not take longer as the defined budget to lint many files with the recommended config', function () {
84-
const cpuAgnosticBudget = 2_300_000;
84+
const cpuAgnosticBudget = 2_325_000;
8585
const budget = cpuAgnosticBudget / cpuSpeed;
8686

8787
const { medianDuration } = runSyncBenchmark(() => {
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Enforce limits for Mocha timeouts (`mocha/limit-timeout`)
2+
3+
🚫 This rule is _disabled_ in the ✅ `recommended` [config](https://github.com/lo1tuma/eslint-plugin-mocha#configs).
4+
5+
<!-- end auto-generated rule header -->
6+
7+
Mocha lets suites, tests, and hooks override their timeout with `this.timeout(...)` or chained calls such as `it(...).timeout(...)`. This rule lets you forbid timeout configuration entirely or constrain the values that are allowed.
8+
9+
This rule applies to:
10+
11+
- `this.timeout(...)` inside suite, test, and hook callbacks
12+
- `describe(...).timeout(...)`, `it(...).timeout(...)`, and hook variants
13+
- TDD interface equivalents such as `suite(...).timeout(...)` and `test(...).timeout(...)`
14+
- Equivalent configured custom names
15+
16+
With `mode: "disallow"`, getter-style calls such as `this.timeout()` and `it(...).timeout()` are also reported. Other modes only check calls with a statically known numeric argument.
17+
18+
## Options
19+
20+
This rule supports the following options:
21+
22+
- `{ "mode": "disallow" }`: Reports every Mocha timeout call.
23+
- `{ "mode": "disallowDisabled" }`: Reports timeout values that disable timeouts. In current Mocha behavior, that includes values less than or equal to `0` and values greater than or equal to `2^31 - 1`.
24+
- `{ "mode": "max", "max": 5000 }`: Reports statically known numeric timeout values greater than `max`.
25+
- `{ "mode": "range", "min": 1, "max": 5000 }`: Reports statically known numeric timeout values outside the inclusive range.
26+
27+
For `max` and `range`, unresolved dynamic values and string shorthands such as `"2s"` are ignored.
28+
29+
```json
30+
{
31+
"rules": {
32+
"mocha/limit-timeout": ["error", {
33+
"mode": "range",
34+
"min": 1,
35+
"max": 5000
36+
}]
37+
}
38+
}
39+
```
40+
41+
## Rule Details
42+
43+
Examples of incorrect code for this rule:
44+
45+
```js
46+
it('works', function () {}).timeout(5000);
47+
48+
describe('suite', function () {
49+
this.timeout(0);
50+
});
51+
52+
beforeEach(function () {}).timeout(10_000);
53+
```
54+
55+
Examples of correct code for this rule with `{ "mode": "range", "min": 1, "max": 5000 }`:
56+
57+
```js
58+
it('works', function () {});
59+
60+
it('works', function () {}).timeout(5000);
61+
62+
describe('suite', function () {
63+
this.timeout(2500);
64+
});
65+
```
66+
67+
Examples of correct code for this rule with `{ "mode": "disallowDisabled" }`:
68+
69+
```js
70+
it('works', function () {}).timeout(5000);
71+
72+
describe('suite', function () {
73+
this.timeout(2500);
74+
});
75+
```
76+
77+
## When Not To Use It
78+
79+
- If your project allows arbitrary Mocha timeout configuration.
80+
- If your project relies heavily on dynamic or string-based timeout values and you need exhaustive checking for those forms.
81+
82+
## Further Reading
83+
84+
- [Mocha Timeouts](https://mochajs.org/features/timeouts/)

source/ast/listener-record.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { Rule } from 'eslint';
2+
3+
export function createListenerRecord<Name extends keyof Rule.RuleListener>(
4+
name: Name,
5+
listener: Rule.RuleListener[Name]
6+
): Partial<Rule.RuleListener> {
7+
const listeners: Partial<Rule.RuleListener> = {};
8+
9+
if (listener !== undefined) {
10+
listeners[name] = listener;
11+
}
12+
13+
return listeners;
14+
}

source/ast/mocha-visitors.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,45 @@ ruleTester.run('mocha-visitors', programOnlyRule, {
3434
invalid: []
3535
});
3636

37+
const configDispatchRule: Readonly<Rule.RuleModule> = {
38+
meta: {
39+
schema: []
40+
},
41+
create(ruleContext) {
42+
return createMochaVisitors(ruleContext, {
43+
config(visitorContext) {
44+
if (visitorContext.config === 'timeout') {
45+
ruleContext.report({
46+
node: visitorContext.node,
47+
message: `timeout:${visitorContext.name}`
48+
});
49+
}
50+
}
51+
});
52+
}
53+
};
54+
55+
ruleTester.run('mocha-visitors config dispatch', configDispatchRule, {
56+
valid: [
57+
'it("name", function () {});',
58+
'beforeEach(function () { this.timeout(1000); });'
59+
],
60+
invalid: [
61+
{
62+
code: 'it("name", function () {}).timeout(1000);',
63+
errors: [{ message: 'timeout:it().timeout()' }]
64+
}
65+
]
66+
});
67+
3768
describe('mocha visitor helpers', function () {
3869
it('dispatchCallback() ignores non-call-expression nodes', function () {
3970
let called = false;
4071

4172
dispatchCallback(function () {
4273
called = true;
4374
}, {
75+
config: null,
4476
interface: 'BDD',
4577
modifier: null,
4678
name: 'it()',

source/ast/mocha-visitors.ts

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import type { Rule, SourceCode } from 'eslint';
22
import type { Except } from 'type-fest';
33
import { getAllCustomNameDetails, getCustomNameDetailsForInterface } from '../mocha/all-name-details.js';
4-
import type { MochaEntityType, MochaInterface, MochaModifier } from '../mocha/descriptors.js';
4+
import type { MochaConfigCall, MochaEntityType, MochaInterface, MochaModifier } from '../mocha/descriptors.js';
55
import { getAdditionalNames, getInterface } from '../settings.js';
66
import { findMochaVariableCalls, type ResolvedReferenceWithNameDetails } from './find-mocha-variable-calls.js';
77
import {
88
type AnyFunctionExpressionNode,
99
getFunctionExpressionLastArgument
1010
} from './function-expression-arguments.js';
11+
import { createListenerRecord } from './listener-record.js';
1112
import { isCallExpression } from './node-types.js';
1213

1314
type MochaVisitor = (context: Readonly<VisitorContext>) => void;
@@ -18,6 +19,8 @@ type MemberExpressionNode = Parameters<ExpressionListener<'MemberExpression'>>[0
1819
type CallExpressionNodeVisitor = (node: CallExpressionNode) => void;
1920
type MochaCallbackVisitor = (context: Readonly<CallbackVisitorContext>) => void;
2021
type TestEntityVisitors = {
22+
config?: MochaVisitor | undefined;
23+
'config:exit'?: MochaVisitor | undefined;
2124
testCase?: MochaVisitor | undefined;
2225
'testCase:exit'?: MochaVisitor | undefined;
2326
testCaseCallback?: MochaCallbackVisitor | undefined;
@@ -76,6 +79,7 @@ export type VisitorContext = {
7679
name: string;
7780
node: Rule.Node;
7881
type: MochaEntityType;
82+
config: MochaConfigCall | null;
7983
modifier: MochaModifier | null;
8084
interface: MochaInterface;
8185
};
@@ -86,6 +90,7 @@ type CachedMochaCall = {
8690
};
8791

8892
type CallExpressionDispatchers = {
93+
readonly config?: MochaVisitor | undefined;
8994
readonly testCase?: MochaVisitor | undefined;
9095
readonly testCaseCallback?: MochaCallbackVisitor | undefined;
9196
readonly suite?: MochaVisitor | undefined;
@@ -147,6 +152,7 @@ function createContext(reference: Readonly<ResolvedReferenceWithNameDetails>): R
147152
name: reference.name,
148153
node: reference.node,
149154
type: reference.nameDetails.type,
155+
config: reference.nameDetails.config,
150156
modifier: reference.nameDetails.modifier,
151157
interface: reference.nameDetails.interface
152158
};
@@ -225,12 +231,13 @@ function dispatchCallExpressionContext(
225231
dispatchers: Readonly<CallExpressionDispatchers>,
226232
cachedMochaCall: Readonly<CachedMochaCall>
227233
): void {
234+
const context = createContext(cachedMochaCall.reference);
235+
228236
if (kind === MochaEntityKind.Config) {
237+
group?.visitor?.(context);
229238
return;
230239
}
231240

232-
const context = createContext(cachedMochaCall.reference);
233-
234241
dispatchSpecificCallExpressionContext(group, dispatchers, context);
235242
dispatchers.anyTestEntity?.(context);
236243
dispatchCallback(dispatchers.anyTestEntityCallback, context);
@@ -240,7 +247,7 @@ function createCallExpressionDispatcher(
240247
dispatchers: Readonly<CallExpressionDispatchers>
241248
): CallExpressionDispatcher | undefined {
242249
const groups = {
243-
[MochaEntityKind.Config]: undefined,
250+
[MochaEntityKind.Config]: createCallExpressionDispatchGroup(dispatchers.config, undefined),
244251
[MochaEntityKind.TestCase]: createCallExpressionDispatchGroup(
245252
dispatchers.testCase,
246253
dispatchers.testCaseCallback,
@@ -256,14 +263,19 @@ function createCallExpressionDispatcher(
256263
dispatchers.hookCallback
257264
)
258265
} as const satisfies Readonly<Record<MochaEntityKind, Readonly<CallExpressionDispatchGroup> | undefined>>;
259-
260-
if (
261-
groups[MochaEntityKind.TestCase] === undefined &&
262-
groups[MochaEntityKind.Suite] === undefined &&
263-
groups[MochaEntityKind.Hook] === undefined &&
264-
dispatchers.anyTestEntity === undefined &&
265-
dispatchers.anyTestEntityCallback === undefined
266-
) {
266+
const hasDispatcher = [
267+
groups[MochaEntityKind.Config],
268+
groups[MochaEntityKind.TestCase],
269+
groups[MochaEntityKind.Suite],
270+
groups[MochaEntityKind.Hook],
271+
dispatchers.anyTestEntity,
272+
dispatchers.anyTestEntityCallback
273+
]
274+
.some((dispatcher) => {
275+
return dispatcher !== undefined;
276+
});
277+
278+
if (!hasDispatcher) {
267279
return undefined;
268280
}
269281

@@ -365,6 +377,8 @@ function splitMochaVisitors(visitors: Readonly<MochaVisitors>): Readonly<SplitMo
365377
nonMochaFunctionExpression,
366378
'mochaFunctionExpression:exit': mochaFunctionExpressionExit,
367379
'nonMochaFunctionExpression:exit': nonMochaFunctionExpressionExit,
380+
config,
381+
'config:exit': configExit,
368382
testCase,
369383
'testCase:exit': testCaseExit,
370384
testCaseCallback,
@@ -422,6 +436,7 @@ function splitMochaVisitors(visitors: Readonly<MochaVisitors>): Readonly<SplitMo
422436
}
423437
},
424438
enterDispatchers: {
439+
config,
425440
testCase,
426441
testCaseCallback,
427442
suite,
@@ -433,6 +448,7 @@ function splitMochaVisitors(visitors: Readonly<MochaVisitors>): Readonly<SplitMo
433448
anyTestEntityCallback
434449
},
435450
exitDispatchers: {
451+
config: configExit,
436452
testCase: testCaseExit,
437453
testCaseCallback: testCaseCallbackExit,
438454
suite: suiteExit,
@@ -473,18 +489,6 @@ function createExpressionRuleListener<Node extends FunctionExpressionNode | Memb
473489
};
474490
}
475491

476-
function createListenerRecord<Name extends keyof Rule.RuleListener>(
477-
name: Name,
478-
listener: Rule.RuleListener[Name]
479-
): Partial<Rule.RuleListener> {
480-
const listeners: Partial<Rule.RuleListener> = {};
481-
if (listener !== undefined) {
482-
listeners[name] = listener;
483-
}
484-
485-
return listeners;
486-
}
487-
488492
function createSpecificVisitors(
489493
cachedMochaCallsByNode: Readonly<WeakMap<Rule.Node, Readonly<CachedMochaCall>>>,
490494
visitors: Readonly<SplitMochaVisitors>

0 commit comments

Comments
 (0)