Skip to content

Commit 727a966

Browse files
authored
refactor(prefer-wait-for): use new custom rule creator (#255)
* refactor: prefer-wait-for with the new settings * refactor: generalized util method * refactor: applied feedback from pr * test: improve coverage
1 parent 8b66b52 commit 727a966

File tree

6 files changed

+1919
-150
lines changed

6 files changed

+1919
-150
lines changed

docs/rules/prefer-wait-for.md

+26
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ Deprecated `wait` async utils are:
1717
Examples of **incorrect** code for this rule:
1818

1919
```js
20+
import { wait, waitForElement, waitForDomChange } from '@testing-library/dom';
21+
// this also works for const { wait, waitForElement, waitForDomChange } = require ('@testing-library/dom')
22+
2023
const foo = async () => {
2124
await wait();
2225
await wait(() => {});
@@ -25,18 +28,41 @@ const foo = async () => {
2528
await waitForDomChange(mutationObserverOptions);
2629
await waitForDomChange({ timeout: 100 });
2730
};
31+
32+
import * as tl from '@testing-library/dom';
33+
// this also works for const tl = require('@testing-library/dom')
34+
const foo = async () => {
35+
await tl.wait();
36+
await tl.wait(() => {});
37+
await tl.waitForElement(() => {});
38+
await tl.waitForDomChange();
39+
await tl.waitForDomChange(mutationObserverOptions);
40+
await tl.waitForDomChange({ timeout: 100 });
41+
};
2842
```
2943

3044
Examples of **correct** code for this rule:
3145

3246
```js
47+
import { waitFor, waitForElementToBeRemoved } from '@testing-library/dom';
48+
// this also works for const { waitFor, waitForElementToBeRemoved } = require('@testing-library/dom')
3349
const foo = async () => {
3450
// new waitFor method
3551
await waitFor(() => {});
3652

3753
// previous waitForElementToBeRemoved is not deprecated
3854
await waitForElementToBeRemoved(() => {});
3955
};
56+
57+
import * as tl from '@testing-library/dom';
58+
// this also works for const tl = require('@testing-library/dom')
59+
const foo = async () => {
60+
// new waitFor method
61+
await tl.waitFor(() => {});
62+
63+
// previous waitForElementToBeRemoved is not deprecated
64+
await tl.waitForElementToBeRemoved(() => {});
65+
};
4066
```
4167

4268
## When Not To Use It

lib/detect-testing-library-utils.ts

+55
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
isImportNamespaceSpecifier,
1313
isImportSpecifier,
1414
isProperty,
15+
isCallExpression,
16+
isObjectPattern,
1517
} from './node-utils';
1618
import { ABSENCE_MATCHERS, PRESENCE_MATCHERS } from './utils';
1719

@@ -55,6 +57,9 @@ export type DetectionHelpers = {
5557
findImportedUtilSpecifier: (
5658
specifierName: string
5759
) => TSESTree.ImportClause | TSESTree.Identifier | undefined;
60+
isNodeComingFromTestingLibrary: (
61+
node: TSESTree.MemberExpression | TSESTree.Identifier
62+
) => boolean;
5863
};
5964

6065
const DEFAULT_FILENAME_PATTERN = '^.*\\.(test|spec)\\.[jt]sx?$';
@@ -229,6 +234,55 @@ export function detectTestingLibraryUtils<
229234
const canReportErrors: DetectionHelpers['canReportErrors'] = () => {
230235
return isTestingLibraryImported() && isValidFilename();
231236
};
237+
/**
238+
* Takes a MemberExpression or an Identifier and verifies if its name comes from the import in TL
239+
* @param node a MemberExpression (in "foo.property" it would be property) or an Identifier (it should be provided from a CallExpression, for example "foo()")
240+
*/
241+
const isNodeComingFromTestingLibrary: DetectionHelpers['isNodeComingFromTestingLibrary'] = (
242+
node: TSESTree.MemberExpression | TSESTree.Identifier
243+
) => {
244+
const importOrRequire =
245+
getCustomModuleImportNode() ?? getTestingLibraryImportNode();
246+
if (!importOrRequire) {
247+
return false;
248+
}
249+
if (ASTUtils.isIdentifier(node)) {
250+
if (isImportDeclaration(importOrRequire)) {
251+
return importOrRequire.specifiers.some(
252+
(s) => isImportSpecifier(s) && s.local.name === node.name
253+
);
254+
} else {
255+
return (
256+
ASTUtils.isVariableDeclarator(importOrRequire.parent) &&
257+
isObjectPattern(importOrRequire.parent.id) &&
258+
importOrRequire.parent.id.properties.some(
259+
(p) =>
260+
isProperty(p) &&
261+
ASTUtils.isIdentifier(p.key) &&
262+
ASTUtils.isIdentifier(p.value) &&
263+
p.value.name === node.name
264+
)
265+
);
266+
}
267+
} else {
268+
if (!ASTUtils.isIdentifier(node.object)) {
269+
return false;
270+
}
271+
if (isImportDeclaration(importOrRequire)) {
272+
return (
273+
isImportDeclaration(importOrRequire) &&
274+
isImportNamespaceSpecifier(importOrRequire.specifiers[0]) &&
275+
node.object.name === importOrRequire.specifiers[0].local.name
276+
);
277+
}
278+
return (
279+
isCallExpression(importOrRequire) &&
280+
ASTUtils.isVariableDeclarator(importOrRequire.parent) &&
281+
ASTUtils.isIdentifier(importOrRequire.parent.id) &&
282+
node.object.name === importOrRequire.parent.id.name
283+
);
284+
}
285+
};
232286

233287
const helpers = {
234288
getTestingLibraryImportNode,
@@ -244,6 +298,7 @@ export function detectTestingLibraryUtils<
244298
isAbsenceAssert,
245299
canReportErrors,
246300
findImportedUtilSpecifier,
301+
isNodeComingFromTestingLibrary,
247302
};
248303

249304
// Instructions for Testing Library detection.

lib/rules/prefer-wait-for.ts

+91-48
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,25 @@
1-
import {
2-
ESLintUtils,
3-
TSESTree,
4-
ASTUtils,
5-
} from '@typescript-eslint/experimental-utils';
6-
import { getDocsUrl } from '../utils';
1+
import { TSESTree, ASTUtils } from '@typescript-eslint/experimental-utils';
2+
import { createTestingLibraryRule } from '../create-testing-library-rule';
73
import {
84
isImportSpecifier,
95
isMemberExpression,
106
findClosestCallExpressionNode,
7+
isCallExpression,
8+
isImportNamespaceSpecifier,
9+
isObjectPattern,
10+
isProperty,
1111
} from '../node-utils';
1212

1313
export const RULE_NAME = 'prefer-wait-for';
14-
export type MessageIds = 'preferWaitForMethod' | 'preferWaitForImport';
14+
export type MessageIds =
15+
| 'preferWaitForMethod'
16+
| 'preferWaitForImport'
17+
| 'preferWaitForRequire';
1518
type Options = [];
1619

1720
const DEPRECATED_METHODS = ['wait', 'waitForElement', 'waitForDomChange'];
1821

19-
export default ESLintUtils.RuleCreator(getDocsUrl)<Options, MessageIds>({
22+
export default createTestingLibraryRule<Options, MessageIds>({
2023
name: RULE_NAME,
2124
meta: {
2225
type: 'suggestion',
@@ -29,14 +32,43 @@ export default ESLintUtils.RuleCreator(getDocsUrl)<Options, MessageIds>({
2932
preferWaitForMethod:
3033
'`{{ methodName }}` is deprecated in favour of `waitFor`',
3134
preferWaitForImport: 'import `waitFor` instead of deprecated async utils',
35+
preferWaitForRequire:
36+
'require `waitFor` instead of deprecated async utils',
3237
},
3338

3439
fixable: 'code',
3540
schema: [],
3641
},
3742
defaultOptions: [],
3843

39-
create(context) {
44+
create(context, _, helpers) {
45+
let addWaitFor = false;
46+
47+
const reportRequire = (node: TSESTree.ObjectPattern) => {
48+
context.report({
49+
node: node,
50+
messageId: 'preferWaitForRequire',
51+
fix(fixer) {
52+
const excludedImports = [...DEPRECATED_METHODS, 'waitFor'];
53+
54+
const newAllRequired = node.properties
55+
.filter(
56+
(s) =>
57+
isProperty(s) &&
58+
ASTUtils.isIdentifier(s.key) &&
59+
!excludedImports.includes(s.key.name)
60+
)
61+
.map(
62+
(s) => ((s as TSESTree.Property).key as TSESTree.Identifier).name
63+
);
64+
65+
newAllRequired.push('waitFor');
66+
67+
return fixer.replaceText(node, `{ ${newAllRequired.join(',')} }`);
68+
},
69+
});
70+
};
71+
4072
const reportImport = (node: TSESTree.ImportDeclaration) => {
4173
context.report({
4274
node: node,
@@ -115,46 +147,57 @@ export default ESLintUtils.RuleCreator(getDocsUrl)<Options, MessageIds>({
115147
};
116148

117149
return {
118-
'ImportDeclaration[source.value=/testing-library/]'(
119-
node: TSESTree.ImportDeclaration
120-
) {
121-
const deprecatedImportSpecifiers = node.specifiers.filter(
122-
(specifier) =>
123-
isImportSpecifier(specifier) &&
124-
specifier.imported &&
125-
DEPRECATED_METHODS.includes(specifier.imported.name)
126-
);
127-
128-
deprecatedImportSpecifiers.forEach((importSpecifier, i) => {
129-
if (i === 0) {
130-
reportImport(node);
131-
}
132-
133-
context
134-
.getDeclaredVariables(importSpecifier)
135-
.forEach((variable) =>
136-
variable.references.forEach((reference) =>
137-
reportWait(reference.identifier)
138-
)
139-
);
140-
});
150+
'CallExpression > MemberExpression'(node: TSESTree.MemberExpression) {
151+
const isDeprecatedMethod =
152+
ASTUtils.isIdentifier(node.property) &&
153+
DEPRECATED_METHODS.includes(node.property.name);
154+
if (!isDeprecatedMethod) {
155+
// the method does not match a deprecated method
156+
return;
157+
}
158+
if (!helpers.isNodeComingFromTestingLibrary(node)) {
159+
// the method does not match from the imported elements from TL (even from custom)
160+
return;
161+
}
162+
addWaitFor = true;
163+
reportWait(node.property as TSESTree.Identifier); // compiler is not picking up correctly, it should have inferred it is an identifier
141164
},
142-
'ImportDeclaration[source.value=/testing-library/] > ImportNamespaceSpecifier'(
143-
node: TSESTree.ImportNamespaceSpecifier
144-
) {
145-
context.getDeclaredVariables(node).forEach((variable) =>
146-
variable.references.forEach((reference) => {
147-
if (
148-
isMemberExpression(reference.identifier.parent) &&
149-
ASTUtils.isIdentifier(reference.identifier.parent.property) &&
150-
DEPRECATED_METHODS.includes(
151-
reference.identifier.parent.property.name
152-
)
153-
) {
154-
reportWait(reference.identifier.parent.property);
155-
}
156-
})
157-
);
165+
'CallExpression > Identifier'(node: TSESTree.Identifier) {
166+
if (!DEPRECATED_METHODS.includes(node.name)) {
167+
return;
168+
}
169+
170+
if (!helpers.isNodeComingFromTestingLibrary(node)) {
171+
return;
172+
}
173+
addWaitFor = true;
174+
reportWait(node);
175+
},
176+
'Program:exit'() {
177+
if (!addWaitFor) {
178+
return;
179+
}
180+
// now that all usages of deprecated methods were replaced, remove the extra imports
181+
const testingLibraryNode =
182+
helpers.getCustomModuleImportNode() ??
183+
helpers.getTestingLibraryImportNode();
184+
if (isCallExpression(testingLibraryNode)) {
185+
const parent = testingLibraryNode.parent as TSESTree.VariableDeclarator;
186+
if (!isObjectPattern(parent.id)) {
187+
// if there is no destructuring, there is nothing to replace
188+
return;
189+
}
190+
reportRequire(parent.id);
191+
} else {
192+
if (
193+
testingLibraryNode.specifiers.length === 1 &&
194+
isImportNamespaceSpecifier(testingLibraryNode.specifiers[0])
195+
) {
196+
// if we import everything, there is nothing to replace
197+
return;
198+
}
199+
reportImport(testingLibraryNode);
200+
}
158201
},
159202
};
160203
},

tests/create-testing-library-rule.test.ts

+20
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,16 @@ ruleTester.run(RULE_NAME, rule, {
185185
queryByRole('button')
186186
`,
187187
},
188+
{
189+
settings: {
190+
'testing-library/module': 'test-utils',
191+
},
192+
code: `
193+
import * as tl from 'test-utils'
194+
const obj = { tl }
195+
obj.tl.waitFor(() => {})
196+
`,
197+
},
188198
],
189199
invalid: [
190200
// Test Cases for Imports & Filename
@@ -516,5 +526,15 @@ ruleTester.run(RULE_NAME, rule, {
516526
`,
517527
errors: [{ line: 4, column: 7, messageId: 'queryByError' }],
518528
},
529+
{
530+
settings: {
531+
'testing-library/module': 'test-utils',
532+
},
533+
code: `
534+
import * as tl from 'test-utils'
535+
tl.waitFor(() => {})
536+
`,
537+
errors: [{ line: 3, column: 9, messageId: 'fakeError' }],
538+
},
519539
],
520540
});

tests/fake-rule.ts

+6
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@ export default createTestingLibraryRule<Options, MessageIds>({
7373
return {
7474
'CallExpression Identifier': reportCallExpressionIdentifier,
7575
MemberExpression: reportMemberExpression,
76+
'CallExpression > MemberExpression'(node: TSESTree.MemberExpression) {
77+
if (!helpers.isNodeComingFromTestingLibrary(node)) {
78+
return;
79+
}
80+
context.report({ node, messageId: 'fakeError' });
81+
},
7682
ImportDeclaration: reportImportDeclaration,
7783
'Program:exit'() {
7884
const importNode = helpers.getCustomModuleImportNode();

0 commit comments

Comments
 (0)