Skip to content

Commit 6e645e6

Browse files
feat: add no-global-regex-flag-in-query rule (#560)
* feat: add no-global-regex-flag-in-query * refactor: review feedback * feat: add fixer * test: add error details * test: add within cases * refactor: use getDeepestIdentifierNode * refactor: review feedback Closes #559
1 parent 7bc2b9c commit 6e645e6

File tree

5 files changed

+327
-1
lines changed

5 files changed

+327
-1
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ To enable this configuration use the `extends` property in your
198198
| [`testing-library/no-container`](./docs/rules/no-container.md) | Disallow the use of `container` methods | | ![angular-badge][] ![react-badge][] ![vue-badge][] |
199199
| [`testing-library/no-debugging-utils`](./docs/rules/no-debugging-utils.md) | Disallow the use of debugging utilities like `debug` | | ![angular-badge][] ![react-badge][] ![vue-badge][] |
200200
| [`testing-library/no-dom-import`](./docs/rules/no-dom-import.md) | Disallow importing from DOM Testing Library | 🔧 | ![angular-badge][] ![react-badge][] ![vue-badge][] |
201+
| [`testing-library/no-global-regexp-flag-in-query`](./docs/rules/no-global-regexp-flag-in-query.md) | Disallow the use of the global RegExp flag (/g) in queries | 🔧 | |
201202
| [`testing-library/no-manual-cleanup`](./docs/rules/no-manual-cleanup.md) | Disallow the use of `cleanup` | | |
202203
| [`testing-library/no-node-access`](./docs/rules/no-node-access.md) | Disallow direct Node access | | ![angular-badge][] ![react-badge][] ![vue-badge][] |
203204
| [`testing-library/no-promise-in-fire-event`](./docs/rules/no-promise-in-fire-event.md) | Disallow the use of promises passed to a `fireEvent` method | | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] |
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Disallow the use of the global RegExp flag (/g) in queries (`testing-library/no-global-regexp-flag-in-query`)
2+
3+
Ensure that there are no global RegExp flags used when using queries.
4+
5+
## Rule Details
6+
7+
A RegExp instance that's using the global flag `/g` holds state and this might cause false-positives while querying for elements.
8+
9+
Examples of **incorrect** code for this rule:
10+
11+
```js
12+
screen.getByText(/hello/gi);
13+
```
14+
15+
```js
16+
await screen.findByRole('button', { otherProp: true, name: /hello/g });
17+
```
18+
19+
Examples of **correct** code for this rule:
20+
21+
```js
22+
screen.getByText(/hello/i);
23+
```
24+
25+
```js
26+
await screen.findByRole('button', { otherProp: true, name: /hello/ });
27+
```
28+
29+
## Further Reading
30+
31+
- [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/lastIndex)
+105
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { ASTUtils, TSESTree } from '@typescript-eslint/utils';
2+
3+
import { createTestingLibraryRule } from '../create-testing-library-rule';
4+
import {
5+
isMemberExpression,
6+
isCallExpression,
7+
isProperty,
8+
isObjectExpression,
9+
getDeepestIdentifierNode,
10+
isLiteral,
11+
} from '../node-utils';
12+
13+
export const RULE_NAME = 'no-global-regexp-flag-in-query';
14+
export type MessageIds = 'noGlobalRegExpFlagInQuery';
15+
type Options = [];
16+
17+
export default createTestingLibraryRule<Options, MessageIds>({
18+
name: RULE_NAME,
19+
meta: {
20+
type: 'suggestion',
21+
docs: {
22+
description: 'Disallow the use of the global RegExp flag (/g) in queries',
23+
recommendedConfig: {
24+
dom: false,
25+
angular: false,
26+
react: false,
27+
vue: false,
28+
},
29+
},
30+
messages: {
31+
noGlobalRegExpFlagInQuery:
32+
'Avoid using the global RegExp flag (/g) in queries',
33+
},
34+
fixable: 'code',
35+
schema: [],
36+
},
37+
defaultOptions: [],
38+
create(context, _, helpers) {
39+
function report(literalNode: TSESTree.Node) {
40+
if (
41+
isLiteral(literalNode) &&
42+
'regex' in literalNode &&
43+
literalNode.regex.flags.includes('g')
44+
) {
45+
context.report({
46+
node: literalNode,
47+
messageId: 'noGlobalRegExpFlagInQuery',
48+
fix(fixer) {
49+
const splitter = literalNode.raw.lastIndexOf('/');
50+
const raw = literalNode.raw.substring(0, splitter);
51+
const flags = literalNode.raw.substring(splitter + 1);
52+
const flagsWithoutGlobal = flags.replace('g', '');
53+
54+
return fixer.replaceText(
55+
literalNode,
56+
`${raw}/${flagsWithoutGlobal}`
57+
);
58+
},
59+
});
60+
return true;
61+
}
62+
return false;
63+
}
64+
65+
function getArguments(identifierNode: TSESTree.Identifier) {
66+
if (isCallExpression(identifierNode.parent)) {
67+
return identifierNode.parent.arguments;
68+
} else if (
69+
isMemberExpression(identifierNode.parent) &&
70+
isCallExpression(identifierNode.parent.parent)
71+
) {
72+
return identifierNode.parent.parent.arguments;
73+
}
74+
75+
return [];
76+
}
77+
78+
return {
79+
CallExpression(node) {
80+
const identifierNode = getDeepestIdentifierNode(node);
81+
if (!identifierNode || !helpers.isQuery(identifierNode)) {
82+
return;
83+
}
84+
85+
const [firstArg, secondArg] = getArguments(identifierNode);
86+
87+
const firstArgumentHasError = report(firstArg);
88+
if (firstArgumentHasError) {
89+
return;
90+
}
91+
92+
if (isObjectExpression(secondArg)) {
93+
const namePropertyNode = secondArg.properties.find(
94+
(p) =>
95+
isProperty(p) &&
96+
ASTUtils.isIdentifier(p.key) &&
97+
p.key.name === 'name' &&
98+
isLiteral(p.value)
99+
) as TSESTree.ObjectLiteralElement & { value: TSESTree.Literal };
100+
report(namePropertyNode.value);
101+
}
102+
},
103+
};
104+
},
105+
});

tests/index.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import plugin from '../lib';
88
const execAsync = util.promisify(exec);
99
const generateConfigs = () => execAsync(`npm run generate:configs`);
1010

11-
const numberOfRules = 26;
11+
const numberOfRules = 27;
1212
const ruleNames = Object.keys(plugin.rules);
1313

1414
// eslint-disable-next-line jest/expect-expect
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import rule, {
2+
RULE_NAME,
3+
} from '../../../lib/rules/no-global-regexp-flag-in-query';
4+
import { createRuleTester } from '../test-utils';
5+
6+
const ruleTester = createRuleTester();
7+
8+
ruleTester.run(RULE_NAME, rule, {
9+
valid: [
10+
`
11+
import { screen } from '@testing-library/dom'
12+
screen.getByText(/hello/)
13+
`,
14+
`
15+
import { screen } from '@testing-library/dom'
16+
screen.getByText(/hello/i)
17+
`,
18+
`
19+
import { screen } from '@testing-library/dom'
20+
screen.getByText('hello')
21+
`,
22+
23+
`
24+
import { screen } from '@testing-library/dom'
25+
screen.findByRole('button', {name: /hello/})
26+
`,
27+
`
28+
import { screen } from '@testing-library/dom'
29+
screen.findByRole('button', {name: /hello/im})
30+
`,
31+
`
32+
import { screen } from '@testing-library/dom'
33+
screen.findByRole('button', {name: 'hello'})
34+
`,
35+
`
36+
const utils = render(<Component/>)
37+
utils.findByRole('button', {name: /hello/m})
38+
`,
39+
`
40+
const {queryAllByPlaceholderText} = render(<Component/>)
41+
queryAllByPlaceholderText(/hello/i)
42+
`,
43+
`
44+
import { within } from '@testing-library/dom'
45+
within(element).findByRole('button', {name: /hello/i})
46+
`,
47+
`
48+
import { within } from '@testing-library/dom'
49+
within(element).queryByText('Hello')
50+
`,
51+
`
52+
const text = 'hello';
53+
/hello/g.test(text)
54+
text.match(/hello/g)
55+
`,
56+
`
57+
const text = somethingElse()
58+
/hello/g.test(text)
59+
text.match(/hello/g)
60+
`,
61+
`
62+
import somethingElse from 'somethingElse'
63+
somethingElse.lookup(/hello/g)
64+
`,
65+
`
66+
import { screen } from '@testing-library/dom'
67+
screen.notAQuery(/hello/g)
68+
`,
69+
`
70+
import { screen } from '@testing-library/dom'
71+
screen.notAQuery('button', {name: /hello/g})
72+
`,
73+
`
74+
const utils = render(<Component/>)
75+
utils.notAQuery('button', {name: /hello/i})
76+
`,
77+
`
78+
const utils = render(<Component/>)
79+
utils.notAQuery(/hello/i)
80+
`,
81+
],
82+
invalid: [
83+
{
84+
code: `
85+
import { screen } from '@testing-library/dom'
86+
screen.getByText(/hello/g)`,
87+
errors: [
88+
{
89+
messageId: 'noGlobalRegExpFlagInQuery',
90+
line: 3,
91+
column: 26,
92+
},
93+
],
94+
output: `
95+
import { screen } from '@testing-library/dom'
96+
screen.getByText(/hello/)`,
97+
},
98+
{
99+
code: `
100+
import { screen } from '@testing-library/dom'
101+
screen.findByRole('button', {name: /hellogg/g})`,
102+
errors: [
103+
{
104+
messageId: 'noGlobalRegExpFlagInQuery',
105+
line: 3,
106+
column: 44,
107+
},
108+
],
109+
output: `
110+
import { screen } from '@testing-library/dom'
111+
screen.findByRole('button', {name: /hellogg/})`,
112+
},
113+
{
114+
code: `
115+
import { screen } from '@testing-library/dom'
116+
screen.findByRole('button', {otherProp: true, name: /hello/g})`,
117+
errors: [
118+
{
119+
messageId: 'noGlobalRegExpFlagInQuery',
120+
line: 3,
121+
column: 61,
122+
},
123+
],
124+
output: `
125+
import { screen } from '@testing-library/dom'
126+
screen.findByRole('button', {otherProp: true, name: /hello/})`,
127+
},
128+
{
129+
code: `
130+
const utils = render(<Component/>)
131+
utils.findByRole('button', {name: /hello/ig})`,
132+
errors: [
133+
{
134+
messageId: 'noGlobalRegExpFlagInQuery',
135+
line: 3,
136+
column: 43,
137+
},
138+
],
139+
output: `
140+
const utils = render(<Component/>)
141+
utils.findByRole('button', {name: /hello/i})`,
142+
},
143+
{
144+
code: `
145+
const {queryAllByLabelText} = render(<Component/>)
146+
queryAllByLabelText(/hello/gi)`,
147+
errors: [
148+
{
149+
messageId: 'noGlobalRegExpFlagInQuery',
150+
line: 3,
151+
column: 29,
152+
},
153+
],
154+
output: `
155+
const {queryAllByLabelText} = render(<Component/>)
156+
queryAllByLabelText(/hello/i)`,
157+
},
158+
{
159+
code: `
160+
import { within } from '@testing-library/dom'
161+
within(element).findByRole('button', {name: /hello/igm})`,
162+
errors: [
163+
{
164+
messageId: 'noGlobalRegExpFlagInQuery',
165+
line: 3,
166+
column: 53,
167+
},
168+
],
169+
output: `
170+
import { within } from '@testing-library/dom'
171+
within(element).findByRole('button', {name: /hello/im})`,
172+
},
173+
{
174+
code: `
175+
import { within } from '@testing-library/dom'
176+
within(element).queryAllByText(/hello/ig)`,
177+
errors: [
178+
{
179+
messageId: 'noGlobalRegExpFlagInQuery',
180+
line: 3,
181+
column: 40,
182+
},
183+
],
184+
output: `
185+
import { within } from '@testing-library/dom'
186+
within(element).queryAllByText(/hello/i)`,
187+
},
188+
],
189+
});

0 commit comments

Comments
 (0)