├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .releaserc.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs └── rules │ ├── expect-expect.md │ ├── max-expects.md │ ├── max-nested-describe.md │ ├── missing-playwright-await.md │ ├── no-commented-out-tests.md │ ├── no-conditional-expect.md │ ├── no-conditional-in-test.md │ ├── no-duplicate-hooks.md │ ├── no-element-handle.md │ ├── no-eval.md │ ├── no-focused-test.md │ ├── no-force-option.md │ ├── no-get-by-title.md │ ├── no-hooks.md │ ├── no-nested-step.md │ ├── no-networkidle.md │ ├── no-nth-methods.md │ ├── no-page-pause.md │ ├── no-raw-locators.md │ ├── no-restricted-matchers.md │ ├── no-skipped-test.md │ ├── no-slowed-test.md │ ├── no-standalone-expect.md │ ├── no-unsafe-references.md │ ├── no-useless-await.md │ ├── no-useless-not.md │ ├── no-wait-for-selector.md │ ├── no-wait-for-timeout.md │ ├── prefer-comparison-matcher.md │ ├── prefer-equality-matcher.md │ ├── prefer-hooks-in-order.md │ ├── prefer-hooks-on-top.md │ ├── prefer-locator.md │ ├── prefer-lowercase-title.md │ ├── prefer-native-locators.md │ ├── prefer-strict-equal.md │ ├── prefer-to-be.md │ ├── prefer-to-contain.md │ ├── prefer-to-have-count.md │ ├── prefer-to-have-length.md │ ├── prefer-web-first-assertions.md │ ├── require-hook.md │ ├── require-soft-assertions.md │ ├── require-to-throw-message.md │ ├── require-top-level-describe.md │ ├── valid-describe-callback.md │ ├── valid-expect-in-promise.md │ ├── valid-expect.md │ └── valid-title.md ├── eslint.config.js ├── examples ├── eslint.config.js ├── index.js ├── package.json └── test.ts ├── index.cjs ├── index.d.ts ├── index.js ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── src ├── index.ts ├── rules │ ├── expect-expect.test.ts │ ├── expect-expect.ts │ ├── max-expects.test.ts │ ├── max-expects.ts │ ├── max-nested-describe.test.ts │ ├── max-nested-describe.ts │ ├── missing-playwright-await.test.ts │ ├── missing-playwright-await.ts │ ├── no-commented-out-tests.test.ts │ ├── no-commented-out-tests.ts │ ├── no-conditional-expect.test.ts │ ├── no-conditional-expect.ts │ ├── no-conditional-in-test.test.ts │ ├── no-conditional-in-test.ts │ ├── no-duplicate-hooks.test.ts │ ├── no-duplicate-hooks.ts │ ├── no-element-handle.test.ts │ ├── no-element-handle.ts │ ├── no-eval.test.ts │ ├── no-eval.ts │ ├── no-focused-test.test.ts │ ├── no-focused-test.ts │ ├── no-force-option.test.ts │ ├── no-force-option.ts │ ├── no-get-by-title.test.ts │ ├── no-get-by-title.ts │ ├── no-hooks.test.ts │ ├── no-hooks.ts │ ├── no-nested-step.test.ts │ ├── no-nested-step.ts │ ├── no-networkidle.test.ts │ ├── no-networkidle.ts │ ├── no-nth-methods.test.ts │ ├── no-nth-methods.ts │ ├── no-page-pause.test.ts │ ├── no-page-pause.ts │ ├── no-raw-locators.test.ts │ ├── no-raw-locators.ts │ ├── no-restricted-matchers.test.ts │ ├── no-restricted-matchers.ts │ ├── no-skipped-test.test.ts │ ├── no-skipped-test.ts │ ├── no-slowed-test.test.ts │ ├── no-slowed-test.ts │ ├── no-standalone-expect.test.ts │ ├── no-standalone-expect.ts │ ├── no-unsafe-references.test.ts │ ├── no-unsafe-references.ts │ ├── no-useless-await.test.ts │ ├── no-useless-await.ts │ ├── no-useless-not.test.ts │ ├── no-useless-not.ts │ ├── no-wait-for-selector.test.ts │ ├── no-wait-for-selector.ts │ ├── no-wait-for-timeout.test.ts │ ├── no-wait-for-timeout.ts │ ├── prefer-comparison-matcher.test.ts │ ├── prefer-comparison-matcher.ts │ ├── prefer-equality-matcher.test.ts │ ├── prefer-equality-matcher.ts │ ├── prefer-hooks-in-order.test.ts │ ├── prefer-hooks-in-order.ts │ ├── prefer-hooks-on-top.test.ts │ ├── prefer-hooks-on-top.ts │ ├── prefer-locator.test.ts │ ├── prefer-locator.ts │ ├── prefer-lowercase-title.test.ts │ ├── prefer-lowercase-title.ts │ ├── prefer-native-locators.test.ts │ ├── prefer-native-locators.ts │ ├── prefer-strict-equal.test.ts │ ├── prefer-strict-equal.ts │ ├── prefer-to-be.test.ts │ ├── prefer-to-be.ts │ ├── prefer-to-contain.test.ts │ ├── prefer-to-contain.ts │ ├── prefer-to-have-count.test.ts │ ├── prefer-to-have-count.ts │ ├── prefer-to-have-length.test.ts │ ├── prefer-to-have-length.ts │ ├── prefer-web-first-assertions.test.ts │ ├── prefer-web-first-assertions.ts │ ├── require-hook.test.ts │ ├── require-hook.ts │ ├── require-soft-assertions.test.ts │ ├── require-soft-assertions.ts │ ├── require-to-throw-message.test.ts │ ├── require-to-throw-message.ts │ ├── require-top-level-describe.test.ts │ ├── require-top-level-describe.ts │ ├── rules.test.ts │ ├── valid-describe-callback.test.ts │ ├── valid-describe-callback.ts │ ├── valid-expect-in-promise.test.ts │ ├── valid-expect-in-promise.ts │ ├── valid-expect.test.ts │ ├── valid-expect.ts │ ├── valid-title.test.ts │ └── valid-title.ts └── utils │ ├── ast.ts │ ├── createRule.ts │ ├── fixer.ts │ ├── misc.ts │ ├── parseFnCall.test.ts │ ├── parseFnCall.ts │ ├── rule-tester.ts │ └── types.ts └── tsconfig.json /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: workflow_dispatch 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: mskelton/setup-pnpm@v2 8 | with: 9 | node-version: '20.x' 10 | - run: pnpm test 11 | release: 12 | needs: test 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Setup Node 16 | uses: mskelton/setup-pnpm@v2 17 | with: 18 | node-version: '20.x' 19 | - name: Build 20 | run: pnpm build 21 | - name: Release 22 | run: pnpm semantic-release 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | NPM_TOKEN: ${{ secrets.npm_token }} 26 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | node-version: [18.x, 20.x, 22.x] 13 | steps: 14 | - name: Setup Node.js ${{ matrix.node-version }} 15 | uses: mskelton/setup-pnpm@v2 16 | with: 17 | node-version: ${{ matrix.node-version }} 18 | - run: pnpm fmt:check 19 | - run: pnpm lint 20 | - run: pnpm test 21 | - run: pnpm ts 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | *.tgz 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-workspace-root-check=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | pnpm-lock.yaml 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "proseWrap": "always", 4 | "singleQuote": true, 5 | "plugins": ["prettier-plugin-jsdoc"] 6 | } 7 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@mskelton/semantic-release-config" 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | The changelog is automatically updated using 4 | [semantic-release](https://github.com/semantic-release/semantic-release). You 5 | can see it on the 6 | [releases page](https://github.com/playwright-community/eslint-plugin-playwright/releases). 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Installing Dependencies 4 | 5 | We use [pnpm](https://pnpm.io) for managing dependencies. You can install the 6 | necessary dependencies using the following command: 7 | 8 | ```bash 9 | pnpm install 10 | ``` 11 | 12 | ## Running Tests 13 | 14 | When making changes to lint rules, you can re-run the tests with the following 15 | command: 16 | 17 | ```bash 18 | pnpm test 19 | ``` 20 | 21 | Or run it in watch mode like so: 22 | 23 | ```bash 24 | pnpm test -- --watch 25 | ``` 26 | 27 | ## Adding new rules 28 | 29 | When adding new rules, make sure to follow these steps: 30 | 31 | 1. Add the rule source code in `src/rules` 32 | 1. Add tests for the rule in `src/rules` 33 | 1. Add docs for the rule to `docs/rules` 34 | 1. Add a short description of the rule in `README.md` 35 | 36 | ## Releasing 37 | 38 | To release a new version with semantic-release, run the following command. 39 | 40 | ```bash 41 | gh workflow run release.yml 42 | ``` 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Max Schmitt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/rules/expect-expect.md: -------------------------------------------------------------------------------- 1 | # Enforce assertion to be made in a test body (`expect-expect`) 2 | 3 | Ensure that there is at least one `expect` call made in a test. 4 | 5 | ## Rule Details 6 | 7 | Examples of **incorrect** code for this rule: 8 | 9 | ```javascript 10 | test('should be a test', () => { 11 | console.log('no assertion') 12 | }) 13 | 14 | test('should assert something', () => {}) 15 | ``` 16 | 17 | Examples of **correct** code for this rule: 18 | 19 | ```javascript 20 | test('should be a test', async () => { 21 | await expect(page).toHaveTitle('foo') 22 | }) 23 | 24 | test('should work with callbacks/async', async () => { 25 | await test.step('step 1', async () => { 26 | await expect(page).toHaveTitle('foo') 27 | }) 28 | }) 29 | ``` 30 | 31 | ## Options 32 | 33 | ```json 34 | { 35 | "playwright/expect-expect": [ 36 | "error", 37 | { 38 | "assertFunctionNames": ["assertCustomCondition"] 39 | } 40 | ] 41 | } 42 | ``` 43 | 44 | ### `assertFunctionNames` 45 | 46 | This array option specifies the names of functions that should be considered to 47 | be asserting functions. 48 | 49 | ```ts 50 | /* eslint playwright/expect-expect: ["error", { "assertFunctionNames": ["assertScrolledToBottom"] }] */ 51 | 52 | function assertScrolledToBottom(page) { 53 | // ... 54 | } 55 | 56 | test('should scroll', async ({ page }) => { 57 | await assertScrolledToBottom(page) 58 | }) 59 | ``` 60 | -------------------------------------------------------------------------------- /docs/rules/max-expects.md: -------------------------------------------------------------------------------- 1 | # Enforces a maximum number assertion calls in a test body (`max-expects`) 2 | 3 | As more assertions are made, there is a possible tendency for the test to be 4 | more likely to mix multiple objectives. To avoid this, this rule reports when 5 | the maximum number of assertions is exceeded. 6 | 7 | ## Rule details 8 | 9 | This rule enforces a maximum number of `expect()` calls. 10 | 11 | The following patterns are considered warnings (with the default option of 12 | `{ "max": 5 } `): 13 | 14 | ```js 15 | test('should not pass', () => { 16 | expect(true).toBeDefined() 17 | expect(true).toBeDefined() 18 | expect(true).toBeDefined() 19 | expect(true).toBeDefined() 20 | expect(true).toBeDefined() 21 | expect(true).toBeDefined() 22 | }) 23 | ``` 24 | 25 | The following patterns are **not** considered warnings (with the default option 26 | of `{ "max": 5 } `): 27 | 28 | ```js 29 | test('shout pass') 30 | 31 | test('shout pass', () => {}) 32 | 33 | test.skip('shout pass', () => {}) 34 | 35 | test('should pass', function () { 36 | expect(true).toBeDefined() 37 | }) 38 | 39 | test('should pass', () => { 40 | expect(true).toBeDefined() 41 | expect(true).toBeDefined() 42 | expect(true).toBeDefined() 43 | expect(true).toBeDefined() 44 | expect(true).toBeDefined() 45 | }) 46 | ``` 47 | 48 | ## Options 49 | 50 | ```json 51 | { 52 | "playwright/max-expects": [ 53 | "error", 54 | { 55 | "max": 5 56 | } 57 | ] 58 | } 59 | ``` 60 | 61 | ### `max` 62 | 63 | Enforces a maximum number of `expect()`. 64 | 65 | This has a default value of `5`. 66 | -------------------------------------------------------------------------------- /docs/rules/max-nested-describe.md: -------------------------------------------------------------------------------- 1 | # Enforces a maximum depth to nested describe calls (`max-nested-describe`) 2 | 3 | While it's useful to be able to group your tests together within the same file 4 | using `describe()`, having too many levels of nesting throughout your tests make 5 | them difficult to read. 6 | 7 | ## Rule Details 8 | 9 | Examples of **incorrect** code for this rule (with the default option of 10 | `{ "max": 5 }` ): 11 | 12 | ```javascript 13 | test.describe('foo', () => { 14 | test.describe('bar', () => { 15 | test.describe('baz', () => { 16 | test.describe('qux', () => { 17 | test.describe('quxx', () => { 18 | test.describe('too many', () => { 19 | test('this test', async ({ page }) => {}) 20 | }) 21 | }) 22 | }) 23 | }) 24 | }) 25 | }) 26 | ``` 27 | 28 | Examples of **correct** code for this rule (with the default option of 29 | `{ "max": 5 }` ): 30 | 31 | ```javascript 32 | test.describe('foo', () => { 33 | test.describe('bar', () => { 34 | test('this test', async ({ page }) => {}) 35 | }) 36 | }) 37 | ``` 38 | 39 | ## Options 40 | 41 | ```json 42 | { 43 | "playwright/max-nested-describe": ["error", { "max": 5 }] 44 | } 45 | ``` 46 | 47 | ### `max` 48 | 49 | Enforces a maximum depth for nested `describe()`. 50 | 51 | This has a default value of `5`. 52 | 53 | Examples of **correct** code with options set to `{ "max": 2 }`: 54 | 55 | ```javascript 56 | test.describe('foo', () => { 57 | test.describe('bar', () => { 58 | test('this test', async ({ page }) => {}) 59 | }) 60 | }) 61 | ``` 62 | -------------------------------------------------------------------------------- /docs/rules/missing-playwright-await.md: -------------------------------------------------------------------------------- 1 | # Enforce Playwright APIs to be awaited (`missing-playwright-await`) 2 | 3 | Identify false positives when async Playwright APIs are not properly awaited. 4 | 5 | ## Rule Details 6 | 7 | Example of **incorrect** code for this rule: 8 | 9 | ```javascript 10 | expect(page).toMatchText('text') 11 | expect.poll(() => foo).toBe(true) 12 | 13 | test.step('clicks the button', async () => { 14 | await page.click('button') 15 | }) 16 | ``` 17 | 18 | Example of **correct** code for this rule: 19 | 20 | ```javascript 21 | await expect(page).toMatchText('text') 22 | await expect.poll(() => foo).toBe(true) 23 | 24 | await test.step('clicks the button', async () => { 25 | await page.click('button') 26 | }) 27 | ``` 28 | 29 | ## Options 30 | 31 | The rule accepts a non-required option which can be used to specify custom 32 | matchers which this rule should also warn about. This is useful when creating 33 | your own async `expect` matchers. 34 | 35 | ```json 36 | { 37 | "playwright/missing-playwright-await": [ 38 | "error", 39 | { "customMatchers": ["toBeCustomThing"] } 40 | ] 41 | } 42 | ``` 43 | -------------------------------------------------------------------------------- /docs/rules/no-commented-out-tests.md: -------------------------------------------------------------------------------- 1 | # Disallow commented out tests (`no-commented-out-tests`) 2 | 3 | This rule raises a warning about commented out tests. It's similar to 4 | `no-skipped-test` rule. 5 | 6 | ## Rule details 7 | 8 | The rule uses fuzzy matching to do its best to determine what constitutes a 9 | commented out test, checking for a presence of `test(`, `test.describe(`, 10 | `test.skip(`, etc. in code comments. 11 | 12 | The following patterns are considered warnings: 13 | 14 | ```js 15 | // describe('foo', () => {}); 16 | // test.describe('foo', () => {}); 17 | // test('foo', () => {}); 18 | 19 | // test.describe.skip('foo', () => {}); 20 | // test.skip('foo', () => {}); 21 | 22 | // test.describe['skip']('bar', () => {}); 23 | // test['skip']('bar', () => {}); 24 | 25 | /* 26 | test.describe('foo', () => {}); 27 | */ 28 | ``` 29 | 30 | These patterns would not be considered warnings: 31 | 32 | ```js 33 | describe('foo', () => {}) 34 | test.describe('foo', () => {}) 35 | test('foo', () => {}) 36 | 37 | test.describe.only('bar', () => {}) 38 | test.only('bar', () => {}) 39 | 40 | // foo('bar', () => {}); 41 | ``` 42 | 43 | ### Limitations 44 | 45 | The plugin looks at the literal function names within test code, so will not 46 | catch more complex examples of commented out tests, such as: 47 | 48 | ```js 49 | // const testSkip = test.skip; 50 | // testSkip('skipped test', () => {}); 51 | 52 | // const myTest = test; 53 | // myTest('does not have function body'); 54 | ``` 55 | -------------------------------------------------------------------------------- /docs/rules/no-conditional-expect.md: -------------------------------------------------------------------------------- 1 | # Disallow calling `expect` conditionally (`no-conditional-expect`) 2 | 3 | This rule prevents the use of `expect` in conditional blocks, such as `if`s & 4 | `catch`s. 5 | 6 | This includes using `expect` in callbacks to functions named `catch`, which are 7 | assumed to be promises. 8 | 9 | ## Rule details 10 | 11 | Playwright only considers a test to have failed if it throws an error, meaning 12 | if calls to assertion functions like `expect` occur in conditional code such as 13 | a `catch` statement, tests can end up passing but not actually test anything. 14 | 15 | Additionally, conditionals tend to make tests more brittle and complex, as they 16 | increase the amount of mental thinking needed to understand what is actually 17 | being tested. 18 | 19 | The following patterns are warnings: 20 | 21 | ```js 22 | test('foo', () => { 23 | doTest && expect(1).toBe(2) 24 | }) 25 | 26 | test('bar', () => { 27 | if (!skipTest) { 28 | expect(1).toEqual(2) 29 | } 30 | }) 31 | 32 | test('baz', async () => { 33 | try { 34 | await foo() 35 | } catch (err) { 36 | expect(err).toMatchObject({ code: 'MODULE_NOT_FOUND' }) 37 | } 38 | }) 39 | 40 | test('throws an error', async () => { 41 | await foo().catch((error) => expect(error).toBeInstanceOf(error)) 42 | }) 43 | ``` 44 | 45 | The following patterns are not warnings: 46 | 47 | ```js 48 | test('foo', () => { 49 | expect(!value).toBe(false) 50 | }) 51 | 52 | function getValue() { 53 | if (process.env.FAIL) { 54 | return 1 55 | } 56 | 57 | return 2 58 | } 59 | 60 | test('foo', () => { 61 | expect(getValue()).toBe(2) 62 | }) 63 | 64 | test('validates the request', () => { 65 | try { 66 | processRequest(request) 67 | } catch { 68 | // ignore errors 69 | } finally { 70 | expect(validRequest).toHaveBeenCalledWith(request) 71 | } 72 | }) 73 | 74 | test('throws an error', async () => { 75 | await expect(foo).rejects.toThrow(Error) 76 | }) 77 | ``` 78 | 79 | ### How to catch a thrown error for testing without violating this rule 80 | 81 | A common situation that comes up with this rule is when wanting to test 82 | properties on a thrown error, as Playwright's `toThrow` matcher only checks the 83 | `message` property. 84 | 85 | Most people write something like this: 86 | 87 | ```typescript 88 | test.describe('when the http request fails', () => { 89 | test('includes the status code in the error', async () => { 90 | try { 91 | await makeRequest(url) 92 | } catch (error) { 93 | expect(error).toHaveProperty('statusCode', 404) 94 | } 95 | }) 96 | }) 97 | ``` 98 | 99 | As stated above, the problem with this is that if `makeRequest()` doesn't throw 100 | the test will still pass as if the `expect` had been called. 101 | 102 | A better way to handle this situation is to introduce a wrapper to handle the 103 | catching, and otherwise return a specific "no error thrown" error if nothing is 104 | thrown by the wrapped function: 105 | 106 | ```typescript 107 | class NoErrorThrownError extends Error {} 108 | 109 | const getError = async (call: () => unknown): Promise => { 110 | try { 111 | await call() 112 | 113 | throw new NoErrorThrownError() 114 | } catch (error: unknown) { 115 | return error as TError 116 | } 117 | } 118 | 119 | test.describe('when the http request fails', () => { 120 | test('includes the status code in the error', async () => { 121 | const error = await getError(async () => makeRequest(url)) 122 | 123 | // check that the returned error wasn't that no error was thrown 124 | expect(error).not.toBeInstanceOf(NoErrorThrownError) 125 | expect(error).toHaveProperty('statusCode', 404) 126 | }) 127 | }) 128 | ``` 129 | -------------------------------------------------------------------------------- /docs/rules/no-conditional-in-test.md: -------------------------------------------------------------------------------- 1 | # Disallow conditional logic in tests (`no-conditional-in-test`) 2 | 3 | Conditional logic in tests is usually an indication that a test is attempting to 4 | cover too much, and not testing the logic it intends to. Each branch of code 5 | executing within a conditional statement will usually be better served by a test 6 | devoted to it. 7 | 8 | ## Rule Details 9 | 10 | Examples of **incorrect** code for this rule: 11 | 12 | ```javascript 13 | test('foo', async ({ page }) => { 14 | if (someCondition) { 15 | bar() 16 | } 17 | }) 18 | 19 | test('bar', async ({ page }) => { 20 | switch (mode) { 21 | case 'single': 22 | generateOne() 23 | break 24 | case 'double': 25 | generateTwo() 26 | break 27 | case 'multiple': 28 | generateMany() 29 | break 30 | } 31 | 32 | await expect(page.locator('.my-image').count()).toBeGreaterThan(0) 33 | }) 34 | 35 | test('baz', async ({ page }) => { 36 | const hotkey = 37 | process.platform === 'linux' ? ['Control', 'Alt', 'f'] : ['Alt', 'f'] 38 | await Promise.all(hotkey.map((x) => page.keyboard.down(x))) 39 | 40 | expect(actionIsPerformed()).toBe(true) 41 | }) 42 | ``` 43 | 44 | Examples of **correct** code for this rule: 45 | 46 | ```javascript 47 | test.describe('my tests', () => { 48 | if (someCondition) { 49 | test('foo', async ({ page }) => { 50 | bar() 51 | }) 52 | } 53 | }) 54 | 55 | beforeEach(() => { 56 | switch (mode) { 57 | case 'single': 58 | generateOne() 59 | break 60 | case 'double': 61 | generateTwo() 62 | break 63 | case 'multiple': 64 | generateMany() 65 | break 66 | } 67 | }) 68 | 69 | test('bar', async ({ page }) => { 70 | await expect(page.locator('.my-image').count()).toBeGreaterThan(0) 71 | }) 72 | 73 | const hotkey = 74 | process.platform === 'linux' ? ['Control', 'Alt', 'f'] : ['Alt', 'f'] 75 | 76 | test('baz', async ({ page }) => { 77 | await Promise.all(hotkey.map((x) => page.keyboard.down(x))) 78 | 79 | expect(actionIsPerformed()).toBe(true) 80 | }) 81 | ``` 82 | -------------------------------------------------------------------------------- /docs/rules/no-duplicate-hooks.md: -------------------------------------------------------------------------------- 1 | # Disallow duplicate setup and teardown hooks (`no-duplicate-hooks`) 2 | 3 | A `describe` block should not contain duplicate hooks. 4 | 5 | ## Rule details 6 | 7 | Examples of **incorrect** code for this rule 8 | 9 | ```js 10 | /* eslint playwright/no-duplicate-hooks: "error" */ 11 | 12 | test.describe('foo', () => { 13 | test.beforeEach(() => { 14 | // some setup 15 | }) 16 | test.beforeEach(() => { 17 | // some setup 18 | }) 19 | test('foo_test', () => { 20 | // some test 21 | }) 22 | }) 23 | 24 | // Nested describe scenario 25 | test.describe('foo', () => { 26 | test.beforeEach(() => { 27 | // some setup 28 | }) 29 | test('foo_test', () => { 30 | // some test 31 | }) 32 | test.describe('bar', () => { 33 | test('bar_test', () => { 34 | test.afterAll(() => { 35 | // some teardown 36 | }) 37 | test.afterAll(() => { 38 | // some teardown 39 | }) 40 | }) 41 | }) 42 | }) 43 | ``` 44 | 45 | Examples of **correct** code for this rule 46 | 47 | ```js 48 | /* eslint playwright/no-duplicate-hooks: "error" */ 49 | 50 | test.describe('foo', () => { 51 | test.beforeEach(() => { 52 | // some setup 53 | }) 54 | test('foo_test', () => { 55 | // some test 56 | }) 57 | }) 58 | 59 | // Nested describe scenario 60 | test.describe('foo', () => { 61 | test.beforeEach(() => { 62 | // some setup 63 | }) 64 | test('foo_test', () => { 65 | // some test 66 | }) 67 | test.describe('bar', () => { 68 | test('bar_test', () => { 69 | test.beforeEach(() => { 70 | // some setup 71 | }) 72 | }) 73 | }) 74 | }) 75 | ``` 76 | -------------------------------------------------------------------------------- /docs/rules/no-element-handle.md: -------------------------------------------------------------------------------- 1 | ## Disallow usage of element handles (`no-element-handle`) 2 | 3 | Disallow the creation of element handles with `page.$` or `page.$$`. 4 | 5 | ## Rule Details 6 | 7 | Examples of **incorrect** code for this rule: 8 | 9 | ```javascript 10 | // Element Handle 11 | const buttonHandle = await page.$('button') 12 | await buttonHandle.click() 13 | 14 | // Element Handles 15 | const linkHandles = await page.$$('a') 16 | ``` 17 | 18 | Example of **correct** code for this rule: 19 | 20 | ```javascript 21 | const buttonLocator = page.locator('button') 22 | await buttonLocator.click() 23 | ``` 24 | -------------------------------------------------------------------------------- /docs/rules/no-eval.md: -------------------------------------------------------------------------------- 1 | # Disallow usage of `page.$eval` and `page.$$eval` (`no-eval`) 2 | 3 | ## Rule Details 4 | 5 | Examples of **incorrect** code for this rule: 6 | 7 | ```javascript 8 | const searchValue = await page.$eval('#search', (el) => el.value) 9 | 10 | const divCounts = await page.$$eval( 11 | 'div', 12 | (divs, min) => divs.length >= min, 13 | 10, 14 | ) 15 | 16 | await page.$eval('#search', (el) => el.value) 17 | await page.$$eval('#search', (el) => el.value) 18 | ``` 19 | 20 | Example of **correct** code for this rule: 21 | 22 | ```javascript 23 | await page.locator('button').evaluate((node) => node.innerText) 24 | await page.locator('div').evaluateAll((divs, min) => divs.length >= min, 10) 25 | ``` 26 | -------------------------------------------------------------------------------- /docs/rules/no-focused-test.md: -------------------------------------------------------------------------------- 1 | # Disallow usage of `.only` annotation (`no-focused-test`) 2 | 3 | Examples of **incorrect** code for this rule: 4 | 5 | ```javascript 6 | test.only('focus this test', async ({ page }) => {}) 7 | 8 | test.describe.only('focus two tests', () => { 9 | test('one', async ({ page }) => {}) 10 | test('two', async ({ page }) => {}) 11 | }) 12 | 13 | test.describe.parallel.only('focus two tests in parallel mode', () => { 14 | test('one', async ({ page }) => {}) 15 | test('two', async ({ page }) => {}) 16 | }) 17 | 18 | test.describe.serial.only('focus two tests in serial mode', () => { 19 | test('one', async ({ page }) => {}) 20 | test('two', async ({ page }) => {}) 21 | }) 22 | ``` 23 | 24 | Examples of **correct** code for this rule: 25 | 26 | ```javascript 27 | test('this test', async ({ page }) => {}) 28 | 29 | test.describe('two tests', () => { 30 | test('one', async ({ page }) => {}) 31 | test('two', async ({ page }) => {}) 32 | }) 33 | 34 | test.describe.parallel('two tests in parallel mode', () => { 35 | test('one', async ({ page }) => {}) 36 | test('two', async ({ page }) => {}) 37 | }) 38 | 39 | test.describe.serial('two tests in serial mode', () => { 40 | test('one', async ({ page }) => {}) 41 | test('two', async ({ page }) => {}) 42 | }) 43 | ``` 44 | -------------------------------------------------------------------------------- /docs/rules/no-force-option.md: -------------------------------------------------------------------------------- 1 | # Disallow usage of the `{ force: true }` option (`no-force-option`) 2 | 3 | ## Rule Details 4 | 5 | Examples of **incorrect** code for this rule: 6 | 7 | ```javascript 8 | await page.locator('button').click({ force: true }) 9 | await page.locator('check').check({ force: true }) 10 | await page.locator('input').fill('something', { force: true }) 11 | ``` 12 | 13 | Examples of **correct** code for this rule: 14 | 15 | ```javascript 16 | await page.locator('button').click() 17 | await page.locator('check').check() 18 | await page.locator('input').fill('something') 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/rules/no-get-by-title.md: -------------------------------------------------------------------------------- 1 | ## Disallow using `getByTitle()` (`no-get-by-title`) 2 | 3 | The HTML `title` attribute does not provide a fully accessible tooltip for 4 | elements so relying on it to identify elements can hide accessibility issues in 5 | your code. This rule helps to prevent that by disallowing use of the 6 | `getByTitle` method. 7 | 8 | ## Rule Details 9 | 10 | Example of **incorrect** code for this rule: 11 | 12 | ```javascript 13 | await page.getByTitle('Delete product').click() 14 | ``` 15 | 16 | Example of **correct** code for this rule: 17 | 18 | ```javascript 19 | await page.getByRole('button', { name: 'Delete product' }).click() 20 | ``` 21 | -------------------------------------------------------------------------------- /docs/rules/no-hooks.md: -------------------------------------------------------------------------------- 1 | # Disallow setup and teardown hooks (`no-hooks`) 2 | 3 | Playwright provides global functions for setup and teardown tasks, which are 4 | called before/after each test case and each test suite. The use of these hooks 5 | promotes shared state between tests. 6 | 7 | ## Rule details 8 | 9 | This rule reports for the following function calls: 10 | 11 | - `beforeAll` 12 | - `beforeEach` 13 | - `afterAll` 14 | - `afterEach` 15 | 16 | Examples of **incorrect** code for this rule: 17 | 18 | ```js 19 | /* eslint playwright/no-hooks: "error" */ 20 | 21 | function setupFoo(options) { 22 | /* ... */ 23 | } 24 | 25 | function setupBar(options) { 26 | /* ... */ 27 | } 28 | 29 | test.describe('foo', () => { 30 | let foo 31 | 32 | test.beforeEach(() => { 33 | foo = setupFoo() 34 | }) 35 | 36 | test.afterEach(() => { 37 | foo = null 38 | }) 39 | 40 | test('does something', () => { 41 | expect(foo.doesSomething()).toBe(true) 42 | }) 43 | 44 | test.describe('with bar', () => { 45 | let bar 46 | 47 | test.beforeEach(() => { 48 | bar = setupBar() 49 | }) 50 | 51 | test.afterEach(() => { 52 | bar = null 53 | }) 54 | 55 | test('does something with bar', () => { 56 | expect(foo.doesSomething(bar)).toBe(true) 57 | }) 58 | }) 59 | }) 60 | ``` 61 | 62 | Examples of **correct** code for this rule: 63 | 64 | ```js 65 | /* eslint playwright/no-hooks: "error" */ 66 | 67 | function setupFoo(options) { 68 | /* ... */ 69 | } 70 | 71 | function setupBar(options) { 72 | /* ... */ 73 | } 74 | 75 | test.describe('foo', () => { 76 | test('does something', () => { 77 | const foo = setupFoo() 78 | expect(foo.doesSomething()).toBe(true) 79 | }) 80 | 81 | test('does something with bar', () => { 82 | const foo = setupFoo() 83 | const bar = setupBar() 84 | expect(foo.doesSomething(bar)).toBe(true) 85 | }) 86 | }) 87 | ``` 88 | 89 | ## Options 90 | 91 | ```json 92 | { 93 | "playwright/no-hooks": [ 94 | "error", 95 | { 96 | "allow": ["afterEach", "afterAll"] 97 | } 98 | ] 99 | } 100 | ``` 101 | 102 | ### `allow` 103 | 104 | This array option controls which Playwright hooks are checked by this rule. 105 | There are four possible values: 106 | 107 | - `"beforeAll"` 108 | - `"beforeEach"` 109 | - `"afterAll"` 110 | - `"afterEach"` 111 | 112 | By default, none of these options are enabled (the equivalent of 113 | `{ "allow": [] }`). 114 | 115 | Examples of **incorrect** code for the `{ "allow": ["afterEach"] }` option: 116 | 117 | ```js 118 | /* eslint playwright/no-hooks: ["error", { "allow": ["afterEach"] }] */ 119 | 120 | function setupFoo(options) { 121 | /* ... */ 122 | } 123 | 124 | let foo 125 | 126 | test.beforeEach(() => { 127 | foo = setupFoo() 128 | }) 129 | 130 | test.afterEach(() => { 131 | playwright.resetModules() 132 | }) 133 | 134 | test('foo does this', () => { 135 | // ... 136 | }) 137 | 138 | test('foo does that', () => { 139 | // ... 140 | }) 141 | ``` 142 | 143 | Examples of **correct** code for the `{ "allow": ["afterEach"] }` option: 144 | 145 | ```js 146 | /* eslint playwright/no-hooks: ["error", { "allow": ["afterEach"] }] */ 147 | 148 | function setupFoo(options) { 149 | /* ... */ 150 | } 151 | 152 | test.afterEach(() => { 153 | playwright.resetModules() 154 | }) 155 | 156 | test('foo does this', () => { 157 | const foo = setupFoo() 158 | // ... 159 | }) 160 | 161 | test('foo does that', () => { 162 | const foo = setupFoo() 163 | // ... 164 | }) 165 | ``` 166 | 167 | ## When Not To Use It 168 | 169 | If you prefer using the setup and teardown hooks provided by Playwright, you can 170 | safely disable this rule. 171 | -------------------------------------------------------------------------------- /docs/rules/no-nested-step.md: -------------------------------------------------------------------------------- 1 | # Disallow nested `test.step()` methods (`no-nested-step`) 2 | 3 | Nesting `test.step()` methods can make your tests difficult to read. 4 | 5 | ## Rule Details 6 | 7 | Examples of **incorrect** code for this rule: 8 | 9 | ```javascript 10 | test('foo', async () => { 11 | await test.step('step1', async () => { 12 | await test.step('nest step', async () => { 13 | await expect(true).toBe(true) 14 | }) 15 | }) 16 | }) 17 | ``` 18 | 19 | Examples of **correct** code for this rule: 20 | 21 | ```javascript 22 | test('foo', async () => { 23 | await test.step('step1', async () => { 24 | await expect(true).toBe(true) 25 | }) 26 | await test.step('step2', async () => { 27 | await expect(true).toBe(true) 28 | }) 29 | }) 30 | ``` 31 | -------------------------------------------------------------------------------- /docs/rules/no-networkidle.md: -------------------------------------------------------------------------------- 1 | # Disallow usage of the `networkidle` option (`no-networkidle`) 2 | 3 | Using `networkidle` is discouraged in favor of using 4 | [web first assertions](https://playwright.dev/docs/best-practices#use-web-first-assertions). 5 | 6 | ## Rule Details 7 | 8 | Examples of **incorrect** code for this rule: 9 | 10 | ```javascript 11 | await page.waitForLoadState('networkidle') 12 | await page.waitForURL('...', { waitUntil: 'networkidle' }) 13 | await page.goto('...', { waitUntil: 'networkidle' }) 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/rules/no-nth-methods.md: -------------------------------------------------------------------------------- 1 | # Disallow usage of `nth` methods (`no-nth-methods`) 2 | 3 | This rule prevents the usage of `nth` methods (`first()`, `last()`, and 4 | `nth()`). These methods can be prone to flakiness if the DOM structure changes. 5 | 6 | ## Rule Details 7 | 8 | Examples of **incorrect** code for this rule: 9 | 10 | ```javascript 11 | page.locator('button').first() 12 | page.locator('button').last() 13 | page.locator('button').nth(3) 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/rules/no-page-pause.md: -------------------------------------------------------------------------------- 1 | ## Disallow using `page.pause` (`no-page-pause`) 2 | 3 | Prevent usage of `page.pause()`. 4 | 5 | ## Rule Details 6 | 7 | Example of **incorrect** code for this rule: 8 | 9 | ```javascript 10 | await page.click('button') 11 | await page.pause() 12 | ``` 13 | 14 | Example of **correct** code for this rule: 15 | 16 | ```javascript 17 | await page.click('button') 18 | ``` 19 | -------------------------------------------------------------------------------- /docs/rules/no-raw-locators.md: -------------------------------------------------------------------------------- 1 | ## Disallow using raw locators (`no-raw-locators`) 2 | 3 | Prefer using user-facing locators over raw locators to make tests more robust. 4 | 5 | Check out the [Playwright documentation](https://playwright.dev/docs/locators) 6 | for more information. 7 | 8 | ## Rule Details 9 | 10 | Example of **incorrect** code for this rule: 11 | 12 | ```javascript 13 | await page.locator('button').click() 14 | ``` 15 | 16 | Example of **correct** code for this rule: 17 | 18 | ```javascript 19 | await page.getByRole('button').click() 20 | ``` 21 | 22 | ```javascript 23 | await page.getByRole('button', { 24 | name: 'Submit', 25 | }) 26 | ``` 27 | 28 | ## Options 29 | 30 | ```json 31 | { 32 | "playwright/no-raw-locators": [ 33 | "error", 34 | { 35 | "allowed": ["iframe", "[aria-busy='false']"] 36 | } 37 | ] 38 | } 39 | ``` 40 | 41 | ### `allowed` 42 | 43 | An array of raw locators that are allowed. This helps for locators such as 44 | `iframe` which does not have a ARIA role that you can select using `getByRole`. 45 | 46 | By default, no raw locators are allowed (the equivalent of `{ "ignore": [] }`). 47 | 48 | Example of **incorrect** code for the `{ "allowed": ["[aria-busy=false]"] }` 49 | option: 50 | 51 | ```javascript 52 | page.getByRole('navigation').and(page.locator('iframe')) 53 | ``` 54 | 55 | Example of **correct** code for the `{ "allowed": ["[aria-busy=false]"] }` 56 | option: 57 | 58 | ```javascript 59 | page.getByRole('navigation').and(page.locator('[aria-busy="false"]')) 60 | ``` 61 | -------------------------------------------------------------------------------- /docs/rules/no-restricted-matchers.md: -------------------------------------------------------------------------------- 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 | ``` 46 | -------------------------------------------------------------------------------- /docs/rules/no-skipped-test.md: -------------------------------------------------------------------------------- 1 | # Disallow usage of the `.skip` annotation (`no-skipped-test`) 2 | 3 | ## Rule Details 4 | 5 | Examples of **incorrect** code for this rule: 6 | 7 | ```javascript 8 | test.skip('skip this test', async ({ page }) => {}) 9 | 10 | test.describe.skip('skip two tests', () => { 11 | test('one', async ({ page }) => {}) 12 | test('two', async ({ page }) => {}) 13 | }) 14 | 15 | test.describe('skip test inside describe', () => { 16 | test.skip() 17 | }) 18 | 19 | test.describe('skip test conditionally', async ({ browserName }) => { 20 | test.skip(browserName === 'firefox', 'Working on it') 21 | }) 22 | ``` 23 | 24 | Examples of **correct** code for this rule: 25 | 26 | ```javascript 27 | test('this test', async ({ page }) => {}) 28 | 29 | test.describe('two tests', () => { 30 | test('one', async ({ page }) => {}) 31 | test('two', async ({ page }) => {}) 32 | }) 33 | ``` 34 | 35 | ## Options 36 | 37 | ```json 38 | { 39 | "playwright/no-skipped-test": [ 40 | "error", 41 | { 42 | "allowConditional": false 43 | } 44 | ] 45 | } 46 | ``` 47 | 48 | ### `allowConditional` 49 | 50 | Setting this option to `true` will allow using `test.skip()` to 51 | [conditionally skip a test](https://playwright.dev/docs/test-annotations#conditionally-skip-a-test). 52 | This can be helpful if you want to prevent usage of `test.skip` being added by 53 | mistake but still allow conditional tests based on browser/environment setup. 54 | 55 | Example of **correct** code for the `{ "allowConditional": true }` option: 56 | 57 | ```javascript 58 | test('foo', ({ browserName }) => { 59 | test.skip(browserName === 'firefox', 'Still working on it') 60 | }) 61 | ``` 62 | -------------------------------------------------------------------------------- /docs/rules/no-slowed-test.md: -------------------------------------------------------------------------------- 1 | # Disallow usage of the `.slow` annotation (`no-slowed-test`) 2 | 3 | ## Rule Details 4 | 5 | Examples of **incorrect** code for this rule: 6 | 7 | ```javascript 8 | test.slow('slow this test', async ({ page }) => {}) 9 | 10 | test.describe('slow test inside describe', () => { 11 | test.slow() 12 | }) 13 | 14 | test.describe('slow test conditionally', async ({ browserName }) => { 15 | test.slow(browserName === 'firefox', 'Working on it') 16 | }) 17 | ``` 18 | 19 | Examples of **correct** code for this rule: 20 | 21 | ```javascript 22 | test('this test', async ({ page }) => {}) 23 | 24 | test.describe('two tests', () => { 25 | test('one', async ({ page }) => {}) 26 | test('two', async ({ page }) => {}) 27 | }) 28 | ``` 29 | 30 | ## Options 31 | 32 | ```json 33 | { 34 | "playwright/no-slowed-test": [ 35 | "error", 36 | { 37 | "allowConditional": false 38 | } 39 | ] 40 | } 41 | ``` 42 | 43 | ### `allowConditional` 44 | 45 | Setting this option to `true` will allow using `test.slow()` to conditionally 46 | mark a test as slow. This can be helpful if you want to prevent usage of 47 | `test.slow` being added by mistake but still allow slow tests based on 48 | browser/environment setup. 49 | 50 | Example of **correct** code for the `{ "allowConditional": true }` option: 51 | 52 | ```javascript 53 | test('foo', ({ browserName }) => { 54 | test.slow(browserName === 'firefox', 'Still working on it') 55 | }) 56 | ``` 57 | -------------------------------------------------------------------------------- /docs/rules/no-standalone-expect.md: -------------------------------------------------------------------------------- 1 | # Disallow using `expect` outside of `test` blocks (`no-standalone-expect`) 2 | 3 | Prevents `expect` statements outside of a `test` block. An `expect` within a 4 | helper function (but outside of a `test` block) will not trigger this rule. 5 | 6 | ## Rule details 7 | 8 | This rule aims to eliminate `expect` statements outside of `test` blocks to 9 | encourage good testing practices. Using `expect` statements outside of `test` 10 | blocks may partially work, but their intent is to be used within a test as doing 11 | so makes it clear the purpose of each test. 12 | 13 | Using `expect` in helper functions is allowed to support grouping several expect 14 | statements into a helper function or page object method. Test hooks such as 15 | `beforeEach` are also allowed to support use cases such as waiting for an 16 | element on the page before each test is executed. While these uses cases are 17 | supported, they should be used sparingly as moving too many `expect` statements 18 | outside of the body of a `test` block can make it difficult to understand the 19 | purpose and primary assertions being made by a given test. 20 | 21 | Examples of **incorrect** code for this rule: 22 | 23 | ```js 24 | // in describe 25 | test.describe('a test', () => { 26 | expect(1).toBe(1) 27 | }) 28 | 29 | // below other tests 30 | test.describe('a test', () => { 31 | test('an it', () => { 32 | expect(1).toBe(1) 33 | }) 34 | 35 | expect(1).toBe(1) 36 | }) 37 | ``` 38 | 39 | Examples of **correct** code for this rule: 40 | 41 | ```js 42 | // in it block 43 | test.describe('a test', () => { 44 | test('an it', () => { 45 | expect(1).toBe(1) 46 | }) 47 | }) 48 | 49 | // in helper function 50 | test.describe('a test', () => { 51 | const helper = () => { 52 | expect(1).toBe(1) 53 | } 54 | 55 | test('an it', () => { 56 | helper() 57 | }) 58 | }) 59 | ``` 60 | 61 | _Note that this rule will not trigger if the helper function is never used even 62 | though the `expect` will not execute. Rely on a rule like no-unused-vars for 63 | this case._ 64 | 65 | ## When Not To Use It 66 | 67 | Don't use this rule on non-playwright test files. 68 | -------------------------------------------------------------------------------- /docs/rules/no-unsafe-references.md: -------------------------------------------------------------------------------- 1 | ## Prevent unsafe variable references in `page.evaluate()` (`no-unsafe-references`) 2 | 3 | This rule prevents common mistakes when using `page.evaluate()` with variables 4 | referenced from the parent scope. When referencing variables from the parent 5 | scope with `page.evaluate()`, you must pass them as an argument so Playwright 6 | can properly serialize and send them to the browser page where the function 7 | being evaluated is executed. 8 | 9 | ## Rule Details 10 | 11 | Example of **incorrect** code for this rule: 12 | 13 | ```javascript 14 | const x = 7 15 | const y = 8 16 | await page.evaluate(() => Promise.resolve(x * y), []) 17 | ``` 18 | 19 | Example of **correct** code for this rule: 20 | 21 | ```javascript 22 | await page.evaluate(([x, y]) => Promise.resolve(x * y), [7, 8]) 23 | 24 | const x = 7 25 | const y = 8 26 | await page.evaluate(([x, y]) => Promise.resolve(x * y), [x, y]) 27 | ``` 28 | -------------------------------------------------------------------------------- /docs/rules/no-useless-await.md: -------------------------------------------------------------------------------- 1 | # Disallow unnecessary `await`s for Playwright methods (`no-useless-await`) 2 | 3 | Some Playwright methods are frequently, yet incorrectly, awaited when the await 4 | expression has no effect. 5 | 6 | ## Rule Details 7 | 8 | Examples of **incorrect** code for this rule: 9 | 10 | ```javascript 11 | await page.locator('.my-element') 12 | await page.getByRole('.my-element') 13 | 14 | await expect(1).toBe(1) 15 | await expect(true).toBeTruthy() 16 | ``` 17 | 18 | Examples of **correct** code for this rule: 19 | 20 | ```javascript 21 | page.locator('.my-element') 22 | page.getByRole('.my-element') 23 | 24 | await page.$('.my-element') 25 | await page.goto('.my-element') 26 | 27 | expect(1).toBe(1) 28 | expect(true).toBeTruthy() 29 | 30 | await expect(page.locator('.foo')).toBeVisible() 31 | await expect(page.locator('.foo')).toHaveText('bar') 32 | ``` 33 | -------------------------------------------------------------------------------- /docs/rules/no-useless-not.md: -------------------------------------------------------------------------------- 1 | # Disallow usage of `not` matchers when a specific matcher exists (`no-useless-not`) 2 | 3 | Several Playwright matchers are complimentary such as `toBeVisible`/`toBeHidden` 4 | and `toBeEnabled`/`toBeDisabled`. While the `not` variants of each of these 5 | matchers can be used, it's preferred to use the complimentary matcher instead. 6 | 7 | ## Rule Details 8 | 9 | Examples of **incorrect** code for this rule: 10 | 11 | ```javascript 12 | expect(locator).not.toBeVisible() 13 | expect(locator).not.toBeHidden() 14 | expect(locator).not.toBeEnabled() 15 | expect(locator).not.toBeDisabled() 16 | ``` 17 | 18 | Example of **correct** code for this rule: 19 | 20 | ```javascript 21 | expect(locator).toBeHidden() 22 | expect(locator).toBeVisible() 23 | expect(locator).toBeDisabled() 24 | expect(locator).toBeEnabled() 25 | ``` 26 | -------------------------------------------------------------------------------- /docs/rules/no-wait-for-selector.md: -------------------------------------------------------------------------------- 1 | # Disallow usage of `page.waitForSelector` (`no-wait-for-selector`) 2 | 3 | ## Rule Details 4 | 5 | Example of **incorrect** code for this rule: 6 | 7 | ```javascript 8 | await page.waitForSelector('#foo') 9 | ``` 10 | 11 | Examples of **correct** code for this rule: 12 | 13 | ```javascript 14 | await page.waitForLoadState() 15 | await page.waitForURL('/home') 16 | await page.waitForFunction(() => window.innerWidth < 100) 17 | ``` 18 | -------------------------------------------------------------------------------- /docs/rules/no-wait-for-timeout.md: -------------------------------------------------------------------------------- 1 | # Disallow usage of `page.waitForTimeout` (`no-wait-for-timeout`) 2 | 3 | ## Rule Details 4 | 5 | Example of **incorrect** code for this rule: 6 | 7 | ```javascript 8 | await page.waitForTimeout(5000) 9 | ``` 10 | 11 | Examples of **correct** code for this rule: 12 | 13 | ```javascript 14 | // Use signals such as network events, selectors becoming visible and others instead. 15 | await page.waitForLoadState() 16 | 17 | await page.waitForURL('/home') 18 | 19 | await page.waitForFunction(() => window.innerWidth < 100) 20 | ``` 21 | -------------------------------------------------------------------------------- /docs/rules/prefer-comparison-matcher.md: -------------------------------------------------------------------------------- 1 | # Suggest using the built-in comparison matchers (`prefer-comparison-matcher`) 2 | 3 | Playwright has a number of built-in matchers for comparing numbers, which allow 4 | for more readable tests and error messages if an expectation fails. 5 | 6 | ## Rule details 7 | 8 | This rule checks for comparisons in tests that could be replaced with one of the 9 | following built-in comparison matchers: 10 | 11 | - `toBeGreaterThan` 12 | - `toBeGreaterThanOrEqual` 13 | - `toBeLessThan` 14 | - `toBeLessThanOrEqual` 15 | 16 | Examples of **incorrect** code for this rule: 17 | 18 | ```js 19 | expect(x > 5).toBe(true) 20 | expect(x < 7).not.toEqual(true) 21 | expect(x <= y).toStrictEqual(true) 22 | ``` 23 | 24 | Examples of **correct** code for this rule: 25 | 26 | ```js 27 | expect(x).toBeGreaterThan(5) 28 | expect(x).not.toBeLessThanOrEqual(7) 29 | expect(x).toBeLessThanOrEqual(y) 30 | 31 | // special case - see below 32 | expect(x < 'Carl').toBe(true) 33 | ``` 34 | 35 | Note that these matchers only work with numbers and bigints, and that the rule 36 | assumes that any variables on either side of the comparison operator are of one 37 | of those types - this means if you're using the comparison operator with 38 | strings, the fix applied by this rule will result in an error. 39 | 40 | ```js 41 | expect(myName).toBeGreaterThanOrEqual(theirName) // Matcher error: received value must be a number or bigint 42 | ``` 43 | 44 | The reason for this is that comparing strings with these operators is expected 45 | to be very rare and would mean not being able to have an automatic fixer for 46 | this rule. 47 | 48 | If for some reason you are using these operators to compare strings, you can 49 | disable this rule using an inline 50 | [configuration comment](https://eslint.org/docs/user-guide/configuring/rules#disabling-rules): 51 | 52 | ```js 53 | // eslint-disable-next-line playwright/prefer-comparison-matcher 54 | expect(myName > theirName).toBe(true) 55 | ``` 56 | -------------------------------------------------------------------------------- /docs/rules/prefer-equality-matcher.md: -------------------------------------------------------------------------------- 1 | # Suggest using the built-in equality matchers (`prefer-equality-matcher`) 2 | 3 | Playwright has built-in matchers for expecting equality, which allow for more 4 | readable tests and error messages if an expectation fails. 5 | 6 | ## Rule details 7 | 8 | This rule checks for _strict_ equality checks (`===` & `!==`) in tests that 9 | could be replaced with one of the following built-in equality matchers: 10 | 11 | - `toBe` 12 | - `toEqual` 13 | - `toStrictEqual` 14 | 15 | Examples of **incorrect** code for this rule: 16 | 17 | ```js 18 | expect(x === 5).toBe(true) 19 | expect(name === 'Carl').not.toEqual(true) 20 | expect(myObj !== thatObj).toStrictEqual(true) 21 | ``` 22 | 23 | Examples of **correct** code for this rule: 24 | 25 | ```js 26 | expect(x).toBe(5) 27 | expect(name).not.toEqual('Carl') 28 | expect(myObj).toStrictEqual(thatObj) 29 | ``` 30 | -------------------------------------------------------------------------------- /docs/rules/prefer-hooks-in-order.md: -------------------------------------------------------------------------------- 1 | # Prefer having hooks in a consistent order (`prefer-hooks-in-order`) 2 | 3 | While hooks can be setup in any order, they're always called by `playwright` in 4 | this specific order: 5 | 6 | 1. `beforeAll` 7 | 1. `beforeEach` 8 | 1. `afterEach` 9 | 1. `afterAll` 10 | 11 | This rule aims to make that more obvious by enforcing grouped hooks be setup in 12 | that order within tests. 13 | 14 | ## Rule details 15 | 16 | Examples of **incorrect** code for this rule 17 | 18 | ```js 19 | /* eslint playwright/prefer-hooks-in-order: "error" */ 20 | 21 | test.describe('foo', () => { 22 | test.beforeEach(() => { 23 | seedMyDatabase() 24 | }) 25 | 26 | test.beforeAll(() => { 27 | createMyDatabase() 28 | }) 29 | 30 | test('accepts this input', () => { 31 | // ... 32 | }) 33 | 34 | test('returns that value', () => { 35 | // ... 36 | }) 37 | 38 | test.describe('when the database has specific values', () => { 39 | const specificValue = '...' 40 | 41 | test.beforeEach(() => { 42 | seedMyDatabase(specificValue) 43 | }) 44 | 45 | test('accepts that input', () => { 46 | // ... 47 | }) 48 | 49 | test('throws an error', () => { 50 | // ... 51 | }) 52 | 53 | test.afterEach(() => { 54 | clearLogger() 55 | }) 56 | test.beforeEach(() => { 57 | mockLogger() 58 | }) 59 | 60 | test('logs a message', () => { 61 | // ... 62 | }) 63 | }) 64 | 65 | test.afterAll(() => { 66 | removeMyDatabase() 67 | }) 68 | }) 69 | ``` 70 | 71 | Examples of **correct** code for this rule 72 | 73 | ```js 74 | /* eslint playwright/prefer-hooks-in-order: "error" */ 75 | 76 | test.describe('foo', () => { 77 | test.beforeAll(() => { 78 | createMyDatabase() 79 | }) 80 | 81 | test.beforeEach(() => { 82 | seedMyDatabase() 83 | }) 84 | 85 | test('accepts this input', () => { 86 | // ... 87 | }) 88 | 89 | test('returns that value', () => { 90 | // ... 91 | }) 92 | 93 | test.describe('when the database has specific values', () => { 94 | const specificValue = '...' 95 | 96 | test.beforeEach(() => { 97 | seedMyDatabase(specificValue) 98 | }) 99 | 100 | test('accepts that input', () => { 101 | // ... 102 | }) 103 | 104 | test('throws an error', () => { 105 | // ... 106 | }) 107 | 108 | test.beforeEach(() => { 109 | mockLogger() 110 | }) 111 | 112 | test.afterEach(() => { 113 | clearLogger() 114 | }) 115 | 116 | test('logs a message', () => { 117 | // ... 118 | }) 119 | }) 120 | 121 | test.afterAll(() => { 122 | removeMyDatabase() 123 | }) 124 | }) 125 | ``` 126 | 127 | ## Also See 128 | 129 | - [`prefer-hooks-on-top`](prefer-hooks-on-top.md) 130 | -------------------------------------------------------------------------------- /docs/rules/prefer-hooks-on-top.md: -------------------------------------------------------------------------------- 1 | # Suggest having hooks before any test cases (`prefer-hooks-on-top`) 2 | 3 | While hooks can be setup anywhere in a test file, they are always called in a 4 | specific order, which means it can be confusing if they're intermixed with test 5 | cases. 6 | 7 | This rule helps to ensure that hooks are always defined before test cases. 8 | 9 | ## Rule details 10 | 11 | Examples of **incorrect** code for this rule 12 | 13 | ```js 14 | /* eslint playwright/prefer-hooks-on-top: "error" */ 15 | 16 | test.describe('foo', () => { 17 | test.beforeEach(() => { 18 | seedMyDatabase() 19 | }) 20 | 21 | test('accepts this input', () => { 22 | // ... 23 | }) 24 | 25 | test.beforeAll(() => { 26 | createMyDatabase() 27 | }) 28 | 29 | test('returns that value', () => { 30 | // ... 31 | }) 32 | 33 | test.describe('when the database has specific values', () => { 34 | const specificValue = '...' 35 | 36 | test.beforeEach(() => { 37 | seedMyDatabase(specificValue) 38 | }) 39 | 40 | test('accepts that input', () => { 41 | // ... 42 | }) 43 | 44 | test('throws an error', () => { 45 | // ... 46 | }) 47 | 48 | test.afterEach(() => { 49 | clearLogger() 50 | }) 51 | 52 | test.beforeEach(() => { 53 | mockLogger() 54 | }) 55 | 56 | test('logs a message', () => { 57 | // ... 58 | }) 59 | }) 60 | 61 | test.afterAll(() => { 62 | removeMyDatabase() 63 | }) 64 | }) 65 | ``` 66 | 67 | Examples of **correct** code for this rule 68 | 69 | ```js 70 | /* eslint playwright/prefer-hooks-on-top: "error" */ 71 | 72 | test.describe('foo', () => { 73 | test.beforeAll(() => { 74 | createMyDatabase() 75 | }) 76 | 77 | test.beforeEach(() => { 78 | seedMyDatabase() 79 | }) 80 | 81 | test.afterAll(() => { 82 | clearMyDatabase() 83 | }) 84 | 85 | test('accepts this input', () => { 86 | // ... 87 | }) 88 | 89 | test('returns that value', () => { 90 | // ... 91 | }) 92 | 93 | test.describe('when the database has specific values', () => { 94 | const specificValue = '...' 95 | 96 | beforeEach(() => { 97 | seedMyDatabase(specificValue) 98 | }) 99 | 100 | beforeEach(() => { 101 | mockLogger() 102 | }) 103 | 104 | afterEach(() => { 105 | clearLogger() 106 | }) 107 | 108 | test('accepts that input', () => { 109 | // ... 110 | }) 111 | 112 | test('throws an error', () => { 113 | // ... 114 | }) 115 | 116 | test('logs a message', () => { 117 | // ... 118 | }) 119 | }) 120 | }) 121 | ``` 122 | -------------------------------------------------------------------------------- /docs/rules/prefer-locator.md: -------------------------------------------------------------------------------- 1 | # Suggest using `page.locator()` (`prefer-locator`) 2 | 3 | Suggest using locators and their associated methods instead of page methods for 4 | performing actions. 5 | 6 | ## Rule details 7 | 8 | This rule triggers a warning if page methods are used, instead of locators. 9 | 10 | The following patterns are considered warnings: 11 | 12 | ```javascript 13 | page.click('css=button') 14 | await page.click('css=button') 15 | await page.dblclick('xpath=//button') 16 | await page.fill('input[type="password"]', 'password') 17 | 18 | await page.frame('frame-name').click('css=button') 19 | ``` 20 | 21 | The following pattern are **not** warnings: 22 | 23 | ```javascript 24 | const locator = page.locator('css=button') 25 | await page.getByRole('password').fill('password') 26 | await page.getByLabel('User Name').fill('John') 27 | await page.getByRole('button', { name: 'Sign in' }).click() 28 | await page.locator('input[type="password"]').fill('password') 29 | await page.locator('css=button').click() 30 | await page.locator('xpath=//button').dblclick() 31 | 32 | await page.frameLocator('#my-iframe').getByText('Submit').click() 33 | ``` 34 | -------------------------------------------------------------------------------- /docs/rules/prefer-lowercase-title.md: -------------------------------------------------------------------------------- 1 | # Enforce lowercase test names (`prefer-lowercase-title`) 2 | 3 | ## Rule details 4 | 5 | Enforce `test` and `test.describe` to have descriptions that begin with a 6 | lowercase letter. This provides more readable test failures. This rule is not 7 | enabled by default. 8 | 9 | The following pattern is considered a warning: 10 | 11 | ```javascript 12 | test('Adds 1 + 2 to equal 3', () => { 13 | expect(sum(1, 2)).toBe(3) 14 | }) 15 | ``` 16 | 17 | The following pattern is **not** considered a warning: 18 | 19 | ```javascript 20 | test('adds 1 + 2 to equal 3', () => { 21 | expect(sum(1, 2)).toBe(3) 22 | }) 23 | ``` 24 | 25 | ## Options 26 | 27 | ```json 28 | { 29 | "playwright/prefer-lowercase-title": [ 30 | "error", 31 | { 32 | "allowedPrefixes": ["GET", "POST"], 33 | "ignore": ["test.describe", "test"], 34 | "ignoreTopLevelDescribe": true 35 | } 36 | ] 37 | } 38 | ``` 39 | 40 | ### `ignore` 41 | 42 | This array option controls which Playwright functions are checked by this rule. 43 | There are two possible values: 44 | 45 | - `"test.describe"` 46 | - `"test"` 47 | 48 | By default, none of these options are enabled (the equivalent of 49 | `{ "ignore": [] }`). 50 | 51 | Example of **correct** code for the `{ "ignore": ["test.describe"] }` option: 52 | 53 | ```javascript 54 | test.describe('Uppercase description') 55 | ``` 56 | 57 | Example of **correct** code for the `{ "ignore": ["test"] }` option: 58 | 59 | ```javascript 60 | test('Uppercase description') 61 | ``` 62 | 63 | ### `allowedPrefixes` 64 | 65 | This array option allows specifying prefixes, which contain capitals that titles 66 | can start with. This can be useful when writing tests for API endpoints, where 67 | you'd like to prefix with the HTTP method. 68 | 69 | By default, nothing is allowed (the equivalent of `{ "allowedPrefixes": [] }`). 70 | 71 | Example of **correct** code for the `{ "allowedPrefixes": ["GET"] }` option: 72 | 73 | ```javascript 74 | test.describe('GET /live') 75 | ``` 76 | 77 | ### `ignoreTopLevelDescribe` 78 | 79 | This option can be set to allow only the top-level `test.describe` blocks to 80 | have a title starting with an upper-case letter. 81 | 82 | Example of **correct** code for the `{ "ignoreTopLevelDescribe": true }` option: 83 | 84 | ```javascript 85 | test.describe('MyClass', () => { 86 | test.describe('#myMethod', () => { 87 | test('does things', () => {}) 88 | }) 89 | }) 90 | ``` 91 | -------------------------------------------------------------------------------- /docs/rules/prefer-native-locators.md: -------------------------------------------------------------------------------- 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 | ``` 71 | -------------------------------------------------------------------------------- /docs/rules/prefer-strict-equal.md: -------------------------------------------------------------------------------- 1 | # Suggest using `toStrictEqual()` (`prefer-strict-equal`) 2 | 3 | `toStrictEqual` not only checks that two objects contain the same data but also 4 | that they have the same structure. It is common to expect objects to not only 5 | have identical values but also to have identical keys. A stricter equality will 6 | catch cases where two objects do not have identical keys. 7 | 8 | ## Rule details 9 | 10 | This rule triggers a warning if `toEqual()` is used to assert equality. 11 | 12 | ### Default configuration 13 | 14 | The following pattern is considered warning: 15 | 16 | ```javascript 17 | expect({ a: 'a', b: undefined }).toEqual({ a: 'a' }) // true 18 | ``` 19 | 20 | The following pattern is not warning: 21 | 22 | ```javascript 23 | expect({ a: 'a', b: undefined }).toStrictEqual({ a: 'a' }) // false 24 | ``` 25 | -------------------------------------------------------------------------------- /docs/rules/prefer-to-be.md: -------------------------------------------------------------------------------- 1 | # Suggest using `toBe()` for primitive literals (`prefer-to-be`) 2 | 3 | When asserting against primitive literals such as numbers and strings, the 4 | equality matchers all operate the same, but read slightly differently in code. 5 | 6 | This rule recommends using the `toBe` matcher in these situations, as it forms 7 | the most grammatically natural sentence. For `null`, `undefined`, and `NaN` this 8 | rule recommends using their specific `toBe` matchers, as they give better error 9 | messages as well. 10 | 11 | ## Rule details 12 | 13 | This rule triggers a warning if `toEqual()` or `toStrictEqual()` are used to 14 | assert a primitive literal value such as numbers, strings, and booleans. 15 | 16 | The following patterns are considered warnings: 17 | 18 | ```javascript 19 | expect(value).not.toEqual(5) 20 | expect(getMessage()).toStrictEqual('hello world') 21 | expect(loadMessage()).resolves.toEqual('hello world') 22 | ``` 23 | 24 | The following pattern is not warning: 25 | 26 | ```javascript 27 | expect(value).not.toBe(5) 28 | expect(getMessage()).toBe('hello world') 29 | expect(loadMessage()).resolves.toBe('hello world') 30 | expect(didError).not.toBe(true) 31 | expect(catchError()).toStrictEqual({ message: 'oh noes!' }) 32 | ``` 33 | 34 | For `null`, `undefined`, and `NaN`, this rule triggers a warning if `toBe` is 35 | used to assert against those literal values instead of their more specific 36 | `toBe` counterparts: 37 | 38 | ```javascript 39 | expect(value).not.toBe(undefined) 40 | expect(getMessage()).toBe(null) 41 | expect(countMessages()).resolves.not.toBe(NaN) 42 | ``` 43 | 44 | The following pattern is not warning: 45 | 46 | ```javascript 47 | expect(value).toBeDefined() 48 | expect(getMessage()).toBeNull() 49 | expect(countMessages()).resolves.not.toBeNaN() 50 | expect(catchError()).toStrictEqual({ message: undefined }) 51 | ``` 52 | -------------------------------------------------------------------------------- /docs/rules/prefer-to-contain.md: -------------------------------------------------------------------------------- 1 | # Suggest using `toContain()` (`prefer-to-contain`) 2 | 3 | In order to have a better failure message, `toContain()` should be used upon 4 | asserting expectations on an array containing an object. 5 | 6 | ## Rule Details 7 | 8 | Example of **incorrect** code for this rule: 9 | 10 | ```javascript 11 | expect(a.includes(b)).toBe(true) 12 | expect(a.includes(b)).not.toBe(true) 13 | expect(a.includes(b)).toBe(false) 14 | expect(a.includes(b)).toEqual(true) 15 | expect(a.includes(b)).toStrictEqual(true) 16 | ``` 17 | 18 | Example of **correct** code for this rule: 19 | 20 | ```javascript 21 | expect(a).toContain(b) 22 | expect(a).not.toContain(b) 23 | ``` 24 | -------------------------------------------------------------------------------- /docs/rules/prefer-to-have-count.md: -------------------------------------------------------------------------------- 1 | # Suggest using `toHaveCount()` (`prefer-to-have-count`) 2 | 3 | In order to have a better failure message, `toHaveCount()` should be used upon 4 | asserting expectations on locators `count()` method. 5 | 6 | ## Rule details 7 | 8 | This rule triggers a warning if `toBe()`, `toEqual()` or `toStrictEqual()` is 9 | used to assert locators `count()` method. 10 | 11 | The following patterns are considered warnings: 12 | 13 | ```javascript 14 | expect(await files.count()).toBe(1) 15 | expect(await files.count()).toEqual(1) 16 | expect(await files.count()).toStrictEqual(1) 17 | ``` 18 | 19 | The following pattern is **not** a warning: 20 | 21 | ```javascript 22 | await expect(files).toHaveCount(1) 23 | ``` 24 | -------------------------------------------------------------------------------- /docs/rules/prefer-to-have-length.md: -------------------------------------------------------------------------------- 1 | # Suggest using `toHaveLength()` (`prefer-to-have-length`) 2 | 3 | In order to have a better failure message, `toHaveLength()` should be used upon 4 | asserting expectations on objects length property. 5 | 6 | ## Rule details 7 | 8 | This rule triggers a warning if `toBe()`, `toEqual()` or `toStrictEqual()` is 9 | used to assert objects length property. 10 | 11 | The following patterns are considered warnings: 12 | 13 | ```javascript 14 | expect(files.length).toBe(1) 15 | expect(files.length).toEqual(1) 16 | expect(files.length).toStrictEqual(1) 17 | ``` 18 | 19 | The following pattern is **not** a warning: 20 | 21 | ```javascript 22 | expect(files).toHaveLength(1) 23 | ``` 24 | -------------------------------------------------------------------------------- /docs/rules/prefer-web-first-assertions.md: -------------------------------------------------------------------------------- 1 | # Prefer web first assertions (`prefer-web-first-assertions`) 2 | 3 | Playwright supports many web first assertions to assert properties or conditions 4 | on elements. These assertions are preferred over instance methods as the web 5 | first assertions will automatically wait for the conditions to be fulfilled 6 | resulting in more resilient tests. 7 | 8 | ## Rule Details 9 | 10 | Examples of **incorrect** code for this rule: 11 | 12 | ```javascript 13 | expect(await page.locator('.tweet').isVisible()).toBe(true) 14 | expect(await page.locator('.tweet').isEnabled()).toBe(true) 15 | expect(await page.locator('.tweet').innerText()).toBe('bar') 16 | ``` 17 | 18 | Example of **correct** code for this rule: 19 | 20 | ```javascript 21 | await expect(page.locator('.tweet')).toBeVisible() 22 | await expect(page.locator('.tweet')).toBeEnabled() 23 | await expect(page.locator('.tweet')).toHaveText('bar') 24 | ``` 25 | -------------------------------------------------------------------------------- /docs/rules/require-hook.md: -------------------------------------------------------------------------------- 1 | # Require setup and teardown code to be within a hook (`require-hook`) 2 | 3 | It's common when writing tests to need to perform setup work that has to happen 4 | before tests run, and finishing work after tests run. 5 | 6 | Because Playwright executes all `describe` handlers in a test file _before_ it 7 | executes any of the actual tests, it's important to ensure setup and teardown 8 | work is done inside `before*` and `after*` handlers respectively, rather than 9 | inside the `describe` blocks. 10 | 11 | ## Rule details 12 | 13 | This rule flags any expression that is either at the toplevel of a test file or 14 | directly within the body of a `describe`, _except_ for the following: 15 | 16 | - `import` statements 17 | - `const` variables 18 | - `let` _declarations_, and initializations to `null` or `undefined` 19 | - Classes 20 | - Types 21 | - Calls to the standard Playwright globals 22 | 23 | This rule flags any function calls within test files that are directly within 24 | the body of a `describe`, and suggests wrapping them in one of the four 25 | lifecycle hooks. 26 | 27 | Here is a slightly contrived test file showcasing some common cases that would 28 | be flagged: 29 | 30 | ```js 31 | const initializeCityDatabase = () => { 32 | database.addCity('Vienna') 33 | database.addCity('San Juan') 34 | database.addCity('Wellington') 35 | } 36 | 37 | const clearCityDatabase = () => { 38 | database.clear() 39 | } 40 | 41 | initializeCityDatabase() 42 | 43 | test('that persists cities', () => { 44 | expect(database.cities.length).toHaveLength(3) 45 | }) 46 | 47 | test('city database has Vienna', () => { 48 | expect(isCity('Vienna')).toBeTruthy() 49 | }) 50 | 51 | test('city database has San Juan', () => { 52 | expect(isCity('San Juan')).toBeTruthy() 53 | }) 54 | 55 | test.describe('when loading cities from the api', () => { 56 | clearCityDatabase() 57 | 58 | test('does not duplicate cities', async () => { 59 | await database.loadCities() 60 | expect(database.cities).toHaveLength(4) 61 | }) 62 | }) 63 | 64 | clearCityDatabase() 65 | ``` 66 | 67 | Here is the same slightly contrived test file showcasing the same common cases 68 | but in ways that would be **not** flagged: 69 | 70 | ```js 71 | const initializeCityDatabase = () => { 72 | database.addCity('Vienna') 73 | database.addCity('San Juan') 74 | database.addCity('Wellington') 75 | } 76 | 77 | const clearCityDatabase = () => { 78 | database.clear() 79 | } 80 | 81 | test.beforeEach(() => { 82 | initializeCityDatabase() 83 | }) 84 | 85 | test('that persists cities', () => { 86 | expect(database.cities.length).toHaveLength(3) 87 | }) 88 | 89 | test('city database has Vienna', () => { 90 | expect(isCity('Vienna')).toBeTruthy() 91 | }) 92 | 93 | test('city database has San Juan', () => { 94 | expect(isCity('San Juan')).toBeTruthy() 95 | }) 96 | 97 | test.describe('when loading cities from the api', () => { 98 | test.beforeEach(() => { 99 | clearCityDatabase() 100 | }) 101 | 102 | test('does not duplicate cities', async () => { 103 | await database.loadCities() 104 | expect(database.cities).toHaveLength(4) 105 | }) 106 | }) 107 | 108 | test.afterEach(() => { 109 | clearCityDatabase() 110 | }) 111 | ``` 112 | 113 | ## Options 114 | 115 | If there are methods that you want to call outside of hooks and tests, you can 116 | mark them as allowed using the `allowedFunctionCalls` option. 117 | 118 | ```json 119 | { 120 | "playwright/require-hook": [ 121 | "error", 122 | { 123 | "allowedFunctionCalls": ["enableAutoDestroy"] 124 | } 125 | ] 126 | } 127 | ``` 128 | 129 | Examples of **correct** code when using 130 | `{ "allowedFunctionCalls": ["enableAutoDestroy"] }` option: 131 | 132 | ```js 133 | /* eslint playwright/require-hook: ["error", { "allowedFunctionCalls": ["enableAutoDestroy"] }] */ 134 | 135 | enableAutoDestroy(test.afterEach) 136 | 137 | test.beforeEach(initDatabase) 138 | test.afterEach(tearDownDatabase) 139 | 140 | test.describe('Foo', () => { 141 | test('always returns 42', () => { 142 | expect(global.getAnswer()).toBe(42) 143 | }) 144 | }) 145 | ``` 146 | -------------------------------------------------------------------------------- /docs/rules/require-soft-assertions.md: -------------------------------------------------------------------------------- 1 | # Require soft assertions (`require-soft-assertions`) 2 | 3 | Some find it easier to write longer test that perform more assertions per test. 4 | In cases like these, it can be helpful to require 5 | [soft assertions](https://playwright.dev/docs/test-assertions#soft-assertions) 6 | in your tests. 7 | 8 | This rule is not enabled by default and is only intended to be used it if fits 9 | your workflow. If you aren't sure if you should use this rule, you probably 10 | shouldn't 🙂. 11 | 12 | ## Rule Details 13 | 14 | Examples of **incorrect** code for this rule: 15 | 16 | ```javascript 17 | await expect(page.locator('foo')).toHaveText('bar') 18 | await expect(page).toHaveTitle('baz') 19 | ``` 20 | 21 | Examples of **correct** code for this rule: 22 | 23 | ```javascript 24 | await expect.soft(page.locator('foo')).toHaveText('bar') 25 | await expect.soft(page).toHaveTitle('baz') 26 | ``` 27 | -------------------------------------------------------------------------------- /docs/rules/require-to-throw-message.md: -------------------------------------------------------------------------------- 1 | # Require a message for `toThrow()` (`require-to-throw-message`) 2 | 3 | `toThrow()` (and its alias `toThrowError()`) is used to check if an error is 4 | thrown by a function call, such as in `expect(() => a()).toThrow()`. However, if 5 | no message is defined, then the test will pass for any thrown error. Requiring a 6 | message ensures that the intended error is thrown. 7 | 8 | ## Rule details 9 | 10 | This rule triggers a warning if `toThrow()` or `toThrowError()` is used without 11 | an error message. 12 | 13 | The following patterns are considered warnings: 14 | 15 | ```js 16 | test('all the things', async () => { 17 | expect(() => a()).toThrow() 18 | expect(() => a()).toThrowError() 19 | 20 | await expect(a()).rejects.toThrow() 21 | await expect(a()).rejects.toThrowError() 22 | }) 23 | ``` 24 | 25 | The following patterns are **not** considered warnings: 26 | 27 | ```js 28 | test('all the things', async () => { 29 | expect(() => a()).toThrow('a') 30 | expect(() => a()).toThrowError('a') 31 | 32 | await expect(a()).rejects.toThrow('a') 33 | await expect(a()).rejects.toThrowError('a') 34 | }) 35 | ``` 36 | -------------------------------------------------------------------------------- /docs/rules/require-top-level-describe.md: -------------------------------------------------------------------------------- 1 | # Require test cases and hooks to be inside a `test.describe` block (`require-top-level-describe`) 2 | 3 | Playwright allows you to organise your test files the way you want it. However, 4 | the more your codebase grows, the more it becomes hard to navigate in your test 5 | files. This rule makes sure you provide at least a top-level `describe` block in 6 | your test file. 7 | 8 | ## Rule Details 9 | 10 | This rule triggers a warning if a test case (`test`) or a hook 11 | (`test.beforeAll`, `test.beforeEach`, `test.afterEach`, `test.afterAll`) is not 12 | located in a top-level `test.describe` block. 13 | 14 | The following patterns are considered warnings: 15 | 16 | ```javascript 17 | // Above a describe block 18 | test('my test', () => {}) 19 | test.describe('test suite', () => { 20 | test('test', () => {}) 21 | }) 22 | 23 | // Below a describe block 24 | test.describe('test suite', () => {}) 25 | test('my test', () => {}) 26 | 27 | // Same for hooks 28 | test.beforeAll('my beforeAll', () => {}) 29 | test.describe('test suite', () => {}) 30 | test.afterEach('my afterEach', () => {}) 31 | ``` 32 | 33 | The following patterns are **not** considered warnings: 34 | 35 | ```javascript 36 | // In a describe block 37 | test.describe('test suite', () => { 38 | test('my test', () => {}) 39 | }) 40 | 41 | // In a nested describe block 42 | test.describe('test suite', () => { 43 | test('my test', () => {}) 44 | 45 | test.describe('another test suite', () => { 46 | test('my other test', () => {}) 47 | }) 48 | }) 49 | ``` 50 | 51 | You can also enforce a limit on the number of describes allowed at the top-level 52 | using the `maxTopLevelDescribes` option: 53 | 54 | ```json 55 | { 56 | "playwright/require-top-level-describe": [ 57 | "error", 58 | { "maxTopLevelDescribes": 2 } 59 | ] 60 | } 61 | ``` 62 | 63 | Examples of **incorrect** code with the above config: 64 | 65 | ```javascript 66 | test.describe('test suite', () => { 67 | test('test', () => {}) 68 | }) 69 | 70 | test.describe('test suite', () => {}) 71 | test.describe('test suite', () => {}) 72 | ``` 73 | 74 | This option defaults to `Infinity`, allowing any number of top-level describes. 75 | -------------------------------------------------------------------------------- /docs/rules/valid-describe-callback.md: -------------------------------------------------------------------------------- 1 | # Enforce valid `describe()` callback (`valid-describe-callback`) 2 | 3 | Using an improper `describe()` callback function can lead to unexpected test 4 | errors. 5 | 6 | ## Rule details 7 | 8 | This rule validates that the second parameter of a `describe()` function is a 9 | callback function. This callback function: 10 | 11 | - should not be 12 | [async](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function) 13 | - should not contain any parameters 14 | - should not contain any `return` statements 15 | 16 | The following patterns are considered warnings: 17 | 18 | ```js 19 | // Async callback functions are not allowed 20 | test.describe('myFunction()', async () => { 21 | // ... 22 | }) 23 | 24 | // Callback function parameters are not allowed 25 | test.describe('myFunction()', (done) => { 26 | // ... 27 | }) 28 | 29 | // No return statements are allowed in block of a callback function 30 | test.describe('myFunction', () => { 31 | return Promise.resolve().then(() => { 32 | test('breaks', () => { 33 | throw new Error('Fail') 34 | }) 35 | }) 36 | }) 37 | 38 | // Returning a value from a describe block is not allowed 39 | test.describe('myFunction', () => 40 | test('returns a truthy value', () => { 41 | expect(myFunction()).toBeTruthy() 42 | })) 43 | ``` 44 | 45 | The following patterns are **not** considered warnings: 46 | 47 | ```js 48 | test.describe('myFunction()', () => { 49 | test('returns a truthy value', () => { 50 | expect(myFunction()).toBeTruthy() 51 | }) 52 | }) 53 | ``` 54 | -------------------------------------------------------------------------------- /docs/rules/valid-expect-in-promise.md: -------------------------------------------------------------------------------- 1 | # Require promises that have expectations in their chain to be valid (`valid-expect-in-promise`) 2 | 3 | Ensure promises that include expectations are returned or awaited. 4 | 5 | ## Rule details 6 | 7 | This rule flags any promises within the body of a test that include expectations 8 | that have either not been returned or awaited. 9 | 10 | The following patterns are considered warnings: 11 | 12 | ```js 13 | test('promises a person', () => { 14 | api.getPersonByName('bob').then((person) => { 15 | expect(person).toHaveProperty('name', 'Bob') 16 | }) 17 | }) 18 | 19 | test('promises a counted person', () => { 20 | const promise = api.getPersonByName('bob').then((person) => { 21 | expect(person).toHaveProperty('name', 'Bob') 22 | }) 23 | 24 | promise.then(() => { 25 | expect(analytics.gottenPeopleCount).toBe(1) 26 | }) 27 | }) 28 | 29 | test('promises multiple people', () => { 30 | const firstPromise = api.getPersonByName('bob').then((person) => { 31 | expect(person).toHaveProperty('name', 'Bob') 32 | }) 33 | const secondPromise = api.getPersonByName('alice').then((person) => { 34 | expect(person).toHaveProperty('name', 'Alice') 35 | }) 36 | 37 | return Promise.any([firstPromise, secondPromise]) 38 | }) 39 | ``` 40 | 41 | The following pattern is not a warning: 42 | 43 | ```js 44 | test('promises a person', async () => { 45 | await api.getPersonByName('bob').then((person) => { 46 | expect(person).toHaveProperty('name', 'Bob') 47 | }) 48 | }) 49 | 50 | test('promises a counted person', () => { 51 | let promise = api.getPersonByName('bob').then((person) => { 52 | expect(person).toHaveProperty('name', 'Bob') 53 | }) 54 | 55 | promise = promise.then(() => { 56 | expect(analytics.gottenPeopleCount).toBe(1) 57 | }) 58 | 59 | return promise 60 | }) 61 | 62 | test('promises multiple people', () => { 63 | const firstPromise = api.getPersonByName('bob').then((person) => { 64 | expect(person).toHaveProperty('name', 'Bob') 65 | }) 66 | const secondPromise = api.getPersonByName('alice').then((person) => { 67 | expect(person).toHaveProperty('name', 'Alice') 68 | }) 69 | 70 | return Promise.allSettled([firstPromise, secondPromise]) 71 | }) 72 | ``` 73 | -------------------------------------------------------------------------------- /docs/rules/valid-expect.md: -------------------------------------------------------------------------------- 1 | # Enforce valid `expect()` usage (`valid-expect`) 2 | 3 | Ensure `expect()` is called with a matcher. 4 | 5 | ## Rule details 6 | 7 | Examples of **incorrect** code for this rule: 8 | 9 | ```javascript 10 | expect() 11 | expect('something') 12 | expect(true).toBeDefined 13 | ``` 14 | 15 | Example of **correct** code for this rule: 16 | 17 | ```javascript 18 | expect(locator).toHaveText('howdy') 19 | expect('something').toBe('something') 20 | expect(true).toBeDefined() 21 | ``` 22 | 23 | ## Options 24 | 25 | ```json 26 | { 27 | "minArgs": 1, 28 | "maxArgs": 2 29 | } 30 | ``` 31 | 32 | ### `minArgs` & `maxArgs` 33 | 34 | Enforces the minimum and maximum number of arguments that `expect` can take, and 35 | is required to take. 36 | 37 | `minArgs` defaults to 1 while `maxArgs` deafults to `2` to support custom expect 38 | messages. If you want to enforce `expect` always or never has a custom message, 39 | you can adjust these two option values to your preference. 40 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import mskelton from '@mskelton/eslint-config' 2 | 3 | export default [ 4 | ...mskelton.recommended, 5 | { 6 | ignores: ['dist', 'examples'], 7 | }, 8 | { 9 | files: ['**/*.test.ts'], 10 | rules: { 11 | 'no-template-curly-in-string': 'off', 12 | }, 13 | }, 14 | ] 15 | -------------------------------------------------------------------------------- /examples/eslint.config.js: -------------------------------------------------------------------------------- 1 | import playwright from 'eslint-plugin-playwright' 2 | 3 | export default { 4 | ...playwright.configs['flat/recommended'], 5 | rules: { 6 | ...playwright.configs['flat/recommended'].rules, 7 | 'playwright/no-commented-out-tests': 'error', 8 | 'playwright/no-duplicate-hooks': 'error', 9 | 'playwright/no-get-by-title': 'error', 10 | 'playwright/no-nth-methods': 'error', 11 | 'playwright/no-raw-locators': 'error', 12 | 'playwright/no-restricted-matchers': 'error', 13 | 'playwright/prefer-comparison-matcher': 'error', 14 | 'playwright/prefer-equality-matcher': 'error', 15 | 'playwright/prefer-hooks-in-order': 'error', 16 | 'playwright/prefer-hooks-on-top': 'error', 17 | 'playwright/prefer-lowercase-title': 'error', 18 | 'playwright/prefer-strict-equal': 'error', 19 | 'playwright/prefer-to-be': 'error', 20 | 'playwright/prefer-to-contain': 'error', 21 | 'playwright/prefer-to-have-count': 'error', 22 | 'playwright/prefer-to-have-length': 'error', 23 | 'playwright/require-to-throw-message': 'error', 24 | 'playwright/require-top-level-describe': 'error', 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /examples/index.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/playwright-community/eslint-plugin-playwright/a710bd7e26df7c818813a5b8d3f16f3d590c51f5/examples/index.js -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "examples", 3 | "type": "module", 4 | "scripts": { 5 | "lint": "eslint ." 6 | }, 7 | "dependencies": { 8 | "@playwright/test": "^1.42.0", 9 | "eslint": "^9.13.0", 10 | "eslint-plugin-playwright": "file:../" 11 | }, 12 | "devDependencies": { 13 | "@types/node": "^20.11.17" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /index.cjs: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-require-imports 2 | module.exports = require('./dist/index.cjs') 3 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import { Linter, Rule } from 'eslint' 2 | 3 | declare const config: { 4 | configs: { 5 | 'flat/recommended': Linter.Config 6 | 'playwright-test': Linter.Config 7 | recommended: Linter.Config 8 | } 9 | rules: Record 10 | } 11 | 12 | export default config 13 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import plugin from './dist/index.cjs' 2 | 3 | export default plugin 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-plugin-playwright", 3 | "description": "ESLint plugin for Playwright testing.", 4 | "version": "0.0.0-semantically-released", 5 | "repository": "https://github.com/playwright-community/eslint-plugin-playwright", 6 | "author": "Mark Skelton ", 7 | "packageManager": "pnpm@8.12.0", 8 | "contributors": [ 9 | "Max Schmitt " 10 | ], 11 | "license": "MIT", 12 | "workspaces": [ 13 | "examples" 14 | ], 15 | "engines": { 16 | "node": ">=16.6.0" 17 | }, 18 | "type": "module", 19 | "types": "./index.d.ts", 20 | "exports": { 21 | ".": { 22 | "types": "./index.d.ts", 23 | "import": "./index.js", 24 | "require": "./index.cjs" 25 | } 26 | }, 27 | "files": [ 28 | "dist", 29 | "index.js", 30 | "index.cjs", 31 | "index.d.ts" 32 | ], 33 | "scripts": { 34 | "build": "tsup src/index.ts --format cjs --out-dir dist", 35 | "lint": "eslint .", 36 | "fmt": "prettier --write .", 37 | "fmt:check": "prettier --check .", 38 | "test": "vitest", 39 | "test:watch": "vitest --reporter=dot", 40 | "ts": "tsc --noEmit" 41 | }, 42 | "peerDependencies": { 43 | "eslint": ">=8.40.0" 44 | }, 45 | "dependencies": { 46 | "globals": "^13.23.0" 47 | }, 48 | "devDependencies": { 49 | "@mskelton/eslint-config": "^9.0.1", 50 | "@mskelton/semantic-release-config": "^1.0.1", 51 | "@types/estree": "^1.0.6", 52 | "@types/node": "^20.11.17", 53 | "@typescript-eslint/parser": "^8.11.0", 54 | "dedent": "^1.5.1", 55 | "eslint": "^9.13.0", 56 | "prettier": "^3.0.3", 57 | "prettier-plugin-jsdoc": "^1.3.0", 58 | "semantic-release": "^23.0.2", 59 | "tsup": "^8.0.1", 60 | "typescript": "^5.2.2", 61 | "vitest": "^1.3.1" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - examples 3 | -------------------------------------------------------------------------------- /src/rules/expect-expect.ts: -------------------------------------------------------------------------------- 1 | import ESTree from 'estree' 2 | import { dig } from '../utils/ast.js' 3 | import { createRule } from '../utils/createRule.js' 4 | import { parseFnCall } from '../utils/parseFnCall.js' 5 | 6 | export default createRule({ 7 | create(context) { 8 | const options = { 9 | assertFunctionNames: [] as string[], 10 | ...((context.options?.[0] as Record) ?? {}), 11 | } 12 | 13 | const unchecked: ESTree.CallExpression[] = [] 14 | 15 | function checkExpressions(nodes: ESTree.Node[]) { 16 | for (const node of nodes) { 17 | const index = 18 | node.type === 'CallExpression' ? unchecked.indexOf(node) : -1 19 | 20 | if (index !== -1) { 21 | unchecked.splice(index, 1) 22 | break 23 | } 24 | } 25 | } 26 | 27 | return { 28 | CallExpression(node) { 29 | const call = parseFnCall(context, node) 30 | 31 | if (call?.type === 'test') { 32 | unchecked.push(node) 33 | } else if ( 34 | call?.type === 'expect' || 35 | options.assertFunctionNames.find((name) => dig(node.callee, name)) 36 | ) { 37 | const ancestors = context.sourceCode.getAncestors(node) 38 | checkExpressions(ancestors) 39 | } 40 | }, 41 | 'Program:exit'() { 42 | unchecked.forEach((node) => { 43 | context.report({ messageId: 'noAssertions', node: node.callee }) 44 | }) 45 | }, 46 | } 47 | }, 48 | meta: { 49 | docs: { 50 | category: 'Best Practices', 51 | description: 'Enforce assertion to be made in a test body', 52 | recommended: true, 53 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/expect-expect.md', 54 | }, 55 | messages: { 56 | noAssertions: 'Test has no assertions', 57 | }, 58 | schema: [ 59 | { 60 | additionalProperties: false, 61 | properties: { 62 | assertFunctionNames: { 63 | items: [{ type: 'string' }], 64 | type: 'array', 65 | }, 66 | }, 67 | type: 'object', 68 | }, 69 | ], 70 | type: 'problem', 71 | }, 72 | }) 73 | -------------------------------------------------------------------------------- /src/rules/max-expects.ts: -------------------------------------------------------------------------------- 1 | import * as ESTree from 'estree' 2 | import { getParent } from '../utils/ast.js' 3 | import { createRule } from '../utils/createRule.js' 4 | import { isTypeOfFnCall, parseFnCall } from '../utils/parseFnCall.js' 5 | 6 | export default createRule({ 7 | create(context) { 8 | const options = { 9 | max: 5, 10 | ...((context.options?.[0] as Record) ?? {}), 11 | } 12 | 13 | let count = 0 14 | 15 | const maybeResetCount = (node: ESTree.Node) => { 16 | const parent = getParent(node) 17 | const isTestFn = 18 | parent?.type !== 'CallExpression' || 19 | isTypeOfFnCall(context, parent, ['test']) 20 | 21 | if (isTestFn) { 22 | count = 0 23 | } 24 | } 25 | 26 | return { 27 | ArrowFunctionExpression: maybeResetCount, 28 | 'ArrowFunctionExpression:exit': maybeResetCount, 29 | CallExpression(node) { 30 | const call = parseFnCall(context, node) 31 | 32 | if ( 33 | call?.type !== 'expect' || 34 | getParent(call.head.node)?.type === 'MemberExpression' 35 | ) { 36 | return 37 | } 38 | 39 | count += 1 40 | 41 | if (count > options.max) { 42 | context.report({ 43 | data: { 44 | count: count.toString(), 45 | max: options.max.toString(), 46 | }, 47 | messageId: 'exceededMaxAssertion', 48 | node, 49 | }) 50 | } 51 | }, 52 | FunctionExpression: maybeResetCount, 53 | 'FunctionExpression:exit': maybeResetCount, 54 | } 55 | }, 56 | meta: { 57 | docs: { 58 | category: 'Best Practices', 59 | description: 'Enforces a maximum number assertion calls in a test body', 60 | recommended: false, 61 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/max-expects.md', 62 | }, 63 | messages: { 64 | exceededMaxAssertion: 65 | 'Too many assertion calls ({{ count }}) - maximum allowed is {{ max }}', 66 | }, 67 | schema: [ 68 | { 69 | additionalProperties: false, 70 | properties: { 71 | max: { 72 | minimum: 1, 73 | type: 'integer', 74 | }, 75 | }, 76 | type: 'object', 77 | }, 78 | ], 79 | type: 'suggestion', 80 | }, 81 | }) 82 | -------------------------------------------------------------------------------- /src/rules/max-nested-describe.ts: -------------------------------------------------------------------------------- 1 | import * as ESTree from 'estree' 2 | import { createRule } from '../utils/createRule.js' 3 | import { isTypeOfFnCall } from '../utils/parseFnCall.js' 4 | 5 | export default createRule({ 6 | create(context) { 7 | const { options } = context 8 | const max: number = options[0]?.max ?? 5 9 | const describes: ESTree.CallExpression[] = [] 10 | 11 | return { 12 | CallExpression(node) { 13 | if (isTypeOfFnCall(context, node, ['describe'])) { 14 | describes.unshift(node) 15 | 16 | if (describes.length > max) { 17 | context.report({ 18 | data: { 19 | depth: describes.length.toString(), 20 | max: max.toString(), 21 | }, 22 | messageId: 'exceededMaxDepth', 23 | node: node.callee, 24 | }) 25 | } 26 | } 27 | }, 28 | 'CallExpression:exit'(node) { 29 | if (describes[0] === node) { 30 | describes.shift() 31 | } 32 | }, 33 | } 34 | }, 35 | meta: { 36 | docs: { 37 | category: 'Best Practices', 38 | description: 'Enforces a maximum depth to nested describe calls', 39 | recommended: true, 40 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/max-nested-describe.md', 41 | }, 42 | messages: { 43 | exceededMaxDepth: 44 | 'Maximum describe call depth exceeded ({{ depth }}). Maximum allowed is {{ max }}.', 45 | }, 46 | schema: [ 47 | { 48 | additionalProperties: false, 49 | properties: { 50 | max: { 51 | minimum: 0, 52 | type: 'integer', 53 | }, 54 | }, 55 | type: 'object', 56 | }, 57 | ], 58 | type: 'suggestion', 59 | }, 60 | }) 61 | -------------------------------------------------------------------------------- /src/rules/no-commented-out-tests.test.ts: -------------------------------------------------------------------------------- 1 | import rule from '../../src/rules/no-commented-out-tests.js' 2 | import { javascript, runRuleTester } from '../utils/rule-tester.js' 3 | 4 | const messageId = 'commentedTests' 5 | 6 | runRuleTester('no-commented-out-tests', rule, { 7 | invalid: [ 8 | { 9 | code: '// test.describe("foo", function () {})', 10 | errors: [{ column: 1, line: 1, messageId }], 11 | }, 12 | { 13 | code: '// describe["skip"]("foo", function () {})', 14 | errors: [{ column: 1, line: 1, messageId }], 15 | }, 16 | { 17 | code: '// describe[\'skip\']("foo", function () {})', 18 | errors: [{ column: 1, line: 1, messageId }], 19 | }, 20 | { 21 | code: '// test("foo", function () {})', 22 | errors: [{ column: 1, line: 1, messageId }], 23 | }, 24 | { 25 | code: '// test.only("foo", function () {})', 26 | errors: [{ column: 1, line: 1, messageId }], 27 | }, 28 | { 29 | code: '// test["skip"]("foo", function () {})', 30 | errors: [{ column: 1, line: 1, messageId }], 31 | }, 32 | { 33 | code: '// test.skip("foo", function () {})', 34 | errors: [{ column: 1, line: 1, messageId }], 35 | }, 36 | { 37 | code: javascript` 38 | // test( 39 | // "foo", function () {} 40 | // ) 41 | `, 42 | errors: [{ column: 1, line: 1, messageId }], 43 | }, 44 | { 45 | code: javascript` 46 | /* test 47 | ( 48 | "foo", function () {} 49 | ) 50 | */ 51 | `, 52 | errors: [{ column: 1, line: 1, messageId }], 53 | }, 54 | { 55 | code: '// test("has title but no callback")', 56 | errors: [{ column: 1, line: 1, messageId }], 57 | }, 58 | { 59 | code: '// test()', 60 | errors: [{ column: 1, line: 1, messageId }], 61 | }, 62 | { 63 | code: '// test.someNewMethodThatMightBeAddedInTheFuture()', 64 | errors: [{ column: 1, line: 1, messageId }], 65 | }, 66 | { 67 | code: '// test["someNewMethodThatMightBeAddedInTheFuture"]()', 68 | errors: [{ column: 1, line: 1, messageId }], 69 | }, 70 | { 71 | code: javascript` 72 | foo() 73 | /* 74 | test.describe("has title but no callback", () => {}) 75 | */ 76 | bar() 77 | `, 78 | errors: [{ column: 1, line: 2, messageId }], 79 | }, 80 | // Global aliases 81 | { 82 | code: '// it("foo", () => {});', 83 | errors: [{ column: 1, line: 1, messageId }], 84 | settings: { 85 | playwright: { 86 | globalAliases: { test: ['it'] }, 87 | }, 88 | }, 89 | }, 90 | ], 91 | valid: [ 92 | '// foo("bar", function () {})', 93 | 'test.describe("foo", function () {})', 94 | 'test("foo", function () {})', 95 | 'test.describe.only("foo", function () {})', 96 | 'test.only("foo", function () {})', 97 | 'test.skip("foo", function () {})', 98 | 'test.concurrent("foo", function () {})', 99 | 'var appliedSkip = describe.skip; appliedSkip.apply(describe)', 100 | 'var calledSkip = test.skip; calledSkip.call(it)', 101 | '({ f: function () {} }).f()', 102 | '(a || b).f()', 103 | 'testHappensToStartWithTest()', 104 | 'testSomething()', 105 | '// latest(dates)', 106 | '// TODO: unify with Git implementation from Shipit (?)', 107 | '#!/usr/bin/env node', 108 | javascript` 109 | import { pending } from "actions" 110 | 111 | test("foo", () => { 112 | expect(pending()).toEqual({}) 113 | }) 114 | `, 115 | javascript` 116 | const { pending } = require("actions") 117 | 118 | test("foo", () => { 119 | expect(pending()).toEqual({}) 120 | }) 121 | `, 122 | javascript` 123 | test("foo", () => { 124 | const pending = getPending() 125 | expect(pending()).toEqual({}) 126 | }) 127 | `, 128 | javascript` 129 | test("foo", () => { 130 | expect(pending()).toEqual({}) 131 | }) 132 | 133 | function pending() { 134 | return {} 135 | } 136 | `, 137 | // Global aliases 138 | { 139 | code: 'it("foo", () => {});', 140 | settings: { 141 | playwright: { 142 | globalAliases: { test: ['it'] }, 143 | }, 144 | }, 145 | }, 146 | ], 147 | }) 148 | -------------------------------------------------------------------------------- /src/rules/no-commented-out-tests.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from 'eslint' 2 | import * as ESTree from 'estree' 3 | import { createRule } from '../utils/createRule.js' 4 | 5 | function getTestNames(context: Rule.RuleContext) { 6 | const aliases = context.settings.playwright?.globalAliases?.test ?? [] 7 | return ['test', ...aliases] 8 | } 9 | 10 | function hasTests(context: Rule.RuleContext, node: ESTree.Comment) { 11 | const testNames = getTestNames(context) 12 | const names = testNames.join('|') 13 | const regex = new RegExp( 14 | `^\\s*(${names}|describe)(\\.\\w+|\\[['"]\\w+['"]\\])?\\s*\\(`, 15 | 'mu', 16 | ) 17 | return regex.test(node.value) 18 | } 19 | 20 | export default createRule({ 21 | create(context) { 22 | function checkNode(node: ESTree.Comment) { 23 | if (!hasTests(context, node)) return 24 | 25 | context.report({ 26 | messageId: 'commentedTests', 27 | node: node as unknown as ESTree.Node, 28 | }) 29 | } 30 | 31 | return { 32 | Program() { 33 | context.sourceCode.getAllComments().forEach(checkNode) 34 | }, 35 | } 36 | }, 37 | meta: { 38 | docs: { 39 | category: 'Best Practices', 40 | description: 'Disallow commented out tests', 41 | recommended: true, 42 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-commented-out-tests.md', 43 | }, 44 | messages: { 45 | commentedTests: 'Some tests seem to be commented', 46 | }, 47 | type: 'problem', 48 | }, 49 | }) 50 | -------------------------------------------------------------------------------- /src/rules/no-conditional-expect.ts: -------------------------------------------------------------------------------- 1 | import { Rule, Scope } from 'eslint' 2 | import * as ESTree from 'estree' 3 | import { getParent, isPropertyAccessor } from '../utils/ast.js' 4 | import { createRule } from '../utils/createRule.js' 5 | import { isTypeOfFnCall, parseFnCall } from '../utils/parseFnCall.js' 6 | import { KnownCallExpression } from '../utils/types.js' 7 | 8 | const isCatchCall = ( 9 | node: ESTree.CallExpression, 10 | ): node is KnownCallExpression => 11 | node.callee.type === 'MemberExpression' && 12 | isPropertyAccessor(node.callee, 'catch') 13 | 14 | const getTestCallExpressionsFromDeclaredVariables = ( 15 | context: Rule.RuleContext, 16 | declaredVariables: readonly Scope.Variable[], 17 | ): ESTree.CallExpression[] => { 18 | return declaredVariables.reduce( 19 | (acc, { references }) => [ 20 | ...acc, 21 | ...references 22 | .map(({ identifier }) => getParent(identifier)) 23 | .filter( 24 | // ESLint types are infurating 25 | (node): node is any => 26 | node?.type === 'CallExpression' && 27 | isTypeOfFnCall(context, node, ['test']), 28 | ), 29 | ], 30 | [] as ESTree.CallExpression[], 31 | ) 32 | } 33 | 34 | export default createRule({ 35 | create(context) { 36 | let conditionalDepth = 0 37 | let inTestCase = false 38 | let inPromiseCatch = false 39 | 40 | const increaseConditionalDepth = () => inTestCase && conditionalDepth++ 41 | const decreaseConditionalDepth = () => inTestCase && conditionalDepth-- 42 | 43 | return { 44 | CallExpression(node: ESTree.CallExpression) { 45 | const call = parseFnCall(context, node) 46 | 47 | if (call?.type === 'test') { 48 | inTestCase = true 49 | } 50 | 51 | if (isCatchCall(node)) { 52 | inPromiseCatch = true 53 | } 54 | 55 | if (inTestCase && call?.type === 'expect' && conditionalDepth > 0) { 56 | context.report({ 57 | messageId: 'conditionalExpect', 58 | node, 59 | }) 60 | } 61 | 62 | if (inPromiseCatch && call?.type === 'expect') { 63 | context.report({ 64 | messageId: 'conditionalExpect', 65 | node, 66 | }) 67 | } 68 | }, 69 | 'CallExpression:exit'(node) { 70 | if (isTypeOfFnCall(context, node, ['test'])) { 71 | inTestCase = false 72 | } 73 | 74 | if (isCatchCall(node)) { 75 | inPromiseCatch = false 76 | } 77 | }, 78 | CatchClause: increaseConditionalDepth, 79 | 'CatchClause:exit': decreaseConditionalDepth, 80 | ConditionalExpression: increaseConditionalDepth, 81 | 'ConditionalExpression:exit': decreaseConditionalDepth, 82 | FunctionDeclaration(node) { 83 | const declaredVariables = context.sourceCode.getDeclaredVariables(node) 84 | const testCallExpressions = getTestCallExpressionsFromDeclaredVariables( 85 | context, 86 | declaredVariables, 87 | ) 88 | 89 | if (testCallExpressions.length > 0) { 90 | inTestCase = true 91 | } 92 | }, 93 | IfStatement: increaseConditionalDepth, 94 | 'IfStatement:exit': decreaseConditionalDepth, 95 | LogicalExpression: increaseConditionalDepth, 96 | 'LogicalExpression:exit': decreaseConditionalDepth, 97 | SwitchStatement: increaseConditionalDepth, 98 | 'SwitchStatement:exit': decreaseConditionalDepth, 99 | } 100 | }, 101 | meta: { 102 | docs: { 103 | category: 'Best Practices', 104 | description: 'Disallow calling `expect` conditionally', 105 | recommended: true, 106 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-conditional-expect.md', 107 | }, 108 | messages: { 109 | conditionalExpect: 'Avoid calling `expect` conditionally`', 110 | }, 111 | type: 'problem', 112 | }, 113 | }) 114 | -------------------------------------------------------------------------------- /src/rules/no-conditional-in-test.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from 'eslint' 2 | import { findParent } from '../utils/ast.js' 3 | import { createRule } from '../utils/createRule.js' 4 | import { isTypeOfFnCall } from '../utils/parseFnCall.js' 5 | 6 | export default createRule({ 7 | create(context) { 8 | function checkConditional(node: Rule.Node & Rule.NodeParentExtension) { 9 | const call = findParent(node, 'CallExpression') 10 | if (!call) return 11 | 12 | if (isTypeOfFnCall(context, call, ['test', 'step'])) { 13 | context.report({ messageId: 'conditionalInTest', node }) 14 | } 15 | } 16 | 17 | return { 18 | ConditionalExpression: checkConditional, 19 | IfStatement: checkConditional, 20 | LogicalExpression: checkConditional, 21 | SwitchStatement: checkConditional, 22 | } 23 | }, 24 | meta: { 25 | docs: { 26 | category: 'Best Practices', 27 | description: 'Disallow conditional logic in tests', 28 | recommended: true, 29 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-conditional-in-test.md', 30 | }, 31 | messages: { 32 | conditionalInTest: 'Avoid having conditionals in tests', 33 | }, 34 | schema: [], 35 | type: 'problem', 36 | }, 37 | }) 38 | -------------------------------------------------------------------------------- /src/rules/no-duplicate-hooks.ts: -------------------------------------------------------------------------------- 1 | import { getStringValue } from '../utils/ast.js' 2 | import { createRule } from '../utils/createRule.js' 3 | import { isTypeOfFnCall, parseFnCall } from '../utils/parseFnCall.js' 4 | 5 | export default createRule({ 6 | create(context) { 7 | const hookContexts: Array> = [{}] 8 | 9 | return { 10 | CallExpression(node) { 11 | const call = parseFnCall(context, node) 12 | if (!call) return 13 | 14 | if (call.type === 'describe') { 15 | hookContexts.push({}) 16 | } 17 | 18 | if (call.type !== 'hook') { 19 | return 20 | } 21 | 22 | const currentLayer = hookContexts[hookContexts.length - 1] 23 | const name = 24 | node.callee.type === 'MemberExpression' 25 | ? getStringValue(node.callee.property) 26 | : '' 27 | 28 | currentLayer[name] ||= 0 29 | currentLayer[name] += 1 30 | 31 | if (currentLayer[name] > 1) { 32 | context.report({ 33 | data: { hook: name }, 34 | messageId: 'noDuplicateHook', 35 | node, 36 | }) 37 | } 38 | }, 39 | 'CallExpression:exit'(node) { 40 | if (isTypeOfFnCall(context, node, ['describe'])) { 41 | hookContexts.pop() 42 | } 43 | }, 44 | } 45 | }, 46 | meta: { 47 | docs: { 48 | category: 'Best Practices', 49 | description: 'Disallow duplicate setup and teardown hooks', 50 | recommended: false, 51 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-duplicate-hooks.md', 52 | }, 53 | messages: { 54 | noDuplicateHook: 'Duplicate {{ hook }} in describe block', 55 | }, 56 | type: 'suggestion', 57 | }, 58 | }) 59 | -------------------------------------------------------------------------------- /src/rules/no-element-handle.ts: -------------------------------------------------------------------------------- 1 | import { AST } from 'eslint' 2 | import ESTree from 'estree' 3 | import { isPageMethod } from '../utils/ast.js' 4 | import { createRule } from '../utils/createRule.js' 5 | 6 | function getPropertyRange(node: ESTree.Node): AST.Range { 7 | return node.type === 'Identifier' 8 | ? node.range! 9 | : [node.range![0] + 1, node.range![1] - 1] 10 | } 11 | 12 | export default createRule({ 13 | create(context) { 14 | return { 15 | CallExpression(node) { 16 | if (isPageMethod(node, '$') || isPageMethod(node, '$$')) { 17 | context.report({ 18 | messageId: 'noElementHandle', 19 | node: node.callee, 20 | suggest: [ 21 | { 22 | fix: (fixer) => { 23 | const { property } = node.callee as ESTree.MemberExpression 24 | 25 | // Replace $/$$ with locator 26 | const fixes = [ 27 | fixer.replaceTextRange( 28 | getPropertyRange(property), 29 | 'locator', 30 | ), 31 | ] 32 | 33 | // Remove the await expression if it exists as locators do 34 | // not need to be awaited. 35 | if (node.parent.type === 'AwaitExpression') { 36 | fixes.push( 37 | fixer.removeRange([ 38 | node.parent.range![0], 39 | node.range![0], 40 | ]), 41 | ) 42 | } 43 | 44 | return fixes 45 | }, 46 | messageId: isPageMethod(node, '$') 47 | ? 'replaceElementHandleWithLocator' 48 | : 'replaceElementHandlesWithLocator', 49 | }, 50 | ], 51 | }) 52 | } 53 | }, 54 | } 55 | }, 56 | meta: { 57 | docs: { 58 | category: 'Possible Errors', 59 | description: 60 | 'The use of ElementHandle is discouraged, use Locator instead', 61 | recommended: true, 62 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-element-handle.md', 63 | }, 64 | hasSuggestions: true, 65 | messages: { 66 | noElementHandle: 'Unexpected use of element handles.', 67 | replaceElementHandlesWithLocator: 'Replace `page.$$` with `page.locator`', 68 | replaceElementHandleWithLocator: 'Replace `page.$` with `page.locator`', 69 | }, 70 | type: 'suggestion', 71 | }, 72 | }) 73 | -------------------------------------------------------------------------------- /src/rules/no-eval.ts: -------------------------------------------------------------------------------- 1 | import { isPageMethod } from '../utils/ast.js' 2 | import { createRule } from '../utils/createRule.js' 3 | 4 | export default createRule({ 5 | create(context) { 6 | return { 7 | CallExpression(node) { 8 | const isEval = isPageMethod(node, '$eval') 9 | 10 | if (isEval || isPageMethod(node, '$$eval')) { 11 | context.report({ 12 | messageId: isEval ? 'noEval' : 'noEvalAll', 13 | node: node.callee, 14 | }) 15 | } 16 | }, 17 | } 18 | }, 19 | meta: { 20 | docs: { 21 | category: 'Possible Errors', 22 | description: 23 | 'The use of `page.$eval` and `page.$$eval` are discouraged, use `locator.evaluate` or `locator.evaluateAll` instead', 24 | recommended: true, 25 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-eval.md', 26 | }, 27 | messages: { 28 | noEval: 'Unexpected use of page.$eval().', 29 | noEvalAll: 'Unexpected use of page.$$eval().', 30 | }, 31 | type: 'problem', 32 | }, 33 | }) 34 | -------------------------------------------------------------------------------- /src/rules/no-focused-test.ts: -------------------------------------------------------------------------------- 1 | import { getStringValue } from '../utils/ast.js' 2 | import { createRule } from '../utils/createRule.js' 3 | import { parseFnCall } from '../utils/parseFnCall.js' 4 | 5 | export default createRule({ 6 | create(context) { 7 | return { 8 | CallExpression(node) { 9 | const call = parseFnCall(context, node) 10 | if (call?.type !== 'test' && call?.type !== 'describe') { 11 | return 12 | } 13 | 14 | const onlyNode = call.members.find((s) => getStringValue(s) === 'only') 15 | if (!onlyNode) return 16 | 17 | context.report({ 18 | messageId: 'noFocusedTest', 19 | node: onlyNode, 20 | suggest: [ 21 | { 22 | fix: (fixer) => { 23 | // - 1 to remove the `.only` annotation with dot notation 24 | return fixer.removeRange([ 25 | onlyNode.range![0] - 1, 26 | onlyNode.range![1] + Number(onlyNode.type !== 'Identifier'), 27 | ]) 28 | }, 29 | messageId: 'suggestRemoveOnly', 30 | }, 31 | ], 32 | }) 33 | }, 34 | } 35 | }, 36 | meta: { 37 | docs: { 38 | category: 'Possible Errors', 39 | description: 'Prevent usage of `.only()` focus test annotation', 40 | recommended: true, 41 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-focused-test.md', 42 | }, 43 | hasSuggestions: true, 44 | messages: { 45 | noFocusedTest: 'Unexpected focused test.', 46 | suggestRemoveOnly: 'Remove .only() annotation.', 47 | }, 48 | type: 'problem', 49 | }, 50 | }) 51 | -------------------------------------------------------------------------------- /src/rules/no-force-option.test.ts: -------------------------------------------------------------------------------- 1 | import rule from '../../src/rules/no-force-option.js' 2 | import { runRuleTester, test } from '../utils/rule-tester.js' 3 | 4 | const messageId = 'noForceOption' 5 | 6 | runRuleTester('no-force-option', rule, { 7 | invalid: [ 8 | { 9 | code: test('await page.locator("check").check({ force: true })'), 10 | errors: [{ column: 64, endColumn: 75, line: 1, messageId }], 11 | }, 12 | { 13 | code: test('await page.locator("check").uncheck({ ["force"]: true })'), 14 | errors: [{ column: 66, endColumn: 81, line: 1, messageId }], 15 | }, 16 | { 17 | code: test('await page.locator("button").click({ [`force`]: true })'), 18 | errors: [{ column: 65, endColumn: 80, line: 1, messageId }], 19 | }, 20 | { 21 | code: test(` 22 | const button = page["locator"]("button") 23 | await button.click({ force: true }) 24 | `), 25 | errors: [{ column: 30, endColumn: 41, endLine: 3, line: 3, messageId }], 26 | }, 27 | { 28 | code: test( 29 | 'await page[`locator`]("button").locator("btn").click({ force: true })', 30 | ), 31 | errors: [{ column: 83, endColumn: 94, line: 1, messageId }], 32 | }, 33 | { 34 | code: test('await page.locator("button").dblclick({ force: true })'), 35 | errors: [{ column: 68, endColumn: 79, line: 1, messageId }], 36 | }, 37 | { 38 | code: test('await page.locator("input").dragTo({ force: true })'), 39 | errors: [{ column: 65, endColumn: 76, line: 1, messageId }], 40 | }, 41 | { 42 | code: test('await page.locator("input").fill("test", { force: true })'), 43 | errors: [{ column: 71, endColumn: 82, line: 1, messageId }], 44 | }, 45 | { 46 | code: test( 47 | 'await page[`locator`]("input").fill("test", { ["force"]: true })', 48 | ), 49 | errors: [{ column: 74, endColumn: 89, line: 1, messageId }], 50 | }, 51 | { 52 | code: test( 53 | 'await page["locator"]("input").fill("test", { [`force`]: true })', 54 | ), 55 | errors: [{ column: 74, endColumn: 89, line: 1, messageId }], 56 | }, 57 | { 58 | code: test('await page.locator("elm").hover({ force: true })'), 59 | errors: [{ column: 62, endColumn: 73, line: 1, messageId }], 60 | }, 61 | { 62 | code: test( 63 | 'await page.locator("select").selectOption({ label: "Blue" }, { force: true })', 64 | ), 65 | errors: [{ column: 91, endColumn: 102, line: 1, messageId }], 66 | }, 67 | { 68 | code: test('await page.locator("select").selectText({ force: true })'), 69 | errors: [{ column: 70, endColumn: 81, line: 1, messageId }], 70 | }, 71 | { 72 | code: test( 73 | 'await page.locator("checkbox").setChecked(true, { force: true })', 74 | ), 75 | errors: [{ column: 78, endColumn: 89, line: 1, messageId }], 76 | }, 77 | { 78 | code: test('await page.locator("button").tap({ force: true })'), 79 | errors: [{ column: 63, endColumn: 74, line: 1, messageId }], 80 | }, 81 | ], 82 | valid: [ 83 | test("await page.locator('check').check()"), 84 | test("await page.locator('check').uncheck()"), 85 | test("await page.locator('button').click()"), 86 | test("await page.locator('button').locator('btn').click()"), 87 | test( 88 | "await page.locator('button').click({ delay: 500, noWaitAfter: true })", 89 | ), 90 | test("await page.locator('button').dblclick()"), 91 | test("await page.locator('input').dragTo()"), 92 | test("await page.locator('input').fill('something', { timeout: 1000 })"), 93 | test("await page.locator('elm').hover()"), 94 | test("await page.locator('select').selectOption({ label: 'Blue' })"), 95 | test("await page.locator('select').selectText()"), 96 | test("await page.locator('checkbox').setChecked(true)"), 97 | test("await page.locator('button').tap()"), 98 | test('doSomething({ force: true })'), 99 | test('await doSomething({ ["force"]: true })'), 100 | test('await doSomething({ [`force`]: true })'), 101 | ], 102 | }) 103 | -------------------------------------------------------------------------------- /src/rules/no-force-option.ts: -------------------------------------------------------------------------------- 1 | import ESTree from 'estree' 2 | import { getStringValue, isBooleanLiteral } from '../utils/ast.js' 3 | import { createRule } from '../utils/createRule.js' 4 | 5 | function isForceOptionEnabled(node: ESTree.CallExpression) { 6 | const arg = node.arguments.at(-1) 7 | 8 | return ( 9 | arg?.type === 'ObjectExpression' && 10 | arg.properties.find( 11 | (property) => 12 | property.type === 'Property' && 13 | getStringValue(property.key) === 'force' && 14 | isBooleanLiteral(property.value, true), 15 | ) 16 | ) 17 | } 18 | 19 | // https://playwright.dev/docs/api/class-locator 20 | const methodsWithForceOption = new Set([ 21 | 'check', 22 | 'uncheck', 23 | 'click', 24 | 'dblclick', 25 | 'dragTo', 26 | 'fill', 27 | 'hover', 28 | 'selectOption', 29 | 'selectText', 30 | 'setChecked', 31 | 'tap', 32 | ]) 33 | 34 | export default createRule({ 35 | create(context) { 36 | return { 37 | MemberExpression(node) { 38 | if ( 39 | methodsWithForceOption.has(getStringValue(node.property)) && 40 | node.parent.type === 'CallExpression' 41 | ) { 42 | const reportNode = isForceOptionEnabled(node.parent) 43 | 44 | if (reportNode) { 45 | context.report({ messageId: 'noForceOption', node: reportNode }) 46 | } 47 | } 48 | }, 49 | } 50 | }, 51 | meta: { 52 | docs: { 53 | category: 'Best Practices', 54 | description: 'Prevent usage of `{ force: true }` option.', 55 | recommended: true, 56 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-force-option.md', 57 | }, 58 | messages: { 59 | noForceOption: 'Unexpected use of { force: true } option.', 60 | }, 61 | type: 'suggestion', 62 | }, 63 | }) 64 | -------------------------------------------------------------------------------- /src/rules/no-get-by-title.test.ts: -------------------------------------------------------------------------------- 1 | import rule from '../../src/rules/no-get-by-title.js' 2 | import { runRuleTester, test } from '../utils/rule-tester.js' 3 | 4 | const messageId = 'noGetByTitle' 5 | 6 | runRuleTester('no-get-by-title', rule, { 7 | invalid: [ 8 | { 9 | code: test('await page.getByTitle("lorem ipsum")'), 10 | errors: [{ column: 34, endColumn: 64, line: 1, messageId }], 11 | }, 12 | { 13 | code: test('await this.page.getByTitle("lorem ipsum")'), 14 | errors: [{ column: 34, endColumn: 69, line: 1, messageId }], 15 | }, 16 | ], 17 | valid: [ 18 | test('await page.locator("[title=lorem ipsum]")'), 19 | test('await page.getByRole("button")'), 20 | ], 21 | }) 22 | -------------------------------------------------------------------------------- /src/rules/no-get-by-title.ts: -------------------------------------------------------------------------------- 1 | import { isPageMethod } from '../utils/ast.js' 2 | import { createRule } from '../utils/createRule.js' 3 | 4 | export default createRule({ 5 | create(context) { 6 | return { 7 | CallExpression(node) { 8 | if (isPageMethod(node, 'getByTitle')) { 9 | context.report({ messageId: 'noGetByTitle', node }) 10 | } 11 | }, 12 | } 13 | }, 14 | meta: { 15 | docs: { 16 | category: 'Best Practices', 17 | description: 'Disallows the usage of getByTitle()', 18 | recommended: false, 19 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-get-by-title.md', 20 | }, 21 | messages: { 22 | noGetByTitle: 23 | 'The HTML title attribute is not an accessible name. Prefer getByRole() or getByLabelText() instead.', 24 | }, 25 | type: 'suggestion', 26 | }, 27 | }) 28 | -------------------------------------------------------------------------------- /src/rules/no-hooks.test.ts: -------------------------------------------------------------------------------- 1 | import rule from '../../src/rules/no-hooks.js' 2 | import { runRuleTester } from '../utils/rule-tester.js' 3 | 4 | const messageId = 'unexpectedHook' 5 | 6 | runRuleTester('no-hooks', rule, { 7 | invalid: [ 8 | { 9 | code: 'test.beforeAll(() => {})', 10 | errors: [{ data: { hookName: 'beforeAll' }, messageId }], 11 | }, 12 | { 13 | code: 'test.beforeEach(() => {})', 14 | errors: [ 15 | { 16 | data: { hookName: 'beforeEach' }, 17 | messageId, 18 | }, 19 | ], 20 | }, 21 | { 22 | code: 'test.afterAll(() => {})', 23 | errors: [{ data: { hookName: 'afterAll' }, messageId }], 24 | }, 25 | { 26 | code: 'test.afterEach(() => {})', 27 | errors: [{ data: { hookName: 'afterEach' }, messageId }], 28 | }, 29 | { 30 | code: 'test.beforeEach(() => {}); afterEach(() => { doStuff() });', 31 | errors: [ 32 | { 33 | data: { hookName: 'beforeEach' }, 34 | messageId, 35 | }, 36 | ], 37 | options: [{ allow: ['afterEach'] }], 38 | }, 39 | ], 40 | valid: [ 41 | 'test("foo")', 42 | 'test.describe("foo", () => { test("bar") })', 43 | 'test("foo", () => { expect(subject.beforeEach()).toBe(true) })', 44 | { 45 | code: 'test.afterEach(() => {}); afterAll(() => {});', 46 | options: [{ allow: ['afterEach', 'afterAll'] }], 47 | }, 48 | { code: 'test("foo")', options: [{ allow: undefined }] }, 49 | ], 50 | }) 51 | -------------------------------------------------------------------------------- /src/rules/no-hooks.ts: -------------------------------------------------------------------------------- 1 | import { createRule } from '../utils/createRule.js' 2 | import { parseFnCall } from '../utils/parseFnCall.js' 3 | 4 | export default createRule({ 5 | create(context) { 6 | const options = { 7 | allow: [] as string[], 8 | ...((context.options?.[0] as Record) ?? {}), 9 | } 10 | 11 | return { 12 | CallExpression(node) { 13 | const call = parseFnCall(context, node) 14 | if (!call) return 15 | 16 | if (call.type === 'hook' && !options.allow.includes(call.name)) { 17 | context.report({ 18 | data: { hookName: call.name }, 19 | messageId: 'unexpectedHook', 20 | node, 21 | }) 22 | } 23 | }, 24 | } 25 | }, 26 | meta: { 27 | docs: { 28 | category: 'Best Practices', 29 | description: 'Disallow setup and teardown hooks', 30 | recommended: false, 31 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-hooks.md', 32 | }, 33 | messages: { 34 | unexpectedHook: "Unexpected '{{ hookName }}' hook", 35 | }, 36 | schema: [ 37 | { 38 | additionalProperties: false, 39 | properties: { 40 | allow: { 41 | contains: ['beforeAll', 'beforeEach', 'afterAll', 'afterEach'], 42 | type: 'array', 43 | }, 44 | }, 45 | type: 'object', 46 | }, 47 | ], 48 | type: 'suggestion', 49 | }, 50 | }) 51 | -------------------------------------------------------------------------------- /src/rules/no-nested-step.test.ts: -------------------------------------------------------------------------------- 1 | import rule from '../../src/rules/no-nested-step.js' 2 | import { javascript, runRuleTester } from '../utils/rule-tester.js' 3 | 4 | const messageId = 'noNestedStep' 5 | 6 | runRuleTester('max-nested-step', rule, { 7 | invalid: [ 8 | { 9 | code: javascript` 10 | test('foo', async () => { 11 | await test.step("step1", async () => { 12 | await test.step("nested step1", async () => { 13 | await expect(true).toBe(true); 14 | }); 15 | }); 16 | }); 17 | `, 18 | errors: [{ column: 11, endColumn: 20, endLine: 3, line: 3, messageId }], 19 | }, 20 | { 21 | code: javascript` 22 | test('foo', async () => { 23 | await test.step("step1", async () => { 24 | await test.step("nested step1", async () => { 25 | await expect(true).toBe(true); 26 | }); 27 | await test.step("nested step1", async () => { 28 | await expect(true).toBe(true); 29 | }); 30 | }); 31 | }); 32 | `, 33 | errors: [ 34 | { column: 11, endColumn: 20, endLine: 3, line: 3, messageId }, 35 | { column: 11, endColumn: 20, endLine: 6, line: 6, messageId }, 36 | ], 37 | }, 38 | { 39 | code: javascript` 40 | test('foo', async () => { 41 | await test.step("step1", async () => { 42 | await test.step("nested step1", async () => { 43 | await expect(true).toBe(true); 44 | }); 45 | await test.step.skip("nested step2", async () => { 46 | await expect(true).toBe(true); 47 | }); 48 | }); 49 | }); 50 | `, 51 | errors: [ 52 | { column: 11, endColumn: 20, endLine: 3, line: 3, messageId }, 53 | { column: 11, endColumn: 25, endLine: 6, line: 6, messageId }, 54 | ], 55 | }, 56 | // Global aliases 57 | { 58 | code: javascript` 59 | it('foo', async () => { 60 | await it.step("step1", async () => { 61 | await it.step("nested step1", async () => { 62 | await expect(true).toBe(true); 63 | }); 64 | }); 65 | }); 66 | `, 67 | errors: [{ column: 11, endColumn: 18, endLine: 3, line: 3, messageId }], 68 | settings: { 69 | playwright: { 70 | globalAliases: { test: ['it'] }, 71 | }, 72 | }, 73 | }, 74 | ], 75 | valid: [ 76 | 'await test.step("step1", () => {});', 77 | 'await test.step("step1", async () => {});', 78 | 'await test.step.skip("step1", async () => {});', 79 | { 80 | code: javascript` 81 | test('foo', async () => { 82 | await expect(true).toBe(true); 83 | }); 84 | `, 85 | }, 86 | { 87 | code: javascript` 88 | test('foo', async () => { 89 | await test.step("step1", async () => { 90 | await expect(true).toBe(true); 91 | }); 92 | }); 93 | `, 94 | }, 95 | { 96 | code: javascript` 97 | test('foo', async () => { 98 | await test.step("step1", async () => { 99 | await expect(true).toBe(true); 100 | }); 101 | await test.step("step2", async () => { 102 | await expect(true).toBe(true); 103 | }); 104 | }); 105 | `, 106 | }, 107 | { 108 | code: javascript` 109 | test('foo', async () => { 110 | await test.step("step1", async () => { 111 | await expect(true).toBe(true); 112 | }); 113 | await test.step.skip("step2", async () => { 114 | await expect(true).toBe(true); 115 | }); 116 | }); 117 | `, 118 | }, 119 | // Global aliases 120 | { 121 | code: 'await it.step("step1", () => {});', 122 | settings: { 123 | playwright: { 124 | globalAliases: { test: ['it'] }, 125 | }, 126 | }, 127 | }, 128 | ], 129 | }) 130 | -------------------------------------------------------------------------------- /src/rules/no-nested-step.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from 'eslint' 2 | import { createRule } from '../utils/createRule.js' 3 | import { isTypeOfFnCall } from '../utils/parseFnCall.js' 4 | 5 | export default createRule({ 6 | create(context) { 7 | const stack: number[] = [] 8 | 9 | function pushStepCallback(node: Rule.Node) { 10 | if ( 11 | node.parent.type !== 'CallExpression' || 12 | !isTypeOfFnCall(context, node.parent, ['step']) 13 | ) { 14 | return 15 | } 16 | 17 | stack.push(0) 18 | 19 | if (stack.length > 1) { 20 | context.report({ 21 | messageId: 'noNestedStep', 22 | node: node.parent.callee, 23 | }) 24 | } 25 | } 26 | 27 | function popStepCallback(node: Rule.Node) { 28 | const { parent } = node 29 | 30 | if ( 31 | parent.type === 'CallExpression' && 32 | isTypeOfFnCall(context, parent, ['step']) 33 | ) { 34 | stack.pop() 35 | } 36 | } 37 | 38 | return { 39 | ArrowFunctionExpression: pushStepCallback, 40 | 'ArrowFunctionExpression:exit': popStepCallback, 41 | FunctionExpression: pushStepCallback, 42 | 'FunctionExpression:exit': popStepCallback, 43 | } 44 | }, 45 | meta: { 46 | docs: { 47 | category: 'Best Practices', 48 | description: 'Disallow nested `test.step()` methods', 49 | recommended: true, 50 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-nested-step.md', 51 | }, 52 | messages: { 53 | noNestedStep: 'Do not nest `test.step()` methods.', 54 | }, 55 | schema: [], 56 | type: 'problem', 57 | }, 58 | }) 59 | -------------------------------------------------------------------------------- /src/rules/no-networkidle.test.ts: -------------------------------------------------------------------------------- 1 | import rule from '../../src/rules/no-networkidle.js' 2 | import { runRuleTester } from '../utils/rule-tester.js' 3 | 4 | const messageId = 'noNetworkidle' 5 | 6 | runRuleTester('no-networkidle', rule, { 7 | invalid: [ 8 | { 9 | code: 'page.waitForLoadState("networkidle")', 10 | errors: [{ column: 23, endColumn: 36, line: 1, messageId }], 11 | }, 12 | { 13 | code: 'page.waitForURL(url, { waitUntil: "networkidle" })', 14 | errors: [{ column: 35, endColumn: 48, line: 1, messageId }], 15 | }, 16 | { 17 | code: 'page["waitForURL"](url, { waitUntil: "networkidle" })', 18 | errors: [{ column: 38, endColumn: 51, line: 1, messageId }], 19 | }, 20 | { 21 | code: 'page[`waitForURL`](url, { waitUntil: "networkidle" })', 22 | errors: [{ column: 38, endColumn: 51, line: 1, messageId }], 23 | }, 24 | { 25 | code: 'page.goto(url, { waitUntil: "networkidle" })', 26 | errors: [{ column: 29, endColumn: 42, line: 1, messageId }], 27 | }, 28 | { 29 | code: 'page.reload(url, { waitUntil: "networkidle" })', 30 | errors: [{ column: 31, endColumn: 44, line: 1, messageId }], 31 | }, 32 | { 33 | code: 'page.setContent(url, { waitUntil: "networkidle" })', 34 | errors: [{ column: 35, endColumn: 48, line: 1, messageId }], 35 | }, 36 | { 37 | code: 'page.goBack(url, { waitUntil: "networkidle" })', 38 | errors: [{ column: 31, endColumn: 44, line: 1, messageId }], 39 | }, 40 | { 41 | code: 'page.goForward(url, { waitUntil: "networkidle" })', 42 | errors: [{ column: 34, endColumn: 47, line: 1, messageId }], 43 | }, 44 | ], 45 | valid: [ 46 | 'foo("networkidle")', 47 | 'foo(url, { waitUntil: "networkidle" })', 48 | 'foo.bar("networkidle")', 49 | 'foo.bar(url, { waitUntil: "networkidle" })', 50 | 'page.hi("networkidle")', 51 | 'page.hi(url, { waitUntil: "networkidle" })', 52 | 'frame.hi("networkidle")', 53 | 'frame.hi(url, { waitUntil: "networkidle" })', 54 | 55 | // Other options are valid 56 | 57 | 'this.page.waitForLoadState()', 58 | 'page.waitForLoadState({ waitUntil: "load" })', 59 | 'page.waitForURL(url, { waitUntil: "load" })', 60 | ], 61 | }) 62 | -------------------------------------------------------------------------------- /src/rules/no-networkidle.ts: -------------------------------------------------------------------------------- 1 | import ESTree from 'estree' 2 | import { getStringValue, isStringLiteral } from '../utils/ast.js' 3 | import { createRule } from '../utils/createRule.js' 4 | 5 | const messageId = 'noNetworkidle' 6 | const methods = new Set([ 7 | 'goBack', 8 | 'goForward', 9 | 'goto', 10 | 'reload', 11 | 'setContent', 12 | 'waitForLoadState', 13 | 'waitForURL', 14 | ]) 15 | 16 | export default createRule({ 17 | create(context) { 18 | return { 19 | CallExpression(node) { 20 | if (node.callee.type !== 'MemberExpression') return 21 | 22 | const methodName = getStringValue(node.callee.property) 23 | if (!methods.has(methodName)) return 24 | 25 | // waitForLoadState has a single string argument 26 | if (methodName === 'waitForLoadState') { 27 | const arg = node.arguments[0] 28 | 29 | if (arg && isStringLiteral(arg, 'networkidle')) { 30 | context.report({ messageId, node: arg }) 31 | } 32 | 33 | return 34 | } 35 | 36 | // All other methods have an options object 37 | if (node.arguments.length >= 2) { 38 | const [_, arg] = node.arguments 39 | if (arg.type !== 'ObjectExpression') return 40 | 41 | const property = arg.properties 42 | .filter((p): p is ESTree.Property => p.type === 'Property') 43 | .find((p) => isStringLiteral(p.value, 'networkidle')) 44 | 45 | if (property) { 46 | context.report({ messageId, node: property.value }) 47 | } 48 | } 49 | }, 50 | } 51 | }, 52 | meta: { 53 | docs: { 54 | category: 'Possible Errors', 55 | description: 'Prevent usage of the networkidle option', 56 | recommended: true, 57 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-networkidle.md', 58 | }, 59 | messages: { 60 | noNetworkidle: 'Unexpected use of networkidle.', 61 | }, 62 | type: 'problem', 63 | }, 64 | }) 65 | -------------------------------------------------------------------------------- /src/rules/no-nth-methods.test.ts: -------------------------------------------------------------------------------- 1 | import rule from '../../src/rules/no-nth-methods.js' 2 | import { runRuleTester } from '../utils/rule-tester.js' 3 | 4 | const messageId = 'noNthMethod' 5 | 6 | runRuleTester('no-nth-methods', rule, { 7 | invalid: [ 8 | // First 9 | { 10 | code: 'page.locator("button").first()', 11 | errors: [{ column: 24, endColumn: 31, line: 1, messageId }], 12 | }, 13 | { 14 | code: 'frame.locator("button").first()', 15 | errors: [{ column: 25, endColumn: 32, line: 1, messageId }], 16 | }, 17 | { 18 | code: 'foo.locator("button").first()', 19 | errors: [{ column: 23, endColumn: 30, line: 1, messageId }], 20 | }, 21 | { 22 | code: 'foo.first()', 23 | errors: [{ column: 5, endColumn: 12, line: 1, messageId }], 24 | }, 25 | 26 | // Last 27 | { 28 | code: 'page.locator("button").last()', 29 | errors: [{ column: 24, endColumn: 30, line: 1, messageId }], 30 | }, 31 | { 32 | code: 'frame.locator("button").last()', 33 | errors: [{ column: 25, endColumn: 31, line: 1, messageId }], 34 | }, 35 | { 36 | code: 'foo.locator("button").last()', 37 | errors: [{ column: 23, endColumn: 29, line: 1, messageId }], 38 | }, 39 | { 40 | code: 'foo.last()', 41 | errors: [{ column: 5, endColumn: 11, line: 1, messageId }], 42 | }, 43 | 44 | // nth 45 | { 46 | code: 'page.locator("button").nth(3)', 47 | errors: [{ column: 24, endColumn: 30, line: 1, messageId }], 48 | }, 49 | { 50 | code: 'frame.locator("button").nth(3)', 51 | errors: [{ column: 25, endColumn: 31, line: 1, messageId }], 52 | }, 53 | { 54 | code: 'foo.locator("button").nth(3)', 55 | errors: [{ column: 23, endColumn: 29, line: 1, messageId }], 56 | }, 57 | { 58 | code: 'foo.nth(32)', 59 | errors: [{ column: 5, endColumn: 12, line: 1, messageId }], 60 | }, 61 | ], 62 | valid: [ 63 | 'page', 64 | 'page.locator("button")', 65 | 'frame.locator("button")', 66 | 'foo.locator("button")', 67 | 68 | 'page.locator("button").click()', 69 | 'frame.locator("button").click()', 70 | 'foo.locator("button").click()', 71 | 'foo.click()', 72 | ], 73 | }) 74 | -------------------------------------------------------------------------------- /src/rules/no-nth-methods.ts: -------------------------------------------------------------------------------- 1 | import { getStringValue } from '../utils/ast.js' 2 | import { createRule } from '../utils/createRule.js' 3 | 4 | const methods = new Set(['first', 'last', 'nth']) 5 | 6 | export default createRule({ 7 | create(context) { 8 | return { 9 | CallExpression(node) { 10 | if (node.callee.type !== 'MemberExpression') return 11 | 12 | const method = getStringValue(node.callee.property) 13 | if (!methods.has(method)) return 14 | 15 | context.report({ 16 | data: { method }, 17 | loc: { 18 | end: node.loc!.end, 19 | start: node.callee.property.loc!.start, 20 | }, 21 | messageId: 'noNthMethod', 22 | }) 23 | }, 24 | } 25 | }, 26 | meta: { 27 | docs: { 28 | category: 'Best Practices', 29 | description: 'Disallow usage of nth methods', 30 | recommended: true, 31 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-nth-methods.md', 32 | }, 33 | messages: { 34 | noNthMethod: 'Unexpected use of {{method}}()', 35 | }, 36 | type: 'problem', 37 | }, 38 | }) 39 | -------------------------------------------------------------------------------- /src/rules/no-page-pause.test.ts: -------------------------------------------------------------------------------- 1 | import rule from '../../src/rules/no-page-pause.js' 2 | import { runRuleTester, test } from '../utils/rule-tester.js' 3 | 4 | const messageId = 'noPagePause' 5 | 6 | runRuleTester('no-page-pause', rule, { 7 | invalid: [ 8 | { 9 | code: test('await page.pause()'), 10 | errors: [{ column: 34, endColumn: 46, line: 1, messageId }], 11 | }, 12 | { 13 | code: test('await this.page.pause()'), 14 | errors: [{ column: 34, endColumn: 51, line: 1, messageId }], 15 | }, 16 | { 17 | code: test('await page["pause"]()'), 18 | errors: [{ column: 34, endColumn: 49, line: 1, messageId }], 19 | }, 20 | { 21 | code: test('await page[`pause`]()'), 22 | errors: [{ column: 34, endColumn: 49, line: 1, messageId }], 23 | }, 24 | ], 25 | valid: [ 26 | test('await page.click()'), 27 | test('await this.page.click()'), 28 | test('await page["hover"]()'), 29 | test('await page[`check`]()'), 30 | test('await expect(page).toBePaused()'), 31 | ], 32 | }) 33 | -------------------------------------------------------------------------------- /src/rules/no-page-pause.ts: -------------------------------------------------------------------------------- 1 | import { isPageMethod } from '../utils/ast.js' 2 | import { createRule } from '../utils/createRule.js' 3 | 4 | export default createRule({ 5 | create(context) { 6 | return { 7 | CallExpression(node) { 8 | if (isPageMethod(node, 'pause')) { 9 | context.report({ messageId: 'noPagePause', node }) 10 | } 11 | }, 12 | } 13 | }, 14 | meta: { 15 | docs: { 16 | category: 'Possible Errors', 17 | description: 'Prevent usage of page.pause()', 18 | recommended: true, 19 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-page-pause.md', 20 | }, 21 | messages: { 22 | noPagePause: 'Unexpected use of page.pause().', 23 | }, 24 | type: 'problem', 25 | }, 26 | }) 27 | -------------------------------------------------------------------------------- /src/rules/no-raw-locators.ts: -------------------------------------------------------------------------------- 1 | import { getStringValue, isPageMethod } from '../utils/ast.js' 2 | import { createRule } from '../utils/createRule.js' 3 | 4 | /** Normalize data attribute locators */ 5 | function normalize(str: string) { 6 | const match = /\[([^=]+?)=['"]?([^'"]+?)['"]?\]/.exec(str) 7 | return match ? `[${match[1]}=${match[2]}]` : str 8 | } 9 | 10 | export default createRule({ 11 | create(context) { 12 | const options = { 13 | allowed: [] as string[], 14 | ...((context.options?.[0] as Record) ?? {}), 15 | } 16 | 17 | function isAllowed(arg: string) { 18 | return options.allowed.some((a) => normalize(a) === normalize(arg)) 19 | } 20 | 21 | return { 22 | CallExpression(node) { 23 | if (node.callee.type !== 'MemberExpression') return 24 | const method = getStringValue(node.callee.property) 25 | const arg = getStringValue(node.arguments[0]) 26 | const isLocator = isPageMethod(node, 'locator') || method === 'locator' 27 | 28 | if (isLocator && !isAllowed(arg)) { 29 | context.report({ messageId: 'noRawLocator', node }) 30 | } 31 | }, 32 | } 33 | }, 34 | meta: { 35 | docs: { 36 | category: 'Best Practices', 37 | description: 'Disallows the usage of raw locators', 38 | recommended: false, 39 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-raw-locators.md', 40 | }, 41 | messages: { 42 | noRawLocator: 43 | 'Usage of raw locator detected. Use methods like .getByRole() or .getByText() instead of raw locators.', 44 | }, 45 | schema: [ 46 | { 47 | additionalProperties: false, 48 | properties: { 49 | allowed: { 50 | items: { type: 'string' }, 51 | type: 'array', 52 | }, 53 | }, 54 | type: 'object', 55 | }, 56 | ], 57 | type: 'suggestion', 58 | }, 59 | }) 60 | -------------------------------------------------------------------------------- /src/rules/no-restricted-matchers.ts: -------------------------------------------------------------------------------- 1 | import { getStringValue } from '../utils/ast.js' 2 | import { createRule } from '../utils/createRule.js' 3 | import { parseFnCall } from '../utils/parseFnCall.js' 4 | 5 | export default createRule({ 6 | create(context) { 7 | const restrictedChains = (context.options?.[0] ?? {}) as { 8 | [key: string]: string | null 9 | } 10 | 11 | return { 12 | CallExpression(node) { 13 | const call = parseFnCall(context, node) 14 | if (call?.type !== 'expect') return 15 | 16 | Object.entries(restrictedChains) 17 | .map(([restriction, message]) => { 18 | const chain = call.members 19 | const restrictionLinks = restriction.split('.').length 20 | 21 | // Find in the full chain, where the restriction chain starts 22 | const startIndex = chain.findIndex((_, i) => { 23 | // Construct the partial chain to compare against the restriction 24 | // chain string. 25 | const partial = chain 26 | .slice(i, i + restrictionLinks) 27 | .map(getStringValue) 28 | .join('.') 29 | 30 | return partial === restriction 31 | }) 32 | 33 | return { 34 | // If the restriction chain was found, return the portion of the 35 | // chain that matches the restriction chain. 36 | chain: 37 | startIndex !== -1 38 | ? chain.slice(startIndex, startIndex + restrictionLinks) 39 | : [], 40 | message, 41 | restriction, 42 | } 43 | }) 44 | .filter(({ chain }) => chain.length) 45 | .forEach(({ chain, message, restriction }) => { 46 | context.report({ 47 | data: { message: message ?? '', restriction }, 48 | loc: { 49 | end: chain.at(-1)!.loc!.end, 50 | start: chain[0].loc!.start, 51 | }, 52 | messageId: message ? 'restrictedWithMessage' : 'restricted', 53 | }) 54 | }) 55 | }, 56 | } 57 | }, 58 | meta: { 59 | docs: { 60 | category: 'Best Practices', 61 | description: 'Disallow specific matchers & modifiers', 62 | recommended: false, 63 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-restricted-matchers.md', 64 | }, 65 | messages: { 66 | restricted: 'Use of `{{restriction}}` is disallowed', 67 | restrictedWithMessage: '{{message}}', 68 | }, 69 | schema: [ 70 | { 71 | additionalProperties: { 72 | type: ['string', 'null'], 73 | }, 74 | type: 'object', 75 | }, 76 | ], 77 | type: 'suggestion', 78 | }, 79 | }) 80 | -------------------------------------------------------------------------------- /src/rules/no-skipped-test.ts: -------------------------------------------------------------------------------- 1 | import { getStringValue } from '../utils/ast.js' 2 | import { createRule } from '../utils/createRule.js' 3 | import { parseFnCall } from '../utils/parseFnCall.js' 4 | 5 | export default createRule({ 6 | create(context) { 7 | return { 8 | CallExpression(node) { 9 | const options = context.options[0] || {} 10 | const allowConditional = !!options.allowConditional 11 | 12 | const call = parseFnCall(context, node) 13 | if ( 14 | call?.group !== 'test' && 15 | call?.group !== 'describe' && 16 | call?.group !== 'step' 17 | ) { 18 | return 19 | } 20 | 21 | const skipNode = call.members.find((s) => getStringValue(s) === 'skip') 22 | if (!skipNode) return 23 | 24 | // If the call is a standalone `test.skip()` call, and not a test 25 | // annotation, we have to treat it a bit differently. 26 | const isStandalone = call.type === 'config' 27 | 28 | // If allowConditional is enabled and it's not a test/describe function, 29 | // we ignore any `test.skip` calls that have no arguments. 30 | if (isStandalone && allowConditional) { 31 | return 32 | } 33 | 34 | context.report({ 35 | messageId: 'noSkippedTest', 36 | node: isStandalone ? node : skipNode, 37 | suggest: [ 38 | { 39 | fix: (fixer) => { 40 | return isStandalone 41 | ? fixer.remove(node.parent) 42 | : fixer.removeRange([ 43 | skipNode.range![0] - 1, 44 | skipNode.range![1] + 45 | Number(skipNode.type !== 'Identifier'), 46 | ]) 47 | }, 48 | messageId: 'removeSkippedTestAnnotation', 49 | }, 50 | ], 51 | }) 52 | }, 53 | } 54 | }, 55 | meta: { 56 | docs: { 57 | category: 'Best Practices', 58 | description: 'Prevent usage of the `.skip()` skip test annotation.', 59 | recommended: true, 60 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-skipped-test.md', 61 | }, 62 | hasSuggestions: true, 63 | messages: { 64 | noSkippedTest: 'Unexpected use of the `.skip()` annotation.', 65 | removeSkippedTestAnnotation: 'Remove the `.skip()` annotation.', 66 | }, 67 | schema: [ 68 | { 69 | additionalProperties: false, 70 | properties: { 71 | allowConditional: { 72 | default: false, 73 | type: 'boolean', 74 | }, 75 | }, 76 | type: 'object', 77 | }, 78 | ], 79 | type: 'suggestion', 80 | }, 81 | }) 82 | -------------------------------------------------------------------------------- /src/rules/no-slowed-test.ts: -------------------------------------------------------------------------------- 1 | import { getStringValue } from '../utils/ast.js' 2 | import { createRule } from '../utils/createRule.js' 3 | import { parseFnCall } from '../utils/parseFnCall.js' 4 | 5 | export default createRule({ 6 | create(context) { 7 | return { 8 | CallExpression(node) { 9 | const options = context.options[0] || {} 10 | const allowConditional = !!options.allowConditional 11 | 12 | const call = parseFnCall(context, node) 13 | if (call?.group !== 'test') { 14 | return 15 | } 16 | 17 | const slowNode = call.members.find((s) => getStringValue(s) === 'slow') 18 | if (!slowNode) return 19 | 20 | // If the call is a standalone `test.slow()` call, and not a test 21 | // annotation, we have to treat it a bit differently. 22 | const isStandalone = call.type === 'config' 23 | 24 | // If allowConditional is enabled and it's not a test function, 25 | // we ignore any `test.slow` calls that have no arguments. 26 | if (isStandalone && allowConditional) { 27 | return 28 | } 29 | 30 | context.report({ 31 | messageId: 'noSlowedTest', 32 | node: isStandalone ? node : slowNode, 33 | suggest: [ 34 | { 35 | fix: (fixer) => { 36 | return isStandalone 37 | ? fixer.remove(node.parent) 38 | : fixer.removeRange([ 39 | slowNode.range![0] - 1, 40 | slowNode.range![1] + 41 | Number(slowNode.type !== 'Identifier'), 42 | ]) 43 | }, 44 | messageId: 'removeSlowedTestAnnotation', 45 | }, 46 | ], 47 | }) 48 | }, 49 | } 50 | }, 51 | meta: { 52 | docs: { 53 | category: 'Best Practices', 54 | description: 'Prevent usage of the `.slow()` slow test annotation.', 55 | recommended: false, 56 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-slowed-test.md', 57 | }, 58 | hasSuggestions: true, 59 | messages: { 60 | noSlowedTest: 'Unexpected use of the `.slow()` annotation.', 61 | removeSlowedTestAnnotation: 'Remove the `.slow()` annotation.', 62 | }, 63 | schema: [ 64 | { 65 | additionalProperties: false, 66 | properties: { 67 | allowConditional: { 68 | default: false, 69 | type: 'boolean', 70 | }, 71 | }, 72 | type: 'object', 73 | }, 74 | ], 75 | type: 'suggestion', 76 | }, 77 | }) 78 | -------------------------------------------------------------------------------- /src/rules/no-standalone-expect.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from 'eslint' 2 | import * as ESTree from 'estree' 3 | import { getParent, isFunction } from '../utils/ast.js' 4 | import { createRule } from '../utils/createRule.js' 5 | import { isTypeOfFnCall, parseFnCall } from '../utils/parseFnCall.js' 6 | 7 | const getBlockType = ( 8 | context: Rule.RuleContext, 9 | statement: ESTree.BlockStatement, 10 | ): 'function' | 'describe' | null => { 11 | const func = getParent(statement) 12 | 13 | if (!func) { 14 | throw new Error( 15 | `Unexpected BlockStatement. No parent defined. - please file a github issue at https://github.com/playwright-community/eslint-plugin-playwright`, 16 | ) 17 | } 18 | 19 | // functionDeclaration: function func() {} 20 | if (func.type === 'FunctionDeclaration') { 21 | return 'function' 22 | } 23 | 24 | if (isFunction(func) && func.parent) { 25 | const expr = func.parent 26 | 27 | // arrow function or function expr 28 | if ( 29 | expr.type === 'VariableDeclarator' || 30 | expr.type === 'MethodDefinition' 31 | ) { 32 | return 'function' 33 | } 34 | 35 | // if it's not a variable, it will be callExpr, we only care about describe 36 | if ( 37 | expr.type === 'CallExpression' && 38 | isTypeOfFnCall(context, expr, ['describe']) 39 | ) { 40 | return 'describe' 41 | } 42 | } 43 | 44 | return null 45 | } 46 | 47 | type BlockType = 48 | | 'arrow' 49 | | 'describe' 50 | | 'function' 51 | | 'hook' 52 | | 'template' 53 | | 'test' 54 | 55 | export default createRule({ 56 | create(context: Rule.RuleContext) { 57 | const callStack: BlockType[] = [] 58 | 59 | return { 60 | ArrowFunctionExpression(node) { 61 | if (node.parent?.type !== 'CallExpression') { 62 | callStack.push('arrow') 63 | } 64 | }, 65 | 'ArrowFunctionExpression:exit'() { 66 | if (callStack.at(-1) === 'arrow') { 67 | callStack.pop() 68 | } 69 | }, 70 | 71 | BlockStatement(statement) { 72 | const blockType = getBlockType(context, statement) 73 | 74 | if (blockType) { 75 | callStack.push(blockType) 76 | } 77 | }, 78 | 'BlockStatement:exit'(statement: ESTree.BlockStatement) { 79 | if (callStack.at(-1) === getBlockType(context, statement)) { 80 | callStack.pop() 81 | } 82 | }, 83 | 84 | CallExpression(node) { 85 | const call = parseFnCall(context, node) 86 | 87 | if (call?.type === 'expect') { 88 | if ( 89 | getParent(call.head.node)?.type === 'MemberExpression' && 90 | call.members.length === 1 91 | ) { 92 | return 93 | } 94 | 95 | const parent = callStack.at(-1) 96 | if (!parent || parent === 'describe') { 97 | context.report({ messageId: 'unexpectedExpect', node }) 98 | } 99 | 100 | return 101 | } 102 | 103 | if (call?.type === 'test') { 104 | callStack.push('test') 105 | } 106 | 107 | if (call?.type === 'hook') { 108 | callStack.push('hook') 109 | } 110 | 111 | if (node.callee.type === 'TaggedTemplateExpression') { 112 | callStack.push('template') 113 | } 114 | }, 115 | 'CallExpression:exit'(node: ESTree.CallExpression) { 116 | const top = callStack.at(-1) 117 | 118 | if ( 119 | (top === 'test' && 120 | isTypeOfFnCall(context, node, ['test']) && 121 | node.callee.type !== 'MemberExpression') || 122 | (top === 'template' && 123 | node.callee.type === 'TaggedTemplateExpression') 124 | ) { 125 | callStack.pop() 126 | } 127 | }, 128 | } 129 | }, 130 | meta: { 131 | docs: { 132 | category: 'Best Practices', 133 | description: 'Disallow using `expect` outside of `test` blocks', 134 | recommended: false, 135 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-standalone-expect.md', 136 | }, 137 | fixable: 'code', 138 | messages: { 139 | unexpectedExpect: 'Expect must be inside of a test block', 140 | }, 141 | type: 'suggestion', 142 | }, 143 | }) 144 | -------------------------------------------------------------------------------- /src/rules/no-useless-await.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from 'eslint' 2 | import ESTree from 'estree' 3 | import { getStringValue, isIdentifier, isPageMethod } from '../utils/ast.js' 4 | import { createRule } from '../utils/createRule.js' 5 | import { parseFnCall } from '../utils/parseFnCall.js' 6 | 7 | const locatorMethods = new Set([ 8 | 'and', 9 | 'first', 10 | 'getByAltText', 11 | 'getByLabel', 12 | 'getByPlaceholder', 13 | 'getByRole', 14 | 'getByTestId', 15 | 'getByText', 16 | 'getByTitle', 17 | 'last', 18 | 'locator', 19 | 'nth', 20 | 'or', 21 | ]) 22 | 23 | const pageMethods = new Set([ 24 | 'childFrames', 25 | 'frame', 26 | 'frameLocator', 27 | 'frames', 28 | 'isClosed', 29 | 'isDetached', 30 | 'mainFrame', 31 | 'name', 32 | 'on', 33 | 'page', 34 | 'parentFrame', 35 | 'setDefaultNavigationTimeout', 36 | 'setDefaultTimeout', 37 | 'url', 38 | 'video', 39 | 'viewportSize', 40 | 'workers', 41 | ]) 42 | 43 | const expectMatchers = new Set([ 44 | 'toBe', 45 | 'toBeCloseTo', 46 | 'toBeDefined', 47 | 'toBeFalsy', 48 | 'toBeGreaterThan', 49 | 'toBeGreaterThanOrEqual', 50 | 'toBeInstanceOf', 51 | 'toBeLessThan', 52 | 'toBeLessThanOrEqual', 53 | 'toBeNaN', 54 | 'toBeNull', 55 | 'toBeTruthy', 56 | 'toBeUndefined', 57 | 'toContain', 58 | 'toContainEqual', 59 | 'toEqual', 60 | 'toHaveLength', 61 | 'toHaveProperty', 62 | 'toMatch', 63 | 'toMatchObject', 64 | 'toStrictEqual', 65 | 'toThrow', 66 | 'toThrowError', 67 | ]) 68 | 69 | function isSupportedMethod(node: ESTree.CallExpression) { 70 | if (node.callee.type !== 'MemberExpression') return false 71 | 72 | const name = getStringValue(node.callee.property) 73 | return ( 74 | locatorMethods.has(name) || 75 | (pageMethods.has(name) && isPageMethod(node, name)) 76 | ) 77 | } 78 | 79 | export default createRule({ 80 | create(context) { 81 | function fix(node: ESTree.Node) { 82 | const start = node.loc!.start 83 | const range = node.range! 84 | 85 | context.report({ 86 | fix: (fixer) => fixer.removeRange([range[0], range[0] + 6]), 87 | loc: { 88 | end: { 89 | column: start.column + 5, 90 | line: start.line, 91 | }, 92 | start, 93 | }, 94 | messageId: 'noUselessAwait', 95 | }) 96 | } 97 | 98 | return { 99 | 'AwaitExpression > CallExpression'( 100 | node: ESTree.CallExpression & Rule.NodeParentExtension, 101 | ) { 102 | // await page.locator('.foo') 103 | if ( 104 | node.callee.type === 'MemberExpression' && 105 | isSupportedMethod(node) 106 | ) { 107 | return fix(node.parent) 108 | } 109 | 110 | // await expect(true).toBe(true) 111 | const call = parseFnCall(context, node) 112 | if ( 113 | call?.type === 'expect' && 114 | !call.modifiers.some((modifier) => 115 | isIdentifier(modifier, /^(resolves|rejects)$/), 116 | ) && 117 | !call.members.some((member) => isIdentifier(member, 'poll')) && 118 | expectMatchers.has(call.matcherName) 119 | ) { 120 | return fix(node.parent) 121 | } 122 | }, 123 | } 124 | }, 125 | meta: { 126 | docs: { 127 | category: 'Possible Errors', 128 | description: 'Disallow unnecessary awaits for Playwright methods', 129 | recommended: true, 130 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-useless-await.md', 131 | }, 132 | fixable: 'code', 133 | messages: { 134 | noUselessAwait: 135 | 'Unnecessary await expression. This method does not return a Promise.', 136 | }, 137 | type: 'problem', 138 | }, 139 | }) 140 | -------------------------------------------------------------------------------- /src/rules/no-wait-for-selector.ts: -------------------------------------------------------------------------------- 1 | import { isPageMethod } from '../utils/ast.js' 2 | import { createRule } from '../utils/createRule.js' 3 | 4 | export default createRule({ 5 | create(context) { 6 | return { 7 | CallExpression(node) { 8 | if (isPageMethod(node, 'waitForSelector')) { 9 | context.report({ 10 | messageId: 'noWaitForSelector', 11 | node, 12 | suggest: [ 13 | { 14 | fix: (fixer) => 15 | fixer.remove( 16 | node.parent && node.parent.type !== 'AwaitExpression' 17 | ? node.parent 18 | : node.parent.parent, 19 | ), 20 | messageId: 'removeWaitForSelector', 21 | }, 22 | ], 23 | }) 24 | } 25 | }, 26 | } 27 | }, 28 | meta: { 29 | docs: { 30 | category: 'Best Practices', 31 | description: 'Prevent usage of page.waitForSelector()', 32 | recommended: true, 33 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-wait-for-selector.md', 34 | }, 35 | hasSuggestions: true, 36 | messages: { 37 | noWaitForSelector: 'Unexpected use of page.waitForSelector().', 38 | removeWaitForSelector: 'Remove the page.waitForSelector() method.', 39 | }, 40 | type: 'suggestion', 41 | }, 42 | }) 43 | -------------------------------------------------------------------------------- /src/rules/no-wait-for-timeout.ts: -------------------------------------------------------------------------------- 1 | import { isPageMethod } from '../utils/ast.js' 2 | import { createRule } from '../utils/createRule.js' 3 | 4 | export default createRule({ 5 | create(context) { 6 | return { 7 | CallExpression(node) { 8 | if (isPageMethod(node, 'waitForTimeout')) { 9 | context.report({ 10 | messageId: 'noWaitForTimeout', 11 | node, 12 | suggest: [ 13 | { 14 | fix: (fixer) => 15 | fixer.remove( 16 | node.parent && node.parent.type !== 'AwaitExpression' 17 | ? node.parent 18 | : node.parent.parent, 19 | ), 20 | messageId: 'removeWaitForTimeout', 21 | }, 22 | ], 23 | }) 24 | } 25 | }, 26 | } 27 | }, 28 | meta: { 29 | docs: { 30 | category: 'Best Practices', 31 | description: 'Prevent usage of page.waitForTimeout()', 32 | recommended: true, 33 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-wait-for-timeout.md', 34 | }, 35 | hasSuggestions: true, 36 | messages: { 37 | noWaitForTimeout: 'Unexpected use of page.waitForTimeout().', 38 | removeWaitForTimeout: 'Remove the page.waitForTimeout() method.', 39 | }, 40 | type: 'suggestion', 41 | }, 42 | }) 43 | -------------------------------------------------------------------------------- /src/rules/prefer-comparison-matcher.ts: -------------------------------------------------------------------------------- 1 | import * as ESTree from 'estree' 2 | import { 3 | equalityMatchers, 4 | getParent, 5 | getRawValue, 6 | getStringValue, 7 | isBooleanLiteral, 8 | isStringLiteral, 9 | } from '../utils/ast.js' 10 | import { createRule } from '../utils/createRule.js' 11 | import { parseFnCall } from '../utils/parseFnCall.js' 12 | 13 | const isString = (node: ESTree.Node) => { 14 | return isStringLiteral(node) || node.type === 'TemplateLiteral' 15 | } 16 | 17 | const isComparingToString = (expression: ESTree.BinaryExpression) => { 18 | return isString(expression.left) || isString(expression.right) 19 | } 20 | 21 | const invertedOperators: Record = { 22 | '<': '>=', 23 | '<=': '>', 24 | '>': '<=', 25 | '>=': '<', 26 | } 27 | 28 | const operatorMatcher: Record = { 29 | '<': 'toBeLessThan', 30 | '<=': 'toBeLessThanOrEqual', 31 | '>': 'toBeGreaterThan', 32 | '>=': 'toBeGreaterThanOrEqual', 33 | } 34 | 35 | const determineMatcher = ( 36 | operator: string, 37 | negated: boolean, 38 | ): string | null => { 39 | const op = negated ? invertedOperators[operator] : operator 40 | return operatorMatcher[op!] ?? null 41 | } 42 | 43 | export default createRule({ 44 | create(context) { 45 | return { 46 | CallExpression(node) { 47 | const call = parseFnCall(context, node) 48 | if (call?.type !== 'expect' || call.matcherArgs.length === 0) return 49 | 50 | const expect = getParent(call.head.node) 51 | if (expect?.type !== 'CallExpression') return 52 | 53 | const [comparison] = expect.arguments 54 | const expectCallEnd = expect.range![1] 55 | const [matcherArg] = call.matcherArgs 56 | 57 | if ( 58 | comparison?.type !== 'BinaryExpression' || 59 | isComparingToString(comparison) || 60 | !equalityMatchers.has(call.matcherName) || 61 | !isBooleanLiteral(matcherArg) 62 | ) { 63 | return 64 | } 65 | 66 | const hasNot = call.modifiers.some( 67 | (node) => getStringValue(node) === 'not', 68 | ) 69 | 70 | const preferredMatcher = determineMatcher( 71 | comparison.operator, 72 | getRawValue(matcherArg) === hasNot.toString(), 73 | ) 74 | 75 | if (!preferredMatcher) { 76 | return 77 | } 78 | 79 | context.report({ 80 | data: { preferredMatcher }, 81 | fix(fixer) { 82 | // Preserve the existing modifier if it's not a negation 83 | const [modifier] = call.modifiers 84 | const modifierText = 85 | modifier && getStringValue(modifier) !== 'not' 86 | ? `.${getStringValue(modifier)}` 87 | : '' 88 | 89 | return [ 90 | // Replace the comparison argument with the left-hand side of the comparison 91 | fixer.replaceText( 92 | comparison, 93 | context.sourceCode.getText(comparison.left), 94 | ), 95 | // Replace the current matcher & modifier with the preferred matcher 96 | fixer.replaceTextRange( 97 | [expectCallEnd, getParent(call.matcher)!.range![1]], 98 | `${modifierText}.${preferredMatcher}`, 99 | ), 100 | // Replace the matcher argument with the right-hand side of the comparison 101 | fixer.replaceText( 102 | matcherArg, 103 | context.sourceCode.getText(comparison.right), 104 | ), 105 | ] 106 | }, 107 | messageId: 'useToBeComparison', 108 | node: call.matcher, 109 | }) 110 | }, 111 | } 112 | }, 113 | meta: { 114 | docs: { 115 | category: 'Best Practices', 116 | description: 'Suggest using the built-in comparison matchers', 117 | recommended: false, 118 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-comparison-matcher.md', 119 | }, 120 | fixable: 'code', 121 | messages: { 122 | useToBeComparison: 'Prefer using `{{ preferredMatcher }}` instead', 123 | }, 124 | type: 'suggestion', 125 | }, 126 | }) 127 | -------------------------------------------------------------------------------- /src/rules/prefer-equality-matcher.ts: -------------------------------------------------------------------------------- 1 | import { 2 | equalityMatchers, 3 | getParent, 4 | getRawValue, 5 | getStringValue, 6 | isBooleanLiteral, 7 | } from '../utils/ast.js' 8 | import { createRule } from '../utils/createRule.js' 9 | import { parseFnCall } from '../utils/parseFnCall.js' 10 | 11 | export default createRule({ 12 | create(context) { 13 | return { 14 | CallExpression(node) { 15 | const call = parseFnCall(context, node) 16 | if (call?.type !== 'expect' || call.matcherArgs.length === 0) return 17 | 18 | const expect = getParent(call.head.node) 19 | if (expect?.type !== 'CallExpression') return 20 | 21 | const [comparison] = expect.arguments 22 | const expectCallEnd = expect.range![1] 23 | const [matcherArg] = call.matcherArgs 24 | 25 | if ( 26 | comparison?.type !== 'BinaryExpression' || 27 | (comparison.operator !== '===' && comparison.operator !== '!==') || 28 | !equalityMatchers.has(call.matcherName) || 29 | !isBooleanLiteral(matcherArg) 30 | ) { 31 | return 32 | } 33 | 34 | const matcherValue = getRawValue(matcherArg) === 'true' 35 | const [modifier] = call.modifiers 36 | const hasNot = call.modifiers.some( 37 | (node) => getStringValue(node) === 'not', 38 | ) 39 | 40 | // we need to negate the expectation if the current expected 41 | // value is itself negated by the "not" modifier 42 | const addNotModifier = 43 | (comparison.operator === '!==' ? !matcherValue : matcherValue) === 44 | hasNot 45 | 46 | context.report({ 47 | messageId: 'useEqualityMatcher', 48 | node: call.matcher, 49 | suggest: [...equalityMatchers.keys()].map((equalityMatcher) => ({ 50 | data: { matcher: equalityMatcher }, 51 | fix(fixer) { 52 | // preserve the existing modifier if it's not a negation 53 | let modifierText = 54 | modifier && getStringValue(modifier) !== 'not' 55 | ? `.${getStringValue(modifier)}` 56 | : '' 57 | 58 | if (addNotModifier) { 59 | modifierText += `.not` 60 | } 61 | 62 | return [ 63 | // replace the comparison argument with the left-hand side of the comparison 64 | fixer.replaceText( 65 | comparison, 66 | context.sourceCode.getText(comparison.left), 67 | ), 68 | // replace the current matcher & modifier with the preferred matcher 69 | fixer.replaceTextRange( 70 | [expectCallEnd, getParent(call.matcher)!.range![1]], 71 | `${modifierText}.${equalityMatcher}`, 72 | ), 73 | // replace the matcher argument with the right-hand side of the comparison 74 | fixer.replaceText( 75 | matcherArg, 76 | context.sourceCode.getText(comparison.right), 77 | ), 78 | ] 79 | }, 80 | messageId: 'suggestEqualityMatcher', 81 | })), 82 | }) 83 | }, 84 | } 85 | }, 86 | meta: { 87 | docs: { 88 | category: 'Best Practices', 89 | description: 'Suggest using the built-in equality matchers', 90 | recommended: false, 91 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-equality-matcher.md', 92 | }, 93 | hasSuggestions: true, 94 | messages: { 95 | suggestEqualityMatcher: 'Use `{{ matcher }}`', 96 | useEqualityMatcher: 'Prefer using one of the equality matchers instead', 97 | }, 98 | type: 'suggestion', 99 | }, 100 | }) 101 | -------------------------------------------------------------------------------- /src/rules/prefer-hooks-in-order.ts: -------------------------------------------------------------------------------- 1 | import { createRule } from '../utils/createRule.js' 2 | import { isTypeOfFnCall, parseFnCall } from '../utils/parseFnCall.js' 3 | 4 | const order = ['beforeAll', 'beforeEach', 'afterEach', 'afterAll'] 5 | 6 | export default createRule({ 7 | create(context) { 8 | let previousHookIndex = -1 9 | let inHook = false 10 | 11 | return { 12 | CallExpression(node) { 13 | // Ignore everything that is passed into a hook 14 | if (inHook) return 15 | 16 | const call = parseFnCall(context, node) 17 | if (call?.type !== 'hook') { 18 | previousHookIndex = -1 19 | return 20 | } 21 | 22 | inHook = true 23 | const currentHook = call.name 24 | const currentHookIndex = order.indexOf(currentHook) 25 | 26 | if (currentHookIndex < previousHookIndex) { 27 | context.report({ 28 | data: { 29 | currentHook, 30 | previousHook: order[previousHookIndex], 31 | }, 32 | messageId: 'reorderHooks', 33 | node, 34 | }) 35 | 36 | return 37 | } 38 | 39 | previousHookIndex = currentHookIndex 40 | }, 41 | 'CallExpression:exit'(node) { 42 | if (isTypeOfFnCall(context, node, ['hook'])) { 43 | inHook = false 44 | return 45 | } 46 | 47 | if (inHook) return 48 | previousHookIndex = -1 49 | }, 50 | } 51 | }, 52 | meta: { 53 | docs: { 54 | category: 'Best Practices', 55 | description: 'Prefer having hooks in a consistent order', 56 | recommended: false, 57 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-hooks-in-order.md', 58 | }, 59 | messages: { 60 | reorderHooks: 61 | '`{{ currentHook }}` hooks should be before any `{{ previousHook }}` hooks', 62 | }, 63 | type: 'suggestion', 64 | }, 65 | }) 66 | -------------------------------------------------------------------------------- /src/rules/prefer-hooks-on-top.ts: -------------------------------------------------------------------------------- 1 | import { createRule } from '../utils/createRule.js' 2 | import { isTypeOfFnCall } from '../utils/parseFnCall.js' 3 | 4 | export default createRule({ 5 | create(context) { 6 | const stack = [false] 7 | 8 | return { 9 | CallExpression(node) { 10 | if (isTypeOfFnCall(context, node, ['test'])) { 11 | stack[stack.length - 1] = true 12 | } 13 | 14 | if (stack.at(-1) && isTypeOfFnCall(context, node, ['hook'])) { 15 | context.report({ messageId: 'noHookOnTop', node }) 16 | } 17 | 18 | stack.push(false) 19 | }, 20 | 'CallExpression:exit'() { 21 | stack.pop() 22 | }, 23 | } 24 | }, 25 | meta: { 26 | docs: { 27 | category: 'Best Practices', 28 | description: 'Suggest having hooks before any test cases', 29 | recommended: false, 30 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-hooks-on-top.md', 31 | }, 32 | messages: { 33 | noHookOnTop: 'Hooks should come before test cases', 34 | }, 35 | type: 'suggestion', 36 | }, 37 | }) 38 | -------------------------------------------------------------------------------- /src/rules/prefer-locator.test.ts: -------------------------------------------------------------------------------- 1 | import { runRuleTester, test } from '../utils/rule-tester.js' 2 | import rule from './prefer-locator.js' 3 | 4 | runRuleTester('prefer-locator', rule, { 5 | invalid: [ 6 | { 7 | code: test(`await page.fill('input[type="password"]', 'password')`), 8 | errors: [ 9 | { 10 | column: 34, 11 | endColumn: 81, 12 | endLine: 1, 13 | line: 1, 14 | messageId: 'preferLocator', 15 | }, 16 | ], 17 | output: null, 18 | }, 19 | { 20 | code: test(`await page.dblclick('xpath=//button')`), 21 | errors: [ 22 | { 23 | column: 34, 24 | endColumn: 65, 25 | endLine: 1, 26 | line: 1, 27 | messageId: 'preferLocator', 28 | }, 29 | ], 30 | output: null, 31 | }, 32 | { 33 | code: `page.click('xpath=//button')`, 34 | errors: [ 35 | { 36 | column: 1, 37 | endColumn: 29, 38 | endLine: 1, 39 | line: 1, 40 | messageId: 'preferLocator', 41 | }, 42 | ], 43 | output: null, 44 | }, 45 | { 46 | code: test(`await page.frame('frame-name').click('css=button')`), 47 | errors: [ 48 | { 49 | column: 34, 50 | endColumn: 78, 51 | endLine: 1, 52 | line: 1, 53 | messageId: 'preferLocator', 54 | }, 55 | ], 56 | output: null, 57 | }, 58 | { 59 | code: `page.frame('frame-name').click('css=button')`, 60 | errors: [ 61 | { 62 | column: 1, 63 | endColumn: 45, 64 | endLine: 1, 65 | line: 1, 66 | messageId: 'preferLocator', 67 | }, 68 | ], 69 | output: null, 70 | }, 71 | ], 72 | valid: [ 73 | { 74 | code: `const locator = page.locator('input[type="password"]')`, 75 | }, 76 | { 77 | code: test( 78 | `await page.locator('input[type="password"]').fill('password')`, 79 | ), 80 | }, 81 | { 82 | code: test(`await page.locator('xpath=//button').dblclick()`), 83 | }, 84 | { 85 | code: `page.locator('xpath=//button').click()`, 86 | }, 87 | { 88 | code: test( 89 | `await page.frameLocator('#my-iframe').locator('css=button').click()`, 90 | ), 91 | }, 92 | { 93 | code: test(`await page.evaluate('1 + 2')`), 94 | }, 95 | { 96 | code: `page.frame('frame-name')`, 97 | }, 98 | ], 99 | }) 100 | -------------------------------------------------------------------------------- /src/rules/prefer-locator.ts: -------------------------------------------------------------------------------- 1 | import ESTree from 'estree' 2 | import { getStringValue, isPageMethod } from '../utils/ast.js' 3 | import { createRule } from '../utils/createRule.js' 4 | 5 | const pageMethods = new Set([ 6 | 'click', 7 | 'dblclick', 8 | 'dispatchEvent', 9 | 'fill', 10 | 'focus', 11 | 'getAttribute', 12 | 'hover', 13 | 'innerHTML', 14 | 'innerText', 15 | 'inputValue', 16 | 'isChecked', 17 | 'isDisabled', 18 | 'isEditable', 19 | 'isEnabled', 20 | 'isHidden', 21 | 'isVisible', 22 | 'press', 23 | 'selectOption', 24 | 'setChecked', 25 | 'setInputFiles', 26 | 'tap', 27 | 'textContent', 28 | 'uncheck', 29 | ]) 30 | 31 | function isSupportedMethod(node: ESTree.CallExpression) { 32 | if (node.callee.type !== 'MemberExpression') return false 33 | 34 | const name = getStringValue(node.callee.property) 35 | return pageMethods.has(name) && isPageMethod(node, name) 36 | } 37 | 38 | export default createRule({ 39 | create(context) { 40 | return { 41 | CallExpression(node) { 42 | // Must be a method we care about 43 | if (!isSupportedMethod(node)) return 44 | 45 | context.report({ 46 | messageId: 'preferLocator', 47 | node, 48 | }) 49 | }, 50 | } 51 | }, 52 | meta: { 53 | docs: { 54 | category: 'Best Practices', 55 | description: 'Suggest locators over page methods', 56 | recommended: false, 57 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-locator.md', 58 | }, 59 | messages: { 60 | preferLocator: 'Prefer locator methods instead of page methods', 61 | }, 62 | schema: [], 63 | type: 'suggestion', 64 | }, 65 | }) 66 | -------------------------------------------------------------------------------- /src/rules/prefer-lowercase-title.ts: -------------------------------------------------------------------------------- 1 | import { AST } from 'eslint' 2 | import ESTree from 'estree' 3 | import { getStringValue, isStringNode } from '../utils/ast.js' 4 | import { createRule } from '../utils/createRule.js' 5 | import { isTypeOfFnCall, parseFnCall } from '../utils/parseFnCall.js' 6 | 7 | type Method = 'test' | 'test.describe' 8 | 9 | export default createRule({ 10 | create(context) { 11 | const { allowedPrefixes, ignore, ignoreTopLevelDescribe } = { 12 | allowedPrefixes: [] as string[], 13 | ignore: [] as Method[], 14 | ignoreTopLevelDescribe: false, 15 | ...((context.options?.[0] as Record) ?? {}), 16 | } 17 | 18 | let describeCount = 0 19 | 20 | return { 21 | CallExpression(node) { 22 | const call = parseFnCall(context, node) 23 | if (call?.type !== 'describe' && call?.type !== 'test') { 24 | return 25 | } 26 | 27 | if (call.type === 'describe') { 28 | describeCount++ 29 | 30 | if (ignoreTopLevelDescribe && describeCount === 1) { 31 | return 32 | } 33 | } 34 | 35 | const [title] = node.arguments 36 | if (!isStringNode(title)) { 37 | return 38 | } 39 | 40 | const description = getStringValue(title) 41 | if ( 42 | !description || 43 | allowedPrefixes.some((name) => description.startsWith(name)) 44 | ) { 45 | return 46 | } 47 | 48 | const method = call.type === 'describe' ? 'test.describe' : 'test' 49 | const firstCharacter = description.charAt(0) 50 | if ( 51 | !firstCharacter || 52 | firstCharacter === firstCharacter.toLowerCase() || 53 | ignore.includes(method) 54 | ) { 55 | return 56 | } 57 | 58 | context.report({ 59 | data: { method }, 60 | fix(fixer) { 61 | const rangeIgnoringQuotes: AST.Range = [ 62 | title.range![0] + 1, 63 | title.range![1] - 1, 64 | ] 65 | 66 | const newDescription = 67 | description.substring(0, 1).toLowerCase() + 68 | description.substring(1) 69 | 70 | return fixer.replaceTextRange(rangeIgnoringQuotes, newDescription) 71 | }, 72 | messageId: 'unexpectedLowercase', 73 | node: node.arguments[0], 74 | }) 75 | }, 76 | 'CallExpression:exit'(node: ESTree.CallExpression) { 77 | if (isTypeOfFnCall(context, node, ['describe'])) { 78 | describeCount-- 79 | } 80 | }, 81 | } 82 | }, 83 | meta: { 84 | docs: { 85 | category: 'Best Practices', 86 | description: 'Enforce lowercase test names', 87 | recommended: false, 88 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-lowercase-title.md', 89 | }, 90 | fixable: 'code', 91 | messages: { 92 | unexpectedLowercase: '`{{method}}`s should begin with lowercase', 93 | }, 94 | schema: [ 95 | { 96 | additionalProperties: false, 97 | properties: { 98 | allowedPrefixes: { 99 | additionalItems: false, 100 | items: { type: 'string' }, 101 | type: 'array', 102 | }, 103 | ignore: { 104 | additionalItems: false, 105 | items: { 106 | enum: ['test.describe', 'test'], 107 | }, 108 | type: 'array', 109 | }, 110 | ignoreTopLevelDescribe: { 111 | default: false, 112 | type: 'boolean', 113 | }, 114 | }, 115 | type: 'object', 116 | }, 117 | ], 118 | type: 'suggestion', 119 | }, 120 | }) 121 | -------------------------------------------------------------------------------- /src/rules/prefer-native-locators.ts: -------------------------------------------------------------------------------- 1 | import { AST } from 'eslint' 2 | import { getStringValue, isPageMethod } from '../utils/ast.js' 3 | import { createRule } from '../utils/createRule.js' 4 | 5 | type Pattern = { 6 | messageId: string 7 | pattern: RegExp 8 | replacement: string 9 | } 10 | 11 | const compilePatterns = ({ 12 | testIdAttribute, 13 | }: { 14 | testIdAttribute: string 15 | }): Pattern[] => { 16 | const patterns = [ 17 | { 18 | attribute: 'aria-label', 19 | messageId: 'unexpectedLabelQuery', 20 | replacement: 'getByLabel', 21 | }, 22 | { 23 | attribute: 'role', 24 | messageId: 'unexpectedRoleQuery', 25 | replacement: 'getByRole', 26 | }, 27 | { 28 | attribute: 'placeholder', 29 | messageId: 'unexpectedPlaceholderQuery', 30 | replacement: 'getByPlaceholder', 31 | }, 32 | { 33 | attribute: 'alt', 34 | messageId: 'unexpectedAltTextQuery', 35 | replacement: 'getByAltText', 36 | }, 37 | { 38 | attribute: 'title', 39 | messageId: 'unexpectedTitleQuery', 40 | replacement: 'getByTitle', 41 | }, 42 | { 43 | attribute: testIdAttribute, 44 | messageId: 'unexpectedTestIdQuery', 45 | replacement: 'getByTestId', 46 | }, 47 | ] 48 | return patterns.map(({ attribute, ...pattern }) => ({ 49 | ...pattern, 50 | pattern: new RegExp(`^\\[${attribute}=['"]?(.+?)['"]?\\]$`), 51 | })) 52 | } 53 | 54 | export default createRule({ 55 | create(context) { 56 | const { testIdAttribute } = { 57 | testIdAttribute: 'data-testid', 58 | ...((context.options?.[0] as Record) ?? {}), 59 | } 60 | 61 | const patterns = compilePatterns({ testIdAttribute }) 62 | 63 | return { 64 | CallExpression(node) { 65 | if (node.callee.type !== 'MemberExpression') return 66 | const query = getStringValue(node.arguments[0]) 67 | if (!isPageMethod(node, 'locator')) return 68 | 69 | for (const pattern of patterns) { 70 | const match = query.match(pattern.pattern) 71 | if (match) { 72 | context.report({ 73 | fix(fixer) { 74 | const start = 75 | node.callee.type === 'MemberExpression' 76 | ? node.callee.property.range![0] 77 | : node.range![0] 78 | const end = node.range![1] 79 | const rangeToReplace: AST.Range = [start, end] 80 | 81 | const newText = `${pattern.replacement}("${match[1]}")` 82 | return fixer.replaceTextRange(rangeToReplace, newText) 83 | }, 84 | messageId: pattern.messageId, 85 | node, 86 | }) 87 | return 88 | } 89 | } 90 | }, 91 | } 92 | }, 93 | meta: { 94 | docs: { 95 | category: 'Best Practices', 96 | description: 'Prefer native locator functions', 97 | recommended: false, 98 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-native-locators.md', 99 | }, 100 | fixable: 'code', 101 | messages: { 102 | unexpectedAltTextQuery: 'Use getByAltText() instead', 103 | unexpectedLabelQuery: 'Use getByLabel() instead', 104 | unexpectedPlaceholderQuery: 'Use getByPlaceholder() instead', 105 | unexpectedRoleQuery: 'Use getByRole() instead', 106 | unexpectedTestIdQuery: 'Use getByTestId() instead', 107 | unexpectedTitleQuery: 'Use getByTitle() instead', 108 | }, 109 | schema: [ 110 | { 111 | additionalProperties: false, 112 | properties: { 113 | testIdAttribute: { 114 | default: 'data-testid', 115 | type: 'string', 116 | }, 117 | }, 118 | type: 'object', 119 | }, 120 | ], 121 | type: 'suggestion', 122 | }, 123 | }) 124 | -------------------------------------------------------------------------------- /src/rules/prefer-strict-equal.test.ts: -------------------------------------------------------------------------------- 1 | import rule from '../../src/rules/prefer-strict-equal.js' 2 | import { runRuleTester } from '../utils/rule-tester.js' 3 | 4 | runRuleTester('prefer-strict-equal', rule, { 5 | invalid: [ 6 | { 7 | code: 'expect(something).toEqual(somethingElse);', 8 | errors: [ 9 | { 10 | column: 19, 11 | endColumn: 26, 12 | line: 1, 13 | messageId: 'useToStrictEqual', 14 | suggestions: [ 15 | { 16 | messageId: 'suggestReplaceWithStrictEqual', 17 | output: 'expect(something).toStrictEqual(somethingElse);', 18 | }, 19 | ], 20 | }, 21 | ], 22 | }, 23 | { 24 | code: 'expect(something)["toEqual"](somethingElse);', 25 | errors: [ 26 | { 27 | column: 19, 28 | endColumn: 28, 29 | line: 1, 30 | messageId: 'useToStrictEqual', 31 | suggestions: [ 32 | { 33 | messageId: 'suggestReplaceWithStrictEqual', 34 | output: 'expect(something)["toStrictEqual"](somethingElse);', 35 | }, 36 | ], 37 | }, 38 | ], 39 | }, 40 | // Global aliases 41 | { 42 | code: 'assert(something).toEqual(somethingElse);', 43 | errors: [ 44 | { 45 | column: 19, 46 | endColumn: 26, 47 | line: 1, 48 | messageId: 'useToStrictEqual', 49 | suggestions: [ 50 | { 51 | messageId: 'suggestReplaceWithStrictEqual', 52 | output: 'assert(something).toStrictEqual(somethingElse);', 53 | }, 54 | ], 55 | }, 56 | ], 57 | settings: { 58 | playwright: { 59 | globalAliases: { expect: ['assert'] }, 60 | }, 61 | }, 62 | }, 63 | ], 64 | valid: [ 65 | 'expect(something).toStrictEqual(somethingElse);', 66 | "a().toEqual('b')", 67 | 'expect(a);', 68 | // Global aliases 69 | { 70 | code: 'assert(something).toStrictEqual(somethingElse);', 71 | settings: { 72 | playwright: { 73 | globalAliases: { expect: ['assert'] }, 74 | }, 75 | }, 76 | }, 77 | ], 78 | }) 79 | -------------------------------------------------------------------------------- /src/rules/prefer-strict-equal.ts: -------------------------------------------------------------------------------- 1 | import { createRule } from '../utils/createRule.js' 2 | import { replaceAccessorFixer } from '../utils/fixer.js' 3 | import { parseFnCall } from '../utils/parseFnCall.js' 4 | 5 | export default createRule({ 6 | create(context) { 7 | return { 8 | CallExpression(node) { 9 | const call = parseFnCall(context, node) 10 | if (call?.type !== 'expect') return 11 | 12 | if (call.matcherName === 'toEqual') { 13 | context.report({ 14 | messageId: 'useToStrictEqual', 15 | node: call.matcher, 16 | suggest: [ 17 | { 18 | fix: (fixer) => { 19 | return replaceAccessorFixer( 20 | fixer, 21 | call.matcher, 22 | 'toStrictEqual', 23 | ) 24 | }, 25 | messageId: 'suggestReplaceWithStrictEqual', 26 | }, 27 | ], 28 | }) 29 | } 30 | }, 31 | } 32 | }, 33 | meta: { 34 | docs: { 35 | category: 'Best Practices', 36 | description: 'Suggest using `toStrictEqual()`', 37 | recommended: false, 38 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-strict-equal.md', 39 | }, 40 | fixable: 'code', 41 | hasSuggestions: true, 42 | messages: { 43 | suggestReplaceWithStrictEqual: 'Replace with `toStrictEqual()`', 44 | useToStrictEqual: 'Use toStrictEqual() instead', 45 | }, 46 | schema: [], 47 | type: 'suggestion', 48 | }, 49 | }) 50 | -------------------------------------------------------------------------------- /src/rules/prefer-to-be.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from 'eslint' 2 | import ESTree from 'estree' 3 | import { equalityMatchers, getStringValue, isIdentifier } from '../utils/ast.js' 4 | import { createRule } from '../utils/createRule.js' 5 | import { replaceAccessorFixer } from '../utils/fixer.js' 6 | import { ParsedExpectFnCall, parseFnCall } from '../utils/parseFnCall.js' 7 | 8 | function shouldUseToBe(call: ParsedExpectFnCall) { 9 | let arg = call.matcherArgs[0] 10 | 11 | if (arg.type === 'UnaryExpression' && arg.operator === '-') { 12 | arg = arg.argument 13 | } 14 | 15 | if (arg.type === 'Literal') { 16 | // regex literals are classed as literals, but they're actually objects 17 | // which means "toBe" will give different results than other matchers 18 | return !('regex' in arg) 19 | } 20 | 21 | return arg.type === 'TemplateLiteral' 22 | } 23 | 24 | function reportPreferToBe( 25 | context: Rule.RuleContext, 26 | call: ParsedExpectFnCall, 27 | whatToBe: string, 28 | notModifier?: ESTree.Node, 29 | ) { 30 | context.report({ 31 | fix(fixer) { 32 | const fixes = [ 33 | replaceAccessorFixer(fixer, call.matcher, `toBe${whatToBe}`), 34 | ] 35 | 36 | if (call.matcherArgs?.length && whatToBe !== '') { 37 | fixes.push(fixer.remove(call.matcherArgs[0])) 38 | } 39 | 40 | if (notModifier) { 41 | const [start, end] = notModifier.range! 42 | fixes.push(fixer.removeRange([start - 1, end])) 43 | } 44 | 45 | return fixes 46 | }, 47 | messageId: `useToBe${whatToBe}`, 48 | node: call.matcher, 49 | }) 50 | } 51 | 52 | export default createRule({ 53 | create(context) { 54 | return { 55 | CallExpression(node) { 56 | const call = parseFnCall(context, node) 57 | if (call?.type !== 'expect') return 58 | 59 | const notMatchers = ['toBeUndefined', 'toBeDefined'] 60 | const notModifier = call.modifiers.find( 61 | (node) => getStringValue(node) === 'not', 62 | ) 63 | 64 | if (notModifier && notMatchers.includes(call.matcherName)) { 65 | return reportPreferToBe( 66 | context, 67 | call, 68 | call.matcherName === 'toBeDefined' ? 'Undefined' : 'Defined', 69 | notModifier, 70 | ) 71 | } 72 | 73 | const firstArg = call.matcherArgs[0] 74 | if (!equalityMatchers.has(call.matcherName) || !firstArg) { 75 | return 76 | } 77 | 78 | if (firstArg.type === 'Literal' && firstArg.value === null) { 79 | return reportPreferToBe(context, call, 'Null') 80 | } 81 | 82 | if (isIdentifier(firstArg, 'undefined')) { 83 | const name = notModifier ? 'Defined' : 'Undefined' 84 | return reportPreferToBe(context, call, name, notModifier) 85 | } 86 | 87 | if (isIdentifier(firstArg, 'NaN')) { 88 | return reportPreferToBe(context, call, 'NaN') 89 | } 90 | 91 | if (shouldUseToBe(call) && call.matcherName !== 'toBe') { 92 | reportPreferToBe(context, call, '') 93 | } 94 | }, 95 | } 96 | }, 97 | meta: { 98 | docs: { 99 | category: 'Best Practices', 100 | description: 'Suggest using `toBe()` for primitive literals', 101 | recommended: false, 102 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-to-be.md', 103 | }, 104 | fixable: 'code', 105 | messages: { 106 | useToBe: 'Use `toBe` when expecting primitive literals', 107 | useToBeDefined: 'Use `toBeDefined` instead', 108 | useToBeNaN: 'Use `toBeNaN` instead', 109 | useToBeNull: 'Use `toBeNull` instead', 110 | useToBeUndefined: 'Use `toBeUndefined` instead', 111 | }, 112 | schema: [], 113 | type: 'suggestion', 114 | }, 115 | }) 116 | -------------------------------------------------------------------------------- /src/rules/prefer-to-contain.ts: -------------------------------------------------------------------------------- 1 | import ESTree from 'estree' 2 | import { 3 | equalityMatchers, 4 | getParent, 5 | getStringValue, 6 | isBooleanLiteral, 7 | isPropertyAccessor, 8 | } from '../utils/ast.js' 9 | import { createRule } from '../utils/createRule.js' 10 | import { parseFnCall } from '../utils/parseFnCall.js' 11 | import { KnownCallExpression } from '../utils/types.js' 12 | 13 | type FixableIncludesCallExpression = KnownCallExpression 14 | 15 | const isFixableIncludesCallExpression = ( 16 | node: ESTree.Node, 17 | ): node is FixableIncludesCallExpression => 18 | node.type === 'CallExpression' && 19 | node.callee.type === 'MemberExpression' && 20 | isPropertyAccessor(node.callee, 'includes') && 21 | node.arguments.length === 1 && 22 | node.arguments[0].type !== 'SpreadElement' 23 | 24 | export default createRule({ 25 | create(context) { 26 | return { 27 | CallExpression(node) { 28 | const call = parseFnCall(context, node) 29 | if (call?.type !== 'expect' || call.matcherArgs.length === 0) return 30 | 31 | const expect = getParent(call.head.node) 32 | if (expect?.type !== 'CallExpression') return 33 | 34 | const [includesCall] = expect.arguments 35 | const { matcher } = call 36 | const [matcherArg] = call.matcherArgs 37 | 38 | if ( 39 | !includesCall || 40 | matcherArg.type === 'SpreadElement' || 41 | !equalityMatchers.has(getStringValue(matcher)) || 42 | !isBooleanLiteral(matcherArg) || 43 | !isFixableIncludesCallExpression(includesCall) 44 | ) { 45 | return 46 | } 47 | 48 | const notModifier = call.modifiers.find( 49 | (node) => getStringValue(node) === 'not', 50 | ) 51 | 52 | context.report({ 53 | fix(fixer) { 54 | // We need to negate the expectation if the current expected 55 | // value is itself negated by the "not" modifier 56 | const addNotModifier = 57 | matcherArg.type === 'Literal' && 58 | matcherArg.value === !!notModifier 59 | 60 | const fixes = [ 61 | // remove the "includes" call entirely 62 | fixer.removeRange([ 63 | includesCall.callee.property.range![0] - 1, 64 | includesCall.range![1], 65 | ]), 66 | // replace the current matcher with "toContain", adding "not" if needed 67 | fixer.replaceText( 68 | matcher, 69 | addNotModifier ? 'not.toContain' : 'toContain', 70 | ), 71 | // replace the matcher argument with the value from the "includes" 72 | fixer.replaceText( 73 | call.matcherArgs[0], 74 | context.sourceCode.getText(includesCall.arguments[0]), 75 | ), 76 | ] 77 | 78 | // Remove the "not" modifier if needed 79 | if (notModifier) { 80 | fixes.push( 81 | fixer.removeRange([ 82 | notModifier.range![0], 83 | notModifier.range![1] + 1, 84 | ]), 85 | ) 86 | } 87 | 88 | return fixes 89 | }, 90 | messageId: 'useToContain', 91 | node: matcher, 92 | }) 93 | }, 94 | } 95 | }, 96 | meta: { 97 | docs: { 98 | category: 'Best Practices', 99 | description: 'Suggest using toContain()', 100 | recommended: false, 101 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-to-contain.md', 102 | }, 103 | fixable: 'code', 104 | messages: { 105 | useToContain: 'Use toContain() instead', 106 | }, 107 | type: 'suggestion', 108 | }, 109 | }) 110 | -------------------------------------------------------------------------------- /src/rules/prefer-to-have-count.ts: -------------------------------------------------------------------------------- 1 | import { 2 | dereference, 3 | equalityMatchers, 4 | isPropertyAccessor, 5 | } from '../utils/ast.js' 6 | import { createRule } from '../utils/createRule.js' 7 | import { replaceAccessorFixer } from '../utils/fixer.js' 8 | import { parseFnCall } from '../utils/parseFnCall.js' 9 | 10 | const matchers = new Set([...equalityMatchers, 'toHaveLength']) 11 | 12 | export default createRule({ 13 | create(context) { 14 | return { 15 | CallExpression(node) { 16 | const call = parseFnCall(context, node) 17 | if (call?.type !== 'expect' || !matchers.has(call.matcherName)) { 18 | return 19 | } 20 | 21 | // If the matcher is `toHaveLength`, we expect the inner call to be 22 | // `all()`, otherwise we expect `count()`. 23 | const accessor = call.matcherName === 'toHaveLength' ? 'all' : 'count' 24 | const argument = dereference(context, call.args[0]) 25 | 26 | if ( 27 | argument?.type !== 'AwaitExpression' || 28 | argument.argument.type !== 'CallExpression' || 29 | argument.argument.callee.type !== 'MemberExpression' || 30 | !isPropertyAccessor(argument.argument.callee, accessor) 31 | ) { 32 | return 33 | } 34 | 35 | const callee = argument.argument.callee 36 | context.report({ 37 | fix(fixer) { 38 | return [ 39 | // remove the "await" expression 40 | fixer.removeRange([ 41 | argument.range![0], 42 | argument.range![0] + 'await'.length + 1, 43 | ]), 44 | // remove the "count()" method accessor 45 | fixer.removeRange([ 46 | callee.property.range![0] - 1, 47 | argument.argument.range![1], 48 | ]), 49 | // replace the current matcher with "toHaveCount" 50 | replaceAccessorFixer(fixer, call.matcher, 'toHaveCount'), 51 | // insert "await" to before "expect()" 52 | fixer.insertTextBefore(node, 'await '), 53 | ] 54 | }, 55 | messageId: 'useToHaveCount', 56 | node: call.matcher, 57 | }) 58 | }, 59 | } 60 | }, 61 | meta: { 62 | docs: { 63 | category: 'Best Practices', 64 | description: 'Suggest using `toHaveCount()`', 65 | recommended: false, 66 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-to-have-count.md', 67 | }, 68 | fixable: 'code', 69 | messages: { 70 | useToHaveCount: 'Use toHaveCount() instead', 71 | }, 72 | schema: [], 73 | type: 'suggestion', 74 | }, 75 | }) 76 | -------------------------------------------------------------------------------- /src/rules/prefer-to-have-length.test.ts: -------------------------------------------------------------------------------- 1 | import rule from '../../src/rules/prefer-to-have-length.js' 2 | import { runRuleTester } from '../utils/rule-tester.js' 3 | 4 | runRuleTester('prefer-to-have-length', rule, { 5 | invalid: [ 6 | { 7 | code: 'expect(files.length).toBe(1)', 8 | errors: [ 9 | { column: 22, endColumn: 26, line: 1, messageId: 'useToHaveLength' }, 10 | ], 11 | output: 'expect(files).toHaveLength(1)', 12 | }, 13 | { 14 | code: 'expect(files.length).not.toBe(1)', 15 | errors: [ 16 | { column: 26, endColumn: 30, line: 1, messageId: 'useToHaveLength' }, 17 | ], 18 | output: 'expect(files).not.toHaveLength(1)', 19 | }, 20 | { 21 | code: 'expect.soft(files["length"]).not.toBe(1)', 22 | errors: [ 23 | { column: 34, endColumn: 38, line: 1, messageId: 'useToHaveLength' }, 24 | ], 25 | output: 'expect.soft(files).not.toHaveLength(1)', 26 | }, 27 | { 28 | code: 'expect(files["length"]).not["toBe"](1)', 29 | errors: [ 30 | { column: 29, endColumn: 35, line: 1, messageId: 'useToHaveLength' }, 31 | ], 32 | output: 'expect(files).not["toHaveLength"](1)', 33 | }, 34 | { 35 | code: 'expect(files.length)[`toEqual`](1)', 36 | errors: [ 37 | { column: 22, endColumn: 31, line: 1, messageId: 'useToHaveLength' }, 38 | ], 39 | output: 'expect(files)[`toHaveLength`](1)', 40 | }, 41 | { 42 | code: 'expect(files.length).toStrictEqual(1)', 43 | errors: [ 44 | { column: 22, endColumn: 35, line: 1, messageId: 'useToHaveLength' }, 45 | ], 46 | output: 'expect(files).toHaveLength(1)', 47 | }, 48 | { 49 | code: 'expect(files.length).not.toStrictEqual(1)', 50 | errors: [ 51 | { column: 26, endColumn: 39, line: 1, messageId: 'useToHaveLength' }, 52 | ], 53 | output: 'expect(files).not.toHaveLength(1)', 54 | }, 55 | // Global aliases 56 | { 57 | code: 'assert(files.length).toBe(1)', 58 | errors: [ 59 | { column: 22, endColumn: 26, line: 1, messageId: 'useToHaveLength' }, 60 | ], 61 | output: 'assert(files).toHaveLength(1)', 62 | settings: { 63 | playwright: { 64 | globalAliases: { expect: ['assert'] }, 65 | }, 66 | }, 67 | }, 68 | { 69 | code: 'expect((await table.rows.all()).length).toBe(5)', 70 | errors: [ 71 | { column: 41, endColumn: 45, line: 1, messageId: 'useToHaveLength' }, 72 | ], 73 | output: 'expect((await table.rows.all())).toHaveLength(5)', 74 | }, 75 | ], 76 | valid: [ 77 | 'expect(files).toHaveLength(1)', 78 | "expect(files.name).toBe('file')", 79 | "expect(files['name']).toBe('file')", 80 | "expect(files[`name`]).toBe('file')", 81 | 'expect(result).toBe(true)', 82 | `expect(user.getUserName(5)).not.toEqual('Paul')`, 83 | 'expect(a)', 84 | // Global aliases 85 | { 86 | code: 'assert(files).toHaveLength(1)', 87 | settings: { 88 | playwright: { 89 | globalAliases: { expect: ['assert'] }, 90 | }, 91 | }, 92 | }, 93 | ], 94 | }) 95 | -------------------------------------------------------------------------------- /src/rules/prefer-to-have-length.ts: -------------------------------------------------------------------------------- 1 | import { equalityMatchers, isPropertyAccessor } from '../utils/ast.js' 2 | import { createRule } from '../utils/createRule.js' 3 | import { replaceAccessorFixer } from '../utils/fixer.js' 4 | import { parseFnCall } from '../utils/parseFnCall.js' 5 | 6 | export default createRule({ 7 | create(context) { 8 | return { 9 | CallExpression(node) { 10 | const call = parseFnCall(context, node) 11 | if ( 12 | call?.type !== 'expect' || 13 | !equalityMatchers.has(call.matcherName) 14 | ) { 15 | return 16 | } 17 | 18 | const [argument] = call.args 19 | if ( 20 | argument?.type !== 'MemberExpression' || 21 | !isPropertyAccessor(argument, 'length') 22 | ) { 23 | return 24 | } 25 | 26 | context.report({ 27 | fix(fixer) { 28 | return [ 29 | // remove the "length" property accessor 30 | fixer.removeRange([ 31 | argument.property.range![0] - 1, 32 | argument.range![1], 33 | ]), 34 | // replace the current matcher with "toHaveLength" 35 | replaceAccessorFixer(fixer, call.matcher, 'toHaveLength'), 36 | ] 37 | }, 38 | messageId: 'useToHaveLength', 39 | node: call.matcher, 40 | }) 41 | }, 42 | } 43 | }, 44 | meta: { 45 | docs: { 46 | category: 'Best Practices', 47 | description: 'Suggest using `toHaveLength()`', 48 | recommended: false, 49 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-to-have-length.md', 50 | }, 51 | fixable: 'code', 52 | messages: { 53 | useToHaveLength: 'Use toHaveLength() instead', 54 | }, 55 | schema: [], 56 | type: 'suggestion', 57 | }, 58 | }) 59 | -------------------------------------------------------------------------------- /src/rules/require-hook.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from 'eslint' 2 | import * as ESTree from 'estree' 3 | import { getStringValue, isFunction, isIdentifier } from '../utils/ast.js' 4 | import { createRule } from '../utils/createRule.js' 5 | import { isTypeOfFnCall, parseFnCall } from '../utils/parseFnCall.js' 6 | 7 | const isNullOrUndefined = (node: ESTree.Expression): boolean => { 8 | return ( 9 | (node.type === 'Literal' && node.value === null) || 10 | isIdentifier(node, 'undefined') 11 | ) 12 | } 13 | 14 | const shouldBeInHook = ( 15 | context: Rule.RuleContext, 16 | node: ESTree.Node, 17 | allowedFunctionCalls: readonly string[] = [], 18 | ): boolean => { 19 | switch (node.type) { 20 | case 'ExpressionStatement': 21 | return shouldBeInHook(context, node.expression, allowedFunctionCalls) 22 | case 'CallExpression': 23 | return !( 24 | parseFnCall(context, node) || 25 | allowedFunctionCalls.includes(getStringValue(node.callee)) 26 | ) 27 | case 'VariableDeclaration': { 28 | if (node.kind === 'const') { 29 | return false 30 | } 31 | 32 | return node.declarations.some( 33 | ({ init }) => init != null && !isNullOrUndefined(init), 34 | ) 35 | } 36 | 37 | default: 38 | return false 39 | } 40 | } 41 | 42 | export default createRule({ 43 | create(context) { 44 | const options = { 45 | allowedFunctionCalls: [] as string[], 46 | ...((context.options?.[0] as Record) ?? {}), 47 | } 48 | 49 | const checkBlockBody = (body: ESTree.Program['body']) => { 50 | for (const statement of body) { 51 | if (shouldBeInHook(context, statement, options.allowedFunctionCalls)) { 52 | context.report({ 53 | messageId: 'useHook', 54 | node: statement, 55 | }) 56 | } 57 | } 58 | } 59 | 60 | return { 61 | CallExpression(node) { 62 | if (!isTypeOfFnCall(context, node, ['describe'])) { 63 | return 64 | } 65 | 66 | const testFn = node.arguments.at(-1) 67 | if (!isFunction(testFn) || testFn.body.type !== 'BlockStatement') { 68 | return 69 | } 70 | 71 | checkBlockBody(testFn.body.body) 72 | }, 73 | Program(program) { 74 | checkBlockBody(program.body) 75 | }, 76 | } 77 | }, 78 | meta: { 79 | docs: { 80 | category: 'Best Practices', 81 | description: 'Require setup and teardown code to be within a hook', 82 | recommended: false, 83 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/require-hook.md', 84 | }, 85 | messages: { 86 | useHook: 'This should be done within a hook', 87 | }, 88 | schema: [ 89 | { 90 | additionalProperties: false, 91 | properties: { 92 | allowedFunctionCalls: { 93 | items: { type: 'string' }, 94 | type: 'array', 95 | }, 96 | }, 97 | type: 'object', 98 | }, 99 | ], 100 | type: 'suggestion', 101 | }, 102 | }) 103 | -------------------------------------------------------------------------------- /src/rules/require-soft-assertions.test.ts: -------------------------------------------------------------------------------- 1 | import rule from '../../src/rules/require-soft-assertions.js' 2 | import { runRuleTester } from '../utils/rule-tester.js' 3 | 4 | const messageId = 'requireSoft' 5 | 6 | runRuleTester('require-soft-assertions', rule, { 7 | invalid: [ 8 | { 9 | code: 'expect(page).toHaveTitle("baz")', 10 | errors: [{ column: 1, endColumn: 7, line: 1, messageId }], 11 | output: 'expect.soft(page).toHaveTitle("baz")', 12 | }, 13 | { 14 | code: 'expect(page.locator("foo")).toHaveText("bar")', 15 | errors: [{ column: 1, endColumn: 7, line: 1, messageId }], 16 | output: 'expect.soft(page.locator("foo")).toHaveText("bar")', 17 | }, 18 | { 19 | code: 'await expect(page.locator("foo")).toHaveText("bar")', 20 | errors: [{ column: 7, endColumn: 13, line: 1, messageId }], 21 | output: 'await expect.soft(page.locator("foo")).toHaveText("bar")', 22 | }, 23 | // Global aliases 24 | { 25 | code: 'assert(page).toHaveTitle("baz")', 26 | errors: [{ column: 1, endColumn: 7, line: 1, messageId }], 27 | output: 'assert.soft(page).toHaveTitle("baz")', 28 | settings: { 29 | playwright: { 30 | globalAliases: { expect: ['assert'] }, 31 | }, 32 | }, 33 | }, 34 | ], 35 | valid: [ 36 | 'expect.soft(page).toHaveTitle("baz")', 37 | 'expect.soft(page.locator("foo")).toHaveText("bar")', 38 | 'expect["soft"](foo).toBe("bar")', 39 | 'expect[`soft`](bar).toHaveText("bar")', 40 | 'expect.poll(() => foo).toBe("bar")', 41 | 'expect["poll"](() => foo).toBe("bar")', 42 | 'expect[`poll`](() => foo).toBe("bar")', 43 | // Global aliases 44 | { 45 | code: 'assert.soft(page).toHaveTitle("baz")', 46 | settings: { 47 | playwright: { 48 | globalAliases: { expect: ['assert'] }, 49 | }, 50 | }, 51 | }, 52 | ], 53 | }) 54 | -------------------------------------------------------------------------------- /src/rules/require-soft-assertions.ts: -------------------------------------------------------------------------------- 1 | import { getStringValue } from '../utils/ast.js' 2 | import { createRule } from '../utils/createRule.js' 3 | import { parseFnCall } from '../utils/parseFnCall.js' 4 | 5 | export default createRule({ 6 | create(context) { 7 | return { 8 | CallExpression(node) { 9 | const call = parseFnCall(context, node) 10 | if ( 11 | call?.type !== 'expect' || 12 | call.modifiers.some((m) => { 13 | const name = getStringValue(m) 14 | return name === 'soft' || name === 'poll' 15 | }) 16 | ) { 17 | return 18 | } 19 | 20 | context.report({ 21 | fix: (fixer) => fixer.insertTextAfter(call.head.node, '.soft'), 22 | messageId: 'requireSoft', 23 | node: call.head.node, 24 | }) 25 | }, 26 | } 27 | }, 28 | meta: { 29 | docs: { 30 | description: 'Require all assertions to use `expect.soft`', 31 | recommended: false, 32 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/require-soft-assertions.md', 33 | }, 34 | fixable: 'code', 35 | messages: { 36 | requireSoft: 'Unexpected non-soft assertion', 37 | }, 38 | schema: [], 39 | type: 'suggestion', 40 | }, 41 | }) 42 | -------------------------------------------------------------------------------- /src/rules/require-to-throw-message.ts: -------------------------------------------------------------------------------- 1 | import { getStringValue } from '../utils/ast.js' 2 | import { createRule } from '../utils/createRule.js' 3 | import { parseFnCall } from '../utils/parseFnCall.js' 4 | 5 | export default createRule({ 6 | create(context) { 7 | return { 8 | CallExpression(node) { 9 | const call = parseFnCall(context, node) 10 | if (call?.type !== 'expect') return 11 | 12 | if ( 13 | call.matcherArgs.length === 0 && 14 | ['toThrow', 'toThrowError'].includes(call.matcherName) && 15 | !call.modifiers.some((nod) => getStringValue(nod) === 'not') 16 | ) { 17 | // Look for `toThrow` calls with no arguments. 18 | context.report({ 19 | data: { matcherName: call.matcherName }, 20 | messageId: 'addErrorMessage', 21 | node: call.matcher, 22 | }) 23 | } 24 | }, 25 | } 26 | }, 27 | meta: { 28 | docs: { 29 | category: 'Best Practices', 30 | description: 'Require a message for `toThrow()`', 31 | recommended: false, 32 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/require-to-throw-message.md', 33 | }, 34 | messages: { 35 | addErrorMessage: 'Add an error message to {{ matcherName }}()', 36 | }, 37 | schema: [], 38 | type: 'suggestion', 39 | }, 40 | }) 41 | -------------------------------------------------------------------------------- /src/rules/require-top-level-describe.ts: -------------------------------------------------------------------------------- 1 | import ESTree from 'estree' 2 | import { createRule } from '../utils/createRule.js' 3 | import { getAmountData } from '../utils/misc.js' 4 | import { isTypeOfFnCall, parseFnCall } from '../utils/parseFnCall.js' 5 | 6 | export default createRule({ 7 | create(context) { 8 | const { maxTopLevelDescribes } = { 9 | maxTopLevelDescribes: Infinity, 10 | ...((context.options?.[0] as Record) ?? {}), 11 | } 12 | 13 | let topLevelDescribeCount = 0 14 | let describeCount = 0 15 | 16 | return { 17 | CallExpression(node) { 18 | const call = parseFnCall(context, node) 19 | if (!call) return 20 | 21 | if (call.type === 'describe') { 22 | describeCount++ 23 | 24 | if (describeCount === 1) { 25 | topLevelDescribeCount++ 26 | 27 | if (topLevelDescribeCount > maxTopLevelDescribes) { 28 | context.report({ 29 | data: getAmountData(maxTopLevelDescribes), 30 | messageId: 'tooManyDescribes', 31 | node: node.callee, 32 | }) 33 | } 34 | } 35 | } else if (!describeCount) { 36 | if (call.type === 'test') { 37 | context.report({ messageId: 'unexpectedTest', node: node.callee }) 38 | } else if (call.type === 'hook') { 39 | context.report({ messageId: 'unexpectedHook', node: node.callee }) 40 | } 41 | } 42 | }, 43 | 'CallExpression:exit'(node: ESTree.CallExpression) { 44 | if (isTypeOfFnCall(context, node, ['describe'])) { 45 | describeCount-- 46 | } 47 | }, 48 | } 49 | }, 50 | meta: { 51 | docs: { 52 | category: 'Best Practices', 53 | description: 54 | 'Require test cases and hooks to be inside a `test.describe` block', 55 | recommended: false, 56 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/require-top-level-describe.md', 57 | }, 58 | messages: { 59 | tooManyDescribes: 60 | 'There should not be more than {{amount}} describe{{s}} at the top level', 61 | unexpectedHook: 'All hooks must be wrapped in a describe block.', 62 | unexpectedTest: 'All test cases must be wrapped in a describe block.', 63 | }, 64 | schema: [ 65 | { 66 | additionalProperties: false, 67 | properties: { 68 | maxTopLevelDescribes: { 69 | minimum: 1, 70 | type: 'number', 71 | }, 72 | }, 73 | type: 'object', 74 | }, 75 | ], 76 | type: 'suggestion', 77 | }, 78 | }) 79 | -------------------------------------------------------------------------------- /src/rules/rules.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises' 2 | import path from 'node:path' 3 | import { expect, test } from 'vitest' 4 | 5 | const plugin = await import('../../src/index.js') 6 | 7 | test('exports all rules', async () => { 8 | const files = await fs.readdir('src/rules') 9 | const { rules } = plugin.configs['flat/recommended'].plugins.playwright 10 | const ruleKeys = Object.keys(rules).sort() 11 | const fileKeys = files 12 | .filter((file) => !file.endsWith('.test.ts')) 13 | .map((file) => file.replace('.ts', '')) 14 | .sort() 15 | 16 | expect(ruleKeys).toEqual(fileKeys) 17 | }) 18 | 19 | test('has all rules in the README', async () => { 20 | const readme = await fs.readFile( 21 | path.resolve(__dirname, '../../README.md'), 22 | 'utf-8', 23 | ) 24 | 25 | const { rules } = plugin.configs['flat/recommended'].plugins.playwright 26 | 27 | for (const rule of Object.keys(rules)) { 28 | expect(readme).toContain(`[${rule}]`) 29 | } 30 | }) 31 | -------------------------------------------------------------------------------- /src/rules/valid-describe-callback.ts: -------------------------------------------------------------------------------- 1 | import * as ESTree from 'estree' 2 | import { getStringValue, isFunction, isStringLiteral } from '../utils/ast.js' 3 | import { createRule } from '../utils/createRule.js' 4 | import { parseFnCall } from '../utils/parseFnCall.js' 5 | 6 | const paramsLocation = ( 7 | params: ESTree.CallExpression['arguments'] | ESTree.Pattern[], 8 | ) => { 9 | const [first] = params 10 | const last = params[params.length - 1] 11 | 12 | return { 13 | end: last.loc!.end, 14 | start: first.loc!.start, 15 | } 16 | } 17 | 18 | export default createRule({ 19 | create(context) { 20 | return { 21 | CallExpression(node) { 22 | const call = parseFnCall(context, node) 23 | if (call?.group !== 'describe') return 24 | 25 | // Ignore `describe.configure()` calls 26 | if (call.members.some((s) => getStringValue(s) === 'configure')) { 27 | return 28 | } 29 | 30 | const callback = node.arguments.at(-1) 31 | 32 | // e.g., test.describe() 33 | if (!callback) { 34 | return context.report({ 35 | loc: node.loc!, 36 | messageId: 'missingCallback', 37 | }) 38 | } 39 | 40 | // e.g., test.describe("foo") 41 | if (node.arguments.length === 1 && isStringLiteral(callback)) { 42 | return context.report({ 43 | loc: paramsLocation(node.arguments), 44 | messageId: 'missingCallback', 45 | }) 46 | } 47 | 48 | // e.g., test.describe("foo", "foo2"); 49 | if (!isFunction(callback)) { 50 | return context.report({ 51 | loc: paramsLocation(node.arguments), 52 | messageId: 'invalidCallback', 53 | }) 54 | } 55 | 56 | // e.g., test.describe("foo", async () => {}); 57 | if (callback.async) { 58 | context.report({ 59 | messageId: 'noAsyncDescribeCallback', 60 | node: callback, 61 | }) 62 | } 63 | 64 | // e.g., test.describe("foo", (done) => {}); 65 | if (callback.params.length) { 66 | context.report({ 67 | loc: paramsLocation(callback.params), 68 | messageId: 'unexpectedDescribeArgument', 69 | }) 70 | } 71 | 72 | // e.g., test.describe("foo", () => { return; }); 73 | if (callback.body.type === 'CallExpression') { 74 | context.report({ 75 | messageId: 'unexpectedReturnInDescribe', 76 | node: callback, 77 | }) 78 | } 79 | 80 | if (callback.body.type === 'BlockStatement') { 81 | callback.body.body.forEach((node) => { 82 | if (node.type === 'ReturnStatement') { 83 | context.report({ 84 | messageId: 'unexpectedReturnInDescribe', 85 | node, 86 | }) 87 | } 88 | }) 89 | } 90 | }, 91 | } 92 | }, 93 | meta: { 94 | docs: { 95 | category: 'Possible Errors', 96 | description: 'Enforce valid `describe()` callback', 97 | recommended: true, 98 | url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/valid-describe-callback.md', 99 | }, 100 | messages: { 101 | invalidCallback: 'Callback argument must be a function', 102 | missingCallback: 'Describe requires a callback', 103 | noAsyncDescribeCallback: 'No async describe callback', 104 | unexpectedDescribeArgument: 'Unexpected argument(s) in describe callback', 105 | unexpectedReturnInDescribe: 106 | 'Unexpected return statement in describe callback', 107 | }, 108 | schema: [], 109 | type: 'problem', 110 | }, 111 | }) 112 | -------------------------------------------------------------------------------- /src/utils/createRule.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from 'eslint' 2 | import { Settings } from './types.js' 3 | 4 | /** Interpolate a message replacing any data placeholders (e.g. `{{foo}}`) */ 5 | function interpolate(str: string, data: Record | undefined) { 6 | return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => data?.[key] ?? '') 7 | } 8 | 9 | export function createRule(rule: Rule.RuleModule): Rule.RuleModule { 10 | return { 11 | create(context: Rule.RuleContext) { 12 | const messages = (context.settings as Settings)?.playwright?.messages 13 | 14 | // If there are no custom messages, we don't need to modify the rule context 15 | if (!messages) { 16 | return rule.create(context) 17 | } 18 | 19 | /** Custom wrapper around `context.report` to support custom messages. */ 20 | const report = (options: Rule.ReportDescriptor) => { 21 | // Support overriding the default messages from global settings 22 | if (messages && 'messageId' in options) { 23 | const { data, messageId, ...rest } = options 24 | const message = messages?.[messageId] 25 | 26 | // If the message is not found, fallback to the default messageId 27 | // in the options. 28 | return context.report( 29 | message 30 | ? { ...rest, message: interpolate(message, data) } 31 | : options, 32 | ) 33 | } 34 | 35 | return context.report(options) 36 | } 37 | 38 | // ESLint does not allow modifying the context object, so we have to create 39 | // a new context object. Also, destructuring the context object will not work 40 | // because the properties are not enumerable, so we have to manually copy 41 | // the properties we need. 42 | const ruleContext = Object.freeze({ 43 | ...context, 44 | cwd: context.cwd, 45 | filename: context.filename, 46 | id: context.id, 47 | options: context.options, 48 | parserOptions: context.parserOptions, 49 | parserPath: context.parserPath, 50 | physicalFilename: context.physicalFilename, 51 | report, 52 | settings: context.settings, 53 | sourceCode: context.sourceCode, 54 | }) 55 | 56 | return rule.create(ruleContext) 57 | }, 58 | meta: rule.meta, 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/utils/fixer.ts: -------------------------------------------------------------------------------- 1 | import { AST, Rule } from 'eslint' 2 | import ESTree from 'estree' 3 | import { getParent } from './ast.js' 4 | 5 | export const getRangeOffset = (node: ESTree.Node) => 6 | node.type === 'Identifier' ? 0 : 1 7 | 8 | /** 9 | * Replaces an accessor node with the given `text`. 10 | * 11 | * This ensures that fixes produce valid code when replacing both dot-based and 12 | * bracket-based property accessors. 13 | */ 14 | export function replaceAccessorFixer( 15 | fixer: Rule.RuleFixer, 16 | node: ESTree.Node, 17 | text: string, 18 | ) { 19 | const [start, end] = node.range! 20 | 21 | return fixer.replaceTextRange( 22 | [start + getRangeOffset(node), end - getRangeOffset(node)], 23 | text, 24 | ) 25 | } 26 | 27 | /** 28 | * Removes an object property, and if it's parent object contains no other keys, 29 | * removes the object in it's entirety. 30 | */ 31 | export function removePropertyFixer( 32 | fixer: Rule.RuleFixer, 33 | property: ESTree.Property, 34 | ) { 35 | const parent = getParent(property) 36 | if (parent?.type !== 'ObjectExpression') return 37 | 38 | // If the property is the only one in the object, remove the entire object. 39 | if (parent.properties.length === 1) { 40 | return fixer.remove(parent) 41 | } 42 | 43 | // If the property is the first in the object, remove the trailing comma, 44 | // otherwise remove the property and the preceding comma. 45 | const index = parent.properties.indexOf(property) 46 | const range: AST.Range = index 47 | ? [parent.properties[index - 1].range![1], property.range![1]] 48 | : [property.range![0], parent.properties[1].range![0]] 49 | 50 | return fixer.removeRange(range) 51 | } 52 | -------------------------------------------------------------------------------- /src/utils/misc.ts: -------------------------------------------------------------------------------- 1 | export const getAmountData = (amount: number) => ({ 2 | amount: amount.toString(), 3 | s: amount === 1 ? '' : 's', 4 | }) 5 | 6 | export const truthy = Boolean as unknown as ( 7 | value: T | undefined | null | false | 0 | '', 8 | ) => value is T 9 | -------------------------------------------------------------------------------- /src/utils/rule-tester.ts: -------------------------------------------------------------------------------- 1 | import parser from '@typescript-eslint/parser' 2 | import dedent from 'dedent' 3 | import { RuleTester } from 'eslint' 4 | import { describe, it } from 'vitest' 5 | 6 | // Override the default `it` and `describe` functions to use `vitest` 7 | RuleTester.it = it 8 | RuleTester.describe = describe 9 | RuleTester.itOnly = it.only 10 | 11 | /** 12 | * @example 13 | * import rule from '../../src/rules/missing-playwright-await'; 14 | * 15 | * runRuleTester('missing-playwright-await', rule, { 16 | * invalid: ['expect(page.locator('checkbox')).toBeChecked()'], 17 | * valid: ['await expect(page.locator('checkbox')).toBeChecked()'], 18 | * }); 19 | */ 20 | export function runRuleTester(...args: Parameters) { 21 | return new RuleTester({ 22 | languageOptions: { 23 | parserOptions: { 24 | ecmaVersion: 2022, 25 | sourceType: 'module', 26 | }, 27 | }, 28 | }).run(...args) 29 | } 30 | 31 | export function runTSRuleTester(...args: Parameters) { 32 | return new RuleTester({ 33 | languageOptions: { 34 | parser, 35 | parserOptions: { 36 | ecmaVersion: 2022, 37 | sourceType: 'module', 38 | }, 39 | }, 40 | }).run(...args) 41 | } 42 | 43 | export const test = (input: string) => `test('test', async () => { ${input} })` 44 | 45 | export const javascript = dedent 46 | export const typescript = dedent 47 | -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from 'eslint' 2 | import ESTree from 'estree' 3 | 4 | export type NodeWithParent = ESTree.Node & Rule.NodeParentExtension 5 | 6 | export type TypedNodeWithParent = Extract< 7 | ESTree.Node, 8 | { type: T } 9 | > & 10 | Rule.NodeParentExtension 11 | 12 | export type KnownCallExpression = ESTree.CallExpression & { 13 | callee: ESTree.MemberExpression 14 | } 15 | 16 | export interface Settings { 17 | playwright?: { 18 | globalAliases?: { 19 | expect?: string[] 20 | test?: string[] 21 | } 22 | messages?: { 23 | [messageId: string]: string | undefined 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "nodenext", 5 | "moduleResolution": "nodenext", 6 | "strict": true, 7 | "skipLibCheck": true, 8 | "esModuleInterop": true 9 | }, 10 | "include": ["src"] 11 | } 12 | --------------------------------------------------------------------------------