eslint-plugin-test-flakiness 1.3.0 → 1.4.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.
@@ -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);
@@ -16,12 +16,24 @@ module.exports = {
16
16
  url: 'https://github.com/tigredonorte/eslint-plugin-test-flakiness/blob/main/docs/rules/no-element-removal-check.md'
17
17
  },
18
18
  fixable: 'code',
19
- schema: [],
19
+ schema: [{
20
+ type: 'object',
21
+ properties: {
22
+ reportWithoutEvidence: {
23
+ type: 'boolean',
24
+ default: true
25
+ }
26
+ },
27
+ additionalProperties: false
28
+ }],
20
29
  messages: {
21
30
  avoidRemovalCheck: 'Checking for element removal can be flaky. Use waitForElementToBeRemoved or wait for a positive condition instead.',
22
31
  useWaitForRemoval: 'Avoid checking for null/undefined without proper waiting. Wrap in waitFor() to handle timing.',
23
32
  avoidNotInDocument: 'Avoid .not.toBeInTheDocument() without proper waiting.',
24
- avoidNotVisibleWithoutWaitFor: 'Avoid .not.toBeVisible() without proper waiting. Wrap in waitFor() to handle timing.'
33
+ avoidNotVisibleWithoutWaitFor: 'Avoid .not.toBeVisible() without proper waiting. Wrap in waitFor() to handle timing.',
34
+ avoidNotInDocumentNoEvidence: 'Element absence check without prior presence evidence. If the element was removed by an action, wrap in waitFor().',
35
+ useWaitForRemovalNoEvidence: 'Element null/undefined check without prior presence evidence. If the element was removed by an action, wrap in waitFor().',
36
+ avoidNotVisibleNoEvidence: 'Element not-visible check without prior presence evidence. If the element was hidden by an action, wrap in waitFor().'
25
37
  }
26
38
  },
27
39
 
@@ -30,6 +42,9 @@ module.exports = {
30
42
  return {};
31
43
  }
32
44
 
45
+ const options = context.options[0] || {};
46
+ const reportWithoutEvidence = options.reportWithoutEvidence !== false;
47
+
33
48
  function isInsideWaitFor(node) {
34
49
  let parent = node.parent;
35
50
  while (parent && parent.type !== 'Program') {
@@ -45,6 +60,129 @@ module.exports = {
45
60
  return false;
46
61
  }
47
62
 
63
+ /**
64
+ * Find the enclosing it()/test() block body as an array of statements.
65
+ */
66
+ function findTestBody(node) {
67
+ let current = node.parent;
68
+ while (current) {
69
+ if (current.type === 'CallExpression' &&
70
+ current.callee && current.callee.type === 'Identifier' &&
71
+ (current.callee.name === 'it' || current.callee.name === 'test' || current.callee.name === 'specify')) {
72
+ // The callback is the second argument (or first if no description)
73
+ const callback = current.arguments[1] || current.arguments[0];
74
+ if (callback && callback.body && callback.body.type === 'BlockStatement') {
75
+ return callback.body.body;
76
+ }
77
+ }
78
+ current = current.parent;
79
+ }
80
+ return null;
81
+ }
82
+
83
+ /**
84
+ * Extract the query target text from an assertion node for matching.
85
+ * Returns the literal string/regex argument of the query call, or null if unmatchable.
86
+ */
87
+ function extractQueryTarget(node) {
88
+ // Walk up to find the expect() call
89
+ let expectCall = null;
90
+ let current = node;
91
+ while (current) {
92
+ if (current.type === 'CallExpression' && current.callee && current.callee.name === 'expect') {
93
+ expectCall = current;
94
+ break;
95
+ }
96
+ if (current.type === 'MemberExpression') {
97
+ current = current.object;
98
+ continue;
99
+ }
100
+ if (current.type === 'CallExpression' && current.callee) {
101
+ current = current.callee;
102
+ continue;
103
+ }
104
+ break;
105
+ }
106
+
107
+ if (!expectCall || !expectCall.arguments[0]) return null;
108
+ const arg = expectCall.arguments[0];
109
+
110
+ // arg should be a query call: queryByText('X'), screen.queryByRole('X')
111
+ let queryCall = arg;
112
+ if (queryCall.type !== 'CallExpression') return null;
113
+ if (!queryCall.arguments[0]) return null;
114
+
115
+ const firstArg = queryCall.arguments[0];
116
+ if (firstArg.type === 'Literal' && typeof firstArg.value === 'string') {
117
+ return { type: 'string', value: firstArg.value };
118
+ }
119
+ if (firstArg.regex) {
120
+ return { type: 'regex', pattern: firstArg.regex.pattern, flags: firstArg.regex.flags };
121
+ }
122
+ return null; // unmatchable (variable, template, function call)
123
+ }
124
+
125
+ /**
126
+ * Check if a statement node contains a positive assertion or usage matching the given target.
127
+ */
128
+ function statementMatchesTarget(stmt, target) {
129
+ const sourceCode = context.getSourceCode();
130
+ const stmtText = sourceCode.getText(stmt);
131
+
132
+ // Check for positive assertions: getByText('X'), getByRole('X'), etc.
133
+ // or expect(getByText('X')).toBeInTheDocument()
134
+ if (target.type === 'string') {
135
+ // Look for getBy/findBy calls with the same string argument
136
+ const escapedValue = target.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
137
+ const pattern = new RegExp(`(?:getBy|findBy)\\w+\\s*\\(\\s*['"]${escapedValue}['"]`);
138
+ return pattern.test(stmtText);
139
+ }
140
+ if (target.type === 'regex') {
141
+ const pattern = new RegExp(`(?:getBy|findBy)\\w+\\s*\\(\\s*/${target.pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/${target.flags}`);
142
+ return pattern.test(stmtText);
143
+ }
144
+ return false;
145
+ }
146
+
147
+ /**
148
+ * Check if there is evidence that an element was present and then removed.
149
+ * Evidence = prior userEvent interaction + (optional) prior positive assertion on the same target.
150
+ * fireEvent is sync so it's NOT evidence (element removed synchronously).
151
+ */
152
+ function hasRemovalEvidence(node) {
153
+ const testBody = findTestBody(node);
154
+ if (!testBody) return false;
155
+
156
+ const target = extractQueryTarget(node);
157
+
158
+ // Find the index of the current statement in the test body
159
+ let currentStmt = node;
160
+ while (currentStmt && currentStmt.parent && currentStmt.parent.type !== 'BlockStatement') {
161
+ currentStmt = currentStmt.parent;
162
+ }
163
+ const currentIndex = testBody.indexOf(currentStmt);
164
+ if (currentIndex === -1) return false; // Node not found in this block
165
+ if (currentIndex === 0) return false; // First statement — no prior evidence possible
166
+
167
+ const sourceCode = context.getSourceCode();
168
+ let hasUserEvent = false;
169
+ let hasPositiveAssertion = false;
170
+
171
+ for (let i = 0; i < currentIndex; i++) {
172
+ const stmtText = sourceCode.getText(testBody[i]);
173
+ if (/userEvent\.\w+\s*\(/.test(stmtText)) {
174
+ hasUserEvent = true;
175
+ }
176
+ if (target && statementMatchesTarget(testBody[i], target)) {
177
+ hasPositiveAssertion = true;
178
+ }
179
+ }
180
+
181
+ // Evidence: userEvent interaction + either matchable target with positive assertion, or unmatchable target
182
+ if (hasUserEvent && (!target || hasPositiveAssertion)) return true;
183
+ return false;
184
+ }
185
+
48
186
  function createWaitForFix(node) {
49
187
  return function(fixer) {
50
188
  const sourceCode = context.getSourceCode();
@@ -85,11 +223,18 @@ module.exports = {
85
223
  expectCall.property.name === 'not') {
86
224
 
87
225
  if (!isInsideWaitFor(node)) {
88
- context.report({
89
- node,
90
- messageId: 'avoidNotInDocument',
91
- fix: createWaitForFix(node)
92
- });
226
+ if (hasRemovalEvidence(node)) {
227
+ context.report({
228
+ node,
229
+ messageId: 'avoidNotInDocument',
230
+ fix: createWaitForFix(node)
231
+ });
232
+ } else if (reportWithoutEvidence) {
233
+ context.report({
234
+ node,
235
+ messageId: 'avoidNotInDocumentNoEvidence'
236
+ });
237
+ }
93
238
  }
94
239
  }
95
240
  }
@@ -123,11 +268,18 @@ module.exports = {
123
268
  (/^query/.test(arg.callee.property.name) || arg.callee.property.name === 'querySelector');
124
269
 
125
270
  if ((isQueryMethod || isScreenQuery || isContainerQuery) && !isInsideWaitFor(node)) {
126
- context.report({
127
- node,
128
- messageId: 'useWaitForRemoval',
129
- fix: createWaitForFix(node)
130
- });
271
+ if (hasRemovalEvidence(node)) {
272
+ context.report({
273
+ node,
274
+ messageId: 'useWaitForRemoval',
275
+ fix: createWaitForFix(node)
276
+ });
277
+ } else if (reportWithoutEvidence) {
278
+ context.report({
279
+ node,
280
+ messageId: 'useWaitForRemovalNoEvidence'
281
+ });
282
+ }
131
283
  }
132
284
  }
133
285
  }
@@ -161,11 +313,18 @@ module.exports = {
161
313
  /^query/.test(arg.callee.property.name);
162
314
 
163
315
  if ((isQueryMethod || isScreenQuery) && !isInsideWaitFor(node)) {
164
- context.report({
165
- node,
166
- messageId: 'useWaitForRemoval',
167
- fix: createWaitForFix(node)
168
- });
316
+ if (hasRemovalEvidence(node)) {
317
+ context.report({
318
+ node,
319
+ messageId: 'useWaitForRemoval',
320
+ fix: createWaitForFix(node)
321
+ });
322
+ } else if (reportWithoutEvidence) {
323
+ context.report({
324
+ node,
325
+ messageId: 'useWaitForRemovalNoEvidence'
326
+ });
327
+ }
169
328
  }
170
329
  }
171
330
  }
@@ -187,10 +346,17 @@ module.exports = {
187
346
  (nonNullSide.callee.property && nonNullSide.callee.property.name);
188
347
 
189
348
  if (queryName && /^query/.test(queryName)) {
190
- context.report({
191
- node,
192
- messageId: 'useWaitForRemoval'
193
- });
349
+ if (hasRemovalEvidence(node)) {
350
+ context.report({
351
+ node,
352
+ messageId: 'useWaitForRemoval'
353
+ });
354
+ } else if (reportWithoutEvidence) {
355
+ context.report({
356
+ node,
357
+ messageId: 'useWaitForRemovalNoEvidence'
358
+ });
359
+ }
194
360
  }
195
361
  }
196
362
  }
@@ -206,11 +372,18 @@ module.exports = {
206
372
  expectCall.property.name === 'not') {
207
373
 
208
374
  if (!isInsideWaitFor(node)) {
209
- context.report({
210
- node,
211
- messageId: 'avoidNotVisibleWithoutWaitFor',
212
- fix: createWaitForFix(node)
213
- });
375
+ if (hasRemovalEvidence(node)) {
376
+ context.report({
377
+ node,
378
+ messageId: 'avoidNotVisibleWithoutWaitFor',
379
+ fix: createWaitForFix(node)
380
+ });
381
+ } else if (reportWithoutEvidence) {
382
+ context.report({
383
+ node,
384
+ messageId: 'avoidNotVisibleNoEvidence'
385
+ });
386
+ }
214
387
  }
215
388
  }
216
389
  }
@@ -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.4.0",
4
4
  "description": "ESLint plugin to detect flaky test patterns and suggest fixes",
5
5
  "keywords": [
6
6
  "eslint",