Skip to content

Commit 7fa4619

Browse files
authored
Add no-restricted-matchers (#92)
* Add no-restricted-matchers * AST helper
1 parent d0876ca commit 7fa4619

File tree

5 files changed

+262
-4
lines changed

5 files changed

+262
-4
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ command line option.\
5959
|| | 💡 | [no-focused-test](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-focused-test.md) | Disallow usage of `.only` annotation |
6060
|| | | [no-force-option](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-force-option.md) | Disallow usage of the `{ force: true }` option |
6161
|| | | [no-page-pause](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-page-pause.md) | Disallow using `page.pause` |
62+
| | | | [no-restricted-matchers](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-restricted-matchers.md) | Disallow specific matchers & modifiers |
6263
|| | 💡 | [no-skipped-test](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-skipped-test.md) | Disallow usage of the `.skip` annotation |
6364
|| 🔧 | | [no-useless-not](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-useless-not.md) | Disallow usage of `not` matchers when a specific matcher exists |
6465
|| | 💡 | [no-wait-for-timeout](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-wait-for-timeout.md) | Disallow usage of `page.waitForTimeout` |

docs/rules/no-restricted-matchers.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Disallow specific matchers & modifiers (`no-restricted-matchers`)
2+
3+
This rule bans specific matchers & modifiers from being used, and can suggest
4+
alternatives.
5+
6+
## Rule Details
7+
8+
Bans are expressed in the form of a map, with the value being either a string
9+
message to be shown, or `null` if the default rule message should be used.
10+
11+
Both matchers, modifiers, and chains of the two are checked, allowing for
12+
specific variations of a matcher to be banned if desired.
13+
14+
By default, this map is empty, meaning no matchers or modifiers are banned.
15+
16+
For example:
17+
18+
```json
19+
{
20+
"playwright/no-restricted-matchers": [
21+
"error",
22+
{
23+
"toBeFalsy": "Use `toBe(false)` instead.",
24+
"not": null,
25+
"not.toHaveText": null
26+
}
27+
]
28+
}
29+
```
30+
31+
Examples of **incorrect** code for this rule with the above configuration
32+
33+
```javascript
34+
test('is false', () => {
35+
expect(a).toBeFalsy();
36+
});
37+
38+
test('not', () => {
39+
expect(a).not.toBe(true);
40+
});
41+
42+
test('chain', async () => {
43+
await expect(foo).not.toHaveText('bar');
44+
});
45+
```

src/index.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ import noWaitForTimeout from './rules/no-wait-for-timeout';
88
import noForceOption from './rules/no-force-option';
99
import maxNestedDescribe from './rules/max-nested-describe';
1010
import noConditionalInTest from './rules/no-conditional-in-test';
11+
import noRestrictedMatchers from './rules/no-restricted-matchers';
1112
import noUselessNot from './rules/no-useless-not';
12-
import validExpect from './rules/valid-expect';
1313
import preferLowercaseTitle from './rules/prefer-lowercase-title';
14-
import requireTopLevelDescribe from './rules/require-top-level-describe';
1514
import preferToHaveLength from './rules/prefer-to-have-length';
15+
import requireTopLevelDescribe from './rules/require-top-level-describe';
16+
import validExpect from './rules/valid-expect';
1617

1718
export = {
1819
configs: {
@@ -82,9 +83,10 @@ export = {
8283
'max-nested-describe': maxNestedDescribe,
8384
'no-conditional-in-test': noConditionalInTest,
8485
'no-useless-not': noUselessNot,
85-
'valid-expect': validExpect,
86+
'no-restricted-matchers': noRestrictedMatchers,
8687
'prefer-lowercase-title': preferLowercaseTitle,
87-
'require-top-level-describe': requireTopLevelDescribe,
8888
'prefer-to-have-length': preferToHaveLength,
89+
'require-top-level-describe': requireTopLevelDescribe,
90+
'valid-expect': validExpect,
8991
},
9092
};

src/rules/no-restricted-matchers.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { Rule } from 'eslint';
2+
import { getMatchers, getNodeName, isExpectCall } from '../utils/ast';
3+
4+
export default {
5+
create(context) {
6+
const restrictedChains = (context.options?.[0] ?? {}) as {
7+
[key: string]: string | null;
8+
};
9+
10+
return {
11+
CallExpression(node) {
12+
if (!isExpectCall(node)) {
13+
return;
14+
}
15+
16+
const matchers = getMatchers(node);
17+
const permutations = matchers.map((_, i) => matchers.slice(0, i + 1));
18+
19+
for (const permutation of permutations) {
20+
const chain = permutation.map(getNodeName).join('.');
21+
22+
if (chain in restrictedChains) {
23+
const message = restrictedChains[chain];
24+
25+
context.report({
26+
messageId: message ? 'restrictedWithMessage' : 'restricted',
27+
data: { message: message ?? '', chain },
28+
loc: {
29+
start: permutation[0].loc!.start,
30+
end: permutation[permutation.length - 1].loc!.end,
31+
},
32+
});
33+
34+
break;
35+
}
36+
}
37+
},
38+
};
39+
},
40+
meta: {
41+
docs: {
42+
category: 'Best Practices',
43+
description: 'Disallow specific matchers & modifiers',
44+
recommended: false,
45+
url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-restricted-matchers.md',
46+
},
47+
messages: {
48+
restricted: 'Use of `{{chain}}` is disallowed',
49+
restrictedWithMessage: '{{message}}',
50+
},
51+
type: 'suggestion',
52+
schema: [
53+
{
54+
type: 'object',
55+
additionalProperties: {
56+
type: ['string', 'null'],
57+
},
58+
},
59+
],
60+
},
61+
} as Rule.RuleModule;
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import rule from '../../src/rules/no-restricted-matchers';
2+
import { runRuleTester } from '../utils/rule-tester';
3+
4+
runRuleTester('no-restricted-matchers', rule, {
5+
valid: [
6+
'expect(a)',
7+
'expect(a).toBe()',
8+
'expect(a).not.toContain()',
9+
'expect(a).toHaveText()',
10+
'expect(a).toThrow()',
11+
'expect.soft(a)',
12+
'expect.soft(a).toHaveText()',
13+
'expect.poll(() => true).toThrow()',
14+
{
15+
code: 'expect(a).toBe(b)',
16+
options: [{ 'not.toBe': null }],
17+
},
18+
{
19+
code: 'expect(a).toBe(b)',
20+
options: [{ 'not.toBe': null }],
21+
},
22+
{
23+
code: 'expect.soft(a).toBe(b)',
24+
options: [{ 'not.toBe': null }],
25+
},
26+
{
27+
code: 'expect.poll(() => true).toBe(b)',
28+
options: [{ 'not.toBe': null }],
29+
},
30+
],
31+
invalid: [
32+
{
33+
code: 'expect(a).toBe(b)',
34+
options: [{ toBe: null }],
35+
errors: [
36+
{
37+
messageId: 'restricted',
38+
data: { message: '', chain: 'toBe' },
39+
column: 11,
40+
line: 1,
41+
},
42+
],
43+
},
44+
{
45+
code: 'expect.soft(a).toBe(b)',
46+
options: [{ toBe: null }],
47+
errors: [
48+
{
49+
messageId: 'restricted',
50+
data: { message: '', chain: 'toBe' },
51+
column: 16,
52+
line: 1,
53+
},
54+
],
55+
},
56+
{
57+
code: 'expect.poll(() => a).toBe(b)',
58+
options: [{ toBe: null }],
59+
errors: [
60+
{
61+
messageId: 'restricted',
62+
data: { message: '', chain: 'toBe' },
63+
column: 22,
64+
line: 1,
65+
},
66+
],
67+
},
68+
{
69+
code: 'expect(a).not.toBe()',
70+
options: [{ not: null }],
71+
errors: [
72+
{
73+
messageId: 'restricted',
74+
data: { message: '', chain: 'not' },
75+
column: 11,
76+
line: 1,
77+
},
78+
],
79+
},
80+
{
81+
code: 'expect(a).not.toBeTruthy()',
82+
options: [{ 'not.toBeTruthy': null }],
83+
errors: [
84+
{
85+
messageId: 'restricted',
86+
data: { message: '', chain: 'not.toBeTruthy' },
87+
endColumn: 25,
88+
column: 11,
89+
line: 1,
90+
},
91+
],
92+
},
93+
{
94+
code: 'expect.soft(a).not.toBe()',
95+
options: [{ not: null }],
96+
errors: [
97+
{
98+
messageId: 'restricted',
99+
data: { message: '', chain: 'not' },
100+
column: 16,
101+
line: 1,
102+
},
103+
],
104+
},
105+
{
106+
code: 'expect.poll(() => true).not.toBeTruthy()',
107+
options: [{ 'not.toBeTruthy': null }],
108+
errors: [
109+
{
110+
messageId: 'restricted',
111+
data: { message: '', chain: 'not.toBeTruthy' },
112+
endColumn: 39,
113+
column: 25,
114+
line: 1,
115+
},
116+
],
117+
},
118+
{
119+
code: 'expect(a).toBe(b)',
120+
options: [{ toBe: 'Prefer `toStrictEqual` instead' }],
121+
errors: [
122+
{
123+
messageId: 'restrictedWithMessage',
124+
data: {
125+
message: 'Prefer `toStrictEqual` instead',
126+
chain: 'toBe',
127+
},
128+
column: 11,
129+
line: 1,
130+
},
131+
],
132+
},
133+
{
134+
code: "expect(foo).not.toHaveText('bar')",
135+
options: [{ 'not.toHaveText': 'Use not.toContainText instead' }],
136+
errors: [
137+
{
138+
messageId: 'restrictedWithMessage',
139+
data: {
140+
message: 'Use not.toContainText instead',
141+
chain: 'not.toHaveText',
142+
},
143+
endColumn: 27,
144+
column: 13,
145+
},
146+
],
147+
},
148+
],
149+
});

0 commit comments

Comments
 (0)