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.
Files changed (112) hide show
  1. package/configs/core-rule-replacements.js +9 -0
  2. package/configs/flat-config-base.js +9 -0
  3. package/index.d.ts +11 -0
  4. package/index.js +51 -0
  5. package/license +9 -0
  6. package/package.json +106 -0
  7. package/readme.md +143 -0
  8. package/rules/assertion-arguments.js +134 -0
  9. package/rules/ast/call-or-new-expression.js +100 -0
  10. package/rules/ast/function-types.js +7 -0
  11. package/rules/ast/index.js +17 -0
  12. package/rules/ast/is-expression-statement.js +7 -0
  13. package/rules/ast/is-function.js +5 -0
  14. package/rules/ast/is-loop.js +5 -0
  15. package/rules/ast/is-member-expression.js +98 -0
  16. package/rules/ast/is-method-call.js +62 -0
  17. package/rules/ast/literal.js +32 -0
  18. package/rules/ast/loop-types.js +9 -0
  19. package/rules/consistent-modifier-style.js +95 -0
  20. package/rules/consistent-test-context-name.js +75 -0
  21. package/rules/consistent-test-filename.js +70 -0
  22. package/rules/consistent-test-it.js +86 -0
  23. package/rules/fix/index.js +5 -0
  24. package/rules/fix/remove-argument.js +58 -0
  25. package/rules/fix/replace-member-expression-property.js +25 -0
  26. package/rules/hooks-order.js +132 -0
  27. package/rules/index.js +66 -0
  28. package/rules/max-assertions.js +87 -0
  29. package/rules/max-nested-describe.js +70 -0
  30. package/rules/no-assert-in-describe.js +51 -0
  31. package/rules/no-assert-in-hook.js +51 -0
  32. package/rules/no-assert-throws-async.js +114 -0
  33. package/rules/no-assert-throws-string.js +65 -0
  34. package/rules/no-async-describe.js +50 -0
  35. package/rules/no-async-fn-without-await.js +74 -0
  36. package/rules/no-callback-and-promise.js +56 -0
  37. package/rules/no-commented-tests.js +59 -0
  38. package/rules/no-conditional-assertion.js +101 -0
  39. package/rules/no-conditional-in-test.js +66 -0
  40. package/rules/no-conditional-tests.js +75 -0
  41. package/rules/no-conflicting-modifiers.js +73 -0
  42. package/rules/no-done-callback.js +58 -0
  43. package/rules/no-duplicate-hooks.js +75 -0
  44. package/rules/no-export.js +79 -0
  45. package/rules/no-identical-assertion-arguments.js +71 -0
  46. package/rules/no-identical-title.js +101 -0
  47. package/rules/no-incorrect-deep-equal.js +100 -0
  48. package/rules/no-incorrect-strict-equal.js +86 -0
  49. package/rules/no-loop-static-title.js +93 -0
  50. package/rules/no-misused-concurrency.js +85 -0
  51. package/rules/no-mock-timers-destructured-import.js +150 -0
  52. package/rules/no-nested-tests.js +71 -0
  53. package/rules/no-only-test.js +11 -0
  54. package/rules/no-skip-test.js +11 -0
  55. package/rules/no-skip-without-reason.js +88 -0
  56. package/rules/no-skip-without-return.js +127 -0
  57. package/rules/no-standalone-assert.js +51 -0
  58. package/rules/no-test-inside-hook.js +68 -0
  59. package/rules/no-test-return-statement.js +114 -0
  60. package/rules/no-todo-test.js +11 -0
  61. package/rules/no-unawaited-rejects.js +74 -0
  62. package/rules/no-unawaited-subtest.js +66 -0
  63. package/rules/no-unknown-test-options.js +77 -0
  64. package/rules/no-useless-assertion.js +47 -0
  65. package/rules/prefer-assert-match.js +245 -0
  66. package/rules/prefer-assert-throws.js +90 -0
  67. package/rules/prefer-async-await.js +203 -0
  68. package/rules/prefer-context-mock.js +59 -0
  69. package/rules/prefer-diagnostic.js +94 -0
  70. package/rules/prefer-equality-assertion.js +101 -0
  71. package/rules/prefer-hooks-on-top.js +73 -0
  72. package/rules/prefer-lowercase-title.js +119 -0
  73. package/rules/prefer-mock-method.js +115 -0
  74. package/rules/prefer-strict-assert.js +69 -0
  75. package/rules/prefer-test-context-assert.js +125 -0
  76. package/rules/prefer-todo.js +98 -0
  77. package/rules/require-assertion.js +92 -0
  78. package/rules/require-await-concurrent-subtests.js +119 -0
  79. package/rules/require-context-assert-with-plan.js +127 -0
  80. package/rules/require-hook.js +108 -0
  81. package/rules/require-throws-expectation.js +52 -0
  82. package/rules/require-top-level-describe.js +89 -0
  83. package/rules/rule/index.js +9 -0
  84. package/rules/rule/to-eslint-create.js +37 -0
  85. package/rules/rule/to-eslint-listener.js +40 -0
  86. package/rules/rule/to-eslint-problem.js +38 -0
  87. package/rules/rule/to-eslint-rule-fixer.js +49 -0
  88. package/rules/rule/to-eslint-rule.js +38 -0
  89. package/rules/rule/to-eslint-rules.js +10 -0
  90. package/rules/rule/unicorn-context.js +36 -0
  91. package/rules/rule/unicorn-listeners.js +65 -0
  92. package/rules/rule/utilities.js +26 -0
  93. package/rules/shared/test-modifier-rule.js +92 -0
  94. package/rules/test-title-format.js +86 -0
  95. package/rules/test-title.js +139 -0
  96. package/rules/utils/contains-suspension-point.js +35 -0
  97. package/rules/utils/escape-string.js +24 -0
  98. package/rules/utils/get-comments.js +15 -0
  99. package/rules/utils/get-documentation-url.js +9 -0
  100. package/rules/utils/get-enclosing-function.js +18 -0
  101. package/rules/utils/index.js +16 -0
  102. package/rules/utils/is-conditional-branch.js +37 -0
  103. package/rules/utils/is-promise-type.js +28 -0
  104. package/rules/utils/is-same-reference.js +179 -0
  105. package/rules/utils/is-value-not-usable.js +5 -0
  106. package/rules/utils/node-test.js +713 -0
  107. package/rules/utils/parentheses/get-parent-syntax-opening-parenthesis.js +80 -0
  108. package/rules/utils/parentheses/iterate-surrounding-parentheses.js +82 -0
  109. package/rules/utils/parentheses/parentheses.js +69 -0
  110. package/rules/utils/types.js +5 -0
  111. package/rules/utils/unwrap-typescript-expression.js +16 -0
  112. package/rules/valid-describe-callback.js +63 -0
@@ -0,0 +1,115 @@
1
+ import {resolveImports, createContextTracker, isGlobalMock} from './utils/node-test.js';
2
+ import {isValueNotUsable} from './utils/index.js';
3
+
4
+ const MESSAGE_ID_ERROR = 'prefer-mock-method/error';
5
+ const MESSAGE_ID_SUGGESTION = 'prefer-mock-method/suggestion';
6
+
7
+ const messages = {
8
+ [MESSAGE_ID_ERROR]: 'Prefer `{{base}}.method()` over assigning `{{base}}.fn()` to a property, so the original method is tracked and can be restored.',
9
+ [MESSAGE_ID_SUGGESTION]: 'Replace with `{{base}}.method()`.',
10
+ };
11
+
12
+ /** @param {import('eslint').Rule.RuleContext} context */
13
+ const create = context => {
14
+ const {sourceCode} = context;
15
+ const imports = resolveImports(context);
16
+ if (!imports.isTestFile) {
17
+ return;
18
+ }
19
+
20
+ const tracker = createContextTracker(imports);
21
+
22
+ // The context `<ctx>.mock`.
23
+ const isContextMock = node =>
24
+ node.type === 'MemberExpression'
25
+ && !node.computed
26
+ && node.property.type === 'Identifier'
27
+ && node.property.name === 'mock'
28
+ && node.object.type === 'Identifier'
29
+ && tracker.isContextName(node.object.name);
30
+
31
+ // Keep the context-name stack in sync as we enter and leave test callbacks.
32
+ context.on('CallExpression', node => {
33
+ tracker.update(node);
34
+ });
35
+ context.onExit('CallExpression', node => {
36
+ tracker.leave(node);
37
+ });
38
+
39
+ context.on('AssignmentExpression', node => {
40
+ if (node.operator !== '=' || node.left.type !== 'MemberExpression' || node.right.type !== 'CallExpression') {
41
+ return;
42
+ }
43
+
44
+ const {callee} = node.right;
45
+ if (
46
+ callee.type !== 'MemberExpression'
47
+ || callee.computed
48
+ || callee.property.type !== 'Identifier'
49
+ || callee.property.name !== 'fn'
50
+ || (!isGlobalMock(callee.object, imports) && !isContextMock(callee.object))
51
+ ) {
52
+ return;
53
+ }
54
+
55
+ const base = sourceCode.getText(callee.object);
56
+ const problem = {
57
+ node,
58
+ messageId: MESSAGE_ID_ERROR,
59
+ data: {base},
60
+ };
61
+
62
+ const {left} = node;
63
+ const mockArguments = node.right.arguments;
64
+
65
+ // Resolve the property name to a `mock.method` second argument.
66
+ let key;
67
+ if (!left.computed && left.property.type === 'Identifier') {
68
+ key = `'${left.property.name}'`;
69
+ } else if (left.computed) {
70
+ key = sourceCode.getText(left.property);
71
+ }
72
+
73
+ // Only suggest a rewrite for the simple cases: a resolvable key, at most one argument (the
74
+ // implementation, which becomes `mock.method`'s third argument), and no inner comments to drop.
75
+ // `<obj>.method = mock.fn()` evaluates to the mock function, but `mock.method(…)` returns the
76
+ // original method, so skip the suggestion when the assignment's value is used.
77
+ const canRewrite = key !== undefined
78
+ && mockArguments.length <= 1
79
+ && isValueNotUsable(node)
80
+ && sourceCode.getCommentsInside(node).length === 0;
81
+
82
+ if (canRewrite) {
83
+ const objectText = sourceCode.getText(left.object);
84
+ const implementation = mockArguments.length === 1 ? `, ${sourceCode.getText(mockArguments[0])}` : '';
85
+ const replacement = `${base}.method(${objectText}, ${key}${implementation})`;
86
+ problem.suggest = [
87
+ {
88
+ messageId: MESSAGE_ID_SUGGESTION,
89
+ data: {base},
90
+ fix: fixer => fixer.replaceText(node, replacement),
91
+ },
92
+ ];
93
+ }
94
+
95
+ return problem;
96
+ });
97
+ };
98
+
99
+ /** @type {import('eslint').Rule.RuleModule} */
100
+ const config = {
101
+ create,
102
+ meta: {
103
+ type: 'suggestion',
104
+ docs: {
105
+ description: 'Prefer `mock.method()` over assigning `mock.fn()` to an object property.',
106
+ recommended: true,
107
+ },
108
+ hasSuggestions: true,
109
+ schema: [],
110
+ messages,
111
+ languages: ['js/js'],
112
+ },
113
+ };
114
+
115
+ export default config;
@@ -0,0 +1,69 @@
1
+ import {resolveImports, parseAssertionCall} from './utils/node-test.js';
2
+
3
+ const MESSAGE_ID = 'prefer-strict-assert';
4
+
5
+ const messages = {
6
+ [MESSAGE_ID]: 'Prefer `{{replacement}}` over the legacy loose `{{method}}`.',
7
+ };
8
+
9
+ // Legacy loose (`==`) assertion methods and their strict equivalents.
10
+ const LOOSE_TO_STRICT = new Map([
11
+ ['equal', 'strictEqual'],
12
+ ['notEqual', 'notStrictEqual'],
13
+ ['deepEqual', 'deepStrictEqual'],
14
+ ['notDeepEqual', 'notDeepStrictEqual'],
15
+ ]);
16
+
17
+ /** @param {import('eslint').Rule.RuleContext} context */
18
+ const create = context => {
19
+ const imports = resolveImports(context);
20
+ if (!imports.isAssertOrTestFile) {
21
+ return;
22
+ }
23
+
24
+ context.on('CallExpression', node => {
25
+ const assertion = parseAssertionCall(node, imports);
26
+ // In a strict-mode assert module the legacy methods already behave strictly, so leave them.
27
+ if (!assertion || assertion.isStrict) {
28
+ return;
29
+ }
30
+
31
+ const replacement = LOOSE_TO_STRICT.get(assertion.method);
32
+ if (!replacement) {
33
+ return;
34
+ }
35
+
36
+ const problem = {
37
+ node,
38
+ messageId: MESSAGE_ID,
39
+ data: {method: assertion.method, replacement},
40
+ };
41
+
42
+ // Autofix only the member forms (`assert.equal`, `t.assert.equal`). A bare named
43
+ // import (`equal`) cannot be rewritten to `strictEqual` without also importing it,
44
+ // so leave it reported but unfixed.
45
+ if (assertion.methodNode && assertion.methodNode !== node.callee) {
46
+ problem.fix = fixer => fixer.replaceText(assertion.methodNode, replacement);
47
+ }
48
+
49
+ return problem;
50
+ });
51
+ };
52
+
53
+ /** @type {import('eslint').Rule.RuleModule} */
54
+ const config = {
55
+ create,
56
+ meta: {
57
+ type: 'suggestion',
58
+ docs: {
59
+ description: 'Prefer strict assertion methods over their legacy loose counterparts.',
60
+ recommended: 'unopinionated',
61
+ },
62
+ fixable: 'code',
63
+ schema: [],
64
+ messages,
65
+ languages: ['js/js'],
66
+ },
67
+ };
68
+
69
+ export default config;
@@ -0,0 +1,125 @@
1
+ import {resolveImports, createContextTracker} from './utils/node-test.js';
2
+
3
+ const MESSAGE_ID_ERROR = 'prefer-test-context-assert/error';
4
+ const MESSAGE_ID_SUGGESTION = 'prefer-test-context-assert/suggestion';
5
+
6
+ const messages = {
7
+ [MESSAGE_ID_ERROR]: 'Prefer the test context `{{context}}.assert.{{method}}()` over the imported `node:assert`, so the runner ties the assertion to this test.',
8
+ [MESSAGE_ID_SUGGESTION]: 'Replace with `{{context}}.assert.{{method}}()`.',
9
+ };
10
+
11
+ // Under `node:assert/strict` these loose methods behave as their strict counterparts.
12
+ // `t.assert.*` exposes the non-strict functions, so preserve the behavior when converting.
13
+ const LOOSE_TO_STRICT = new Map([
14
+ ['equal', 'strictEqual'],
15
+ ['notEqual', 'notStrictEqual'],
16
+ ['deepEqual', 'deepStrictEqual'],
17
+ ['notDeepEqual', 'notDeepStrictEqual'],
18
+ ]);
19
+
20
+ /**
21
+ Classify a call as an imported `node:assert` assertion (not the `t.assert.*` form, which is
22
+ already preferred) and resolve the method name to use on `t.assert`, accounting for strict mode.
23
+
24
+ @returns {{method: string} | undefined}
25
+ */
26
+ const getAssertMethod = (node, imports) => {
27
+ const {callee} = node;
28
+
29
+ // `strictEqual(…)` — named import.
30
+ if (callee.type === 'Identifier' && imports.assertNamed.has(callee.name)) {
31
+ const method = imports.assertNamed.get(callee.name);
32
+ const isStrict = imports.strictAssertLocals.has(callee.name);
33
+ return {method: (isStrict && LOOSE_TO_STRICT.get(method)) || method};
34
+ }
35
+
36
+ // `assert(…)` — the bare assert function (alias of `ok`).
37
+ if (callee.type === 'Identifier' && imports.assertNamespace.has(callee.name)) {
38
+ return {method: 'ok'};
39
+ }
40
+
41
+ // `assert.strictEqual(…)` — namespace member.
42
+ if (
43
+ callee.type === 'MemberExpression'
44
+ && !callee.computed
45
+ && callee.property.type === 'Identifier'
46
+ && callee.object.type === 'Identifier'
47
+ && imports.assertNamespace.has(callee.object.name)
48
+ ) {
49
+ const method = callee.property.name;
50
+ const isStrict = imports.strictAssertLocals.has(callee.object.name);
51
+ return {method: (isStrict && LOOSE_TO_STRICT.get(method)) || method};
52
+ }
53
+
54
+ return undefined;
55
+ };
56
+
57
+ /** @param {import('eslint').Rule.RuleContext} context */
58
+ const create = context => {
59
+ const {sourceCode} = context;
60
+ const imports = resolveImports(context);
61
+ // Needs both: an imported `node:assert` to convert from, and `node:test` to provide a context.
62
+ if (!imports.hasAssert || !imports.isTestFile) {
63
+ return;
64
+ }
65
+
66
+ const tracker = createContextTracker(imports);
67
+
68
+ context.on('CallExpression', node => {
69
+ tracker.update(node);
70
+
71
+ const contextName = tracker.current();
72
+ if (!contextName) {
73
+ return;
74
+ }
75
+
76
+ const assertion = getAssertMethod(node, imports);
77
+ if (!assertion) {
78
+ return;
79
+ }
80
+
81
+ const {method} = assertion;
82
+ const data = {context: contextName, method};
83
+
84
+ const problem = {
85
+ node: node.callee,
86
+ messageId: MESSAGE_ID_ERROR,
87
+ data,
88
+ };
89
+
90
+ // Replacing the whole callee would drop any comments inside it.
91
+ if (sourceCode.getCommentsInside(node.callee).length === 0) {
92
+ problem.suggest = [
93
+ {
94
+ messageId: MESSAGE_ID_SUGGESTION,
95
+ data,
96
+ fix: fixer => fixer.replaceText(node.callee, `${contextName}.assert.${method}`),
97
+ },
98
+ ];
99
+ }
100
+
101
+ return problem;
102
+ });
103
+
104
+ context.onExit('CallExpression', node => {
105
+ tracker.leave(node);
106
+ });
107
+ };
108
+
109
+ /** @type {import('eslint').Rule.RuleModule} */
110
+ const config = {
111
+ create,
112
+ meta: {
113
+ type: 'suggestion',
114
+ docs: {
115
+ description: 'Prefer the test context `t.assert` over the imported `node:assert`.',
116
+ recommended: true,
117
+ },
118
+ hasSuggestions: true,
119
+ schema: [],
120
+ messages,
121
+ languages: ['js/js'],
122
+ },
123
+ };
124
+
125
+ export default config;
@@ -0,0 +1,98 @@
1
+ import {
2
+ resolveImports,
3
+ parseTestCall,
4
+ getTestCallback,
5
+ getTestOptions,
6
+ getTestTitle,
7
+ } from './utils/node-test.js';
8
+ import {removeArgument} from './fix/index.js';
9
+
10
+ const MESSAGE_ID_ERROR = 'prefer-todo/error';
11
+ const MESSAGE_ID_SUGGESTION = 'prefer-todo/suggestion';
12
+
13
+ const messages = {
14
+ [MESSAGE_ID_ERROR]: 'Empty placeholder test. Use `.todo` to mark it as unfinished.',
15
+ [MESSAGE_ID_SUGGESTION]: 'Mark as `.todo`.',
16
+ };
17
+
18
+ /** @param {import('eslint').Rule.RuleContext} context */
19
+ const create = context => {
20
+ const {sourceCode} = context;
21
+ const imports = resolveImports(context);
22
+ if (!imports.isTestFile) {
23
+ return;
24
+ }
25
+
26
+ context.on('CallExpression', node => {
27
+ const parsed = parseTestCall(node, imports);
28
+ // Only plain tests (a placeholder suite is a different concept); an existing modifier is intentional.
29
+ if (parsed?.kind !== 'test' || parsed.modifiers.length > 0) {
30
+ return;
31
+ }
32
+
33
+ // A `.todo` needs a title to be meaningful.
34
+ if (!getTestTitle(node, context)) {
35
+ return;
36
+ }
37
+
38
+ // An options object (`test('title', {skip: true}, …)`) marks intent, so leave it alone.
39
+ if (getTestOptions(node)) {
40
+ return;
41
+ }
42
+
43
+ const callback = getTestCallback(node);
44
+
45
+ // `test('title')` — only a title, no implementation.
46
+ const isTitleOnly = !callback && node.arguments.length === 1;
47
+
48
+ // `test('title', () => {})` — an empty implementation body.
49
+ const hasEmptyBody = callback?.body.type === 'BlockStatement' && callback.body.body.length === 0;
50
+
51
+ if (!isTitleOnly && !hasEmptyBody) {
52
+ return;
53
+ }
54
+
55
+ const {callee} = node;
56
+ // Dropping the function would drop any comments inside its body, so skip the fix then.
57
+ const canFix = !callback || sourceCode.getCommentsInside(callback).length === 0;
58
+
59
+ const problem = {
60
+ node,
61
+ messageId: MESSAGE_ID_ERROR,
62
+ };
63
+
64
+ if (canFix) {
65
+ problem.suggest = [
66
+ {
67
+ messageId: MESSAGE_ID_SUGGESTION,
68
+ * fix(fixer) {
69
+ yield fixer.insertTextAfter(callee, '.todo');
70
+ if (callback) {
71
+ yield removeArgument(fixer, callback, context);
72
+ }
73
+ },
74
+ },
75
+ ];
76
+ }
77
+
78
+ return problem;
79
+ });
80
+ };
81
+
82
+ /** @type {import('eslint').Rule.RuleModule} */
83
+ const config = {
84
+ create,
85
+ meta: {
86
+ type: 'suggestion',
87
+ docs: {
88
+ description: 'Prefer `.todo` for empty placeholder tests.',
89
+ recommended: true,
90
+ },
91
+ hasSuggestions: true,
92
+ schema: [],
93
+ messages,
94
+ languages: ['js/js'],
95
+ },
96
+ };
97
+
98
+ export default config;
@@ -0,0 +1,92 @@
1
+ import {
2
+ resolveImports,
3
+ parseTestCall,
4
+ getTestCallback,
5
+ parseAssertionCall,
6
+ } from './utils/node-test.js';
7
+
8
+ const MESSAGE_ID = 'require-assertion/error';
9
+
10
+ const messages = {
11
+ [MESSAGE_ID]: 'Test is missing an assertion. Tests without assertions will always pass.',
12
+ };
13
+
14
+ /** @param {import('eslint').Rule.RuleContext} context */
15
+ const create = context => {
16
+ const imports = resolveImports(context);
17
+ if (!imports.isTestFile) {
18
+ return;
19
+ }
20
+
21
+ /*
22
+ Stack of {callNode, callback, hasAssertion} for each open test call.
23
+ We push when we enter a test call with an inline callback, and pop (and possibly report) on exit.
24
+ */
25
+ const testStack = [];
26
+
27
+ context.on('CallExpression', node => {
28
+ const parsed = parseTestCall(node, imports);
29
+
30
+ // Track nested test calls as their own scope (don't let their assertions count for parent).
31
+ if (parsed && parsed.kind === 'test') {
32
+ const callback = getTestCallback(node);
33
+ // Only push if there's an inline function body to inspect.
34
+ if (callback) {
35
+ testStack.push({callNode: node, callback, hasAssertion: false});
36
+ return;
37
+ }
38
+
39
+ // No inline callback: skip/todo or external implementation — don't report.
40
+ if (testStack.length > 0) {
41
+ // Mark parent as having an assertion-like (external impl may assert).
42
+ testStack.at(-1).hasAssertion = true;
43
+ }
44
+
45
+ return;
46
+ }
47
+
48
+ // Check if this call is an assertion.
49
+ if (testStack.length > 0 && parseAssertionCall(node, imports)) {
50
+ testStack.at(-1).hasAssertion = true;
51
+ }
52
+ });
53
+
54
+ context.onExit('CallExpression', node => {
55
+ if (testStack.length === 0) {
56
+ return;
57
+ }
58
+
59
+ const top = testStack.at(-1);
60
+ if (top.callNode !== node) {
61
+ return;
62
+ }
63
+
64
+ testStack.pop();
65
+
66
+ if (!top.hasAssertion) {
67
+ return {
68
+ node,
69
+ messageId: MESSAGE_ID,
70
+ };
71
+ }
72
+
73
+ // Propagate to parent: a nested test call itself doesn't count as an assertion in the parent.
74
+ });
75
+ };
76
+
77
+ /** @type {import('eslint').Rule.RuleModule} */
78
+ const config = {
79
+ create,
80
+ meta: {
81
+ type: 'problem',
82
+ docs: {
83
+ description: 'Require that each test contains at least one assertion.',
84
+ recommended: true,
85
+ },
86
+ schema: [],
87
+ messages,
88
+ languages: ['js/js'],
89
+ },
90
+ };
91
+
92
+ export default config;
@@ -0,0 +1,119 @@
1
+ import {resolveImports, createContextTracker} from './utils/node-test.js';
2
+ import isFunction from './ast/is-function.js';
3
+
4
+ const MESSAGE_ID = 'require-await-concurrent-subtests';
5
+
6
+ const messages = {
7
+ [MESSAGE_ID]: 'Subtests created in a `{{method}}()` callback are not awaited, so they are cancelled when the parent test finishes. Use `await Promise.all(items.map(item => t.test(…)))`.',
8
+ };
9
+
10
+ // Array methods commonly used to create one subtest per element.
11
+ const ITERATION_METHODS = new Set(['map', 'forEach', 'flatMap']);
12
+
13
+ /** Find the iteration call (`xs.map(cb)`) whose callback directly encloses `node`, or `undefined`. */
14
+ function findEnclosingIterationCall(node) {
15
+ let current = node.parent;
16
+ while (current) {
17
+ if (isFunction(current)) {
18
+ const {parent} = current;
19
+ if (
20
+ parent?.type === 'CallExpression'
21
+ && parent.callee.type === 'MemberExpression'
22
+ && !parent.callee.computed
23
+ && parent.callee.property.type === 'Identifier'
24
+ && ITERATION_METHODS.has(parent.callee.property.name)
25
+ && parent.arguments.includes(current)
26
+ ) {
27
+ return parent;
28
+ }
29
+
30
+ // Any other function is a scope boundary (the test callback or a helper).
31
+ return undefined;
32
+ }
33
+
34
+ current = current.parent;
35
+ }
36
+ }
37
+
38
+ /** Whether the iteration call is an argument to a consumed `Promise.all(…)` / `Promise.allSettled(…)`. */
39
+ function isAwaitedViaPromiseAll(iterationCall) {
40
+ const {parent} = iterationCall;
41
+ if (
42
+ parent?.type === 'CallExpression'
43
+ && parent.callee.type === 'MemberExpression'
44
+ && !parent.callee.computed
45
+ && parent.callee.property.type === 'Identifier'
46
+ && (parent.callee.property.name === 'all' || parent.callee.property.name === 'allSettled')
47
+ && parent.callee.object.type === 'Identifier'
48
+ && parent.callee.object.name === 'Promise'
49
+ && parent.arguments.includes(iterationCall)
50
+ ) {
51
+ // The `Promise.all(…)` itself must be consumed (awaited, returned, or assigned), not discarded —
52
+ // otherwise the parent test still finishes before the subtests settle. It is discarded when left
53
+ // as a floating bare statement or explicitly thrown away with `void`.
54
+ const {parent: grandparent} = parent;
55
+ const isDiscarded = grandparent?.type === 'ExpressionStatement'
56
+ || (grandparent?.type === 'UnaryExpression' && grandparent.operator === 'void');
57
+ return !isDiscarded;
58
+ }
59
+
60
+ return false;
61
+ }
62
+
63
+ /** @param {import('eslint').Rule.RuleContext} context */
64
+ const create = context => {
65
+ const imports = resolveImports(context);
66
+ if (!imports.isTestFile) {
67
+ return;
68
+ }
69
+
70
+ const tracker = createContextTracker(imports);
71
+
72
+ context.on('CallExpression', node => {
73
+ const isSubtest = tracker.isSubtestCall(node);
74
+
75
+ let problem;
76
+ // A bare-statement subtest is already covered by `no-unawaited-subtest`; this rule covers the
77
+ // expression-body and `return` forms inside an iteration callback that it misses.
78
+ if (isSubtest && node.parent?.type !== 'ExpressionStatement') {
79
+ const iterationCall = findEnclosingIterationCall(node);
80
+ if (iterationCall) {
81
+ const method = iterationCall.callee.property.name;
82
+ // `forEach` discards its callbacks' results entirely; `map`/`flatMap` are fine only when
83
+ // the resulting array is awaited via `Promise.all`.
84
+ const handled = method !== 'forEach' && isAwaitedViaPromiseAll(iterationCall);
85
+ if (!handled) {
86
+ problem = {
87
+ node,
88
+ messageId: MESSAGE_ID,
89
+ data: {method},
90
+ };
91
+ }
92
+ }
93
+ }
94
+
95
+ tracker.update(node);
96
+ return problem;
97
+ });
98
+
99
+ context.onExit('CallExpression', node => {
100
+ tracker.leave(node);
101
+ });
102
+ };
103
+
104
+ /** @type {import('eslint').Rule.RuleModule} */
105
+ const config = {
106
+ create,
107
+ meta: {
108
+ type: 'problem',
109
+ docs: {
110
+ description: 'Require subtests created in a loop callback to be awaited.',
111
+ recommended: 'unopinionated',
112
+ },
113
+ schema: [],
114
+ messages,
115
+ languages: ['js/js'],
116
+ },
117
+ };
118
+
119
+ export default config;