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,150 @@
1
+ import {resolveImports} from './utils/node-test.js';
2
+
3
+ const MESSAGE_ID = 'no-mock-timers-destructured-import';
4
+
5
+ const messages = {
6
+ [MESSAGE_ID]: '`{{name}}` is imported directly, so `mock.timers` cannot intercept it. Call the global `{{name}}` instead.',
7
+ };
8
+
9
+ const TIMER_MODULES = new Set(['node:timers', 'timers']);
10
+
11
+ // Imported timer function -> the `mock.timers` API name that mocks it.
12
+ const FUNCTION_TO_API = new Map([
13
+ ['setTimeout', 'setTimeout'],
14
+ ['clearTimeout', 'setTimeout'],
15
+ ['setInterval', 'setInterval'],
16
+ ['clearInterval', 'setInterval'],
17
+ ['setImmediate', 'setImmediate'],
18
+ ['clearImmediate', 'setImmediate'],
19
+ ]);
20
+
21
+ /*
22
+ The enabled timer APIs, or `null` when `enable()` was called without an `apis` list (all APIs).
23
+ */
24
+ function getEnabledApis(callExpression) {
25
+ const [argument] = callExpression.arguments;
26
+ if (argument?.type !== 'ObjectExpression') {
27
+ return null;
28
+ }
29
+
30
+ const apisProperty = argument.properties.find(property =>
31
+ property.type === 'Property'
32
+ && !property.computed
33
+ && (
34
+ (property.key.type === 'Identifier' && property.key.name === 'apis')
35
+ || (property.key.type === 'Literal' && property.key.value === 'apis')
36
+ ));
37
+
38
+ if (apisProperty?.value.type !== 'ArrayExpression') {
39
+ return null;
40
+ }
41
+
42
+ return apisProperty.value.elements
43
+ .filter(element => element?.type === 'Literal' && typeof element.value === 'string')
44
+ .map(element => element.value);
45
+ }
46
+
47
+ /** @param {import('eslint').Rule.RuleContext} context */
48
+ const create = context => {
49
+ const {sourceCode} = context;
50
+ const imports = resolveImports(context);
51
+ if (!imports.isTestFile) {
52
+ return;
53
+ }
54
+
55
+ // Named timer-function imports from `node:timers`.
56
+ const timerImports = [];
57
+ for (const node of sourceCode.ast.body) {
58
+ if (node.type !== 'ImportDeclaration' || !TIMER_MODULES.has(node.source.value)) {
59
+ continue;
60
+ }
61
+
62
+ for (const specifier of node.specifiers) {
63
+ if (
64
+ specifier.type === 'ImportSpecifier'
65
+ && specifier.imported.type === 'Identifier'
66
+ && FUNCTION_TO_API.has(specifier.imported.name)
67
+ ) {
68
+ timerImports.push(specifier);
69
+ }
70
+ }
71
+ }
72
+
73
+ if (timerImports.length === 0) {
74
+ return;
75
+ }
76
+
77
+ const {mockLocals} = imports;
78
+
79
+ // `mock.timers` (global) or `t.mock.timers` (context).
80
+ const isMockTimers = node =>
81
+ node.type === 'MemberExpression'
82
+ && !node.computed
83
+ && node.property.type === 'Identifier'
84
+ && node.property.name === 'timers'
85
+ && (
86
+ // `mock.timers` (global, named/renamed import).
87
+ (node.object.type === 'Identifier' && mockLocals.has(node.object.name))
88
+ // `t.mock.timers` (context) or `namespace.mock.timers`.
89
+ || (
90
+ node.object.type === 'MemberExpression'
91
+ && !node.object.computed
92
+ && node.object.property.type === 'Identifier'
93
+ && node.object.property.name === 'mock'
94
+ )
95
+ );
96
+
97
+ const enabledApis = new Set();
98
+ let allEnabled = false;
99
+
100
+ context.on('CallExpression', node => {
101
+ const {callee} = node;
102
+ if (
103
+ callee.type === 'MemberExpression'
104
+ && !callee.computed
105
+ && callee.property.type === 'Identifier'
106
+ && callee.property.name === 'enable'
107
+ && isMockTimers(callee.object)
108
+ ) {
109
+ const apis = getEnabledApis(node);
110
+ if (apis === null) {
111
+ allEnabled = true;
112
+ } else {
113
+ for (const api of apis) {
114
+ enabledApis.add(api);
115
+ }
116
+ }
117
+ }
118
+ });
119
+
120
+ context.onExit('Program', () => {
121
+ if (!allEnabled && enabledApis.size === 0) {
122
+ return;
123
+ }
124
+
125
+ return timerImports
126
+ .filter(specifier => allEnabled || enabledApis.has(FUNCTION_TO_API.get(specifier.imported.name)))
127
+ .map(specifier => ({
128
+ node: specifier,
129
+ messageId: MESSAGE_ID,
130
+ data: {name: specifier.imported.name},
131
+ }));
132
+ });
133
+ };
134
+
135
+ /** @type {import('eslint').Rule.RuleModule} */
136
+ const config = {
137
+ create,
138
+ meta: {
139
+ type: 'problem',
140
+ docs: {
141
+ description: 'Disallow destructured timer imports when using `mock.timers`.',
142
+ recommended: 'unopinionated',
143
+ },
144
+ schema: [],
145
+ messages,
146
+ languages: ['js/js'],
147
+ },
148
+ };
149
+
150
+ export default config;
@@ -0,0 +1,71 @@
1
+ import {resolveImports, parseTestCall, getTestCallback} from './utils/node-test.js';
2
+
3
+ const MESSAGE_ID = 'no-nested-tests/error';
4
+
5
+ const messages = {
6
+ [MESSAGE_ID]: 'Do not define a test or suite inside a test body. Use `t.test()` for subtests instead.',
7
+ };
8
+
9
+ /** @param {import('eslint').Rule.RuleContext} context */
10
+ const create = context => {
11
+ const imports = resolveImports(context);
12
+ if (!imports.isTestFile) {
13
+ return;
14
+ }
15
+
16
+ // Stack of callback function nodes of the `test`/`it` calls we are currently inside.
17
+ // Suites (`describe`/`suite`) legitimately contain tests and nested suites, so they
18
+ // do not open a scope here — only a test/it body does.
19
+ const testCallbackStack = [];
20
+
21
+ context.on('CallExpression', node => {
22
+ const parsed = parseTestCall(node, imports);
23
+ if (!parsed) {
24
+ return;
25
+ }
26
+
27
+ // A test or suite defined inside a test body should be a subtest (`t.test()`).
28
+ if ((parsed.kind === 'test' || parsed.kind === 'suite') && testCallbackStack.length > 0) {
29
+ return {
30
+ node,
31
+ messageId: MESSAGE_ID,
32
+ };
33
+ }
34
+
35
+ if (parsed.kind === 'test') {
36
+ const callback = getTestCallback(node);
37
+ if (callback) {
38
+ testCallbackStack.push(callback);
39
+ }
40
+ }
41
+ });
42
+
43
+ context.onExit('CallExpression', node => {
44
+ const parsed = parseTestCall(node, imports);
45
+ if (!parsed || parsed.kind !== 'test') {
46
+ return;
47
+ }
48
+
49
+ const callback = getTestCallback(node);
50
+ if (callback && testCallbackStack.at(-1) === callback) {
51
+ testCallbackStack.pop();
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 tests and suites nested inside a test body.',
63
+ recommended: 'unopinionated',
64
+ },
65
+ schema: [],
66
+ messages,
67
+ languages: ['js/js'],
68
+ },
69
+ };
70
+
71
+ export default config;
@@ -0,0 +1,11 @@
1
+ import createTestModifierRule from './shared/test-modifier-rule.js';
2
+
3
+ /** @type {import('eslint').Rule.RuleModule} */
4
+ const config = createTestModifierRule({
5
+ modifier: 'only',
6
+ description: 'Disallow the `.only` test modifier.',
7
+ errorMessage: 'Do not use the `.only` test modifier as it prevents the other tests from running.',
8
+ recommended: 'unopinionated',
9
+ });
10
+
11
+ export default config;
@@ -0,0 +1,11 @@
1
+ import createTestModifierRule from './shared/test-modifier-rule.js';
2
+
3
+ /** @type {import('eslint').Rule.RuleModule} */
4
+ const config = createTestModifierRule({
5
+ modifier: 'skip',
6
+ description: 'Disallow the `.skip` test modifier.',
7
+ errorMessage: 'Do not skip tests.',
8
+ recommended: true,
9
+ });
10
+
11
+ export default config;
@@ -0,0 +1,88 @@
1
+ import {
2
+ resolveImports,
3
+ parseTestCall,
4
+ createContextTracker,
5
+ getTestOptions,
6
+ findOptionsProperty,
7
+ } from './utils/node-test.js';
8
+
9
+ const MESSAGE_ID_OPTION = 'no-skip-without-reason/option';
10
+ const MESSAGE_ID_CALL = 'no-skip-without-reason/call';
11
+
12
+ const messages = {
13
+ [MESSAGE_ID_OPTION]: 'Give `{{modifier}}` a reason string instead of `true` explaining why.',
14
+ [MESSAGE_ID_CALL]: 'Pass a reason message to `{{context}}.{{modifier}}()`.',
15
+ };
16
+
17
+ const REASON_MODIFIERS = ['skip', 'todo'];
18
+
19
+ /** @param {import('eslint').Rule.RuleContext} context */
20
+ const create = context => {
21
+ const imports = resolveImports(context);
22
+ if (!imports.isTestFile) {
23
+ return;
24
+ }
25
+
26
+ const tracker = createContextTracker(imports);
27
+
28
+ context.on('CallExpression', node => {
29
+ const problems = [];
30
+
31
+ // Options form: `{skip: true}` / `{todo: true}` on a test/suite/hook.
32
+ if (parseTestCall(node, imports)) {
33
+ const options = getTestOptions(node);
34
+ for (const modifier of REASON_MODIFIERS) {
35
+ const property = findOptionsProperty(options, modifier);
36
+ if (property?.value.type === 'Literal' && property.value.value === true) {
37
+ problems.push({
38
+ node: property,
39
+ messageId: MESSAGE_ID_OPTION,
40
+ data: {modifier},
41
+ });
42
+ }
43
+ }
44
+ }
45
+
46
+ // Context method form: `t.skip()` / `t.todo()` with no reason message.
47
+ const {callee} = node;
48
+ if (
49
+ node.arguments.length === 0
50
+ && callee.type === 'MemberExpression'
51
+ && !callee.computed
52
+ && callee.property.type === 'Identifier'
53
+ && REASON_MODIFIERS.includes(callee.property.name)
54
+ && callee.object.type === 'Identifier'
55
+ && tracker.isContextName(callee.object.name)
56
+ ) {
57
+ problems.push({
58
+ node,
59
+ messageId: MESSAGE_ID_CALL,
60
+ data: {context: callee.object.name, modifier: callee.property.name},
61
+ });
62
+ }
63
+
64
+ tracker.update(node);
65
+ return problems;
66
+ });
67
+
68
+ context.onExit('CallExpression', node => {
69
+ tracker.leave(node);
70
+ });
71
+ };
72
+
73
+ /** @type {import('eslint').Rule.RuleModule} */
74
+ const config = {
75
+ create,
76
+ meta: {
77
+ type: 'suggestion',
78
+ docs: {
79
+ description: 'Require a reason when skipping or marking a test as todo.',
80
+ recommended: false,
81
+ },
82
+ schema: [],
83
+ messages,
84
+ languages: ['js/js'],
85
+ },
86
+ };
87
+
88
+ export default config;
@@ -0,0 +1,127 @@
1
+ import {resolveImports, createContextTracker} from './utils/node-test.js';
2
+ import isFunction from './ast/is-function.js';
3
+
4
+ const MESSAGE_ID_ERROR = 'no-skip-without-return/error';
5
+ const MESSAGE_ID_SUGGESTION = 'no-skip-without-return/suggestion';
6
+
7
+ const messages = {
8
+ [MESSAGE_ID_ERROR]: '`{{name}}.{{method}}()` does not stop the test; code after it still runs. Return afterwards.',
9
+ [MESSAGE_ID_SUGGESTION]: 'Add `return` after `{{name}}.{{method}}()`.',
10
+ };
11
+
12
+ const SKIP_METHODS = new Set(['skip', 'todo']);
13
+
14
+ /*
15
+ Whether reachable code follows the skip statement before the enclosing test function ends.
16
+ Walks outward: a following sibling statement means code runs after the skip, unless the skip
17
+ is directly followed by a `return`/`throw`. Only block and program bodies are inspected; a skip
18
+ inside a `switch` case is best-effort and not flagged, since detecting it correctly would require
19
+ modeling break/return/fall-through control flow.
20
+ */
21
+ function hasCodeAfter(skipStatement) {
22
+ let node = skipStatement;
23
+ while (node) {
24
+ const {parent} = node;
25
+ if (!parent) {
26
+ return false;
27
+ }
28
+
29
+ if (parent.type === 'BlockStatement' || parent.type === 'Program') {
30
+ const next = parent.body[parent.body.indexOf(node) + 1];
31
+ if (next) {
32
+ // A `return`/`throw` immediately after the skip itself is the correct pattern.
33
+ if (node === skipStatement && (next.type === 'ReturnStatement' || next.type === 'ThrowStatement')) {
34
+ return false;
35
+ }
36
+
37
+ return true;
38
+ }
39
+ }
40
+
41
+ if (isFunction(parent)) {
42
+ return false;
43
+ }
44
+
45
+ node = parent;
46
+ }
47
+
48
+ return false;
49
+ }
50
+
51
+ /** @param {import('eslint').Rule.RuleContext} context */
52
+ const create = context => {
53
+ const {sourceCode} = context;
54
+ const imports = resolveImports(context);
55
+ if (!imports.isTestFile) {
56
+ return;
57
+ }
58
+
59
+ const tracker = createContextTracker(imports);
60
+
61
+ context.on('CallExpression', node => {
62
+ let problem;
63
+
64
+ const {callee, parent} = node;
65
+ if (
66
+ parent.type === 'ExpressionStatement'
67
+ && callee.type === 'MemberExpression'
68
+ && !callee.computed
69
+ && callee.property.type === 'Identifier'
70
+ && SKIP_METHODS.has(callee.property.name)
71
+ && callee.object.type === 'Identifier'
72
+ && tracker.isContextName(callee.object.name)
73
+ && hasCodeAfter(parent)
74
+ ) {
75
+ const {name} = callee.object;
76
+ const method = callee.property.name;
77
+ problem = {
78
+ node,
79
+ messageId: MESSAGE_ID_ERROR,
80
+ data: {name, method},
81
+ };
82
+
83
+ // Only suggest inserting `return` when the skip is in a block; in a braceless
84
+ // `if (x) t.skip()` the inserted `return` would escape the condition.
85
+ if (parent.parent.type === 'BlockStatement') {
86
+ problem.suggest = [
87
+ {
88
+ messageId: MESSAGE_ID_SUGGESTION,
89
+ data: {name, method},
90
+ fix(fixer) {
91
+ // Insert `return;` on its own line, matching the skip statement's indentation.
92
+ const [start] = sourceCode.getRange(parent);
93
+ const lineStart = sourceCode.text.lastIndexOf('\n', start - 1) + 1;
94
+ const [indentation] = /^\s*/.exec(sourceCode.text.slice(lineStart, start));
95
+ return fixer.insertTextAfter(parent, `\n${indentation}return;`);
96
+ },
97
+ },
98
+ ];
99
+ }
100
+ }
101
+
102
+ tracker.update(node);
103
+ return problem;
104
+ });
105
+
106
+ context.onExit('CallExpression', node => {
107
+ tracker.leave(node);
108
+ });
109
+ };
110
+
111
+ /** @type {import('eslint').Rule.RuleModule} */
112
+ const config = {
113
+ create,
114
+ meta: {
115
+ type: 'problem',
116
+ docs: {
117
+ description: 'Disallow `t.skip()`/`t.todo()` without returning afterwards.',
118
+ recommended: true,
119
+ },
120
+ hasSuggestions: true,
121
+ schema: [],
122
+ messages,
123
+ languages: ['js/js'],
124
+ },
125
+ };
126
+
127
+ export default config;
@@ -0,0 +1,51 @@
1
+ import {resolveImports, parseAssertionCall} from './utils/node-test.js';
2
+ import isFunction from './ast/is-function.js';
3
+
4
+ const MESSAGE_ID = 'no-standalone-assert';
5
+
6
+ const messages = {
7
+ [MESSAGE_ID]: 'Assertion runs when the module is loaded, not as part of a test. Move it into a test or hook.',
8
+ };
9
+
10
+ /** @param {import('eslint').Rule.RuleContext} context */
11
+ const create = context => {
12
+ const imports = resolveImports(context);
13
+ if (!imports.isTestFile || !imports.hasAssert) {
14
+ return;
15
+ }
16
+
17
+ context.on('CallExpression', node => {
18
+ if (!parseAssertionCall(node, imports)) {
19
+ return;
20
+ }
21
+
22
+ // Any enclosing function (test/hook callback or a helper) means it is not standalone.
23
+ for (let current = node.parent; current; current = current.parent) {
24
+ if (isFunction(current)) {
25
+ return;
26
+ }
27
+ }
28
+
29
+ return {
30
+ node,
31
+ messageId: MESSAGE_ID,
32
+ };
33
+ });
34
+ };
35
+
36
+ /** @type {import('eslint').Rule.RuleModule} */
37
+ const config = {
38
+ create,
39
+ meta: {
40
+ type: 'problem',
41
+ docs: {
42
+ description: 'Disallow assertions outside of a test.',
43
+ recommended: 'unopinionated',
44
+ },
45
+ schema: [],
46
+ messages,
47
+ languages: ['js/js'],
48
+ },
49
+ };
50
+
51
+ export default config;
@@ -0,0 +1,68 @@
1
+ import {resolveImports, parseTestCall, getTestCallback} from './utils/node-test.js';
2
+
3
+ const MESSAGE_ID = 'no-test-inside-hook';
4
+
5
+ const messages = {
6
+ [MESSAGE_ID]: 'Do not define a test or suite inside a hook. Define it at the top level or inside a `describe`.',
7
+ };
8
+
9
+ /** @param {import('eslint').Rule.RuleContext} context */
10
+ const create = context => {
11
+ const imports = resolveImports(context);
12
+ if (!imports.isTestFile) {
13
+ return;
14
+ }
15
+
16
+ // Stack of hook callback function nodes we are currently inside.
17
+ const hookCallbackStack = [];
18
+
19
+ context.on('CallExpression', node => {
20
+ const parsed = parseTestCall(node, imports);
21
+ if (!parsed) {
22
+ return;
23
+ }
24
+
25
+ if ((parsed.kind === 'test' || parsed.kind === 'suite') && hookCallbackStack.length > 0) {
26
+ return {
27
+ node,
28
+ messageId: MESSAGE_ID,
29
+ };
30
+ }
31
+
32
+ if (parsed.kind === 'hook') {
33
+ const callback = getTestCallback(node);
34
+ if (callback) {
35
+ hookCallbackStack.push(callback);
36
+ }
37
+ }
38
+ });
39
+
40
+ context.onExit('CallExpression', node => {
41
+ const parsed = parseTestCall(node, imports);
42
+ if (parsed?.kind !== 'hook') {
43
+ return;
44
+ }
45
+
46
+ const callback = getTestCallback(node);
47
+ if (callback && hookCallbackStack.at(-1) === callback) {
48
+ hookCallbackStack.pop();
49
+ }
50
+ });
51
+ };
52
+
53
+ /** @type {import('eslint').Rule.RuleModule} */
54
+ const config = {
55
+ create,
56
+ meta: {
57
+ type: 'problem',
58
+ docs: {
59
+ description: 'Disallow defining tests and suites inside a hook.',
60
+ recommended: 'unopinionated',
61
+ },
62
+ schema: [],
63
+ messages,
64
+ languages: ['js/js'],
65
+ },
66
+ };
67
+
68
+ export default config;
@@ -0,0 +1,114 @@
1
+ import {resolveImports, parseTestCall, getTestCallback} from './utils/node-test.js';
2
+ import isFunction from './ast/is-function.js';
3
+ import isPromiseType from './utils/is-promise-type.js';
4
+
5
+ const MESSAGE_ID = 'no-test-return-statement';
6
+
7
+ const messages = {
8
+ [MESSAGE_ID]: 'Do not return a value from a test. Return a Promise to signal async completion, or return nothing.',
9
+ };
10
+
11
+ // "Nothing" types and types we cannot pin down — all fine to return.
12
+ const ALLOWED_TYPE_STRINGS = new Set(['void', 'undefined', 'null', 'any', 'never', 'unknown']);
13
+
14
+ /*
15
+ Whether the type is safe to return from a test: a returned Promise (awaited by `node:test`),
16
+ a "nothing" type, or a type we cannot resolve. Anything else is a concrete value.
17
+ */
18
+ function isAllowedReturnType(type, checker) {
19
+ // `isPromiseType` returns `undefined` when indeterminate; only a definite `false` is a value.
20
+ if (isPromiseType(type, checker) !== false) {
21
+ return true;
22
+ }
23
+
24
+ if (type.isUnion()) {
25
+ return type.types.every(member => isAllowedReturnType(member, checker));
26
+ }
27
+
28
+ return ALLOWED_TYPE_STRINGS.has(checker.typeToString(type));
29
+ }
30
+
31
+ /** @param {import('eslint').Rule.RuleContext} context */
32
+ const create = context => {
33
+ const imports = resolveImports(context);
34
+ if (!imports.isTestFile) {
35
+ return;
36
+ }
37
+
38
+ // This rule relies on type information to tell a Promise from a plain value. Without it
39
+ // (plain JavaScript, or TypeScript linted without a program), do nothing.
40
+ const {parserServices} = context.sourceCode;
41
+ if (!parserServices?.program) {
42
+ return;
43
+ }
44
+
45
+ const checker = parserServices.program.getTypeChecker();
46
+ const testCallbacks = new Set();
47
+
48
+ context.on('CallExpression', node => {
49
+ const parsed = parseTestCall(node, imports);
50
+ if (parsed?.kind !== 'test') {
51
+ return;
52
+ }
53
+
54
+ const callback = getTestCallback(node);
55
+ if (callback) {
56
+ testCallbacks.add(callback);
57
+ }
58
+ });
59
+
60
+ context.on('ReturnStatement', node => {
61
+ if (!node.argument) {
62
+ return;
63
+ }
64
+
65
+ // The return must belong to the test callback itself, not a nested helper function.
66
+ let enclosing = node.parent;
67
+ while (enclosing && !isFunction(enclosing)) {
68
+ enclosing = enclosing.parent;
69
+ }
70
+
71
+ if (!enclosing || !testCallbacks.has(enclosing)) {
72
+ return;
73
+ }
74
+
75
+ // An `async` function always wraps its return value in a Promise, which `node:test`
76
+ // awaits, so returning a plain value there is fine.
77
+ if (enclosing.async) {
78
+ return;
79
+ }
80
+
81
+ let type;
82
+ try {
83
+ type = parserServices.getTypeAtLocation(node.argument);
84
+ } catch {
85
+ return;
86
+ }
87
+
88
+ if (isAllowedReturnType(type, checker)) {
89
+ return;
90
+ }
91
+
92
+ return {
93
+ node,
94
+ messageId: MESSAGE_ID,
95
+ };
96
+ });
97
+ };
98
+
99
+ /** @type {import('eslint').Rule.RuleModule} */
100
+ const config = {
101
+ create,
102
+ meta: {
103
+ type: 'suggestion',
104
+ docs: {
105
+ description: 'Disallow returning a non-Promise value from a test.',
106
+ recommended: true,
107
+ },
108
+ schema: [],
109
+ messages,
110
+ languages: ['js/js'],
111
+ },
112
+ };
113
+
114
+ export default config;
@@ -0,0 +1,11 @@
1
+ import createTestModifierRule from './shared/test-modifier-rule.js';
2
+
3
+ /** @type {import('eslint').Rule.RuleModule} */
4
+ const config = createTestModifierRule({
5
+ modifier: 'todo',
6
+ description: 'Disallow the `.todo` test modifier.',
7
+ errorMessage: 'Do not commit `.todo` tests.',
8
+ recommended: false,
9
+ });
10
+
11
+ export default config;