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,79 @@
1
+ import {resolveImports} from './utils/node-test.js';
2
+
3
+ const MESSAGE_ID = 'no-export';
4
+
5
+ const messages = {
6
+ [MESSAGE_ID]: 'Do not export from a test file.',
7
+ };
8
+
9
+ // `module.exports` / `exports.foo` / `module.exports.foo` assignment targets.
10
+ function isCommonJsExport(node) {
11
+ if (node.type !== 'MemberExpression' || node.computed) {
12
+ return false;
13
+ }
14
+
15
+ const {object} = node;
16
+
17
+ // `exports.foo = …`
18
+ if (object.type === 'Identifier' && object.name === 'exports') {
19
+ return true;
20
+ }
21
+
22
+ // `module.exports = …` and `module.exports.foo = …`
23
+ if (object.type === 'Identifier' && object.name === 'module' && node.property.type === 'Identifier' && node.property.name === 'exports') {
24
+ return true;
25
+ }
26
+
27
+ return (
28
+ object.type === 'MemberExpression'
29
+ && !object.computed
30
+ && object.object.type === 'Identifier'
31
+ && object.object.name === 'module'
32
+ && object.property.type === 'Identifier'
33
+ && object.property.name === 'exports'
34
+ );
35
+ }
36
+
37
+ /** @param {import('eslint').Rule.RuleContext} context */
38
+ const create = context => {
39
+ const imports = resolveImports(context);
40
+ if (!imports.isTestFile) {
41
+ return;
42
+ }
43
+
44
+ const report = node => ({node, messageId: MESSAGE_ID});
45
+
46
+ context.on('ExportNamedDeclaration', node => {
47
+ // `export {}` exports nothing — it is only a marker to make a file a module. Leave it alone.
48
+ if (!node.declaration && !node.source && node.specifiers.length === 0) {
49
+ return;
50
+ }
51
+
52
+ return report(node);
53
+ });
54
+ context.on('ExportDefaultDeclaration', report);
55
+ context.on('ExportAllDeclaration', report);
56
+
57
+ context.on('AssignmentExpression', node => {
58
+ if (isCommonJsExport(node.left)) {
59
+ return {node, messageId: MESSAGE_ID};
60
+ }
61
+ });
62
+ };
63
+
64
+ /** @type {import('eslint').Rule.RuleModule} */
65
+ const config = {
66
+ create,
67
+ meta: {
68
+ type: 'suggestion',
69
+ docs: {
70
+ description: 'Disallow exports from test files.',
71
+ recommended: true,
72
+ },
73
+ schema: [],
74
+ messages,
75
+ languages: ['js/js'],
76
+ },
77
+ };
78
+
79
+ export default config;
@@ -0,0 +1,71 @@
1
+ import {resolveImports, parseAssertionCall} from './utils/node-test.js';
2
+ import {isSameReference} from './utils/index.js';
3
+
4
+ const MESSAGE_ID_ALWAYS_PASSES = 'no-identical-assertion-arguments/always-passes';
5
+ const MESSAGE_ID_ALWAYS_FAILS = 'no-identical-assertion-arguments/always-fails';
6
+
7
+ const messages = {
8
+ [MESSAGE_ID_ALWAYS_PASSES]: 'Both arguments are the same, so this assertion always passes.',
9
+ [MESSAGE_ID_ALWAYS_FAILS]: 'Both arguments are the same, so this assertion always fails.',
10
+ };
11
+
12
+ // Two-operand `node:assert` comparisons. The negated ones always fail on identical operands.
13
+ const POSITIVE_METHODS = new Set(['equal', 'strictEqual', 'deepEqual', 'deepStrictEqual']);
14
+ const NEGATED_METHODS = new Set(['notEqual', 'notStrictEqual', 'notDeepEqual', 'notDeepStrictEqual']);
15
+
16
+ /** @param {import('eslint').Rule.RuleContext} context */
17
+ const create = context => {
18
+ const imports = resolveImports(context);
19
+ // Activate on a `node:assert` import, or in a test file where `t.assert.*` may be used.
20
+ if (!imports.isAssertOrTestFile) {
21
+ return;
22
+ }
23
+
24
+ context.on('CallExpression', node => {
25
+ const assertion = parseAssertionCall(node, imports);
26
+ if (!assertion) {
27
+ return;
28
+ }
29
+
30
+ const isNegated = NEGATED_METHODS.has(assertion.method);
31
+ if (!isNegated && !POSITIVE_METHODS.has(assertion.method)) {
32
+ return;
33
+ }
34
+
35
+ const [first, second] = node.arguments;
36
+ if (
37
+ !first
38
+ || !second
39
+ || first.type === 'SpreadElement'
40
+ || second.type === 'SpreadElement'
41
+ ) {
42
+ return;
43
+ }
44
+
45
+ if (!isSameReference(first, second)) {
46
+ return;
47
+ }
48
+
49
+ return {
50
+ node,
51
+ messageId: isNegated ? MESSAGE_ID_ALWAYS_FAILS : MESSAGE_ID_ALWAYS_PASSES,
52
+ };
53
+ });
54
+ };
55
+
56
+ /** @type {import('eslint').Rule.RuleModule} */
57
+ const config = {
58
+ create,
59
+ meta: {
60
+ type: 'problem',
61
+ docs: {
62
+ description: 'Disallow comparing a value to itself in an assertion.',
63
+ recommended: 'unopinionated',
64
+ },
65
+ schema: [],
66
+ messages,
67
+ languages: ['js/js'],
68
+ },
69
+ };
70
+
71
+ export default config;
@@ -0,0 +1,101 @@
1
+ import {
2
+ resolveImports,
3
+ parseTestCall,
4
+ getTestTitle,
5
+ getStaticString,
6
+ getTestCallback,
7
+ } from './utils/node-test.js';
8
+
9
+ const MESSAGE_ID = 'no-identical-title/duplicate';
10
+
11
+ const messages = {
12
+ [MESSAGE_ID]: 'Test title is already used by another test in the same scope.',
13
+ };
14
+
15
+ /** @param {import('eslint').Rule.RuleContext} context */
16
+ const create = context => {
17
+ const imports = resolveImports(context);
18
+ if (!imports.isTestFile) {
19
+ return;
20
+ }
21
+
22
+ /*
23
+ Stack of title sets, one per scope level.
24
+ The bottom of the stack (index 0) is the module top-level.
25
+ Each describe/suite callback body pushes a new set on entry and pops it on exit.
26
+ */
27
+ const scopeStack = [new Set()];
28
+
29
+ /*
30
+ Map from a suite callback function node to the call expression that created it,
31
+ so we can push/pop the scope when entering/exiting the callback body.
32
+ */
33
+ const suiteCallbackNodes = new WeakSet();
34
+
35
+ context.on('CallExpression', node => {
36
+ const parsed = parseTestCall(node, imports);
37
+ if (!parsed || parsed.kind === 'hook') {
38
+ return;
39
+ }
40
+
41
+ // Track suite callbacks for scope push/pop.
42
+ if (parsed.kind === 'suite') {
43
+ const callback = getTestCallback(node);
44
+ if (callback) {
45
+ suiteCallbackNodes.add(callback);
46
+ }
47
+ }
48
+
49
+ const titleNode = getTestTitle(node, context);
50
+ if (!titleNode) {
51
+ return;
52
+ }
53
+
54
+ const titleValue = getStaticString(titleNode, context);
55
+ if (titleValue === undefined) {
56
+ return;
57
+ }
58
+
59
+ const currentScope = scopeStack.at(-1);
60
+ if (currentScope.has(titleValue)) {
61
+ return {
62
+ node: titleNode,
63
+ messageId: MESSAGE_ID,
64
+ };
65
+ }
66
+
67
+ currentScope.add(titleValue);
68
+ });
69
+
70
+ // Push/pop a scope around each suite callback body.
71
+ const functionTypes = ['FunctionExpression', 'ArrowFunctionExpression'];
72
+
73
+ context.on(functionTypes, node => {
74
+ if (suiteCallbackNodes.has(node)) {
75
+ scopeStack.push(new Set());
76
+ }
77
+ });
78
+
79
+ context.onExit(functionTypes, node => {
80
+ if (suiteCallbackNodes.has(node)) {
81
+ scopeStack.pop();
82
+ }
83
+ });
84
+ };
85
+
86
+ /** @type {import('eslint').Rule.RuleModule} */
87
+ const config = {
88
+ create,
89
+ meta: {
90
+ type: 'problem',
91
+ docs: {
92
+ description: 'Disallow identical test titles within the same scope.',
93
+ recommended: 'unopinionated',
94
+ },
95
+ schema: [],
96
+ messages,
97
+ languages: ['js/js'],
98
+ },
99
+ };
100
+
101
+ export default config;
@@ -0,0 +1,100 @@
1
+ import {resolveImports, parseAssertionCall} from './utils/node-test.js';
2
+ import unwrapTypeScriptExpression from './utils/unwrap-typescript-expression.js';
3
+
4
+ const MESSAGE_ID = 'no-deep-equal-with-primitive';
5
+
6
+ const DEEP_EQUAL_METHODS = new Map([
7
+ ['deepEqual', 'equal'],
8
+ ['deepStrictEqual', 'strictEqual'],
9
+ ['notDeepEqual', 'notEqual'],
10
+ ['notDeepStrictEqual', 'notStrictEqual'],
11
+ ]);
12
+
13
+ /**
14
+ Check if a node represents a primitive value.
15
+ Covers: literals, `undefined`/`NaN`/`Infinity` identifiers, template literals (always a string,
16
+ regardless of interpolation), `void` expressions, and negated numeric/Infinity/NaN literals.
17
+ */
18
+ function isPrimitive(node) {
19
+ node = unwrapTypeScriptExpression(node);
20
+ return (
21
+ (node.type === 'Literal' && !node.regex)
22
+ || (node.type === 'Identifier' && (node.name === 'undefined' || node.name === 'NaN' || node.name === 'Infinity'))
23
+ || node.type === 'TemplateLiteral'
24
+ || (node.type === 'UnaryExpression' && node.operator === 'void')
25
+ || (
26
+ node.type === 'UnaryExpression'
27
+ && node.operator === '-'
28
+ && (
29
+ (node.argument.type === 'Literal' && !node.argument.regex)
30
+ || (node.argument.type === 'Identifier' && (node.argument.name === 'Infinity' || node.argument.name === 'NaN'))
31
+ )
32
+ )
33
+ );
34
+ }
35
+
36
+ /** @param {import('eslint').Rule.RuleContext} context */
37
+ const create = context => {
38
+ const imports = resolveImports(context);
39
+ if (!imports.isAssertOrTestFile) {
40
+ return;
41
+ }
42
+
43
+ context.on('CallExpression', node => {
44
+ const assertion = parseAssertionCall(node, imports);
45
+ if (!assertion) {
46
+ return;
47
+ }
48
+
49
+ const {method} = assertion;
50
+ const replacement = DEEP_EQUAL_METHODS.get(method);
51
+ if (!replacement) {
52
+ return;
53
+ }
54
+
55
+ const [actual, expected] = node.arguments;
56
+ if (!actual || !expected) {
57
+ return;
58
+ }
59
+
60
+ if (!isPrimitive(actual) && !isPrimitive(expected)) {
61
+ return;
62
+ }
63
+
64
+ const {callee} = node;
65
+ const problem = {
66
+ node,
67
+ messageId: MESSAGE_ID,
68
+ data: {method},
69
+ };
70
+
71
+ // Autofix only the member forms (`assert.deepEqual`, `t.assert.deepEqual`). A bare named
72
+ // import (`deepEqual`) cannot be rewritten to `equal` without also importing it, so leave
73
+ // it reported but unfixed.
74
+ if (callee.type === 'MemberExpression') {
75
+ problem.fix = fixer => fixer.replaceText(callee.property, replacement);
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: 'Disallow `deepEqual`/`deepStrictEqual` (and their `notDeep*` variants) when comparing with primitives.',
89
+ recommended: 'unopinionated',
90
+ },
91
+ fixable: 'code',
92
+ schema: [],
93
+ messages: {
94
+ [MESSAGE_ID]: 'Avoid using `{{method}}` with a primitive. Use the strict equality equivalent instead.',
95
+ },
96
+ languages: ['js/js'],
97
+ },
98
+ };
99
+
100
+ export default config;
@@ -0,0 +1,86 @@
1
+ import {resolveImports, parseAssertionCall} from './utils/node-test.js';
2
+ import unwrapTypeScriptExpression from './utils/unwrap-typescript-expression.js';
3
+
4
+ const MESSAGE_ID = 'no-incorrect-strict-equal';
5
+
6
+ // Strict/loose equality methods and their deep equivalents.
7
+ const STRICT_TO_DEEP = new Map([
8
+ ['equal', 'deepEqual'],
9
+ ['strictEqual', 'deepStrictEqual'],
10
+ ['notEqual', 'notDeepEqual'],
11
+ ['notStrictEqual', 'notDeepStrictEqual'],
12
+ ]);
13
+
14
+ /**
15
+ Check if a node is an object or array literal. These are freshly allocated, so a strict/loose
16
+ equality comparison against them is decided purely by reference identity, never by structure.
17
+ */
18
+ function isObjectOrArrayLiteral(node) {
19
+ const unwrapped = unwrapTypeScriptExpression(node);
20
+ return unwrapped.type === 'ObjectExpression' || unwrapped.type === 'ArrayExpression';
21
+ }
22
+
23
+ /** @param {import('eslint').Rule.RuleContext} context */
24
+ const create = context => {
25
+ const imports = resolveImports(context);
26
+ if (!imports.isAssertOrTestFile) {
27
+ return;
28
+ }
29
+
30
+ context.on('CallExpression', node => {
31
+ const assertion = parseAssertionCall(node, imports);
32
+ if (!assertion) {
33
+ return;
34
+ }
35
+
36
+ const replacement = STRICT_TO_DEEP.get(assertion.method);
37
+ if (!replacement) {
38
+ return;
39
+ }
40
+
41
+ const [actual, expected] = node.arguments;
42
+ if (!actual || !expected) {
43
+ return;
44
+ }
45
+
46
+ if (!isObjectOrArrayLiteral(actual) && !isObjectOrArrayLiteral(expected)) {
47
+ return;
48
+ }
49
+
50
+ const {callee} = node;
51
+ const problem = {
52
+ node,
53
+ messageId: MESSAGE_ID,
54
+ data: {method: assertion.method, replacement},
55
+ };
56
+
57
+ // Autofix only the member forms (`assert.strictEqual`, `t.assert.strictEqual`). A bare named
58
+ // import (`strictEqual`) cannot be rewritten to `deepStrictEqual` without also importing it,
59
+ // so leave it reported but unfixed.
60
+ if (callee.type === 'MemberExpression') {
61
+ problem.fix = fixer => fixer.replaceText(callee.property, replacement);
62
+ }
63
+
64
+ return problem;
65
+ });
66
+ };
67
+
68
+ /** @type {import('eslint').Rule.RuleModule} */
69
+ const config = {
70
+ create,
71
+ meta: {
72
+ type: 'suggestion',
73
+ docs: {
74
+ description: 'Disallow `strictEqual`/`equal` (and their `not*` variants) when comparing with an object or array literal.',
75
+ recommended: 'unopinionated',
76
+ },
77
+ fixable: 'code',
78
+ schema: [],
79
+ messages: {
80
+ [MESSAGE_ID]: 'Avoid using `{{method}}` with an object or array literal, which compares by reference rather than structure. Use `{{replacement}}` instead.',
81
+ },
82
+ languages: ['js/js'],
83
+ },
84
+ };
85
+
86
+ export default config;
@@ -0,0 +1,93 @@
1
+ import {resolveImports, parseTestCall, getStaticString} from './utils/node-test.js';
2
+ import {isLoop, isFunction} from './ast/index.js';
3
+
4
+ const MESSAGE_ID = 'no-loop-static-title';
5
+
6
+ const messages = {
7
+ [MESSAGE_ID]: 'This title is static but generated in a loop, so every iteration registers the same title. Include the loop variable so each title is unique.',
8
+ };
9
+
10
+ // Array methods whose callback is commonly used to generate one test per element.
11
+ const ITERATION_METHODS = new Set(['map', 'forEach', 'flatMap']);
12
+
13
+ /** Whether `callExpression` is an iteration method call (`xs.map(fn)`) whose callback is `callback`. */
14
+ function isIterationCall(callExpression, callback) {
15
+ const {callee} = callExpression;
16
+ return (
17
+ callee.type === 'MemberExpression'
18
+ && !callee.computed
19
+ && callee.property.type === 'Identifier'
20
+ && ITERATION_METHODS.has(callee.property.name)
21
+ && callExpression.arguments.includes(callback)
22
+ );
23
+ }
24
+
25
+ /*
26
+ Whether the test/suite call repeats across a loop without an intervening test/suite scope.
27
+ Walking up from the call: a loop means it repeats; the first enclosing function that is an
28
+ iteration callback (`xs.map(…)`) also means it repeats; any other function is a scope boundary
29
+ (a `describe`/test callback or helper) under which the static title is no longer a duplicate.
30
+ */
31
+ function isInRepeatingScope(node) {
32
+ let current = node.parent;
33
+ while (current) {
34
+ if (isLoop(current)) {
35
+ return true;
36
+ }
37
+
38
+ if (isFunction(current)) {
39
+ return current.parent?.type === 'CallExpression' && isIterationCall(current.parent, current);
40
+ }
41
+
42
+ current = current.parent;
43
+ }
44
+
45
+ return false;
46
+ }
47
+
48
+ /** @param {import('eslint').Rule.RuleContext} context */
49
+ const create = context => {
50
+ const imports = resolveImports(context);
51
+ if (!imports.isTestFile) {
52
+ return;
53
+ }
54
+
55
+ context.on('CallExpression', node => {
56
+ const parsed = parseTestCall(node, imports);
57
+ if (parsed?.kind !== 'test' && parsed?.kind !== 'suite') {
58
+ return;
59
+ }
60
+
61
+ // A static title resolves to a constant string; a title that interpolates the loop variable
62
+ // does not, so it is correctly left alone.
63
+ if (getStaticString(node.arguments[0], context) === undefined) {
64
+ return;
65
+ }
66
+
67
+ if (!isInRepeatingScope(node)) {
68
+ return;
69
+ }
70
+
71
+ return {
72
+ node: node.arguments[0],
73
+ messageId: MESSAGE_ID,
74
+ };
75
+ });
76
+ };
77
+
78
+ /** @type {import('eslint').Rule.RuleModule} */
79
+ const config = {
80
+ create,
81
+ meta: {
82
+ type: 'problem',
83
+ docs: {
84
+ description: 'Disallow a static test or suite title inside a loop.',
85
+ recommended: 'unopinionated',
86
+ },
87
+ schema: [],
88
+ messages,
89
+ languages: ['js/js'],
90
+ },
91
+ };
92
+
93
+ export default config;
@@ -0,0 +1,85 @@
1
+ import {
2
+ resolveImports,
3
+ parseTestCall,
4
+ createContextTracker,
5
+ getSubtestReceiver,
6
+ getTestOptions,
7
+ findOptionsProperty,
8
+ } from './utils/node-test.js';
9
+
10
+ const MESSAGE_ID = 'no-misused-concurrency';
11
+
12
+ const messages = {
13
+ [MESSAGE_ID]: 'The `concurrency` option has no effect on a test without subtests. It only controls how a suite or a test\'s subtests run concurrently.',
14
+ };
15
+
16
+ /** @param {import('eslint').Rule.RuleContext} context */
17
+ const create = context => {
18
+ const imports = resolveImports(context);
19
+ if (!imports.isTestFile) {
20
+ return;
21
+ }
22
+
23
+ const tracker = createContextTracker(imports);
24
+
25
+ // One frame per test/subtest. A frame with a `concurrency` option but no subtests is misused.
26
+ const frames = [];
27
+
28
+ context.on('CallExpression', node => {
29
+ const isSubtest = tracker.isSubtestCall(node);
30
+
31
+ // Attribute this subtest to the frame that owns its receiver context, before it pushes its own.
32
+ if (isSubtest) {
33
+ const receiver = getSubtestReceiver(node).name;
34
+ const ownerFrame = frames.findLast(frame => frame.contextName === receiver);
35
+ if (ownerFrame) {
36
+ ownerFrame.hasSubtest = true;
37
+ }
38
+ }
39
+
40
+ const isTest = parseTestCall(node, imports)?.kind === 'test';
41
+ tracker.update(node);
42
+
43
+ if (isTest || isSubtest) {
44
+ frames.push({
45
+ node,
46
+ contextName: tracker.current(),
47
+ concurrencyProperty: findOptionsProperty(getTestOptions(node), 'concurrency'),
48
+ hasSubtest: false,
49
+ });
50
+ }
51
+ });
52
+
53
+ context.onExit('CallExpression', node => {
54
+ tracker.leave(node);
55
+
56
+ if (frames.at(-1)?.node !== node) {
57
+ return;
58
+ }
59
+
60
+ const frame = frames.pop();
61
+ if (frame.concurrencyProperty && !frame.hasSubtest) {
62
+ return {
63
+ node: frame.concurrencyProperty,
64
+ messageId: MESSAGE_ID,
65
+ };
66
+ }
67
+ });
68
+ };
69
+
70
+ /** @type {import('eslint').Rule.RuleModule} */
71
+ const config = {
72
+ create,
73
+ meta: {
74
+ type: 'problem',
75
+ docs: {
76
+ description: 'Disallow the `concurrency` option on a test without subtests.',
77
+ recommended: 'unopinionated',
78
+ },
79
+ schema: [],
80
+ messages,
81
+ languages: ['js/js'],
82
+ },
83
+ };
84
+
85
+ export default config;