eslint-plugin-test-flakiness 1.2.0 → 1.3.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.
|
@@ -31,7 +31,6 @@ module.exports = {
|
|
|
31
31
|
],
|
|
32
32
|
messages: {
|
|
33
33
|
missingAwait: '{{method}} must be awaited to prevent race conditions',
|
|
34
|
-
missingAwaitFireEvent: 'fireEvent.{{method}} should be awaited',
|
|
35
34
|
missingAwaitUserEvent: 'userEvent.{{method}} must be awaited',
|
|
36
35
|
missingAwaitAct: 'act() with async callback must be awaited',
|
|
37
36
|
missingAwaitPage: 'Playwright page.{{method}} must be awaited',
|
|
@@ -58,11 +57,6 @@ module.exports = {
|
|
|
58
57
|
'hover', 'unhover', 'paste', 'keyboard'
|
|
59
58
|
];
|
|
60
59
|
|
|
61
|
-
const asyncFireEventMethods = [
|
|
62
|
-
'click', 'change', 'input', 'submit', 'focus', 'blur',
|
|
63
|
-
'keyDown', 'keyUp', 'keyPress', 'mouseDown', 'mouseUp'
|
|
64
|
-
];
|
|
65
|
-
|
|
66
60
|
const asyncPageMethods = [
|
|
67
61
|
'click', 'fill', 'type', 'press', 'check', 'uncheck',
|
|
68
62
|
'selectOption', 'setInputFiles', 'focus', 'hover', 'tap',
|
|
@@ -144,32 +138,6 @@ module.exports = {
|
|
|
144
138
|
}
|
|
145
139
|
}
|
|
146
140
|
|
|
147
|
-
function checkFireEvent(node) {
|
|
148
|
-
if (node.callee.type === 'MemberExpression' &&
|
|
149
|
-
node.callee.object.name === 'fireEvent') {
|
|
150
|
-
|
|
151
|
-
const methodName = node.callee.property.name;
|
|
152
|
-
if (asyncFireEventMethods.includes(methodName) &&
|
|
153
|
-
!isPromiseHandled(node)) {
|
|
154
|
-
|
|
155
|
-
context.report({
|
|
156
|
-
node,
|
|
157
|
-
messageId: 'missingAwaitFireEvent',
|
|
158
|
-
data: { method: methodName },
|
|
159
|
-
fix(fixer) {
|
|
160
|
-
const funcNode = findEnclosingFunction(node);
|
|
161
|
-
const asyncFixes = ensureAsyncFunction(fixer, funcNode);
|
|
162
|
-
if (asyncFixes === null) return null;
|
|
163
|
-
return [
|
|
164
|
-
fixer.insertTextBefore(node, 'await '),
|
|
165
|
-
...asyncFixes
|
|
166
|
-
];
|
|
167
|
-
}
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
141
|
function checkAct(node) {
|
|
174
142
|
if (node.callee.name === 'act' && node.arguments[0]) {
|
|
175
143
|
const callback = node.arguments[0];
|
|
@@ -309,7 +277,6 @@ module.exports = {
|
|
|
309
277
|
},
|
|
310
278
|
CallExpression(node) {
|
|
311
279
|
checkUserEvent(node);
|
|
312
|
-
checkFireEvent(node);
|
|
313
280
|
checkAct(node);
|
|
314
281
|
checkPlaywright(node);
|
|
315
282
|
checkElementMethods(node);
|
|
@@ -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,16 @@ 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.',
|
|
25
|
+
avoidNotInDocumentNoEvidence: 'Element absence check without prior presence evidence. If the element was removed by an action, wrap in waitFor().',
|
|
26
|
+
useWaitForRemovalNoEvidence: 'Element null/undefined check without prior presence evidence. If the element was removed by an action, wrap in waitFor().',
|
|
27
|
+
avoidNotVisibleNoEvidence: 'Element not-visible check without prior presence evidence. If the element was hidden by an action, wrap in waitFor().'
|
|
23
28
|
}
|
|
24
29
|
},
|
|
25
30
|
|
|
@@ -28,34 +33,196 @@ module.exports = {
|
|
|
28
33
|
return {};
|
|
29
34
|
}
|
|
30
35
|
|
|
36
|
+
function isInsideWaitFor(node) {
|
|
37
|
+
let parent = node.parent;
|
|
38
|
+
while (parent && parent.type !== 'Program') {
|
|
39
|
+
if (parent.type === 'CallExpression') {
|
|
40
|
+
const calleeName = parent.callee.name ||
|
|
41
|
+
(parent.callee.property && parent.callee.property.name);
|
|
42
|
+
if (calleeName === 'waitFor' || calleeName === 'waitForElementToBeRemoved') {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
parent = parent.parent;
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Find the enclosing it()/test() block body as an array of statements.
|
|
53
|
+
*/
|
|
54
|
+
function findTestBody(node) {
|
|
55
|
+
let current = node.parent;
|
|
56
|
+
while (current) {
|
|
57
|
+
if (current.type === 'CallExpression' &&
|
|
58
|
+
current.callee && current.callee.type === 'Identifier' &&
|
|
59
|
+
(current.callee.name === 'it' || current.callee.name === 'test' || current.callee.name === 'specify')) {
|
|
60
|
+
// The callback is the second argument (or first if no description)
|
|
61
|
+
const callback = current.arguments[1] || current.arguments[0];
|
|
62
|
+
if (callback && callback.body && callback.body.type === 'BlockStatement') {
|
|
63
|
+
return callback.body.body;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
current = current.parent;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Extract the query target text from an assertion node for matching.
|
|
73
|
+
* Returns the literal string/regex argument of the query call, or null if unmatchable.
|
|
74
|
+
*/
|
|
75
|
+
function extractQueryTarget(node) {
|
|
76
|
+
// Walk up to find the expect() call
|
|
77
|
+
let expectCall = null;
|
|
78
|
+
let current = node;
|
|
79
|
+
while (current) {
|
|
80
|
+
if (current.type === 'CallExpression' && current.callee && current.callee.name === 'expect') {
|
|
81
|
+
expectCall = current;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
if (current.type === 'MemberExpression') {
|
|
85
|
+
current = current.object;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (current.type === 'CallExpression' && current.callee) {
|
|
89
|
+
current = current.callee;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!expectCall || !expectCall.arguments[0]) return null;
|
|
96
|
+
const arg = expectCall.arguments[0];
|
|
97
|
+
|
|
98
|
+
// arg should be a query call: queryByText('X'), screen.queryByRole('X')
|
|
99
|
+
let queryCall = arg;
|
|
100
|
+
if (queryCall.type !== 'CallExpression') return null;
|
|
101
|
+
if (!queryCall.arguments[0]) return null;
|
|
102
|
+
|
|
103
|
+
const firstArg = queryCall.arguments[0];
|
|
104
|
+
if (firstArg.type === 'Literal' && typeof firstArg.value === 'string') {
|
|
105
|
+
return { type: 'string', value: firstArg.value };
|
|
106
|
+
}
|
|
107
|
+
if (firstArg.regex) {
|
|
108
|
+
return { type: 'regex', pattern: firstArg.regex.pattern, flags: firstArg.regex.flags };
|
|
109
|
+
}
|
|
110
|
+
return null; // unmatchable (variable, template, function call)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Check if a statement node contains a positive assertion or usage matching the given target.
|
|
115
|
+
*/
|
|
116
|
+
function statementMatchesTarget(stmt, target) {
|
|
117
|
+
const sourceCode = context.getSourceCode();
|
|
118
|
+
const stmtText = sourceCode.getText(stmt);
|
|
119
|
+
|
|
120
|
+
// Check for positive assertions: getByText('X'), getByRole('X'), etc.
|
|
121
|
+
// or expect(getByText('X')).toBeInTheDocument()
|
|
122
|
+
if (target.type === 'string') {
|
|
123
|
+
// Look for getBy/findBy calls with the same string argument
|
|
124
|
+
const escapedValue = target.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
125
|
+
const pattern = new RegExp(`(?:getBy|findBy)\\w+\\s*\\(\\s*['"]${escapedValue}['"]`);
|
|
126
|
+
return pattern.test(stmtText);
|
|
127
|
+
}
|
|
128
|
+
if (target.type === 'regex') {
|
|
129
|
+
const pattern = new RegExp(`(?:getBy|findBy)\\w+\\s*\\(\\s*/${target.pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/${target.flags}`);
|
|
130
|
+
return pattern.test(stmtText);
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Check if there is evidence that an element was present and then removed.
|
|
137
|
+
* Evidence = prior userEvent interaction + (optional) prior positive assertion on the same target.
|
|
138
|
+
* fireEvent is sync so it's NOT evidence (element removed synchronously).
|
|
139
|
+
*/
|
|
140
|
+
function hasRemovalEvidence(node) {
|
|
141
|
+
const testBody = findTestBody(node);
|
|
142
|
+
if (!testBody) return false;
|
|
143
|
+
|
|
144
|
+
const target = extractQueryTarget(node);
|
|
145
|
+
|
|
146
|
+
// Find the index of the current statement in the test body
|
|
147
|
+
let currentStmt = node;
|
|
148
|
+
while (currentStmt && currentStmt.parent && currentStmt.parent.type !== 'BlockStatement') {
|
|
149
|
+
currentStmt = currentStmt.parent;
|
|
150
|
+
}
|
|
151
|
+
const currentIndex = testBody.indexOf(currentStmt);
|
|
152
|
+
if (currentIndex === -1) return false; // Node not found in this block
|
|
153
|
+
if (currentIndex === 0) return false; // First statement — no prior evidence possible
|
|
154
|
+
|
|
155
|
+
const sourceCode = context.getSourceCode();
|
|
156
|
+
let hasUserEvent = false;
|
|
157
|
+
let hasPositiveAssertion = false;
|
|
158
|
+
|
|
159
|
+
for (let i = 0; i < currentIndex; i++) {
|
|
160
|
+
const stmtText = sourceCode.getText(testBody[i]);
|
|
161
|
+
if (/userEvent\.\w+\s*\(/.test(stmtText)) {
|
|
162
|
+
hasUserEvent = true;
|
|
163
|
+
}
|
|
164
|
+
if (target && statementMatchesTarget(testBody[i], target)) {
|
|
165
|
+
hasPositiveAssertion = true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Evidence: userEvent interaction + either matchable target with positive assertion, or unmatchable target
|
|
170
|
+
if (hasUserEvent && (!target || hasPositiveAssertion)) return true;
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function createWaitForFix(node) {
|
|
175
|
+
return function(fixer) {
|
|
176
|
+
const sourceCode = context.getSourceCode();
|
|
177
|
+
// Find the containing expression statement
|
|
178
|
+
let statement = node.parent;
|
|
179
|
+
while (statement && statement.type !== 'ExpressionStatement') {
|
|
180
|
+
statement = statement.parent;
|
|
181
|
+
}
|
|
182
|
+
if (!statement || !statement.expression) return null;
|
|
183
|
+
|
|
184
|
+
const importFixes = addWaitForImport(fixer, context);
|
|
185
|
+
if (importFixes === null) return null; // Skip fix for incompatible frameworks
|
|
186
|
+
|
|
187
|
+
const funcNode = findEnclosingFunction(node);
|
|
188
|
+
const asyncFixes = ensureAsyncFunction(fixer, funcNode);
|
|
189
|
+
if (asyncFixes === null) return null; // Skip fix for getters/setters/constructors
|
|
190
|
+
|
|
191
|
+
const statementText = sourceCode.getText(statement.expression);
|
|
192
|
+
|
|
193
|
+
return [
|
|
194
|
+
fixer.replaceText(
|
|
195
|
+
statement,
|
|
196
|
+
`await waitFor(() => { ${statementText}; });`
|
|
197
|
+
),
|
|
198
|
+
...asyncFixes,
|
|
199
|
+
...importFixes
|
|
200
|
+
];
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
31
204
|
function checkNotToBeInTheDocument(node) {
|
|
32
205
|
// Check for expect().not.toBeInTheDocument() patterns
|
|
33
206
|
if (node.callee.type === 'MemberExpression' &&
|
|
34
207
|
node.callee.property.name === 'toBeInTheDocument') {
|
|
35
|
-
|
|
208
|
+
|
|
36
209
|
const expectCall = node.callee.object;
|
|
37
210
|
if (expectCall.type === 'MemberExpression' &&
|
|
38
211
|
expectCall.property.name === 'not') {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
212
|
+
|
|
213
|
+
if (!isInsideWaitFor(node)) {
|
|
214
|
+
if (hasRemovalEvidence(node)) {
|
|
215
|
+
context.report({
|
|
216
|
+
node,
|
|
217
|
+
messageId: 'avoidNotInDocument',
|
|
218
|
+
fix: createWaitForFix(node)
|
|
219
|
+
});
|
|
220
|
+
} else {
|
|
221
|
+
context.report({
|
|
222
|
+
node,
|
|
223
|
+
messageId: 'avoidNotInDocumentNoEvidence'
|
|
224
|
+
});
|
|
50
225
|
}
|
|
51
|
-
parent = parent.parent;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
if (!insideWaitFor) {
|
|
55
|
-
context.report({
|
|
56
|
-
node,
|
|
57
|
-
messageId: 'avoidNotInDocument'
|
|
58
|
-
});
|
|
59
226
|
}
|
|
60
227
|
}
|
|
61
228
|
}
|
|
@@ -88,11 +255,19 @@ module.exports = {
|
|
|
88
255
|
arg.callee.property &&
|
|
89
256
|
(/^query/.test(arg.callee.property.name) || arg.callee.property.name === 'querySelector');
|
|
90
257
|
|
|
91
|
-
if (isQueryMethod || isScreenQuery || isContainerQuery) {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
258
|
+
if ((isQueryMethod || isScreenQuery || isContainerQuery) && !isInsideWaitFor(node)) {
|
|
259
|
+
if (hasRemovalEvidence(node)) {
|
|
260
|
+
context.report({
|
|
261
|
+
node,
|
|
262
|
+
messageId: 'useWaitForRemoval',
|
|
263
|
+
fix: createWaitForFix(node)
|
|
264
|
+
});
|
|
265
|
+
} else {
|
|
266
|
+
context.report({
|
|
267
|
+
node,
|
|
268
|
+
messageId: 'useWaitForRemovalNoEvidence'
|
|
269
|
+
});
|
|
270
|
+
}
|
|
96
271
|
}
|
|
97
272
|
}
|
|
98
273
|
}
|
|
@@ -125,11 +300,19 @@ module.exports = {
|
|
|
125
300
|
arg.callee.property &&
|
|
126
301
|
/^query/.test(arg.callee.property.name);
|
|
127
302
|
|
|
128
|
-
if (isQueryMethod || isScreenQuery) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
303
|
+
if ((isQueryMethod || isScreenQuery) && !isInsideWaitFor(node)) {
|
|
304
|
+
if (hasRemovalEvidence(node)) {
|
|
305
|
+
context.report({
|
|
306
|
+
node,
|
|
307
|
+
messageId: 'useWaitForRemoval',
|
|
308
|
+
fix: createWaitForFix(node)
|
|
309
|
+
});
|
|
310
|
+
} else {
|
|
311
|
+
context.report({
|
|
312
|
+
node,
|
|
313
|
+
messageId: 'useWaitForRemovalNoEvidence'
|
|
314
|
+
});
|
|
315
|
+
}
|
|
133
316
|
}
|
|
134
317
|
}
|
|
135
318
|
}
|
|
@@ -151,10 +334,17 @@ module.exports = {
|
|
|
151
334
|
(nonNullSide.callee.property && nonNullSide.callee.property.name);
|
|
152
335
|
|
|
153
336
|
if (queryName && /^query/.test(queryName)) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
337
|
+
if (hasRemovalEvidence(node)) {
|
|
338
|
+
context.report({
|
|
339
|
+
node,
|
|
340
|
+
messageId: 'useWaitForRemoval'
|
|
341
|
+
});
|
|
342
|
+
} else {
|
|
343
|
+
context.report({
|
|
344
|
+
node,
|
|
345
|
+
messageId: 'useWaitForRemovalNoEvidence'
|
|
346
|
+
});
|
|
347
|
+
}
|
|
158
348
|
}
|
|
159
349
|
}
|
|
160
350
|
}
|
|
@@ -169,25 +359,19 @@ module.exports = {
|
|
|
169
359
|
if (expectCall.type === 'MemberExpression' &&
|
|
170
360
|
expectCall.property.name === 'not') {
|
|
171
361
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
362
|
+
if (!isInsideWaitFor(node)) {
|
|
363
|
+
if (hasRemovalEvidence(node)) {
|
|
364
|
+
context.report({
|
|
365
|
+
node,
|
|
366
|
+
messageId: 'avoidNotVisibleWithoutWaitFor',
|
|
367
|
+
fix: createWaitForFix(node)
|
|
368
|
+
});
|
|
369
|
+
} else {
|
|
370
|
+
context.report({
|
|
371
|
+
node,
|
|
372
|
+
messageId: 'avoidNotVisibleNoEvidence'
|
|
373
|
+
});
|
|
182
374
|
}
|
|
183
|
-
parent = parent.parent;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
if (!insideWaitFor) {
|
|
187
|
-
context.report({
|
|
188
|
-
node,
|
|
189
|
-
messageId: 'avoidNotInDocument'
|
|
190
|
-
});
|
|
191
375
|
}
|
|
192
376
|
}
|
|
193
377
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
'use strict';
|
|
6
6
|
|
|
7
|
-
const { isTestFile, findEnclosingFunction, ensureAsyncFunction, addWaitForImport, getTestFramework } = require('../utils/helpers');
|
|
7
|
+
const { isTestFile, getFilename, findEnclosingFunction, ensureAsyncFunction, addWaitForImport, getTestFramework } = require('../utils/helpers');
|
|
8
8
|
|
|
9
9
|
module.exports = {
|
|
10
10
|
meta: {
|
|
@@ -49,7 +49,7 @@ module.exports = {
|
|
|
49
49
|
},
|
|
50
50
|
|
|
51
51
|
create(context) {
|
|
52
|
-
if (!isTestFile(
|
|
52
|
+
if (!isTestFile(getFilename(context))) {
|
|
53
53
|
return {};
|
|
54
54
|
}
|
|
55
55
|
|
|
@@ -61,10 +61,31 @@ module.exports = {
|
|
|
61
61
|
|
|
62
62
|
const stateChangingPatterns = [
|
|
63
63
|
'setState', 'setProps', 'dispatch', 'commit',
|
|
64
|
-
'click', 'type', 'change', 'submit',
|
|
64
|
+
'click', 'type', 'change', 'submit',
|
|
65
65
|
'userEvent', 'simulate', 'trigger'
|
|
66
66
|
];
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Returns true when the node is a fireEvent.* call (synchronous action).
|
|
70
|
+
* fireEvent is sync — React Testing Library flushes synchronously within act(),
|
|
71
|
+
* so assertions immediately after fireEvent are correct as-is.
|
|
72
|
+
*/
|
|
73
|
+
function isSyncFireEvent(node) {
|
|
74
|
+
// Unwrap `await fireEvent.click()` — still a sync fireEvent even if mistakenly awaited
|
|
75
|
+
const target = node.type === 'AwaitExpression' ? node.argument : node;
|
|
76
|
+
if (!target || target.type !== 'CallExpression') return false;
|
|
77
|
+
const callee = target.callee;
|
|
78
|
+
// fireEvent.click(...), fireEvent.change(...), etc.
|
|
79
|
+
if (callee.type === 'MemberExpression' && callee.object && callee.object.name === 'fireEvent') {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
// Direct fireEvent(...) call
|
|
83
|
+
if (callee.type === 'Identifier' && callee.name === 'fireEvent') {
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
68
89
|
function isStateChangingAction(node) {
|
|
69
90
|
if (node.type !== 'CallExpression') return false;
|
|
70
91
|
|
|
@@ -174,6 +195,11 @@ module.exports = {
|
|
|
174
195
|
if (node.type === 'ExpressionStatement' &&
|
|
175
196
|
isStateChangingAction(node.expression)) {
|
|
176
197
|
|
|
198
|
+
// Skip fireEvent — it's synchronous, assertions after it are fine
|
|
199
|
+
if (isSyncFireEvent(node.expression)) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
177
203
|
// Check the next statement
|
|
178
204
|
const nextStatement = statements[currentIndex + 1];
|
|
179
205
|
if (nextStatement &&
|
|
@@ -283,6 +309,11 @@ module.exports = {
|
|
|
283
309
|
if (isStateChangingAction(expressions[i]) &&
|
|
284
310
|
isExpectCall(expressions[i + 1])) {
|
|
285
311
|
|
|
312
|
+
// Skip fireEvent — synchronous action
|
|
313
|
+
if (isSyncFireEvent(expressions[i])) {
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
|
|
286
317
|
// Skip if requireWaitFor is false
|
|
287
318
|
if (!requireWaitFor) {
|
|
288
319
|
continue;
|
|
@@ -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.1",
|
|
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",
|