diff --git a/README.md b/README.md index a27f9e6..f49baee 100644 --- a/README.md +++ b/README.md @@ -117,58 +117,59 @@ CLI option\ 💡 Manually fixable by [editor suggestions](https://eslint.org/docs/latest/developer-guide/working-with-rules#providing-suggestions) -| Rule | Description | ✅ | 🔧 | 💡 | -| --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | :-: | :-: | :-: | -| [expect-expect](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/expect-expect.md) | Enforce assertion to be made in a test body | ✅ | | | -| [max-expects](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/max-expects.md) | Enforces a maximum number assertion calls in a test body | | | | -| [max-nested-describe](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/max-nested-describe.md) | Enforces a maximum depth to nested describe calls | ✅ | | | -| [missing-playwright-await](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/missing-playwright-await.md) | Enforce Playwright APIs to be awaited | ✅ | 🔧 | | -| [no-commented-out-tests](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-commented-out-tests.md) | Disallow commented out tests | | | | -| [no-conditional-expect](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-conditional-expect.md) | Disallow calling `expect` conditionally | ✅ | | | -| [no-conditional-in-test](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-conditional-in-test.md) | Disallow conditional logic in tests | ✅ | | | -| [no-duplicate-hooks](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-duplicate-hooks.md) | Disallow duplicate setup and teardown hooks | | | | -| [no-element-handle](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-element-handle.md) | Disallow usage of element handles | ✅ | | 💡 | -| [no-eval](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-eval.md) | Disallow usage of `page.$eval()` and `page.$$eval()` | ✅ | | | -| [no-focused-test](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-focused-test.md) | Disallow usage of `.only` annotation | ✅ | | 💡 | -| [no-force-option](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-force-option.md) | Disallow usage of the `{ force: true }` option | ✅ | | | -| [no-get-by-title](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-get-by-title.md) | Disallow using `getByTitle()` | | 🔧 | | -| [no-hooks](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-hooks.md) | Disallow setup and teardown hooks | | | | -| [no-nested-step](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-nested-step.md) | Disallow nested `test.step()` methods | ✅ | | | -| [no-networkidle](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-networkidle.md) | Disallow usage of the `networkidle` option | ✅ | | | -| [no-nth-methods](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-nth-methods.md) | Disallow usage of `first()`, `last()`, and `nth()` methods | | | | -| [no-page-pause](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-page-pause.md) | Disallow using `page.pause()` | ✅ | | | -| [no-raw-locators](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-raw-locators.md) | Disallow using raw locators | | | | -| [no-restricted-locators](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-restricted-locators.md) | Disallow specific locator methods | | | | -| [no-restricted-matchers](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-restricted-matchers.md) | Disallow specific matchers & modifiers | | | | -| [no-skipped-test](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-skipped-test.md) | Disallow usage of the `.skip` annotation | ✅ | | 💡 | -| [no-slowed-test](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-slowed-test.md) | Disallow usage of the `.slow` annotation | | | 💡 | -| [no-standalone-expect](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-standalone-expect.md) | Disallow using expect outside of `test` blocks | ✅ | | | -| [no-unsafe-references](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-unsafe-references.md) | Prevent unsafe variable references in `page.evaluate()` | ✅ | 🔧 | | -| [no-unused-locators](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-unused-locators.md) | Disallow usage of page locators that are not used | ✅ | | | -| [no-useless-await](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-useless-await.md) | Disallow unnecessary `await`s for Playwright methods | ✅ | 🔧 | | -| [no-useless-not](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-useless-not.md) | Disallow usage of `not` matchers when a specific matcher exists | ✅ | 🔧 | | -| [no-wait-for-navigation](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-wait-for-navigation.md) | Disallow usage of `page.waitForNavigation()` | ✅ | | 💡 | -| [no-wait-for-selector](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-wait-for-selector.md) | Disallow usage of `page.waitForSelector()` | ✅ | | 💡 | -| [no-wait-for-timeout](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-wait-for-timeout.md) | Disallow usage of `page.waitForTimeout()` | ✅ | | 💡 | -| [prefer-comparison-matcher](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/prefer-comparison-matcher.md) | Suggest using the built-in comparison matchers | | 🔧 | | -| [prefer-equality-matcher](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/prefer-equality-matcher.md) | Suggest using the built-in equality matchers | | | 💡 | -| [prefer-hooks-in-order](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/prefer-hooks-in-order.md) | Prefer having hooks in a consistent order | | | | -| [prefer-hooks-on-top](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/prefer-hooks-on-top.md) | Suggest having hooks before any test cases | | | | -| [prefer-lowercase-title](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/prefer-lowercase-title.md) | Enforce lowercase test names | | 🔧 | | -| [prefer-native-locators](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/prefer-native-locators.md) | Suggest built-in locators over `page.locator()` | | 🔧 | | -| [prefer-locator](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/prefer-locator.md) | Suggest locators over page methods | | | | -| [prefer-strict-equal](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/prefer-strict-equal.md) | Suggest using `toStrictEqual()` | | | 💡 | -| [prefer-to-be](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/prefer-to-be.md) | Suggest using `toBe()` | | 🔧 | | -| [prefer-to-contain](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/prefer-to-contain.md) | Suggest using `toContain()` | | 🔧 | | -| [prefer-to-have-count](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/prefer-to-have-count.md) | Suggest using `toHaveCount()` | | 🔧 | | -| [prefer-to-have-length](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/prefer-to-have-length.md) | Suggest using `toHaveLength()` | | 🔧 | | -| [prefer-web-first-assertions](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/prefer-web-first-assertions.md) | Suggest using web first assertions | ✅ | 🔧 | | -| [require-hook](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/require-hook.md) | Require setup and teardown code to be within a hook | | | | -| [require-soft-assertions](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/require-soft-assertions.md) | Require assertions to use `expect.soft()` | | 🔧 | | -| [require-to-throw-message](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/require-to-throw-message.md) | Require a message for `toThrow()` | | | | -| [require-top-level-describe](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/require-top-level-describe.md) | Require test cases and hooks to be inside a `test.describe` block | | | | -| [valid-describe-callback](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/valid-describe-callback.md) | Enforce valid `describe()` callback | ✅ | | | -| [valid-expect-in-promise](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/valid-expect-in-promise.md) | Require promises that have expectations in their chain to be valid | ✅ | | | -| [valid-expect](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/valid-expect.md) | Enforce valid `expect()` usage | ✅ | | | -| [valid-title](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/valid-title.md) | Enforce valid titles | ✅ | 🔧 | | -| [valid-test-tags](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/valid-test-tags.md) | Enforce valid tag format in test blocks | ✅ | | | +| Rule | Description | ✅ | 🔧 | 💡 | +| --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | :-: | :-: | :-: | +| [consistent-spacing-between-blocks](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/consistent-spacing-between-blocks.md) | Enforce consistent spacing between test blocks | ✅ | 🔧 | | +| [expect-expect](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/expect-expect.md) | Enforce assertion to be made in a test body | ✅ | | | +| [max-expects](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/max-expects.md) | Enforces a maximum number assertion calls in a test body | | | | +| [max-nested-describe](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/max-nested-describe.md) | Enforces a maximum depth to nested describe calls | ✅ | | | +| [missing-playwright-await](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/missing-playwright-await.md) | Enforce Playwright APIs to be awaited | ✅ | 🔧 | | +| [no-commented-out-tests](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-commented-out-tests.md) | Disallow commented out tests | | | | +| [no-conditional-expect](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-conditional-expect.md) | Disallow calling `expect` conditionally | ✅ | | | +| [no-conditional-in-test](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-conditional-in-test.md) | Disallow conditional logic in tests | ✅ | | | +| [no-duplicate-hooks](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-duplicate-hooks.md) | Disallow duplicate setup and teardown hooks | | | | +| [no-element-handle](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-element-handle.md) | Disallow usage of element handles | ✅ | | 💡 | +| [no-eval](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-eval.md) | Disallow usage of `page.$eval()` and `page.$$eval()` | ✅ | | | +| [no-focused-test](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-focused-test.md) | Disallow usage of `.only` annotation | ✅ | | 💡 | +| [no-force-option](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-force-option.md) | Disallow usage of the `{ force: true }` option | ✅ | | | +| [no-get-by-title](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-get-by-title.md) | Disallow using `getByTitle()` | | 🔧 | | +| [no-hooks](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-hooks.md) | Disallow setup and teardown hooks | | | | +| [no-nested-step](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-nested-step.md) | Disallow nested `test.step()` methods | ✅ | | | +| [no-networkidle](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-networkidle.md) | Disallow usage of the `networkidle` option | ✅ | | | +| [no-nth-methods](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-nth-methods.md) | Disallow usage of `first()`, `last()`, and `nth()` methods | | | | +| [no-page-pause](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-page-pause.md) | Disallow using `page.pause()` | ✅ | | | +| [no-raw-locators](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-raw-locators.md) | Disallow using raw locators | | | | +| [no-restricted-locators](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-restricted-locators.md) | Disallow specific locator methods | | | | +| [no-restricted-matchers](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-restricted-matchers.md) | Disallow specific matchers & modifiers | | | | +| [no-skipped-test](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-skipped-test.md) | Disallow usage of the `.skip` annotation | ✅ | | 💡 | +| [no-slowed-test](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-slowed-test.md) | Disallow usage of the `.slow` annotation | | | 💡 | +| [no-standalone-expect](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-standalone-expect.md) | Disallow using expect outside of `test` blocks | ✅ | | | +| [no-unsafe-references](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-unsafe-references.md) | Prevent unsafe variable references in `page.evaluate()` | ✅ | 🔧 | | +| [no-unused-locators](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-unused-locators.md) | Disallow usage of page locators that are not used | ✅ | | | +| [no-useless-await](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-useless-await.md) | Disallow unnecessary `await`s for Playwright methods | ✅ | 🔧 | | +| [no-useless-not](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-useless-not.md) | Disallow usage of `not` matchers when a specific matcher exists | ✅ | 🔧 | | +| [no-wait-for-navigation](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-wait-for-navigation.md) | Disallow usage of `page.waitForNavigation()` | ✅ | | 💡 | +| [no-wait-for-selector](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-wait-for-selector.md) | Disallow usage of `page.waitForSelector()` | ✅ | | 💡 | +| [no-wait-for-timeout](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-wait-for-timeout.md) | Disallow usage of `page.waitForTimeout()` | ✅ | | 💡 | +| [prefer-comparison-matcher](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/prefer-comparison-matcher.md) | Suggest using the built-in comparison matchers | | 🔧 | | +| [prefer-equality-matcher](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/prefer-equality-matcher.md) | Suggest using the built-in equality matchers | | | 💡 | +| [prefer-hooks-in-order](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/prefer-hooks-in-order.md) | Prefer having hooks in a consistent order | | | | +| [prefer-hooks-on-top](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/prefer-hooks-on-top.md) | Suggest having hooks before any test cases | | | | +| [prefer-lowercase-title](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/prefer-lowercase-title.md) | Enforce lowercase test names | | 🔧 | | +| [prefer-native-locators](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/prefer-native-locators.md) | Suggest built-in locators over `page.locator()` | | 🔧 | | +| [prefer-locator](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/prefer-locator.md) | Suggest locators over page methods | | | | +| [prefer-strict-equal](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/prefer-strict-equal.md) | Suggest using `toStrictEqual()` | | | 💡 | +| [prefer-to-be](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/prefer-to-be.md) | Suggest using `toBe()` | | 🔧 | | +| [prefer-to-contain](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/prefer-to-contain.md) | Suggest using `toContain()` | | 🔧 | | +| [prefer-to-have-count](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/prefer-to-have-count.md) | Suggest using `toHaveCount()` | | 🔧 | | +| [prefer-to-have-length](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/prefer-to-have-length.md) | Suggest using `toHaveLength()` | | 🔧 | | +| [prefer-web-first-assertions](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/prefer-web-first-assertions.md) | Suggest using web first assertions | ✅ | 🔧 | | +| [require-hook](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/require-hook.md) | Require setup and teardown code to be within a hook | | | | +| [require-soft-assertions](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/require-soft-assertions.md) | Require assertions to use `expect.soft()` | | 🔧 | | +| [require-to-throw-message](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/require-to-throw-message.md) | Require a message for `toThrow()` | | | | +| [require-top-level-describe](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/require-top-level-describe.md) | Require test cases and hooks to be inside a `test.describe` block | | | | +| [valid-describe-callback](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/valid-describe-callback.md) | Enforce valid `describe()` callback | ✅ | | | +| [valid-expect-in-promise](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/valid-expect-in-promise.md) | Require promises that have expectations in their chain to be valid | ✅ | | | +| [valid-expect](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/valid-expect.md) | Enforce valid `expect()` usage | ✅ | | | +| [valid-title](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/valid-title.md) | Enforce valid titles | ✅ | 🔧 | | +| [valid-test-tags](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/valid-test-tags.md) | Enforce valid tag format in test blocks | ✅ | | | diff --git a/docs/rules/consistent-spacing-between-blocks.md b/docs/rules/consistent-spacing-between-blocks.md new file mode 100644 index 0000000..cf00160 --- /dev/null +++ b/docs/rules/consistent-spacing-between-blocks.md @@ -0,0 +1,54 @@ +# Enforce consistent spacing between test blocks (`enforce-consistent-spacing-between-blocks`) + +Ensure that there is a consistent spacing between test blocks. + +## Rule Details + +Examples of **incorrect** code for this rule: + +```javascript +test('example 1', () => { + expect(true).toBe(true) +}) +test('example 2', () => { + expect(true).toBe(true) +}) +``` + +```javascript +test.beforeEach(() => {}) +test('example 3', () => { + await test.step('first', async () => { + expect(true).toBe(true) + }) + await test.step('second', async () => { + expect(true).toBe(true) + }) +}) +``` + +Examples of **correct** code for this rule: + +```javascript +test('example 1', () => { + expect(true).toBe(true) +}) + +test('example 2', () => { + expect(true).toBe(true) +}) +``` + +```javascript +test.beforeEach(() => {}) + +test('example 3', () => { + await test.step('first', async () => { + expect(true).toBe(true) + }) + + await test.step('second', async () => { + expect(true).toBe(true) + }) +}) +``` diff --git a/package.json b/package.json index 8bcbafc..15c7ff1 100644 --- a/package.json +++ b/package.json @@ -35,8 +35,8 @@ "lint": "eslint .", "format": "prettier --write .", "format:check": "prettier --check .", - "test": "vitest --run --hideSkippedTests", - "test:watch": "vitest --reporter=dot --run", + "test": "vitest --hideSkippedTests", + "test:watch": "vitest --reporter=dot", "ts": "tsc --noEmit" }, "peerDependencies": { diff --git a/src/index.ts b/src/index.ts index ccf1500..6e90f07 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import globals from 'globals' +import consistentSpacingBetweenBlocks from './rules/consistent-spacing-between-blocks.js' import expectExpect from './rules/expect-expect.js' import maxExpects from './rules/max-expects.js' import maxNestedDescribe from './rules/max-nested-describe.js' @@ -56,6 +57,7 @@ import validTitle from './rules/valid-title.js' const index = { configs: {}, rules: { + 'consistent-spacing-between-blocks': consistentSpacingBetweenBlocks, 'expect-expect': expectExpect, 'max-expects': maxExpects, 'max-nested-describe': maxNestedDescribe, @@ -115,6 +117,7 @@ const index = { const sharedConfig = { rules: { 'no-empty-pattern': 'off', + 'playwright/consistent-spacing-between-blocks': 'warn', 'playwright/expect-expect': 'warn', 'playwright/max-nested-describe': 'warn', 'playwright/missing-playwright-await': 'error', diff --git a/src/rules/consistent-spacing-between-blocks.test.ts b/src/rules/consistent-spacing-between-blocks.test.ts new file mode 100644 index 0000000..ea39c08 --- /dev/null +++ b/src/rules/consistent-spacing-between-blocks.test.ts @@ -0,0 +1,730 @@ +import dedent from 'dedent' +import { runRuleTester } from '../utils/rule-tester.js' +import rule from './consistent-spacing-between-blocks.js' + +runRuleTester('consistent-spacing-between-blocks', rule, { + invalid: [ + { + code: dedent` + test.beforeEach('should pass', () => {}); + test('should fail', async () => { + await test.step('should pass', () => {}); + // a comment + test.step('should fail', () => {}); + test.step('should fail', () => {}); + const foo = await test.step('should fail', () => {}); + foo = await test.step('should fail', () => {}); + }); + /** + * another comment + */ + test('should fail', () => {}); + `, + errors: [ + { line: 2, messageId: 'missingWhitespace' }, + { line: 5, messageId: 'missingWhitespace' }, + { line: 6, messageId: 'missingWhitespace' }, + { line: 7, messageId: 'missingWhitespace' }, + { line: 8, messageId: 'missingWhitespace' }, + { line: 13, messageId: 'missingWhitespace' }, + ], + name: 'missing blank lines before test blocks', + output: dedent` + test.beforeEach('should pass', () => {}); + + test('should fail', async () => { + await test.step('should pass', () => {}); + + // a comment + test.step('should fail', () => {}); + + test.step('should fail', () => {}); + + const foo = await test.step('should fail', () => {}); + + foo = await test.step('should fail', () => {}); + }); + + /** + * another comment + */ + test('should fail', () => {}); + `, + }, + { + code: dedent` + const someText = 'abc'; + test.afterAll(() => { + }); + test.describe('someText', () => { + const something = 'abc'; + // A comment + test.afterAll(() => { + // stuff + }); + test.afterAll(() => { + // other stuff + }); + }); + + test.describe('someText', () => { + const something = 'abc'; + test.afterAll(() => { + // stuff + }); + }); + `, + errors: [ + { line: 2, messageId: 'missingWhitespace' }, + { line: 4, messageId: 'missingWhitespace' }, + { line: 7, messageId: 'missingWhitespace' }, + { line: 10, messageId: 'missingWhitespace' }, + { line: 17, messageId: 'missingWhitespace' }, + ], + name: 'padding around test blocks with describe blocks', + output: dedent` + const someText = 'abc'; + + test.afterAll(() => { + }); + + test.describe('someText', () => { + const something = 'abc'; + + // A comment + test.afterAll(() => { + // stuff + }); + + test.afterAll(() => { + // other stuff + }); + }); + + test.describe('someText', () => { + const something = 'abc'; + + test.afterAll(() => { + // stuff + }); + }); + `, + }, + { + code: dedent` + test('does something', () => {}); + const someVariable = 'value'; + `, + errors: [{ line: 2, messageId: 'missingWhitespace' }], + name: 'Missing padding after test block followed by variable declaration', + output: dedent` + test('does something', () => {}); + + const someVariable = 'value'; + `, + }, + { + code: dedent` + test('does something', () => {}); + function helperFunction() { + return true; + } + `, + errors: [{ line: 2, messageId: 'missingWhitespace' }], + name: 'Missing padding after test block followed by function declaration', + output: dedent` + test('does something', () => {}); + + function helperFunction() { + return true; + } + `, + }, + { + code: dedent` + test('does something', () => {}); + // A comment after test + const x = 1; + `, + errors: [{ line: 3, messageId: 'missingWhitespace' }], + name: 'Missing padding after test block followed by comment', + output: dedent` + test('does something', () => {}); + + // A comment after test + const x = 1; + `, + }, + { + code: dedent` + test.describe('My Test', () => { + test('does something', () => {}); + const helper = 'value'; + }); + `, + errors: [{ line: 3, messageId: 'missingWhitespace' }], + name: 'Missing padding after test block followed by variable in describe', + output: dedent` + test.describe('My Test', () => { + test('does something', () => {}); + + const helper = 'value'; + }); + `, + }, + { + code: dedent` + test('first test', () => {}); + export const config = { timeout: 5000 }; + `, + errors: [{ line: 2, messageId: 'missingWhitespace' }], + name: 'Missing padding after test block followed by export statement', + output: dedent` + test('first test', () => {}); + + export const config = { timeout: 5000 }; + `, + }, + { + code: dedent` + test.beforeEach(() => {}); + const setup = 'value'; + test('does something', () => {}); + `, + errors: [ + { line: 2, messageId: 'missingWhitespace' }, + { line: 3, messageId: 'missingWhitespace' }, + ], + name: 'Missing padding before and after test blocks', + output: dedent` + test.beforeEach(() => {}); + + const setup = 'value'; + + test('does something', () => {}); + `, + }, + ], + valid: [ + { + code: dedent` + const a = 'value'; + doSomething('does something', () => {}); + const b = 'value'; + testing('does something', () => {}); + testing.beforeEach(() => {}); + helloWorld(); + class C{} + function helloWorld() {} + let d = 'value'; + if (e) { + doSomething('does something', () => {}); + } + `, + name: 'Non test blocks', + }, + { + code: dedent` + test('does something', () => {}); + + const someVariable = 'value'; + `, + name: 'Test block followed by variable declaration with proper padding', + }, + { + code: dedent` + test('does something', () => {}); + + function helperFunction() { + return true; + } + `, + name: 'Test block followed by function declaration with proper padding', + }, + { + code: dedent` + test('does something', () => {}); + + // A comment after test + const x = 1; + `, + name: 'Test block followed by comment and code with proper padding', + }, + { + code: dedent` + test.describe('My Test', () => { + test('does something', () => {}); + + const helper = 'value'; + }); + `, + name: 'Test block followed by variable in describe with proper padding', + }, + { + code: dedent` + test('first test', () => {}); + + export const config = { timeout: 5000 }; + `, + name: 'Test block followed by export statement with proper padding', + }, + { + code: dedent` + test.beforeEach(() => {}); + + const setup = 'value'; + + test('does something', () => {}); + `, + name: 'Test blocks with proper padding before and after', + }, + ], +}) + +runRuleTester('consistent-spacing-between-blocks - Mocha tests', rule, { + invalid: [ + { + code: dedent` + test.describe('My Test', function () { + test('does something', () => {}); + test.afterEach(() => {}); + }); + `, + errors: [{ line: 3, messageId: 'missingWhitespace' }], + name: 'Missing line break between test and afterEach', + output: dedent` + test.describe('My Test', function () { + test('does something', () => {}); + + test.afterEach(() => {}); + }); + `, + }, + { + code: dedent` + test.describe('My Test', () => { + test.beforeEach(() => {}); + test('does something', () => {}); + }); + `, + errors: [{ line: 3, messageId: 'missingWhitespace' }], + name: 'Missing line break between beforeEach and test', + output: dedent` + test.describe('My Test', () => { + test.beforeEach(() => {}); + + test('does something', () => {}); + }); + `, + }, + { + code: dedent` + test.describe('Variable declaration', () => { + const a = 1; + test('uses a variable', () => {}); + }); + `, + errors: [{ line: 3, messageId: 'missingWhitespace' }], + name: 'Missing line break after a variable declaration', + output: dedent` + test.describe('Variable declaration', () => { + const a = 1; + + test('uses a variable', () => {}); + }); + `, + }, + { + code: dedent` + test.describe('Same line blocks', () => { + test('block one', () => {}); + test('block two', () => {}); + }); + `, + errors: [{ line: 3, messageId: 'missingWhitespace' }], + name: 'Blocks on the same line', + output: dedent` + test.describe('Same line blocks', () => { + test('block one', () => {}); + + test('block two', () => {}); + }); + `, + }, + { + code: dedent` + test.describe('Same line blocks', () => { + test('block one', () => {}) + .timeout(42); + test('block two', () => {}); + }); + `, + errors: [{ line: 4, messageId: 'missingWhitespace' }], + name: 'Chained method calls', + output: dedent` + test.describe('Same line blocks', () => { + test('block one', () => {}) + .timeout(42); + + test('block two', () => {}); + }); + `, + }, + { + code: dedent` + test.describe("", () => {}); + test.describe("", () => {}); + `, + errors: [{ line: 2, messageId: 'missingWhitespace' }], + name: 'Missing line break between describe calls', + output: dedent` + test.describe("", () => {}); + + test.describe("", () => {}); + `, + }, + { + code: dedent` + test.describe('one', () => {}); + test.describe('two', () => {}); + `, + errors: [{ line: 2, messageId: 'missingWhitespace' }], + name: 'Missing line break between describe calls', + output: dedent` + test.describe('one', () => {}); + + test.describe('two', () => {}); + `, + }, + ], + valid: [ + { + code: dedent` + test.describe('one', () => {}); + + test.describe('two', () => {}); + `, + name: 'Proper line break between describe calls', + }, + { + code: dedent` + test.describe('My Test', () => { + test('does something', () => {}); + }); + `, + name: 'Single test block in describe', + }, + { + code: dedent` + test.describe('My Test', () => { + test('performs action one', () => {}); + + test('performs action two', () => {}); + }); + `, + name: 'Proper line break before each block within describe', + }, + { + code: dedent` + test.describe('Outer block', () => { + test.describe('Inner block', () => { + test('performs an action', () => {}); + }); + + test.afterEach(() => {}); + }); + `, + name: 'Nested describe blocks with proper spacing', + }, + { + code: dedent` + test.describe('My Test With Comments', () => { + test('does something', () => {}); + + // Some comment + test.afterEach(() => {}); + }); + `, + name: 'Describe block with comments', + }, + { + code: dedent` + test('does something outside a describe block', () => {}); + + test.afterEach(() => {}); + `, + name: 'Test blocks outside describe with proper spacing', + }, + { + code: dedent` + test.describe('foo', () => { + test('bar', () => {}).timeout(42); + }); + `, + name: 'Single test with chained timeout', + }, + { + code: dedent` + test.describe('foo', () => { + test('bar', () => {}).timeout(42); + + test('baz', () => {}).timeout(42); + }); + `, + name: 'Multiple tests with chained timeout and proper spacing', + }, + { + code: dedent` + test.describe('foo', () => { + test('bar', () => {}) + .timeout(42); + + test('baz', () => {}) + .timeout(42); + }); + `, + name: 'Multiple tests with multiline chained timeout', + }, + { + code: dedent` + test.describe('foo', () => { + [ + { title: 'bar' }, + { title: 'baz' }, + ].forEach((testCase) => { + test(testCase.title, () => {}); + }); + }); + `, + name: 'Tests created via forEach loop', + }, + ], +}) + +runRuleTester('consistent-spacing-between-blocks - Jest tests', rule, { + invalid: [ + { + code: dedent` + const someText = 'abc'; + test.afterAll(() => { + }); + test.describe('someText', () => { + const something = 'abc'; + // A comment + test.afterAll(() => { + // stuff + }); + test.afterAll(() => { + // other stuff + }); + }); + + test.describe('someText', () => { + const something = 'abc'; + test.afterAll(() => { + // stuff + }); + }); + `, + errors: [ + { column: 1, line: 2, messageId: 'missingWhitespace' }, + { column: 1, line: 4, messageId: 'missingWhitespace' }, + { column: 3, line: 7, messageId: 'missingWhitespace' }, + { column: 3, line: 10, messageId: 'missingWhitespace' }, + { column: 3, line: 17, messageId: 'missingWhitespace' }, + ], + name: 'Missing padding around test blocks with describe blocks', + output: dedent` + const someText = 'abc'; + + test.afterAll(() => { + }); + + test.describe('someText', () => { + const something = 'abc'; + + // A comment + test.afterAll(() => { + // stuff + }); + + test.afterAll(() => { + // other stuff + }); + }); + + test.describe('someText', () => { + const something = 'abc'; + + test.afterAll(() => { + // stuff + }); + }); + `, + }, + { + code: dedent` + const someText = 'abc' + ;test.afterEach(() => {}) + `, + errors: [ + { + column: 2, + line: 2, + messageId: 'missingWhitespace', + }, + ], + name: 'Missing padding after variable with semicolon', + output: dedent` + const someText = 'abc' + + ;test.afterEach(() => {}) + `, + }, + { + code: dedent` + const someText = 'abc'; + xyz: + test.afterEach(() => {}); + `, + errors: [ + { + column: 1, + line: 3, + messageId: 'missingWhitespace', + }, + ], + name: 'Missing padding after variable with label', + output: dedent` + const someText = 'abc'; + + xyz: + test.afterEach(() => {}); + `, + }, + { + code: dedent` + const expr = 'Papayas'; + test.beforeEach(() => {}); + test('does something?', async () => { + switch (expr) { + case 'Oranges': + await test.step('should pass', () => {}); + break; + case 'Mangoes': + case 'Papayas': + const v = 1; + await test.step('should pass', () => {}); + console.log('Mangoes and papayas are $2.79 a pound.'); + // Expected output: "Mangoes and papayas are $2.79 a pound." + break; + default: + console.log(\`Sorry, we are out of $\{expr}.\`); + } + }); + `, + errors: [ + { + column: 1, + endColumn: 27, + endLine: 2, + line: 2, + messageId: 'missingWhitespace', + }, + { + column: 1, + endColumn: 4, + endLine: 18, + line: 3, + messageId: 'missingWhitespace', + }, + { + column: 7, + endColumn: 13, + endLine: 7, + line: 7, + messageId: 'missingWhitespace', + }, + { + column: 7, + endColumn: 48, + endLine: 11, + line: 11, + messageId: 'missingWhitespace', + }, + { + column: 7, + endColumn: 61, + endLine: 12, + line: 12, + messageId: 'missingWhitespace', + }, + ], + name: 'Missing padding in switch statement with test blocks', + output: dedent` + const expr = 'Papayas'; + + test.beforeEach(() => {}); + + test('does something?', async () => { + switch (expr) { + case 'Oranges': + await test.step('should pass', () => {}); + + break; + case 'Mangoes': + case 'Papayas': + const v = 1; + + await test.step('should pass', () => {}); + + console.log('Mangoes and papayas are $2.79 a pound.'); + // Expected output: "Mangoes and papayas are $2.79 a pound." + break; + default: + console.log(\`Sorry, we are out of $\{expr}.\`); + } + }); + `, + }, + ], + valid: [ + { + code: dedent` + xyz: + test.afterEach(() => {}); + `, + name: 'Label before test block', + }, + { + code: dedent` + const someText = 'abc'; + + test.afterAll(() => { + }); + + test.describe('someText', () => { + const something = 'abc'; + + // A comment + test.afterAll(() => { + // stuff + }); + + test.afterAll(() => { + // other stuff + }); + }); + + test.describe('someText', () => { + const something = 'abc'; + + test.afterAll(() => { + // stuff + }); + }); + `, + name: 'Proper padding around test blocks with describe blocks', + }, + ], +}) diff --git a/src/rules/consistent-spacing-between-blocks.ts b/src/rules/consistent-spacing-between-blocks.ts new file mode 100644 index 0000000..ad0eac5 --- /dev/null +++ b/src/rules/consistent-spacing-between-blocks.ts @@ -0,0 +1,191 @@ +import type { AST, Rule, SourceCode } from 'eslint' +import type * as ESTree from 'estree' +import { + areTokensOnSameLine, + getActualLastToken, + getPaddingLineSequences, +} from '../utils/ast.js' +import { createRule } from '../utils/createRule.js' +import { isTypeOfFnCall } from '../utils/parseFnCall.js' +import { createScopeInfo, ScopeInfo } from '../utils/scope.js' +import { NodeWithParent } from '../utils/types.js' + +interface Context { + ruleContext: Rule.RuleContext + scopeInfo: ScopeInfo + sourceCode: SourceCode +} + +const STATEMENT_LIST_PARENTS = new Set([ + 'Program', + 'BlockStatement', + 'SwitchCase', + 'SwitchStatement', +]) + +function isValidParent(parentType: string): boolean { + return STATEMENT_LIST_PARENTS.has(parentType) +} + +/** + * This autofix inserts a blank line between the given 2 statements. If + * the`prevNode` has trailing comments, it inserts a blank line after the + * trailing comments. + */ +function fixPadding( + prevNode: ESTree.Node, + nextNode: ESTree.Node, + ctx: Context, +): void { + const { ruleContext, sourceCode } = ctx + const paddingLines = getPaddingLineSequences(prevNode, nextNode, sourceCode) + + // We've got some padding lines. Great. + if (paddingLines.length > 0) { + return + } + + // Missing padding line + ruleContext.report({ + fix(fixer: Rule.RuleFixer) { + let prevToken = getActualLastToken(sourceCode, prevNode) + + const nextToken = (sourceCode.getFirstTokenBetween(prevToken, nextNode, { + /** + * Skip the trailing comments of the previous node. This inserts a blank + * line after the last trailing comment. + * + * For example: + * + * foo() // trailing comment. + * // comment. + * bar() + * + * Get fixed to: + * + * foo() // trailing comment. + * + * // comment. + * bar() + */ + filter(token): boolean { + if (areTokensOnSameLine(prevToken, token)) { + prevToken = token as AST.Token + return false + } + + return true + }, + includeComments: true, + }) || nextNode) as AST.Token + + const insertText = areTokensOnSameLine(prevToken, nextToken) + ? '\n\n' + : '\n' + + return fixer.insertTextAfter(prevToken, insertText) + }, + messageId: 'missingWhitespace', + node: nextNode, + }) +} + +function isTestNode(node: ESTree.Node, ctx: Context): boolean { + let curNode = node + + if (curNode.type === 'ExpressionStatement') { + curNode = curNode.expression + } else if (curNode.type === 'VariableDeclaration') { + const decl = curNode.declarations.at(-1)! + if (decl.init == null) { + return false + } + + curNode = decl.init + } + + // Unfurl await expressions + if (curNode.type === 'AwaitExpression') { + curNode = curNode.argument + } + + if (curNode.type !== 'CallExpression') { + return false + } + + return isTypeOfFnCall(ctx.ruleContext, curNode, [ + 'describe', + 'test', + 'step', + 'hook', + ]) +} + +function testPadding( + prevNode: ESTree.Node, + nextNode: ESTree.Node, + ctx: Context, +) { + while (nextNode.type === 'LabeledStatement') { + nextNode = nextNode.body + } + + if (isTestNode(prevNode, ctx) || isTestNode(nextNode, ctx)) { + fixPadding(prevNode, nextNode, ctx) + return + } +} + +function verifyNode(node: ESTree.Node, ctx: Context) { + const { scopeInfo } = ctx + + if (!isValidParent((node as NodeWithParent).parent.type)) { + return + } + + if (scopeInfo.prevNode) { + testPadding(scopeInfo.prevNode, node, ctx) + } + + scopeInfo.prevNode = node +} + +export default createRule({ + create(context) { + const scopeInfo = createScopeInfo() + const ctx: Context = { + ruleContext: context, + scopeInfo, + sourceCode: context.sourceCode, + } + + return { + ':statement': (node: ESTree.Node) => verifyNode(node, ctx), + BlockStatement: scopeInfo.enter, + 'BlockStatement:exit': scopeInfo.exit, + Program: scopeInfo.enter, + 'Program:exit': scopeInfo.enter, + SwitchCase(node) { + verifyNode(node, ctx) + scopeInfo.enter() + }, + 'SwitchCase:exit': scopeInfo.exit, + SwitchStatement: scopeInfo.enter, + 'SwitchStatement:exit': scopeInfo.exit, + } + }, + meta: { + docs: { + description: + 'Enforces a blank line between Playwright test blocks (e.g., test, test.step, test.beforeEach, etc.).', + recommended: true, + url: 'https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/consistent-spacing-between-blocks.md', + }, + fixable: 'whitespace', + messages: { + missingWhitespace: 'Expected blank line before this statement.', + }, + schema: [], + type: 'layout', + }, +}) diff --git a/src/utils/ast.ts b/src/utils/ast.ts index 22f42f0..af10641 100644 --- a/src/utils/ast.ts +++ b/src/utils/ast.ts @@ -1,4 +1,4 @@ -import { Rule } from 'eslint' +import { AST, Rule, SourceCode } from 'eslint' import ESTree, { AssignmentExpression } from 'estree' import { isSupportedAccessor } from './parseFnCall.js' import { NodeWithParent, TypedNodeWithParent } from './types.js' @@ -244,3 +244,66 @@ export function dereference( return expr?.right ?? decl?.init } + +/** + * Gets the actual last token. + * + * If a semicolon is semicolon-less style's semicolon, this ignores it. For + * example: + * + * foo() + * ;[1, 2, 3].forEach(bar) + */ +export const getActualLastToken = ( + sourceCode: SourceCode, + node: ESTree.Node, +): AST.Token => { + const semiToken = sourceCode.getLastToken(node)! + const prevToken = sourceCode.getTokenBefore(semiToken)! + const nextToken = sourceCode.getTokenAfter(semiToken) + + const isSemicolonLessStyle = + !!prevToken && + !!nextToken && + prevToken.range![0] >= node.range![0] && + semiToken.type === 'Punctuator' && + semiToken.value === ';' && + semiToken.loc.start.line !== prevToken.loc.end.line && + semiToken.loc.end.line === nextToken.loc.start.line + + return isSemicolonLessStyle ? prevToken : semiToken +} + +/** + * Gets padding line sequences between the given 2 statements. Comments are + * separators of the padding line sequences. + */ +export const getPaddingLineSequences = ( + prevNode: ESTree.Node, + nextNode: ESTree.Node, + sourceCode: SourceCode, +): AST.Token[][] => { + const pairs: AST.Token[][] = [] + let prevToken = getActualLastToken(sourceCode, prevNode) + + if (nextNode.loc!.start.line - prevToken.loc.end.line >= 2) { + do { + const token = sourceCode.getTokenAfter(prevToken, { + includeComments: true, + }) as AST.Token + + if (token.loc.start.line - prevToken.loc.end.line >= 2) { + pairs.push([prevToken, token]) + } + + prevToken = token + } while (prevToken.range[0] < nextNode.range![0]) + } + + return pairs +} + +export const areTokensOnSameLine = ( + left: ESTree.Comment | AST.Token, + right: ESTree.Comment | AST.Token, +): boolean => left.loc!.end.line === right.loc!.start.line diff --git a/src/utils/scope.ts b/src/utils/scope.ts new file mode 100644 index 0000000..092e0be --- /dev/null +++ b/src/utils/scope.ts @@ -0,0 +1,31 @@ +import * as ESTree from 'estree' + +export interface Scope { + prevNode: ESTree.Node | null + upper: Scope | null +} + +export interface ScopeInfo { + enter(): void + exit(): void + prevNode: ESTree.Node | null +} + +export function createScopeInfo(): ScopeInfo { + let scope: Scope | null = null + + return { + enter() { + scope = { prevNode: null, upper: scope } + }, + exit() { + scope = scope!.upper + }, + get prevNode() { + return scope!.prevNode + }, + set prevNode(node: ESTree.Node | null) { + scope!.prevNode = node + }, + } +}