eslint-plugin-test-flakiness 1.3.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);
|
|
@@ -21,7 +21,10 @@ module.exports = {
|
|
|
21
21
|
avoidRemovalCheck: 'Checking for element removal can be flaky. Use waitForElementToBeRemoved or wait for a positive condition instead.',
|
|
22
22
|
useWaitForRemoval: 'Avoid checking for null/undefined without proper waiting. Wrap in waitFor() to handle timing.',
|
|
23
23
|
avoidNotInDocument: 'Avoid .not.toBeInTheDocument() without proper waiting.',
|
|
24
|
-
avoidNotVisibleWithoutWaitFor: 'Avoid .not.toBeVisible() without proper waiting. Wrap in waitFor() to handle timing.'
|
|
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().'
|
|
25
28
|
}
|
|
26
29
|
},
|
|
27
30
|
|
|
@@ -45,6 +48,129 @@ module.exports = {
|
|
|
45
48
|
return false;
|
|
46
49
|
}
|
|
47
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
|
+
|
|
48
174
|
function createWaitForFix(node) {
|
|
49
175
|
return function(fixer) {
|
|
50
176
|
const sourceCode = context.getSourceCode();
|
|
@@ -85,11 +211,18 @@ module.exports = {
|
|
|
85
211
|
expectCall.property.name === 'not') {
|
|
86
212
|
|
|
87
213
|
if (!isInsideWaitFor(node)) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
+
});
|
|
225
|
+
}
|
|
93
226
|
}
|
|
94
227
|
}
|
|
95
228
|
}
|
|
@@ -123,11 +256,18 @@ module.exports = {
|
|
|
123
256
|
(/^query/.test(arg.callee.property.name) || arg.callee.property.name === 'querySelector');
|
|
124
257
|
|
|
125
258
|
if ((isQueryMethod || isScreenQuery || isContainerQuery) && !isInsideWaitFor(node)) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
+
}
|
|
131
271
|
}
|
|
132
272
|
}
|
|
133
273
|
}
|
|
@@ -161,11 +301,18 @@ module.exports = {
|
|
|
161
301
|
/^query/.test(arg.callee.property.name);
|
|
162
302
|
|
|
163
303
|
if ((isQueryMethod || isScreenQuery) && !isInsideWaitFor(node)) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
+
}
|
|
169
316
|
}
|
|
170
317
|
}
|
|
171
318
|
}
|
|
@@ -187,10 +334,17 @@ module.exports = {
|
|
|
187
334
|
(nonNullSide.callee.property && nonNullSide.callee.property.name);
|
|
188
335
|
|
|
189
336
|
if (queryName && /^query/.test(queryName)) {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
+
}
|
|
194
348
|
}
|
|
195
349
|
}
|
|
196
350
|
}
|
|
@@ -206,11 +360,18 @@ module.exports = {
|
|
|
206
360
|
expectCall.property.name === 'not') {
|
|
207
361
|
|
|
208
362
|
if (!isInsideWaitFor(node)) {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
+
});
|
|
374
|
+
}
|
|
214
375
|
}
|
|
215
376
|
}
|
|
216
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;
|