
Playwright Pro
- 604 installs
- 23.5k repo stars
- Updated July 17, 2026
- alirezarezvani/claude-skills
playwright-pro is a Claude Code skill that teaches reliable, auto-retrying Playwright web-first assertions for developers who need stable end-to-end tests on dynamic UIs.
About
playwright-pro is a reference skill from alirezarezvani/claude-skills that catalogs Playwright web-first assertions designed to auto-retry until timeout, making them safe for dynamic content and flaky timing. The skill covers visibility checks like toBeVisible and toBeHidden, text matchers including toHaveText and toContainText, input value assertions, and attribute or class validation with regex support. Developers reach for playwright-pro when Playwright tests fail intermittently because imperative checks run before the DOM settles, or when they want a consistent assertion style across a TypeScript test suite. The excerpts show concrete TypeScript patterns using expect(locator) against real UI states rather than manual waits or sleep calls.
- Web-first assertions with automatic retry until timeout
- Covers visibility, text, value, attributes, state, count, CSS and screenshots
- Page-level URL, title and full-page screenshot assertions
- Explicit anti-patterns section to prevent common test mistakes
- Designed for agentic workflows using Claude Code or Cursor
Playwright Pro by the numbers
- 604 all-time installs (skills.sh)
- Ranked #595 of 2,159 Testing & QA skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
npx skills add https://github.com/alirezarezvani/claude-skills --skill playwright-proAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 604 |
|---|---|
| repo stars | ★ 23.5k |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 17, 2026 |
| Repository | alirezarezvani/claude-skills ↗ |
How do you stop flaky Playwright UI assertions?
Write reliable, auto-retrying Playwright assertions that survive dynamic UIs and flaky timing.
Who is it for?
Frontend and QA engineers maintaining Playwright end-to-end suites on SPAs, dashboards, or other UIs where elements load asynchronously.
Skip if: Teams that only need unit or API tests without browser automation, or projects not using Playwright as the E2E runner.
When should I use this skill?
Playwright tests flake on timing, or the developer asks which expect() matchers to use for dynamic DOM content.
What you get
A consistent catalog of web-first Playwright assertion patterns using expect(locator) for visibility, text, values, attributes, and classes in TypeScript tests.
- Web-first assertion reference patterns
- Stable expect(locator) examples for dynamic UIs
By the numbers
- Covers five assertion families: visibility, text, value, attributes, and class or id checks
Files
Playwright Pro
Production-grade Playwright testing toolkit for AI coding agents.
Available Commands
When installed as a Claude Code plugin, these are available as /pw: commands:
| Command | What it does |
|---|---|
/pw:init | Set up Playwright — detects framework, generates config, CI, first test |
/pw:generate <spec> | Generate tests from user story, URL, or component |
/pw:review | Review tests for anti-patterns and coverage gaps |
/pw:fix <test> | Diagnose and fix failing or flaky tests |
/pw:migrate | Migrate from Cypress or Selenium to Playwright |
/pw:coverage | Analyze what's tested vs. what's missing |
/pw:testrail | Sync with TestRail — read cases, push results |
/pw:browserstack | Run on BrowserStack, pull cross-browser reports |
/pw:report | Generate test report in your preferred format |
Quick Start Workflow
The recommended sequence for most projects:
1. /pw:init → scaffolds config, CI pipeline, and a first smoke test
2. /pw:generate → generates tests from your spec or URL
3. /pw:review → validates quality and flags anti-patterns ← always run after generate
4. /pw:fix <test> → diagnoses and repairs any failing/flaky tests ← run when CI turns redValidation checkpoints:
- After
/pw:generate— always run/pw:reviewbefore committing; it catches locator anti-patterns and missing assertions automatically. - After
/pw:fix— re-run the full suite locally (npx playwright test) to confirm the fix doesn't introduce regressions. - After
/pw:migrate— run/pw:coverageto confirm parity with the old suite before decommissioning Cypress/Selenium tests.
Example: Generate → Review → Fix
# 1. Generate tests from a user story
/pw:generate "As a user I can log in with email and password"
# Generated: tests/auth/login.spec.ts
# → Playwright Pro creates the file using the auth template.
# 2. Review the generated tests
/pw:review tests/auth/login.spec.ts
# → Flags: one test used page.locator('input[type=password]') — suggests getByLabel('Password')
# → Fix applied automatically.
# 3. Run locally to confirm
npx playwright test tests/auth/login.spec.ts --headed
# 4. If a test is flaky in CI, diagnose it
/pw:fix tests/auth/login.spec.ts
# → Identifies missing web-first assertion; replaces waitForTimeout(2000) with expect(locator).toBeVisible()Golden Rules
1. getByRole() over CSS/XPath — resilient to markup changes 2. Never page.waitForTimeout() — use web-first assertions 3. expect(locator) auto-retries; expect(await locator.textContent()) does not 4. Isolate every test — no shared state between tests 5. baseURL in config — zero hardcoded URLs 6. Retries: 2 in CI, 0 locally 7. Traces: 'on-first-retry' — rich debugging without slowdown 8. Fixtures over globals — test.extend() for shared state 9. One behavior per test — multiple related assertions are fine 10. Mock external services only — never mock your own app
Locator Priority
1. getByRole() — buttons, links, headings, form elements
2. getByLabel() — form fields with labels
3. getByText() — non-interactive text
4. getByPlaceholder() — inputs with placeholder
5. getByTestId() — when no semantic option exists
6. page.locator() — CSS/XPath as last resortWhat's Included
- 9 skills with detailed step-by-step instructions
- 3 specialized agents: test-architect, test-debugger, migration-planner
- 55 test templates: auth, CRUD, checkout, search, forms, dashboard, settings, onboarding, notifications, API, accessibility
- 2 MCP servers (TypeScript): TestRail and BrowserStack integrations
- Smart hooks: auto-validate test quality, auto-detect Playwright projects
- 6 reference docs: golden rules, locators, assertions, fixtures, pitfalls, flaky tests
- Migration guides: Cypress and Selenium mapping tables
Integration Setup
TestRail (Optional)
export TESTRAIL_URL="https://your-instance.testrail.io"
export TESTRAIL_USER="your@email.com"
export TESTRAIL_API_KEY="your-api-key"BrowserStack (Optional)
export BROWSERSTACK_USERNAME="your-username"
export BROWSERSTACK_ACCESS_KEY="your-access-key"Quick Reference
See reference/ directory for:
golden-rules.md— The 10 non-negotiable ruleslocators.md— Complete locator priority with cheat sheetassertions.md— Web-first assertions referencefixtures.md— Custom fixtures and storageState patternscommon-pitfalls.md— Top 10 mistakes and fixesflaky-tests.md— Diagnosis commands and quick fixes
See templates/README.md for the full template index.
Assertions Reference
Web-First Assertions (Always Use These)
Auto-retry until timeout. Safe for dynamic content.
// Visibility
await expect(locator).toBeVisible();
await expect(locator).not.toBeVisible();
await expect(locator).toBeHidden();
// Text
await expect(locator).toHaveText('exact text');
await expect(locator).toHaveText(/partial/i);
await expect(locator).toContainText('partial');
// Value (inputs)
await expect(locator).toHaveValue('entered text');
await expect(locator).toHaveValues(['option1', 'option2']);
// Attributes
await expect(locator).toHaveAttribute('href', '/dashboard');
await expect(locator).toHaveClass(/active/);
await expect(locator).toHaveId('main-nav');
// State
await expect(locator).toBeEnabled();
await expect(locator).toBeDisabled();
await expect(locator).toBeChecked();
await expect(locator).toBeEditable();
await expect(locator).toBeFocused();
await expect(locator).toBeAttached();
// Count
await expect(locator).toHaveCount(5);
await expect(locator).toHaveCount(0); // element doesn't exist
// CSS
await expect(locator).toHaveCSS('color', 'rgb(255, 0, 0)');
// Screenshots
await expect(locator).toHaveScreenshot('button.png');
await expect(page).toHaveScreenshot('full-page.png');Page Assertions
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveURL(/\/dashboard/);
await expect(page).toHaveTitle('Dashboard - App');
await expect(page).toHaveTitle(/Dashboard/);Anti-Patterns (Never Do This)
// BAD — no auto-retry
const text = await locator.textContent();
expect(text).toBe('Hello');
// BAD — snapshot in time, not reactive
const isVisible = await locator.isVisible();
expect(isVisible).toBe(true);
// BAD — evaluating in page context
const value = await page.evaluate(() =>
document.querySelector('input')?.value
);
expect(value).toBe('test');Custom Timeout
// Override timeout for slow operations
await expect(locator).toBeVisible({ timeout: 30_000 });Soft Assertions
Continue test even if assertion fails (report all failures at end):
await expect.soft(locator).toHaveText('Expected');
await expect.soft(page).toHaveURL('/next');
// Test continues even if above failCommon Pitfalls (Top 10)
1. waitForTimeout
Symptom: Slow, flaky tests.
// BAD
await page.waitForTimeout(3000);
// GOOD
await expect(page.getByTestId('result')).toBeVisible();2. Non-Web-First Assertions
Symptom: Assertions fail on dynamic content.
// BAD — checks once, no retry
const text = await page.textContent('.msg');
expect(text).toBe('Done');
// GOOD — retries until timeout
await expect(page.getByText('Done')).toBeVisible();3. Missing await
Symptom: Random passes/failures, tests seem to skip steps.
// BAD
page.goto('/dashboard');
expect(page.getByText('Welcome')).toBeVisible();
// GOOD
await page.goto('/dashboard');
await expect(page.getByText('Welcome')).toBeVisible();4. Hardcoded URLs
Symptom: Tests break in different environments.
// BAD
await page.goto('http://localhost:3000/login');
// GOOD — uses baseURL from config
await page.goto('/login');5. CSS Selectors Instead of Roles
Symptom: Tests break after CSS refactors.
// BAD
await page.click('#submit-btn');
// GOOD
await page.getByRole('button', { name: 'Submit' }).click();6. Shared State Between Tests
Symptom: Tests pass alone, fail in suite.
// BAD — test B depends on test A
let userId: string;
test('create user', async () => { userId = '123'; });
test('edit user', async () => { /* uses userId */ });
// GOOD — each test is independent
test('edit user', async ({ request }) => {
const res = await request.post('/api/users', { data: { name: 'Test' } });
const { id } = await res.json();
// ...
});7. Using networkidle
Symptom: Tests hang or timeout unpredictably.
// BAD — waits for all network activity to stop
await page.goto('/dashboard', { waitUntil: 'networkidle' });
// GOOD — wait for specific content
await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();8. Not Waiting for Navigation
Symptom: Assertions run on wrong page.
// BAD — click navigates but we don't wait
await page.getByRole('link', { name: 'Settings' }).click();
await expect(page.getByRole('heading')).toHaveText('Settings');
// GOOD — wait for URL change
await page.getByRole('link', { name: 'Settings' }).click();
await expect(page).toHaveURL('/settings');
await expect(page.getByRole('heading')).toHaveText('Settings');9. Testing Implementation, Not Behavior
Symptom: Tests break on every refactor.
// BAD — tests CSS class (implementation detail)
await expect(page.locator('.btn')).toHaveClass('btn-primary active');
// GOOD — tests what the user sees
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled();10. No Error Case Tests
Symptom: App breaks on errors but all tests pass.
// Missing: what happens when the API fails?
test('should handle API error', async ({ page }) => {
await page.route('**/api/data', (route) =>
route.fulfill({ status: 500 })
);
await page.goto('/dashboard');
await expect(page.getByText(/error|try again/i)).toBeVisible();
});Fixtures Reference
What Are Fixtures
Fixtures provide setup/teardown for each test. They replace beforeEach/afterEach for shared state and are composable, type-safe, and lazy (only run when used).
Creating Custom Fixtures
// fixtures.ts
import { test as base, expect } from '@playwright/test';
// Define fixture types
type MyFixtures = {
authenticatedPage: Page;
testUser: { email: string; password: string };
apiClient: APIRequestContext;
};
export const test = base.extend<MyFixtures>({
// Simple value fixture
testUser: async ({}, use) => {
await use({
email: `test-${Date.now()}@example.com`,
password: 'Test123!',
});
},
// Fixture with setup and teardown
authenticatedPage: async ({ page, testUser }, use) => {
// Setup: log in
await page.goto('/login');
await page.getByLabel('Email').fill(testUser.email);
await page.getByLabel('Password').fill(testUser.password);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('/dashboard');
// Provide the authenticated page to the test
await use(page);
// Teardown: clean up (optional)
await page.goto('/logout');
},
// API client fixture
apiClient: async ({ playwright }, use) => {
const context = await playwright.request.newContext({
baseURL: 'http://localhost:3000',
extraHTTPHeaders: {
Authorization: `Bearer ${process.env.API_TOKEN}`,
},
});
await use(context);
await context.dispose();
},
});
export { expect };Using Fixtures in Tests
import { test, expect } from './fixtures';
test('should show dashboard for logged in user', async ({ authenticatedPage }) => {
// authenticatedPage is already logged in
await expect(authenticatedPage.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
test('should create item via API', async ({ apiClient }) => {
const response = await apiClient.post('/api/items', {
data: { name: 'Test Item' },
});
expect(response.ok()).toBeTruthy();
});Shared Auth State (storageState)
For performance, authenticate once and reuse:
// auth.setup.ts
import { test as setup } from '@playwright/test';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('admin@example.com');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: '.auth/user.json' });
});// playwright.config.ts
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: {
storageState: '.auth/user.json',
},
dependencies: ['setup'],
},
],
});When to Use What
| Need | Use |
|---|---|
| Shared login state | storageState + setup project |
| Per-test data creation | Custom fixture with API calls |
| Reusable page helpers | Custom fixture returning page |
| Test data cleanup | Fixture teardown (after use()) |
| Config values | Simple value fixture |
Flaky Test Quick Reference
Diagnosis Commands
# Burn-in: expose timing issues
npx playwright test tests/checkout.spec.ts --repeat-each=10
# Isolation: expose state leaks
npx playwright test tests/checkout.spec.ts --grep "adds item" --workers=1
# Full trace: capture everything
npx playwright test tests/checkout.spec.ts --trace=on --retries=0
# Parallel stress: expose race conditions
npx playwright test --fully-parallel --workers=4 --repeat-each=5Four Categories
| Category | Symptom | Fix |
|---|---|---|
| Timing | Fails intermittently | Replace waits with assertions |
| Isolation | Fails in suite, passes alone | Remove shared state |
| Environment | Fails in CI only | Match viewport, fonts, timezone |
| Infrastructure | Random crashes | Reduce workers, increase memory |
Quick Fixes
Timing → Add proper waits:
// Wait for specific response
const response = page.waitForResponse('**/api/data');
await page.getByRole('button', { name: 'Load' }).click();
await response;
await expect(page.getByTestId('results')).toBeVisible();Isolation → Unique test data:
const uniqueEmail = `test-${Date.now()}@example.com`;Environment → Explicit viewport:
test.use({ viewport: { width: 1280, height: 720 } });Infrastructure → CI-safe config:
export default defineConfig({
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
timeout: process.env.CI ? 60_000 : 30_000,
});Golden Rules
1. `getByRole()` over CSS/XPath — resilient to markup changes, mirrors assistive technology 2. Never `page.waitForTimeout()` — use expect(locator).toBeVisible() or page.waitForURL() 3. Web-first assertions — expect(locator) auto-retries; expect(await locator.textContent()) does not 4. Isolate every test — no shared state, no execution-order dependencies 5. `baseURL` in config — zero hardcoded URLs in tests 6. Retries: `2` in CI, `0` locally — surface flakiness where it matters 7. Traces: `'on-first-retry'` — rich debugging artifacts without CI slowdown 8. Fixtures over globals — share state via test.extend(), not module-level variables 9. One behavior per test — multiple related expect() calls are fine 10. Mock external services only — never mock your own app; mock third-party APIs, payment gateways, email
Locator Priority
Use the first option that works:
| Priority | Locator | Use for |
|---|---|---|
| 1 | getByRole('button', { name: 'Submit' }) | Buttons, links, headings, form elements |
| 2 | getByLabel('Email address') | Form fields with associated labels |
| 3 | getByText('Welcome back') | Non-interactive text content |
| 4 | getByPlaceholder('Search...') | Inputs with placeholder text |
| 5 | getByAltText('Company logo') | Images with alt text |
| 6 | getByTitle('Close dialog') | Elements with title attribute |
| 7 | getByTestId('checkout-summary') | When no semantic option exists |
| 8 | page.locator('.legacy-widget') | CSS/XPath — absolute last resort |
Role Locator Cheat Sheet
// Buttons — <button>, <input type="submit">, [role="button"]
page.getByRole('button', { name: 'Save changes' })
// Links — <a href>
page.getByRole('link', { name: 'View profile' })
// Headings — h1-h6
page.getByRole('heading', { name: 'Dashboard', level: 1 })
// Text inputs — by label association
page.getByRole('textbox', { name: 'Email' })
// Checkboxes
page.getByRole('checkbox', { name: 'Remember me' })
// Radio buttons
page.getByRole('radio', { name: 'Monthly billing' })
// Dropdowns — <select>
page.getByRole('combobox', { name: 'Country' })
// Navigation
page.getByRole('navigation', { name: 'Main' })
// Tables
page.getByRole('table', { name: 'Recent orders' })
// Rows within tables
page.getByRole('row', { name: /Order #123/ })
// Tab panels
page.getByRole('tab', { name: 'Settings' })
// Dialogs
page.getByRole('dialog', { name: 'Confirm deletion' })
// Alerts
page.getByRole('alert')Filtering and Chaining
// Filter by text
page.getByRole('listitem').filter({ hasText: 'Product A' })
// Filter by child locator
page.getByRole('listitem').filter({
has: page.getByRole('button', { name: 'Buy' })
})
// Chain locators
page.getByRole('navigation').getByRole('link', { name: 'Settings' })
// Nth match
page.getByRole('listitem').nth(0)
page.getByRole('listitem').first()
page.getByRole('listitem').last()Color Contrast Template
Tests contrast ratios, color-blind safe palettes, and focus indicator visibility.
Prerequisites
- App running at
{{baseUrl}} - axe-playwright installed:
npm i -D @axe-core/playwright - Page under test:
{{baseUrl}}/{{pagePath}}
---
TypeScript
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Color Contrast', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{pagePath}}');
});
// Happy path: no color contrast violations (axe)
test('has no color contrast violations', async ({ page }) => {
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.withRules(['color-contrast'])
.analyze();
expect(results.violations).toEqual([]);
});
// Happy path: body text contrast ratio ≥ 4.5:1
test('body text meets WCAG AA contrast ratio', async ({ page }) => {
const ratio = await page.evaluate(() => {
const el = document.querySelector('p, main, [class*="body"]') as HTMLElement;
if (!el) return null;
const style = getComputedStyle(el);
// Simplified check — use axe for full verification
return style.color !== 'rgba(0, 0, 0, 0)' ? style.color : null;
});
expect(ratio).toBeTruthy();
});
// Happy path: large text contrast ratio ≥ 3:1
test('headings have sufficient contrast', async ({ page }) => {
const results = await new AxeBuilder({ page })
.withRules(['color-contrast'])
.include('h1, h2, h3, h4, h5, h6')
.analyze();
expect(results.violations).toEqual([]);
});
// Happy path: focus indicator meets contrast requirement
test('focus indicator is visible and meets contrast', async ({ page }) => {
await page.getByRole('button').first().focus();
const outline = await page.getByRole('button').first().evaluate(el => {
const s = getComputedStyle(el, ':focus');
return {
outlineWidth: parseFloat(s.outlineWidth),
outlineColor: s.outlineColor,
outlineStyle: s.outlineStyle,
};
});
expect(outline.outlineWidth).toBeGreaterThanOrEqual(2);
expect(outline.outlineColor).not.toBe('rgba(0, 0, 0, 0)');
});
// Happy path: error text contrast
test('error messages have sufficient contrast', async ({ page }) => {
await page.goto('{{baseUrl}}/{{formPath}}');
await page.getByRole('button', { name: /submit/i }).click();
const results = await new AxeBuilder({ page })
.withRules(['color-contrast'])
.include('[class*="error"], [role="alert"]')
.analyze();
expect(results.violations).toEqual([]);
});
// Happy path: no information conveyed by color alone
test('status badges use text or icon in addition to color', async ({ page }) => {
const badges = page.getByRole('status');
const count = await badges.count();
for (let i = 0; i < count; i++) {
const text = await badges.nth(i).textContent();
const ariaLabel = await badges.nth(i).getAttribute('aria-label');
expect(text?.trim() || ariaLabel).toBeTruthy();
}
});
// Edge case: full page axe scan for all WCAG 2.1 AA issues
test('full page passes WCAG 2.1 AA axe scan', async ({ page }) => {
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.exclude('{{knownExcludedSelector}}')
.analyze();
if (results.violations.length > 0) {
const messages = results.violations.map(v =>
`${v.id}: ${v.description} — ${v.nodes.map(n => n.target).join(', ')}`
).join('\n');
throw new Error(`Axe violations:\n${messages}`);
}
});
});---
JavaScript
const { test, expect } = require('@playwright/test');
const AxeBuilder = require('@axe-core/playwright').default;
test.describe('Color Contrast', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{pagePath}}');
});
test('no color contrast violations', async ({ page }) => {
const results = await new AxeBuilder({ page })
.withRules(['color-contrast'])
.analyze();
expect(results.violations).toEqual([]);
});
test('focus indicator is visible', async ({ page }) => {
await page.getByRole('button').first().focus();
const outlineWidth = await page.getByRole('button').first().evaluate(
el => parseFloat(getComputedStyle(el).outlineWidth)
);
expect(outlineWidth).toBeGreaterThanOrEqual(2);
});
test('status badges use text not just color', async ({ page }) => {
const badges = page.getByRole('status');
const count = await badges.count();
for (let i = 0; i < count; i++) {
const text = await badges.nth(i).textContent();
const label = await badges.nth(i).getAttribute('aria-label');
expect((text?.trim()) || label).toBeTruthy();
}
});
test('full page passes WCAG 2.1 AA', async ({ page }) => {
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.analyze();
expect(results.violations).toEqual([]);
});
});Variants
| Variant | Description |
|---|---|
| Contrast violations | axe color-contrast rule → no violations |
| Body text contrast | Text color non-transparent |
| Heading contrast | axe include h1-h6 → no violations |
| Focus indicator | outline-width ≥ 2px and non-transparent |
| Error text contrast | Error messages pass axe |
| Color-only info | Badges have text or aria-label |
| Full axe scan | WCAG 2.1 AA complete scan |
Keyboard Navigation Template
Tests tab order, focus visibility, and keyboard shortcuts.
Prerequisites
- App running at
{{baseUrl}} - Page under test:
{{baseUrl}}/{{pagePath}}
---
TypeScript
import { test, expect } from '@playwright/test';
test.describe('Keyboard Navigation', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{pagePath}}');
});
// Happy path: Tab moves through interactive elements in logical order
test('Tab key cycles through focusable elements in correct order', async ({ page }) => {
await page.keyboard.press('Tab');
await expect(page.getByRole('link', { name: /skip.*main|skip navigation/i }))
.toBeFocused();
await page.keyboard.press('Tab');
// First nav link focused
const navLinks = page.getByRole('navigation').getByRole('link');
await expect(navLinks.first()).toBeFocused();
});
// Happy path: skip link skips to main content
test('skip-to-content link moves focus to main', async ({ page }) => {
await page.keyboard.press('Tab');
await page.keyboard.press('Enter');
await expect(page.getByRole('main')).toBeFocused();
});
// Happy path: focus visible on all interactive elements
test('focus ring visible on interactive elements', async ({ page }) => {
const interactive = page.getByRole('button').first();
await interactive.focus();
const box = await interactive.boundingBox();
// Take screenshot with focus and assert element has outline (visual only — use CSS check)
const outline = await interactive.evaluate(el =>
getComputedStyle(el).outlineWidth
);
expect(parseFloat(outline)).toBeGreaterThan(0);
});
// Happy path: modal traps focus
test('focus is trapped within modal when open', async ({ page }) => {
await page.getByRole('button', { name: /open modal/i }).click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible();
// Repeatedly Tab and verify focus stays within dialog
for (let i = 0; i < 10; i++) {
await page.keyboard.press('Tab');
const focused = page.locator(':focus');
await expect(modal).toContainElement(focused);
}
});
// Happy path: Escape closes modal
test('Escape key closes modal', async ({ page }) => {
await page.getByRole('button', { name: /open modal/i }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).toBeHidden();
// Focus returns to trigger button
await expect(page.getByRole('button', { name: /open modal/i })).toBeFocused();
});
// Happy path: keyboard shortcut
test('keyboard shortcut {{shortcutKey}} triggers action', async ({ page }) => {
await page.keyboard.press('{{shortcutKey}}');
await expect(page.getByRole('{{shortcutTargetRole}}', { name: /{{shortcutTargetName}}/i })).toBeVisible();
});
// Error case: focus not lost on dynamic content update
test('focus stays on element after async update', async ({ page }) => {
const btn = page.getByRole('button', { name: /{{asyncButton}}/i });
await btn.focus();
await btn.press('Enter');
await expect(btn).toBeFocused();
});
// Edge case: arrow keys navigate within component (listbox, tabs)
test('arrow keys navigate within tab list', async ({ page }) => {
const firstTab = page.getByRole('tab').first();
await firstTab.focus();
await page.keyboard.press('ArrowRight');
await expect(page.getByRole('tab').nth(1)).toBeFocused();
});
});---
JavaScript
const { test, expect } = require('@playwright/test');
test.describe('Keyboard Navigation', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{pagePath}}');
});
test('skip link moves focus to main content', async ({ page }) => {
await page.keyboard.press('Tab');
await page.keyboard.press('Enter');
await expect(page.getByRole('main')).toBeFocused();
});
test('Escape closes modal and returns focus', async ({ page }) => {
await page.getByRole('button', { name: /open modal/i }).click();
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).toBeHidden();
await expect(page.getByRole('button', { name: /open modal/i })).toBeFocused();
});
test('focus ring visible on buttons', async ({ page }) => {
await page.getByRole('button').first().focus();
const outline = await page.getByRole('button').first().evaluate(
el => getComputedStyle(el).outlineWidth
);
expect(parseFloat(outline)).toBeGreaterThan(0);
});
test('arrow keys navigate tab list', async ({ page }) => {
await page.getByRole('tab').first().focus();
await page.keyboard.press('ArrowRight');
await expect(page.getByRole('tab').nth(1)).toBeFocused();
});
});Variants
| Variant | Description |
|---|---|
| Tab order | Skip link first, nav links after |
| Skip link | Moves focus to <main> |
| Focus ring | CSS outline-width > 0 on focus |
| Focus trap | Tab stays within open modal |
| Escape closes | Modal closed, trigger re-focused |
| Keyboard shortcut | Custom key triggers action |
| Focus after update | Focus not lost on async update |
| Arrow keys | Tab/listbox/menu arrow navigation |
Screen Reader Template
Tests ARIA labels, live regions, and announcements for assistive technology.
Prerequisites
- App running at
{{baseUrl}} - Page under test:
{{baseUrl}}/{{pagePath}}
---
TypeScript
import { test, expect } from '@playwright/test';
test.describe('Screen Reader Accessibility', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{pagePath}}');
});
// Happy path: page has descriptive title
test('page has meaningful title', async ({ page }) => {
await expect(page).toHaveTitle(/{{expectedPageTitle}}/i);
});
// Happy path: main landmark exists
test('page has main landmark', async ({ page }) => {
await expect(page.getByRole('main')).toBeVisible();
});
// Happy path: images have alt text
test('informational images have non-empty alt text', async ({ page }) => {
const images = page.getByRole('img');
const count = await images.count();
for (let i = 0; i < count; i++) {
const alt = await images.nth(i).getAttribute('alt');
const isDecorative = await images.nth(i).getAttribute('role') === 'presentation'
|| alt === '';
if (!isDecorative) {
expect(alt).toBeTruthy();
}
}
});
// Happy path: form fields have accessible labels
test('all form inputs have associated labels', async ({ page }) => {
const inputs = page.getByRole('textbox');
const count = await inputs.count();
for (let i = 0; i < count; i++) {
const input = inputs.nth(i);
const labelledBy = await input.getAttribute('aria-labelledby');
const ariaLabel = await input.getAttribute('aria-label');
const id = await input.getAttribute('id');
const hasLabel = labelledBy || ariaLabel || (id && await page.locator(`label[for="${id}"]`).count() > 0);
expect(hasLabel).toBeTruthy();
}
});
// Happy path: live region announces updates
test('live region announces async updates', async ({ page }) => {
const liveRegion = page.getByRole('status').or(page.locator('[aria-live]'));
await page.getByRole('button', { name: /{{asyncTrigger}}/i }).click();
await expect(liveRegion).not.toBeEmpty();
});
// Happy path: alert role used for errors
test('validation errors use role="alert"', async ({ page }) => {
await page.goto('{{baseUrl}}/{{formPath}}');
await page.getByRole('button', { name: /submit/i }).click();
await expect(page.getByRole('alert')).toBeVisible();
const liveValue = await page.getByRole('alert').first().getAttribute('aria-live');
expect(liveValue ?? 'assertive').toBe('assertive');
});
// Happy path: buttons have accessible names
test('icon-only buttons have aria-label', async ({ page }) => {
const buttons = page.getByRole('button');
const count = await buttons.count();
for (let i = 0; i < count; i++) {
const btn = buttons.nth(i);
const text = (await btn.textContent())?.trim();
const ariaLabel = await btn.getAttribute('aria-label');
const ariaLabelledBy = await btn.getAttribute('aria-labelledby');
// Must have visible text or aria-label or aria-labelledby
expect(text || ariaLabel || ariaLabelledBy).toBeTruthy();
}
});
// Happy path: navigation landmark labelled
test('multiple nav elements have distinct aria-labels', async ({ page }) => {
const navs = page.getByRole('navigation');
const count = await navs.count();
if (count > 1) {
const labels = new Set<string>();
for (let i = 0; i < count; i++) {
const label = await navs.nth(i).getAttribute('aria-label') ?? '';
labels.add(label);
}
expect(labels.size).toBe(count); // all unique
}
});
// Edge case: expanded/collapsed state communicated
test('accordion aria-expanded reflects open/closed state', async ({ page }) => {
const trigger = page.getByRole('button', { name: /{{accordionItem}}/i });
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
await trigger.click();
await expect(trigger).toHaveAttribute('aria-expanded', 'true');
});
});---
JavaScript
const { test, expect } = require('@playwright/test');
test.describe('Screen Reader Accessibility', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{pagePath}}');
});
test('page has meaningful title', async ({ page }) => {
await expect(page).toHaveTitle(/{{expectedPageTitle}}/i);
});
test('main landmark exists', async ({ page }) => {
await expect(page.getByRole('main')).toBeVisible();
});
test('validation errors use role=alert', async ({ page }) => {
await page.goto('{{baseUrl}}/{{formPath}}');
await page.getByRole('button', { name: /submit/i }).click();
await expect(page.getByRole('alert')).toBeVisible();
});
test('accordion aria-expanded toggles', async ({ page }) => {
const trigger = page.getByRole('button', { name: /{{accordionItem}}/i });
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
await trigger.click();
await expect(trigger).toHaveAttribute('aria-expanded', 'true');
});
});Variants
| Variant | Description |
|---|---|
| Page title | <title> matches expected pattern |
| Main landmark | <main> present and visible |
| Image alt text | Informational images have non-empty alt |
| Form labels | All inputs have accessible label |
| Live region | Status region updated on async action |
| Alert role | Errors use role=alert (assertive) |
| Button names | Icon buttons have aria-label |
| Unique nav labels | Multiple navs have distinct labels |
| aria-expanded | Accordion state communicated |
Auth Headers Template
Tests token authentication, expired token handling, and token refresh flow.
Prerequisites
- Valid token:
{{apiToken}} - Expired token:
{{expiredApiToken}} - Refresh token:
{{refreshToken}} - API base:
{{apiBaseUrl}}
---
TypeScript
import { test, expect } from '@playwright/test';
test.describe('API Auth Headers', () => {
// Happy path: valid Bearer token accepted
test('accepts valid Bearer token', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/me', {
headers: { 'Authorization': `Bearer {{apiToken}}` },
});
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.id).toBeTruthy();
});
// Happy path: API key in header accepted
test('accepts API key header', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/{{entityName}}s', {
headers: { 'X-API-Key': '{{apiKey}}' },
});
expect(res.status()).toBe(200);
});
// Error case: no auth header returns 401
test('returns 401 without auth header', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/me');
expect(res.status()).toBe(401);
const body = await res.json();
expect(body.error ?? body.message).toMatch(/unauthorized|authentication required/i);
});
// Error case: expired token returns 401
test('returns 401 for expired token', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/me', {
headers: { 'Authorization': `Bearer {{expiredApiToken}}` },
});
expect(res.status()).toBe(401);
const body = await res.json();
expect(body.error ?? body.code).toMatch(/token.*expired|expired_token/i);
});
// Happy path: refresh token obtains new access token
test('refreshes expired token and retries request', async ({ request }) => {
// Step 1: refresh
const refresh = await request.post('{{apiBaseUrl}}/auth/refresh', {
data: { refresh_token: '{{refreshToken}}' },
});
expect(refresh.status()).toBe(200);
const { access_token } = await refresh.json();
expect(access_token).toBeTruthy();
// Step 2: use new token
const res = await request.get('{{apiBaseUrl}}/me', {
headers: { 'Authorization': `Bearer ${access_token}` },
});
expect(res.status()).toBe(200);
});
// Error case: invalid token format returns 401
test('returns 401 for malformed token', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/me', {
headers: { 'Authorization': 'Bearer not.a.jwt' },
});
expect(res.status()).toBe(401);
});
// Edge case: token in cookie vs header
test('accepts session cookie as auth alternative', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/me', {
headers: { 'Cookie': `{{sessionCookieName}}={{sessionCookieValue}}` },
});
expect(res.status()).toBe(200);
});
// Edge case: revoked token returns 401
test('returns 401 for revoked token', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/me', {
headers: { 'Authorization': `Bearer {{revokedApiToken}}` },
});
expect(res.status()).toBe(401);
});
});---
JavaScript
const { test, expect } = require('@playwright/test');
test.describe('API Auth Headers', () => {
test('accepts valid Bearer token', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/me', {
headers: { 'Authorization': `Bearer {{apiToken}}` },
});
expect(res.status()).toBe(200);
});
test('returns 401 without auth header', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/me');
expect(res.status()).toBe(401);
});
test('returns 401 for expired token', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/me', {
headers: { 'Authorization': `Bearer {{expiredApiToken}}` },
});
expect(res.status()).toBe(401);
});
test('refreshes token and retries', async ({ request }) => {
const refresh = await request.post('{{apiBaseUrl}}/auth/refresh', {
data: { refresh_token: '{{refreshToken}}' },
});
const { access_token } = await refresh.json();
const res = await request.get('{{apiBaseUrl}}/me', {
headers: { 'Authorization': `Bearer ${access_token}` },
});
expect(res.status()).toBe(200);
});
});Variants
| Variant | Description |
|---|---|
| Valid Bearer | 200 with user data |
| API key | X-API-Key header accepted |
| No auth | 401 + error message |
| Expired token | 401 + expired error code |
| Token refresh | New token from refresh endpoint |
| Malformed token | 401 for non-JWT |
| Cookie auth | Session cookie accepted |
| Revoked token | 401 for revoked token |
API Error Responses Template
Tests 400, 401, 403, 404, and 500 HTTP error handling.
Prerequisites
- Valid auth token:
{{apiToken}} - API base:
{{apiBaseUrl}}
---
TypeScript
import { test, expect } from '@playwright/test';
const validHeaders = {
'Authorization': `Bearer {{apiToken}}`,
'Content-Type': 'application/json',
};
test.describe('API Error Responses', () => {
// 400 Bad Request
test('POST with invalid body returns 400', async ({ request }) => {
const res = await request.post('{{apiBaseUrl}}/{{entityName}}s', {
headers: validHeaders,
data: { name: '' }, // name too short / blank
});
expect(res.status()).toBe(400);
const body = await res.json();
expect(body.message ?? body.error).toMatch(/bad request|invalid/i);
expect(body.errors ?? body.details).toBeDefined();
});
// 401 Unauthorized
test('request without token returns 401', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/{{entityName}}s');
expect(res.status()).toBe(401);
const body = await res.json();
expect(body.message ?? body.error).toMatch(/unauthorized|authentication/i);
});
// 403 Forbidden
test('accessing admin endpoint as regular user returns 403', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/admin/users', {
headers: { 'Authorization': `Bearer {{userToken}}` },
});
expect(res.status()).toBe(403);
const body = await res.json();
expect(body.message ?? body.error).toMatch(/forbidden|insufficient.*permission/i);
});
// 404 Not Found
test('GET non-existent resource returns 404', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/{{entityName}}s/999999', { headers: validHeaders });
expect(res.status()).toBe(404);
const body = await res.json();
expect(body.message ?? body.error).toMatch(/not found/i);
});
// 422 Unprocessable Entity
test('POST with missing required field returns 422', async ({ request }) => {
const res = await request.post('{{apiBaseUrl}}/{{entityName}}s', {
headers: validHeaders,
data: { description: 'no name provided' },
});
expect([422, 400]).toContain(res.status());
const body = await res.json();
expect(body.errors ?? body.details).toBeDefined();
});
// 429 Too Many Requests (handled in rate-limiting template — kept here for completeness)
test('returns 429 when rate limit exceeded', async ({ request }) => {
let lastStatus = 0;
for (let i = 0; i < {{rateLimitThreshold}} + 1; i++) {
const res = await request.get('{{apiBaseUrl}}/{{rateLimitedEndpoint}}', { headers: validHeaders });
lastStatus = res.status();
if (lastStatus === 429) break;
}
expect(lastStatus).toBe(429);
});
// 500 Internal Server Error
test('server error returns 500 with error body', async ({ page }) => {
await page.route('{{apiBaseUrl}}/{{entityName}}s', route =>
route.fulfill({ status: 500, body: JSON.stringify({ error: 'Internal Server Error' }) })
);
const res = await page.request.get('{{apiBaseUrl}}/{{entityName}}s', { headers: validHeaders });
expect(res.status()).toBe(500);
const body = await res.json();
expect(body.error ?? body.message).toBeTruthy();
});
// Edge case: error response has consistent shape
test('all errors return JSON with error field', async ({ request }) => {
const endpoints = [
{ method: 'get' as const, url: '{{apiBaseUrl}}/{{entityName}}s/000000', headers: validHeaders },
{ method: 'get' as const, url: '{{apiBaseUrl}}/{{entityName}}s' },
];
for (const ep of endpoints) {
const res = await request[ep.method](ep.url, { headers: ep.headers });
if (res.status() >= 400) {
const body = await res.json();
expect(body.error ?? body.message ?? body.errors).toBeDefined();
}
}
});
});---
JavaScript
const { test, expect } = require('@playwright/test');
const headers = { 'Authorization': `Bearer {{apiToken}}`, 'Content-Type': 'application/json' };
test.describe('API Error Responses', () => {
test('POST with invalid body returns 400', async ({ request }) => {
const res = await request.post('{{apiBaseUrl}}/{{entityName}}s', {
headers,
data: { name: '' },
});
expect(res.status()).toBe(400);
});
test('no token returns 401', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/{{entityName}}s');
expect(res.status()).toBe(401);
});
test('regular user on admin endpoint returns 403', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/admin/users', {
headers: { 'Authorization': `Bearer {{userToken}}` },
});
expect(res.status()).toBe(403);
});
test('non-existent resource returns 404', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/{{entityName}}s/999999', { headers });
expect(res.status()).toBe(404);
});
});Variants
| Variant | Description |
|---|---|
| 400 Bad Request | Invalid body → 400 + errors detail |
| 401 Unauthorized | No token → 401 |
| 403 Forbidden | Wrong role → 403 |
| 404 Not Found | Missing resource → 404 |
| 422 Unprocessable | Missing required field → 422/400 |
| 429 Rate Limit | Threshold exceeded → 429 |
| 500 Server Error | Mocked 500 → error body present |
| Consistent shape | All errors have error/message field |
GraphQL API Template
Tests query, mutation, and subscription via Playwright's request API.
Prerequisites
- Valid auth token:
{{apiToken}} - GraphQL endpoint:
{{graphqlEndpoint}} - WebSocket endpoint for subscriptions:
{{graphqlWsEndpoint}}
---
TypeScript
import { test, expect } from '@playwright/test';
const GQL_URL = '{{graphqlEndpoint}}';
const headers = {
'Authorization': `Bearer {{apiToken}}`,
'Content-Type': 'application/json',
};
async function gql(request: any, query: string, variables = {}) {
const res = await request.post(GQL_URL, { headers, data: { query, variables } });
const body = await res.json();
expect(body.errors).toBeUndefined();
return body.data;
}
test.describe('GraphQL API', () => {
// Happy path: query
test('query fetches {{entityName}} list', async ({ request }) => {
const data = await gql(request, `
query Get{{EntityName}}s($limit: Int) {
{{entityName}}s(limit: $limit) { id name createdAt }
}
`, { limit: 10 });
expect(Array.isArray(data.{{entityName}}s)).toBe(true);
expect(data.{{entityName}}s.length).toBeLessThanOrEqual(10);
});
// Happy path: query single entity
test('query fetches single {{entityName}} by id', async ({ request }) => {
const data = await gql(request, `
query Get{{EntityName}}($id: ID!) {
{{entityName}}(id: $id) { id name description }
}
`, { id: '{{existingEntityId}}' });
expect(data.{{entityName}}.id).toBe('{{existingEntityId}}');
});
// Happy path: mutation creates entity
test('mutation creates {{entityName}}', async ({ request }) => {
const data = await gql(request, `
mutation Create{{EntityName}}($input: {{EntityName}}Input!) {
create{{EntityName}}(input: $input) { id name }
}
`, { input: { name: '{{testEntityName}}', description: '{{testDescription}}' } });
expect(data.create{{EntityName}}.id).toBeTruthy();
expect(data.create{{EntityName}}.name).toBe('{{testEntityName}}');
});
// Happy path: mutation updates entity
test('mutation updates {{entityName}}', async ({ request }) => {
const data = await gql(request, `
mutation Update{{EntityName}}($id: ID!, $input: {{EntityName}}Input!) {
update{{EntityName}}(id: $id, input: $input) { id name }
}
`, { id: '{{existingEntityId}}', input: { name: '{{updatedName}}' } });
expect(data.update{{EntityName}}.name).toBe('{{updatedName}}');
});
// Happy path: mutation deletes entity
test('mutation deletes {{entityName}}', async ({ request }) => {
const data = await gql(request, `
mutation Delete{{EntityName}}($id: ID!) {
delete{{EntityName}}(id: $id) { success }
}
`, { id: '{{deletableEntityId}}' });
expect(data.delete{{EntityName}}.success).toBe(true);
});
// Error case: invalid query returns errors array
test('invalid query returns errors', async ({ request }) => {
const res = await request.post(GQL_URL, {
headers,
data: { query: '{ invalidField }' },
});
const body = await res.json();
expect(body.errors).toBeDefined();
expect(body.errors.length).toBeGreaterThan(0);
});
// Error case: unauthorized query
test('query without auth returns unauthorized error', async ({ request }) => {
const res = await request.post(GQL_URL, {
headers: { 'Content-Type': 'application/json' }, // No auth
data: { query: '{ {{entityName}}s { id } }' },
});
const body = await res.json();
expect(body.errors?.[0]?.extensions?.code).toMatch(/UNAUTHENTICATED|UNAUTHORIZED/);
});
// Edge case: subscription via page WebSocket
test('subscription receives real-time update', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
const received: any[] = [];
await page.evaluate(() => {
const ws = new WebSocket('{{graphqlWsEndpoint}}');
ws.onmessage = e => (window as any).__gqlMsg = JSON.parse(e.data);
});
// Trigger mutation to fire subscription
await page.request.post(GQL_URL, {
headers,
data: { query: 'mutation { trigger{{EntityName}}Event { id } }' },
});
const msg = await page.evaluate(() => (window as any).__gqlMsg);
expect(msg?.type).toBe('data');
});
});---
JavaScript
const { test, expect } = require('@playwright/test');
const headers = { 'Authorization': `Bearer {{apiToken}}`, 'Content-Type': 'application/json' };
async function gql(request, query, variables = {}) {
const res = await request.post('{{graphqlEndpoint}}', { headers, data: { query, variables } });
const body = await res.json();
expect(body.errors).toBeUndefined();
return body.data;
}
test.describe('GraphQL API', () => {
test('query fetches entity list', async ({ request }) => {
const data = await gql(request, '{ {{entityName}}s { id name } }');
expect(Array.isArray(data.{{entityName}}s)).toBe(true);
});
test('mutation creates entity', async ({ request }) => {
const data = await gql(request,
'mutation($input: {{EntityName}}Input!) { create{{EntityName}}(input: $input) { id } }',
{ input: { name: '{{testEntityName}}' } }
);
expect(data.create{{EntityName}}.id).toBeTruthy();
});
test('invalid query returns errors array', async ({ request }) => {
const res = await request.post('{{graphqlEndpoint}}', {
headers,
data: { query: '{ nonExistentField }' },
});
const body = await res.json();
expect(body.errors?.length).toBeGreaterThan(0);
});
});Variants
| Variant | Description |
|---|---|
| List query | Returns array of entities |
| Single query | Returns entity by ID |
| Create mutation | Returns new entity with ID |
| Update mutation | Returns updated field value |
| Delete mutation | Returns success: true |
| Invalid query | errors[] defined in response |
| Unauthenticated | UNAUTHENTICATED extension code |
| Subscription | Real-time message via WebSocket |
Rate Limiting Template
Tests rate limit headers, 429 response, and Retry-After handling.
Prerequisites
- Valid auth token:
{{apiToken}} - Rate-limited endpoint:
{{rateLimitedEndpoint}} - Rate limit:
{{rateLimit}}requests per{{rateLimitWindow}} - API base:
{{apiBaseUrl}}
---
TypeScript
import { test, expect } from '@playwright/test';
const headers = {
'Authorization': `Bearer {{apiToken}}`,
'Content-Type': 'application/json',
};
test.describe('Rate Limiting', () => {
// Happy path: rate limit headers present on normal requests
test('includes rate limit headers on success response', async ({ request }) => {
const res = await request.get(`{{apiBaseUrl}}/{{rateLimitedEndpoint}}`, { headers });
expect(res.status()).toBe(200);
expect(res.headers()['x-ratelimit-limit']).toBeTruthy();
expect(res.headers()['x-ratelimit-remaining']).toBeTruthy();
expect(Number(res.headers()['x-ratelimit-limit'])).toBe({{rateLimit}});
});
// Happy path: remaining count decrements
test('x-ratelimit-remaining decrements with each request', async ({ request }) => {
const first = await request.get(`{{apiBaseUrl}}/{{rateLimitedEndpoint}}`, { headers });
const second = await request.get(`{{apiBaseUrl}}/{{rateLimitedEndpoint}}`, { headers });
const remaining1 = Number(first.headers()['x-ratelimit-remaining']);
const remaining2 = Number(second.headers()['x-ratelimit-remaining']);
expect(remaining2).toBeLessThan(remaining1);
});
// Error case: 429 when limit exceeded
test('returns 429 when rate limit exceeded', async ({ request }) => {
let lastStatus = 200;
let retryAfter: string | undefined;
for (let i = 0; i <= {{rateLimit}}; i++) {
const res = await request.get(`{{apiBaseUrl}}/{{rateLimitedEndpoint}}`, { headers });
lastStatus = res.status();
if (lastStatus === 429) {
retryAfter = res.headers()['retry-after'];
break;
}
}
expect(lastStatus).toBe(429);
expect(retryAfter).toBeTruthy();
});
// Error case: 429 body contains error message
test('429 response body contains error and retry info', async ({ request }) => {
// Exhaust limit
for (let i = 0; i <= {{rateLimit}}; i++) {
await request.get(`{{apiBaseUrl}}/{{rateLimitedEndpoint}}`, { headers });
}
const res = await request.get(`{{apiBaseUrl}}/{{rateLimitedEndpoint}}`, { headers });
if (res.status() === 429) {
const body = await res.json();
expect(body.error ?? body.message).toMatch(/rate limit|too many requests/i);
expect(Number(res.headers()['retry-after'])).toBeGreaterThan(0);
}
});
// Happy path: different users have separate rate limit buckets
test('rate limit is per-user, not global', async ({ request }) => {
// Exhaust limit for user 1
for (let i = 0; i <= {{rateLimit}}; i++) {
await request.get(`{{apiBaseUrl}}/{{rateLimitedEndpoint}}`, {
headers: { 'Authorization': `Bearer {{apiToken}}` },
});
}
// User 2 should still succeed
const res = await request.get(`{{apiBaseUrl}}/{{rateLimitedEndpoint}}`, {
headers: { 'Authorization': `Bearer {{apiToken2}}` },
});
expect(res.status()).toBe(200);
});
// Edge case: reset after window expires
test('rate limit resets after window expires', async ({ page, request }) => {
// Exhaust limit
for (let i = 0; i <= {{rateLimit}}; i++) {
await request.get(`{{apiBaseUrl}}/{{rateLimitedEndpoint}}`, { headers });
}
// Advance clock past the window
await page.clock.install();
await page.clock.fastForward({{rateLimitWindowMs}});
// Should succeed again
const res = await request.get(`{{apiBaseUrl}}/{{rateLimitedEndpoint}}`, { headers });
expect(res.status()).toBe(200);
});
});---
JavaScript
const { test, expect } = require('@playwright/test');
const headers = { 'Authorization': `Bearer {{apiToken}}` };
test.describe('Rate Limiting', () => {
test('includes rate limit headers on success', async ({ request }) => {
const res = await request.get(`{{apiBaseUrl}}/{{rateLimitedEndpoint}}`, { headers });
expect(res.status()).toBe(200);
expect(res.headers()['x-ratelimit-limit']).toBeTruthy();
expect(res.headers()['x-ratelimit-remaining']).toBeTruthy();
});
test('returns 429 with Retry-After when limit exceeded', async ({ request }) => {
let lastStatus = 200;
let retryAfter;
for (let i = 0; i <= {{rateLimit}}; i++) {
const res = await request.get(`{{apiBaseUrl}}/{{rateLimitedEndpoint}}`, { headers });
lastStatus = res.status();
if (lastStatus === 429) { retryAfter = res.headers()['retry-after']; break; }
}
expect(lastStatus).toBe(429);
expect(retryAfter).toBeTruthy();
});
test('per-user buckets: other user unaffected', async ({ request }) => {
for (let i = 0; i <= {{rateLimit}}; i++) {
await request.get(`{{apiBaseUrl}}/{{rateLimitedEndpoint}}`, { headers });
}
const res = await request.get(`{{apiBaseUrl}}/{{rateLimitedEndpoint}}`, {
headers: { 'Authorization': `Bearer {{apiToken2}}` },
});
expect(res.status()).toBe(200);
});
});Variants
| Variant | Description |
|---|---|
| Headers present | x-ratelimit-limit and -remaining on 200 |
| Decrement | remaining decreases each request |
| 429 triggered | Limit exceeded → 429 + Retry-After |
| 429 body | Error message + retry info in body |
| Per-user bucket | Exhausted user doesn't affect others |
| Window reset | Clock advanced → limit resets |
REST CRUD API Template
Tests GET, POST, PUT, and DELETE API endpoints directly via Playwright's request API.
Prerequisites
- Valid auth token:
{{apiToken}} - Base API URL:
{{apiBaseUrl}} - Test entity endpoint:
/{{entityName}}s
---
TypeScript
import { test, expect } from '@playwright/test';
test.describe('REST CRUD — /{{entityName}}s', () => {
let createdId: string;
const headers = {
'Authorization': `Bearer {{apiToken}}`,
'Content-Type': 'application/json',
};
// Happy path: GET list
test('GET /{{entityName}}s returns list', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/{{entityName}}s', { headers });
expect(res.status()).toBe(200);
const body = await res.json();
expect(Array.isArray(body.data ?? body)).toBe(true);
});
// Happy path: POST creates entity
test('POST /{{entityName}}s creates new entity', async ({ request }) => {
const res = await request.post('{{apiBaseUrl}}/{{entityName}}s', {
headers,
data: { name: '{{testEntityName}}', description: '{{testDescription}}' },
});
expect(res.status()).toBe(201);
const body = await res.json();
expect(body.id).toBeTruthy();
expect(body.name).toBe('{{testEntityName}}');
createdId = body.id;
});
// Happy path: GET single entity
test('GET /{{entityName}}s/:id returns entity', async ({ request }) => {
const res = await request.get(`{{apiBaseUrl}}/{{entityName}}s/{{existingEntityId}}`, { headers });
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.id).toBe('{{existingEntityId}}');
expect(body.name).toBeTruthy();
});
// Happy path: PUT updates entity
test('PUT /{{entityName}}s/:id updates entity', async ({ request }) => {
const res = await request.put(`{{apiBaseUrl}}/{{entityName}}s/{{existingEntityId}}`, {
headers,
data: { name: '{{updatedEntityName}}' },
});
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.name).toBe('{{updatedEntityName}}');
});
// Happy path: PATCH partial update
test('PATCH /{{entityName}}s/:id partially updates entity', async ({ request }) => {
const res = await request.patch(`{{apiBaseUrl}}/{{entityName}}s/{{existingEntityId}}`, {
headers,
data: { description: '{{patchedDescription}}' },
});
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.description).toBe('{{patchedDescription}}');
});
// Happy path: DELETE removes entity
test('DELETE /{{entityName}}s/:id deletes entity', async ({ request }) => {
const del = await request.delete(`{{apiBaseUrl}}/{{entityName}}s/{{deletableEntityId}}`, { headers });
expect(del.status()).toBe(204);
// Verify gone
const get = await request.get(`{{apiBaseUrl}}/{{entityName}}s/{{deletableEntityId}}`, { headers });
expect(get.status()).toBe(404);
});
// Error case: POST with missing required field returns 422
test('POST with missing required field returns 422', async ({ request }) => {
const res = await request.post('{{apiBaseUrl}}/{{entityName}}s', {
headers,
data: {},
});
expect(res.status()).toBe(422);
const body = await res.json();
expect(body.errors).toBeTruthy();
});
// Error case: GET non-existent entity returns 404
test('GET non-existent entity returns 404', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/{{entityName}}s/999999', { headers });
expect(res.status()).toBe(404);
});
});---
JavaScript
const { test, expect } = require('@playwright/test');
const headers = {
'Authorization': `Bearer {{apiToken}}`,
'Content-Type': 'application/json',
};
test.describe('REST CRUD — /{{entityName}}s', () => {
test('GET list returns 200 and array', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/{{entityName}}s', { headers });
expect(res.status()).toBe(200);
const body = await res.json();
expect(Array.isArray(body.data ?? body)).toBe(true);
});
test('POST creates entity and returns 201', async ({ request }) => {
const res = await request.post('{{apiBaseUrl}}/{{entityName}}s', {
headers,
data: { name: '{{testEntityName}}' },
});
expect(res.status()).toBe(201);
expect((await res.json()).id).toBeTruthy();
});
test('DELETE removes entity, GET returns 404', async ({ request }) => {
await request.delete(`{{apiBaseUrl}}/{{entityName}}s/{{deletableEntityId}}`, { headers });
const res = await request.get(`{{apiBaseUrl}}/{{entityName}}s/{{deletableEntityId}}`, { headers });
expect(res.status()).toBe(404);
});
});Variants
| Variant | Description |
|---|---|
| GET list | 200 + array body |
| POST create | 201 + id in response |
| GET single | 200 + correct entity body |
| PUT update | 200 + updated field in response |
| PATCH partial | 200 + patched field only changed |
| DELETE | 204 → subsequent GET returns 404 |
| POST validation | Missing field → 422 + errors |
| GET 404 | Non-existent ID → 404 |
Login Template
Tests email/password login, social login, and remember me functionality.
Prerequisites
- Valid user account:
{{username}}/{{password}} - Social provider configured (Google/GitHub)
- App running at
{{baseUrl}}
---
TypeScript
import { test, expect } from '@playwright/test';
test.describe('Login', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/login');
});
// Happy path: email/password login
test('logs in with valid credentials', async ({ page }) => {
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('textbox', { name: /password/i }).fill('{{password}}');
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
});
// Happy path: remember me
test('persists session with remember me checked', async ({ page, context }) => {
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('textbox', { name: /password/i }).fill('{{password}}');
await page.getByRole('checkbox', { name: /remember me/i }).check();
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
const cookies = await context.cookies();
const session = cookies.find(c => c.name === '{{sessionCookieName}}');
expect(session?.expires).toBeGreaterThan(Date.now() / 1000 + 86400);
});
// Happy path: social login
test('redirects to social provider', async ({ page }) => {
await page.getByRole('button', { name: /continue with google/i }).click();
await expect(page).toHaveURL(/accounts\.google\.com/);
});
// Error case: invalid credentials
test('shows error for wrong password', async ({ page }) => {
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('textbox', { name: /password/i }).fill('wrong-password');
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page.getByRole('alert')).toContainText(/invalid.*credentials/i);
await expect(page).toHaveURL('{{baseUrl}}/login');
});
// Edge case: empty fields
test('shows validation for empty submission', async ({ page }) => {
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page.getByRole('textbox', { name: /email/i })).toBeFocused();
await expect(page.getByText(/email is required/i)).toBeVisible();
});
// Edge case: locked account
test('shows account locked message after multiple failures', async ({ page }) => {
for (let i = 0; i < {{lockoutAttempts}}; i++) {
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('textbox', { name: /password/i }).fill('wrong');
await page.getByRole('button', { name: /sign in/i }).click();
}
await expect(page.getByRole('alert')).toContainText(/account.*locked/i);
});
});---
JavaScript
const { test, expect } = require('@playwright/test');
test.describe('Login', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/login');
});
test('logs in with valid credentials', async ({ page }) => {
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('textbox', { name: /password/i }).fill('{{password}}');
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
});
test('shows error for wrong password', async ({ page }) => {
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('textbox', { name: /password/i }).fill('wrong-password');
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page.getByRole('alert')).toContainText(/invalid.*credentials/i);
});
test('shows validation for empty submission', async ({ page }) => {
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page.getByText(/email is required/i)).toBeVisible();
});
});Variants
| Variant | Description |
|---|---|
| Happy path | Valid credentials → dashboard redirect |
| Remember me | Long-lived cookie set |
| Social login | OAuth redirect to provider |
| Wrong password | Alert with error message |
| Empty form | Inline validation shown |
| Locked account | Lockout message after N failures |
Logout Template
Tests logout from navigation, session cleanup, and redirect behaviour.
Prerequisites
- Authenticated session (use
storageStateor login fixture) - App running at
{{baseUrl}}
---
TypeScript
import { test, expect } from '@playwright/test';
test.describe('Logout', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
// Happy path: logout via nav menu
test('logs out from user menu', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /user menu/i }).click();
await page.getByRole('menuitem', { name: /sign out/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/login');
await expect(page.getByRole('heading', { name: /sign in/i })).toBeVisible();
});
// Happy path: session cookies cleared
test('clears session cookie on logout', async ({ page, context }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /user menu/i }).click();
await page.getByRole('menuitem', { name: /sign out/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/login');
const cookies = await context.cookies();
const session = cookies.find(c => c.name === '{{sessionCookieName}}');
expect(session).toBeUndefined();
});
// Happy path: accessing protected page after logout redirects
test('redirects to login when accessing protected page after logout', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /user menu/i }).click();
await page.getByRole('menuitem', { name: /sign out/i }).click();
await page.goto('{{baseUrl}}/dashboard');
await expect(page).toHaveURL(/\/login/);
});
// Error case: double logout (stale session)
test('handles logout gracefully when session already expired', async ({ page, context }) => {
await page.goto('{{baseUrl}}/dashboard');
await context.clearCookies();
await page.getByRole('button', { name: /user menu/i }).click();
await page.getByRole('menuitem', { name: /sign out/i }).click();
await expect(page).toHaveURL(/\/login/);
});
// Edge case: logout from multiple tabs
test('invalidates session across tabs', async ({ page, context }) => {
const tab2 = await context.newPage();
await page.goto('{{baseUrl}}/dashboard');
await tab2.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /user menu/i }).click();
await page.getByRole('menuitem', { name: /sign out/i }).click();
await tab2.reload();
await expect(tab2).toHaveURL(/\/login/);
});
});---
JavaScript
const { test, expect } = require('@playwright/test');
test.describe('Logout', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('logs out from user menu', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /user menu/i }).click();
await page.getByRole('menuitem', { name: /sign out/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/login');
});
test('clears session cookie on logout', async ({ page, context }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /user menu/i }).click();
await page.getByRole('menuitem', { name: /sign out/i }).click();
const cookies = await context.cookies();
expect(cookies.find(c => c.name === '{{sessionCookieName}}')).toBeUndefined();
});
test('redirects protected page to login after logout', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /user menu/i }).click();
await page.getByRole('menuitem', { name: /sign out/i }).click();
await page.goto('{{baseUrl}}/dashboard');
await expect(page).toHaveURL(/\/login/);
});
});Variants
| Variant | Description |
|---|---|
| Happy path | Nav menu → sign out → login page |
| Cookie cleanup | Session cookie removed after logout |
| Protected redirect | Accessing /dashboard after logout → /login |
| Stale session | Already-expired session handled gracefully |
| Multi-tab | Logout invalidates other open tabs |
MFA Template
Tests 2FA TOTP code entry, backup codes, and MFA enrollment flow.
Prerequisites
- MFA-enabled account:
{{mfaUsername}}/{{mfaPassword}} - TOTP secret for generating codes:
{{totpSecret}} - Backup code:
{{backupCode}} - App running at
{{baseUrl}}
---
TypeScript
import { test, expect } from '@playwright/test';
import { authenticator } from 'otplib'; // npm i otplib
test.describe('MFA', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('textbox', { name: /email/i }).fill('{{mfaUsername}}');
await page.getByRole('textbox', { name: /password/i }).fill('{{mfaPassword}}');
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL(/\/mfa|\/two-factor/);
});
// Happy path: valid TOTP code
test('accepts valid TOTP code', async ({ page }) => {
const token = authenticator.generate('{{totpSecret}}');
await page.getByRole('textbox', { name: /code|token/i }).fill(token);
await page.getByRole('button', { name: /verify/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
});
// Happy path: backup code
test('accepts backup code', async ({ page }) => {
await page.getByRole('link', { name: /use backup code/i }).click();
await page.getByRole('textbox', { name: /backup code/i }).fill('{{backupCode}}');
await page.getByRole('button', { name: /verify/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
// Backup code consumed — warning shown
await expect(page.getByRole('alert')).toContainText(/backup code used/i);
});
// Error case: wrong TOTP code
test('rejects invalid TOTP code', async ({ page }) => {
await page.getByRole('textbox', { name: /code|token/i }).fill('000000');
await page.getByRole('button', { name: /verify/i }).click();
await expect(page.getByRole('alert')).toContainText(/invalid.*code/i);
await expect(page).toHaveURL(/\/mfa|\/two-factor/);
});
// Error case: expired code (simulate by providing code + 1 step)
test('rejects expired TOTP code', async ({ page }) => {
const expiredToken = authenticator.generate('{{totpSecret}}');
// Advance time simulation via clock if supported, else use a fixed stale code
await page.getByRole('textbox', { name: /code|token/i }).fill(expiredToken);
await page.clock.fastForward(60_000); // advance 60s past TOTP window
await page.getByRole('button', { name: /verify/i }).click();
await expect(page.getByRole('alert')).toContainText(/expired|invalid.*code/i);
});
// Edge case: MFA enrollment for new user
test('enrolls MFA via QR code scan', async ({ page: enrollPage }) => {
await enrollPage.goto('{{baseUrl}}/settings/security');
await enrollPage.getByRole('button', { name: /enable.*two-factor/i }).click();
await expect(enrollPage.getByRole('img', { name: /qr code/i })).toBeVisible();
await expect(enrollPage.getByText(/scan.*authenticator/i)).toBeVisible();
// User scans QR → enters token
const token = authenticator.generate('{{totpSecret}}');
await enrollPage.getByRole('textbox', { name: /verification code/i }).fill(token);
await enrollPage.getByRole('button', { name: /activate/i }).click();
await expect(enrollPage.getByRole('heading', { name: /backup codes/i })).toBeVisible();
});
});---
JavaScript
const { test, expect } = require('@playwright/test');
const { authenticator } = require('otplib');
test.describe('MFA', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('textbox', { name: /email/i }).fill('{{mfaUsername}}');
await page.getByRole('textbox', { name: /password/i }).fill('{{mfaPassword}}');
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL(/\/mfa|\/two-factor/);
});
test('accepts valid TOTP code', async ({ page }) => {
const token = authenticator.generate('{{totpSecret}}');
await page.getByRole('textbox', { name: /code|token/i }).fill(token);
await page.getByRole('button', { name: /verify/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
});
test('accepts backup code', async ({ page }) => {
await page.getByRole('link', { name: /use backup code/i }).click();
await page.getByRole('textbox', { name: /backup code/i }).fill('{{backupCode}}');
await page.getByRole('button', { name: /verify/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
});
test('rejects invalid TOTP code', async ({ page }) => {
await page.getByRole('textbox', { name: /code|token/i }).fill('000000');
await page.getByRole('button', { name: /verify/i }).click();
await expect(page.getByRole('alert')).toContainText(/invalid.*code/i);
});
});Variants
| Variant | Description |
|---|---|
| Valid TOTP | Correct time-based code → dashboard |
| Backup code | Single-use backup code accepted; warning shown |
| Invalid code | Wrong code → alert, stays on MFA page |
| Expired code | Clock-advanced token rejected |
| MFA enrollment | QR shown → token verified → backup codes displayed |
Password Reset Template
Tests reset request, setting a new password, and expired link handling.
Prerequisites
- Account with email:
{{username}} - Reset link / token available in test environment (
{{resetToken}}) - App running at
{{baseUrl}}
---
TypeScript
import { test, expect } from '@playwright/test';
test.describe('Password Reset', () => {
// Happy path: request reset email
test('sends reset email for known address', async ({ page }) => {
await page.goto('{{baseUrl}}/forgot-password');
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('button', { name: /send reset/i }).click();
await expect(page.getByRole('alert')).toContainText(/check your email/i);
});
// Happy path: set new password via reset link
test('sets new password with valid reset token', async ({ page }) => {
await page.goto('{{baseUrl}}/reset-password?token={{resetToken}}');
await expect(page.getByRole('heading', { name: /set.*new password/i })).toBeVisible();
await page.getByRole('textbox', { name: /^new password$/i }).fill('{{newPassword}}');
await page.getByRole('textbox', { name: /confirm password/i }).fill('{{newPassword}}');
await page.getByRole('button', { name: /reset password/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/login');
await expect(page.getByRole('alert')).toContainText(/password.*updated/i);
});
// Happy path: login with new password
test('can log in with updated password', async ({ page }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('textbox', { name: /password/i }).fill('{{newPassword}}');
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
});
// Error case: expired reset link
test('shows error for expired reset token', async ({ page }) => {
await page.goto('{{baseUrl}}/reset-password?token={{expiredResetToken}}');
await expect(page.getByRole('alert')).toContainText(/link.*expired|token.*invalid/i);
await expect(page.getByRole('link', { name: /request new link/i })).toBeVisible();
});
// Error case: unknown email
test('shows generic message for unknown email (anti-enumeration)', async ({ page }) => {
await page.goto('{{baseUrl}}/forgot-password');
await page.getByRole('textbox', { name: /email/i }).fill('unknown@example.com');
await page.getByRole('button', { name: /send reset/i }).click();
// Should NOT reveal whether email exists
await expect(page.getByRole('alert')).toContainText(/check your email/i);
});
// Error case: passwords do not match
test('validates that passwords match', async ({ page }) => {
await page.goto('{{baseUrl}}/reset-password?token={{resetToken}}');
await page.getByRole('textbox', { name: /^new password$/i }).fill('{{newPassword}}');
await page.getByRole('textbox', { name: /confirm password/i }).fill('different-password');
await page.getByRole('button', { name: /reset password/i }).click();
await expect(page.getByText(/passwords.*do not match/i)).toBeVisible();
});
// Edge case: weak password rejected
test('rejects password that does not meet strength requirements', async ({ page }) => {
await page.goto('{{baseUrl}}/reset-password?token={{resetToken}}');
await page.getByRole('textbox', { name: /^new password$/i }).fill('123');
await page.getByRole('textbox', { name: /confirm password/i }).fill('123');
await page.getByRole('button', { name: /reset password/i }).click();
await expect(page.getByText(/password.*too weak|must be at least/i)).toBeVisible();
});
});---
JavaScript
const { test, expect } = require('@playwright/test');
test.describe('Password Reset', () => {
test('sends reset email for known address', async ({ page }) => {
await page.goto('{{baseUrl}}/forgot-password');
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('button', { name: /send reset/i }).click();
await expect(page.getByRole('alert')).toContainText(/check your email/i);
});
test('sets new password with valid reset token', async ({ page }) => {
await page.goto('{{baseUrl}}/reset-password?token={{resetToken}}');
await page.getByRole('textbox', { name: /^new password$/i }).fill('{{newPassword}}');
await page.getByRole('textbox', { name: /confirm password/i }).fill('{{newPassword}}');
await page.getByRole('button', { name: /reset password/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/login');
});
test('shows error for expired reset token', async ({ page }) => {
await page.goto('{{baseUrl}}/reset-password?token={{expiredResetToken}}');
await expect(page.getByRole('alert')).toContainText(/link.*expired|token.*invalid/i);
});
test('validates passwords match', async ({ page }) => {
await page.goto('{{baseUrl}}/reset-password?token={{resetToken}}');
await page.getByRole('textbox', { name: /^new password$/i }).fill('{{newPassword}}');
await page.getByRole('textbox', { name: /confirm password/i }).fill('other');
await page.getByRole('button', { name: /reset password/i }).click();
await expect(page.getByText(/passwords.*do not match/i)).toBeVisible();
});
});Variants
| Variant | Description |
|---|---|
| Request reset | Known email → check email message |
| Set new password | Valid token → new password set → login page |
| Login with new pw | Updated credentials accepted |
| Expired token | Error + "request new link" shown |
| Unknown email | Generic response (anti-enumeration) |
| Passwords mismatch | Inline validation error |
| Weak password | Strength requirement error |
RBAC Template
Tests role-based access control: admin vs user permissions and forbidden pages.
Prerequisites
- Admin account:
{{adminUsername}}/{{adminPassword}} - Regular user:
{{userUsername}}/{{userPassword}} - App running at
{{baseUrl}}
---
TypeScript
import { test, expect } from '@playwright/test';
const adminState = '{{adminStorageStatePath}}';
const userState = '{{userStorageStatePath}}';
test.describe('RBAC — Admin', () => {
test.use({ storageState: adminState });
// Happy path: admin accesses admin panel
test('admin can access admin panel', async ({ page }) => {
await page.goto('{{baseUrl}}/admin');
await expect(page.getByRole('heading', { name: /admin/i })).toBeVisible();
});
test('admin can see user management menu item', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await expect(page.getByRole('link', { name: /user management/i })).toBeVisible();
});
test('admin can delete any resource', async ({ page }) => {
await page.goto('{{baseUrl}}/admin/{{entityName}}s');
await page.getByRole('row').nth(1).getByRole('button', { name: /delete/i }).click();
await page.getByRole('button', { name: /confirm/i }).click();
await expect(page.getByRole('alert')).toContainText(/deleted/i);
});
});
test.describe('RBAC — Regular User', () => {
test.use({ storageState: userState });
// Error case: user cannot access admin panel
test('regular user sees 403 on admin panel', async ({ page }) => {
await page.goto('{{baseUrl}}/admin');
await expect(page).toHaveURL(/\/403|\/forbidden|\/dashboard/);
const forbidden = page.getByRole('heading', { name: /403|forbidden|not authorized/i });
await expect(forbidden).toBeVisible();
});
test('regular user does not see admin menu items', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await expect(page.getByRole('link', { name: /user management/i })).toBeHidden();
});
// Error case: user cannot delete others' resources
test('regular user cannot delete another user\'s resource', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{otherUsersEntityId}}');
await expect(page.getByRole('button', { name: /delete/i })).toBeHidden();
});
// Edge case: direct navigation to admin API returns 403
test('API returns 403 for unauthorized role', async ({ page }) => {
const response = await page.request.get('{{baseUrl}}/api/admin/users');
expect(response.status()).toBe(403);
});
});
test.describe('RBAC — Role Elevation', () => {
// Edge case: user promoted to admin gains access
test('newly promoted admin can access admin panel', async ({ browser }) => {
// Step 1: use admin context to promote user
const adminCtx = await browser.newContext({ storageState: adminState });
const adminPage = await adminCtx.newPage();
await adminPage.goto('{{baseUrl}}/admin/users/{{promotedUserId}}/role');
await adminPage.getByRole('combobox', { name: /role/i }).selectOption('admin');
await adminPage.getByRole('button', { name: /save/i }).click();
await adminCtx.close();
// Step 2: promoted user can now access admin panel
const userCtx = await browser.newContext({ storageState: userState });
const userPage = await userCtx.newPage();
await userPage.goto('{{baseUrl}}/admin');
await expect(userPage.getByRole('heading', { name: /admin/i })).toBeVisible();
await userCtx.close();
});
});---
JavaScript
const { test, expect } = require('@playwright/test');
test.describe('RBAC — Admin', () => {
test.use({ storageState: '{{adminStorageStatePath}}' });
test('admin can access admin panel', async ({ page }) => {
await page.goto('{{baseUrl}}/admin');
await expect(page.getByRole('heading', { name: /admin/i })).toBeVisible();
});
});
test.describe('RBAC — Regular User', () => {
test.use({ storageState: '{{userStorageStatePath}}' });
test('regular user sees 403 on admin panel', async ({ page }) => {
await page.goto('{{baseUrl}}/admin');
await expect(page.getByRole('heading', { name: /403|forbidden/i })).toBeVisible();
});
test('API returns 403 for unauthorized role', async ({ page }) => {
const res = await page.request.get('{{baseUrl}}/api/admin/users');
expect(res.status()).toBe(403);
});
});Variants
| Variant | Description |
|---|---|
| Admin access | Admin reaches /admin panel |
| Admin menu | Admin-only nav items visible |
| Admin delete | Admin can delete any resource |
| User forbidden | Regular user → 403/redirect on /admin |
| User hidden menu | Admin nav items not rendered for user |
| API 403 | Backend enforces role on API routes |
| Role elevation | Promoted user gains new access immediately |
Remember Me Template
Tests persistent login cookie behaviour and expiry.
Prerequisites
- Valid account:
{{username}}/{{password}} {{sessionCookieName}}cookie used for auth- App running at
{{baseUrl}}
---
TypeScript
import { test, expect } from '@playwright/test';
test.describe('Remember Me', () => {
// Happy path: cookie is long-lived when remember me is checked
test('sets persistent cookie when remember me is checked', async ({ page, context }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('textbox', { name: /password/i }).fill('{{password}}');
await page.getByRole('checkbox', { name: /remember me/i }).check();
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
const cookies = await context.cookies();
const session = cookies.find(c => c.name === '{{sessionCookieName}}');
// Cookie should expire > 7 days from now
expect(session?.expires).toBeGreaterThan(Date.now() / 1000 + 7 * 86400);
});
// Happy path: session cookie (no remember me) is session-scoped
test('sets session-scoped cookie when remember me is unchecked', async ({ page, context }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('textbox', { name: /password/i }).fill('{{password}}');
const checkbox = page.getByRole('checkbox', { name: /remember me/i });
if (await checkbox.isChecked()) await checkbox.uncheck();
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
const cookies = await context.cookies();
const session = cookies.find(c => c.name === '{{sessionCookieName}}');
// Session cookie: expires = -1 (browser session only)
expect(session?.expires).toBeLessThanOrEqual(0);
});
// Happy path: persistent login survives page reload
test('stays logged in across browser restart with remember me', async ({ page, context }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('textbox', { name: /password/i }).fill('{{password}}');
await page.getByRole('checkbox', { name: /remember me/i }).check();
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
// Simulate new browser session by closing & reopening page (cookies persist)
await page.close();
const newPage = await context.newPage();
await newPage.goto('{{baseUrl}}/dashboard');
await expect(newPage).toHaveURL('{{baseUrl}}/dashboard');
await expect(newPage.getByRole('heading', { name: /dashboard/i })).toBeVisible();
});
// Error case: expired persistent cookie redirects to login
test('redirects to login when persistent cookie has expired', async ({ page, context }) => {
await context.addCookies([{
name: '{{sessionCookieName}}',
value: '{{expiredCookieValue}}',
domain: '{{cookieDomain}}',
path: '/',
expires: Math.floor(Date.now() / 1000) - 1, // already expired
}]);
await page.goto('{{baseUrl}}/dashboard');
await expect(page).toHaveURL(/\/login/);
});
// Edge case: remember me checkbox state is preserved on validation error
test('retains remember me checkbox state after failed login', async ({ page }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('checkbox', { name: /remember me/i }).check();
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('textbox', { name: /password/i }).fill('wrong');
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page.getByRole('alert')).toContainText(/invalid/i);
await expect(page.getByRole('checkbox', { name: /remember me/i })).toBeChecked();
});
});---
JavaScript
const { test, expect } = require('@playwright/test');
test.describe('Remember Me', () => {
test('sets persistent cookie when remember me is checked', async ({ page, context }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('textbox', { name: /password/i }).fill('{{password}}');
await page.getByRole('checkbox', { name: /remember me/i }).check();
await page.getByRole('button', { name: /sign in/i }).click();
const cookies = await context.cookies();
const session = cookies.find(c => c.name === '{{sessionCookieName}}');
expect(session?.expires).toBeGreaterThan(Date.now() / 1000 + 7 * 86400);
});
test('sets session cookie when remember me is unchecked', async ({ page, context }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('textbox', { name: /password/i }).fill('{{password}}');
await page.getByRole('button', { name: /sign in/i }).click();
const cookies = await context.cookies();
const session = cookies.find(c => c.name === '{{sessionCookieName}}');
expect(session?.expires).toBeLessThanOrEqual(0);
});
});Variants
| Variant | Description |
|---|---|
| Persistent cookie | Remember me → long-lived cookie (>7 days) |
| Session cookie | No remember me → session-scoped cookie |
| Survives reload | Persistent cookie keeps user logged in across restart |
| Expired cookie | Stale cookie → redirect to /login |
| Checkbox retained | State preserved after failed login attempt |
Session Timeout Template
Tests auto-logout after inactivity and session refresh behaviour.
Prerequisites
- Authenticated session via
{{authStorageStatePath}} - Session timeout configured to
{{sessionTimeoutMs}}ms in test env - App running at
{{baseUrl}}
---
TypeScript
import { test, expect } from '@playwright/test';
test.describe('Session Timeout', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
// Happy path: session refresh on activity
test('refreshes session on user activity', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.clock.install();
// Advance to just before timeout
await page.clock.fastForward({{sessionTimeoutMs}} - 5000);
await page.getByRole('button', { name: /any interactive element/i }).click();
// Advance past original timeout — session should still be valid
await page.clock.fastForward(10_000);
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
});
// Happy path: warning dialog shown before logout
test('shows session-expiry warning before auto-logout', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.clock.install();
await page.clock.fastForward({{sessionTimeoutMs}} - {{warningLeadMs}});
await expect(page.getByRole('dialog', { name: /session.*expiring/i })).toBeVisible();
await expect(page.getByRole('button', { name: /stay signed in/i })).toBeVisible();
});
// Happy path: extend session from warning dialog
test('extends session when "stay signed in" clicked', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.clock.install();
await page.clock.fastForward({{sessionTimeoutMs}} - {{warningLeadMs}});
await page.getByRole('button', { name: /stay signed in/i }).click();
await expect(page.getByRole('dialog', { name: /session.*expiring/i })).toBeHidden();
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
});
// Error case: auto-logout after inactivity
test('redirects to login after session timeout', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.clock.install();
await page.clock.fastForward({{sessionTimeoutMs}} + 1000);
await expect(page).toHaveURL(/\/login/);
await expect(page.getByText(/session.*expired|signed out/i)).toBeVisible();
});
// Edge case: API calls return 401 after timeout
test('shows re-auth prompt when API returns 401', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.route('{{baseUrl}}/api/**', route =>
route.fulfill({ status: 401, body: JSON.stringify({ error: 'Unauthorized' }) })
);
await page.getByRole('button', { name: /refresh|reload/i }).click();
await expect(page.getByRole('dialog', { name: /session.*expired/i })).toBeVisible();
});
});---
JavaScript
const { test, expect } = require('@playwright/test');
test.describe('Session Timeout', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('shows warning before auto-logout', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.clock.install();
await page.clock.fastForward({{sessionTimeoutMs}} - {{warningLeadMs}});
await expect(page.getByRole('dialog', { name: /session.*expiring/i })).toBeVisible();
});
test('auto-logs out after inactivity', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.clock.install();
await page.clock.fastForward({{sessionTimeoutMs}} + 1000);
await expect(page).toHaveURL(/\/login/);
});
test('extends session on "stay signed in"', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.clock.install();
await page.clock.fastForward({{sessionTimeoutMs}} - {{warningLeadMs}});
await page.getByRole('button', { name: /stay signed in/i }).click();
await expect(page.getByRole('dialog', { name: /session.*expiring/i })).toBeHidden();
});
});Variants
| Variant | Description |
|---|---|
| Session refresh | Activity before timeout resets the clock |
| Warning dialog | Shown N ms before timeout |
| Extend session | "Stay signed in" dismisses warning |
| Auto-logout | Inactivity past timeout → /login |
| 401 from API | Re-auth dialog shown when backend rejects request |
SSO Template
Tests SSO redirect flow, IdP callback handling, and attribute mapping.
Prerequisites
- SSO provider configured (SAML / OIDC) at
{{ssoProviderUrl}} - Test IdP with user
{{ssoUsername}} - App running at
{{baseUrl}}
---
TypeScript
import { test, expect, Page } from '@playwright/test';
async function completeSsoLogin(page: Page, username: string): Promise<void> {
// Fill IdP login form — adapt selectors to your provider
await page.getByRole('textbox', { name: /username/i }).fill(username);
await page.getByRole('button', { name: /login/i }).click();
}
test.describe('SSO', () => {
// Happy path: SSO redirect and callback
test('redirects to IdP and returns authenticated', async ({ page }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('button', { name: /sign in with sso/i }).click();
await expect(page).toHaveURL(/{{ssoProviderDomain}}/);
await completeSsoLogin(page, '{{ssoUsername}}');
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
});
// Happy path: SSO with domain hint
test('pre-fills organisation domain and redirects', async ({ page }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('textbox', { name: /work email/i }).fill('{{ssoUsername}}');
await page.getByRole('button', { name: /continue/i }).click();
await expect(page).toHaveURL(/{{ssoProviderDomain}}/);
});
// Happy path: attributes mapped to user profile
test('maps SSO attributes to user profile', async ({ page }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('button', { name: /sign in with sso/i }).click();
await completeSsoLogin(page, '{{ssoUsername}}');
await page.goto('{{baseUrl}}/settings/profile');
await expect(page.getByRole('textbox', { name: /email/i })).toHaveValue('{{ssoUsername}}');
});
// Error case: IdP returns error
test('shows error page when IdP returns error response', async ({ page }) => {
await page.goto('{{baseUrl}}/auth/callback?error=access_denied&error_description=User+denied+access');
await expect(page.getByRole('alert')).toContainText(/access denied/i);
await expect(page.getByRole('link', { name: /back to login/i })).toBeVisible();
});
// Error case: invalid callback state
test('rejects callback with invalid state parameter', async ({ page }) => {
await page.goto('{{baseUrl}}/auth/callback?code=valid_code&state=tampered_state');
await expect(page.getByRole('alert')).toContainText(/invalid.*state|authentication failed/i);
});
// Edge case: SSO user first login provisions account
test('provisions new account on first SSO login', async ({ page }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('button', { name: /sign in with sso/i }).click();
await completeSsoLogin(page, '{{newSsoUsername}}');
await expect(page).toHaveURL(/{{baseUrl}}\/(dashboard|onboarding)/);
});
});---
JavaScript
const { test, expect } = require('@playwright/test');
async function completeSsoLogin(page, username) {
await page.getByRole('textbox', { name: /username/i }).fill(username);
await page.getByRole('button', { name: /login/i }).click();
}
test.describe('SSO', () => {
test('redirects to IdP and returns authenticated', async ({ page }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('button', { name: /sign in with sso/i }).click();
await expect(page).toHaveURL(/{{ssoProviderDomain}}/);
await completeSsoLogin(page, '{{ssoUsername}}');
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
});
test('shows error when IdP returns access_denied', async ({ page }) => {
await page.goto('{{baseUrl}}/auth/callback?error=access_denied');
await expect(page.getByRole('alert')).toContainText(/access denied/i);
});
test('rejects tampered state parameter', async ({ page }) => {
await page.goto('{{baseUrl}}/auth/callback?code=abc&state=tampered');
await expect(page.getByRole('alert')).toContainText(/invalid.*state|authentication failed/i);
});
});Variants
| Variant | Description |
|---|---|
| Happy path | SSO button → IdP → callback → dashboard |
| Domain hint | Email triggers org-specific IdP redirect |
| Attribute mapping | SSO profile fields populate user record |
| IdP error | access_denied → error page with back link |
| Invalid state | CSRF protection rejects tampered callback |
| First login | Auto-provisions account on initial SSO |
Add to Cart Template
Tests adding items to cart and quantity updates.
Prerequisites
- Authenticated (or guest) session
- Product: ID
{{productId}}, name{{productName}}, price{{productPrice}} - App running at
{{baseUrl}}
---
TypeScript
import { test, expect } from '@playwright/test';
test.describe('Add to Cart', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/products/{{productId}}');
});
// Happy path: add single item
test('adds product to cart', async ({ page }) => {
await page.getByRole('button', { name: /add to cart/i }).click();
await expect(page.getByRole('status', { name: /cart/i })).toContainText('1');
await expect(page.getByRole('alert')).toContainText(/added to cart/i);
});
// Happy path: add multiple items increments count
test('increments cart count on repeated add', async ({ page }) => {
await page.getByRole('button', { name: /add to cart/i }).click();
await page.getByRole('button', { name: /add to cart/i }).click();
await expect(page.getByRole('status', { name: /cart/i })).toContainText('2');
});
// Happy path: add with quantity selector
test('adds specified quantity to cart', async ({ page }) => {
await page.getByRole('spinbutton', { name: /quantity/i }).fill('3');
await page.getByRole('button', { name: /add to cart/i }).click();
await expect(page.getByRole('status', { name: /cart/i })).toContainText('3');
});
// Happy path: cart persists on navigation
test('cart persists after navigating away', async ({ page }) => {
await page.getByRole('button', { name: /add to cart/i }).click();
await page.goto('{{baseUrl}}/products');
await expect(page.getByRole('status', { name: /cart/i })).toContainText('1');
});
// Error case: out of stock product cannot be added
test('add to cart button disabled for out-of-stock product', async ({ page }) => {
await page.goto('{{baseUrl}}/products/{{outOfStockProductId}}');
await expect(page.getByRole('button', { name: /add to cart/i })).toBeDisabled();
await expect(page.getByText(/out of stock/i)).toBeVisible();
});
// Error case: quantity exceeds stock
test('shows error when quantity exceeds available stock', async ({ page }) => {
await page.getByRole('spinbutton', { name: /quantity/i }).fill('{{overStockQuantity}}');
await page.getByRole('button', { name: /add to cart/i }).click();
await expect(page.getByRole('alert')).toContainText(/only.*available|exceeds.*stock/i);
});
// Edge case: cart opens after add
test('cart drawer opens after adding item', async ({ page }) => {
await page.getByRole('button', { name: /add to cart/i }).click();
await expect(page.getByRole('dialog', { name: /cart/i })).toBeVisible();
await expect(page.getByRole('dialog').getByText('{{productName}}')).toBeVisible();
});
});---
JavaScript
const { test, expect } = require('@playwright/test');
test.describe('Add to Cart', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/products/{{productId}}');
});
test('adds product to cart', async ({ page }) => {
await page.getByRole('button', { name: /add to cart/i }).click();
await expect(page.getByRole('status', { name: /cart/i })).toContainText('1');
});
test('add to cart disabled for out-of-stock', async ({ page }) => {
await page.goto('{{baseUrl}}/products/{{outOfStockProductId}}');
await expect(page.getByRole('button', { name: /add to cart/i })).toBeDisabled();
});
test('cart persists after navigation', async ({ page }) => {
await page.getByRole('button', { name: /add to cart/i }).click();
await page.goto('{{baseUrl}}/products');
await expect(page.getByRole('status', { name: /cart/i })).toContainText('1');
});
});Variants
| Variant | Description |
|---|---|
| Single add | Product added, cart count = 1 |
| Repeated add | Cart count increments |
| Quantity selector | Specified quantity added |
| Persist on nav | Cart count survives page change |
| Out of stock | Button disabled, label shown |
| Quantity exceeds stock | Error alert |
| Cart drawer | Slide-in cart opens showing added item |
Apply Coupon Template
Tests valid coupon code, invalid code, and expired coupon handling.
Prerequisites
- Cart with items totalling
{{cartTotal}} - Valid coupon:
{{validCouponCode}}({{discountPercent}}% off) - Expired coupon:
{{expiredCouponCode}} - App running at
{{baseUrl}}
---
TypeScript
import { test, expect } from '@playwright/test';
test.describe('Apply Coupon', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/cart');
});
// Happy path: valid coupon applied
test('applies valid coupon and shows discount', async ({ page }) => {
await page.getByRole('textbox', { name: /coupon|promo code/i }).fill('{{validCouponCode}}');
await page.getByRole('button', { name: /apply/i }).click();
await expect(page.getByText(/{{discountPercent}}%.*off|discount applied/i)).toBeVisible();
await expect(page.getByText('{{discountedTotal}}')).toBeVisible();
await expect(page.getByRole('button', { name: /remove coupon/i })).toBeVisible();
});
// Happy path: percentage discount calculated correctly
test('calculates discount amount correctly', async ({ page }) => {
await page.getByRole('textbox', { name: /coupon|promo code/i }).fill('{{validCouponCode}}');
await page.getByRole('button', { name: /apply/i }).click();
const discountLine = page.getByRole('row', { name: /discount/i });
await expect(discountLine).toContainText('-{{discountAmount}}');
});
// Happy path: remove applied coupon
test('removes applied coupon and restores original total', async ({ page }) => {
await page.getByRole('textbox', { name: /coupon|promo code/i }).fill('{{validCouponCode}}');
await page.getByRole('button', { name: /apply/i }).click();
await page.getByRole('button', { name: /remove coupon/i }).click();
await expect(page.getByText('{{cartTotal}}')).toBeVisible();
await expect(page.getByRole('button', { name: /remove coupon/i })).toBeHidden();
});
// Error case: invalid coupon code
test('shows error for invalid coupon code', async ({ page }) => {
await page.getByRole('textbox', { name: /coupon|promo code/i }).fill('INVALID123');
await page.getByRole('button', { name: /apply/i }).click();
await expect(page.getByRole('alert')).toContainText(/invalid.*coupon|code not found/i);
await expect(page.getByText('{{cartTotal}}')).toBeVisible();
});
// Error case: expired coupon
test('shows error for expired coupon', async ({ page }) => {
await page.getByRole('textbox', { name: /coupon|promo code/i }).fill('{{expiredCouponCode}}');
await page.getByRole('button', { name: /apply/i }).click();
await expect(page.getByRole('alert')).toContainText(/expired|no longer valid/i);
});
// Error case: coupon not applicable to cart items
test('shows error when coupon excludes cart products', async ({ page }) => {
await page.getByRole('textbox', { name: /coupon|promo code/i }).fill('{{categoryRestrictedCoupon}}');
await page.getByRole('button', { name: /apply/i }).click();
await expect(page.getByRole('alert')).toContainText(/not applicable|excluded/i);
});
// Edge case: empty coupon field
test('apply button disabled when coupon field is empty', async ({ page }) => {
const applyBtn = page.getByRole('button', { name: /apply/i });
await expect(applyBtn).toBeDisabled();
await page.getByRole('textbox', { name: /coupon|promo code/i }).fill('X');
await expect(applyBtn).toBeEnabled();
});
});---
JavaScript
const { test, expect } = require('@playwright/test');
test.describe('Apply Coupon', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/cart');
});
test('applies valid coupon and shows discount', async ({ page }) => {
await page.getByRole('textbox', { name: /coupon|promo code/i }).fill('{{validCouponCode}}');
await page.getByRole('button', { name: /apply/i }).click();
await expect(page.getByText(/discount applied/i)).toBeVisible();
await expect(page.getByText('{{discountedTotal}}')).toBeVisible();
});
test('shows error for invalid coupon', async ({ page }) => {
await page.getByRole('textbox', { name: /coupon|promo code/i }).fill('INVALID123');
await page.getByRole('button', { name: /apply/i }).click();
await expect(page.getByRole('alert')).toContainText(/invalid.*coupon/i);
});
test('shows error for expired coupon', async ({ page }) => {
await page.getByRole('textbox', { name: /coupon|promo code/i }).fill('{{expiredCouponCode}}');
await page.getByRole('button', { name: /apply/i }).click();
await expect(page.getByRole('alert')).toContainText(/expired/i);
});
});Variants
| Variant | Description |
|---|---|
| Valid coupon | Discount applied, total updated |
| Discount calculation | Discount line shows correct amount |
| Remove coupon | Original total restored |
| Invalid code | Error alert, total unchanged |
| Expired coupon | Expiry error shown |
| Category restriction | Coupon not applicable error |
| Empty field | Apply button disabled |
Order Confirmation Template
Tests the success page and order details after checkout.
Prerequisites
- Completed order with ID
{{orderId}} - Authenticated session via
{{authStorageStatePath}} - App running at
{{baseUrl}}
---
TypeScript
import { test, expect } from '@playwright/test';
test.describe('Order Confirmation', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
// Happy path: confirmation page content
test('shows order confirmation with correct details', async ({ page }) => {
await page.goto('{{baseUrl}}/order-confirmation/{{orderId}}');
await expect(page.getByRole('heading', { name: /order confirmed|thank you/i })).toBeVisible();
await expect(page.getByText('{{orderId}}')).toBeVisible();
await expect(page.getByText('{{productName}}')).toBeVisible();
await expect(page.getByText('{{orderTotal}}')).toBeVisible();
});
// Happy path: confirmation email notice
test('shows confirmation email notice', async ({ page }) => {
await page.goto('{{baseUrl}}/order-confirmation/{{orderId}}');
await expect(page.getByText(/confirmation.*sent to|email.*{{username}}/i)).toBeVisible();
});
// Happy path: billing and shipping details shown
test('displays shipping address on confirmation page', async ({ page }) => {
await page.goto('{{baseUrl}}/order-confirmation/{{orderId}}');
await expect(page.getByText('{{shippingAddress}}')).toBeVisible();
await expect(page.getByText('{{billingAddress}}')).toBeVisible();
});
// Happy path: CTA navigates to order history
test('"view your orders" link navigates to order history', async ({ page }) => {
await page.goto('{{baseUrl}}/order-confirmation/{{orderId}}');
await page.getByRole('link', { name: /view.*orders|my orders/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/orders');
});
// Happy path: continue shopping CTA
test('"continue shopping" returns to products', async ({ page }) => {
await page.goto('{{baseUrl}}/order-confirmation/{{orderId}}');
await page.getByRole('link', { name: /continue shopping/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/products');
});
// Error case: accessing another user's order shows 403
test('cannot access another user\'s confirmation page', async ({ page }) => {
await page.goto('{{baseUrl}}/order-confirmation/{{otherUsersOrderId}}');
await expect(page).toHaveURL(/\/403|\/dashboard/);
});
// Edge case: cart is empty after successful checkout
test('cart is empty after order confirmed', async ({ page }) => {
await page.goto('{{baseUrl}}/order-confirmation/{{orderId}}');
await expect(page.getByRole('status', { name: /cart/i })).toContainText('0');
});
});---
JavaScript
const { test, expect } = require('@playwright/test');
test.describe('Order Confirmation', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('shows order id and total on confirmation', async ({ page }) => {
await page.goto('{{baseUrl}}/order-confirmation/{{orderId}}');
await expect(page.getByRole('heading', { name: /order confirmed|thank you/i })).toBeVisible();
await expect(page.getByText('{{orderId}}')).toBeVisible();
await expect(page.getByText('{{orderTotal}}')).toBeVisible();
});
test('cart is empty after checkout', async ({ page }) => {
await page.goto('{{baseUrl}}/order-confirmation/{{orderId}}');
await expect(page.getByRole('status', { name: /cart/i })).toContainText('0');
});
test('cannot access another user\'s order', async ({ page }) => {
await page.goto('{{baseUrl}}/order-confirmation/{{otherUsersOrderId}}');
await expect(page).toHaveURL(/\/403|\/dashboard/);
});
});Variants
| Variant | Description |
|---|---|
| Confirmation content | Order ID, product, total visible |
| Email notice | Confirmation email address shown |
| Shipping/billing | Addresses displayed |
| View orders CTA | Navigates to /orders |
| Continue shopping | Returns to /products |
| Unauthorized | Other user's order → 403 |
| Cart cleared | Cart count = 0 after checkout |
Order History Template
Tests listing orders, viewing order details, and pagination.
Prerequisites
- Authenticated session via
{{authStorageStatePath}} - At least
{{orderCount}}orders seeded for user - App running at
{{baseUrl}}
---
TypeScript
import { test, expect } from '@playwright/test';
test.describe('Order History', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
// Happy path: order list
test('displays list of orders with key details', async ({ page }) => {
await page.goto('{{baseUrl}}/orders');
await expect(page.getByRole('heading', { name: /orders|order history/i })).toBeVisible();
const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
await expect(rows.first()).toContainText('{{latestOrderId}}');
await expect(rows.first()).toContainText('{{latestOrderStatus}}');
await expect(rows.first()).toContainText('{{latestOrderTotal}}');
});
// Happy path: view order details
test('navigates to order detail from history', async ({ page }) => {
await page.goto('{{baseUrl}}/orders');
await page.getByRole('link', { name: new RegExp('{{latestOrderId}}') }).click();
await expect(page).toHaveURL(`{{baseUrl}}/orders/{{latestOrderId}}`);
await expect(page.getByRole('heading', { name: '{{latestOrderId}}' })).toBeVisible();
await expect(page.getByText('{{productName}}')).toBeVisible();
});
// Happy path: order status badge
test('shows correct status badge for each order', async ({ page }) => {
await page.goto('{{baseUrl}}/orders');
const deliveredBadge = page.getByRole('status', { name: /delivered/i }).first();
await expect(deliveredBadge).toBeVisible();
});
// Happy path: pagination
test('paginates through orders', async ({ page }) => {
await page.goto('{{baseUrl}}/orders');
const firstPageFirstOrder = await page.getByRole('row').nth(1).textContent();
await page.getByRole('button', { name: /next page|>/i }).click();
await expect(page.getByRole('row').nth(1)).not.toHaveText(firstPageFirstOrder!);
await expect(page.getByRole('button', { name: /previous page|</i })).toBeEnabled();
});
// Happy path: items per page selector
test('changes items per page', async ({ page }) => {
await page.goto('{{baseUrl}}/orders');
await page.getByRole('combobox', { name: /per page|items per page/i }).selectOption('50');
const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
await expect(rows).toHaveCount(Math.min(50, {{orderCount}}));
});
// Error case: empty order history
test('shows empty state for user with no orders', async ({ page }) => {
await page.goto('{{baseUrl}}/orders');
// Assumes this user context has no orders
await expect(page.getByText(/no orders yet|start shopping/i)).toBeVisible();
});
// Edge case: reorder from history
test('adds previous order items to cart via reorder', async ({ page }) => {
await page.goto('{{baseUrl}}/orders/{{latestOrderId}}');
await page.getByRole('button', { name: /reorder|buy again/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/cart');
await expect(page.getByText('{{productName}}')).toBeVisible();
});
});---
JavaScript
const { test, expect } = require('@playwright/test');
test.describe('Order History', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('displays orders with id, status, and total', async ({ page }) => {
await page.goto('{{baseUrl}}/orders');
const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
await expect(rows.first()).toContainText('{{latestOrderId}}');
});
test('navigates to order detail', async ({ page }) => {
await page.goto('{{baseUrl}}/orders');
await page.getByRole('link', { name: new RegExp('{{latestOrderId}}') }).click();
await expect(page).toHaveURL(`{{baseUrl}}/orders/{{latestOrderId}}`);
});
test('paginates through orders', async ({ page }) => {
await page.goto('{{baseUrl}}/orders');
await page.getByRole('button', { name: /next page|>/i }).click();
await expect(page.getByRole('button', { name: /previous page|</i })).toBeEnabled();
});
});Variants
| Variant | Description |
|---|---|
| Order list | ID, status, total visible per row |
| Order detail | Clicking order → detail page |
| Status badge | Correct badge per order state |
| Pagination | Next page loads different orders |
| Items per page | Selector changes row count |
| Empty state | No-orders message with CTA |
| Reorder | Previous order items added to cart |
Payment Template
Tests card form entry, validation, and payment processing.
Prerequisites
- Cart with items, shipping filled
- Test card numbers:
{{testCardNumber}}(success),{{declinedCardNumber}}(decline) - App running at
{{baseUrl}}
---
TypeScript
import { test, expect, Page } from '@playwright/test';
async function fillCardForm(page: Page, card: {
number: string; expiry: string; cvc: string; name: string;
}): Promise<void> {
// Stripe/Braintree iframes — adapt frame locator to your provider
const cardFrame = page.frameLocator('[data-testid="card-number-frame"]');
await cardFrame.getByRole('textbox', { name: /card number/i }).fill(card.number);
const expiryFrame = page.frameLocator('[data-testid="expiry-frame"]');
await expiryFrame.getByRole('textbox', { name: /expiry/i }).fill(card.expiry);
const cvcFrame = page.frameLocator('[data-testid="cvc-frame"]');
await cvcFrame.getByRole('textbox', { name: /cvc|cvv/i }).fill(card.cvc);
await page.getByRole('textbox', { name: /cardholder name/i }).fill(card.name);
}
test.describe('Payment', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/checkout/payment');
});
// Happy path: successful payment
test('completes payment with valid card', async ({ page }) => {
await fillCardForm(page, {
number: '{{testCardNumber}}',
expiry: '12/28',
cvc: '123',
name: '{{cardholderName}}',
});
await page.getByRole('button', { name: /pay|place order/i }).click();
await expect(page).toHaveURL(/\/order-confirmation|\/success/);
await expect(page.getByRole('heading', { name: /order confirmed|thank you/i })).toBeVisible();
});
// Happy path: processing state shown
test('shows processing state while payment is pending', async ({ page }) => {
await fillCardForm(page, {
number: '{{testCardNumber}}',
expiry: '12/28',
cvc: '123',
name: '{{cardholderName}}',
});
const payBtn = page.getByRole('button', { name: /pay|place order/i });
await payBtn.click();
await expect(payBtn).toBeDisabled();
await expect(page.getByText(/processing|please wait/i)).toBeVisible();
});
// Error case: declined card
test('shows decline error for rejected card', async ({ page }) => {
await fillCardForm(page, {
number: '{{declinedCardNumber}}',
expiry: '12/28',
cvc: '123',
name: '{{cardholderName}}',
});
await page.getByRole('button', { name: /pay|place order/i }).click();
await expect(page.getByRole('alert')).toContainText(/declined|card.*not accepted/i);
await expect(page).toHaveURL(/\/checkout\/payment/);
});
// Error case: invalid card number format
test('shows inline error for invalid card number', async ({ page }) => {
const cardFrame = page.frameLocator('[data-testid="card-number-frame"]');
await cardFrame.getByRole('textbox', { name: /card number/i }).fill('1234');
await page.getByRole('button', { name: /pay|place order/i }).click();
await expect(page.getByText(/invalid.*card number/i)).toBeVisible();
});
// Error case: expired card
test('shows error for expired card', async ({ page }) => {
await fillCardForm(page, {
number: '{{testCardNumber}}',
expiry: '01/20',
cvc: '123',
name: '{{cardholderName}}',
});
await page.getByRole('button', { name: /pay|place order/i }).click();
await expect(page.getByRole('alert')).toContainText(/expired|invalid.*expiry/i);
});
// Edge case: 3DS authentication required
test('handles 3DS challenge and completes payment', async ({ page }) => {
await fillCardForm(page, {
number: '{{threeDsCardNumber}}',
expiry: '12/28',
cvc: '123',
name: '{{cardholderName}}',
});
await page.getByRole('button', { name: /pay|place order/i }).click();
// 3DS modal appears
const challengeFrame = page.frameLocator('[data-testid="3ds-challenge-frame"]');
await challengeFrame.getByRole('button', { name: /complete authentication/i }).click();
await expect(page).toHaveURL(/\/order-confirmation|\/success/);
});
});---
JavaScript
const { test, expect } = require('@playwright/test');
test.describe('Payment', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/checkout/payment');
});
test('completes payment with valid card', async ({ page }) => {
const cardFrame = page.frameLocator('[data-testid="card-number-frame"]');
await cardFrame.getByRole('textbox', { name: /card number/i }).fill('{{testCardNumber}}');
await page.getByRole('button', { name: /pay|place order/i }).click();
await expect(page).toHaveURL(/\/order-confirmation/);
});
test('shows decline error for rejected card', async ({ page }) => {
const cardFrame = page.frameLocator('[data-testid="card-number-frame"]');
await cardFrame.getByRole('textbox', { name: /card number/i }).fill('{{declinedCardNumber}}');
await page.getByRole('button', { name: /pay|place order/i }).click();
await expect(page.getByRole('alert')).toContainText(/declined/i);
});
});Variants
| Variant | Description |
|---|---|
| Successful payment | Valid test card → order confirmation |
| Processing state | Button disabled + spinner during processing |
| Declined card | Error alert, stays on payment page |
| Invalid card number | Inline validation from provider |
| Expired card | Expiry error |
| 3DS challenge | Modal completed, payment succeeds |
Update Cart Quantity Template
Tests increasing, decreasing, and removing items from cart.
Prerequisites
- Cart with at least one item:
{{productName}}(quantity 2) - App running at
{{baseUrl}}
---
TypeScript
import { test, expect } from '@playwright/test';
test.describe('Update Cart Quantity', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/cart');
// Assumes cart is pre-populated via storageState or API setup
});
// Happy path: increase quantity
test('increases item quantity', async ({ page }) => {
const row = page.getByRole('row', { name: new RegExp('{{productName}}') });
await row.getByRole('button', { name: /increase|plus|\+/i }).click();
await expect(row.getByRole('spinbutton', { name: /quantity/i })).toHaveValue('3');
await expect(page.getByRole('region', { name: /order summary/i })).toContainText('{{updatedTotal}}');
});
// Happy path: decrease quantity
test('decreases item quantity', async ({ page }) => {
const row = page.getByRole('row', { name: new RegExp('{{productName}}') });
await row.getByRole('button', { name: /decrease|minus|−/i }).click();
await expect(row.getByRole('spinbutton', { name: /quantity/i })).toHaveValue('1');
});
// Happy path: type quantity directly
test('updates quantity by typing in field', async ({ page }) => {
const row = page.getByRole('row', { name: new RegExp('{{productName}}') });
const qtyInput = row.getByRole('spinbutton', { name: /quantity/i });
await qtyInput.fill('5');
await qtyInput.press('Tab');
await expect(qtyInput).toHaveValue('5');
});
// Happy path: remove item with remove button
test('removes item from cart', async ({ page }) => {
const row = page.getByRole('row', { name: new RegExp('{{productName}}') });
await row.getByRole('button', { name: /remove|delete/i }).click();
await expect(row).toBeHidden();
await expect(page.getByText(/cart is empty/i)).toBeVisible();
});
// Happy path: decrease to 0 removes item
test('removing to quantity 0 removes item', async ({ page }) => {
const row = page.getByRole('row', { name: new RegExp('{{productName}}') });
await row.getByRole('button', { name: /decrease|minus/i }).click(); // from 2 to 1
await row.getByRole('button', { name: /decrease|minus/i }).click(); // should trigger remove
await expect(row).toBeHidden();
});
// Error case: quantity cannot go below 1 via decrease button
test('decrease button disabled at minimum quantity', async ({ page }) => {
const row = page.getByRole('row').nth(1);
const qty = row.getByRole('spinbutton', { name: /quantity/i });
await qty.fill('1');
await qty.press('Tab');
await expect(row.getByRole('button', { name: /decrease|minus/i })).toBeDisabled();
});
// Edge case: quantity clamped to stock limit
test('quantity capped at available stock', async ({ page }) => {
const row = page.getByRole('row', { name: new RegExp('{{productName}}') });
const qtyInput = row.getByRole('spinbutton', { name: /quantity/i });
await qtyInput.fill('{{overStockQuantity}}');
await qtyInput.press('Tab');
await expect(qtyInput).toHaveValue('{{maxStock}}');
await expect(page.getByRole('alert')).toContainText(/max.*available|stock limit/i);
});
});---
JavaScript
const { test, expect } = require('@playwright/test');
test.describe('Update Cart Quantity', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/cart');
});
test('increases item quantity', async ({ page }) => {
const row = page.getByRole('row', { name: new RegExp('{{productName}}') });
await row.getByRole('button', { name: /increase|plus|\+/i }).click();
await expect(row.getByRole('spinbutton', { name: /quantity/i })).toHaveValue('3');
});
test('removes item from cart', async ({ page }) => {
await page.getByRole('row', { name: new RegExp('{{productName}}') })
.getByRole('button', { name: /remove|delete/i }).click();
await expect(page.getByText(/cart is empty/i)).toBeVisible();
});
test('decrease button disabled at quantity 1', async ({ page }) => {
const row = page.getByRole('row').nth(1);
await row.getByRole('spinbutton', { name: /quantity/i }).fill('1');
await row.getByRole('spinbutton', { name: /quantity/i }).press('Tab');
await expect(row.getByRole('button', { name: /decrease|minus/i })).toBeDisabled();
});
});Variants
| Variant | Description |
|---|---|
| Increase | +1 → quantity updates, total recalculates |
| Decrease | -1 → quantity updates |
| Type directly | Manual quantity input accepted on blur/tab |
| Remove button | Item removed, empty-cart message shown |
| Decrease to 0 | Triggers item removal |
| Min quantity | Decrease button disabled at 1 |
| Stock cap | Input clamped to available stock |
Related skills
How it compares
Pick playwright-pro when you need assertion syntax and retry semantics, not browser install or CI wiring.
FAQ
What Playwright assertions auto-retry?
playwright-pro recommends Playwright web-first assertions such as expect(locator).toBeVisible(), toHaveText(), toHaveValue(), and toHaveAttribute(), which auto-retry until the configured timeout instead of failing on the first DOM miss.
When should developers use web-first assertions?
playwright-pro directs developers to web-first assertions whenever UI content loads asynchronously, because matchers like toContainText(/partial/i) and toHaveClass(/active/) keep polling until the element stabilizes or times out.
Is Playwright Pro safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.