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
- context.report({
89
- node,
90
- messageId: 'avoidNotInDocument',
91
- fix: createWaitForFix(node)
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
- context.report({
127
- node,
128
- messageId: 'useWaitForRemoval',
129
- fix: createWaitForFix(node)
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
- context.report({
165
- node,
166
- messageId: 'useWaitForRemoval',
167
- fix: createWaitForFix(node)
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
- context.report({
191
- node,
192
- messageId: 'useWaitForRemoval'
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
- context.report({
210
- node,
211
- messageId: 'avoidNotVisibleWithoutWaitFor',
212
- fix: createWaitForFix(node)
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(context.getFilename())) {
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', 'fireEvent',
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-test-flakiness",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "ESLint plugin to detect flaky test patterns and suggest fixes",
5
5
  "keywords": [
6
6
  "eslint",