Use when writing or reviewing Playwright end-to-end tests. Covers Page Object Model, fixtures, selectors, assertions, authentication, network mocking, visual testing, and test organization. Invoke explicitly -- not loaded by default.
From beenpx claudepluginhub bee-coded/bee-dev --plugin beeThis skill uses the workspace's default tool permissions.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Enables AI agents to execute x402 payments with per-task budgets, spending controls, and non-custodial wallets via MCP tools. Use when agents pay for APIs, services, or other agents.
These standards apply when writing or reviewing Playwright end-to-end tests. This skill is invoked on demand — it is NOT loaded automatically by stack skills. Use it when the task involves writing, reviewing, or debugging Playwright tests.
getByRole() — accessible role + name. Best for buttons, links, headings, inputs.getByLabel() — form inputs by their associated label text.getByText() — visible text content. Use for paragraphs, messages, status text.getByPlaceholder() — inputs by placeholder when no label exists.getByTestId() — data-testid attribute. Last resort when semantic selectors don't work.// ✅ Preferred — semantic, resilient to UI changes
await page.getByRole('button', { name: 'Submit Order' }).click();
await page.getByLabel('Email address').fill('user@example.com');
await page.getByRole('heading', { name: 'Order Confirmation' }).isVisible();
// ✅ Acceptable — when semantic doesn't work
await page.getByTestId('order-total').textContent();
// ❌ Avoid — fragile, breaks on refactors
await page.locator('.btn-primary').click();
await page.locator('#email-input').fill('user@example.com');
await page.locator('div > span.price').textContent();
.btn, .card-header) — they change on refactors.div > div > span) — break on layout changes.getByRole with { name: } for interactive elements.locator().filter() for narrowing within a section.// Filter within a specific section
const orderSection = page.locator('[data-testid="order-123"]');
await orderSection.getByRole('button', { name: 'Cancel' }).click();
// Chaining with nth for lists
await page.getByRole('listitem').nth(2).getByRole('button', { name: 'Edit' }).click();
Every major page or reusable section gets a Page Object class:
// pages/orders.page.ts
import { type Page, type Locator, expect } from '@playwright/test';
export class OrdersPage {
readonly page: Page;
readonly searchInput: Locator;
readonly createButton: Locator;
readonly orderRows: Locator;
constructor(page: Page) {
this.page = page;
this.searchInput = page.getByRole('searchbox');
this.createButton = page.getByRole('button', { name: 'Create Order' });
this.orderRows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
}
async goto() {
await this.page.goto('/orders');
}
async search(query: string) {
await this.searchInput.fill(query);
// Auto-waits for network, no manual wait needed
}
async createOrder(data: { client: string; notes: string }) {
await this.createButton.click();
await this.page.getByLabel('Client').selectOption(data.client);
await this.page.getByLabel('Notes').fill(data.notes);
await this.page.getByRole('button', { name: 'Save' }).click();
}
async expectOrderCount(count: number) {
await expect(this.orderRows).toHaveCount(count);
}
async expectOrderVisible(name: string) {
await expect(this.page.getByRole('cell', { name })).toBeVisible();
}
}
expect* methods.expect* helper methods. Tests decide what to assert.tests/pages/ or e2e/pages/ — separate from test files.Extend the base test to provide POMs and shared setup:
// fixtures.ts
import { test as base } from '@playwright/test';
import { OrdersPage } from './pages/orders.page';
import { LoginPage } from './pages/login.page';
type Fixtures = {
ordersPage: OrdersPage;
loginPage: LoginPage;
};
export const test = base.extend<Fixtures>({
ordersPage: async ({ page }, use) => {
const ordersPage = new OrdersPage(page);
await use(ordersPage);
},
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await use(loginPage);
},
});
export { expect } from '@playwright/test';
// orders.spec.ts
import { test, expect } from './fixtures';
test('can search orders', async ({ ordersPage }) => {
await ordersPage.goto();
await ordersPage.search('shipped');
await ordersPage.expectOrderVisible('Order #123');
});
For multi-role testing (admin vs user):
export const test = base.extend<{ adminPage: AdminPage; userPage: UserPage }>({
adminPage: async ({ browser }, use) => {
const ctx = await browser.newContext({ storageState: 'playwright/.auth/admin.json' });
const page = await ctx.newPage();
await use(new AdminPage(page));
await ctx.close();
},
userPage: async ({ browser }, use) => {
const ctx = await browser.newContext({ storageState: 'playwright/.auth/user.json' });
const page = await ctx.newPage();
await use(new UserPage(page));
await ctx.close();
},
});
Use a setup project to authenticate once, save state, reuse across tests:
// playwright.config.ts
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
],
});
// auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('test@example.com');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: authFile });
});
setup('authenticate via API', async ({ request }) => {
await request.post('/api/login', {
data: { email: 'test@example.com', password: 'password' },
});
await request.storageState({ path: 'playwright/.auth/user.json' });
});
test('shows orders from mocked API', async ({ page }) => {
await page.route('**/api/orders', async (route) => {
await route.fulfill({
json: [
{ id: 1, name: 'Order A', status: 'active' },
{ id: 2, name: 'Order B', status: 'pending' },
],
});
});
await page.goto('/orders');
await expect(page.getByText('Order A')).toBeVisible();
await expect(page.getByText('Order B')).toBeVisible();
});
test('adds extra item to real API response', async ({ page }) => {
await page.route('**/api/orders', async (route) => {
const response = await route.fetch();
const json = await response.json();
json.push({ id: 999, name: 'Injected Order', status: 'test' });
await route.fulfill({ json });
});
await page.goto('/orders');
await expect(page.getByText('Injected Order')).toBeVisible();
});
test('submits form and waits for API response', async ({ page }) => {
await page.goto('/orders/create');
const responsePromise = page.waitForResponse('**/api/orders');
await page.getByRole('button', { name: 'Submit' }).click();
const response = await responsePromise;
expect(response.status()).toBe(201);
});
Always use expect() from Playwright — it auto-retries until timeout:
// ✅ Auto-retrying — waits up to timeout for condition
await expect(page.getByText('Order created')).toBeVisible();
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
await expect(page).toHaveURL('/orders/1');
await expect(page).toHaveTitle(/Orders/);
await expect(page.getByRole('table')).toContainText('Order A');
// ❌ Non-retrying — snapshot check, can be flaky
expect(await page.textContent('.status')).toBe('active'); // DON'T
await expect(locator).toBeVisible();
await expect(locator).toBeHidden();
await expect(locator).toBeEnabled();
await expect(locator).toBeDisabled();
await expect(locator).toHaveText('exact text');
await expect(locator).toContainText('partial');
await expect(locator).toHaveCount(5);
await expect(locator).toHaveAttribute('href', '/orders');
await expect(locator).toHaveClass(/active/);
await expect(locator).toHaveValue('search term');
await expect(page).toHaveURL(/orders/);
// Full page screenshot comparison
await expect(page).toHaveScreenshot('orders-page.png');
// Component screenshot comparison
await expect(page.getByTestId('order-card')).toHaveScreenshot('order-card.png');
// With threshold for minor differences
await expect(page).toHaveScreenshot('dashboard.png', { maxDiffPixels: 100 });
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [['html', { open: 'never' }]],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
{
name: 'mobile',
use: { ...devices['iPhone 14'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
tests/
e2e/
pages/ ← Page Object Models
orders.page.ts
login.page.ts
dashboard.page.ts
fixtures.ts ← Custom test fixtures
auth.setup.ts ← Authentication setup
orders.spec.ts ← Test files by feature
dashboard.spec.ts
auth.spec.ts
playwright.config.ts
playwright/
.auth/ ← Saved auth state (gitignored)
page.waitForTimeout() / sleep — Playwright auto-waits. If you need a wait, you're missing a proper assertion or locator.getByRole, getByLabel, getByText.test.describe.serial() unless absolutely necessary.expect(locator).toHaveText() not expect(await locator.textContent()).toBe().baseURL from config and relative paths.expect* helpers, tests decide what to assert.trace: 'on-first-retry' captures everything. Use npx playwright show-trace to debug.storageState across all tests.page.route() to avoid flaky network dependencies.toHaveScreenshot() on key pages and components.fullyParallel: true. No shared state, no ordering.trace: 'on-first-retry' to capture full execution trace for debugging.devices['iPhone 14']) for responsive testing.waitForResponse for form submissions. Verify the server received and processed the request.When looking up Playwright documentation, use these Context7 library identifiers:
/websites/playwright_dev — API reference, best practices, configuration, assertions/microsoft/playwright — source-level API, latest featuresAlways check Context7 for the latest Playwright API — features like component testing and API testing evolve between versions.