Initialize Playwright Architecture Rule
Write the Playwright architecture rule to .claude/rules/playwright-architecture.md in the current project. The rule auto-loads when Claude touches files matching tests/**, playwright-utils/**, or playwright.config.ts.
Target File
.claude/rules/playwright-architecture.md
Workflow
- Verify project context — cwd must contain
package.json. Otherwise stop and tell the user. - Ensure
.claude/rules/exists —mkdir -p .claude/rules. - Check for existing file:
- Not present → write the content from the
<!-- RULES_START -->block below, then reportCreated .claude/rules/playwright-architecture.md. - Present and identical → report
Already up to dateand exit. - Present and different → use
AskUserQuestionwith options: Overwrite / Show diff / Skip.- Overwrite → write content, report
Updated .claude/rules/playwright-architecture.md. - Show diff → run
git diff --no-index .claude/rules/playwright-architecture.md <temp-file-with-new-content>, then re-prompt Overwrite / Skip. - Skip → leave existing file untouched, report
Skipped — kept existing file.
- Overwrite → write content, report
- Not present → write the content from the
- Do NOT touch
CLAUDE.md. Rules in.claude/rules/*.mdauto-load via path frontmatter; explicit@imports are redundant.
Content to write
Everything between <!-- RULES_START --> and <!-- RULES_END --> (exclusive of the markers) is the verbatim file content, including the rule's own frontmatter.
description: Playwright Page Objects, PageManager, fixtures, auth setup, and test organization patterns paths: [tests/,playwright-utils/,playwright.config.ts]
Playwright Architecture
When to Use What
| Pattern | When | Location |
|---|---|---|
| Page Object | A page or major component with multi-step user flows | playwright-utils/pages/ |
| PageManager | Single class that constructs every page object — exposed via the pom fixture |
playwright-utils/fixtures/page-manager.ts |
| Custom Fixture | Resources needing setup/teardown (auth, DB, API) | playwright-utils/fixtures/ |
| Helper Function | Stateless utility, no cleanup needed | playwright-utils/helpers/ |
Page Object Conventions
- One TypeScript class per page or major component
- File name: kebab-case (
login-page.ts,course-detail-page.ts) - Class name: PascalCase +
Page(or component-appropriate) suffix (LoginPage,HeaderComponent) - Constructor takes
Pageonly — no locator properties, no eager locator construction - Locators inline in methods — locators are self-descriptive and lazy; duplicating across methods is fine
- No tiny methods — a method covers a meaningful user task with multiple steps; never a single click or fill
- Strict page boundaries — a method only interacts with its own page; navigation marks the end of one method and the start of another on the next page
- Validation at the start — if the first step auto-waits (
click,fill, locator assertion), the auto-wait is the start guard. Otherwise gate with an explicitawait expect(...)orwaitFor(...)before non-auto-waiting code (textContent,count,all,inputValue,allTextContents) - Validation at the end — when the last step is an action without a trailing assertion, add a confirmation. For navigation use
expect(page).toHaveURL(...)(page-agnostic). For in-page changes usetoBeVisible/toHaveText/toContainTexton the affected element. This is a stabilizing check, not the test's goal assertion - Action vs validation methods — action methods may include lightweight stabilizing assertions; the test's goal assertions live in dedicated
expect*-prefixed methods that are parametrized for reuse - Naming — camelCase, descriptive verb phrases, no abbreviations or acronyms
- Reuse first — before adding a new method, scan the relevant class. Reuse if a method covers the flow; parametrize an existing method if it nearly does. Never write two methods that differ only in a hardcoded value
// playwright-utils/pages/login-page.ts
import { type Page, expect } from '@playwright/test'
export class LoginPage {
constructor(private page: Page) {}
async loginWithCredentials(email: string, password: string) {
await this.page.getByRole('textbox', { name: 'Email' }).fill(email)
await this.page.getByLabel('Password').fill(password)
await this.page.getByRole('button', { name: 'Login', exact: true }).click()
await expect(this.page).toHaveURL(/\/dashboard$/)
}
// Validation method — owns the test's goal assertion, parametrized for reuse
async expectErrorMessage(message: string) {
await expect(this.page.getByRole('alert')).toHaveText(message)
}
}
Test body reads as action → action → validation:
test('User sees error for invalid password', async ({ pom }) => {
await pom.homePage.openLogin()
await pom.loginPage.loginWithCredentials('user@example.com', 'wrong-password')
await pom.loginPage.expectErrorMessage('Invalid email or password')
})
Component-Level Page Objects
For widgets reused across pages (header, cart drawer, modals), create a separate class. Components follow the same rules as pages.
export class HeaderComponent {
constructor(private page: Page) {}
async openLogin() {
await this.page.getByRole('link', { name: 'Log In' }).click()
await expect(this.page).toHaveURL(/\/login$/)
}
}
PageManager and the pom Fixture
Every page object is instantiated eagerly inside PageManager and exposed as a camelCase property. A single pom fixture is the only test entry point — tests never construct page objects directly.
// playwright-utils/fixtures/page-manager.ts
import type { Page } from '@playwright/test'
import { LoginPage } from '../pages/login-page'
import { DashboardPage } from '../pages/dashboard-page'
import { HomePage } from '../pages/home-page'
export class PageManager {
readonly homePage: HomePage
readonly loginPage: LoginPage
readonly dashboardPage: DashboardPage
constructor(page: Page) {
this.homePage = new HomePage(page)
this.loginPage = new LoginPage(page)
this.dashboardPage = new DashboardPage(page)
}
}
// playwright-utils/fixtures/index.ts
import { test as base, expect } from '@playwright/test'
import { PageManager } from './page-manager'
type Fixtures = { pom: PageManager }
export const test = base.extend<Fixtures>({
pom: async ({ page }, use) => {
await use(new PageManager(page))
},
})
export { expect }
Usage in tests:
import { test, expect } from '../../playwright-utils/fixtures'
test('User can log in', async ({ pom }) => {
await pom.homePage.openLogin()
await pom.loginPage.loginWithCredentials('user@example.com', 'Password123!')
})
When adding a new page class:
- Create
playwright-utils/pages/<page-name>.ts - In
PageManager: add the import, thereadonlyproperty, and the constructor instantiation - Property name = camelCase of the class name (
LoginPage→loginPage)
Auth Setup with storageState
Save authenticated sessions to avoid logging in every test:
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test'
const studentFile = 'playwright-utils/.auth/student.json'
const adminFile = 'playwright-utils/.auth/admin.json'
setup('authenticate as student', async ({ page }) => {
await page.goto('/')
await page.getByRole('link', { name: 'Log In' }).click()
await page.getByRole('textbox', { name: 'Email' }).fill(process.env.STUDENT_EMAIL!)
await page.getByLabel('Password').fill(process.env.STUDENT_PASSWORD!)
await page.getByRole('button', { name: 'Login', exact: true }).click()
await page.waitForURL('**/dashboard')
await page.context().storageState({ path: studentFile })
})
setup('authenticate as admin', async ({ page }) => {
await page.goto('/')
await page.getByRole('link', { name: 'Log In' }).click()
await page.getByRole('textbox', { name: 'Email' }).fill(process.env.ADMIN_EMAIL!)
await page.getByLabel('Password').fill(process.env.ADMIN_PASSWORD!)
await page.getByRole('button', { name: 'Login', exact: true }).click()
await page.waitForURL('**/admin')
await page.context().storageState({ path: adminFile })
})
Auth setup intentionally bypasses the POM — setup() blocks run before any test and don't import the pom fixture.
Config with Auth Projects
// playwright.config.ts pattern
projects: [
{ name: 'auth-setup', testMatch: 'auth.setup.ts' },
{
name: 'student-tests',
testMatch: 'student/**/*.spec.ts',
use: { storageState: 'playwright-utils/.auth/student.json' },
dependencies: ['auth-setup'],
},
{
name: 'admin-tests',
testMatch: 'admin/**/*.spec.ts',
use: { storageState: 'playwright-utils/.auth/admin.json' },
dependencies: ['auth-setup'],
},
{
name: 'guest-tests',
testMatch: 'guest/**/*.spec.ts',
// No storageState — unauthenticated
},
]
Directory Structure
tests/ is for spec files and setup files only — everything executable by the test runner. All support code lives in playwright-utils/.
tests/
auth.setup.ts # Auth state persistence (setup project)
student/ # Authenticated student tests
login.spec.ts
courses.spec.ts
dashboard.spec.ts
admin/ # Authenticated admin tests
courses.spec.ts
users.spec.ts
guest/ # Unauthenticated tests
catalog.spec.ts
blog.spec.ts
playwright-utils/
fixtures/
index.ts # `pom` fixture + extended `test` and `expect`
page-manager.ts # PageManager — constructs every page object
pages/
home-page.ts
login-page.ts
dashboard-page.ts
course-detail-page.ts
helpers/
test-data.ts # Data factories, utilities
Configuration Best Practices
// playwright.config.ts
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry', // Trace for debugging failures
screenshot: 'only-on-failure', // Screenshot on failure
video: 'retain-on-failure', // Video for CI debugging
}
CI webServer Config
// Auto-start dev server in CI
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
}
Test Suite Hooks
When all tests in a test.describe block share the same setup steps (e.g., starting at the home page), extract them into test.beforeEach.
// GOOD: shared setup in beforeEach
test.describe('Guest Smoke', () => {
test.beforeEach(async ({ pom }) => {
await pom.homePage.open()
})
test('Home page displays key sections', async ({ pom }) => {
await pom.homePage.expectHeroVisible()
})
test('User can navigate to blog', async ({ pom }) => {
await pom.homePage.openBlog()
await pom.blogPage.expectArticlesListVisible()
})
})
Anti-Patterns
- Locator constants on the class (constructor or properties) — keep locators inline in methods
- Single-action methods (
clickLoginButton,fillEmail) — fold them into a multi-step flow - Methods spanning two pages — every navigation marks a method boundary on a different page object
- Action methods owning goal assertions — the test's goal lives in a separate
expect*validation method - Duplicate methods differing only in a hardcoded value — parametrize the existing method instead
- Fat page objects with every possible method — only add what tests actually use
- Shared mutable state between tests — each test must be independent
- Global variables for test data — use fixtures with proper setup/teardown
- Skipping cleanup — fixtures guarantee teardown even on failure
Notes
- The
@symbol at the start of a line is treated as a file import by Claude Code — escape with\@in prose if it ever appears (none in current content). - This skill is self-contained: the rule lives inside
SKILL.md, so it works on any machine, even one without other Playwright skills installed. - To update the rule across projects, update this
SKILL.mdonce, then re-run/init-playwright-architecturein each project.