eslint-plugin-test-flakiness 1.2.0 → 1.3.0
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.
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
'use strict';
|
|
6
6
|
|
|
7
|
-
const { isTestFile, getFilename } = require('../utils/helpers');
|
|
7
|
+
const { isTestFile, getFilename, findEnclosingFunction, ensureAsyncFunction, addWaitForImport } = require('../utils/helpers');
|
|
8
8
|
|
|
9
9
|
module.exports = {
|
|
10
10
|
meta: {
|
|
@@ -15,11 +15,13 @@ module.exports = {
|
|
|
15
15
|
recommended: true,
|
|
16
16
|
url: 'https://github.com/tigredonorte/eslint-plugin-test-flakiness/blob/main/docs/rules/no-element-removal-check.md'
|
|
17
17
|
},
|
|
18
|
+
fixable: 'code',
|
|
18
19
|
schema: [],
|
|
19
20
|
messages: {
|
|
20
21
|
avoidRemovalCheck: 'Checking for element removal can be flaky. Use waitForElementToBeRemoved or wait for a positive condition instead.',
|
|
21
|
-
useWaitForRemoval: '
|
|
22
|
-
avoidNotInDocument: 'Avoid .not.toBeInTheDocument() without proper waiting.'
|
|
22
|
+
useWaitForRemoval: 'Avoid checking for null/undefined without proper waiting. Wrap in waitFor() to handle timing.',
|
|
23
|
+
avoidNotInDocument: 'Avoid .not.toBeInTheDocument() without proper waiting.',
|
|
24
|
+
avoidNotVisibleWithoutWaitFor: 'Avoid .not.toBeVisible() without proper waiting. Wrap in waitFor() to handle timing.'
|
|
23
25
|
}
|
|
24
26
|
},
|
|
25
27
|
|
|
@@ -28,33 +30,65 @@ module.exports = {
|
|
|
28
30
|
return {};
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
function isInsideWaitFor(node) {
|
|
34
|
+
let parent = node.parent;
|
|
35
|
+
while (parent && parent.type !== 'Program') {
|
|
36
|
+
if (parent.type === 'CallExpression') {
|
|
37
|
+
const calleeName = parent.callee.name ||
|
|
38
|
+
(parent.callee.property && parent.callee.property.name);
|
|
39
|
+
if (calleeName === 'waitFor' || calleeName === 'waitForElementToBeRemoved') {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
parent = parent.parent;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function createWaitForFix(node) {
|
|
49
|
+
return function(fixer) {
|
|
50
|
+
const sourceCode = context.getSourceCode();
|
|
51
|
+
// Find the containing expression statement
|
|
52
|
+
let statement = node.parent;
|
|
53
|
+
while (statement && statement.type !== 'ExpressionStatement') {
|
|
54
|
+
statement = statement.parent;
|
|
55
|
+
}
|
|
56
|
+
if (!statement || !statement.expression) return null;
|
|
57
|
+
|
|
58
|
+
const importFixes = addWaitForImport(fixer, context);
|
|
59
|
+
if (importFixes === null) return null; // Skip fix for incompatible frameworks
|
|
60
|
+
|
|
61
|
+
const funcNode = findEnclosingFunction(node);
|
|
62
|
+
const asyncFixes = ensureAsyncFunction(fixer, funcNode);
|
|
63
|
+
if (asyncFixes === null) return null; // Skip fix for getters/setters/constructors
|
|
64
|
+
|
|
65
|
+
const statementText = sourceCode.getText(statement.expression);
|
|
66
|
+
|
|
67
|
+
return [
|
|
68
|
+
fixer.replaceText(
|
|
69
|
+
statement,
|
|
70
|
+
`await waitFor(() => { ${statementText}; });`
|
|
71
|
+
),
|
|
72
|
+
...asyncFixes,
|
|
73
|
+
...importFixes
|
|
74
|
+
];
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
31
78
|
function checkNotToBeInTheDocument(node) {
|
|
32
79
|
// Check for expect().not.toBeInTheDocument() patterns
|
|
33
80
|
if (node.callee.type === 'MemberExpression' &&
|
|
34
81
|
node.callee.property.name === 'toBeInTheDocument') {
|
|
35
|
-
|
|
82
|
+
|
|
36
83
|
const expectCall = node.callee.object;
|
|
37
84
|
if (expectCall.type === 'MemberExpression' &&
|
|
38
85
|
expectCall.property.name === 'not') {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
let parent = node.parent;
|
|
42
|
-
let insideWaitFor = false;
|
|
43
|
-
|
|
44
|
-
while (parent && parent.type !== 'Program') {
|
|
45
|
-
if (parent.type === 'CallExpression' &&
|
|
46
|
-
(parent.callee.name === 'waitFor' ||
|
|
47
|
-
parent.callee.name === 'waitForElementToBeRemoved')) {
|
|
48
|
-
insideWaitFor = true;
|
|
49
|
-
break;
|
|
50
|
-
}
|
|
51
|
-
parent = parent.parent;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
if (!insideWaitFor) {
|
|
86
|
+
|
|
87
|
+
if (!isInsideWaitFor(node)) {
|
|
55
88
|
context.report({
|
|
56
89
|
node,
|
|
57
|
-
messageId: 'avoidNotInDocument'
|
|
90
|
+
messageId: 'avoidNotInDocument',
|
|
91
|
+
fix: createWaitForFix(node)
|
|
58
92
|
});
|
|
59
93
|
}
|
|
60
94
|
}
|
|
@@ -88,10 +122,11 @@ module.exports = {
|
|
|
88
122
|
arg.callee.property &&
|
|
89
123
|
(/^query/.test(arg.callee.property.name) || arg.callee.property.name === 'querySelector');
|
|
90
124
|
|
|
91
|
-
if (isQueryMethod || isScreenQuery || isContainerQuery) {
|
|
125
|
+
if ((isQueryMethod || isScreenQuery || isContainerQuery) && !isInsideWaitFor(node)) {
|
|
92
126
|
context.report({
|
|
93
127
|
node,
|
|
94
|
-
messageId: 'useWaitForRemoval'
|
|
128
|
+
messageId: 'useWaitForRemoval',
|
|
129
|
+
fix: createWaitForFix(node)
|
|
95
130
|
});
|
|
96
131
|
}
|
|
97
132
|
}
|
|
@@ -125,10 +160,11 @@ module.exports = {
|
|
|
125
160
|
arg.callee.property &&
|
|
126
161
|
/^query/.test(arg.callee.property.name);
|
|
127
162
|
|
|
128
|
-
if (isQueryMethod || isScreenQuery) {
|
|
163
|
+
if ((isQueryMethod || isScreenQuery) && !isInsideWaitFor(node)) {
|
|
129
164
|
context.report({
|
|
130
165
|
node,
|
|
131
|
-
messageId: 'useWaitForRemoval'
|
|
166
|
+
messageId: 'useWaitForRemoval',
|
|
167
|
+
fix: createWaitForFix(node)
|
|
132
168
|
});
|
|
133
169
|
}
|
|
134
170
|
}
|
|
@@ -169,24 +205,11 @@ module.exports = {
|
|
|
169
205
|
if (expectCall.type === 'MemberExpression' &&
|
|
170
206
|
expectCall.property.name === 'not') {
|
|
171
207
|
|
|
172
|
-
|
|
173
|
-
let parent = node.parent;
|
|
174
|
-
let insideWaitFor = false;
|
|
175
|
-
|
|
176
|
-
while (parent && parent.type !== 'Program') {
|
|
177
|
-
if (parent.type === 'CallExpression' &&
|
|
178
|
-
(parent.callee.name === 'waitFor' ||
|
|
179
|
-
parent.callee.name === 'waitForElementToBeRemoved')) {
|
|
180
|
-
insideWaitFor = true;
|
|
181
|
-
break;
|
|
182
|
-
}
|
|
183
|
-
parent = parent.parent;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
if (!insideWaitFor) {
|
|
208
|
+
if (!isInsideWaitFor(node)) {
|
|
187
209
|
context.report({
|
|
188
210
|
node,
|
|
189
|
-
messageId: '
|
|
211
|
+
messageId: 'avoidNotVisibleWithoutWaitFor',
|
|
212
|
+
fix: createWaitForFix(node)
|
|
190
213
|
});
|
|
191
214
|
}
|
|
192
215
|
}
|
|
@@ -16,6 +16,7 @@ module.exports = {
|
|
|
16
16
|
url: 'https://github.com/tigredonorte/eslint-plugin-test-flakiness/blob/main/docs/rules/no-long-text-match.md'
|
|
17
17
|
},
|
|
18
18
|
fixable: null,
|
|
19
|
+
hasSuggestions: true,
|
|
19
20
|
schema: [
|
|
20
21
|
{
|
|
21
22
|
type: 'object',
|
|
@@ -44,7 +45,9 @@ module.exports = {
|
|
|
44
45
|
textTooLong: 'Text match of {{length}} characters is too long and brittle. Use partial matches or data-testid.',
|
|
45
46
|
usePartialMatch: 'Use partial text matching or regex for long content.',
|
|
46
47
|
useTestId: 'Consider using data-testid for element selection instead of text content.',
|
|
47
|
-
avoidExactMatch: 'Exact matching of long dynamic content is fragile.'
|
|
48
|
+
avoidExactMatch: 'Exact matching of long dynamic content is fragile.',
|
|
49
|
+
suggestExactFalse: 'Add { exact: false } option for partial matching.',
|
|
50
|
+
suggestUseRegex: 'Convert to regex pattern for partial matching.'
|
|
48
51
|
}
|
|
49
52
|
},
|
|
50
53
|
|
|
@@ -72,7 +75,43 @@ module.exports = {
|
|
|
72
75
|
DECIMAL_NUMBERS: /\d+[.,]\d+[.,]\d+/ // Multiple decimal/comma separated numbers
|
|
73
76
|
};
|
|
74
77
|
|
|
75
|
-
function
|
|
78
|
+
function createTextSuggestions(callNode, textNode, text) {
|
|
79
|
+
const suggestions = [];
|
|
80
|
+
|
|
81
|
+
// Suggestion 1: Add { exact: false }
|
|
82
|
+
if (callNode && callNode.arguments) {
|
|
83
|
+
const hasSecondArg = callNode.arguments.length > 1;
|
|
84
|
+
if (!hasSecondArg) {
|
|
85
|
+
suggestions.push({
|
|
86
|
+
messageId: 'suggestExactFalse',
|
|
87
|
+
fix(fixer) {
|
|
88
|
+
return fixer.insertTextAfter(textNode, ', { exact: false }');
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Suggestion 2: Convert to regex (use up to 3 significant words for a more targeted pattern)
|
|
95
|
+
if (textNode && textNode.type === 'Literal' && typeof textNode.value === 'string') {
|
|
96
|
+
const words = text.split(/\s+/).filter(w => w.length > 3);
|
|
97
|
+
if (words.length > 0) {
|
|
98
|
+
const keywordWords = words.slice(0, 3).map(w =>
|
|
99
|
+
w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\//g, '\\/')
|
|
100
|
+
);
|
|
101
|
+
const keyword = keywordWords.join('\\s+');
|
|
102
|
+
suggestions.push({
|
|
103
|
+
messageId: 'suggestUseRegex',
|
|
104
|
+
fix(fixer) {
|
|
105
|
+
return fixer.replaceText(textNode, `/${keyword}/`);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return suggestions.length > 0 ? suggestions : undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function checkTextContent(node, text, suggestions) {
|
|
76
115
|
if (typeof text !== 'string') return;
|
|
77
116
|
|
|
78
117
|
// Ignore if it's in a comment
|
|
@@ -94,14 +133,20 @@ module.exports = {
|
|
|
94
133
|
DYNAMIC_PATTERNS.TEMPLATE_VAR.test(text) ||
|
|
95
134
|
DYNAMIC_PATTERNS.DECIMAL_NUMBERS.test(text);
|
|
96
135
|
|
|
97
|
-
|
|
136
|
+
const report = {
|
|
98
137
|
node,
|
|
99
138
|
messageId: isDynamic ? 'avoidExactMatch' : 'textTooLong',
|
|
100
139
|
data: {
|
|
101
140
|
length: text.length,
|
|
102
141
|
maxLength: maxLength
|
|
103
142
|
}
|
|
104
|
-
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
if (suggestions) {
|
|
146
|
+
report.suggest = suggestions;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
context.report(report);
|
|
105
150
|
}
|
|
106
151
|
}
|
|
107
152
|
|
|
@@ -143,7 +188,7 @@ module.exports = {
|
|
|
143
188
|
|
|
144
189
|
if (arg && arg.type === 'Literal') {
|
|
145
190
|
if (typeof arg.value === 'string') {
|
|
146
|
-
checkTextContent(arg, arg.value);
|
|
191
|
+
checkTextContent(arg, arg.value, createTextSuggestions(node, arg, arg.value));
|
|
147
192
|
} else if (arg.regex) {
|
|
148
193
|
// For regex literals, check the pattern length
|
|
149
194
|
const patternLength = arg.regex.pattern.length;
|
|
@@ -202,7 +247,7 @@ module.exports = {
|
|
|
202
247
|
|
|
203
248
|
if (arg && arg.type === 'Literal') {
|
|
204
249
|
if (typeof arg.value === 'string') {
|
|
205
|
-
checkTextContent(arg, arg.value);
|
|
250
|
+
checkTextContent(arg, arg.value, createTextSuggestions(node, arg, arg.value));
|
|
206
251
|
} else if (arg.regex) {
|
|
207
252
|
// For regex literals, check the pattern length
|
|
208
253
|
const patternLength = arg.regex.pattern.length;
|
|
@@ -258,7 +303,7 @@ module.exports = {
|
|
|
258
303
|
|
|
259
304
|
if (arg && arg.type === 'Literal') {
|
|
260
305
|
if (typeof arg.value === 'string') {
|
|
261
|
-
checkTextContent(arg, arg.value);
|
|
306
|
+
checkTextContent(arg, arg.value, createTextSuggestions(node, arg, arg.value));
|
|
262
307
|
} else if (arg.regex) {
|
|
263
308
|
// For regex literals, check the pattern length
|
|
264
309
|
const patternLength = arg.regex.pattern.length;
|
package/lib/utils/helpers.js
CHANGED
|
@@ -540,9 +540,39 @@ function addWaitForImport(fixer, context) {
|
|
|
540
540
|
}
|
|
541
541
|
}
|
|
542
542
|
|
|
543
|
-
//
|
|
543
|
+
// Try to augment an existing @testing-library require that actually exports waitFor
|
|
544
|
+
if (ast && ast.body) {
|
|
545
|
+
for (const node of ast.body) {
|
|
546
|
+
if (node.type === 'VariableDeclaration') {
|
|
547
|
+
for (const decl of node.declarations) {
|
|
548
|
+
if (!decl.init) continue;
|
|
549
|
+
const init = decl.init;
|
|
550
|
+
const isRequire = init.type === 'CallExpression' && init.callee && init.callee.name === 'require';
|
|
551
|
+
if (!isRequire) continue;
|
|
552
|
+
const requireArg = init.arguments && init.arguments[0];
|
|
553
|
+
if (!requireArg || !WAITFOR_MODULES.has(requireArg.value)) continue;
|
|
554
|
+
if (decl.id.type === 'ObjectPattern' && decl.id.properties.length > 0) {
|
|
555
|
+
const lastProp = decl.id.properties[decl.id.properties.length - 1];
|
|
556
|
+
return [fixer.insertTextAfter(lastProp, ', waitFor')];
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// No suitable import/require found — add a new one at the top of the file.
|
|
564
|
+
// Default to @testing-library/react since it's the most common testing-library package.
|
|
565
|
+
// Detect CJS by looking for top-level require() calls; default to ESM otherwise.
|
|
544
566
|
const firstNode = ast && ast.body && ast.body[0];
|
|
545
567
|
if (firstNode) {
|
|
568
|
+
const isCJS = ast.body.some(n =>
|
|
569
|
+
n.type === 'VariableDeclaration' && n.declarations.some(d =>
|
|
570
|
+
d.init && d.init.type === 'CallExpression' && d.init.callee && d.init.callee.name === 'require'
|
|
571
|
+
)
|
|
572
|
+
);
|
|
573
|
+
if (isCJS) {
|
|
574
|
+
return [fixer.insertTextBefore(firstNode, 'const { waitFor } = require(\'@testing-library/react\');\n')];
|
|
575
|
+
}
|
|
546
576
|
return [fixer.insertTextBefore(firstNode, 'import { waitFor } from \'@testing-library/react\';\n')];
|
|
547
577
|
}
|
|
548
578
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eslint-plugin-test-flakiness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "ESLint plugin to detect flaky test patterns and suggest fixes",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"eslint",
|
|
@@ -27,6 +27,9 @@
|
|
|
27
27
|
"test:coverage": "jest --coverage",
|
|
28
28
|
"test:ci": "jest --ci --coverage --maxWorkers=2",
|
|
29
29
|
"lint": "eslint .",
|
|
30
|
+
"dev:check": "bash scripts/dev-lint.sh --main && bash scripts/dev-test.sh --main",
|
|
31
|
+
"dev:lint": "bash scripts/dev-lint.sh",
|
|
32
|
+
"dev:test": "bash scripts/dev-test.sh",
|
|
30
33
|
"lint:fix": "eslint . --fix",
|
|
31
34
|
"lint:markdown": "npx markdownlint-cli '**/*.md' '.github/**/*.md' --ignore node_modules --config .markdownlintrc.json",
|
|
32
35
|
"lint:markdown:fix": "npx markdownlint-cli '**/*.md' '.github/**/*.md' --ignore node_modules --config .markdownlintrc.json --fix",
|