Skip to content

Commit b48b286

Browse files
authored
refactor(prefer-presence-queries): use custom rule creator (#252)
* test(prefer-presence-queries): improve existing invalid tests * refactor(prefer-presence-queries): use custom rule creator * feat(prefer-presence-queries): use aggressive query reporting * refactor(prefer-presence-queries): rename message ids * test: add fake rule tests for queries * refactor(extract helpers for detecting presence/absence assets): add fake rule tests for queries * refactor(prefer-presence-queries): use presence/absence helpers * refactor: simplify negated matcher condition * style: format files after rebase
1 parent c880f2f commit b48b286

9 files changed

+1042
-190
lines changed

.lintstagedrc

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"*.{js,ts}": [
3-
"eslint --fix",
3+
"eslint --max-warnings 0 --fix",
44
"prettier --write",
55
"jest --findRelatedTests"
66
],

lib/detect-testing-library-utils.ts

+78-9
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
1-
import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils';
1+
import {
2+
ASTUtils,
3+
TSESLint,
4+
TSESTree,
5+
} from '@typescript-eslint/experimental-utils';
26
import {
37
getImportModuleName,
8+
getAssertNodeInfo,
49
isLiteral,
510
ImportModuleNode,
611
isImportDeclaration,
712
isImportNamespaceSpecifier,
813
isImportSpecifier,
9-
isIdentifier,
1014
isProperty,
1115
} from './node-utils';
16+
import { ABSENCE_MATCHERS, PRESENCE_MATCHERS } from './utils';
1217

1318
export type TestingLibrarySettings = {
1419
'testing-library/module'?: string;
@@ -41,6 +46,11 @@ export type DetectionHelpers = {
4146
getCustomModuleImportName: () => string | undefined;
4247
getIsTestingLibraryImported: () => boolean;
4348
getIsValidFilename: () => boolean;
49+
isGetByQuery: (node: TSESTree.Identifier) => boolean;
50+
isQueryByQuery: (node: TSESTree.Identifier) => boolean;
51+
isSyncQuery: (node: TSESTree.Identifier) => boolean;
52+
isPresenceAssert: (node: TSESTree.MemberExpression) => boolean;
53+
isAbsenceAssert: (node: TSESTree.MemberExpression) => boolean;
4454
canReportErrors: () => boolean;
4555
findImportedUtilSpecifier: (
4656
specifierName: string
@@ -85,7 +95,8 @@ export function detectTestingLibraryUtils<
8595
return getImportModuleName(importedCustomModuleNode);
8696
},
8797
/**
88-
* Gets if Testing Library is considered as imported or not.
98+
* Determines whether Testing Library utils are imported or not for
99+
* current file being analyzed.
89100
*
90101
* By default, it is ALWAYS considered as imported. This is what we call
91102
* "aggressive reporting" so we don't miss TL utils reexported from
@@ -105,17 +116,75 @@ export function detectTestingLibraryUtils<
105116
},
106117

107118
/**
108-
* Gets if filename being analyzed is valid or not.
109-
*
110-
* This is based on "testing-library/filename-pattern" setting.
119+
* Determines whether filename is valid or not for current file
120+
* being analyzed based on "testing-library/filename-pattern" setting.
111121
*/
112122
getIsValidFilename() {
113123
const fileName = context.getFilename();
114124
return !!fileName.match(filenamePattern);
115125
},
116126

117127
/**
118-
* Wraps all conditions that must be met to report rules.
128+
* Determines whether a given node is `getBy*` or `getAllBy*` query variant or not.
129+
*/
130+
isGetByQuery(node) {
131+
return !!node.name.match(/^get(All)?By.+$/);
132+
},
133+
134+
/**
135+
* Determines whether a given node is `queryBy*` or `queryAllBy*` query variant or not.
136+
*/
137+
isQueryByQuery(node) {
138+
return !!node.name.match(/^query(All)?By.+$/);
139+
},
140+
141+
/**
142+
* Determines whether a given node is sync query or not.
143+
*/
144+
isSyncQuery(node) {
145+
return this.isGetByQuery(node) || this.isQueryByQuery(node);
146+
},
147+
148+
/**
149+
* Determines whether a given MemberExpression node is a presence assert
150+
*
151+
* Presence asserts could have shape of:
152+
* - expect(element).toBeInTheDocument()
153+
* - expect(element).not.toBeNull()
154+
*/
155+
isPresenceAssert(node) {
156+
const { matcher, isNegated } = getAssertNodeInfo(node);
157+
158+
if (!matcher) {
159+
return false;
160+
}
161+
162+
return isNegated
163+
? ABSENCE_MATCHERS.includes(matcher)
164+
: PRESENCE_MATCHERS.includes(matcher);
165+
},
166+
167+
/**
168+
* Determines whether a given MemberExpression node is an absence assert
169+
*
170+
* Absence asserts could have shape of:
171+
* - expect(element).toBeNull()
172+
* - expect(element).not.toBeInTheDocument()
173+
*/
174+
isAbsenceAssert(node) {
175+
const { matcher, isNegated } = getAssertNodeInfo(node);
176+
177+
if (!matcher) {
178+
return false;
179+
}
180+
181+
return isNegated
182+
? PRESENCE_MATCHERS.includes(matcher)
183+
: ABSENCE_MATCHERS.includes(matcher);
184+
},
185+
186+
/**
187+
* Determines if file inspected meets all conditions to be reported by rules or not.
119188
*/
120189
canReportErrors() {
121190
return (
@@ -144,7 +213,7 @@ export function detectTestingLibraryUtils<
144213
return node.specifiers.find((n) => isImportNamespaceSpecifier(n));
145214
} else {
146215
const requireNode = node.parent as TSESTree.VariableDeclarator;
147-
if (isIdentifier(requireNode.id)) {
216+
if (ASTUtils.isIdentifier(requireNode.id)) {
148217
// this is const rtl = require('foo')
149218
return requireNode.id;
150219
}
@@ -153,7 +222,7 @@ export function detectTestingLibraryUtils<
153222
const property = destructuring.properties.find(
154223
(n) =>
155224
isProperty(n) &&
156-
isIdentifier(n.key) &&
225+
ASTUtils.isIdentifier(n.key) &&
157226
n.key.name === specifierName
158227
);
159228
return (property as TSESTree.Property).key as TSESTree.Identifier;

lib/node-utils.ts

+39
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
AST_NODE_TYPES,
3+
ASTUtils,
34
TSESLint,
45
TSESTree,
56
} from '@typescript-eslint/experimental-utils';
@@ -253,3 +254,41 @@ export function getImportModuleName(
253254
return node.arguments[0].value;
254255
}
255256
}
257+
258+
type AssertNodeInfo = {
259+
matcher: string | null;
260+
isNegated: boolean;
261+
};
262+
/**
263+
* Extracts matcher info from MemberExpression node representing an assert.
264+
*/
265+
export function getAssertNodeInfo(
266+
node: TSESTree.MemberExpression
267+
): AssertNodeInfo {
268+
const emptyInfo = { matcher: null, isNegated: false } as AssertNodeInfo;
269+
270+
if (
271+
!isCallExpression(node.object) ||
272+
!ASTUtils.isIdentifier(node.object.callee)
273+
) {
274+
return emptyInfo;
275+
}
276+
277+
if (node.object.callee.name !== 'expect') {
278+
return emptyInfo;
279+
}
280+
281+
let matcher = ASTUtils.getPropertyName(node);
282+
const isNegated = matcher === 'not';
283+
if (isNegated) {
284+
matcher = isMemberExpression(node.parent)
285+
? ASTUtils.getPropertyName(node.parent)
286+
: null;
287+
}
288+
289+
if (!matcher) {
290+
return emptyInfo;
291+
}
292+
293+
return { matcher, isNegated };
294+
}

lib/rules/prefer-presence-queries.ts

+29-61
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,12 @@
1-
import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils';
2-
import {
3-
getDocsUrl,
4-
ALL_QUERIES_METHODS,
5-
PRESENCE_MATCHERS,
6-
ABSENCE_MATCHERS,
7-
} from '../utils';
8-
import {
9-
findClosestCallNode,
10-
isMemberExpression,
11-
isIdentifier,
12-
} from '../node-utils';
1+
import { TSESTree } from '@typescript-eslint/experimental-utils';
2+
import { findClosestCallNode, isMemberExpression } from '../node-utils';
3+
import { createTestingLibraryRule } from '../create-testing-library-rule';
134

145
export const RULE_NAME = 'prefer-presence-queries';
15-
export type MessageIds = 'presenceQuery' | 'absenceQuery' | 'expectQueryBy';
6+
export type MessageIds = 'wrongPresenceQuery' | 'wrongAbsenceQuery';
167
type Options = [];
178

18-
const QUERIES_REGEXP = new RegExp(
19-
`^(get|query)(All)?(${ALL_QUERIES_METHODS.join('|')})$`
20-
);
21-
22-
function isThrowingQuery(node: TSESTree.Identifier) {
23-
return node.name.startsWith('get');
24-
}
25-
26-
export default ESLintUtils.RuleCreator(getDocsUrl)<Options, MessageIds>({
9+
export default createTestingLibraryRule<Options, MessageIds>({
2710
name: RULE_NAME,
2811
meta: {
2912
docs: {
@@ -33,62 +16,47 @@ export default ESLintUtils.RuleCreator(getDocsUrl)<Options, MessageIds>({
3316
recommended: 'error',
3417
},
3518
messages: {
36-
presenceQuery:
19+
wrongPresenceQuery:
3720
'Use `getBy*` queries rather than `queryBy*` for checking element is present',
38-
absenceQuery:
21+
wrongAbsenceQuery:
3922
'Use `queryBy*` queries rather than `getBy*` for checking element is NOT present',
40-
expectQueryBy:
41-
'Use `getBy*` only when checking elements are present, otherwise use `queryBy*`',
4223
},
4324
schema: [],
4425
type: 'suggestion',
4526
fixable: null,
4627
},
4728
defaultOptions: [],
4829

49-
create(context) {
30+
create(context, _, helpers) {
5031
return {
51-
[`CallExpression Identifier[name=${QUERIES_REGEXP}]`](
52-
node: TSESTree.Identifier
53-
) {
32+
'CallExpression Identifier'(node: TSESTree.Identifier) {
5433
const expectCallNode = findClosestCallNode(node, 'expect');
5534

56-
if (expectCallNode && isMemberExpression(expectCallNode.parent)) {
57-
const expectStatement = expectCallNode.parent;
58-
const property = expectStatement.property as TSESTree.Identifier;
59-
let matcher = property.name;
60-
let isNegatedMatcher = false;
35+
if (!expectCallNode || !isMemberExpression(expectCallNode.parent)) {
36+
return;
37+
}
6138

62-
if (
63-
matcher === 'not' &&
64-
isMemberExpression(expectStatement.parent) &&
65-
isIdentifier(expectStatement.parent.property)
66-
) {
67-
isNegatedMatcher = true;
68-
matcher = expectStatement.parent.property.name;
69-
}
39+
// Sync queries (getBy and queryBy) are corresponding ones used
40+
// to check presence or absence. If none found, stop the rule.
41+
if (!helpers.isSyncQuery(node)) {
42+
return;
43+
}
7044

71-
const validMatchers = isThrowingQuery(node)
72-
? PRESENCE_MATCHERS
73-
: ABSENCE_MATCHERS;
45+
const isPresenceQuery = helpers.isGetByQuery(node);
46+
const expectStatement = expectCallNode.parent;
47+
const isPresenceAssert = helpers.isPresenceAssert(expectStatement);
48+
const isAbsenceAssert = helpers.isAbsenceAssert(expectStatement);
7449

75-
const invalidMatchers = isThrowingQuery(node)
76-
? ABSENCE_MATCHERS
77-
: PRESENCE_MATCHERS;
50+
if (!isPresenceAssert && !isAbsenceAssert) {
51+
return;
52+
}
7853

79-
const messageId = isThrowingQuery(node)
80-
? 'absenceQuery'
81-
: 'presenceQuery';
54+
if (isPresenceAssert && !isPresenceQuery) {
55+
return context.report({ node, messageId: 'wrongPresenceQuery' });
56+
}
8257

83-
if (
84-
(!isNegatedMatcher && invalidMatchers.includes(matcher)) ||
85-
(isNegatedMatcher && validMatchers.includes(matcher))
86-
) {
87-
return context.report({
88-
node,
89-
messageId,
90-
});
91-
}
58+
if (isAbsenceAssert && isPresenceQuery) {
59+
return context.report({ node, messageId: 'wrongAbsenceQuery' });
9260
}
9361
},
9462
};

lib/utils.ts

+1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const LIBRARY_MODULES = [
2424
'@testing-library/svelte',
2525
];
2626

27+
// TODO: should be deleted after all rules are migrated to v4
2728
const hasTestingLibraryImportModule = (
2829
node: TSESTree.ImportDeclaration
2930
): boolean => {

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"scripts": {
2828
"build": "tsc",
2929
"postbuild": "cpy README.md ./dist && cpy package.json ./dist && cpy LICENSE ./dist",
30-
"lint": "eslint . --ext .js,.ts",
30+
"lint": "eslint . --max-warnings 0 --ext .js,.ts",
3131
"lint:fix": "npm run lint -- --fix",
3232
"format": "prettier --write README.md \"{lib,docs,tests}/**/*.{js,ts,md}\"",
3333
"format:check": "prettier --check README.md \"{lib,docs,tests}/**/*.{js,json,yml,ts,md}\"",

0 commit comments

Comments
 (0)