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: 'Use waitForElementToBeRemoved() instead of checking for null/undefined.',
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
- // Check if it's wrapped in waitFor
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;
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
- context.report({
93
- node,
94
- messageId: 'useWaitForRemoval'
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
- context.report({
130
- node,
131
- messageId: 'useWaitForRemoval'
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
- context.report({
155
- node,
156
- messageId: 'useWaitForRemoval'
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
- // Check if it's wrapped in waitFor
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;
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(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;
@@ -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 checkTextContent(node, text) {
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
- context.report({
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;
@@ -540,9 +540,39 @@ function addWaitForImport(fixer, context) {
540
540
  }
541
541
  }
542
542
 
543
- // No suitable import found add a new one at the top of the file
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.2.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",
@@ -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",