Skip to content

Commit e6f92b0

Browse files
authored
feat(prefer-native-locators): Add rule to suggest using built-in locators (#308)
* Add label text query * Add role query * Add placeholder query * Add alt text query * Add test ID query * Add title query * Simplify text matching and replacement * Put new text in its own variable * Add support for custom test ID attribute * Add more tests * Add docs * Add prefer-native-locators to README * Export prefer-native-locators rule * Move patterns to array * Drop periods from messages * Remove non-page locator method check * Add tests for empty string + no args * Use AST.Range instead of tuple * Add more tests * Allow replacing for selectors without quotes * Add docs on testIdAttribute * Undo line-break * Use same RegExp for each attribute * Move range into fixer * Remove unnecessary identifier * Add more test cases
1 parent 482e4cd commit e6f92b0

File tree

5 files changed

+390
-0
lines changed

5 files changed

+390
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ CLI option\
194194
| [prefer-hooks-in-order](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-hooks-in-order.md) | Prefer having hooks in a consistent order | | | |
195195
| [prefer-hooks-on-top](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-hooks-on-top.md) | Suggest having hooks before any test cases | | | |
196196
| [prefer-lowercase-title](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-lowercase-title.md) | Enforce lowercase test names | | 🔧 | |
197+
| [prefer-native-locators](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-native-locators.md) | Suggest built-in locators over `page.locator()` | | 🔧 | |
197198
| [prefer-strict-equal](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-strict-equal.md) | Suggest using `toStrictEqual()` | | | 💡 |
198199
| [prefer-to-be](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-to-be.md) | Suggest using `toBe()` | | 🔧 | |
199200
| [prefer-to-contain](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-to-contain.md) | Suggest using `toContain()` | | 🔧 | |

docs/rules/prefer-native-locators.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Suggest using native Playwright locators (`prefer-native-locators`)
2+
3+
Playwright has built-in locators for common query selectors such as finding
4+
elements by placeholder text, ARIA role, accessible name, and more. This rule
5+
suggests using these native locators instead of using `page.locator()` with an
6+
equivalent selector.
7+
8+
In some cases this can be more robust too, such as finding elements by ARIA role
9+
or accessible name, because some elements have implicit roles, and there are
10+
multiple ways to specify accessible names.
11+
12+
## Rule details
13+
14+
Examples of **incorrect** code for this rule:
15+
16+
```javascript
17+
page.locator('[aria-label="View more"]')
18+
page.locator('[role="button"]')
19+
page.locator('[placeholder="Enter some text..."]')
20+
page.locator('[alt="Playwright logo"]')
21+
page.locator('[title="Additional context"]')
22+
page.locator('[data-testid="password-input"]')
23+
```
24+
25+
Examples of **correct** code for this rule:
26+
27+
```javascript
28+
page.getByLabel('View more')
29+
page.getByRole('Button')
30+
page.getByPlaceholder('Enter some text...')
31+
page.getByAltText('Playwright logo')
32+
page.getByTestId('password-input')
33+
page.getByTitle('Additional context')
34+
```
35+
36+
## Options
37+
38+
```json
39+
{
40+
"playwright/prefer-native-locators": [
41+
"error",
42+
{
43+
"testIdAttribute": "data-testid"
44+
}
45+
]
46+
}
47+
```
48+
49+
### `testIdAttribute`
50+
51+
Default: `data-testid`
52+
53+
This string option specifies the test ID attribute to look for and replace with
54+
`page.getByTestId()` calls. If you are using
55+
[`page.setTestIdAttribute()`](https://playwright.dev/docs/api/class-selectors#selectors-set-test-id-attribute),
56+
this should be set to the same value as what you pass in to that method.
57+
58+
Examples of **incorrect** code when using
59+
`{ "testIdAttribute": "data-custom-testid" }` option:
60+
61+
```js
62+
page.locator('[data-custom-testid="password-input"]')
63+
```
64+
65+
Examples of **correct** code when using
66+
`{ "testIdAttribute": "data-custom-testid" }` option:
67+
68+
```js
69+
page.getByTestId('password-input')
70+
```

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import preferEqualityMatcher from './rules/prefer-equality-matcher'
3131
import preferHooksInOrder from './rules/prefer-hooks-in-order'
3232
import preferHooksOnTop from './rules/prefer-hooks-on-top'
3333
import preferLowercaseTitle from './rules/prefer-lowercase-title'
34+
import preferNativeLocators from './rules/prefer-native-locators'
3435
import preferStrictEqual from './rules/prefer-strict-equal'
3536
import preferToBe from './rules/prefer-to-be'
3637
import preferToContain from './rules/prefer-to-contain'
@@ -81,6 +82,7 @@ const index = {
8182
'prefer-hooks-in-order': preferHooksInOrder,
8283
'prefer-hooks-on-top': preferHooksOnTop,
8384
'prefer-lowercase-title': preferLowercaseTitle,
85+
'prefer-native-locators': preferNativeLocators,
8486
'prefer-strict-equal': preferStrictEqual,
8587
'prefer-to-be': preferToBe,
8688
'prefer-to-contain': preferToContain,
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { runRuleTester } from '../utils/rule-tester'
2+
import rule from './prefer-native-locators'
3+
4+
runRuleTester('prefer-native-locators', rule, {
5+
invalid: [
6+
{
7+
code: `page.locator('[aria-label="View more"]')`,
8+
errors: [{ column: 1, line: 1, messageId: 'unexpectedLabelQuery' }],
9+
output: 'page.getByLabel("View more")',
10+
},
11+
{
12+
code: `page.locator('[aria-label=Edit]')`,
13+
errors: [{ column: 1, line: 1, messageId: 'unexpectedLabelQuery' }],
14+
output: 'page.getByLabel("Edit")',
15+
},
16+
{
17+
code: `page.locator('[role="button"]')`,
18+
errors: [{ column: 1, line: 1, messageId: 'unexpectedRoleQuery' }],
19+
output: 'page.getByRole("button")',
20+
},
21+
{
22+
code: `page.locator('[role=button]')`,
23+
errors: [{ column: 1, line: 1, messageId: 'unexpectedRoleQuery' }],
24+
output: 'page.getByRole("button")',
25+
},
26+
{
27+
code: `page.locator('[placeholder="Enter some text..."]')`,
28+
errors: [{ column: 1, line: 1, messageId: 'unexpectedPlaceholderQuery' }],
29+
output: 'page.getByPlaceholder("Enter some text...")',
30+
},
31+
{
32+
code: `page.locator('[placeholder=Name]')`,
33+
errors: [{ column: 1, line: 1, messageId: 'unexpectedPlaceholderQuery' }],
34+
output: 'page.getByPlaceholder("Name")',
35+
},
36+
{
37+
code: `page.locator('[alt="Playwright logo"]')`,
38+
errors: [{ column: 1, line: 1, messageId: 'unexpectedAltTextQuery' }],
39+
output: 'page.getByAltText("Playwright logo")',
40+
},
41+
{
42+
code: `page.locator('[alt=Logo]')`,
43+
errors: [{ column: 1, line: 1, messageId: 'unexpectedAltTextQuery' }],
44+
output: 'page.getByAltText("Logo")',
45+
},
46+
{
47+
code: `page.locator('[title="Additional context"]')`,
48+
errors: [{ column: 1, line: 1, messageId: 'unexpectedTitleQuery' }],
49+
output: 'page.getByTitle("Additional context")',
50+
},
51+
{
52+
code: `page.locator('[title=Context]')`,
53+
errors: [{ column: 1, line: 1, messageId: 'unexpectedTitleQuery' }],
54+
output: 'page.getByTitle("Context")',
55+
},
56+
{
57+
code: `page.locator('[data-testid="password-input"]')`,
58+
errors: [{ column: 1, line: 1, messageId: 'unexpectedTestIdQuery' }],
59+
output: 'page.getByTestId("password-input")',
60+
},
61+
{
62+
code: `page.locator('[data-testid=input]')`,
63+
errors: [{ column: 1, line: 1, messageId: 'unexpectedTestIdQuery' }],
64+
output: 'page.getByTestId("input")',
65+
},
66+
{
67+
code: `page.locator('[data-custom-testid="password-input"]')`,
68+
errors: [{ column: 1, line: 1, messageId: 'unexpectedTestIdQuery' }],
69+
options: [{ testIdAttribute: 'data-custom-testid' }],
70+
output: 'page.getByTestId("password-input")',
71+
},
72+
{
73+
code: `page.locator('[data-custom-testid=input]')`,
74+
errors: [{ column: 1, line: 1, messageId: 'unexpectedTestIdQuery' }],
75+
options: [{ testIdAttribute: 'data-custom-testid' }],
76+
output: 'page.getByTestId("input")',
77+
},
78+
// Works when locators are chained
79+
{
80+
code: `this.page.locator('[role="heading"]').first()`,
81+
errors: [{ column: 1, line: 1, messageId: 'unexpectedRoleQuery' }],
82+
output: 'this.page.getByRole("heading").first()',
83+
},
84+
// Works when used inside an assertion
85+
{
86+
code: `await expect(page.locator('[role="alert"]')).toBeVisible()`,
87+
errors: [{ column: 14, line: 1, messageId: 'unexpectedRoleQuery' }],
88+
output: 'await expect(page.getByRole("alert")).toBeVisible()',
89+
},
90+
{
91+
code: `await expect(page.locator('[data-testid="top"]')).toContainText(firstRule)`,
92+
errors: [{ column: 14, line: 1, messageId: 'unexpectedTestIdQuery' }],
93+
output: 'await expect(page.getByTestId("top")).toContainText(firstRule)',
94+
},
95+
// Works when used as part of an action
96+
{
97+
code: `await page.locator('[placeholder="New password"]').click()`,
98+
errors: [{ column: 7, line: 1, messageId: 'unexpectedPlaceholderQuery' }],
99+
output: 'await page.getByPlaceholder("New password").click()',
100+
},
101+
// Works as part of a declaration or other usage
102+
{
103+
code: `const dialog = page.locator('[role="dialog"]')`,
104+
errors: [{ column: 16, line: 1, messageId: 'unexpectedRoleQuery' }],
105+
output: 'const dialog = page.getByRole("dialog")',
106+
},
107+
{
108+
code: `this.closeModalLocator = this.page.locator('[data-test=close-modal]');`,
109+
errors: [{ column: 26, line: 1, messageId: 'unexpectedTestIdQuery' }],
110+
options: [{ testIdAttribute: 'data-test' }],
111+
output: 'this.closeModalLocator = this.page.getByTestId("close-modal");',
112+
},
113+
{
114+
code: `export class TestClass {
115+
container = () => this.page.locator('[data-testid="container"]');
116+
}`,
117+
errors: [{ column: 27, line: 2, messageId: 'unexpectedTestIdQuery' }],
118+
output: `export class TestClass {
119+
container = () => this.page.getByTestId("container");
120+
}`,
121+
},
122+
{
123+
code: `export class TestClass {
124+
get alert() {
125+
return this.page.locator("[role='alert']");
126+
}
127+
}`,
128+
errors: [{ column: 18, line: 3, messageId: 'unexpectedRoleQuery' }],
129+
output: `export class TestClass {
130+
get alert() {
131+
return this.page.getByRole("alert");
132+
}
133+
}`,
134+
},
135+
],
136+
valid: [
137+
{ code: 'page.getByLabel("View more")' },
138+
{ code: 'page.getByRole("button")' },
139+
{ code: 'page.getByRole("button", {name: "Open"})' },
140+
{ code: 'page.getByPlaceholder("Enter some text...")' },
141+
{ code: 'page.getByAltText("Playwright logo")' },
142+
{ code: 'page.getByTestId("password-input")' },
143+
{ code: 'page.getByTitle("Additional context")' },
144+
{ code: 'this.page.getByLabel("View more")' },
145+
{ code: 'this.page.getByRole("button")' },
146+
{ code: 'this.page.getByPlaceholder("Enter some text...")' },
147+
{ code: 'this.page.getByAltText("Playwright logo")' },
148+
{ code: 'this.page.getByTestId("password-input")' },
149+
{ code: 'this.page.getByTitle("Additional context")' },
150+
{ code: 'page.locator(".class")' },
151+
{ code: 'page.locator("#id")' },
152+
{ code: 'this.page.locator("#id")' },
153+
// Does not match on more complex queries
154+
{
155+
code: `page.locator('[complex-query] > [aria-label="View more"]')`,
156+
},
157+
{
158+
code: `page.locator('[complex-query] > [role="button"]')`,
159+
},
160+
{
161+
code: `page.locator('[complex-query] > [placeholder="Enter some text..."]')`,
162+
},
163+
{
164+
code: `page.locator('[complex-query] > [alt="Playwright logo"]')`,
165+
},
166+
{
167+
code: `page.locator('[complex-query] > [data-testid="password-input"]')`,
168+
},
169+
{
170+
code: `page.locator('[complex-query] > [title="Additional context"]')`,
171+
},
172+
{
173+
code: `this.page.locator('[complex-query] > [title="Additional context"]')`,
174+
},
175+
// Works for empty string and no arguments
176+
{ code: `page.locator('')` },
177+
{ code: `page.locator()` },
178+
// Works for classes and declarations
179+
{ code: `const dialog = page.getByRole("dialog")` },
180+
{
181+
code: `export class TestClass {
182+
get alert() {
183+
return this.page.getByRole("alert");
184+
}
185+
}`,
186+
},
187+
{
188+
code: `export class TestClass {
189+
container = () => this.page.getByTestId("container");
190+
}`,
191+
},
192+
{ code: `this.closeModalLocator = this.page.getByTestId("close-modal");` },
193+
],
194+
})

0 commit comments

Comments
 (0)