eslint-plugin-test-flakiness 1.2.0 → 1.3.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.
@@ -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,13 @@ 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.'
23
25
  }
24
26
  },
25
27
 
@@ -28,33 +30,65 @@ module.exports = {
28
30
  return {};
29
31
  }
30
32
 
33
+ function isInsideWaitFor(node) {
34
+ let parent = node.parent;
35
+ while (parent && parent.type !== 'Program') {
36
+ if (parent.type === 'CallExpression') {
37
+ const calleeName = parent.callee.name ||
38
+ (parent.callee.property && parent.callee.property.name);
39
+ if (calleeName === 'waitFor' || calleeName === 'waitForElementToBeRemoved') {
40
+ return true;
41
+ }
42
+ }
43
+ parent = parent.parent;
44
+ }
45
+ return false;
46
+ }
47
+
48
+ function createWaitForFix(node) {
49
+ return function(fixer) {
50
+ const sourceCode = context.getSourceCode();
51
+ // Find the containing expression statement
52
+ let statement = node.parent;
53
+ while (statement && statement.type !== 'ExpressionStatement') {
54
+ statement = statement.parent;
55
+ }
56
+ if (!statement || !statement.expression) return null;
57
+
58
+ const importFixes = addWaitForImport(fixer, context);
59
+ if (importFixes === null) return null; // Skip fix for incompatible frameworks
60
+
61
+ const funcNode = findEnclosingFunction(node);
62
+ const asyncFixes = ensureAsyncFunction(fixer, funcNode);
63
+ if (asyncFixes === null) return null; // Skip fix for getters/setters/constructors
64
+
65
+ const statementText = sourceCode.getText(statement.expression);
66
+
67
+ return [
68
+ fixer.replaceText(
69
+ statement,
70
+ `await waitFor(() => { ${statementText}; });`
71
+ ),
72
+ ...asyncFixes,
73
+ ...importFixes
74
+ ];
75
+ };
76
+ }
77
+
31
78
  function checkNotToBeInTheDocument(node) {
32
79
  // Check for expect().not.toBeInTheDocument() patterns
33
80
  if (node.callee.type === 'MemberExpression' &&
34
81
  node.callee.property.name === 'toBeInTheDocument') {
35
-
82
+
36
83
  const expectCall = node.callee.object;
37
84
  if (expectCall.type === 'MemberExpression' &&
38
85
  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;
50
- }
51
- parent = parent.parent;
52
- }
53
-
54
- if (!insideWaitFor) {
86
+
87
+ if (!isInsideWaitFor(node)) {
55
88
  context.report({
56
89
  node,
57
- messageId: 'avoidNotInDocument'
90
+ messageId: 'avoidNotInDocument',
91
+ fix: createWaitForFix(node)
58
92
  });
59
93
  }
60
94
  }
@@ -88,10 +122,11 @@ module.exports = {
88
122
  arg.callee.property &&
89
123
  (/^query/.test(arg.callee.property.name) || arg.callee.property.name === 'querySelector');
90
124
 
91
- if (isQueryMethod || isScreenQuery || isContainerQuery) {
125
+ if ((isQueryMethod || isScreenQuery || isContainerQuery) && !isInsideWaitFor(node)) {
92
126
  context.report({
93
127
  node,
94
- messageId: 'useWaitForRemoval'
128
+ messageId: 'useWaitForRemoval',
129
+ fix: createWaitForFix(node)
95
130
  });
96
131
  }
97
132
  }
@@ -125,10 +160,11 @@ module.exports = {
125
160
  arg.callee.property &&
126
161
  /^query/.test(arg.callee.property.name);
127
162
 
128
- if (isQueryMethod || isScreenQuery) {
163
+ if ((isQueryMethod || isScreenQuery) && !isInsideWaitFor(node)) {
129
164
  context.report({
130
165
  node,
131
- messageId: 'useWaitForRemoval'
166
+ messageId: 'useWaitForRemoval',
167
+ fix: createWaitForFix(node)
132
168
  });
133
169
  }
134
170
  }
@@ -169,24 +205,11 @@ module.exports = {
169
205
  if (expectCall.type === 'MemberExpression' &&
170
206
  expectCall.property.name === 'not') {
171
207
 
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;
182
- }
183
- parent = parent.parent;
184
- }
185
-
186
- if (!insideWaitFor) {
208
+ if (!isInsideWaitFor(node)) {
187
209
  context.report({
188
210
  node,
189
- messageId: 'avoidNotInDocument'
211
+ messageId: 'avoidNotVisibleWithoutWaitFor',
212
+ fix: createWaitForFix(node)
190
213
  });
191
214
  }
192
215
  }
@@ -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.0",
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",