eslint-node-test 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/configs/core-rule-replacements.js +9 -0
- package/configs/flat-config-base.js +9 -0
- package/index.d.ts +11 -0
- package/index.js +51 -0
- package/license +9 -0
- package/package.json +106 -0
- package/readme.md +143 -0
- package/rules/assertion-arguments.js +134 -0
- package/rules/ast/call-or-new-expression.js +100 -0
- package/rules/ast/function-types.js +7 -0
- package/rules/ast/index.js +17 -0
- package/rules/ast/is-expression-statement.js +7 -0
- package/rules/ast/is-function.js +5 -0
- package/rules/ast/is-loop.js +5 -0
- package/rules/ast/is-member-expression.js +98 -0
- package/rules/ast/is-method-call.js +62 -0
- package/rules/ast/literal.js +32 -0
- package/rules/ast/loop-types.js +9 -0
- package/rules/consistent-modifier-style.js +95 -0
- package/rules/consistent-test-context-name.js +75 -0
- package/rules/consistent-test-filename.js +70 -0
- package/rules/consistent-test-it.js +86 -0
- package/rules/fix/index.js +5 -0
- package/rules/fix/remove-argument.js +58 -0
- package/rules/fix/replace-member-expression-property.js +25 -0
- package/rules/hooks-order.js +132 -0
- package/rules/index.js +66 -0
- package/rules/max-assertions.js +87 -0
- package/rules/max-nested-describe.js +70 -0
- package/rules/no-assert-in-describe.js +51 -0
- package/rules/no-assert-in-hook.js +51 -0
- package/rules/no-assert-throws-async.js +114 -0
- package/rules/no-assert-throws-string.js +65 -0
- package/rules/no-async-describe.js +50 -0
- package/rules/no-async-fn-without-await.js +74 -0
- package/rules/no-callback-and-promise.js +56 -0
- package/rules/no-commented-tests.js +59 -0
- package/rules/no-conditional-assertion.js +101 -0
- package/rules/no-conditional-in-test.js +66 -0
- package/rules/no-conditional-tests.js +75 -0
- package/rules/no-conflicting-modifiers.js +73 -0
- package/rules/no-done-callback.js +58 -0
- package/rules/no-duplicate-hooks.js +75 -0
- package/rules/no-export.js +79 -0
- package/rules/no-identical-assertion-arguments.js +71 -0
- package/rules/no-identical-title.js +101 -0
- package/rules/no-incorrect-deep-equal.js +100 -0
- package/rules/no-incorrect-strict-equal.js +86 -0
- package/rules/no-loop-static-title.js +93 -0
- package/rules/no-misused-concurrency.js +85 -0
- package/rules/no-mock-timers-destructured-import.js +150 -0
- package/rules/no-nested-tests.js +71 -0
- package/rules/no-only-test.js +11 -0
- package/rules/no-skip-test.js +11 -0
- package/rules/no-skip-without-reason.js +88 -0
- package/rules/no-skip-without-return.js +127 -0
- package/rules/no-standalone-assert.js +51 -0
- package/rules/no-test-inside-hook.js +68 -0
- package/rules/no-test-return-statement.js +114 -0
- package/rules/no-todo-test.js +11 -0
- package/rules/no-unawaited-rejects.js +74 -0
- package/rules/no-unawaited-subtest.js +66 -0
- package/rules/no-unknown-test-options.js +77 -0
- package/rules/no-useless-assertion.js +47 -0
- package/rules/prefer-assert-match.js +245 -0
- package/rules/prefer-assert-throws.js +90 -0
- package/rules/prefer-async-await.js +203 -0
- package/rules/prefer-context-mock.js +59 -0
- package/rules/prefer-diagnostic.js +94 -0
- package/rules/prefer-equality-assertion.js +101 -0
- package/rules/prefer-hooks-on-top.js +73 -0
- package/rules/prefer-lowercase-title.js +119 -0
- package/rules/prefer-mock-method.js +115 -0
- package/rules/prefer-strict-assert.js +69 -0
- package/rules/prefer-test-context-assert.js +125 -0
- package/rules/prefer-todo.js +98 -0
- package/rules/require-assertion.js +92 -0
- package/rules/require-await-concurrent-subtests.js +119 -0
- package/rules/require-context-assert-with-plan.js +127 -0
- package/rules/require-hook.js +108 -0
- package/rules/require-throws-expectation.js +52 -0
- package/rules/require-top-level-describe.js +89 -0
- package/rules/rule/index.js +9 -0
- package/rules/rule/to-eslint-create.js +37 -0
- package/rules/rule/to-eslint-listener.js +40 -0
- package/rules/rule/to-eslint-problem.js +38 -0
- package/rules/rule/to-eslint-rule-fixer.js +49 -0
- package/rules/rule/to-eslint-rule.js +38 -0
- package/rules/rule/to-eslint-rules.js +10 -0
- package/rules/rule/unicorn-context.js +36 -0
- package/rules/rule/unicorn-listeners.js +65 -0
- package/rules/rule/utilities.js +26 -0
- package/rules/shared/test-modifier-rule.js +92 -0
- package/rules/test-title-format.js +86 -0
- package/rules/test-title.js +139 -0
- package/rules/utils/contains-suspension-point.js +35 -0
- package/rules/utils/escape-string.js +24 -0
- package/rules/utils/get-comments.js +15 -0
- package/rules/utils/get-documentation-url.js +9 -0
- package/rules/utils/get-enclosing-function.js +18 -0
- package/rules/utils/index.js +16 -0
- package/rules/utils/is-conditional-branch.js +37 -0
- package/rules/utils/is-promise-type.js +28 -0
- package/rules/utils/is-same-reference.js +179 -0
- package/rules/utils/is-value-not-usable.js +5 -0
- package/rules/utils/node-test.js +713 -0
- package/rules/utils/parentheses/get-parent-syntax-opening-parenthesis.js +80 -0
- package/rules/utils/parentheses/iterate-surrounding-parentheses.js +82 -0
- package/rules/utils/parentheses/parentheses.js +69 -0
- package/rules/utils/types.js +5 -0
- package/rules/utils/unwrap-typescript-expression.js +16 -0
- package/rules/valid-describe-callback.js +63 -0
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
import {getStaticValue} from '@eslint-community/eslint-utils';
|
|
2
|
+
import isFunction from '../ast/is-function.js';
|
|
3
|
+
import unwrapTypeScriptExpression from './unwrap-typescript-expression.js';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Detection helpers for Node.js's built-in test runner (`node:test`).
|
|
7
|
+
|
|
8
|
+
Unlike AVA, `node:test` is import-based. Rules first resolve the local names that
|
|
9
|
+
the file imported from `node:test` / `node:assert`, then match calls against them.
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
import test, {describe, it, before} from 'node:test';
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
import {strictEqual} from 'node:assert';
|
|
15
|
+
```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Canonical test/suite/hook function names exported from `node:test`. */
|
|
19
|
+
const TEST_FUNCTIONS = new Set(['test', 'it']);
|
|
20
|
+
const SUITE_FUNCTIONS = new Set(['describe', 'suite']);
|
|
21
|
+
const HOOK_FUNCTIONS = new Set(['before', 'after', 'beforeEach', 'afterEach']);
|
|
22
|
+
const ALL_TEST_EXPORTS = new Set([...TEST_FUNCTIONS, ...SUITE_FUNCTIONS, ...HOOK_FUNCTIONS, 'mock']);
|
|
23
|
+
|
|
24
|
+
export {TEST_FUNCTIONS, SUITE_FUNCTIONS, HOOK_FUNCTIONS};
|
|
25
|
+
|
|
26
|
+
/** Modifier names usable as `test.only()`, `test.skip()`, `test.todo()`. */
|
|
27
|
+
const MODIFIERS = new Set(['only', 'skip', 'todo']);
|
|
28
|
+
export {MODIFIERS};
|
|
29
|
+
|
|
30
|
+
const TEST_MODULES = new Set(['node:test', 'test']);
|
|
31
|
+
const ASSERT_MODULES = new Set(['node:assert', 'node:assert/strict', 'assert', 'assert/strict']);
|
|
32
|
+
|
|
33
|
+
const getRequireSource = node => {
|
|
34
|
+
if (
|
|
35
|
+
node?.type === 'CallExpression'
|
|
36
|
+
&& node.callee.type === 'Identifier'
|
|
37
|
+
&& node.callee.name === 'require'
|
|
38
|
+
&& node.arguments.length === 1
|
|
39
|
+
&& node.arguments[0].type === 'Literal'
|
|
40
|
+
&& typeof node.arguments[0].value === 'string'
|
|
41
|
+
) {
|
|
42
|
+
return node.arguments[0].value;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
Scan a file's top-level imports/requires and resolve the local bindings for
|
|
48
|
+
`node:test` and `node:assert`.
|
|
49
|
+
|
|
50
|
+
@returns {{
|
|
51
|
+
locals: Map<string, string>,
|
|
52
|
+
namespace: string | undefined,
|
|
53
|
+
assertNamespace: Set<string>,
|
|
54
|
+
assertNamed: Map<string, string>,
|
|
55
|
+
strictAssertLocals: Set<string>,
|
|
56
|
+
isTestFile: boolean,
|
|
57
|
+
hasAssert: boolean,
|
|
58
|
+
}}
|
|
59
|
+
*/
|
|
60
|
+
// Cache the result per AST so the many rules sharing this helper only scan the file once.
|
|
61
|
+
const importsCache = new WeakMap();
|
|
62
|
+
|
|
63
|
+
export function resolveImports(context) {
|
|
64
|
+
const {ast} = context.sourceCode;
|
|
65
|
+
const cached = importsCache.get(ast);
|
|
66
|
+
if (cached) {
|
|
67
|
+
return cached;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const result = scanImports(context);
|
|
71
|
+
importsCache.set(ast, result);
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Classify an import source as the test module, the assert module, or neither. */
|
|
76
|
+
function moduleKind(source) {
|
|
77
|
+
if (TEST_MODULES.has(source)) {
|
|
78
|
+
return 'test';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (ASSERT_MODULES.has(source)) {
|
|
82
|
+
return 'assert';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
Record an assert binding. A named import passes the canonical `importedName`; a whole-module
|
|
90
|
+
binding (`import assert from …`, `import * as assert …`, `const assert = require(…)`) passes
|
|
91
|
+
`undefined`. Strict-mode sources also mark the local as already-strict.
|
|
92
|
+
*/
|
|
93
|
+
function addAssertBinding(bindings, localName, importedName, isStrict) {
|
|
94
|
+
if (importedName === undefined) {
|
|
95
|
+
bindings.assertNamespace.add(localName);
|
|
96
|
+
} else {
|
|
97
|
+
bindings.assertNamed.set(localName, importedName);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (isStrict) {
|
|
101
|
+
bindings.strictAssertLocals.add(localName);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Collect bindings from an ESM `import` declaration. */
|
|
106
|
+
function collectFromImport(node, bindings) {
|
|
107
|
+
const {value: source} = node.source;
|
|
108
|
+
const kind = moduleKind(source);
|
|
109
|
+
if (!kind) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const isStrict = source.endsWith('/strict');
|
|
114
|
+
|
|
115
|
+
for (const specifier of node.specifiers) {
|
|
116
|
+
const localName = specifier.local.name;
|
|
117
|
+
|
|
118
|
+
// Named import: `import {describe} from 'node:test'` / `import {strictEqual} from 'node:assert'`.
|
|
119
|
+
if (specifier.type === 'ImportSpecifier') {
|
|
120
|
+
if (specifier.imported.type !== 'Identifier') {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (kind === 'assert') {
|
|
125
|
+
addAssertBinding(bindings, localName, specifier.imported.name, isStrict);
|
|
126
|
+
} else if (ALL_TEST_EXPORTS.has(specifier.imported.name)) {
|
|
127
|
+
bindings.locals.set(localName, specifier.imported.name);
|
|
128
|
+
}
|
|
129
|
+
} else if (kind === 'assert') {
|
|
130
|
+
// Default or namespace import of the whole assert module.
|
|
131
|
+
addAssertBinding(bindings, localName, undefined, isStrict);
|
|
132
|
+
} else if (specifier.type === 'ImportDefaultSpecifier') {
|
|
133
|
+
// `import test from 'node:test'` -> the default export is the callable `test` function, which
|
|
134
|
+
// also exposes the named exports as properties. Bind it as both the `test` local (bare
|
|
135
|
+
// `test(…)`, `test.only(…)`) and a namespace (`test.describe(…)`), like the CJS bare require.
|
|
136
|
+
bindings.locals.set(localName, 'test');
|
|
137
|
+
bindings.namespace = localName;
|
|
138
|
+
} else {
|
|
139
|
+
// `import * as nodeTest from 'node:test'`.
|
|
140
|
+
bindings.namespace = localName;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Collect bindings from CommonJS `require` declarations (`const {test} = require('node:test')`). */
|
|
146
|
+
function collectFromRequire(node, bindings) {
|
|
147
|
+
for (const declarator of node.declarations) {
|
|
148
|
+
const source = getRequireSource(declarator.init);
|
|
149
|
+
const kind = source ? moduleKind(source) : undefined;
|
|
150
|
+
if (!kind) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const isStrict = source.endsWith('/strict');
|
|
155
|
+
|
|
156
|
+
// `const test = require('node:test')` / `const assert = require('node:assert')`.
|
|
157
|
+
if (declarator.id.type === 'Identifier') {
|
|
158
|
+
if (kind === 'assert') {
|
|
159
|
+
addAssertBinding(bindings, declarator.id.name, undefined, isStrict);
|
|
160
|
+
} else {
|
|
161
|
+
// `require('node:test')` returns the callable `test` function, which also exposes the
|
|
162
|
+
// named exports as properties. So the binding is both the `test` local (bare `test(…)`,
|
|
163
|
+
// `test.only(…)`) and a namespace (`test.describe(…)`), mirroring `import test from 'node:test'`.
|
|
164
|
+
bindings.locals.set(declarator.id.name, 'test');
|
|
165
|
+
bindings.namespace = declarator.id.name;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (declarator.id.type !== 'ObjectPattern') {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
for (const property of declarator.id.properties) {
|
|
176
|
+
if (
|
|
177
|
+
property.type !== 'Property'
|
|
178
|
+
|| property.computed
|
|
179
|
+
|| property.key.type !== 'Identifier'
|
|
180
|
+
|| property.value.type !== 'Identifier'
|
|
181
|
+
) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (kind === 'assert') {
|
|
186
|
+
addAssertBinding(bindings, property.value.name, property.key.name, isStrict);
|
|
187
|
+
} else if (ALL_TEST_EXPORTS.has(property.key.name)) {
|
|
188
|
+
bindings.locals.set(property.value.name, property.key.name);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function scanImports(context) {
|
|
195
|
+
const bindings = {
|
|
196
|
+
// Map of local identifier name -> canonical `node:test` export name.
|
|
197
|
+
locals: new Map(),
|
|
198
|
+
// `import * as nodeTest from 'node:test'` -> namespace local name.
|
|
199
|
+
namespace: undefined,
|
|
200
|
+
// Local names bound to the whole `node:assert` module (`import assert from …`).
|
|
201
|
+
assertNamespace: new Set(),
|
|
202
|
+
// Map of local name -> canonical `node:assert` method name (named imports).
|
|
203
|
+
assertNamed: new Map(),
|
|
204
|
+
// Local names bound to a strict-mode assert module (`node:assert/strict`), where
|
|
205
|
+
// the legacy methods (`equal`/`deepEqual`/…) already behave as their strict counterparts.
|
|
206
|
+
strictAssertLocals: new Set(),
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
for (const node of context.sourceCode.ast.body) {
|
|
210
|
+
if (node.type === 'ImportDeclaration' && typeof node.source.value === 'string') {
|
|
211
|
+
collectFromImport(node, bindings);
|
|
212
|
+
} else if (node.type === 'VariableDeclaration') {
|
|
213
|
+
collectFromRequire(node, bindings);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const {locals, namespace, assertNamespace, assertNamed} = bindings;
|
|
218
|
+
// The file imports test/suite/hook bindings from `node:test`.
|
|
219
|
+
const isTestFile = locals.size > 0 || namespace !== undefined;
|
|
220
|
+
// The file imports anything from `node:assert`.
|
|
221
|
+
const hasAssert = assertNamespace.size > 0 || assertNamed.size > 0;
|
|
222
|
+
return {
|
|
223
|
+
...bindings,
|
|
224
|
+
// Local names bound to the `mock` export (`import {mock} from 'node:test'`, renamed too).
|
|
225
|
+
mockLocals: new Set([...locals].filter(([, canonical]) => canonical === 'mock').map(([local]) => local)),
|
|
226
|
+
isTestFile,
|
|
227
|
+
hasAssert,
|
|
228
|
+
// Assertion rules activate on either: a `node:assert` import, or a test file (where `t.assert.*`
|
|
229
|
+
// works without importing `node:assert`).
|
|
230
|
+
isAssertOrTestFile: hasAssert || isTestFile,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Whether a node references the global `mock` — a named/renamed import or `namespace.mock`. */
|
|
235
|
+
export function isGlobalMock(node, imports) {
|
|
236
|
+
return (
|
|
237
|
+
(node.type === 'Identifier' && imports.mockLocals.has(node.name))
|
|
238
|
+
|| (
|
|
239
|
+
node.type === 'MemberExpression'
|
|
240
|
+
&& !node.computed
|
|
241
|
+
&& node.property.type === 'Identifier'
|
|
242
|
+
&& node.property.name === 'mock'
|
|
243
|
+
&& node.object.type === 'Identifier'
|
|
244
|
+
&& node.object.name === imports.namespace
|
|
245
|
+
)
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
Walk a callee chain into its root identifier and the member property nodes after it.
|
|
251
|
+
|
|
252
|
+
@returns {{root: import('estree').Identifier, members: import('estree').Identifier[]} | undefined}
|
|
253
|
+
*/
|
|
254
|
+
function getCalleeChain(node) {
|
|
255
|
+
if (node.type === 'Identifier') {
|
|
256
|
+
return {root: node, members: []};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (
|
|
260
|
+
node.type === 'MemberExpression'
|
|
261
|
+
&& !node.computed
|
|
262
|
+
&& node.property.type === 'Identifier'
|
|
263
|
+
) {
|
|
264
|
+
const inner = getCalleeChain(node.object);
|
|
265
|
+
if (!inner) {
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return {root: inner.root, members: [...inner.members, node.property]};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Classify a canonical export name as a test, suite, or hook (or `undefined`). */
|
|
276
|
+
function getCallKind(name) {
|
|
277
|
+
if (TEST_FUNCTIONS.has(name)) {
|
|
278
|
+
return 'test';
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (SUITE_FUNCTIONS.has(name)) {
|
|
282
|
+
return 'suite';
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (HOOK_FUNCTIONS.has(name)) {
|
|
286
|
+
return 'hook';
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/*
|
|
293
|
+
Memoize the `parse*Call` classifiers by node. The same `CallExpression` is parsed by many rules
|
|
294
|
+
during one lint run (34 rules call `parseTestCall`, 21 call `parseAssertionCall`), and `imports`
|
|
295
|
+
is stable per file (it is itself cached per AST), so the first parse can be reused across all of
|
|
296
|
+
them. Keyed by node with an `imports` guard for safety.
|
|
297
|
+
|
|
298
|
+
The cached result object is shared between callers, so treat it as read-only — never mutate the
|
|
299
|
+
returned `modifiers` array or reassign its fields.
|
|
300
|
+
*/
|
|
301
|
+
const parseTestCallCache = new WeakMap();
|
|
302
|
+
const parseAssertionCallCache = new WeakMap();
|
|
303
|
+
|
|
304
|
+
const memoizeByNode = (cache, compute) => (callExpression, imports) => {
|
|
305
|
+
if (callExpression.type !== 'CallExpression') {
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const cached = cache.get(callExpression);
|
|
310
|
+
if (cached && cached.imports === imports) {
|
|
311
|
+
return cached.result;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const result = compute(callExpression, imports);
|
|
315
|
+
cache.set(callExpression, {imports, result});
|
|
316
|
+
return result;
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
Classify a `CallExpression` as a `node:test` test/suite/hook call.
|
|
321
|
+
|
|
322
|
+
@returns {{
|
|
323
|
+
name: string,
|
|
324
|
+
kind: 'test' | 'suite' | 'hook',
|
|
325
|
+
modifiers: import('estree').Identifier[],
|
|
326
|
+
} | undefined}
|
|
327
|
+
*/
|
|
328
|
+
export const parseTestCall = memoizeByNode(parseTestCallCache, (callExpression, imports) => {
|
|
329
|
+
const chain = getCalleeChain(callExpression.callee);
|
|
330
|
+
if (!chain) {
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const {root, members} = chain;
|
|
335
|
+
|
|
336
|
+
let name;
|
|
337
|
+
let modifiers;
|
|
338
|
+
|
|
339
|
+
if (
|
|
340
|
+
imports.namespace
|
|
341
|
+
&& root.name === imports.namespace
|
|
342
|
+
&& members.length > 0
|
|
343
|
+
&& ALL_TEST_EXPORTS.has(members[0].name)
|
|
344
|
+
) {
|
|
345
|
+
// `nodeTest.test.only(…)` — namespace member access into a known export.
|
|
346
|
+
const [first, ...rest] = members;
|
|
347
|
+
name = first.name;
|
|
348
|
+
modifiers = rest;
|
|
349
|
+
} else if (imports.locals.has(root.name)) {
|
|
350
|
+
// `test.only(…)` / bare `test(…)` — a callable test binding. A binding that is both a local and
|
|
351
|
+
// the namespace (CJS `const test = require('node:test')`) reaches here for member chains whose
|
|
352
|
+
// first segment is not a known export, e.g. `test.only(…)`.
|
|
353
|
+
name = imports.locals.get(root.name);
|
|
354
|
+
modifiers = members;
|
|
355
|
+
} else {
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const kind = getCallKind(name);
|
|
360
|
+
if (!kind) {
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return {name, kind, modifiers};
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
/** Get the modifier identifier node with the given name (`only`/`skip`/`todo`), or `undefined`. */
|
|
368
|
+
export const findModifier = (modifiers, name) => modifiers.find(modifier => modifier.name === name);
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
For a subtest-shaped call (`receiver.test(…)`, optionally with chained `.only`/`.skip`/`.todo`
|
|
372
|
+
modifiers), return the receiver identifier node. Otherwise `undefined`.
|
|
373
|
+
*/
|
|
374
|
+
export function getSubtestReceiver(callExpression) {
|
|
375
|
+
if (callExpression.type !== 'CallExpression') {
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const chain = getCalleeChain(callExpression.callee);
|
|
380
|
+
if (
|
|
381
|
+
chain
|
|
382
|
+
&& chain.members[0]?.name === 'test'
|
|
383
|
+
&& chain.members.slice(1).every(member => MODIFIERS.has(member.name))
|
|
384
|
+
) {
|
|
385
|
+
return chain.root;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return undefined;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
Track the test-context parameter names (`t`) introduced by enclosing test and subtest callbacks.
|
|
393
|
+
|
|
394
|
+
Subtests (`t.test(…)`) are method calls, not imported bindings, so recognizing them requires
|
|
395
|
+
knowing the enclosing context name. Drive the tracker from a `CallExpression` visitor: query
|
|
396
|
+
`isSubtestCall`/`isContextName` first (against the current stack), then call `update(node)` to
|
|
397
|
+
push this call's own context, and `leave(node)` on exit.
|
|
398
|
+
|
|
399
|
+
@returns {{
|
|
400
|
+
isSubtestCall: (node: import('estree').Node) => boolean,
|
|
401
|
+
isContextName: (name: string | undefined) => boolean,
|
|
402
|
+
current: () => string | undefined,
|
|
403
|
+
update: (node: import('estree').Node) => void,
|
|
404
|
+
leave: (node: import('estree').Node) => void,
|
|
405
|
+
}}
|
|
406
|
+
*/
|
|
407
|
+
export function createContextTracker(imports) {
|
|
408
|
+
const names = [];
|
|
409
|
+
const callbacks = [];
|
|
410
|
+
const pushedCalls = new Set();
|
|
411
|
+
|
|
412
|
+
const isSubtestCall = node => {
|
|
413
|
+
const receiver = getSubtestReceiver(node);
|
|
414
|
+
return receiver !== undefined && names.includes(receiver.name);
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
isSubtestCall,
|
|
419
|
+
isContextName: name => name !== undefined && names.includes(name),
|
|
420
|
+
// The name of the innermost enclosing test/subtest context, or `undefined` when its
|
|
421
|
+
// callback declared no context parameter (or we are not inside a test).
|
|
422
|
+
current: () => names.at(-1),
|
|
423
|
+
// The callback function node of the innermost enclosing test/subtest. The context parameter is
|
|
424
|
+
// only in scope inside this node, so a node visited in the call's title/options arguments (which
|
|
425
|
+
// the traversal reaches before the callback) is not actually within the context's scope.
|
|
426
|
+
currentCallback: () => callbacks.at(-1),
|
|
427
|
+
update(node) {
|
|
428
|
+
if (parseTestCall(node, imports)?.kind === 'test' || isSubtestCall(node)) {
|
|
429
|
+
const callback = getTestCallback(node);
|
|
430
|
+
if (callback) {
|
|
431
|
+
const parameter = callback.params[0];
|
|
432
|
+
names.push(parameter?.type === 'Identifier' ? parameter.name : undefined);
|
|
433
|
+
callbacks.push(callback);
|
|
434
|
+
pushedCalls.add(node);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
},
|
|
438
|
+
leave(node) {
|
|
439
|
+
if (pushedCalls.has(node)) {
|
|
440
|
+
pushedCalls.delete(node);
|
|
441
|
+
names.pop();
|
|
442
|
+
callbacks.pop();
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
Track the nesting depth of enclosing `describe`/`suite` blocks across a `CallExpression` visitor.
|
|
450
|
+
|
|
451
|
+
`depth` reflects the suites currently on the stack. Call `enterSuite(node)` once a call has been
|
|
452
|
+
classified as a suite, and `exitSuite(node)` from the matching `CallExpression:exit` listener (it
|
|
453
|
+
ignores nodes that were never entered, so it is safe to call for every exit). Reading `depth` before
|
|
454
|
+
`enterSuite` gives the enclosing depth; reading it after includes the just-entered suite.
|
|
455
|
+
|
|
456
|
+
@returns {{depth: number, enterSuite: (node: import('estree').Node) => void, exitSuite: (node: import('estree').Node) => void}}
|
|
457
|
+
*/
|
|
458
|
+
export function createSuiteDepthTracker() {
|
|
459
|
+
const suiteCalls = new Set();
|
|
460
|
+
let depth = 0;
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
get depth() {
|
|
464
|
+
return depth;
|
|
465
|
+
},
|
|
466
|
+
enterSuite(node) {
|
|
467
|
+
depth += 1;
|
|
468
|
+
suiteCalls.add(node);
|
|
469
|
+
},
|
|
470
|
+
exitSuite(node) {
|
|
471
|
+
if (suiteCalls.has(node)) {
|
|
472
|
+
suiteCalls.delete(node);
|
|
473
|
+
depth -= 1;
|
|
474
|
+
}
|
|
475
|
+
},
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
Get the title argument node of a test/suite call, if its first argument is a static string.
|
|
481
|
+
*/
|
|
482
|
+
export function getTestTitle(callExpression, context) {
|
|
483
|
+
const {sourceCode} = context;
|
|
484
|
+
const first = unwrapTypeScriptExpression(callExpression.arguments[0]);
|
|
485
|
+
if (!first) {
|
|
486
|
+
return undefined;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (first.type === 'Literal' && typeof first.value === 'string') {
|
|
490
|
+
return first;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (first.type === 'TemplateLiteral') {
|
|
494
|
+
return first;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const staticValue = getStaticValue(first, sourceCode.getScope(first));
|
|
498
|
+
return typeof staticValue?.value === 'string' ? first : undefined;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Get the static string value of a node, if it resolves to one. */
|
|
502
|
+
export function getStaticString(node, context) {
|
|
503
|
+
if (!node) {
|
|
504
|
+
return undefined;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const {sourceCode} = context;
|
|
508
|
+
node = unwrapTypeScriptExpression(node);
|
|
509
|
+
|
|
510
|
+
if (node.type === 'Literal' && typeof node.value === 'string') {
|
|
511
|
+
return node.value;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {
|
|
515
|
+
return node.quasis[0].value.cooked ?? undefined;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const staticValue = getStaticValue(node, sourceCode.getScope(node));
|
|
519
|
+
return typeof staticValue?.value === 'string' ? staticValue.value : undefined;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
Get the inline function implementation argument of a test/suite/hook call, if any.
|
|
524
|
+
`node:test` signature is `(name?, options?, fn?)`, so the implementation is the last argument.
|
|
525
|
+
*/
|
|
526
|
+
export function getTestCallback(callExpression) {
|
|
527
|
+
for (let index = callExpression.arguments.length - 1; index >= 0; index -= 1) {
|
|
528
|
+
const argument = unwrapTypeScriptExpression(callExpression.arguments[index]);
|
|
529
|
+
if (isFunction(argument)) {
|
|
530
|
+
return argument;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Stop at the first non-function trailing argument (options/title).
|
|
534
|
+
if (argument.type !== 'SpreadElement') {
|
|
535
|
+
return undefined;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return undefined;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/*
|
|
543
|
+
The number of parameters before the first default or rest parameter — the same value as
|
|
544
|
+
`Function.prototype.length`. `node:test` uses this arity to decide whether to pass a `done`
|
|
545
|
+
callback, so a declared second parameter means the function opted into callback style.
|
|
546
|
+
*/
|
|
547
|
+
export function getEffectiveArity(parameters) {
|
|
548
|
+
let count = 0;
|
|
549
|
+
for (const parameter of parameters) {
|
|
550
|
+
if (parameter.type === 'AssignmentPattern' || parameter.type === 'RestElement') {
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
count += 1;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return count;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Get the options `ObjectExpression` argument of a test/suite/hook call, if any. */
|
|
561
|
+
export function getTestOptions(callExpression) {
|
|
562
|
+
for (const argument of callExpression.arguments) {
|
|
563
|
+
const unwrapped = unwrapTypeScriptExpression(argument);
|
|
564
|
+
if (unwrapped.type === 'ObjectExpression') {
|
|
565
|
+
return unwrapped;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return undefined;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** Find a boolean-ish property (`only`/`skip`/`todo`) in an options object. */
|
|
573
|
+
export function findOptionsProperty(optionsObject, name) {
|
|
574
|
+
if (optionsObject?.type !== 'ObjectExpression') {
|
|
575
|
+
return undefined;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return optionsObject.properties.find(property =>
|
|
579
|
+
property.type === 'Property'
|
|
580
|
+
&& !property.computed
|
|
581
|
+
&& (
|
|
582
|
+
(property.key.type === 'Identifier' && property.key.name === name)
|
|
583
|
+
|| (property.key.type === 'Literal' && property.key.value === name)
|
|
584
|
+
));
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
Find an options property (`only`/`skip`/`todo`) that is set to an *enabled* value, i.e. present
|
|
589
|
+
and not a statically-falsy literal. `node:test` checks these options for truthiness, so `false`,
|
|
590
|
+
`null`, `0`, and `''` all mean disabled; a truthy literal (`true`), a skip/todo reason string, or
|
|
591
|
+
a dynamic value all count as enabled. Returns the property node, or `undefined`.
|
|
592
|
+
*/
|
|
593
|
+
export function findEnabledOptionsProperty(optionsObject, name) {
|
|
594
|
+
const property = findOptionsProperty(optionsObject, name);
|
|
595
|
+
if (property && !(property.value.type === 'Literal' && !property.value.value)) {
|
|
596
|
+
return property;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return undefined;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
Determine the kind (`test`/`suite`/`hook`) of the nearest enclosing test-related callback.
|
|
604
|
+
|
|
605
|
+
Returns `undefined` when the nearest enclosing function is a regular function (e.g. a helper),
|
|
606
|
+
or there is none. Subtests (`t.test(…)`) are method calls rather than imported bindings, so they
|
|
607
|
+
are recognized structurally and classified as `'test'`.
|
|
608
|
+
*/
|
|
609
|
+
export function nearestTestCallbackKind(node, imports) {
|
|
610
|
+
let current = node.parent;
|
|
611
|
+
while (current) {
|
|
612
|
+
if (isFunction(current)) {
|
|
613
|
+
const call = current.parent;
|
|
614
|
+
if (call?.type === 'CallExpression' && getTestCallback(call) === current) {
|
|
615
|
+
const parsed = parseTestCall(call, imports);
|
|
616
|
+
if (parsed) {
|
|
617
|
+
return parsed.kind;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// A subtest (`t.test(…)`) is a method call, not an imported binding.
|
|
621
|
+
const {callee} = call;
|
|
622
|
+
if (
|
|
623
|
+
callee.type === 'MemberExpression'
|
|
624
|
+
&& !callee.computed
|
|
625
|
+
&& callee.property.type === 'Identifier'
|
|
626
|
+
&& callee.property.name === 'test'
|
|
627
|
+
) {
|
|
628
|
+
return 'test';
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Inside some other function — not directly in a test/suite/hook body.
|
|
633
|
+
return undefined;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
current = current.parent;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return undefined;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
Classify a `CallExpression` as a `node:assert` assertion call.
|
|
644
|
+
|
|
645
|
+
Matches:
|
|
646
|
+
- `assert.strictEqual(…)` / `assert(…)` (namespace import)
|
|
647
|
+
- `strictEqual(…)` (named import)
|
|
648
|
+
- `t.assert.strictEqual(…)` (`TestContext#assert`)
|
|
649
|
+
|
|
650
|
+
`methodNode` is the identifier node holding the method name, which fixers rewrite. It is the callee
|
|
651
|
+
itself for a named import, the property for the member forms, and `undefined` for bare `assert(…)`
|
|
652
|
+
(which has no method identifier). `isStrict` is `true` when the binding resolves to a strict-mode
|
|
653
|
+
assert module (`node:assert/strict`), where the legacy methods already behave strictly.
|
|
654
|
+
|
|
655
|
+
@returns {{method: string, methodNode: import('estree').Node | undefined, isStrict: boolean}|undefined}
|
|
656
|
+
*/
|
|
657
|
+
export const parseAssertionCall = memoizeByNode(parseAssertionCallCache, (callExpression, imports) => {
|
|
658
|
+
const {callee} = callExpression;
|
|
659
|
+
|
|
660
|
+
// `strictEqual(…)` — named import.
|
|
661
|
+
if (callee.type === 'Identifier' && imports.assertNamed.has(callee.name)) {
|
|
662
|
+
return {
|
|
663
|
+
method: imports.assertNamed.get(callee.name),
|
|
664
|
+
methodNode: callee,
|
|
665
|
+
isStrict: imports.strictAssertLocals.has(callee.name),
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (callee.type === 'Identifier' && imports.assertNamespace.has(callee.name)) {
|
|
670
|
+
// `assert(value)` — the bare assert function (alias of `ok`); no method identifier to rewrite.
|
|
671
|
+
return {
|
|
672
|
+
method: 'ok',
|
|
673
|
+
methodNode: undefined,
|
|
674
|
+
isStrict: imports.strictAssertLocals.has(callee.name),
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (
|
|
679
|
+
callee.type === 'MemberExpression'
|
|
680
|
+
&& !callee.computed
|
|
681
|
+
&& callee.property.type === 'Identifier'
|
|
682
|
+
) {
|
|
683
|
+
const {object} = callee;
|
|
684
|
+
|
|
685
|
+
// `assert.strictEqual(…)`
|
|
686
|
+
if (object.type === 'Identifier' && imports.assertNamespace.has(object.name)) {
|
|
687
|
+
return {
|
|
688
|
+
method: callee.property.name,
|
|
689
|
+
methodNode: callee.property,
|
|
690
|
+
isStrict: imports.strictAssertLocals.has(object.name),
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// `t.assert.strictEqual(…)` — `t.assert` is always loose mode. The receiver must be a plain
|
|
695
|
+
// identifier (a test context parameter); deeper chains like `a.b.assert.equal(…)`, `this.assert`,
|
|
696
|
+
// or `foo().assert` are unrelated objects that merely have an `assert` property.
|
|
697
|
+
if (
|
|
698
|
+
object.type === 'MemberExpression'
|
|
699
|
+
&& !object.computed
|
|
700
|
+
&& object.object.type === 'Identifier'
|
|
701
|
+
&& object.property.type === 'Identifier'
|
|
702
|
+
&& object.property.name === 'assert'
|
|
703
|
+
) {
|
|
704
|
+
return {
|
|
705
|
+
method: callee.property.name,
|
|
706
|
+
methodNode: callee.property,
|
|
707
|
+
isStrict: false,
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
return undefined;
|
|
713
|
+
});
|