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,203 @@
1
+ import {findVariable} from '@eslint-community/eslint-utils';
2
+ import {resolveImports, parseTestCall, getTestCallback} from './utils/node-test.js';
3
+
4
+ const MESSAGE_ID = 'prefer-async-await/error';
5
+
6
+ const messages = {
7
+ [MESSAGE_ID]: 'Prefer async/await instead of returning a Promise.',
8
+ };
9
+
10
+ /**
11
+ Collect all `return` statements that are directly inside `block`, descending
12
+ into control-flow nodes but not into nested functions.
13
+
14
+ @param {import('estree').BlockStatement} block
15
+ @returns {import('estree').ReturnStatement[]}
16
+ */
17
+ function findReturnStatements(block) {
18
+ const results = [];
19
+
20
+ function walk(node) {
21
+ if (!node) {
22
+ return;
23
+ }
24
+
25
+ switch (node.type) {
26
+ case 'ReturnStatement': {
27
+ results.push(node);
28
+ break;
29
+ }
30
+
31
+ case 'BlockStatement': {
32
+ for (const statement of node.body) {
33
+ walk(statement);
34
+ }
35
+
36
+ break;
37
+ }
38
+
39
+ case 'IfStatement': {
40
+ walk(node.consequent);
41
+ walk(node.alternate);
42
+ break;
43
+ }
44
+
45
+ case 'SwitchStatement': {
46
+ for (const switchCase of node.cases) {
47
+ for (const statement of switchCase.consequent) {
48
+ walk(statement);
49
+ }
50
+ }
51
+
52
+ break;
53
+ }
54
+
55
+ case 'TryStatement': {
56
+ walk(node.block);
57
+ if (node.handler) {
58
+ walk(node.handler.body);
59
+ }
60
+
61
+ walk(node.finalizer);
62
+ break;
63
+ }
64
+
65
+ case 'ForStatement':
66
+ case 'ForInStatement':
67
+ case 'ForOfStatement':
68
+ case 'WhileStatement':
69
+ case 'DoWhileStatement':
70
+ case 'LabeledStatement':
71
+ case 'WithStatement': {
72
+ walk(node.body);
73
+ break;
74
+ }
75
+
76
+ // Do not descend into nested functions
77
+ default: {
78
+ break;
79
+ }
80
+ }
81
+ }
82
+
83
+ walk(block);
84
+
85
+ return results;
86
+ }
87
+
88
+ /**
89
+ Check whether a node contains a `.then(...)` call anywhere in its `.then`/`.catch`/`.finally`
90
+ member chain.
91
+
92
+ @param {import('estree').Node | null | undefined} node
93
+ @returns {boolean}
94
+ */
95
+ function containsThen(node) {
96
+ if (!node) {
97
+ return false;
98
+ }
99
+
100
+ if (node.type === 'ChainExpression') {
101
+ return containsThen(node.expression);
102
+ }
103
+
104
+ if (
105
+ node.type !== 'CallExpression'
106
+ || node.callee.type !== 'MemberExpression'
107
+ ) {
108
+ return false;
109
+ }
110
+
111
+ const {callee} = node;
112
+ if (
113
+ callee.property.type === 'Identifier'
114
+ && callee.property.name === 'then'
115
+ ) {
116
+ return true;
117
+ }
118
+
119
+ return containsThen(callee.object);
120
+ }
121
+
122
+ /** @param {import('eslint').Rule.RuleContext} context */
123
+ const create = context => {
124
+ const {sourceCode} = context;
125
+ const imports = resolveImports(context);
126
+ if (!imports.isTestFile) {
127
+ return;
128
+ }
129
+
130
+ context.on('CallExpression', node => {
131
+ const parsed = parseTestCall(node, imports);
132
+ if (!parsed) {
133
+ return;
134
+ }
135
+
136
+ // `describe`/`suite` callbacks run synchronously and are never awaited, so returning a
137
+ // Promise from them is meaningless and converting to async/await would not help.
138
+ if (parsed.kind === 'suite') {
139
+ return;
140
+ }
141
+
142
+ const callback = getTestCallback(node);
143
+ // Only flag non-async functions with a block body (arrow shorthand already returns)
144
+ if (!callback || callback.async || callback.body.type !== 'BlockStatement') {
145
+ return;
146
+ }
147
+
148
+ const returnStatements = findReturnStatements(callback.body);
149
+ if (returnStatements.length === 0) {
150
+ return;
151
+ }
152
+
153
+ // Flag if any return statement returns a .then() call chain
154
+ for (const returnStatement of returnStatements) {
155
+ if (containsThen(returnStatement.argument)) {
156
+ return {
157
+ node: callback,
158
+ messageId: MESSAGE_ID,
159
+ };
160
+ }
161
+ }
162
+
163
+ // Flag if any return statement returns a variable that was assigned from a .then() call
164
+ for (const returnStatement of returnStatements) {
165
+ if (returnStatement.argument?.type !== 'Identifier') {
166
+ continue;
167
+ }
168
+
169
+ const variable = findVariable(sourceCode.getScope(returnStatement), returnStatement.argument);
170
+ if (!variable) {
171
+ continue;
172
+ }
173
+
174
+ for (const definition of variable.defs) {
175
+ if (definition.type !== 'Variable' || !containsThen(definition.node.init)) {
176
+ continue;
177
+ }
178
+
179
+ return {
180
+ node: callback,
181
+ messageId: MESSAGE_ID,
182
+ };
183
+ }
184
+ }
185
+ });
186
+ };
187
+
188
+ /** @type {import('eslint').Rule.RuleModule} */
189
+ const config = {
190
+ create,
191
+ meta: {
192
+ type: 'suggestion',
193
+ docs: {
194
+ description: 'Prefer async/await over returning a Promise.',
195
+ recommended: true,
196
+ },
197
+ schema: [],
198
+ messages,
199
+ languages: ['js/js'],
200
+ },
201
+ };
202
+
203
+ export default config;
@@ -0,0 +1,59 @@
1
+ import {resolveImports, isGlobalMock} from './utils/node-test.js';
2
+
3
+ const MESSAGE_ID = 'prefer-context-mock';
4
+
5
+ const messages = {
6
+ [MESSAGE_ID]: 'Prefer `t.mock.{{accessor}}` over the global `mock.{{accessor}}`, which is not automatically restored between tests.',
7
+ };
8
+
9
+ // Accessors that create state which the global `mock` does not auto-restore.
10
+ const STATEFUL_ACCESSORS = new Set(['fn', 'method', 'getter', 'setter', 'property', 'module', 'timers']);
11
+
12
+ /** @param {import('eslint').Rule.RuleContext} context */
13
+ const create = context => {
14
+ const imports = resolveImports(context);
15
+ if (!imports.isTestFile) {
16
+ return;
17
+ }
18
+
19
+ if (imports.mockLocals.size === 0 && !imports.namespace) {
20
+ return;
21
+ }
22
+
23
+ context.on('CallExpression', node => {
24
+ let member = node.callee;
25
+ while (member.type === 'MemberExpression') {
26
+ if (isGlobalMock(member.object, imports) && !member.computed && member.property.type === 'Identifier') {
27
+ const accessor = member.property.name;
28
+ if (STATEFUL_ACCESSORS.has(accessor)) {
29
+ return {
30
+ node,
31
+ messageId: MESSAGE_ID,
32
+ data: {accessor},
33
+ };
34
+ }
35
+
36
+ return;
37
+ }
38
+
39
+ member = member.object;
40
+ }
41
+ });
42
+ };
43
+
44
+ /** @type {import('eslint').Rule.RuleModule} */
45
+ const config = {
46
+ create,
47
+ meta: {
48
+ type: 'suggestion',
49
+ docs: {
50
+ description: 'Prefer the test context `t.mock` over the global `mock`.',
51
+ recommended: true,
52
+ },
53
+ schema: [],
54
+ messages,
55
+ languages: ['js/js'],
56
+ },
57
+ };
58
+
59
+ export default config;
@@ -0,0 +1,94 @@
1
+ import {resolveImports, createContextTracker} from './utils/node-test.js';
2
+
3
+ const MESSAGE_ID_ERROR = 'prefer-diagnostic/error';
4
+ const MESSAGE_ID_SUGGESTION = 'prefer-diagnostic/suggestion';
5
+
6
+ const messages = {
7
+ [MESSAGE_ID_ERROR]: 'Prefer `{{context}}.diagnostic()` over `console.{{method}}()` inside a test, so the message is attached to the test as a TAP diagnostic.',
8
+ [MESSAGE_ID_SUGGESTION]: 'Replace with `{{context}}.diagnostic()`.',
9
+ };
10
+
11
+ const CONSOLE_METHODS = new Set(['log', 'info', 'debug']);
12
+
13
+ /** @param {import('eslint').Rule.RuleContext} context */
14
+ const create = context => {
15
+ const imports = resolveImports(context);
16
+ if (!imports.isTestFile) {
17
+ return;
18
+ }
19
+
20
+ const tracker = createContextTracker(imports);
21
+
22
+ context.on('CallExpression', node => {
23
+ tracker.update(node);
24
+
25
+ const {callee} = node;
26
+ if (
27
+ callee.type !== 'MemberExpression'
28
+ || callee.computed
29
+ || callee.object.type !== 'Identifier'
30
+ || callee.object.name !== 'console'
31
+ || callee.property.type !== 'Identifier'
32
+ || !CONSOLE_METHODS.has(callee.property.name)
33
+ ) {
34
+ return;
35
+ }
36
+
37
+ const contextName = tracker.current();
38
+ if (!contextName) {
39
+ return;
40
+ }
41
+
42
+ // The context parameter is only in scope inside the test callback. Skip console calls in the
43
+ // title/options arguments (visited before the callback), where the context does not exist.
44
+ const callback = tracker.currentCallback();
45
+ const [callStart, callEnd] = context.sourceCode.getRange(callback);
46
+ const [consoleStart] = context.sourceCode.getRange(node);
47
+ if (consoleStart < callStart || consoleStart >= callEnd) {
48
+ return;
49
+ }
50
+
51
+ const method = callee.property.name;
52
+ const data = {context: contextName, method};
53
+ const problem = {
54
+ node: callee,
55
+ messageId: MESSAGE_ID_ERROR,
56
+ data,
57
+ };
58
+
59
+ // `diagnostic()` takes a single message, so only suggest a rewrite for a single argument.
60
+ if (node.arguments.length === 1) {
61
+ problem.suggest = [
62
+ {
63
+ messageId: MESSAGE_ID_SUGGESTION,
64
+ data,
65
+ fix: fixer => fixer.replaceText(callee, `${contextName}.diagnostic`),
66
+ },
67
+ ];
68
+ }
69
+
70
+ return problem;
71
+ });
72
+
73
+ context.onExit('CallExpression', node => {
74
+ tracker.leave(node);
75
+ });
76
+ };
77
+
78
+ /** @type {import('eslint').Rule.RuleModule} */
79
+ const config = {
80
+ create,
81
+ meta: {
82
+ type: 'suggestion',
83
+ docs: {
84
+ description: 'Prefer the test context `diagnostic()` over `console` inside tests.',
85
+ recommended: false,
86
+ },
87
+ hasSuggestions: true,
88
+ schema: [],
89
+ messages,
90
+ languages: ['js/js'],
91
+ },
92
+ };
93
+
94
+ export default config;
@@ -0,0 +1,101 @@
1
+ import {resolveImports, parseAssertionCall} from './utils/node-test.js';
2
+ import {isParenthesized, getParenthesizedRange} from './utils/index.js';
3
+ import unwrapTypeScriptExpression from './utils/unwrap-typescript-expression.js';
4
+
5
+ const MESSAGE_ID = 'prefer-equality-assertion';
6
+
7
+ const messages = {
8
+ [MESSAGE_ID]: 'Prefer `{{replacement}}` over `{{method}}` with a `{{operator}}` comparison for a clearer failure message.',
9
+ };
10
+
11
+ // Comparison operators and the assertion that preserves their semantics.
12
+ const OPERATOR_TO_METHOD = new Map([
13
+ ['===', 'strictEqual'],
14
+ ['!==', 'notStrictEqual'],
15
+ ['==', 'equal'],
16
+ ['!=', 'notEqual'],
17
+ ]);
18
+
19
+ /** @param {import('eslint').Rule.RuleContext} context */
20
+ const create = context => {
21
+ const {sourceCode} = context;
22
+ const imports = resolveImports(context);
23
+ if (!imports.isAssertOrTestFile) {
24
+ return;
25
+ }
26
+
27
+ context.on('CallExpression', node => {
28
+ const parsed = parseAssertionCall(node, imports);
29
+ // Only the truthiness assertions (`assert(…)` / `assert.ok(…)`) benefit.
30
+ if (parsed?.method !== 'ok') {
31
+ return;
32
+ }
33
+
34
+ const [rawArgument] = node.arguments;
35
+ // Unwrap TypeScript casts (`(a === b) as boolean`) so the comparison is still recognized.
36
+ const argument = rawArgument && unwrapTypeScriptExpression(rawArgument);
37
+ if (argument?.type !== 'BinaryExpression') {
38
+ return;
39
+ }
40
+
41
+ const replacement = OPERATOR_TO_METHOD.get(argument.operator);
42
+ if (!replacement) {
43
+ return;
44
+ }
45
+
46
+ const {callee} = node;
47
+ const method = callee.type === 'MemberExpression' ? callee.property.name : 'ok';
48
+
49
+ const problem = {
50
+ node,
51
+ messageId: MESSAGE_ID,
52
+ data: {method, replacement, operator: argument.operator},
53
+ };
54
+
55
+ // Skip the autofix when it would be unsafe: extra parentheses or a comment inside
56
+ // the comparison would be mangled by the rewrite, and a bare named import (`ok`)
57
+ // cannot be rewritten to an unimported `strictEqual`.
58
+ const isBareNamedImport = callee.type === 'Identifier' && !imports.assertNamespace.has(callee.name);
59
+ if (
60
+ isBareNamedImport
61
+ || isParenthesized(argument, context)
62
+ || sourceCode.getCommentsInside(argument).length > 0
63
+ ) {
64
+ return problem;
65
+ }
66
+
67
+ problem.fix = function * (fixer) {
68
+ // `assert.ok(…)` / `t.assert.ok(…)` rewrite just the method, while the bare
69
+ // `assert(…)` namespace function becomes `assert.strictEqual(…)`.
70
+ yield callee.type === 'MemberExpression'
71
+ ? fixer.replaceText(callee.property, replacement)
72
+ : fixer.replaceText(callee, `${callee.name}.${replacement}`);
73
+
74
+ // Split the comparison into two arguments: `left === right` -> `left, right`.
75
+ // Use the parenthesized ranges so parenthesized operands stay intact.
76
+ const leftEnd = getParenthesizedRange(argument.left, context)[1];
77
+ const rightStart = getParenthesizedRange(argument.right, context)[0];
78
+ yield fixer.replaceTextRange([leftEnd, rightStart], ', ');
79
+ };
80
+
81
+ return problem;
82
+ });
83
+ };
84
+
85
+ /** @type {import('eslint').Rule.RuleModule} */
86
+ const config = {
87
+ create,
88
+ meta: {
89
+ type: 'suggestion',
90
+ docs: {
91
+ description: 'Prefer an equality assertion over a truthiness assertion on a comparison.',
92
+ recommended: 'unopinionated',
93
+ },
94
+ fixable: 'code',
95
+ schema: [],
96
+ messages,
97
+ languages: ['js/js'],
98
+ },
99
+ };
100
+
101
+ export default config;
@@ -0,0 +1,73 @@
1
+ import {resolveImports, parseTestCall, getTestCallback} from './utils/node-test.js';
2
+
3
+ const MESSAGE_ID = 'prefer-hooks-on-top';
4
+
5
+ const messages = {
6
+ [MESSAGE_ID]: 'Hook `{{name}}` should come before any test or `describe` in its scope.',
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 scopes; each tracks whether a test/suite has appeared in it yet.
17
+ const scopeStack = [{seenTest: false}];
18
+ const pushedCalls = new Set();
19
+
20
+ context.on('CallExpression', node => {
21
+ const parsed = parseTestCall(node, imports);
22
+ if (!parsed) {
23
+ return;
24
+ }
25
+
26
+ const scope = scopeStack.at(-1);
27
+
28
+ let problem;
29
+ if (parsed.kind === 'hook' && scope.seenTest) {
30
+ problem = {
31
+ node,
32
+ messageId: MESSAGE_ID,
33
+ data: {name: parsed.name},
34
+ };
35
+ }
36
+
37
+ if (parsed.kind === 'test' || parsed.kind === 'suite') {
38
+ scope.seenTest = true;
39
+
40
+ const callback = getTestCallback(node);
41
+ if (callback) {
42
+ scopeStack.push({seenTest: false});
43
+ pushedCalls.add(node);
44
+ }
45
+ }
46
+
47
+ return problem;
48
+ });
49
+
50
+ context.onExit('CallExpression', node => {
51
+ if (pushedCalls.has(node)) {
52
+ pushedCalls.delete(node);
53
+ scopeStack.pop();
54
+ }
55
+ });
56
+ };
57
+
58
+ /** @type {import('eslint').Rule.RuleModule} */
59
+ const config = {
60
+ create,
61
+ meta: {
62
+ type: 'suggestion',
63
+ docs: {
64
+ description: 'Require hooks to be declared before the tests in their scope.',
65
+ recommended: true,
66
+ },
67
+ schema: [],
68
+ messages,
69
+ languages: ['js/js'],
70
+ },
71
+ };
72
+
73
+ export default config;
@@ -0,0 +1,119 @@
1
+ import {resolveImports, parseTestCall, getTestTitle} from './utils/node-test.js';
2
+
3
+ const MESSAGE_ID = 'prefer-lowercase-title';
4
+
5
+ const messages = {
6
+ [MESSAGE_ID]: 'Start the title with a lowercase letter.',
7
+ };
8
+
9
+ /** Get the static text at the start of a title node, or `undefined` if there is none. */
10
+ const getLeadingText = node => {
11
+ if (node.type === 'Literal' && typeof node.value === 'string') {
12
+ return node.value;
13
+ }
14
+
15
+ // `` `text ${x}` `` — the text before the first expression. Empty if it starts with `${…}`.
16
+ if (node.type === 'TemplateLiteral') {
17
+ return node.quasis[0].value.cooked || undefined;
18
+ }
19
+
20
+ return undefined;
21
+ };
22
+
23
+ /** @param {import('eslint').Rule.RuleContext} context */
24
+ const create = context => {
25
+ const {sourceCode} = context;
26
+ const imports = resolveImports(context);
27
+ if (!imports.isTestFile) {
28
+ return;
29
+ }
30
+
31
+ const {ignore, allowedPrefixes} = context.options[0];
32
+
33
+ context.on('CallExpression', node => {
34
+ const parsed = parseTestCall(node, imports);
35
+ if (parsed?.kind !== 'test' && parsed?.kind !== 'suite') {
36
+ return;
37
+ }
38
+
39
+ if (ignore.includes(parsed.name)) {
40
+ return;
41
+ }
42
+
43
+ const titleNode = getTestTitle(node, context);
44
+ if (!titleNode) {
45
+ return;
46
+ }
47
+
48
+ const leadingText = getLeadingText(titleNode);
49
+ if (!leadingText) {
50
+ return;
51
+ }
52
+
53
+ if (allowedPrefixes.some(prefix => leadingText.startsWith(prefix))) {
54
+ return;
55
+ }
56
+
57
+ const firstCharacter = leadingText[0];
58
+ if (!/\p{Uppercase_Letter}/u.test(firstCharacter)) {
59
+ return;
60
+ }
61
+
62
+ const problem = {
63
+ node: titleNode,
64
+ messageId: MESSAGE_ID,
65
+ };
66
+
67
+ // The first content character sits right after the opening quote/backtick.
68
+ const start = sourceCode.getRange(titleNode)[0] + 1;
69
+ // Skip the fix when the first character is written as a Unicode/hex escape, so the
70
+ // raw source does not start with the letter itself and replacing it would corrupt the escape.
71
+ if (sourceCode.getText(titleNode)[1] === firstCharacter) {
72
+ problem.fix = fixer => fixer.replaceTextRange([start, start + 1], firstCharacter.toLowerCase());
73
+ }
74
+
75
+ return problem;
76
+ });
77
+ };
78
+
79
+ /** @type {import('eslint').Rule.RuleModule} */
80
+ const config = {
81
+ create,
82
+ meta: {
83
+ type: 'suggestion',
84
+ docs: {
85
+ description: 'Enforce lowercase test titles.',
86
+ recommended: false,
87
+ },
88
+ fixable: 'code',
89
+ schema: [
90
+ {
91
+ type: 'object',
92
+ properties: {
93
+ ignore: {
94
+ type: 'array',
95
+ items: {
96
+ enum: ['test', 'it', 'describe', 'suite'],
97
+ },
98
+ uniqueItems: true,
99
+ description: 'Test functions whose titles are not checked.',
100
+ },
101
+ allowedPrefixes: {
102
+ type: 'array',
103
+ items: {
104
+ type: 'string',
105
+ },
106
+ uniqueItems: true,
107
+ description: 'Title prefixes that are allowed to start with an uppercase letter.',
108
+ },
109
+ },
110
+ additionalProperties: false,
111
+ },
112
+ ],
113
+ defaultOptions: [{ignore: [], allowedPrefixes: []}],
114
+ messages,
115
+ languages: ['js/js'],
116
+ },
117
+ };
118
+
119
+ export default config;