npm package: @kinosuke01/eslint-plugin-playwright-pom
Description: ESLint plugin that enforces Page Object Model (POM) pattern for Playwright tests
Author: kinosuke01
Latest version: 0.0.1

# eslint-plugin-playwright-pom

ESLint plugin that enforces Page Object Model (POM) pattern for Playwright tests, including proper directory structure, separation of concerns, and robust element selection strategies.

## Table of Contents

- [NPM Package Usage](#-npm-package-usage)
  - [Installation](#installation)
  - [Configuration](#configuration)
  - [Rules](#rules)
  - [Directory Structure](#directory-structure)
- [Developer Guide](#-developer-guide)
  - [Development Setup](#development-setup)
  - [Available Scripts](#available-scripts)
  - [Testing](#testing)
  - [Building](#building)

---

## 📦 NPM Package Usage

### Installation

```bash
npm install --save-dev @kinosuke01/eslint-plugin-playwright-pom
```

### Configuration

#### ESLint Flat Config (eslint.config.js)

```javascript
import playwrightPom from '@kinosuke01/eslint-plugin-playwright-pom';

export default [
  {
    plugins: {
      'playwright-pom': playwrightPom
    },
    rules: {
      ...playwrightPom.configs.recommended.rules
    }
  }
];
```

#### Legacy Config (.eslintrc.json)

```json
{
  "plugins": ["@kinosuke01/playwright-pom"],
  "rules": {
    "@kinosuke01/playwright-pom/directory-structure": "error",
    "@kinosuke01/playwright-pom/allow-assertions-only-in-flows": "error",
    "@kinosuke01/playwright-pom/allow-locators-only-in-pages": "error",
    "@kinosuke01/playwright-pom/discourage-locators": "warn"
  }
}
```

### Rules

This plugin provides 4 rules to enforce the Page Object Model pattern:

#### 1. `directory-structure` (error)

**Enforces strict directory structure and naming conventions for Playwright test files.**

This rule ensures that your test codebase follows a clear, organized structure:

- **Test specs** (`*.spec.ts`) → Must be in `tests/flows/` directory
- **Page Objects** (`*-page.ts`) → Must be in `tests/pages/` directory
- **Fixtures** (`*-fixture.ts`) → Must be in `tests/fixtures/` directory
- **Helpers** → Must be in `tests/helpers/` directory with kebab-case naming

**Examples:**

```typescript
// ✅ Valid - Test spec in flows directory
// tests/flows/login.spec.ts
import { test } from '@playwright/test';

// ✅ Valid - Page Object in pages directory
// tests/pages/login-page.ts
export class LoginPage {}

// ✅ Valid - Fixture in fixtures directory
// tests/fixtures/user-fixture.ts
export const userFixture = test.extend({});

// ✅ Valid - Helper with kebab-case naming
// tests/helpers/date-formatter.ts
export function formatDate() {}

// ❌ Invalid - Spec file not in flows/
// tests/login.spec.ts

// ❌ Invalid - Page Object not in pages/
// tests/login-page.ts

// ❌ Invalid - Wrong naming convention
// tests/pages/LoginPage.ts (should be login-page.ts)
```

---

#### 2. `allow-assertions-only-in-flows` (error)

**Prohibits the use of assertions (`expect`) outside of test flow files.**

This rule enforces separation of concerns by ensuring:

- **Test assertions** are only written in `tests/flows/` (test spec files)
- **Page Objects** focus on returning values and encapsulating page interactions
- Clear boundary between test logic and page abstraction

**Why?** Page Objects should be reusable and testable independently. Embedding assertions in Page Objects couples them to specific test expectations, reducing reusability.

**Examples:**

```typescript
// ✅ Valid - Assertions in test flow
// tests/flows/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login-page';

test('user can login', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.navigate();
  await loginPage.login('user@example.com', 'password');

  expect(await loginPage.getWelcomeMessage()).toBe('Welcome!');
});

// ✅ Valid - Page Object returns data for testing
// tests/pages/login-page.ts
export class LoginPage {
  async getWelcomeMessage() {
    return await this.page.locator('[data-testid="welcome"]').textContent();
  }
}

// ❌ Invalid - Assertion in Page Object
// tests/pages/login-page.ts
import { expect } from '@playwright/test'; // ❌ Error: expect import outside flows/

export class LoginPage {
  async verifyLogin() {
    expect(this.page.url()).toContain('/dashboard'); // ❌ Error: assertion outside flows/
  }
}
```

---

#### 3. `allow-locators-only-in-pages` (error)

**Restricts element locator methods to Page Objects only.**

This rule enforces the core POM principle: element selection logic must be encapsulated in Page Objects.

**Restricted methods (only allowed in `tests/pages/`):**
- `locator()`
- `getByRole()`
- `getByTestId()`
- `getByText()`
- `getByLabel()`
- `getByPlaceholder()`
- `getByAltText()`
- `getByTitle()`
- `querySelector()` / `querySelectorAll()`
- `$()` / `$$()`

**Benefits:**
- **Maintainability**: When UI changes, update only Page Objects
- **Reusability**: Same page methods across multiple tests
- **Readability**: Tests express business logic, not DOM queries

**Examples:**

```typescript
// ✅ Valid - Locators in Page Object
// tests/pages/login-page.ts
export class LoginPage {
  constructor(private page: Page) {}

  get emailInput() {
    return this.page.getByTestId('email-input');
  }

  get passwordInput() {
    return this.page.getByTestId('password-input');
  }

  get submitButton() {
    return this.page.getByRole('button', { name: 'Sign in' });
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}

// ✅ Valid - Test uses Page Object methods
// tests/flows/login.spec.ts
test('user can login', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.login('user@example.com', 'password123');
});

// ❌ Invalid - Direct locator usage in test
// tests/flows/login.spec.ts
test('user can login', async ({ page }) => {
  await page.getByTestId('email-input').fill('user@example.com'); // ❌ Error
  await page.getByTestId('password-input').fill('password123'); // ❌ Error
  await page.getByRole('button', { name: 'Sign in' }).click(); // ❌ Error
});
```

---

#### 4. `discourage-locators` (warn)

**Discourages fragile element locator methods in favor of robust selection strategies.**

This rule promotes using locators that are resilient to UI changes.

**Recommended methods:**
- ✅ `getByRole()` - Based on ARIA roles, semantic and accessible
- ✅ `getByTestId()` - Explicit test identifiers, stable across changes

**Discouraged methods (generates warnings):**
- ⚠️ `locator()` - Generic CSS/XPath, can be fragile
- ⚠️ `getByText()` - Breaks when copy changes
- ⚠️ `getByLabel()` - Dependent on label text
- ⚠️ `getByPlaceholder()` - Dependent on placeholder text
- ⚠️ `getByAltText()` - Dependent on alt text
- ⚠️ `getByTitle()` - Dependent on title attribute
- ⚠️ `querySelector()` / `querySelectorAll()` - Low-level DOM queries
- ⚠️ `$()` / `$$()` - Playwright shorthand for querySelector

**Examples:**

```typescript
// ✅ Recommended - Using getByRole
await page.getByRole('button', { name: 'Submit' }).click();

// ✅ Recommended - Using getByTestId
await page.getByTestId('submit-button').click();

// ⚠️ Warning - Using getByText (fragile against copy changes)
await page.getByText('Submit').click();

// ⚠️ Warning - Using generic locator
await page.locator('.submit-btn').click();

// ⚠️ Warning - Using querySelector
await page.querySelector('.submit-btn').click();
```

**Why these recommendations?**
- `getByRole()` aligns with accessibility best practices
- `getByTestId()` provides stable, explicit test hooks
- Text-based selectors break when copy/translations change
- CSS selectors break when styling refactors occur

---

### Directory Structure

The recommended directory structure enforced by this plugin:

```
tests/
├── flows/              # Test specification files
│   ├── login.spec.ts
│   └── checkout.spec.ts
├── pages/              # Page Object classes
│   ├── login-page.ts
│   ├── home-page.ts
│   └── checkout-page.ts
├── fixtures/           # Test fixtures
│   └── user-fixture.ts
└── helpers/            # Utility functions
    ├── date-formatter.ts
    └── data-generator.ts
```

**Example: Complete Login Flow**

```typescript
// tests/pages/login-page.ts
import type { Page } from '@playwright/test';

export class LoginPage {
  constructor(private page: Page) {}

  async navigate() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.page.getByTestId('email-input').fill(email);
    await this.page.getByTestId('password-input').fill(password);
    await this.page.getByRole('button', { name: 'Sign in' }).click();
  }

  async getErrorMessage() {
    return await this.page.getByTestId('error-message').textContent();
  }
}

// tests/flows/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login-page';

test.describe('Login Flow', () => {
  test('should login successfully with valid credentials', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.navigate();
    await loginPage.login('user@example.com', 'password123');

    await expect(page).toHaveURL('/dashboard');
  });

  test('should show error with invalid credentials', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.navigate();
    await loginPage.login('invalid@example.com', 'wrongpassword');

    expect(await loginPage.getErrorMessage()).toContain('Invalid credentials');
  });
});
```

---

## 🛠️ Developer Guide

### Development Setup

1. **Clone the repository**
   ```bash
   git clone https://github.com/kinosuke01/eslint-plugin-playwright-pom.git
   cd eslint-plugin-playwright-pom
   ```

2. **Install dependencies**
   ```bash
   npm install
   ```

3. **Build the project**
   ```bash
   npm run build
   ```

### Available Scripts

| Script | Command | Description |
|--------|---------|-------------|
| **Build** | `npm run build` | Compile TypeScript to JavaScript using tsup |
| **Lint** | `npm run lint` | Check code quality with Biome |
| **Lint Fix** | `npm run lint:fix` | Auto-fix linting issues |
| **Test** | `npm run test` | Run tests once with Vitest |
| **Test Watch** | `npm run test:watch` | Run tests in watch mode |
| **Test Coverage** | `npm run test:coverage` | Generate test coverage report |

### Testing

This project uses [Vitest](https://vitest.dev/) for testing.

**Run all tests:**
```bash
npm test
```

**Run tests in watch mode during development:**
```bash
npm run test:watch
```

**Generate coverage report:**
```bash
npm run test:coverage
```

Test files are co-located with source files following the pattern `*.test.ts`.

### Building

The project uses [tsup](https://tsup.egoist.dev/) for building:

```bash
npm run build
```

This generates:
- `dist/index.js` - CommonJS output
- `dist/index.mjs` - ESM output
- `dist/index.d.ts` - TypeScript declarations

The build is automatically run before publishing via the `prepublishOnly` hook.

### Project Structure

```
eslint-plugin-playwright-pom/
├── src/
│   ├── index.ts                              # Plugin entry point
│   └── rules/
│       ├── directory-structure.ts            # Directory/naming enforcement
│       ├── allow-assertions-only-in-flows.ts # Assertion restriction
│       ├── allow-locators-only-in-pages.ts   # Locator restriction
│       └── discourage-locators.ts            # Locator recommendation
├── dist/                                      # Build output (gitignored)
├── package.json
├── tsconfig.json
└── README.md
```

### License

MIT

### Author

kinosuke01