Repository: playwright-community/eslint-plugin-playwright
Pull request #315 (2024-09-12 02:29:25)

feat(prefer-locator): Add rule to suggest not using page methods
This rule detects where page.fill(loc, value) has been used and suggests the user uses a locator based fill as per the documentation for page.fill

https://github.com/microsoft/playwright/blob/d1926e2f9b91e31d73acfb87e564634f55b0dbb2/docs/src/api/class-page.md?plain=1#L2027

   * **NOTE** Use locator-based [locator.fill(value[, options])](https://playwright.dev/docs/api/class-locator#locator-fill)
   * instead. Read more about [locators](https://playwright.dev/docs/locators).

mskelton: This rule should be more generic for all page actions that are like it.  For example click is the same exact way
carlbray: > This rule should be more generic for all page actions that are like it. For example click is the same exact way

Looks like their a quite a few Page methods tagged with **NOTE** Use locator-based now that you mention it

- click
- dblclick
- dispatchEvent
- focus
- getAttribute
- hover
- innerHTML
- innerText
- inputValue
- isChecked
- isDisabled
- isEditable
- isEnabled
- isHidden
- isVisible
- press
- selectOption
- setChecked
- setInputFiles
- tap
- textContent
- uncheck

I'll extend it to those



Patch:
diff --git a/README.md b/README.md
index 73d387a..b86d0a3 100644
--- a/README.md
+++ b/README.md
@@ -195,6 +195,7 @@ CLI option\
 | [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                         |     |     |     |
 | [prefer-lowercase-title](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-lowercase-title.md)           | Enforce lowercase test names                                       |     | 🔧  |     |
 | [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()`                    |     | 🔧  |     |
+| [prefer-locator](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-locator.md)                           | Suggest locators over page methods                                 |     |     |     |
 | [prefer-strict-equal](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-strict-equal.md)                 | Suggest using `toStrictEqual()`                                    |     |     | 💡  |
 | [prefer-to-be](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-to-be.md)                               | Suggest using `toBe()`                                             |     | 🔧  |     |
 | [prefer-to-contain](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-to-contain.md)                     | Suggest using `toContain()`                                        |     | 🔧  |     |
diff --git a/docs/rules/prefer-locator.md b/docs/rules/prefer-locator.md
new file mode 100644
index 0000000..14efbc0
--- /dev/null
+++ b/docs/rules/prefer-locator.md
@@ -0,0 +1,33 @@
+# Suggest using `page.locator()` (`prefer-locator`)
+
+Suggest using locators and their associated methods instead of page methods for
+performing actions.
+
+## Rule details
+
+This rule triggers a warning if page methods are used, instead of locators.
+
+The following patterns are considered warnings:
+
+```javascript
+page.click('css=button')
+await page.click('css=button')
+await page.dblclick('xpath=//button')
+await page.fill('input[type="password"]', 'password')
+
+await page.frame('frame-name').click('css=button')
+```
+
+The following pattern are **not** warnings:
+
+```javascript
+const locator = page.locator('css=button')
+await page.getByRole('password').fill('password')
+await page.getByLabel('User Name').fill('John')
+await page.getByRole('button', { name: 'Sign in' }).click()
+await page.locator('input[type="password"]').fill('password')
+await page.locator('css=button').click()
+await page.locator('xpath=//button').dblclick()
+
+await page.frameLocator('#my-iframe').getByText('Submit').click()
+```
diff --git a/src/index.ts b/src/index.ts
index c185547..e7312d8 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -30,6 +30,7 @@ import preferComparisonMatcher from './rules/prefer-comparison-matcher'
 import preferEqualityMatcher from './rules/prefer-equality-matcher'
 import preferHooksInOrder from './rules/prefer-hooks-in-order'
 import preferHooksOnTop from './rules/prefer-hooks-on-top'
+import preferLocator from './rules/prefer-locator'
 import preferLowercaseTitle from './rules/prefer-lowercase-title'
 import preferNativeLocators from './rules/prefer-native-locators'
 import preferStrictEqual from './rules/prefer-strict-equal'
@@ -81,6 +82,7 @@ const index = {
     'prefer-equality-matcher': preferEqualityMatcher,
     'prefer-hooks-in-order': preferHooksInOrder,
     'prefer-hooks-on-top': preferHooksOnTop,
+    'prefer-locator': preferLocator,
     'prefer-lowercase-title': preferLowercaseTitle,
     'prefer-native-locators': preferNativeLocators,
     'prefer-strict-equal': preferStrictEqual,
diff --git a/src/rules/prefer-locator.ts b/src/rules/prefer-locator.ts
new file mode 100644
index 0000000..e2ccab1
--- /dev/null
+++ b/src/rules/prefer-locator.ts
@@ -0,0 +1,65 @@
+import ESTree from 'estree'
+import { getStringValue, isPageMethod } from '../utils/ast'
+import { createRule } from '../utils/createRule'
+
+const pageMethods = new Set([
+  'click',
+  'dblclick',
+  'dispatchEvent',
+  'fill',
+  'focus',
+  'getAttribute',
+  'hover',
+  'innerHTML',
+  'innerText',
+  'inputValue',
+  'isChecked',
+  'isDisabled',
+  'isEditable',
+  'isEnabled',
+  'isHidden',
+  'isVisible',
+  'press',
+  'selectOption',
+  'setChecked',
+  'setInputFiles',
+  'tap',
+  'textContent',
+  'uncheck',
+])
+
+function isSupportedMethod(node: ESTree.CallExpression) {
+  if (node.callee.type !== 'MemberExpression') return false
+
+  const name = getStringValue(node.callee.property)
+  return pageMethods.has(name) && isPageMethod(node, name)
+}
+
+export default createRule({
+  create(context) {
+    return {
+      CallExpression(node) {
+        // Must be a method we care about
+        if (!isSupportedMethod(node)) return
+
+        context.report({
+          messageId: 'preferLocator',
+          node,
+        })
+      },
+    }
+  },
+  meta: {
+    docs: {
+      category: 'Best Practices',
+      description: 'Suggest locators over page methods',
+      recommended: false,
+      url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-locator.md',
+    },
+    messages: {
+      preferLocator: 'Prefer locator methods instead of page methods',
+    },
+    schema: [],
+    type: 'suggestion',
+  },
+})


Test patch:
diff --git a/src/rules/prefer-locator.test.ts b/src/rules/prefer-locator.test.ts
new file mode 100644
index 0000000..13135d4
--- /dev/null
+++ b/src/rules/prefer-locator.test.ts
@@ -0,0 +1,99 @@
+import { runRuleTester, test } from '../utils/rule-tester'
+import rule from './prefer-locator'
+
+runRuleTester('prefer-locator', rule, {
+  invalid: [
+    {
+      code: test(`await page.fill('input[type="password"]', 'password')`),
+      errors: [
+        {
+          column: 34,
+          endColumn: 81,
+          endLine: 1,
+          line: 1,
+          messageId: 'preferLocator',
+        },
+      ],
+      output: null,
+    },
+    {
+      code: test(`await page.dblclick('xpath=//button')`),
+      errors: [
+        {
+          column: 34,
+          endColumn: 65,
+          endLine: 1,
+          line: 1,
+          messageId: 'preferLocator',
+        },
+      ],
+      output: null,
+    },
+    {
+      code: `page.click('xpath=//button')`,
+      errors: [
+        {
+          column: 1,
+          endColumn: 29,
+          endLine: 1,
+          line: 1,
+          messageId: 'preferLocator',
+        },
+      ],
+      output: null,
+    },
+    {
+      code: test(`await page.frame('frame-name').click('css=button')`),
+      errors: [
+        {
+          column: 34,
+          endColumn: 78,
+          endLine: 1,
+          line: 1,
+          messageId: 'preferLocator',
+        },
+      ],
+      output: null,
+    },
+    {
+      code: `page.frame('frame-name').click('css=button')`,
+      errors: [
+        {
+          column: 1,
+          endColumn: 45,
+          endLine: 1,
+          line: 1,
+          messageId: 'preferLocator',
+        },
+      ],
+      output: null,
+    },
+  ],
+  valid: [
+    {
+      code: `const locator = page.locator('input[type="password"]')`,
+    },
+    {
+      code: test(
+        `await page.locator('input[type="password"]').fill('password')`,
+      ),
+    },
+    {
+      code: test(`await page.locator('xpath=//button').dblclick()`),
+    },
+    {
+      code: `page.locator('xpath=//button').click()`,
+    },
+    {
+      code: test(
+        `await page.frameLocator('#my-iframe').locator('css=button').click()`,
+      ),
+    },
+    {
+      code: test(`await page.evaluate('1 + 2')`),
+    },
+    {
+      code: `page.frame('frame-name')`,
+    },
+  ],
+})