feat: add playwright-pro plugin — production-grade Playwright testing toolkit (#254)

Complete Claude Code plugin with:
- 9 skills (/pw:init, generate, review, fix, migrate, coverage, testrail, browserstack, report)
- 3 specialized agents (test-architect, test-debugger, migration-planner)
- 55 test case templates across 11 categories (auth, CRUD, checkout, search, forms, dashboard, settings, onboarding, notifications, API, accessibility)
- TestRail MCP server (TypeScript) — 8 tools for bidirectional sync
- BrowserStack MCP server (TypeScript) — 7 tools for cross-browser testing
- Smart hooks (auto-validate tests, auto-detect Playwright projects)
- 6 curated reference docs (golden rules, locators, assertions, fixtures, pitfalls, flaky tests)
- Leverages Claude Code built-ins (/batch, /debug, Explore subagent)
- Zero-config for core features; TestRail/BrowserStack via env vars
- Both TypeScript and JavaScript support throughout

Co-authored-by: Leo <leo@openclaw.ai>
This commit is contained in:
Alireza Rezvani
2026-03-05 13:50:05 +01:00
committed by GitHub
parent b9a60ed506
commit d33d03da50
98 changed files with 11375 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
{
"name": "pw",
"description": "Production-grade Playwright testing toolkit. Generate tests from specs, fix flaky failures, migrate from Cypress/Selenium, sync with TestRail, run on BrowserStack. 55+ ready-to-use templates, 3 specialized agents, smart reporting that plugs into your existing workflow.",
"version": "1.0.0",
"author": {
"name": "Reza Rezvani",
"email": "reza.rezvani73@googlemail.com"
},
"homepage": "https://github.com/alirezarezvani/claude-skills/tree/main/engineering-team/playwright-pro",
"repository": {
"type": "git",
"url": "https://github.com/alirezarezvani/claude-skills"
},
"license": "MIT",
"keywords": [
"playwright",
"testing",
"e2e",
"qa",
"browserstack",
"testrail",
"test-automation",
"cross-browser",
"migration",
"cypress",
"selenium"
]
}

View File

@@ -0,0 +1,27 @@
{
"mcpServers": {
"pw-testrail": {
"command": "npx",
"args": [
"tsx",
"${CLAUDE_PLUGIN_ROOT}/integrations/testrail-mcp/src/index.ts"
],
"env": {
"TESTRAIL_URL": "${TESTRAIL_URL}",
"TESTRAIL_USER": "${TESTRAIL_USER}",
"TESTRAIL_API_KEY": "${TESTRAIL_API_KEY}"
}
},
"pw-browserstack": {
"command": "npx",
"args": [
"tsx",
"${CLAUDE_PLUGIN_ROOT}/integrations/browserstack-mcp/src/index.ts"
],
"env": {
"BROWSERSTACK_USERNAME": "${BROWSERSTACK_USERNAME}",
"BROWSERSTACK_ACCESS_KEY": "${BROWSERSTACK_ACCESS_KEY}"
}
}
}
}

View File

@@ -0,0 +1,84 @@
# Playwright Pro — Agent Context
You are working in a project with the Playwright Pro plugin installed. Follow these rules for all test-related work.
## Golden Rules (Non-Negotiable)
1. **`getByRole()` over CSS/XPath** — resilient to markup changes, mirrors how users see the page
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 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
## Locator Priority
Always use the first option that works:
```typescript
page.getByRole('button', { name: 'Submit' }) // 1. Role (default)
page.getByLabel('Email address') // 2. Label (form fields)
page.getByText('Welcome back') // 3. Text (non-interactive)
page.getByPlaceholder('Search...') // 4. Placeholder
page.getByAltText('Company logo') // 5. Alt text (images)
page.getByTitle('Close dialog') // 6. Title attribute
page.getByTestId('checkout-summary') // 7. Test ID (last semantic)
page.locator('.legacy-widget') // 8. CSS (last resort)
```
## How to Use This Plugin
### Generating Tests
When generating tests, always:
1. Use the `Explore` subagent to scan the project structure first
2. Check `playwright.config.ts` for `testDir`, `baseURL`, and project settings
3. Load relevant templates from `templates/` directory
4. Match the project's language (check for `tsconfig.json` → TypeScript, else JavaScript)
5. Place tests in the configured `testDir` (default: `tests/` or `e2e/`)
6. Include a descriptive test name that explains the behavior being verified
### Reviewing Tests
When reviewing, check against:
1. All 10 golden rules above
2. The anti-patterns in `skills/review/anti-patterns.md`
3. Missing edge cases (empty state, error state, loading state)
4. Proper use of fixtures for shared setup
### Fixing Flaky Tests
When fixing flaky tests:
1. Categorize first: timing, isolation, environment, or infrastructure
2. Use `npx playwright test <file> --repeat-each=10` to reproduce
3. Use `--trace=on` for every attempt
4. Apply the targeted fix from `skills/fix/flaky-taxonomy.md`
### Using Built-in Commands
Leverage Claude Code's built-in capabilities:
- **Large migrations**: Use `/batch` for parallel file-by-file conversion
- **Post-generation cleanup**: Use `/simplify` after generating a test suite
- **Debugging sessions**: Use `/debug` alongside `/pw:fix` for trace analysis
- **Code review**: Use `/review` for general code quality, `/pw:review` for Playwright-specific
### Integrations
- **TestRail**: Configured via `TESTRAIL_URL`, `TESTRAIL_USER`, `TESTRAIL_API_KEY` env vars
- **BrowserStack**: Configured via `BROWSERSTACK_USERNAME`, `BROWSERSTACK_ACCESS_KEY` env vars
- Both are optional. The plugin works fully without them.
## File Conventions
- Test files: `*.spec.ts` or `*.spec.js`
- Page objects: `*.page.ts` in a `pages/` directory
- Fixtures: `fixtures.ts` or `fixtures/` directory
- Test data: `test-data/` directory with JSON/factory files

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Reza Rezvani
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,133 @@
# Playwright Pro
> Production-grade Playwright testing toolkit for AI coding agents.
Generate tests, fix flaky failures, migrate from Cypress/Selenium, sync with TestRail, run on BrowserStack — all from your AI agent.
## Install
```bash
# Claude Code plugin
claude plugin install pw@claude-skills
# Or load directly
claude --plugin-dir ./engineering-team/playwright-pro
```
## Commands
| Command | What it does |
|---|---|
| `/pw:init` | Set up Playwright in your project — detects framework, generates config, CI, first test |
| `/pw:generate <spec>` | Generate tests from a user story, URL, or component name |
| `/pw:review` | Review existing tests for anti-patterns and coverage gaps |
| `/pw:fix <test>` | Diagnose and fix a failing or flaky test |
| `/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, create runs |
| `/pw:browserstack` | Run tests on BrowserStack, pull cross-browser reports |
| `/pw:report` | Generate a test report in your preferred format |
## Quick Start
```bash
# In Claude Code:
/pw:init # Set up Playwright
/pw:generate "user can log in" # Generate your first test
# Tests are auto-validated by hooks — no extra steps
```
## What's Inside
### 9 Skills
Slash commands that turn natural language into production-ready Playwright tests. Each skill leverages Claude Code's built-in capabilities (`/batch` for parallel work, `Explore` for codebase analysis, `/debug` for trace inspection).
### 3 Specialized Agents
- **test-architect** — Plans test strategy for complex applications
- **test-debugger** — Diagnoses flaky tests using a systematic taxonomy
- **migration-planner** — Creates file-by-file migration plans from Cypress/Selenium
### 55 Test Templates
Ready-to-use, parametrizable templates covering:
| Category | Count | Examples |
|---|---|---|
| Authentication | 8 | Login, logout, SSO, MFA, password reset, RBAC |
| CRUD | 6 | Create, read, update, delete, bulk ops |
| Checkout | 6 | Cart, payment, coupon, order history |
| Search | 5 | Basic search, filters, sorting, pagination |
| Forms | 6 | Multi-step, validation, file upload |
| Dashboard | 5 | Data loading, charts, export |
| Settings | 4 | Profile, password, notifications |
| Onboarding | 4 | Registration, email verify, welcome tour |
| Notifications | 3 | In-app, toast, notification center |
| API | 5 | REST CRUD, GraphQL, error handling |
| Accessibility | 3 | Keyboard nav, screen reader, contrast |
### 2 MCP Integrations
- **TestRail** — Read test cases, create runs, push pass/fail results
- **BrowserStack** — Trigger cross-browser runs, pull session reports with video/screenshots
### Smart Hooks
- Auto-validates test quality when you write `*.spec.ts` files
- Auto-detects Playwright projects on session start
- Zero configuration required
## Integrations Setup
### TestRail (Optional)
Set environment variables:
```bash
export TESTRAIL_URL="https://your-instance.testrail.io"
export TESTRAIL_USER="your@email.com"
export TESTRAIL_API_KEY="your-api-key"
```
Then use `/pw:testrail` to sync test cases and push results.
### BrowserStack (Optional)
```bash
export BROWSERSTACK_USERNAME="your-username"
export BROWSERSTACK_ACCESS_KEY="your-access-key"
```
Then use `/pw:browserstack` to run tests across browsers.
## Works With
| Agent | How |
|---|---|
| **Claude Code** | Full plugin — slash commands, MCP tools, hooks, agents |
| **Codex CLI** | Copy `CLAUDE.md` to your project root as `AGENTS.md` |
| **OpenClaw** | Use as a skill with `SKILL.md` entry point |
## Built-in Command Integration
Playwright Pro doesn't reinvent what your AI agent already does. It orchestrates built-in capabilities:
- `/pw:generate` uses Claude's `Explore` subagent to understand your codebase before generating tests
- `/pw:migrate` uses `/batch` for parallel file-by-file conversion on large test suites
- `/pw:fix` uses `/debug` for trace analysis alongside Playwright-specific diagnostics
- `/pw:review` extends `/review` with Playwright anti-pattern detection
## Reference
Based on battle-tested patterns from production test suites. Includes curated guidance on:
- Locator strategies and priority hierarchy
- Assertion patterns and auto-retry behavior
- Fixture architecture and composition
- Common pitfalls (top 20, ranked by frequency)
- Flaky test diagnosis taxonomy
## License
MIT

View File

@@ -0,0 +1,121 @@
---
name: migration-planner
description: >-
Analyzes Cypress or Selenium test suites and creates a file-by-file
migration plan. Invoked by /pw:migrate before conversion starts.
allowed-tools:
- Read
- Grep
- Glob
- LS
---
# Migration Planner Agent
You are a test migration specialist. Your job is to analyze an existing Cypress or Selenium test suite and create a detailed, ordered migration plan.
## Planning Protocol
### Step 1: Detect Source Framework
Scan the project:
**Cypress indicators:**
- `cypress/` directory
- `cypress.config.ts` or `cypress.config.js`
- `@cypress` packages in `package.json`
- `.cy.ts` or `.cy.js` test files
**Selenium indicators:**
- `selenium-webdriver` in dependencies
- `webdriver` or `wdio` in dependencies
- Test files importing `selenium-webdriver`
- `chromedriver` or `geckodriver` in dependencies
- Python files importing `selenium`
### Step 2: Inventory All Test Files
List every test file with:
- File path
- Number of tests (count `it()`, `test()`, or test methods)
- Dependencies (custom commands, page objects, fixtures)
- Complexity (simple/medium/complex based on lines and patterns)
```
## Test Inventory
| # | File | Tests | Dependencies | Complexity |
|---|---|---|---|---|
| 1 | cypress/e2e/login.cy.ts | 5 | login command | Simple |
| 2 | cypress/e2e/checkout.cy.ts | 12 | api helpers, fixtures | Complex |
| 3 | cypress/e2e/search.cy.ts | 8 | none | Medium |
```
### Step 3: Map Dependencies
Identify shared resources that need migration:
**Custom commands** (`cypress/support/commands.ts`):
- List each command and what it does
- Map to Playwright equivalent (fixture, helper function, or page object)
**Fixtures** (`cypress/fixtures/`):
- List data files
- Plan: copy to `test-data/` with any format adjustments
**Plugins** (`cypress/plugins/`):
- List plugin functionality
- Map to Playwright config options or fixtures
**Page Objects** (if used):
- List page object files
- Plan: convert API calls (minimal structural change)
**Support files** (`cypress/support/`):
- List setup/teardown logic
- Map to `playwright.config.ts` or `fixtures/`
### Step 4: Determine Migration Order
Order files by dependency graph:
1. **Shared resources first**: custom commands → fixtures, page objects → helpers
2. **Simple tests next**: files with no dependencies, few tests
3. **Complex tests last**: files with many dependencies, custom commands
```
## Migration Order
### Phase 1: Foundation (do first)
1. Convert custom commands → fixtures.ts
2. Copy fixtures → test-data/
3. Convert page objects (API changes only)
### Phase 2: Simple Tests (quick wins)
4. login.cy.ts → auth/login.spec.ts (5 tests, ~15 min)
5. about.cy.ts → static/about.spec.ts (2 tests, ~5 min)
### Phase 3: Complex Tests
6. checkout.cy.ts → checkout/checkout.spec.ts (12 tests, ~45 min)
7. search.cy.ts → search/search.spec.ts (8 tests, ~30 min)
```
### Step 5: Estimate Effort
| Complexity | Time per test | Notes |
|---|---|---|
| Simple | 2-3 min | Direct API mapping |
| Medium | 5-10 min | Needs locator upgrade |
| Complex | 10-20 min | Custom commands, plugins, complex flows |
### Step 6: Identify Risks
Flag tests that may need manual intervention:
- Tests using Cypress-only features (`cy.origin()`, `cy.session()`)
- Tests with complex `cy.intercept()` patterns
- Tests relying on Cypress retry-ability semantics
- Tests using Cypress plugins with no Playwright equivalent
### Step 7: Return Plan
Return the complete migration plan to `/pw:migrate` for execution.

View File

@@ -0,0 +1,105 @@
---
name: test-architect
description: >-
Plans test strategy for complex applications. Invoked by /pw:generate and
/pw:coverage when the app has multiple routes, complex state, or requires
a structured test plan before writing tests.
allowed-tools:
- Read
- Grep
- Glob
- LS
---
# Test Architect Agent
You are a test architecture specialist. Your job is to analyze an application's structure and create a comprehensive test plan before any tests are written.
## Your Responsibilities
1. **Map the application surface**: routes, components, API endpoints, user flows
2. **Identify critical paths**: the flows that, if broken, cause revenue loss or user churn
3. **Design test structure**: folder organization, fixture strategy, data management
4. **Prioritize**: which tests deliver the most confidence per effort
5. **Select patterns**: which template or approach fits each test scenario
## How You Work
You are a read-only agent. You analyze and plan — you do not write test files.
### Step 1: Scan the Codebase
- Read route definitions (Next.js `app/`, React Router, Vue Router, Angular routes)
- Read `package.json` for framework and dependencies
- Check for existing tests and their patterns
- Identify state management (Redux, Zustand, Pinia, etc.)
- Check for API layer (REST, GraphQL, tRPC)
### Step 2: Catalog Testable Surfaces
Create a structured inventory:
```
## Application Surface
### Pages (by priority)
1. /login — Auth entry point [CRITICAL]
2. /dashboard — Main user view [CRITICAL]
3. /settings — User preferences [HIGH]
4. /admin — Admin panel [HIGH]
5. /about — Static page [LOW]
### Interactive Components
1. SearchBar — complex state, debounced API calls
2. DataTable — sorting, filtering, pagination
3. FileUploader — drag-drop, progress, error handling
### API Endpoints
1. POST /api/auth/login — authentication
2. GET /api/users — user list with pagination
3. PUT /api/users/:id — user update
### User Flows (multi-page)
1. Registration → Email Verify → Onboarding → Dashboard
2. Search → Filter → Select → Add to Cart → Checkout → Confirm
```
### Step 3: Design Test Plan
```
## Test Plan
### Folder Structure
e2e/
├── auth/ # Authentication tests
├── dashboard/ # Dashboard tests
├── checkout/ # Checkout flow tests
├── fixtures/ # Shared fixtures
├── pages/ # Page object models
└── test-data/ # Test data files
### Fixture Strategy
- Auth fixture: shared `storageState` for logged-in tests
- API fixture: request context for data seeding
- Data fixture: factory functions for test entities
### Test Distribution
| Area | Tests | Template | Effort |
|---|---|---|---|
| Auth | 8 | auth/* | 1h |
| Dashboard | 6 | dashboard/* | 1h |
| Checkout | 10 | checkout/* | 2h |
| Search | 5 | search/* | 45m |
| Settings | 4 | settings/* | 30m |
| API | 5 | api/* | 45m |
### Priority Order
1. Auth (blocks everything else)
2. Core user flow (the main thing users do)
3. Payment/checkout (revenue-critical)
4. Everything else
```
### Step 4: Return Plan
Return the complete plan to the calling skill. Do not write files.

View File

@@ -0,0 +1,117 @@
---
name: test-debugger
description: >-
Diagnoses flaky or failing Playwright tests using systematic taxonomy.
Invoked by /pw:fix when a test needs deep analysis including running
tests, reading traces, and identifying root causes.
allowed-tools:
- Read
- Grep
- Glob
- LS
- Bash
---
# Test Debugger Agent
You are a Playwright test debugging specialist. Your job is to systematically diagnose why a test fails or behaves flakily, identify the root cause category, and return a specific fix.
## Debugging Protocol
### Step 1: Read the Test
Read the test file and understand:
- What behavior it's testing
- Which pages/URLs it visits
- Which locators it uses
- Which assertions it makes
- Any setup/teardown (fixtures, beforeEach)
### Step 2: Run the Test
Run it multiple ways to classify the failure:
```bash
# Single run — get the error
npx playwright test <file> --grep "<test name>" --reporter=list 2>&1
# Burn-in — expose timing issues
npx playwright test <file> --grep "<test name>" --repeat-each=10 --reporter=list 2>&1
# Isolation check — expose state leaks
npx playwright test <file> --grep "<test name>" --workers=1 --reporter=list 2>&1
# Full suite — expose interaction
npx playwright test --reporter=list 2>&1
```
### Step 3: Capture Trace
```bash
npx playwright test <file> --grep "<test name>" --trace=on --retries=0 2>&1
```
Read the trace output for:
- Network requests that failed or were slow
- Elements that weren't visible when expected
- Navigation timing issues
- Console errors
### Step 4: Classify
| Category | Evidence |
|---|---|
| **Timing/Async** | Fails on `--repeat-each=10`; error mentions timeout or element not found intermittently |
| **Test Isolation** | Passes alone (`--workers=1 --grep`), fails in full suite |
| **Environment** | Passes locally, fails in CI (check viewport, fonts, timezone) |
| **Infrastructure** | Random crash errors, OOM, browser process killed |
### Step 5: Identify Specific Cause
Common root causes per category:
**Timing:**
- Missing `await` on a Playwright call
- `waitForTimeout()` that's too short
- Clicking before element is actionable
- Asserting before data loads
- Animation interference
**Isolation:**
- Global variable shared between tests
- Database not cleaned between tests
- localStorage/cookies leaking
- Test creates data with non-unique identifier
**Environment:**
- Different viewport size in CI
- Font rendering differences affect screenshots
- Timezone affects date assertions
- Network latency in CI is higher
**Infrastructure:**
- Browser runs out of memory with too many workers
- File system race condition
- DNS resolution failure
### Step 6: Return Diagnosis
Return to the calling skill:
```
## Diagnosis
**Category:** Timing/Async
**Root Cause:** Missing await on line 23 — `page.goto('/dashboard')` runs without
waiting, so the assertion on line 24 runs before navigation completes.
**Evidence:** Fails 3/10 times on `--repeat-each=10`. Trace shows assertion firing
before navigation response received.
## Fix
Line 23: Add `await` before `page.goto('/dashboard')`
## Verification
After fix: 10/10 passes on `--repeat-each=10`
```

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Session start hook: detects if the project uses Playwright.
# Outputs context hint for Claude if playwright.config exists.
set -euo pipefail
# Check for Playwright config in current directory or common locations
PW_CONFIG=""
for config in playwright.config.ts playwright.config.js playwright.config.mjs; do
if [[ -f "$config" ]]; then
PW_CONFIG="$config"
break
fi
done
if [[ -z "$PW_CONFIG" ]]; then
exit 0
fi
# Count existing test files
TEST_COUNT=$(find . -name "*.spec.ts" -o -name "*.spec.js" -o -name "*.test.ts" -o -name "*.test.js" 2>/dev/null | grep -v node_modules | wc -l | tr -d ' ')
echo "🎭 Playwright detected ($PW_CONFIG) — $TEST_COUNT test files found. Use /pw: commands for testing workflows."

View File

@@ -0,0 +1,25 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/validate-test.sh"
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/detect-playwright.sh"
}
]
}
]
}
}

View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Post-write hook: validates Playwright test files for common anti-patterns.
# Runs silently — only outputs warnings if issues found.
# Input: JSON on stdin with tool_input.file_path
set -euo pipefail
# Read the file path from stdin JSON
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
print(data.get('tool_input', {}).get('file_path', ''))
except:
print('')
" 2>/dev/null || echo "")
# Only check .spec.ts and .spec.js files
if [[ ! "$FILE_PATH" =~ \.(spec|test)\.(ts|js|mjs)$ ]]; then
exit 0
fi
# Check if file exists
if [[ ! -f "$FILE_PATH" ]]; then
exit 0
fi
WARNINGS=""
# Check for waitForTimeout
if grep -n 'waitForTimeout' "$FILE_PATH" >/dev/null 2>&1; then
LINES=$(grep -n 'waitForTimeout' "$FILE_PATH" | head -3)
WARNINGS="${WARNINGS}\n⚠ waitForTimeout() found — use web-first assertions instead:\n${LINES}\n"
fi
# Check for non-web-first assertions
if grep -n 'expect(await ' "$FILE_PATH" >/dev/null 2>&1; then
LINES=$(grep -n 'expect(await ' "$FILE_PATH" | head -3)
WARNINGS="${WARNINGS}\n⚠ Non-web-first assertion — use expect(locator) instead:\n${LINES}\n"
fi
# Check for hardcoded localhost URLs
if grep -n "http://localhost\|https://localhost\|http://127.0.0.1" "$FILE_PATH" >/dev/null 2>&1; then
LINES=$(grep -n "http://localhost\|https://localhost\|http://127.0.0.1" "$FILE_PATH" | head -3)
WARNINGS="${WARNINGS}\n⚠ Hardcoded URL — use baseURL from config:\n${LINES}\n"
fi
# Check for page.$() usage
if grep -n 'page\.\$(' "$FILE_PATH" >/dev/null 2>&1; then
LINES=$(grep -n 'page\.\$(' "$FILE_PATH" | head -3)
WARNINGS="${WARNINGS}\n⚠ page.\$() is deprecated — use page.locator() or getByRole():\n${LINES}\n"
fi
# Output warnings if any found
if [[ -n "$WARNINGS" ]]; then
echo -e "\n🎭 Playwright Pro — Test Validation${WARNINGS}"
fi

View File

@@ -0,0 +1,18 @@
{
"name": "@pw/browserstack-mcp",
"version": "1.0.0",
"description": "MCP server for BrowserStack integration with Playwright Pro",
"type": "module",
"main": "src/index.ts",
"scripts": {
"start": "tsx src/index.ts",
"build": "tsc"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0"
},
"devDependencies": {
"tsx": "^4.0.0",
"typescript": "^5.0.0"
}
}

View File

@@ -0,0 +1,97 @@
import type {
BrowserStackConfig,
BrowserStackPlan,
BrowserStackBrowser,
BrowserStackBuild,
BrowserStackSession,
BrowserStackSessionUpdate,
} from './types.js';
export class BrowserStackClient {
private readonly baseUrl = 'https://api.browserstack.com';
private readonly headers: Record<string, string>;
constructor(config: BrowserStackConfig) {
const auth = Buffer.from(`${config.username}:${config.accessKey}`).toString('base64');
this.headers = {
Authorization: `Basic ${auth}`,
'Content-Type': 'application/json',
};
}
private async request<T>(
method: string,
endpoint: string,
body?: unknown,
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const options: RequestInit = {
method,
headers: this.headers,
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(url, options);
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`BrowserStack API error ${response.status}: ${errorText}`,
);
}
return response.json() as Promise<T>;
}
async getPlan(): Promise<BrowserStackPlan> {
return this.request<BrowserStackPlan>('GET', '/automate/plan.json');
}
async getBrowsers(): Promise<BrowserStackBrowser[]> {
return this.request<BrowserStackBrowser[]>('GET', '/automate/browsers.json');
}
async getBuilds(limit?: number, status?: string): Promise<BrowserStackBuild[]> {
let endpoint = '/automate/builds.json';
const params: string[] = [];
if (limit) params.push(`limit=${limit}`);
if (status) params.push(`status=${status}`);
if (params.length > 0) endpoint += `?${params.join('&')}`;
return this.request<BrowserStackBuild[]>('GET', endpoint);
}
async getSessions(buildId: string, limit?: number): Promise<BrowserStackSession[]> {
let endpoint = `/automate/builds/${buildId}/sessions.json`;
if (limit) endpoint += `?limit=${limit}`;
return this.request<BrowserStackSession[]>('GET', endpoint);
}
async getSession(sessionId: string): Promise<BrowserStackSession> {
return this.request<BrowserStackSession>(
'GET',
`/automate/sessions/${sessionId}.json`,
);
}
async updateSession(
sessionId: string,
update: BrowserStackSessionUpdate,
): Promise<BrowserStackSession> {
return this.request<BrowserStackSession>(
'PUT',
`/automate/sessions/${sessionId}.json`,
update,
);
}
async getSessionLogs(sessionId: string): Promise<string> {
const url = `${this.baseUrl}/automate/sessions/${sessionId}/logs`;
const response = await fetch(url, { headers: this.headers });
if (!response.ok) {
throw new Error(`BrowserStack logs error ${response.status}`);
}
return response.text();
}
}

View File

@@ -0,0 +1,183 @@
#!/usr/bin/env npx tsx
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { BrowserStackClient } from './client.js';
import type { BrowserStackSessionUpdate } from './types.js';
const config = {
username: process.env.BROWSERSTACK_USERNAME ?? '',
accessKey: process.env.BROWSERSTACK_ACCESS_KEY ?? '',
};
if (!config.username || !config.accessKey) {
console.error(
'Missing BrowserStack configuration. Set BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY.',
);
process.exit(1);
}
const client = new BrowserStackClient(config);
const server = new Server(
{ name: 'pw-browserstack', version: '1.0.0' },
{ capabilities: { tools: {} } },
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'browserstack_get_plan',
description: 'Get BrowserStack Automate plan details including parallel session limits',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'browserstack_get_browsers',
description: 'List all available browser and OS combinations for Playwright testing',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'browserstack_get_builds',
description: 'List recent test builds with status',
inputSchema: {
type: 'object',
properties: {
limit: { type: 'number', description: 'Max builds to return (default 10)' },
status: {
type: 'string',
enum: ['running', 'done', 'failed', 'timeout'],
description: 'Filter by status',
},
},
},
},
{
name: 'browserstack_get_sessions',
description: 'List test sessions within a build',
inputSchema: {
type: 'object',
properties: {
build_id: { type: 'string', description: 'Build hashed ID' },
limit: { type: 'number', description: 'Max sessions to return' },
},
required: ['build_id'],
},
},
{
name: 'browserstack_get_session',
description: 'Get detailed session info including video URL, logs, and screenshots',
inputSchema: {
type: 'object',
properties: {
session_id: { type: 'string', description: 'Session hashed ID' },
},
required: ['session_id'],
},
},
{
name: 'browserstack_update_session',
description: 'Update session status (mark as passed/failed) and name',
inputSchema: {
type: 'object',
properties: {
session_id: { type: 'string', description: 'Session hashed ID' },
status: {
type: 'string',
enum: ['passed', 'failed'],
description: 'Test result status',
},
name: { type: 'string', description: 'Updated session name' },
reason: { type: 'string', description: 'Reason for failure' },
},
required: ['session_id'],
},
},
{
name: 'browserstack_get_logs',
description: 'Get text logs for a specific test session',
inputSchema: {
type: 'object',
properties: {
session_id: { type: 'string', description: 'Session hashed ID' },
},
required: ['session_id'],
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case 'browserstack_get_plan': {
const plan = await client.getPlan();
return { content: [{ type: 'text', text: JSON.stringify(plan, null, 2) }] };
}
case 'browserstack_get_browsers': {
const browsers = await client.getBrowsers();
const playwrightBrowsers = browsers.filter(
(b) =>
['chrome', 'firefox', 'playwright-chromium', 'playwright-firefox', 'playwright-webkit'].includes(
b.browser?.toLowerCase() ?? '',
) || b.browser?.toLowerCase().includes('playwright'),
);
const summary = playwrightBrowsers.length > 0 ? playwrightBrowsers : browsers.slice(0, 50);
return { content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }] };
}
case 'browserstack_get_builds': {
const builds = await client.getBuilds(
(args?.limit as number) ?? 10,
args?.status as string | undefined,
);
return { content: [{ type: 'text', text: JSON.stringify(builds, null, 2) }] };
}
case 'browserstack_get_sessions': {
const sessions = await client.getSessions(
args!.build_id as string,
args?.limit as number | undefined,
);
return { content: [{ type: 'text', text: JSON.stringify(sessions, null, 2) }] };
}
case 'browserstack_get_session': {
const session = await client.getSession(args!.session_id as string);
return { content: [{ type: 'text', text: JSON.stringify(session, null, 2) }] };
}
case 'browserstack_update_session': {
const update: BrowserStackSessionUpdate = {};
if (args?.status) update.status = args.status as 'passed' | 'failed';
if (args?.name) update.name = args.name as string;
if (args?.reason) update.reason = args.reason as string;
const updated = await client.updateSession(args!.session_id as string, update);
return { content: [{ type: 'text', text: JSON.stringify(updated, null, 2) }] };
}
case 'browserstack_get_logs': {
const logs = await client.getSessionLogs(args!.session_id as string);
return { content: [{ type: 'text', text: logs }] };
}
default:
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
}
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch(console.error);

View File

@@ -0,0 +1,61 @@
export interface BrowserStackConfig {
username: string;
accessKey: string;
}
export interface BrowserStackPlan {
automate_plan: string;
parallel_sessions_running: number;
team_parallel_sessions_max_allowed: number;
parallel_sessions_max_allowed: number;
queued_sessions: number;
queued_sessions_max_allowed: number;
}
export interface BrowserStackBrowser {
os: string;
os_version: string;
browser: string;
browser_version: string;
device: string | null;
real_mobile: boolean | null;
}
export interface BrowserStackBuild {
automation_build: {
name: string;
hashed_id: string;
duration: number;
status: string;
build_tag: string | null;
};
}
export interface BrowserStackSession {
automation_session: {
name: string;
duration: number;
os: string;
os_version: string;
browser_version: string;
browser: string;
device: string | null;
status: string;
hashed_id: string;
reason: string;
build_name: string;
project_name: string;
logs: string;
browser_url: string;
public_url: string;
video_url: string;
browser_console_logs_url: string;
har_logs_url: string;
};
}
export interface BrowserStackSessionUpdate {
name?: string;
status?: 'passed' | 'failed';
reason?: string;
}

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}

View File

@@ -0,0 +1,18 @@
{
"name": "@pw/testrail-mcp",
"version": "1.0.0",
"description": "MCP server for TestRail integration with Playwright Pro",
"type": "module",
"main": "src/index.ts",
"scripts": {
"start": "tsx src/index.ts",
"build": "tsc"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0"
},
"devDependencies": {
"tsx": "^4.0.0",
"typescript": "^5.0.0"
}
}

View File

@@ -0,0 +1,147 @@
import type {
TestRailConfig,
TestRailProject,
TestRailSuite,
TestRailCase,
TestRailCasePayload,
TestRailRun,
TestRailRunPayload,
TestRailResult,
TestRailResultPayload,
} from './types.js';
export class TestRailClient {
private readonly baseUrl: string;
private readonly headers: Record<string, string>;
constructor(config: TestRailConfig) {
this.baseUrl = config.url.replace(/\/+$/, '');
const auth = Buffer.from(`${config.user}:${config.apiKey}`).toString('base64');
this.headers = {
Authorization: `Basic ${auth}`,
'Content-Type': 'application/json',
};
}
private async request<T>(
method: string,
endpoint: string,
body?: unknown,
): Promise<T> {
const url = `${this.baseUrl}/index.php?/api/v2/${endpoint}`;
const options: RequestInit = {
method,
headers: this.headers,
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(url, options);
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`TestRail API error ${response.status}: ${errorText}`,
);
}
return response.json() as Promise<T>;
}
async getProjects(): Promise<TestRailProject[]> {
const result = await this.request<{ projects: TestRailProject[] }>(
'GET',
'get_projects',
);
return result.projects ?? result as unknown as TestRailProject[];
}
async getSuites(projectId: number): Promise<TestRailSuite[]> {
return this.request<TestRailSuite[]>('GET', `get_suites/${projectId}`);
}
async getCases(
projectId: number,
suiteId?: number,
sectionId?: number,
limit?: number,
offset?: number,
filter?: string,
): Promise<TestRailCase[]> {
let endpoint = `get_cases/${projectId}`;
const params: string[] = [];
if (suiteId) params.push(`suite_id=${suiteId}`);
if (sectionId) params.push(`section_id=${sectionId}`);
if (limit) params.push(`limit=${limit}`);
if (offset) params.push(`offset=${offset}`);
if (filter) params.push(`filter=${encodeURIComponent(filter)}`);
if (params.length > 0) endpoint += `&${params.join('&')}`;
const result = await this.request<{ cases: TestRailCase[] }>(
'GET',
endpoint,
);
return result.cases ?? result as unknown as TestRailCase[];
}
async addCase(
sectionId: number,
payload: TestRailCasePayload,
): Promise<TestRailCase> {
return this.request<TestRailCase>(
'POST',
`add_case/${sectionId}`,
payload,
);
}
async updateCase(
caseId: number,
payload: Partial<TestRailCasePayload>,
): Promise<TestRailCase> {
return this.request<TestRailCase>(
'POST',
`update_case/${caseId}`,
payload,
);
}
async addRun(
projectId: number,
payload: TestRailRunPayload,
): Promise<TestRailRun> {
return this.request<TestRailRun>(
'POST',
`add_run/${projectId}`,
payload,
);
}
async addResultForCase(
runId: number,
caseId: number,
payload: TestRailResultPayload,
): Promise<TestRailResult> {
return this.request<TestRailResult>(
'POST',
`add_result_for_case/${runId}/${caseId}`,
payload,
);
}
async getResultsForCase(
runId: number,
caseId: number,
limit?: number,
): Promise<TestRailResult[]> {
let endpoint = `get_results_for_case/${runId}/${caseId}`;
if (limit) endpoint += `&limit=${limit}`;
const result = await this.request<{ results: TestRailResult[] }>(
'GET',
endpoint,
);
return result.results ?? result as unknown as TestRailResult[];
}
}

View File

@@ -0,0 +1,270 @@
#!/usr/bin/env npx tsx
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { TestRailClient } from './client.js';
import type { TestRailCasePayload, TestRailRunPayload, TestRailResultPayload } from './types.js';
const config = {
url: process.env.TESTRAIL_URL ?? '',
user: process.env.TESTRAIL_USER ?? '',
apiKey: process.env.TESTRAIL_API_KEY ?? '',
};
if (!config.url || !config.user || !config.apiKey) {
console.error(
'Missing TestRail configuration. Set TESTRAIL_URL, TESTRAIL_USER, and TESTRAIL_API_KEY.',
);
process.exit(1);
}
const client = new TestRailClient(config);
const server = new Server(
{ name: 'pw-testrail', version: '1.0.0' },
{ capabilities: { tools: {} } },
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'testrail_get_projects',
description: 'List all TestRail projects',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'testrail_get_suites',
description: 'List test suites in a project',
inputSchema: {
type: 'object',
properties: {
project_id: { type: 'number', description: 'Project ID' },
},
required: ['project_id'],
},
},
{
name: 'testrail_get_cases',
description: 'Get test cases from a project. Supports filtering by suite, section, and search text.',
inputSchema: {
type: 'object',
properties: {
project_id: { type: 'number', description: 'Project ID' },
suite_id: { type: 'number', description: 'Suite ID (optional)' },
section_id: { type: 'number', description: 'Section ID (optional)' },
limit: { type: 'number', description: 'Max results (default 250)' },
offset: { type: 'number', description: 'Offset for pagination' },
filter: { type: 'string', description: 'Search text filter' },
},
required: ['project_id'],
},
},
{
name: 'testrail_add_case',
description: 'Create a new test case in a section',
inputSchema: {
type: 'object',
properties: {
section_id: { type: 'number', description: 'Section ID to add the case to' },
title: { type: 'string', description: 'Test case title' },
template_id: { type: 'number', description: 'Template ID (2 = Test Case Steps)' },
priority_id: { type: 'number', description: 'Priority (1=Low, 2=Medium, 3=High, 4=Critical)' },
custom_preconds: { type: 'string', description: 'Preconditions text' },
custom_steps_separated: {
type: 'array',
items: {
type: 'object',
properties: {
content: { type: 'string', description: 'Step action' },
expected: { type: 'string', description: 'Expected result' },
},
},
description: 'Test steps with expected results',
},
},
required: ['section_id', 'title'],
},
},
{
name: 'testrail_update_case',
description: 'Update an existing test case',
inputSchema: {
type: 'object',
properties: {
case_id: { type: 'number', description: 'Case ID to update' },
title: { type: 'string', description: 'Updated title' },
custom_preconds: { type: 'string', description: 'Updated preconditions' },
custom_steps_separated: {
type: 'array',
items: {
type: 'object',
properties: {
content: { type: 'string' },
expected: { type: 'string' },
},
},
description: 'Updated test steps',
},
},
required: ['case_id'],
},
},
{
name: 'testrail_add_run',
description: 'Create a new test run in a project',
inputSchema: {
type: 'object',
properties: {
project_id: { type: 'number', description: 'Project ID' },
name: { type: 'string', description: 'Run name' },
description: { type: 'string', description: 'Run description' },
suite_id: { type: 'number', description: 'Suite ID' },
include_all: { type: 'boolean', description: 'Include all cases (default true)' },
case_ids: {
type: 'array',
items: { type: 'number' },
description: 'Specific case IDs to include (if include_all is false)',
},
},
required: ['project_id', 'name'],
},
},
{
name: 'testrail_add_result',
description: 'Add a test result for a specific case in a run',
inputSchema: {
type: 'object',
properties: {
run_id: { type: 'number', description: 'Run ID' },
case_id: { type: 'number', description: 'Case ID' },
status_id: {
type: 'number',
description: 'Status: 1=Passed, 2=Blocked, 3=Untested, 4=Retest, 5=Failed',
},
comment: { type: 'string', description: 'Result comment or error message' },
elapsed: { type: 'string', description: 'Time spent (e.g., "30s", "1m 45s")' },
defects: { type: 'string', description: 'Defect IDs (comma-separated)' },
},
required: ['run_id', 'case_id', 'status_id'],
},
},
{
name: 'testrail_get_results',
description: 'Get historical results for a test case in a run',
inputSchema: {
type: 'object',
properties: {
run_id: { type: 'number', description: 'Run ID' },
case_id: { type: 'number', description: 'Case ID' },
limit: { type: 'number', description: 'Max results to return' },
},
required: ['run_id', 'case_id'],
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case 'testrail_get_projects': {
const projects = await client.getProjects();
return { content: [{ type: 'text', text: JSON.stringify(projects, null, 2) }] };
}
case 'testrail_get_suites': {
const suites = await client.getSuites(args!.project_id as number);
return { content: [{ type: 'text', text: JSON.stringify(suites, null, 2) }] };
}
case 'testrail_get_cases': {
const cases = await client.getCases(
args!.project_id as number,
args?.suite_id as number | undefined,
args?.section_id as number | undefined,
args?.limit as number | undefined,
args?.offset as number | undefined,
args?.filter as string | undefined,
);
return { content: [{ type: 'text', text: JSON.stringify(cases, null, 2) }] };
}
case 'testrail_add_case': {
const payload: TestRailCasePayload = {
title: args!.title as string,
template_id: args?.template_id as number | undefined,
priority_id: args?.priority_id as number | undefined,
custom_preconds: args?.custom_preconds as string | undefined,
custom_steps_separated: args?.custom_steps_separated as TestRailCasePayload['custom_steps_separated'],
};
const newCase = await client.addCase(args!.section_id as number, payload);
return { content: [{ type: 'text', text: JSON.stringify(newCase, null, 2) }] };
}
case 'testrail_update_case': {
const updatePayload: Partial<TestRailCasePayload> = {};
if (args?.title) updatePayload.title = args.title as string;
if (args?.custom_preconds) updatePayload.custom_preconds = args.custom_preconds as string;
if (args?.custom_steps_separated) {
updatePayload.custom_steps_separated = args.custom_steps_separated as TestRailCasePayload['custom_steps_separated'];
}
const updated = await client.updateCase(args!.case_id as number, updatePayload);
return { content: [{ type: 'text', text: JSON.stringify(updated, null, 2) }] };
}
case 'testrail_add_run': {
const runPayload: TestRailRunPayload = {
name: args!.name as string,
description: args?.description as string | undefined,
suite_id: args?.suite_id as number | undefined,
include_all: (args?.include_all as boolean) ?? true,
case_ids: args?.case_ids as number[] | undefined,
};
const run = await client.addRun(args!.project_id as number, runPayload);
return { content: [{ type: 'text', text: JSON.stringify(run, null, 2) }] };
}
case 'testrail_add_result': {
const resultPayload: TestRailResultPayload = {
status_id: args!.status_id as number,
comment: args?.comment as string | undefined,
elapsed: args?.elapsed as string | undefined,
defects: args?.defects as string | undefined,
};
const result = await client.addResultForCase(
args!.run_id as number,
args!.case_id as number,
resultPayload,
);
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
}
case 'testrail_get_results': {
const results = await client.getResultsForCase(
args!.run_id as number,
args!.case_id as number,
args?.limit as number | undefined,
);
return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
}
default:
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
}
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch(console.error);

View File

@@ -0,0 +1,105 @@
export interface TestRailConfig {
url: string;
user: string;
apiKey: string;
}
export interface TestRailProject {
id: number;
name: string;
announcement: string;
is_completed: boolean;
suite_mode: number;
url: string;
}
export interface TestRailSuite {
id: number;
name: string;
description: string | null;
project_id: number;
url: string;
}
export interface TestRailSection {
id: number;
suite_id: number;
name: string;
description: string | null;
parent_id: number | null;
depth: number;
}
export interface TestRailCaseStep {
content: string;
expected: string;
}
export interface TestRailCase {
id: number;
title: string;
section_id: number;
template_id: number;
type_id: number;
priority_id: number;
estimate: string | null;
refs: string | null;
custom_preconds: string | null;
custom_steps_separated: TestRailCaseStep[] | null;
custom_steps: string | null;
custom_expected: string | null;
}
export interface TestRailRun {
id: number;
suite_id: number;
name: string;
description: string | null;
assignedto_id: number | null;
include_all: boolean;
is_completed: boolean;
passed_count: number;
failed_count: number;
untested_count: number;
url: string;
}
export interface TestRailResult {
id: number;
test_id: number;
status_id: number;
comment: string | null;
created_on: number;
elapsed: string | null;
defects: string | null;
}
export interface TestRailResultPayload {
status_id: number;
comment?: string;
elapsed?: string;
defects?: string;
}
export interface TestRailRunPayload {
suite_id?: number;
name: string;
description?: string;
assignedto_id?: number;
include_all?: boolean;
case_ids?: number[];
refs?: string;
}
export interface TestRailCasePayload {
title: string;
template_id?: number;
type_id?: number;
priority_id?: number;
estimate?: string;
refs?: string;
custom_preconds?: string;
custom_steps_separated?: TestRailCaseStep[];
custom_steps?: string;
custom_expected?: string;
}

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}

View File

@@ -0,0 +1,89 @@
# Assertions Reference
## Web-First Assertions (Always Use These)
Auto-retry until timeout. Safe for dynamic content.
```typescript
// 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
```typescript
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)
```typescript
// 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
```typescript
// 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):
```typescript
await expect.soft(locator).toHaveText('Expected');
await expect.soft(page).toHaveURL('/next');
// Test continues even if above fail
```

View File

@@ -0,0 +1,137 @@
# Common Pitfalls (Top 10)
## 1. waitForTimeout
**Symptom:** Slow, flaky tests.
```typescript
// BAD
await page.waitForTimeout(3000);
// GOOD
await expect(page.getByTestId('result')).toBeVisible();
```
## 2. Non-Web-First Assertions
**Symptom:** Assertions fail on dynamic content.
```typescript
// 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.
```typescript
// 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.
```typescript
// 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.
```typescript
// 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.
```typescript
// 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.
```typescript
// 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.
```typescript
// 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.
```typescript
// 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.
```typescript
// 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();
});
```

View File

@@ -0,0 +1,121 @@
# 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
```typescript
// 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
```typescript
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:
```typescript
// 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' });
});
```
```typescript
// 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 |

View File

@@ -0,0 +1,56 @@
# Flaky Test Quick Reference
## Diagnosis Commands
```bash
# 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=5
```
## Four 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:**
```typescript
// 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:**
```typescript
const uniqueEmail = `test-${Date.now()}@example.com`;
```
**Environment → Explicit viewport:**
```typescript
test.use({ viewport: { width: 1280, height: 720 } });
```
**Infrastructure → CI-safe config:**
```typescript
export default defineConfig({
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
timeout: process.env.CI ? 60_000 : 30_000,
});
```

View File

@@ -0,0 +1,12 @@
# 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

View File

@@ -0,0 +1,77 @@
# 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
```typescript
// 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
```typescript
// 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()
```

View File

@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(npx playwright*)",
"Bash(npx tsx*)"
]
}
}

View File

@@ -0,0 +1,168 @@
---
name: browserstack
description: >-
Run tests on BrowserStack. Use when user mentions "browserstack",
"cross-browser", "cloud testing", "browser matrix", "test on safari",
"test on firefox", or "browser compatibility".
---
# BrowserStack Integration
Run Playwright tests on BrowserStack's cloud grid for cross-browser and cross-device testing.
## Prerequisites
Environment variables must be set:
- `BROWSERSTACK_USERNAME` — your BrowserStack username
- `BROWSERSTACK_ACCESS_KEY` — your access key
If not set, inform the user how to get them from [browserstack.com/accounts/settings](https://www.browserstack.com/accounts/settings) and stop.
## Capabilities
### 1. Configure for BrowserStack
```
/pw:browserstack setup
```
Steps:
1. Check current `playwright.config.ts`
2. Add BrowserStack connect options:
```typescript
// Add to playwright.config.ts
import { defineConfig } from '@playwright/test';
const isBS = !!process.env.BROWSERSTACK_USERNAME;
export default defineConfig({
// ... existing config
projects: isBS ? [
{
name: 'chrome@latest:Windows 11',
use: {
connectOptions: {
wsEndpoint: `wss://cdp.browserstack.com/playwright?caps=${encodeURIComponent(JSON.stringify({
'browser': 'chrome',
'browser_version': 'latest',
'os': 'Windows',
'os_version': '11',
'browserstack.username': process.env.BROWSERSTACK_USERNAME,
'browserstack.accessKey': process.env.BROWSERSTACK_ACCESS_KEY,
}))}`,
},
},
},
{
name: 'firefox@latest:Windows 11',
use: {
connectOptions: {
wsEndpoint: `wss://cdp.browserstack.com/playwright?caps=${encodeURIComponent(JSON.stringify({
'browser': 'playwright-firefox',
'browser_version': 'latest',
'os': 'Windows',
'os_version': '11',
'browserstack.username': process.env.BROWSERSTACK_USERNAME,
'browserstack.accessKey': process.env.BROWSERSTACK_ACCESS_KEY,
}))}`,
},
},
},
{
name: 'webkit@latest:OS X Ventura',
use: {
connectOptions: {
wsEndpoint: `wss://cdp.browserstack.com/playwright?caps=${encodeURIComponent(JSON.stringify({
'browser': 'playwright-webkit',
'browser_version': 'latest',
'os': 'OS X',
'os_version': 'Ventura',
'browserstack.username': process.env.BROWSERSTACK_USERNAME,
'browserstack.accessKey': process.env.BROWSERSTACK_ACCESS_KEY,
}))}`,
},
},
},
] : [
// ... local projects fallback
],
});
```
3. Add npm script: `"test:e2e:cloud": "npx playwright test --project='chrome@*' --project='firefox@*' --project='webkit@*'"`
### 2. Run Tests on BrowserStack
```
/pw:browserstack run
```
Steps:
1. Verify credentials are set
2. Run tests with BrowserStack projects:
```bash
BROWSERSTACK_USERNAME=$BROWSERSTACK_USERNAME \
BROWSERSTACK_ACCESS_KEY=$BROWSERSTACK_ACCESS_KEY \
npx playwright test --project='chrome@*' --project='firefox@*'
```
3. Monitor execution
4. Report results per browser
### 3. Get Build Results
```
/pw:browserstack results
```
Steps:
1. Call `browserstack_get_builds` MCP tool
2. Get latest build's sessions
3. For each session:
- Status (pass/fail)
- Browser and OS
- Duration
- Video URL
- Log URLs
4. Format as summary table
### 4. Check Available Browsers
```
/pw:browserstack browsers
```
Steps:
1. Call `browserstack_get_browsers` MCP tool
2. Filter for Playwright-compatible browsers
3. Display available browser/OS combinations
### 5. Local Testing
```
/pw:browserstack local
```
For testing localhost or staging behind firewall:
1. Install BrowserStack Local: `npm install -D browserstack-local`
2. Add local tunnel to config
3. Provide setup instructions
## MCP Tools Used
| Tool | When |
|---|---|
| `browserstack_get_plan` | Check account limits |
| `browserstack_get_browsers` | List available browsers |
| `browserstack_get_builds` | List recent builds |
| `browserstack_get_sessions` | Get sessions in a build |
| `browserstack_get_session` | Get session details (video, logs) |
| `browserstack_update_session` | Mark pass/fail |
| `browserstack_get_logs` | Get text/network logs |
## Output
- Cross-browser test results table
- Per-browser pass/fail status
- Links to BrowserStack dashboard for video/screenshots
- Any browser-specific failures highlighted

View File

@@ -0,0 +1,98 @@
---
name: coverage
description: >-
Analyze test coverage gaps. Use when user says "test coverage",
"what's not tested", "coverage gaps", "missing tests", "coverage report",
or "what needs testing".
---
# Analyze Test Coverage Gaps
Map all testable surfaces in the application and identify what's tested vs. what's missing.
## Steps
### 1. Map Application Surface
Use the `Explore` subagent to catalog:
**Routes/Pages:**
- Scan route definitions (Next.js `app/`, React Router config, Vue Router, etc.)
- List all user-facing pages with their paths
**Components:**
- Identify interactive components (forms, modals, dropdowns, tables)
- Note components with complex state logic
**API Endpoints:**
- Scan API route files or backend controllers
- List all endpoints with their methods
**User Flows:**
- Identify critical paths: auth, checkout, onboarding, core features
- Map multi-step workflows
### 2. Map Existing Tests
Scan all `*.spec.ts` / `*.spec.js` files:
- Extract which pages/routes are covered (by `page.goto()` calls)
- Extract which components are tested (by locator usage)
- Extract which API endpoints are mocked or hit
- Count tests per area
### 3. Generate Coverage Matrix
```
## Coverage Matrix
| Area | Route | Tests | Status |
|---|---|---|---|
| Auth | /login | 5 | ✅ Covered |
| Auth | /register | 0 | ❌ Missing |
| Auth | /forgot-password | 0 | ❌ Missing |
| Dashboard | /dashboard | 3 | ⚠️ Partial (no error states) |
| Settings | /settings | 0 | ❌ Missing |
| Checkout | /checkout | 8 | ✅ Covered |
```
### 4. Prioritize Gaps
Rank uncovered areas by business impact:
1. **Critical** — auth, payment, core features → test first
2. **High** — user-facing CRUD, search, navigation
3. **Medium** — settings, preferences, edge cases
4. **Low** — static pages, about, terms
### 5. Suggest Test Plan
For each gap, recommend:
- Number of tests needed
- Which template from `templates/` to use
- Estimated effort (quick/medium/complex)
```
## Recommended Test Plan
### Priority 1: Critical
1. /register (4 tests) — use auth/registration template — quick
2. /forgot-password (3 tests) — use auth/password-reset template — quick
### Priority 2: High
3. /settings (4 tests) — use settings/ templates — medium
4. Dashboard error states (2 tests) — use dashboard/data-loading template — quick
```
### 6. Auto-Generate (Optional)
Ask user: "Generate tests for the top N gaps? [Yes/No/Pick specific]"
If yes, invoke `/pw:generate` for each gap with the recommended template.
## Output
- Coverage matrix (table format)
- Coverage percentage estimate
- Prioritized gap list with effort estimates
- Option to auto-generate missing tests

View File

@@ -0,0 +1,113 @@
---
name: fix
description: >-
Fix failing or flaky Playwright tests. Use when user says "fix test",
"flaky test", "test failing", "debug test", "test broken", "test passes
sometimes", or "intermittent failure".
---
# Fix Failing or Flaky Tests
Diagnose and fix a Playwright test that fails or passes intermittently using a systematic taxonomy.
## Input
`$ARGUMENTS` contains:
- A test file path: `e2e/login.spec.ts`
- A test name: `"should redirect after login"`
- A description: `"the checkout test fails in CI but passes locally"`
## Steps
### 1. Reproduce the Failure
Run the test to capture the error:
```bash
npx playwright test <file> --reporter=list
```
If the test passes, it's likely flaky. Run burn-in:
```bash
npx playwright test <file> --repeat-each=10 --reporter=list
```
If it still passes, try with parallel workers:
```bash
npx playwright test --fully-parallel --workers=4 --repeat-each=5
```
### 2. Capture Trace
Run with full tracing:
```bash
npx playwright test <file> --trace=on --retries=0
```
Read the trace output. Use `/debug` to analyze trace files if available.
### 3. Categorize the Failure
Load `flaky-taxonomy.md` from this skill directory.
Every failing test falls into one of four categories:
| Category | Symptom | Diagnosis |
|---|---|---|
| **Timing/Async** | Fails intermittently everywhere | `--repeat-each=20` reproduces locally |
| **Test Isolation** | Fails in suite, passes alone | `--workers=1 --grep "test name"` passes |
| **Environment** | Fails in CI, passes locally | Compare CI vs local screenshots/traces |
| **Infrastructure** | Random, no pattern | Error references browser internals |
### 4. Apply Targeted Fix
**Timing/Async:**
- Replace `waitForTimeout()` with web-first assertions
- Add `await` to missing Playwright calls
- Wait for specific network responses before asserting
- Use `toBeVisible()` before interacting with elements
**Test Isolation:**
- Remove shared mutable state between tests
- Create test data per-test via API or fixtures
- Use unique identifiers (timestamps, random strings) for test data
- Check for database state leaks
**Environment:**
- Match viewport sizes between local and CI
- Account for font rendering differences in screenshots
- Use `docker` locally to match CI environment
- Check for timezone-dependent assertions
**Infrastructure:**
- Increase timeout for slow CI runners
- Add retries in CI config (`retries: 2`)
- Check for browser OOM (reduce parallel workers)
- Ensure browser dependencies are installed
### 5. Verify the Fix
Run the test 10 times to confirm stability:
```bash
npx playwright test <file> --repeat-each=10 --reporter=list
```
All 10 must pass. If any fail, go back to step 3.
### 6. Prevent Recurrence
Suggest:
- Add to CI with `retries: 2` if not already
- Enable `trace: 'on-first-retry'` in config
- Add the fix pattern to project's test conventions doc
## Output
- Root cause category and specific issue
- The fix applied (with diff)
- Verification result (10/10 passes)
- Prevention recommendation

View File

@@ -0,0 +1,134 @@
# Flaky Test Taxonomy
## Decision Tree
```
Test is flaky
├── Fails locally with --repeat-each=20?
│ ├── YES → TIMING / ASYNC
│ │ ├── Missing await? → Add await
│ │ ├── waitForTimeout? → Replace with assertion
│ │ ├── Race condition? → Wait for specific event
│ │ └── Animation? → Wait for animation end or disable
│ │
│ └── NO → Continue...
├── Passes alone, fails in suite?
│ ├── YES → TEST ISOLATION
│ │ ├── Shared variable? → Make per-test
│ │ ├── Database state? → Reset per-test
│ │ ├── localStorage? → Clear in beforeEach
│ │ └── Cookie leak? → Use isolated contexts
│ │
│ └── NO → Continue...
├── Fails in CI, passes locally?
│ ├── YES → ENVIRONMENT
│ │ ├── Viewport? → Set explicit size
│ │ ├── Fonts? → Use Docker locally
│ │ ├── Timezone? → Use UTC everywhere
│ │ └── Network? → Mock external services
│ │
│ └── NO → INFRASTRUCTURE
│ ├── Browser crash? → Reduce workers
│ ├── OOM? → Limit parallel tests
│ ├── DNS? → Add retry config
│ └── File system? → Use unique temp dirs
```
## Common Fixes by Category
### Timing / Async
**Missing await:**
```typescript
// BAD — race condition
page.goto('/dashboard');
expect(page.getByText('Welcome')).toBeVisible();
// GOOD
await page.goto('/dashboard');
await expect(page.getByText('Welcome')).toBeVisible();
```
**Clicking before visible:**
```typescript
// BAD — element may not be ready
await page.getByRole('button', { name: 'Submit' }).click();
// GOOD — ensure visible first
const submitBtn = page.getByRole('button', { name: 'Submit' });
await expect(submitBtn).toBeVisible();
await submitBtn.click();
```
**Race with network:**
```typescript
// BAD — data might not be loaded
await page.goto('/users');
await expect(page.getByRole('table')).toBeVisible();
// GOOD — wait for API response
const responsePromise = page.waitForResponse('**/api/users');
await page.goto('/users');
await responsePromise;
await expect(page.getByRole('table')).toBeVisible();
```
### Test Isolation
**Shared state fix:**
```typescript
// BAD — tests share userId
let userId: string;
test('create', async () => { userId = '123'; });
test('read', async () => { /* uses userId */ });
// GOOD — each test is independent
test('read user', async ({ request }) => {
const response = await request.post('/api/users', { data: { name: 'Test' } });
const { id } = await response.json();
// Use id within this test
});
```
**localStorage cleanup:**
```typescript
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.evaluate(() => localStorage.clear());
});
```
### Environment
**Explicit viewport:**
```typescript
test.use({ viewport: { width: 1280, height: 720 } });
```
**Timezone-safe dates:**
```typescript
// BAD
expect(dateText).toBe('March 5, 2026');
// GOOD — timezone independent
expect(dateText).toMatch(/\d{1,2}\/\d{1,2}\/\d{4}/);
```
### Infrastructure
**Retry config:**
```typescript
// playwright.config.ts
export default defineConfig({
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
});
```
**Increase timeout for CI:**
```typescript
test.setTimeout(60_000); // 60s for slow CI
```

View File

@@ -0,0 +1,144 @@
---
name: generate
description: >-
Generate Playwright tests. Use when user says "write tests", "generate tests",
"add tests for", "test this component", "e2e test", "create test for",
"test this page", or "test this feature".
---
# Generate Playwright Tests
Generate production-ready Playwright tests from a user story, URL, component name, or feature description.
## Input
`$ARGUMENTS` contains what to test. Examples:
- `"user can log in with email and password"`
- `"the checkout flow"`
- `"src/components/UserProfile.tsx"`
- `"the search page with filters"`
## Steps
### 1. Understand the Target
Parse `$ARGUMENTS` to determine:
- **User story**: Extract the behavior to verify
- **Component path**: Read the component source code
- **Page/URL**: Identify the route and its elements
- **Feature name**: Map to relevant app areas
### 2. Explore the Codebase
Use the `Explore` subagent to gather context:
- Read `playwright.config.ts` for `testDir`, `baseURL`, `projects`
- Check existing tests in `testDir` for patterns, fixtures, and conventions
- If a component path is given, read the component to understand its props, states, and interactions
- Check for existing page objects in `pages/`
- Check for existing fixtures in `fixtures/`
- Check for auth setup (`auth.setup.ts` or `storageState` config)
### 3. Select Templates
Check `templates/` in this plugin for matching patterns:
| If testing... | Load template from |
|---|---|
| Login/auth flow | `templates/auth/login.md` |
| CRUD operations | `templates/crud/` |
| Checkout/payment | `templates/checkout/` |
| Search/filter UI | `templates/search/` |
| Form submission | `templates/forms/` |
| Dashboard/data | `templates/dashboard/` |
| Settings page | `templates/settings/` |
| Onboarding flow | `templates/onboarding/` |
| API endpoints | `templates/api/` |
| Accessibility | `templates/accessibility/` |
Adapt the template to the specific app — replace `{{placeholders}}` with actual selectors, URLs, and data.
### 4. Generate the Test
Follow these rules:
**Structure:**
```typescript
import { test, expect } from '@playwright/test';
// Import custom fixtures if the project uses them
test.describe('Feature Name', () => {
// Group related behaviors
test('should <expected behavior>', async ({ page }) => {
// Arrange: navigate, set up state
// Act: perform user action
// Assert: verify outcome
});
});
```
**Locator priority** (use the first that works):
1. `getByRole()` — buttons, links, headings, form elements
2. `getByLabel()` — form fields with labels
3. `getByText()` — non-interactive text content
4. `getByPlaceholder()` — inputs with placeholder text
5. `getByTestId()` — when semantic options aren't available
**Assertions** — always web-first:
```typescript
// GOOD — auto-retries
await expect(page.getByRole('heading')).toBeVisible();
await expect(page.getByRole('alert')).toHaveText('Success');
// BAD — no retry
const text = await page.textContent('.msg');
expect(text).toBe('Success');
```
**Never use:**
- `page.waitForTimeout()`
- `page.$(selector)` or `page.$$(selector)`
- Bare CSS selectors unless absolutely necessary
- `page.evaluate()` for things locators can do
**Always include:**
- Descriptive test names that explain the behavior
- Error/edge case tests alongside happy path
- Proper `await` on every Playwright call
- `baseURL`-relative navigation (`page.goto('/')` not `page.goto('http://...')`)
### 5. Match Project Conventions
- If project uses TypeScript → generate `.spec.ts`
- If project uses JavaScript → generate `.spec.js` with `require()` imports
- If project has page objects → use them instead of inline locators
- If project has custom fixtures → import and use them
- If project has a test data directory → create test data files there
### 6. Generate Supporting Files (If Needed)
- **Page object**: If the test touches 5+ unique locators on one page, create a page object
- **Fixture**: If the test needs shared setup (auth, data), create or extend a fixture
- **Test data**: If the test uses structured data, create a JSON file in `test-data/`
### 7. Verify
Run the generated test:
```bash
npx playwright test <generated-file> --reporter=list
```
If it fails:
1. Read the error
2. Fix the test (not the app)
3. Run again
4. If it's an app issue, report it to the user
## Output
- Generated test file(s) with path
- Any supporting files created (page objects, fixtures, data)
- Test run result
- Coverage note: what behaviors are now tested

View File

@@ -0,0 +1,163 @@
# Test Generation Patterns
## Pattern: Authentication Flow
```typescript
test.describe('Authentication', () => {
test('should login with valid credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
test('should show error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('wrong@example.com');
await page.getByLabel('Password').fill('wrong');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('alert')).toHaveText(/invalid/i);
await expect(page).toHaveURL('/login');
});
});
```
## Pattern: CRUD Operations
```typescript
test.describe('Items', () => {
test('should create a new item', async ({ page }) => {
await page.goto('/items');
await page.getByRole('button', { name: 'Add item' }).click();
await page.getByLabel('Name').fill('Test Item');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Test Item')).toBeVisible();
});
test('should edit an existing item', async ({ page }) => {
await page.goto('/items');
await page.getByRole('row', { name: /Test Item/ })
.getByRole('button', { name: 'Edit' }).click();
await page.getByLabel('Name').clear();
await page.getByLabel('Name').fill('Updated Item');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Updated Item')).toBeVisible();
});
test('should delete an item with confirmation', async ({ page }) => {
await page.goto('/items');
await page.getByRole('row', { name: /Test Item/ })
.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByText('Test Item')).not.toBeVisible();
});
});
```
## Pattern: Form with Validation
```typescript
test.describe('Contact Form', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/contact');
});
test('should submit valid form', async ({ page }) => {
await page.getByLabel('Name').fill('Jane Doe');
await page.getByLabel('Email').fill('jane@example.com');
await page.getByLabel('Message').fill('Hello, this is a test message.');
await page.getByRole('button', { name: 'Send' }).click();
await expect(page.getByText('Message sent')).toBeVisible();
});
test('should show validation errors for empty required fields', async ({ page }) => {
await page.getByRole('button', { name: 'Send' }).click();
await expect(page.getByText('Name is required')).toBeVisible();
await expect(page.getByText('Email is required')).toBeVisible();
});
test('should validate email format', async ({ page }) => {
await page.getByLabel('Email').fill('not-an-email');
await page.getByRole('button', { name: 'Send' }).click();
await expect(page.getByText('Invalid email')).toBeVisible();
});
});
```
## Pattern: Search and Filter
```typescript
test.describe('Product Search', () => {
test('should return results for valid query', async ({ page }) => {
await page.goto('/products');
await page.getByPlaceholder('Search products').fill('laptop');
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByRole('list')).toBeVisible();
const results = page.getByRole('listitem');
await expect(results).not.toHaveCount(0);
});
test('should show empty state for no results', async ({ page }) => {
await page.goto('/products');
await page.getByPlaceholder('Search products').fill('xyznonexistent');
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByText('No products found')).toBeVisible();
});
test('should filter by category', async ({ page }) => {
await page.goto('/products');
await page.getByRole('combobox', { name: 'Category' }).selectOption('Electronics');
await expect(page.getByRole('listitem')).not.toHaveCount(0);
});
});
```
## Pattern: Navigation and Layout
```typescript
test.describe('Navigation', () => {
test('should navigate between pages', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: 'About' }).click();
await expect(page).toHaveURL('/about');
await expect(page.getByRole('heading', { level: 1 })).toHaveText('About');
});
test('should show mobile menu on small screens', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/');
await expect(page.getByRole('navigation')).not.toBeVisible();
await page.getByRole('button', { name: 'Menu' }).click();
await expect(page.getByRole('navigation')).toBeVisible();
});
});
```
## Pattern: API Mocking
```typescript
test.describe('Dashboard with mocked API', () => {
test('should display data from API', async ({ page }) => {
await page.route('**/api/dashboard', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ revenue: 50000, users: 1200 }),
});
});
await page.goto('/dashboard');
await expect(page.getByText('$50,000')).toBeVisible();
await expect(page.getByText('1,200')).toBeVisible();
});
test('should handle API errors gracefully', async ({ page }) => {
await page.route('**/api/dashboard', (route) => {
route.fulfill({ status: 500 });
});
await page.goto('/dashboard');
await expect(page.getByText(/error|try again/i)).toBeVisible();
});
});
```

View File

@@ -0,0 +1,201 @@
---
name: init
description: >-
Set up Playwright in a project. Use when user says "set up playwright",
"add e2e tests", "configure playwright", "testing setup", "init playwright",
or "add test infrastructure".
---
# Initialize Playwright Project
Set up a production-ready Playwright testing environment. Detect the framework, generate config, folder structure, example test, and CI workflow.
## Steps
### 1. Analyze the Project
Use the `Explore` subagent to scan the project:
- Check `package.json` for framework (React, Next.js, Vue, Angular, Svelte)
- Check for `tsconfig.json` → use TypeScript; otherwise JavaScript
- Check if Playwright is already installed (`@playwright/test` in dependencies)
- Check for existing test directories (`tests/`, `e2e/`, `__tests__/`)
- Check for existing CI config (`.github/workflows/`, `.gitlab-ci.yml`)
### 2. Install Playwright
If not already installed:
```bash
npm init playwright@latest -- --quiet
```
Or if the user prefers manual setup:
```bash
npm install -D @playwright/test
npx playwright install --with-deps chromium
```
### 3. Generate `playwright.config.ts`
Adapt to the detected framework:
**Next.js:**
```typescript
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { open: 'never' }],
['list'],
],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
```
**React (Vite):**
- Change `baseURL` to `http://localhost:5173`
- Change `webServer.command` to `npm run dev`
**Vue/Nuxt:**
- Change `baseURL` to `http://localhost:3000`
- Change `webServer.command` to `npm run dev`
**Angular:**
- Change `baseURL` to `http://localhost:4200`
- Change `webServer.command` to `npm run start`
**No framework detected:**
- Omit `webServer` block
- Set `baseURL` from user input or leave as placeholder
### 4. Create Folder Structure
```
e2e/
├── fixtures/
│ └── index.ts # Custom fixtures
├── pages/
│ └── .gitkeep # Page object models
├── test-data/
│ └── .gitkeep # Test data files
└── example.spec.ts # First example test
```
### 5. Generate Example Test
```typescript
import { test, expect } from '@playwright/test';
test.describe('Homepage', () => {
test('should load successfully', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/.+/);
});
test('should have visible navigation', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('navigation')).toBeVisible();
});
});
```
### 6. Generate CI Workflow
If `.github/workflows/` exists, create `playwright.yml`:
```yaml
name: Playwright Tests
on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30
```
If `.gitlab-ci.yml` exists, add a Playwright stage instead.
### 7. Update `.gitignore`
Append if not already present:
```
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
```
### 8. Add npm Scripts
Add to `package.json` scripts:
```json
{
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug"
}
```
### 9. Verify Setup
Run the example test:
```bash
npx playwright test
```
Report the result. If it fails, diagnose and fix before completing.
## Output
Confirm what was created:
- Config file path and key settings
- Test directory and example test
- CI workflow (if applicable)
- npm scripts added
- How to run: `npx playwright test` or `npm run test:e2e`

View File

@@ -0,0 +1,135 @@
---
name: migrate
description: >-
Migrate from Cypress or Selenium to Playwright. Use when user mentions
"cypress", "selenium", "migrate tests", "convert tests", "switch to
playwright", "move from cypress", or "replace selenium".
---
# Migrate to Playwright
Interactive migration from Cypress or Selenium to Playwright with file-by-file conversion.
## Input
`$ARGUMENTS` can be:
- `"from cypress"` — migrate Cypress test suite
- `"from selenium"` — migrate Selenium/WebDriver tests
- A file path: convert a specific test file
- Empty: auto-detect source framework
## Steps
### 1. Detect Source Framework
Use `Explore` subagent to scan:
- `cypress/` directory or `cypress.config.ts` → Cypress
- `selenium`, `webdriver` in `package.json` deps → Selenium
- `.py` test files with `selenium` imports → Selenium (Python)
### 2. Assess Migration Scope
Count files and categorize:
```
Migration Assessment:
- Total test files: X
- Cypress custom commands: Y
- Cypress fixtures: Z
- Estimated effort: [small|medium|large]
```
| Size | Files | Approach |
|---|---|---|
| Small (1-10) | Convert sequentially | Direct conversion |
| Medium (11-30) | Batch in groups of 5 | Use sub-agents |
| Large (31+) | Use `/batch` | Parallel conversion with `/batch` |
### 3. Set Up Playwright (If Not Present)
Run `/pw:init` first if Playwright isn't configured.
### 4. Convert Files
For each file, apply the appropriate mapping:
#### Cypress → Playwright
Load `cypress-mapping.md` for complete reference.
Key translations:
```
cy.visit(url) → page.goto(url)
cy.get(selector) → page.locator(selector) or page.getByRole(...)
cy.contains(text) → page.getByText(text)
cy.find(selector) → locator.locator(selector)
cy.click() → locator.click()
cy.type(text) → locator.fill(text)
cy.should('be.visible') → expect(locator).toBeVisible()
cy.should('have.text') → expect(locator).toHaveText(text)
cy.intercept() → page.route()
cy.wait('@alias') → page.waitForResponse()
cy.fixture() → JSON import or test data file
```
**Cypress custom commands** → Playwright fixtures or helper functions
**Cypress plugins** → Playwright config or fixtures
**`before`/`beforeEach`** → `test.beforeAll()` / `test.beforeEach()`
#### Selenium → Playwright
Load `selenium-mapping.md` for complete reference.
Key translations:
```
driver.get(url) → page.goto(url)
driver.findElement(By.id('x')) → page.locator('#x') or page.getByTestId('x')
driver.findElement(By.css('.x')) → page.locator('.x') or page.getByRole(...)
element.click() → locator.click()
element.sendKeys(text) → locator.fill(text)
element.getText() → locator.textContent()
WebDriverWait + ExpectedConditions → expect(locator).toBeVisible()
driver.switchTo().frame() → page.frameLocator()
Actions → locator.hover(), locator.dragTo()
```
### 5. Upgrade Locators
During conversion, upgrade selectors to Playwright best practices:
- `#id``getByTestId()` or `getByRole()`
- `.class``getByRole()` or `getByText()`
- `[data-testid]``getByTestId()`
- XPath → role-based locators
### 6. Convert Custom Commands / Utilities
- Cypress custom commands → Playwright custom fixtures via `test.extend()`
- Selenium page objects → Playwright page objects (keep structure, update API)
- Shared helpers → TypeScript utility functions
### 7. Verify Each Converted File
After converting each file:
```bash
npx playwright test <converted-file> --reporter=list
```
Fix any compilation or runtime errors before moving to the next file.
### 8. Clean Up
After all files are converted:
- Remove Cypress/Selenium dependencies from `package.json`
- Remove old config files (`cypress.config.ts`, etc.)
- Update CI workflow to use Playwright
- Update README with new test commands
Ask user before deleting anything.
## Output
- Conversion summary: files converted, total tests migrated
- Any tests that couldn't be auto-converted (manual intervention needed)
- Updated CI config
- Before/after comparison of test run results

View File

@@ -0,0 +1,79 @@
# Cypress → Playwright Mapping
## Commands
| Cypress | Playwright | Notes |
|---|---|---|
| `cy.visit('/page')` | `await page.goto('/page')` | Use `baseURL` in config |
| `cy.get('.selector')` | `page.locator('.selector')` | Prefer `getByRole()` |
| `cy.get('[data-cy=x]')` | `page.getByTestId('x')` | |
| `cy.contains('text')` | `page.getByText('text')` | |
| `cy.find('.child')` | `parent.locator('.child')` | Chain from parent locator |
| `cy.first()` | `locator.first()` | |
| `cy.last()` | `locator.last()` | |
| `cy.eq(n)` | `locator.nth(n)` | |
| `cy.parent()` | `locator.locator('..')` | Or restructure with better locators |
| `cy.children()` | `locator.locator('> *')` | |
| `cy.siblings()` | Not direct — restructure test | Use parent + filter |
## Actions
| Cypress | Playwright | Notes |
|---|---|---|
| `.click()` | `await locator.click()` | Always `await` |
| `.dblclick()` | `await locator.dblclick()` | |
| `.rightclick()` | `await locator.click({ button: 'right' })` | |
| `.type('text')` | `await locator.fill('text')` | `fill()` clears first |
| `.type('text', { delay: 50 })` | `await locator.pressSequentially('text', { delay: 50 })` | Simulates typing |
| `.clear()` | `await locator.clear()` | |
| `.check()` | `await locator.check()` | |
| `.uncheck()` | `await locator.uncheck()` | |
| `.select('value')` | `await locator.selectOption('value')` | |
| `.scrollTo()` | `await locator.scrollIntoViewIfNeeded()` | |
| `.trigger('event')` | `await locator.dispatchEvent('event')` | |
| `.focus()` | `await locator.focus()` | |
| `.blur()` | `await locator.blur()` | |
## Assertions
| Cypress | Playwright | Notes |
|---|---|---|
| `.should('be.visible')` | `await expect(locator).toBeVisible()` | Web-first, auto-retry |
| `.should('not.exist')` | `await expect(locator).not.toBeVisible()` | Or `.toHaveCount(0)` |
| `.should('have.text', 'x')` | `await expect(locator).toHaveText('x')` | |
| `.should('contain', 'x')` | `await expect(locator).toContainText('x')` | |
| `.should('have.value', 'x')` | `await expect(locator).toHaveValue('x')` | |
| `.should('have.attr', 'x', 'y')` | `await expect(locator).toHaveAttribute('x', 'y')` | |
| `.should('have.class', 'x')` | `await expect(locator).toHaveClass(/x/)` | |
| `.should('be.disabled')` | `await expect(locator).toBeDisabled()` | |
| `.should('be.checked')` | `await expect(locator).toBeChecked()` | |
| `.should('have.length', n)` | `await expect(locator).toHaveCount(n)` | |
| `cy.url().should('include', '/x')` | `await expect(page).toHaveURL(/\/x/)` | |
| `cy.title().should('eq', 'x')` | `await expect(page).toHaveTitle('x')` | |
## Network
| Cypress | Playwright |
|---|---|
| `cy.intercept('GET', '/api/*', { body: data })` | `await page.route('**/api/*', route => route.fulfill({ body: JSON.stringify(data) }))` |
| `cy.intercept('POST', '/api/*').as('save')` | `const savePromise = page.waitForResponse('**/api/*')` |
| `cy.wait('@save')` | `await savePromise` |
## Fixtures & Custom Commands
| Cypress | Playwright |
|---|---|
| `cy.fixture('data.json')` | `import data from './test-data/data.json'` |
| `Cypress.Commands.add('login', ...)` | `test.extend({ authenticatedPage: ... })` |
| `beforeEach(() => { ... })` | `test.beforeEach(async ({ page }) => { ... })` |
| `before(() => { ... })` | `test.beforeAll(async () => { ... })` |
## Config
| Cypress | Playwright |
|---|---|
| `baseUrl` in `cypress.config.ts` | `use.baseURL` in `playwright.config.ts` |
| `defaultCommandTimeout` | `expect.timeout` or `use.actionTimeout` |
| `video: true` | `use.video: 'on'` |
| `screenshotOnRunFailure` | `use.screenshot: 'only-on-failure'` |
| `retries: { runMode: 2 }` | `retries: 2` |

View File

@@ -0,0 +1,94 @@
# Selenium → Playwright Mapping
## Driver Setup
| Selenium (JS) | Playwright |
|---|---|
| `new Builder().forBrowser('chrome').build()` | Handled by config — no driver setup |
| `driver.quit()` | Automatic — Playwright manages browser lifecycle |
| `driver.manage().setTimeouts(...)` | Config: `timeout`, `expect.timeout` |
## Navigation
| Selenium | Playwright | Notes |
|---|---|---|
| `driver.get(url)` | `await page.goto(url)` | Use `baseURL` |
| `driver.navigate().back()` | `await page.goBack()` | |
| `driver.navigate().forward()` | `await page.goForward()` | |
| `driver.navigate().refresh()` | `await page.reload()` | |
| `driver.getCurrentUrl()` | `page.url()` | |
| `driver.getTitle()` | `await page.title()` | |
## Element Location
| Selenium | Playwright | Preferred |
|---|---|---|
| `By.id('x')` | `page.locator('#x')` | `page.getByTestId('x')` |
| `By.css('.x')` | `page.locator('.x')` | `page.getByRole(...)` |
| `By.xpath('//div')` | `page.locator('xpath=//div')` | Avoid — use role-based |
| `By.name('x')` | `page.locator('[name=x]')` | `page.getByLabel(...)` |
| `By.linkText('x')` | `page.getByRole('link', { name: 'x' })` | ✅ Best practice |
| `By.partialLinkText('x')` | `page.getByRole('link', { name: /x/ })` | ✅ Best practice |
| `By.tagName('button')` | `page.getByRole('button')` | ✅ Best practice |
| `By.className('x')` | `page.locator('.x')` | `page.getByRole(...)` |
| `findElement()` | Returns first match | `locator.first()` |
| `findElements()` | `page.locator(selector)` | Use `.count()` or `.all()` |
## Actions
| Selenium | Playwright |
|---|---|
| `element.click()` | `await locator.click()` |
| `element.sendKeys('text')` | `await locator.fill('text')` |
| `element.sendKeys(Key.ENTER)` | `await locator.press('Enter')` |
| `element.clear()` | `await locator.clear()` |
| `element.submit()` | `await locator.press('Enter')` or click submit button |
| `element.getText()` | `await locator.textContent()` |
| `element.getAttribute('x')` | `await locator.getAttribute('x')` |
| `element.isDisplayed()` | `await locator.isVisible()` |
| `element.isEnabled()` | `await locator.isEnabled()` |
| `element.isSelected()` | `await locator.isChecked()` |
## Waits
| Selenium | Playwright | Notes |
|---|---|---|
| `WebDriverWait(driver, 10).until(EC.visibilityOf(el))` | `await expect(locator).toBeVisible()` | Auto-retries |
| `WebDriverWait(driver, 10).until(EC.elementToBeClickable(el))` | `await locator.click()` | Auto-waits for clickable |
| `WebDriverWait(driver, 10).until(EC.presenceOf(el))` | `await expect(locator).toBeAttached()` | |
| `WebDriverWait(driver, 10).until(EC.textToBe(el, 'x'))` | `await expect(locator).toHaveText('x')` | |
| `Thread.sleep(3000)` | ❌ Never use | Use assertions instead |
| `driver.manage().setTimeouts({ implicit: 10000 })` | Not needed | Playwright auto-waits |
## Advanced
| Selenium | Playwright |
|---|---|
| `Actions(driver).moveToElement(el).perform()` | `await locator.hover()` |
| `Actions(driver).dragAndDrop(src, tgt).perform()` | `await src.dragTo(tgt)` |
| `Actions(driver).doubleClick(el).perform()` | `await locator.dblclick()` |
| `Actions(driver).contextClick(el).perform()` | `await locator.click({ button: 'right' })` |
| `driver.switchTo().frame(el)` | `page.frameLocator('#frame')` |
| `driver.switchTo().defaultContent()` | Not needed — use `page` directly |
| `driver.switchTo().alert()` | `page.on('dialog', d => d.accept())` |
| `driver.switchTo().window(handle)` | `const popup = await page.waitForEvent('popup')` |
| `driver.executeScript(js)` | `await page.evaluate(js)` |
| `driver.takeScreenshot()` | `await page.screenshot({ path: 'x.png' })` |
## Test Structure
| Selenium (Jest/Mocha) | Playwright |
|---|---|
| `describe('Suite', () => { ... })` | `test.describe('Suite', () => { ... })` |
| `it('should...', () => { ... })` | `test('should...', async ({ page }) => { ... })` |
| `beforeAll(() => { ... })` | `test.beforeAll(async () => { ... })` |
| `beforeEach(() => { ... })` | `test.beforeEach(async ({ page }) => { ... })` |
| `afterEach(() => { ... })` | `test.afterEach(async ({ page }) => { ... })` |
## Key Differences
1. **No implicit waits** — Playwright auto-waits for actionability
2. **No driver management** — Playwright handles browser lifecycle
3. **Built-in assertions**`expect(locator)` with auto-retry
4. **Parallel by default** — tests run in parallel, must be isolated
5. **Traces instead of screenshots** — richer debugging artifacts

View File

@@ -0,0 +1,126 @@
---
name: report
description: >-
Generate test report. Use when user says "test report", "results summary",
"test status", "show results", "test dashboard", or "how did tests go".
---
# Smart Test Reporting
Generate test reports that plug into the user's existing workflow. Zero new tools.
## Steps
### 1. Run Tests (If Not Already Run)
Check if recent test results exist:
```bash
ls -la test-results/ playwright-report/ 2>/dev/null
```
If no recent results, run tests:
```bash
npx playwright test --reporter=json,html,list 2>&1 | tee test-output.log
```
### 2. Parse Results
Read the JSON report:
```bash
npx playwright test --reporter=json 2> /dev/null
```
Extract:
- Total tests, passed, failed, skipped, flaky
- Duration per test and total
- Failed test names with error messages
- Flaky tests (passed on retry)
### 3. Detect Report Destination
Check what's configured and route automatically:
| Check | If found | Action |
|---|---|---|
| `TESTRAIL_URL` env var | TestRail configured | Push results via `/pw:testrail push` |
| `SLACK_WEBHOOK_URL` env var | Slack configured | Post summary to Slack |
| `.github/workflows/` | GitHub Actions | Results go to PR comment via artifacts |
| `playwright-report/` | HTML reporter | Open or serve the report |
| None of the above | Default | Generate markdown report |
### 4. Generate Report
#### Markdown Report (Always Generated)
```markdown
# Test Results — {{date}}
## Summary
- ✅ Passed: {{passed}}
- ❌ Failed: {{failed}}
- ⏭️ Skipped: {{skipped}}
- 🔄 Flaky: {{flaky}}
- ⏱️ Duration: {{duration}}
## Failed Tests
| Test | Error | File |
|---|---|---|
| {{name}} | {{error}} | {{file}}:{{line}} |
## Flaky Tests
| Test | Retries | File |
|---|---|---|
| {{name}} | {{retries}} | {{file}} |
## By Project
| Browser | Passed | Failed | Duration |
|---|---|---|---|
| Chromium | X | Y | Zs |
| Firefox | X | Y | Zs |
| WebKit | X | Y | Zs |
```
Save to `test-reports/{{date}}-report.md`.
#### Slack Summary (If Webhook Configured)
```bash
curl -X POST "$SLACK_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{
"text": "🧪 Test Results: ✅ {{passed}} | ❌ {{failed}} | ⏱️ {{duration}}\n{{failed_details}}"
}'
```
#### TestRail Push (If Configured)
Invoke `/pw:testrail push` with the JSON results.
#### HTML Report
```bash
npx playwright show-report
```
Or if in CI:
```bash
echo "HTML report available at: playwright-report/index.html"
```
### 5. Trend Analysis (If Historical Data Exists)
If previous reports exist in `test-reports/`:
- Compare pass rate over time
- Identify tests that became flaky recently
- Highlight new failures vs. recurring failures
## Output
- Summary with pass/fail/skip/flaky counts
- Failed test details with error messages
- Report destination confirmation
- Trend comparison (if historical data available)
- Next action recommendation (fix failures or celebrate green)

View File

@@ -0,0 +1,102 @@
---
name: review
description: >-
Review Playwright tests for quality. Use when user says "review tests",
"check test quality", "audit tests", "improve tests", "test code review",
or "playwright best practices check".
---
# Review Playwright Tests
Systematically review Playwright test files for anti-patterns, missed best practices, and coverage gaps.
## Input
`$ARGUMENTS` can be:
- A file path: review that specific test file
- A directory: review all test files in the directory
- Empty: review all tests in the project's `testDir`
## Steps
### 1. Gather Context
- Read `playwright.config.ts` for project settings
- List all `*.spec.ts` / `*.spec.js` files in scope
- If reviewing a single file, also check related page objects and fixtures
### 2. Check Each File Against Anti-Patterns
Load `anti-patterns.md` from this skill directory. Check for all 20 anti-patterns.
**Critical (must fix):**
1. `waitForTimeout()` usage
2. Non-web-first assertions (`expect(await ...)`)
3. Hardcoded URLs instead of `baseURL`
4. CSS/XPath selectors when role-based exists
5. Missing `await` on Playwright calls
6. Shared mutable state between tests
7. Test execution order dependencies
**Warning (should fix):**
8. Tests longer than 50 lines (consider splitting)
9. Magic strings without named constants
10. Missing error/edge case tests
11. `page.evaluate()` for things locators can do
12. Nested `test.describe()` more than 2 levels deep
13. Generic test names ("should work", "test 1")
**Info (consider):**
14. No page objects for pages with 5+ locators
15. Inline test data instead of factory/fixture
16. Missing accessibility assertions
17. No visual regression tests for UI-heavy pages
18. Console error assertions not checked
19. Network idle waits instead of specific assertions
20. Missing `test.describe()` grouping
### 3. Score Each File
Rate 1-10 based on:
- **9-10**: Production-ready, follows all golden rules
- **7-8**: Good, minor improvements possible
- **5-6**: Functional but has anti-patterns
- **3-4**: Significant issues, likely flaky
- **1-2**: Needs rewrite
### 4. Generate Review Report
For each file:
```
## <filename> — Score: X/10
### Critical
- Line 15: `waitForTimeout(2000)` → use `expect(locator).toBeVisible()`
- Line 28: CSS selector `.btn-submit` → `getByRole('button', { name: 'Submit' })`
### Warning
- Line 42: Test name "test login" → "should redirect to dashboard after login"
### Suggestions
- Consider adding error case: what happens with invalid credentials?
```
### 5. For Project-Wide Review
If reviewing an entire test suite:
- Spawn sub-agents per file for parallel review (up to 5 concurrent)
- Or use `/batch` for very large suites
- Aggregate results into a summary table
### 6. Offer Fixes
For each critical issue, provide the corrected code. Ask user: "Apply these fixes? [Yes/No]"
If yes, apply all fixes using `Edit` tool.
## Output
- File-by-file review with scores
- Summary: total files, average score, critical issue count
- Actionable fix list
- Coverage gaps identified (pages/features with no tests)

View File

@@ -0,0 +1,182 @@
# Playwright Anti-Patterns Reference
## 1. Using `waitForTimeout()`
**Bad:**
```typescript
await page.click('.submit');
await page.waitForTimeout(3000);
await expect(page.locator('.result')).toBeVisible();
```
**Good:**
```typescript
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByTestId('result')).toBeVisible();
```
**Why:** Arbitrary waits slow tests and cause flakiness. Web-first assertions auto-retry.
## 2. Non-Web-First Assertions
**Bad:**
```typescript
const text = await page.textContent('.message');
expect(text).toBe('Success');
```
**Good:**
```typescript
await expect(page.getByText('Success')).toBeVisible();
```
**Why:** `expect(locator)` auto-retries until timeout. `expect(value)` checks once and fails.
## 3. Hardcoded URLs
**Bad:**
```typescript
await page.goto('http://localhost:3000/login');
```
**Good:**
```typescript
await page.goto('/login');
```
**Why:** `baseURL` in config handles the host. Tests break across environments with hardcoded URLs.
## 4. CSS/XPath When Role-Based Exists
**Bad:**
```typescript
await page.click('#submit-btn');
await page.locator('.nav-link.active').click();
```
**Good:**
```typescript
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('link', { name: 'Dashboard' }).click();
```
**Why:** Role-based locators survive CSS renames, class refactors, and component library changes.
## 5. Missing `await`
**Bad:**
```typescript
page.goto('/dashboard');
expect(page.getByText('Welcome')).toBeVisible();
```
**Good:**
```typescript
await page.goto('/dashboard');
await expect(page.getByText('Welcome')).toBeVisible();
```
**Why:** Missing `await` causes race conditions. Tests pass sometimes, fail others.
## 6. Shared Mutable State
**Bad:**
```typescript
let userId: string;
test('create user', async ({ page }) => {
// ... creates user, sets userId
userId = '123';
});
test('edit user', async ({ page }) => {
await page.goto(`/users/${userId}`); // depends on previous test
});
```
**Good:**
```typescript
test('edit user', async ({ page }) => {
// Create user via API in this test's setup
const userId = await createUserViaAPI();
await page.goto(`/users/${userId}`);
});
```
**Why:** Tests must be independent. Shared state causes order-dependent failures.
## 7. Execution Order Dependencies
**Bad:**
```typescript
test('step 1: fill form', async ({ page }) => { ... });
test('step 2: submit form', async ({ page }) => { ... });
test('step 3: verify result', async ({ page }) => { ... });
```
**Good:**
```typescript
test('should fill and submit form successfully', async ({ page }) => {
// All steps in one test
});
```
**Why:** Playwright runs tests in parallel by default. Order-dependent tests fail randomly.
## 8. Tests Over 50 Lines
Split into focused tests. Each test should verify one behavior.
## 9. Magic Strings
**Bad:**
```typescript
await page.getByLabel('Email').fill('admin@test.com');
```
**Good:**
```typescript
const TEST_USER = { email: 'admin@test.com', password: 'Test123!' };
await page.getByLabel('Email').fill(TEST_USER.email);
```
## 10. Missing Error Cases
If you test the happy path, also test:
- Invalid input
- Empty state
- Network error
- Permission denied
- Timeout/loading state
## 11. Using `page.evaluate()` Unnecessarily
**Bad:**
```typescript
const text = await page.evaluate(() => document.querySelector('.title')?.textContent);
```
**Good:**
```typescript
await expect(page.getByRole('heading')).toHaveText('Expected Title');
```
## 12. Deep Nesting
Keep `test.describe()` to max 2 levels. More makes tests hard to find and maintain.
## 13. Generic Test Names
**Bad:** `test('test 1')`, `test('should work')`, `test('login test')`
**Good:** `test('should show error when email is invalid')`, `test('should redirect to dashboard after successful login')`
## 14-20. Style Issues
- No page objects for complex pages → create them
- Inline data → use factories or fixtures
- Missing a11y assertions → add `toHaveAttribute('role', ...)`
- No visual regression → add `toHaveScreenshot()` for key pages
- Not checking console errors → add `page.on('console', ...)`
- Using `networkidle` → use specific assertions instead
- No `test.describe()` → group related tests

View File

@@ -0,0 +1,129 @@
---
name: testrail
description: >-
Sync tests with TestRail. Use when user mentions "testrail", "test management",
"test cases", "test run", "sync test cases", "push results to testrail",
or "import from testrail".
---
# TestRail Integration
Bidirectional sync between Playwright tests and TestRail test management.
## Prerequisites
Environment variables must be set:
- `TESTRAIL_URL` — e.g., `https://your-instance.testrail.io`
- `TESTRAIL_USER` — your email
- `TESTRAIL_API_KEY` — API key from TestRail
If not set, inform the user how to configure them and stop.
## Capabilities
### 1. Import Test Cases → Generate Playwright Tests
```
/pw:testrail import --project <id> --suite <id>
```
Steps:
1. Call `testrail_get_cases` MCP tool to fetch test cases
2. For each test case:
- Read title, preconditions, steps, expected results
- Map to a Playwright test using appropriate template
- Include TestRail case ID as test annotation: `test.info().annotations.push({ type: 'testrail', description: 'C12345' })`
3. Generate test files grouped by section
4. Report: X cases imported, Y tests generated
### 2. Push Test Results → TestRail
```
/pw:testrail push --run <id>
```
Steps:
1. Run Playwright tests with JSON reporter:
```bash
npx playwright test --reporter=json > test-results.json
```
2. Parse results: map each test to its TestRail case ID (from annotations)
3. Call `testrail_add_result` MCP tool for each test:
- Pass → status_id: 1
- Fail → status_id: 5, include error message
- Skip → status_id: 2
4. Report: X results pushed, Y passed, Z failed
### 3. Create Test Run
```
/pw:testrail run --project <id> --name "Sprint 42 Regression"
```
Steps:
1. Call `testrail_add_run` MCP tool
2. Include all test case IDs found in Playwright test annotations
3. Return run ID for result pushing
### 4. Sync Status
```
/pw:testrail status --project <id>
```
Steps:
1. Fetch test cases from TestRail
2. Scan local Playwright tests for TestRail annotations
3. Report coverage:
```
TestRail cases: 150
Playwright tests with TestRail IDs: 120
Unlinked TestRail cases: 30
Playwright tests without TestRail IDs: 15
```
### 5. Update Test Cases in TestRail
```
/pw:testrail update --case <id>
```
Steps:
1. Read the Playwright test for this case ID
2. Extract steps and expected results from test code
3. Call `testrail_update_case` MCP tool to update steps
## MCP Tools Used
| Tool | When |
|---|---|
| `testrail_get_projects` | List available projects |
| `testrail_get_suites` | List suites in project |
| `testrail_get_cases` | Read test cases |
| `testrail_add_case` | Create new test case |
| `testrail_update_case` | Update existing case |
| `testrail_add_run` | Create test run |
| `testrail_add_result` | Push individual result |
| `testrail_get_results` | Read historical results |
## Test Annotation Format
All Playwright tests linked to TestRail include:
```typescript
test('should login successfully', async ({ page }) => {
test.info().annotations.push({
type: 'testrail',
description: 'C12345',
});
// ... test code
});
```
This annotation is the bridge between Playwright and TestRail.
## Output
- Operation summary with counts
- Any errors or unmatched cases
- Link to TestRail run/results

View File

@@ -0,0 +1,123 @@
# Test Case Templates
55 ready-to-use, parametrizable Playwright test templates. Each includes TypeScript and JavaScript examples with `{{placeholder}}` markers for customization.
## Usage
Templates are loaded by `/pw:generate` when it detects a matching scenario. You can also reference them directly:
```
/pw:generate "login flow" → loads templates/auth/login.md
```
## Template Index
### Authentication (8)
| Template | Tests |
|---|---|
| [login.md](auth/login.md) | Email/password login, social login, remember me |
| [logout.md](auth/logout.md) | Logout from nav, session cleanup |
| [sso.md](auth/sso.md) | SSO redirect flow, callback handling |
| [mfa.md](auth/mfa.md) | 2FA code entry, backup codes |
| [password-reset.md](auth/password-reset.md) | Request reset, enter new password, expired link |
| [session-timeout.md](auth/session-timeout.md) | Auto-logout, session refresh |
| [remember-me.md](auth/remember-me.md) | Persistent login, cookie expiry |
| [rbac.md](auth/rbac.md) | Role-based access, forbidden page |
### CRUD Operations (6)
| Template | Tests |
|---|---|
| [create.md](crud/create.md) | Create entity with form |
| [read.md](crud/read.md) | View details, list view |
| [update.md](crud/update.md) | Edit entity, inline edit |
| [delete.md](crud/delete.md) | Delete with confirmation |
| [bulk-operations.md](crud/bulk-operations.md) | Select multiple, bulk actions |
| [soft-delete.md](crud/soft-delete.md) | Archive, restore |
### Checkout (6)
| Template | Tests |
|---|---|
| [add-to-cart.md](checkout/add-to-cart.md) | Add item, update cart |
| [update-quantity.md](checkout/update-quantity.md) | Increase, decrease, remove |
| [apply-coupon.md](checkout/apply-coupon.md) | Valid/invalid/expired codes |
| [payment.md](checkout/payment.md) | Card form, validation, processing |
| [order-confirm.md](checkout/order-confirm.md) | Success page, order details |
| [order-history.md](checkout/order-history.md) | List orders, pagination |
### Search & Filter (5)
| Template | Tests |
|---|---|
| [basic-search.md](search/basic-search.md) | Search input, results |
| [filters.md](search/filters.md) | Category, price, checkboxes |
| [sorting.md](search/sorting.md) | Sort by name, date, price |
| [pagination.md](search/pagination.md) | Page nav, items per page |
| [empty-state.md](search/empty-state.md) | No results, clear filters |
### Forms (6)
| Template | Tests |
|---|---|
| [single-step.md](forms/single-step.md) | Simple form submission |
| [multi-step.md](forms/multi-step.md) | Wizard with progress |
| [validation.md](forms/validation.md) | Required, format, inline errors |
| [file-upload.md](forms/file-upload.md) | Single, multiple, drag-drop |
| [conditional-fields.md](forms/conditional-fields.md) | Show/hide based on selection |
| [autosave.md](forms/autosave.md) | Draft save, restore |
### Dashboard (5)
| Template | Tests |
|---|---|
| [data-loading.md](dashboard/data-loading.md) | Loading state, skeleton, data |
| [chart-rendering.md](dashboard/chart-rendering.md) | Chart visible, tooltips |
| [date-range-filter.md](dashboard/date-range-filter.md) | Date picker, presets |
| [export.md](dashboard/export.md) | CSV/PDF download |
| [realtime-updates.md](dashboard/realtime-updates.md) | Live data, websocket |
### Settings (4)
| Template | Tests |
|---|---|
| [profile-update.md](settings/profile-update.md) | Name, email, avatar |
| [password-change.md](settings/password-change.md) | Current + new password |
| [notification-prefs.md](settings/notification-prefs.md) | Toggle, save prefs |
| [account-delete.md](settings/account-delete.md) | Confirm deletion |
### Onboarding (4)
| Template | Tests |
|---|---|
| [registration.md](onboarding/registration.md) | Signup form, validation |
| [email-verification.md](onboarding/email-verification.md) | Verify link, resend |
| [welcome-tour.md](onboarding/welcome-tour.md) | Step tour, skip |
| [first-time-setup.md](onboarding/first-time-setup.md) | Initial config |
### Notifications (3)
| Template | Tests |
|---|---|
| [in-app.md](notifications/in-app.md) | Badge, dropdown, mark read |
| [toast-messages.md](notifications/toast-messages.md) | Success/error toasts |
| [notification-center.md](notifications/notification-center.md) | List, filter, clear |
### API Testing (5)
| Template | Tests |
|---|---|
| [rest-crud.md](api/rest-crud.md) | GET/POST/PUT/DELETE |
| [graphql.md](api/graphql.md) | Query, mutation |
| [auth-headers.md](api/auth-headers.md) | Token, expired, refresh |
| [error-responses.md](api/error-responses.md) | 400-500 status handling |
| [rate-limiting.md](api/rate-limiting.md) | Rate limit, retry-after |
### Accessibility (3)
| Template | Tests |
|---|---|
| [keyboard-navigation.md](accessibility/keyboard-navigation.md) | Tab order, focus |
| [screen-reader.md](accessibility/screen-reader.md) | ARIA labels, live regions |
| [color-contrast.md](accessibility/color-contrast.md) | Contrast ratios |

View File

@@ -0,0 +1,162 @@
# 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
```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
```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 |

View File

@@ -0,0 +1,149 @@
# Keyboard Navigation Template
Tests tab order, focus visibility, and keyboard shortcuts.
## Prerequisites
- App running at `{{baseUrl}}`
- Page under test: `{{baseUrl}}/{{pagePath}}`
---
## TypeScript
```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
```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 |

View File

@@ -0,0 +1,159 @@
# Screen Reader Template
Tests ARIA labels, live regions, and announcements for assistive technology.
## Prerequisites
- App running at `{{baseUrl}}`
- Page under test: `{{baseUrl}}/{{pagePath}}`
---
## TypeScript
```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
```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 |

View File

@@ -0,0 +1,148 @@
# 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
```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
```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 |

View File

@@ -0,0 +1,157 @@
# API Error Responses Template
Tests 400, 401, 403, 404, and 500 HTTP error handling.
## Prerequisites
- Valid auth token: `{{apiToken}}`
- API base: `{{apiBaseUrl}}`
---
## TypeScript
```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
```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 |

View File

@@ -0,0 +1,174 @@
# 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
```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
```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 |

View File

@@ -0,0 +1,152 @@
# 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
```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
```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 |

View File

@@ -0,0 +1,152 @@
# 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
```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
```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 |

View File

@@ -0,0 +1,119 @@
# 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
```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
```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 |

View File

@@ -0,0 +1,112 @@
# Logout Template
Tests logout from navigation, session cleanup, and redirect behaviour.
## Prerequisites
- Authenticated session (use `storageState` or login fixture)
- App running at `{{baseUrl}}`
---
## TypeScript
```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
```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 |

View File

@@ -0,0 +1,125 @@
# 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
```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
```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 |

View File

@@ -0,0 +1,129 @@
# 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
```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
```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 |

View File

@@ -0,0 +1,132 @@
# 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
```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
```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 |

View File

@@ -0,0 +1,127 @@
# 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
```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
```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 |

View File

@@ -0,0 +1,113 @@
# 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
```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
```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 |

View File

@@ -0,0 +1,115 @@
# 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
```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
```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 |

View File

@@ -0,0 +1,112 @@
# 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
```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
```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 |

View File

@@ -0,0 +1,123 @@
# 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
```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
```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 |

View File

@@ -0,0 +1,108 @@
# 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
```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
```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 |

View File

@@ -0,0 +1,119 @@
# 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
```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
```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 |

View File

@@ -0,0 +1,148 @@
# 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
```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
```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 |

View File

@@ -0,0 +1,125 @@
# 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
```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
```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 |

View File

@@ -0,0 +1,129 @@
# Bulk Operations Template
Tests selecting multiple items and performing bulk delete/update actions.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- At least `{{minItemCount}}` entities seeded in list
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Bulk Operations', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s');
});
// Happy path: select all and bulk delete
test('selects all and bulk deletes', async ({ page }) => {
await page.getByRole('checkbox', { name: /select all/i }).check();
const checkboxes = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') })
.getByRole('checkbox');
await expect(checkboxes.first()).toBeChecked();
await page.getByRole('button', { name: /bulk delete/i }).click();
await page.getByRole('dialog').getByRole('button', { name: /confirm/i }).click();
await expect(page.getByRole('alert')).toContainText(/deleted/i);
await expect(page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') }))
.toHaveCount(0);
});
// Happy path: select specific rows and bulk update status
test('updates status of selected rows', async ({ page }) => {
const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
await rows.nth(0).getByRole('checkbox').check();
await rows.nth(1).getByRole('checkbox').check();
await expect(page.getByText(/2 selected/i)).toBeVisible();
await page.getByRole('button', { name: /bulk actions/i }).click();
await page.getByRole('menuitem', { name: /mark as active/i }).click();
await expect(page.getByRole('alert')).toContainText(/2.*updated/i);
});
// Happy path: toolbar appears only when items selected
test('shows bulk action toolbar only when items are selected', async ({ page }) => {
await expect(page.getByRole('toolbar', { name: /bulk actions/i })).toBeHidden();
await page.getByRole('row').nth(1).getByRole('checkbox').check();
await expect(page.getByRole('toolbar', { name: /bulk actions/i })).toBeVisible();
});
// Happy path: deselect all clears toolbar
test('hides toolbar after deselecting all', async ({ page }) => {
await page.getByRole('checkbox', { name: /select all/i }).check();
await page.getByRole('checkbox', { name: /select all/i }).uncheck();
await expect(page.getByRole('toolbar', { name: /bulk actions/i })).toBeHidden();
});
// Error case: bulk delete requires confirmation
test('requires confirmation before bulk delete', async ({ page }) => {
await page.getByRole('checkbox', { name: /select all/i }).check();
await page.getByRole('button', { name: /bulk delete/i }).click();
await expect(page.getByRole('dialog', { name: /confirm/i })).toBeVisible();
await page.getByRole('button', { name: /cancel/i }).click();
const rowCount = await page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') }).count();
expect(rowCount).toBeGreaterThan(0);
});
// Edge case: select all across pages
test('shows "select all across pages" option when applicable', async ({ page }) => {
await page.getByRole('checkbox', { name: /select all/i }).check();
const crossPage = page.getByRole('button', { name: /select all.*across pages/i });
if (await crossPage.isVisible()) {
await crossPage.click();
await expect(page.getByText(/all.*selected/i)).toBeVisible();
}
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Bulk Operations', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s');
});
test('shows bulk action toolbar when items selected', async ({ page }) => {
await expect(page.getByRole('toolbar', { name: /bulk actions/i })).toBeHidden();
await page.getByRole('row').nth(1).getByRole('checkbox').check();
await expect(page.getByRole('toolbar', { name: /bulk actions/i })).toBeVisible();
});
test('selects all and bulk deletes', async ({ page }) => {
await page.getByRole('checkbox', { name: /select all/i }).check();
await page.getByRole('button', { name: /bulk delete/i }).click();
await page.getByRole('dialog').getByRole('button', { name: /confirm/i }).click();
await expect(page.getByRole('alert')).toContainText(/deleted/i);
});
test('requires confirmation before bulk delete', async ({ page }) => {
await page.getByRole('checkbox', { name: /select all/i }).check();
await page.getByRole('button', { name: /bulk delete/i }).click();
await expect(page.getByRole('dialog', { name: /confirm/i })).toBeVisible();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Select all + delete | All rows selected → confirmed delete → empty list |
| Partial select + update | N rows selected → status updated → success |
| Toolbar visibility | Appears on select, hides on deselect |
| Deselect all | Select all → uncheck → toolbar gone |
| Confirmation required | Bulk delete shows dialog first |
| Cross-page select | Select-all-pages option shown on multi-page lists |

View File

@@ -0,0 +1,118 @@
# Create Entity Template
Tests creating a new entity via form submission.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- Entity type: `{{entityName}}` (e.g. "Project", "Product", "User")
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Create {{entityName}}', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/new');
});
// Happy path: create with valid data
test('creates {{entityName}} with valid data', async ({ page }) => {
await page.getByRole('textbox', { name: /name/i }).fill('{{testEntityName}}');
await page.getByRole('textbox', { name: /description/i }).fill('{{testEntityDescription}}');
await page.getByRole('combobox', { name: /category/i }).selectOption('{{testEntityCategory}}');
await page.getByRole('button', { name: /create|save/i }).click();
await expect(page).toHaveURL(/\/{{entityName}}s\/\d+/);
await expect(page.getByRole('heading', { name: '{{testEntityName}}' })).toBeVisible();
await expect(page.getByRole('alert')).toContainText(/created successfully/i);
});
// Happy path: create and add another
test('clears form after "save and add another"', async ({ page }) => {
await page.getByRole('textbox', { name: /name/i }).fill('{{testEntityName}}');
await page.getByRole('button', { name: /save and add another/i }).click();
await expect(page.getByRole('textbox', { name: /name/i })).toHaveValue('');
await expect(page.getByRole('alert')).toContainText(/created successfully/i);
});
// Error case: required fields missing
test('shows validation errors for empty required fields', async ({ page }) => {
await page.getByRole('button', { name: /create|save/i }).click();
await expect(page.getByText(/name is required/i)).toBeVisible();
await expect(page).toHaveURL('{{baseUrl}}/{{entityName}}s/new');
});
// Error case: duplicate name
test('shows error when entity name already exists', async ({ page }) => {
await page.getByRole('textbox', { name: /name/i }).fill('{{existingEntityName}}');
await page.getByRole('button', { name: /create|save/i }).click();
await expect(page.getByRole('alert')).toContainText(/already exists|duplicate/i);
});
// Edge case: max length enforcement
test('enforces max length on name field', async ({ page }) => {
const longName = 'A'.repeat({{maxNameLength}} + 1);
await page.getByRole('textbox', { name: /name/i }).fill(longName);
const actualValue = await page.getByRole('textbox', { name: /name/i }).inputValue();
expect(actualValue.length).toBeLessThanOrEqual({{maxNameLength}});
});
// Edge case: cancel navigates away without saving
test('cancel navigates back without creating', async ({ page }) => {
await page.getByRole('textbox', { name: /name/i }).fill('should-not-save');
await page.getByRole('button', { name: /cancel/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/{{entityName}}s');
await expect(page.getByRole('cell', { name: 'should-not-save' })).toBeHidden();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Create {{entityName}}', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/new');
});
test('creates entity with valid data', async ({ page }) => {
await page.getByRole('textbox', { name: /name/i }).fill('{{testEntityName}}');
await page.getByRole('textbox', { name: /description/i }).fill('{{testEntityDescription}}');
await page.getByRole('button', { name: /create|save/i }).click();
await expect(page).toHaveURL(/\/{{entityName}}s\/\d+/);
await expect(page.getByRole('alert')).toContainText(/created successfully/i);
});
test('shows validation errors for empty form', async ({ page }) => {
await page.getByRole('button', { name: /create|save/i }).click();
await expect(page.getByText(/name is required/i)).toBeVisible();
});
test('cancel navigates back without saving', async ({ page }) => {
await page.getByRole('textbox', { name: /name/i }).fill('not-saved');
await page.getByRole('button', { name: /cancel/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/{{entityName}}s');
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Happy path | Valid form → entity created → detail page |
| Save and add | Form cleared, ready for next entry |
| Required fields | Empty submit → inline validation |
| Duplicate name | Server error shown |
| Max length | Input truncated at field max |
| Cancel | No entity created, returns to list |

View File

@@ -0,0 +1,116 @@
# Delete Entity Template
Tests deletion with confirmation dialog and post-delete behaviour.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- Entity to delete: ID `{{entityId}}`, name `{{entityName}}`
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Delete {{entityName}}', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
// Happy path: delete from detail page
test('deletes entity after confirming dialog', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}');
await page.getByRole('button', { name: /delete/i }).click();
const dialog = page.getByRole('dialog', { name: /delete|confirm/i });
await expect(dialog).toBeVisible();
await expect(dialog).toContainText('{{entityName}}');
await dialog.getByRole('button', { name: /delete|confirm/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/{{entityName}}s');
await expect(page.getByRole('alert')).toContainText(/deleted successfully/i);
await expect(page.getByRole('link', { name: '{{entityName}}' })).toBeHidden();
});
// Happy path: delete from list view
test('deletes entity from list row action', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s');
const row = page.getByRole('row', { name: new RegExp('{{entityName}}') });
await row.getByRole('button', { name: /delete/i }).click();
await page.getByRole('dialog').getByRole('button', { name: /confirm|delete/i }).click();
await expect(row).toBeHidden();
});
// Error case: cancel deletion
test('does not delete when cancel is clicked in dialog', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}');
await page.getByRole('button', { name: /delete/i }).click();
await page.getByRole('dialog').getByRole('button', { name: /cancel/i }).click();
await expect(page.getByRole('dialog')).toBeHidden();
await expect(page).toHaveURL(`{{baseUrl}}/{{entityName}}s/{{entityId}}`);
await expect(page.getByRole('heading', { name: '{{entityName}}' })).toBeVisible();
});
// Error case: delete entity with dependents
test('shows error when entity has dependent records', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityWithDependentsId}}');
await page.getByRole('button', { name: /delete/i }).click();
await page.getByRole('dialog').getByRole('button', { name: /confirm|delete/i }).click();
await expect(page.getByRole('alert')).toContainText(/cannot delete|has dependents/i);
await expect(page).toHaveURL(`{{baseUrl}}/{{entityName}}s/{{entityWithDependentsId}}`);
});
// Edge case: confirmation dialog requires typing entity name
test('requires typing entity name to confirm deletion', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}');
await page.getByRole('button', { name: /delete/i }).click();
const confirmBtn = page.getByRole('dialog').getByRole('button', { name: /confirm|delete/i });
await expect(confirmBtn).toBeDisabled();
await page.getByRole('textbox', { name: /type.*to confirm/i }).fill('{{entityName}}');
await expect(confirmBtn).toBeEnabled();
await confirmBtn.click();
await expect(page).toHaveURL('{{baseUrl}}/{{entityName}}s');
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Delete {{entityName}}', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('deletes entity after confirming dialog', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}');
await page.getByRole('button', { name: /delete/i }).click();
await page.getByRole('dialog').getByRole('button', { name: /confirm|delete/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/{{entityName}}s');
await expect(page.getByRole('alert')).toContainText(/deleted successfully/i);
});
test('does not delete when cancel clicked', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}');
await page.getByRole('button', { name: /delete/i }).click();
await page.getByRole('dialog').getByRole('button', { name: /cancel/i }).click();
await expect(page.getByRole('heading', { name: '{{entityName}}' })).toBeVisible();
});
test('shows error for entity with dependents', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityWithDependentsId}}');
await page.getByRole('button', { name: /delete/i }).click();
await page.getByRole('dialog').getByRole('button', { name: /confirm|delete/i }).click();
await expect(page.getByRole('alert')).toContainText(/cannot delete/i);
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Delete confirmed | Dialog confirmed → entity removed → list page |
| Delete from list | Row action → confirm → row removed |
| Cancel deletion | Dialog cancelled → entity intact |
| Dependent error | Entity with children → deletion blocked |
| Type-to-confirm | Confirm button disabled until name typed |

View File

@@ -0,0 +1,117 @@
# Read Entity Template
Tests viewing entity details and list view with correct data display.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- Seeded entity with ID `{{entityId}}` and name `{{entityName}}`
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Read {{entityName}}', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
// Happy path: detail page
test('displays entity details correctly', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}');
await expect(page.getByRole('heading', { name: '{{expectedTitle}}' })).toBeVisible();
await expect(page.getByText('{{expectedField}}')).toBeVisible();
await expect(page.getByText('{{expectedCategory}}')).toBeVisible();
});
// Happy path: list view shows all items
test('displays list of entities', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s');
await expect(page.getByRole('table')).toBeVisible();
const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
await expect(rows).toHaveCount({{expectedItemCount}});
});
// Happy path: list item links to detail
test('clicking list item navigates to detail page', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s');
await page.getByRole('link', { name: '{{expectedTitle}}' }).click();
await expect(page).toHaveURL(`{{baseUrl}}/{{entityName}}s/{{entityId}}`);
});
// Happy path: breadcrumb navigation
test('breadcrumb shows correct path', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}');
await expect(page.getByRole('navigation', { name: /breadcrumb/i })).toContainText('{{entityName}}s');
await expect(page.getByRole('navigation', { name: /breadcrumb/i })).toContainText('{{expectedTitle}}');
});
// Error case: non-existent entity shows 404
test('shows 404 for non-existent entity', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/999999');
await expect(page.getByRole('heading', { name: /404|not found/i })).toBeVisible();
});
// Edge case: loading state resolves to data
test('shows data after loading completes', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}');
// Skeleton/spinner should be gone, data visible
await expect(page.getByTestId('skeleton')).toBeHidden();
await expect(page.getByRole('heading', { name: '{{expectedTitle}}' })).toBeVisible();
});
// Edge case: empty list state
test('shows empty state when no entities exist', async ({ page }) => {
// Assumes a fresh context or filter that returns no results
await page.goto('{{baseUrl}}/{{entityName}}s?filter={{emptyFilter}}');
await expect(page.getByText(/no {{entityName}}s found/i)).toBeVisible();
await expect(page.getByRole('button', { name: /create|add/i })).toBeVisible();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Read {{entityName}}', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('displays entity details correctly', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}');
await expect(page.getByRole('heading', { name: '{{expectedTitle}}' })).toBeVisible();
await expect(page.getByText('{{expectedField}}')).toBeVisible();
});
test('displays list of entities with correct count', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s');
const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
await expect(rows).toHaveCount({{expectedItemCount}});
});
test('shows 404 for non-existent entity', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/999999');
await expect(page.getByRole('heading', { name: /404|not found/i })).toBeVisible();
});
test('shows empty state when list is empty', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s?filter={{emptyFilter}}');
await expect(page.getByText(/no {{entityName}}s found/i)).toBeVisible();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Detail view | Entity fields rendered correctly |
| List view | Correct row count in table |
| List → detail | Clicking row/link navigates correctly |
| Breadcrumb | Path reflects current location |
| 404 | Non-existent ID shows not-found page |
| Loading → data | Skeleton hidden, data visible after load |
| Empty list | No-results state with call to action |

View File

@@ -0,0 +1,113 @@
# Soft Delete (Archive/Restore) Template
Tests archiving an entity, viewing archived items, and restoring them.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- Active entity: ID `{{entityId}}`, name `{{entityName}}`
- Archived entity: ID `{{archivedEntityId}}`
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Soft Delete — Archive & Restore', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
// Happy path: archive entity
test('archives entity and removes from active list', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}');
await page.getByRole('button', { name: /archive/i }).click();
await page.getByRole('dialog').getByRole('button', { name: /archive|confirm/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/{{entityName}}s');
await expect(page.getByRole('alert')).toContainText(/archived/i);
await expect(page.getByRole('link', { name: '{{entityName}}' })).toBeHidden();
});
// Happy path: archived entity appears in archived view
test('archived entity visible in archived list', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s?status=archived');
await expect(page.getByRole('link', { name: '{{entityName}}' })).toBeVisible();
});
// Happy path: restore archived entity
test('restores archived entity to active list', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s?status=archived');
const row = page.getByRole('row', { name: new RegExp('{{entityName}}') });
await row.getByRole('button', { name: /restore/i }).click();
await expect(page.getByRole('alert')).toContainText(/restored/i);
await expect(row).toBeHidden();
await page.goto('{{baseUrl}}/{{entityName}}s');
await expect(page.getByRole('link', { name: '{{entityName}}' })).toBeVisible();
});
// Happy path: active list does not show archived by default
test('active list does not include archived entities', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s');
await expect(page.getByRole('link', { name: /{{archivedEntityName}}/i })).toBeHidden();
});
// Error case: archived entity cannot be edited
test('archived entity edit button is disabled', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{archivedEntityId}}');
await expect(page.getByRole('button', { name: /edit/i })).toBeDisabled();
await expect(page.getByText(/archived/i)).toBeVisible();
});
// Edge case: permanently delete archived entity
test('permanently deletes archived entity', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{archivedEntityId}}');
await page.getByRole('button', { name: /delete permanently/i }).click();
await page.getByRole('dialog').getByRole('button', { name: /delete permanently/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/{{entityName}}s?status=archived');
await expect(page.getByRole('link', { name: '{{archivedEntityName}}' })).toBeHidden();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Soft Delete — Archive & Restore', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('archives entity and removes from active list', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}');
await page.getByRole('button', { name: /archive/i }).click();
await page.getByRole('dialog').getByRole('button', { name: /archive|confirm/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/{{entityName}}s');
await expect(page.getByRole('link', { name: '{{entityName}}' })).toBeHidden();
});
test('restores archived entity to active list', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s?status=archived');
await page.getByRole('row', { name: new RegExp('{{entityName}}') })
.getByRole('button', { name: /restore/i }).click();
await expect(page.getByRole('alert')).toContainText(/restored/i);
});
test('archived entity edit button is disabled', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{archivedEntityId}}');
await expect(page.getByRole('button', { name: /edit/i })).toBeDisabled();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Archive | Entity moved to archived list, removed from active |
| Archived list | Archived items visible with status=archived filter |
| Restore | Archived entity returned to active list |
| Active list clean | Archived items hidden from default view |
| Edit disabled | Archived entity cannot be edited |
| Permanent delete | Hard-delete of archived entity |

View File

@@ -0,0 +1,129 @@
# Update Entity Template
Tests editing an entity via form and inline edit interactions.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- Existing entity ID: `{{entityId}}`, name: `{{originalEntityName}}`
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Update {{entityName}}', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
// Happy path: edit via form
test('updates entity via edit form', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}/edit');
const nameField = page.getByRole('textbox', { name: /name/i });
await nameField.clear();
await nameField.fill('{{updatedEntityName}}');
await page.getByRole('button', { name: /save|update/i }).click();
await expect(page).toHaveURL(`{{baseUrl}}/{{entityName}}s/{{entityId}}`);
await expect(page.getByRole('heading', { name: '{{updatedEntityName}}' })).toBeVisible();
await expect(page.getByRole('alert')).toContainText(/updated successfully/i);
});
// Happy path: inline edit
test('updates name via inline edit', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}');
await page.getByRole('button', { name: /edit name/i }).click();
const inlineInput = page.getByRole('textbox', { name: /name/i });
await inlineInput.clear();
await inlineInput.fill('{{updatedEntityName}}');
await inlineInput.press('Enter');
await expect(page.getByRole('heading', { name: '{{updatedEntityName}}' })).toBeVisible();
});
// Happy path: edit then navigate away — unsaved changes warning
test('warns before discarding unsaved changes', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}/edit');
await page.getByRole('textbox', { name: /name/i }).fill('unsaved-change');
await page.getByRole('link', { name: /cancel|back/i }).click();
await expect(page.getByRole('dialog', { name: /unsaved changes/i })).toBeVisible();
await page.getByRole('button', { name: /discard/i }).click();
await expect(page).toHaveURL(`{{baseUrl}}/{{entityName}}s/{{entityId}}`);
await expect(page.getByRole('heading', { name: '{{originalEntityName}}' })).toBeVisible();
});
// Error case: clearing required field
test('shows validation error when required field is cleared', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}/edit');
await page.getByRole('textbox', { name: /name/i }).clear();
await page.getByRole('button', { name: /save|update/i }).click();
await expect(page.getByText(/name is required/i)).toBeVisible();
await expect(page).toHaveURL(`{{baseUrl}}/{{entityName}}s/{{entityId}}/edit`);
});
// Error case: conflict (optimistic update failure)
test('handles concurrent edit conflict gracefully', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}/edit');
// Simulate another user modifying the record
await page.request.put(`{{baseUrl}}/api/{{entityName}}s/{{entityId}}`, {
data: { name: 'modified-by-other', version: 999 },
});
await page.getByRole('textbox', { name: /name/i }).fill('my-change');
await page.getByRole('button', { name: /save|update/i }).click();
await expect(page.getByRole('alert')).toContainText(/conflict|modified by another/i);
});
// Edge case: inline edit cancelled with Escape
test('cancels inline edit on Escape key', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}');
await page.getByRole('button', { name: /edit name/i }).click();
await page.getByRole('textbox', { name: /name/i }).fill('should-not-save');
await page.keyboard.press('Escape');
await expect(page.getByRole('heading', { name: '{{originalEntityName}}' })).toBeVisible();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Update {{entityName}}', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('updates entity via edit form', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}/edit');
await page.getByRole('textbox', { name: /name/i }).clear();
await page.getByRole('textbox', { name: /name/i }).fill('{{updatedEntityName}}');
await page.getByRole('button', { name: /save|update/i }).click();
await expect(page.getByRole('heading', { name: '{{updatedEntityName}}' })).toBeVisible();
});
test('shows validation error when required field cleared', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}/edit');
await page.getByRole('textbox', { name: /name/i }).clear();
await page.getByRole('button', { name: /save|update/i }).click();
await expect(page.getByText(/name is required/i)).toBeVisible();
});
test('cancels inline edit on Escape', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}');
await page.getByRole('button', { name: /edit name/i }).click();
await page.getByRole('textbox', { name: /name/i }).fill('nope');
await page.keyboard.press('Escape');
await expect(page.getByRole('heading', { name: '{{originalEntityName}}' })).toBeVisible();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Edit form | Full edit form → save → detail page |
| Inline edit | Click field → type → Enter to save |
| Unsaved changes | Navigation shows discard confirmation |
| Required field | Cleared required field → validation |
| Conflict | Concurrent edit → conflict error |
| Escape cancel | Inline edit cancelled, original value restored |

View File

@@ -0,0 +1,131 @@
# Chart Rendering Template
Tests chart visibility, interactive tooltips, and legend behaviour.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- Dashboard with charts at `{{baseUrl}}/dashboard`
- Chart library: `{{chartLibrary}}` (e.g. Chart.js, Recharts, D3)
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Chart Rendering', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
// Wait for chart container to be visible
await expect(page.getByRole('img', { name: /{{chartName}} chart/i })
.or(page.getByTestId('{{chartTestId}}'))).toBeVisible();
});
// Happy path: chart rendered and visible
test('renders {{chartName}} chart', async ({ page }) => {
const chart = page.getByTestId('{{chartTestId}}');
await expect(chart).toBeVisible();
// Chart has non-zero dimensions
const box = await chart.boundingBox();
expect(box?.width).toBeGreaterThan(0);
expect(box?.height).toBeGreaterThan(0);
});
// Happy path: tooltip shown on hover
test('shows tooltip on data point hover', async ({ page }) => {
const chart = page.getByTestId('{{chartTestId}}');
const box = await chart.boundingBox();
// Hover over the centre of the chart
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2);
await expect(page.getByRole('tooltip')).toBeVisible();
await expect(page.getByRole('tooltip')).toContainText(/\d/);
});
// Happy path: legend visible with correct labels
test('displays chart legend with correct series labels', async ({ page }) => {
const legend = page.getByRole('list', { name: /legend/i });
await expect(legend).toBeVisible();
await expect(legend.getByRole('listitem', { name: '{{seriesName1}}' })).toBeVisible();
await expect(legend.getByRole('listitem', { name: '{{seriesName2}}' })).toBeVisible();
});
// Happy path: clicking legend toggles series visibility
test('toggles series visibility via legend click', async ({ page }) => {
await page.getByRole('button', { name: '{{seriesName1}}' }).click();
// Series hidden — legend item shows struck-through or disabled state
await expect(page.getByRole('button', { name: '{{seriesName1}}' })).toHaveAttribute('aria-pressed', 'false');
});
// Happy path: chart updates when date range changed
test('updates chart when date range filter applied', async ({ page }) => {
const before = await page.getByTestId('{{chartTestId}}').screenshot();
await page.getByRole('combobox', { name: /date range/i }).selectOption('last_7_days');
const after = await page.getByTestId('{{chartTestId}}').screenshot();
expect(Buffer.compare(before, after)).not.toBe(0);
});
// Error case: empty data shows no-data state
test('shows no-data message when chart has no data', async ({ page }) => {
await page.route('{{baseUrl}}/api/chart-data*', route =>
route.fulfill({ status: 200, body: JSON.stringify({ data: [] }) })
);
await page.reload();
const chart = page.getByTestId('{{chartTestId}}');
await expect(chart.getByText(/no data|no results/i)).toBeVisible();
});
// Edge case: chart accessible via aria
test('chart has accessible title and description', async ({ page }) => {
const chart = page.getByTestId('{{chartTestId}}');
await expect(chart.getByRole('img')).toHaveAttribute('aria-label', /{{chartName}}/i);
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Chart Rendering', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('renders chart with non-zero dimensions', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
const chart = page.getByTestId('{{chartTestId}}');
await expect(chart).toBeVisible();
const box = await chart.boundingBox();
expect(box?.width).toBeGreaterThan(0);
expect(box?.height).toBeGreaterThan(0);
});
test('shows tooltip on hover', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
const chart = page.getByTestId('{{chartTestId}}');
const box = await chart.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await expect(page.getByRole('tooltip')).toBeVisible();
});
test('displays legend labels', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await expect(page.getByRole('list', { name: /legend/i })).toBeVisible();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Chart visible | Non-zero bounding box confirmed |
| Tooltip on hover | Tooltip appears with numeric value |
| Legend labels | Series names present in legend |
| Legend toggle | Click hides/shows series |
| Date range update | Chart changes when filter applied |
| No-data state | Empty dataset → no-data message |
| Accessible label | aria-label present on chart element |

View File

@@ -0,0 +1,128 @@
# Dashboard Data Loading Template
Tests loading state, skeleton screens, and data display after fetch.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- Dashboard at `{{baseUrl}}/dashboard`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Dashboard Data Loading', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
// Happy path: skeleton shown then replaced by data
test('shows skeleton during load, then displays data', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
// Skeleton should resolve; real data appears
await expect(page.getByTestId('skeleton')).toBeHidden();
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
await expect(page.getByRole('region', { name: /{{widgetName}}/i })).toBeVisible();
});
// Happy path: all metric cards populated
test('renders metric cards with values', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
const cards = page.getByRole('article', { name: /metric/i });
await expect(cards).toHaveCount({{expectedMetricCount}});
await expect(cards.first().getByText(/\d/)).toBeVisible();
});
// Happy path: data updates on refresh
test('refreshes data when refresh button clicked', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await expect(page.getByTestId('skeleton')).toBeHidden();
const before = await page.getByTestId('{{metricId}}').textContent();
await page.getByRole('button', { name: /refresh/i }).click();
await expect(page.getByTestId('skeleton')).toBeHidden();
// Value may or may not change — just confirm data loads again
await expect(page.getByTestId('{{metricId}}')).toBeVisible();
});
// Error case: shows error state when API fails
test('shows error state when data fetch fails', async ({ page }) => {
await page.route('{{baseUrl}}/api/dashboard*', route =>
route.fulfill({ status: 500, body: JSON.stringify({ error: 'Internal Server Error' }) })
);
await page.goto('{{baseUrl}}/dashboard');
await expect(page.getByRole('alert')).toContainText(/failed to load|error loading/i);
await expect(page.getByRole('button', { name: /retry/i })).toBeVisible();
});
// Error case: retry after failure loads data
test('retries and loads data after error', async ({ page }) => {
let callCount = 0;
await page.route('{{baseUrl}}/api/dashboard*', route => {
callCount++;
if (callCount === 1) return route.fulfill({ status: 500, body: '{}' });
return route.continue();
});
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /retry/i }).click();
await expect(page.getByTestId('skeleton')).toBeHidden();
await expect(page.getByRole('region', { name: /{{widgetName}}/i })).toBeVisible();
});
// Edge case: slow network shows skeleton for duration
test('skeleton persists during slow API response', async ({ page }) => {
await page.route('{{baseUrl}}/api/dashboard*', async route => {
await new Promise(r => setTimeout(r, 2000));
await route.continue();
});
await page.goto('{{baseUrl}}/dashboard');
await expect(page.getByTestId('skeleton')).toBeVisible();
await expect(page.getByTestId('skeleton')).toBeHidden(); // eventually resolves
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Dashboard Data Loading', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('renders metric cards after loading', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await expect(page.getByTestId('skeleton')).toBeHidden();
await expect(page.getByRole('article', { name: /metric/i }).first()).toBeVisible();
});
test('shows error state on API failure', async ({ page }) => {
await page.route('{{baseUrl}}/api/dashboard*', route =>
route.fulfill({ status: 500, body: '{}' })
);
await page.goto('{{baseUrl}}/dashboard');
await expect(page.getByRole('alert')).toContainText(/failed to load|error/i);
await expect(page.getByRole('button', { name: /retry/i })).toBeVisible();
});
test('skeleton visible during slow response', async ({ page }) => {
await page.route('{{baseUrl}}/api/dashboard*', async route => {
await new Promise(r => setTimeout(r, 1500));
await route.continue();
});
await page.goto('{{baseUrl}}/dashboard');
await expect(page.getByTestId('skeleton')).toBeVisible();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Skeleton → data | Loading state resolves to populated widgets |
| Metric cards | N cards each showing a numeric value |
| Refresh | Data reloaded on button click |
| API error | Error alert + retry button shown |
| Retry success | Second request succeeds after failure |
| Slow network | Skeleton persists during delay |

View File

@@ -0,0 +1,136 @@
# Date Range Filter Template
Tests date picker interaction, preset ranges, and data refresh on selection.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- Dashboard at `{{baseUrl}}/dashboard`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Date Range Filter', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
});
// Happy path: preset range — last 7 days
test('applies "last 7 days" preset', async ({ page }) => {
await page.getByRole('button', { name: /date range/i }).click();
await page.getByRole('option', { name: /last 7 days/i }).click();
await expect(page).toHaveURL(/from=|start_date=/);
await expect(page.getByRole('button', { name: /date range/i })).toContainText(/last 7 days/i);
});
// Happy path: preset range — last 30 days
test('applies "last 30 days" preset', async ({ page }) => {
await page.getByRole('button', { name: /date range/i }).click();
await page.getByRole('option', { name: /last 30 days/i }).click();
await expect(page.getByRole('button', { name: /date range/i })).toContainText(/last 30 days/i);
await expect(page.getByTestId('skeleton')).toBeHidden();
});
// Happy path: custom date range via date picker
test('applies custom date range from picker', async ({ page }) => {
await page.getByRole('button', { name: /date range/i }).click();
await page.getByRole('option', { name: /custom/i }).click();
const picker = page.getByRole('dialog', { name: /date range/i });
await expect(picker).toBeVisible();
// Select start date
await picker.getByRole('button', { name: '{{startDay}}' }).click();
// Select end date
await picker.getByRole('button', { name: '{{endDay}}' }).click();
await picker.getByRole('button', { name: /apply/i }).click();
await expect(picker).toBeHidden();
await expect(page.getByRole('button', { name: /date range/i })).toContainText('{{startDateFormatted}}');
});
// Happy path: data reloads on range change
test('reloads dashboard data on date range change', async ({ page }) => {
let requestCount = 0;
await page.route('{{baseUrl}}/api/dashboard*', route => {
requestCount++;
return route.continue();
});
await page.getByRole('button', { name: /date range/i }).click();
await page.getByRole('option', { name: /last 7 days/i }).click();
expect(requestCount).toBeGreaterThan(0);
await expect(page.getByTestId('skeleton')).toBeHidden();
});
// Error case: invalid custom range (end before start)
test('shows error when end date is before start date', async ({ page }) => {
await page.getByRole('button', { name: /date range/i }).click();
await page.getByRole('option', { name: /custom/i }).click();
const picker = page.getByRole('dialog', { name: /date range/i });
await picker.getByRole('button', { name: '{{endDay}}' }).click(); // pick later date first
await picker.getByRole('button', { name: '{{startDay}}' }).click(); // then earlier
await expect(picker.getByText(/end.*after.*start|invalid.*range/i)).toBeVisible();
await expect(picker.getByRole('button', { name: /apply/i })).toBeDisabled();
});
// Edge case: range persists after page reload
test('date range persists in URL after reload', async ({ page }) => {
await page.getByRole('button', { name: /date range/i }).click();
await page.getByRole('option', { name: /last 7 days/i }).click();
const url = page.url();
await page.reload();
await expect(page).toHaveURL(url);
await expect(page.getByRole('button', { name: /date range/i })).toContainText(/last 7 days/i);
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Date Range Filter', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('applies last-7-days preset', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /date range/i }).click();
await page.getByRole('option', { name: /last 7 days/i }).click();
await expect(page.getByRole('button', { name: /date range/i })).toContainText(/last 7 days/i);
});
test('shows error for invalid range', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /date range/i }).click();
await page.getByRole('option', { name: /custom/i }).click();
const picker = page.getByRole('dialog', { name: /date range/i });
await picker.getByRole('button', { name: '{{endDay}}' }).click();
await picker.getByRole('button', { name: '{{startDay}}' }).click();
await expect(picker.getByRole('button', { name: /apply/i })).toBeDisabled();
});
test('range persists after page reload', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /date range/i }).click();
await page.getByRole('option', { name: /last 7 days/i }).click();
const url = page.url();
await page.reload();
await expect(page).toHaveURL(url);
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Last 7 days | Preset applied, URL updated |
| Last 30 days | Preset applied, data refreshed |
| Custom range | Date picker → start + end → apply |
| Data reload | API called again on range change |
| Invalid range | End before start → apply disabled |
| URL persistence | Range in URL survives reload |

View File

@@ -0,0 +1,146 @@
# Export Template
Tests CSV and PDF export, download triggering, and file verification.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- Dashboard or report page at `{{baseUrl}}/{{reportPath}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
import path from 'path';
import fs from 'fs';
test.describe('Export', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{reportPath}}');
});
// Happy path: CSV download
test('downloads CSV export', async ({ page }) => {
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: /export.*csv|download.*csv/i }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/\.csv$/);
const filePath = path.join('/tmp', download.suggestedFilename());
await download.saveAs(filePath);
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).toContain('{{expectedCsvHeader}}');
expect(content.split('\n').length).toBeGreaterThan(1);
});
// Happy path: PDF download
test('downloads PDF export', async ({ page }) => {
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: /export.*pdf|download.*pdf/i }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/\.pdf$/);
const filePath = path.join('/tmp', download.suggestedFilename());
await download.saveAs(filePath);
const buffer = fs.readFileSync(filePath);
// PDF magic bytes
expect(buffer.slice(0, 4).toString()).toBe('%PDF');
});
// Happy path: export with current filters applied
test('export respects active date range filter', async ({ page }) => {
await page.getByRole('button', { name: /date range/i }).click();
await page.getByRole('option', { name: /last 7 days/i }).click();
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: /export.*csv/i }).click();
const download = await downloadPromise;
const filePath = path.join('/tmp', download.suggestedFilename());
await download.saveAs(filePath);
const content = fs.readFileSync(filePath, 'utf-8');
expect(content.split('\n').length).toBeGreaterThan(1);
});
// Happy path: export loading indicator
test('shows loading state during export generation', async ({ page }) => {
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: /export.*csv/i }).click();
await expect(page.getByRole('button', { name: /export.*csv/i })).toBeDisabled();
await downloadPromise;
await expect(page.getByRole('button', { name: /export.*csv/i })).toBeEnabled();
});
// Error case: export fails with server error
test('shows error when export generation fails', async ({ page }) => {
await page.route('{{baseUrl}}/api/export*', route =>
route.fulfill({ status: 500, body: JSON.stringify({ error: 'Export failed' }) })
);
await page.getByRole('button', { name: /export.*csv/i }).click();
await expect(page.getByRole('alert')).toContainText(/export failed|could not generate/i);
});
// Edge case: export with no data shows warning
test('shows warning when exporting empty dataset', async ({ page }) => {
await page.route('{{baseUrl}}/api/{{reportEndpoint}}*', route =>
route.fulfill({ status: 200, body: JSON.stringify({ data: [] }) })
);
await page.reload();
await page.getByRole('button', { name: /export.*csv/i }).click();
await expect(page.getByRole('alert')).toContainText(/no data to export|empty/i);
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
const path = require('path');
const fs = require('fs');
test.describe('Export', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('downloads CSV export with correct header', async ({ page }) => {
await page.goto('{{baseUrl}}/{{reportPath}}');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: /export.*csv/i }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/\.csv$/);
const filePath = path.join('/tmp', download.suggestedFilename());
await download.saveAs(filePath);
expect(fs.readFileSync(filePath, 'utf-8')).toContain('{{expectedCsvHeader}}');
});
test('downloads PDF with correct magic bytes', async ({ page }) => {
await page.goto('{{baseUrl}}/{{reportPath}}');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: /export.*pdf/i }).click();
const download = await downloadPromise;
const filePath = path.join('/tmp', download.suggestedFilename());
await download.saveAs(filePath);
expect(fs.readFileSync(filePath).slice(0, 4).toString()).toBe('%PDF');
});
test('shows error when export fails', async ({ page }) => {
await page.goto('{{baseUrl}}/{{reportPath}}');
await page.route('{{baseUrl}}/api/export*', route =>
route.fulfill({ status: 500, body: '{}' })
);
await page.getByRole('button', { name: /export.*csv/i }).click();
await expect(page.getByRole('alert')).toContainText(/export failed/i);
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| CSV download | File downloaded, header row verified |
| PDF download | File downloaded, %PDF magic bytes checked |
| Filtered export | Active filters applied to exported data |
| Loading state | Button disabled during generation |
| Server error | Export failure → error alert |
| Empty dataset | No-data warning shown |

View File

@@ -0,0 +1,143 @@
# Realtime Updates Template
Tests live data via WebSocket or polling, connection handling, and reconnection.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- Dashboard with live data at `{{baseUrl}}/dashboard`
- WebSocket endpoint: `{{wsEndpoint}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Realtime Updates', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
// Happy path: live metric updates via WebSocket
test('updates metric when WebSocket message received', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await expect(page.getByTestId('{{metricId}}')).toBeVisible();
// Inject a WS message to simulate server push
await page.evaluate(() => {
const ws = (window as any).__dashboardWs;
if (ws) ws.dispatchEvent(new MessageEvent('message', {
data: JSON.stringify({ type: 'metric_update', id: '{{metricId}}', value: 9999 })
}));
});
await expect(page.getByTestId('{{metricId}}')).toContainText('9,999');
});
// Happy path: connection status indicator
test('shows "connected" status indicator', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await expect(page.getByRole('status', { name: /live|connected/i })).toBeVisible();
});
// Happy path: data highlighted on update
test('highlights updated value briefly', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.evaluate(() => {
const ws = (window as any).__dashboardWs;
if (ws) ws.dispatchEvent(new MessageEvent('message', {
data: JSON.stringify({ type: 'metric_update', id: '{{metricId}}', value: 42 })
}));
});
await expect(page.getByTestId('{{metricId}}')).toHaveClass(/updated|flash/);
// Highlight fades
await expect(page.getByTestId('{{metricId}}')).not.toHaveClass(/updated|flash/);
});
// Error case: WebSocket disconnected — shows offline indicator
test('shows disconnected state when WebSocket closes', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.evaluate(() => {
const ws = (window as any).__dashboardWs;
if (ws) ws.close();
});
await expect(page.getByRole('status', { name: /disconnected|offline/i })).toBeVisible();
await expect(page.getByText(/reconnecting/i)).toBeVisible();
});
// Error case: connection refused — error state shown
test('shows connection error when WebSocket cannot connect', async ({ page }) => {
await page.route('**/{{wsEndpoint}}', route => route.abort());
await page.goto('{{baseUrl}}/dashboard');
await expect(page.getByRole('alert')).toContainText(/connection.*failed|live updates.*unavailable/i);
});
// Edge case: reconnects automatically after disconnect
test('reconnects automatically after network interruption', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.evaluate(() => {
const ws = (window as any).__dashboardWs;
if (ws) ws.close();
});
await expect(page.getByRole('status', { name: /disconnected/i })).toBeVisible();
// Wait for auto-reconnect
await expect(page.getByRole('status', { name: /connected|live/i })).toBeVisible();
});
// Edge case: stale data badge shown when disconnected
test('shows stale data warning when disconnected', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.evaluate(() => {
const ws = (window as any).__dashboardWs;
if (ws) ws.close();
});
await expect(page.getByText(/data may be outdated|stale/i)).toBeVisible();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Realtime Updates', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('shows connected status on load', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await expect(page.getByRole('status', { name: /live|connected/i })).toBeVisible();
});
test('shows disconnected state when WS closes', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.evaluate(() => {
const ws = window.__dashboardWs;
if (ws) ws.close();
});
await expect(page.getByRole('status', { name: /disconnected|offline/i })).toBeVisible();
});
test('updates metric on WS message', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.evaluate(() => {
const ws = window.__dashboardWs;
if (ws) ws.dispatchEvent(new MessageEvent('message', {
data: JSON.stringify({ type: 'metric_update', id: '{{metricId}}', value: 9999 })
}));
});
await expect(page.getByTestId('{{metricId}}')).toContainText('9,999');
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Live update | WS message updates metric value |
| Connected status | Status indicator shows "live" |
| Update highlight | Changed value briefly highlighted |
| Disconnected | WS close → disconnected indicator |
| Connection refused | WS blocked → error alert |
| Auto-reconnect | Reconnects after close |
| Stale data | Warning shown while disconnected |

View File

@@ -0,0 +1,135 @@
# Autosave Template
Tests auto-save draft functionality and draft restoration on revisit.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- Form with autosave at `{{baseUrl}}/{{formPath}}`
- Autosave interval: `{{autosaveIntervalMs}}` ms
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Autosave', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{formPath}}');
});
// Happy path: autosave indicator appears after typing
test('shows autosave indicator after typing', async ({ page }) => {
await page.getByRole('textbox', { name: /{{fieldLabel}}/i }).fill('{{draftContent}}');
await page.clock.install();
await page.clock.fastForward({{autosaveIntervalMs}});
await expect(page.getByText(/saved|draft saved/i)).toBeVisible();
});
// Happy path: draft restored on revisit
test('restores draft on page revisit', async ({ page }) => {
await page.getByRole('textbox', { name: /{{fieldLabel}}/i }).fill('{{draftContent}}');
await page.clock.install();
await page.clock.fastForward({{autosaveIntervalMs}});
await expect(page.getByText(/draft saved/i)).toBeVisible();
// Simulate revisit
await page.reload();
await expect(page.getByRole('textbox', { name: /{{fieldLabel}}/i })).toHaveValue('{{draftContent}}');
await expect(page.getByText(/draft restored|you have a saved draft/i)).toBeVisible();
});
// Happy path: restore draft via banner
test('restores draft when user clicks restore', async ({ page }) => {
await page.reload();
const banner = page.getByRole('alert', { name: /saved draft/i });
if (await banner.isVisible()) {
await banner.getByRole('button', { name: /restore/i }).click();
await expect(page.getByRole('textbox', { name: /{{fieldLabel}}/i })).toHaveValue('{{draftContent}}');
}
});
// Happy path: dismiss draft banner discards old draft
test('discards draft when user clicks dismiss', async ({ page }) => {
await page.reload();
const banner = page.getByRole('alert', { name: /saved draft/i });
if (await banner.isVisible()) {
await banner.getByRole('button', { name: /dismiss|discard/i }).click();
await expect(banner).toBeHidden();
await expect(page.getByRole('textbox', { name: /{{fieldLabel}}/i })).toHaveValue('');
}
});
// Happy path: draft cleared after successful submit
test('clears autosaved draft after form submission', async ({ page }) => {
await page.getByRole('textbox', { name: /{{fieldLabel}}/i }).fill('{{draftContent}}');
await page.clock.install();
await page.clock.fastForward({{autosaveIntervalMs}});
await page.getByRole('button', { name: /submit|save/i }).click();
await expect(page.getByRole('alert')).toContainText(/submitted|saved/i);
// Revisit — no draft banner
await page.goto('{{baseUrl}}/{{formPath}}');
await expect(page.getByRole('alert', { name: /saved draft/i })).toBeHidden();
});
// Error case: autosave fails silently and retries
test('shows autosave error when network fails', async ({ page }) => {
await page.route('{{baseUrl}}/api/drafts*', route => route.abort('failed'));
await page.getByRole('textbox', { name: /{{fieldLabel}}/i }).fill('{{draftContent}}');
await page.clock.install();
await page.clock.fastForward({{autosaveIntervalMs}});
await expect(page.getByText(/save failed|could not save/i)).toBeVisible();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Autosave', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('shows autosave indicator after interval', async ({ page }) => {
await page.goto('{{baseUrl}}/{{formPath}}');
await page.getByRole('textbox', { name: /{{fieldLabel}}/i }).fill('{{draftContent}}');
await page.clock.install();
await page.clock.fastForward({{autosaveIntervalMs}});
await expect(page.getByText(/draft saved/i)).toBeVisible();
});
test('restores draft on page revisit', async ({ page }) => {
await page.goto('{{baseUrl}}/{{formPath}}');
await page.getByRole('textbox', { name: /{{fieldLabel}}/i }).fill('{{draftContent}}');
await page.clock.install();
await page.clock.fastForward({{autosaveIntervalMs}});
await page.reload();
await expect(page.getByRole('textbox', { name: /{{fieldLabel}}/i })).toHaveValue('{{draftContent}}');
});
test('clears draft after successful submit', async ({ page }) => {
await page.goto('{{baseUrl}}/{{formPath}}');
await page.getByRole('textbox', { name: /{{fieldLabel}}/i }).fill('{{draftContent}}');
await page.clock.install();
await page.clock.fastForward({{autosaveIntervalMs}});
await page.getByRole('button', { name: /submit/i }).click();
await page.goto('{{baseUrl}}/{{formPath}}');
await expect(page.getByRole('alert', { name: /saved draft/i })).toBeHidden();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Autosave indicator | "Draft saved" shown after interval |
| Draft restored | Revisit → field pre-filled |
| Restore via banner | Banner restore button populates field |
| Dismiss draft | Discard clears saved value |
| Cleared on submit | No draft banner after successful submit |
| Network failure | Save-failed message shown |

View File

@@ -0,0 +1,120 @@
# Conditional Fields Template
Tests show/hide fields based on selection and correct validation of visible fields only.
## Prerequisites
- Form at `{{baseUrl}}/{{formPath}}`
- Trigger field: `{{triggerField}}` (e.g. country, type selector)
- Conditional field shown when value is `{{triggerValue}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Conditional Fields', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{formPath}}');
});
// Happy path: conditional field shown on trigger
test('shows conditional field when trigger value selected', async ({ page }) => {
await expect(page.getByRole('textbox', { name: /{{conditionalFieldLabel}}/i })).toBeHidden();
await page.getByRole('combobox', { name: /{{triggerField}}/i }).selectOption('{{triggerValue}}');
await expect(page.getByRole('textbox', { name: /{{conditionalFieldLabel}}/i })).toBeVisible();
});
// Happy path: conditional field hidden when trigger changes
test('hides conditional field when trigger changes back', async ({ page }) => {
await page.getByRole('combobox', { name: /{{triggerField}}/i }).selectOption('{{triggerValue}}');
await expect(page.getByRole('textbox', { name: /{{conditionalFieldLabel}}/i })).toBeVisible();
await page.getByRole('combobox', { name: /{{triggerField}}/i }).selectOption('{{nonTriggerValue}}');
await expect(page.getByRole('textbox', { name: /{{conditionalFieldLabel}}/i })).toBeHidden();
});
// Happy path: form submits with conditional field filled
test('submits form when conditional field is shown and filled', async ({ page }) => {
await page.getByRole('combobox', { name: /{{triggerField}}/i }).selectOption('{{triggerValue}}');
await page.getByRole('textbox', { name: /{{conditionalFieldLabel}}/i }).fill('{{conditionalFieldValue}}');
await page.getByRole('button', { name: /submit/i }).click();
await expect(page.getByRole('alert')).toContainText(/submitted|saved/i);
});
// Error case: conditional field required when visible
test('validates conditional field when it is visible', async ({ page }) => {
await page.getByRole('combobox', { name: /{{triggerField}}/i }).selectOption('{{triggerValue}}');
await page.getByRole('button', { name: /submit/i }).click();
await expect(page.getByText(/{{conditionalFieldLabel}}.*required/i)).toBeVisible();
});
// Error case: hidden field not validated
test('does not validate conditional field when hidden', async ({ page }) => {
await page.getByRole('combobox', { name: /{{triggerField}}/i }).selectOption('{{nonTriggerValue}}');
await page.getByRole('button', { name: /submit/i }).click();
await expect(page.getByText(/{{conditionalFieldLabel}}.*required/i)).toBeHidden();
});
// Edge case: conditional field value cleared when hidden
test('clears conditional field value when field is hidden', async ({ page }) => {
await page.getByRole('combobox', { name: /{{triggerField}}/i }).selectOption('{{triggerValue}}');
await page.getByRole('textbox', { name: /{{conditionalFieldLabel}}/i }).fill('some value');
await page.getByRole('combobox', { name: /{{triggerField}}/i }).selectOption('{{nonTriggerValue}}');
// Re-show and verify value is cleared
await page.getByRole('combobox', { name: /{{triggerField}}/i }).selectOption('{{triggerValue}}');
await expect(page.getByRole('textbox', { name: /{{conditionalFieldLabel}}/i })).toHaveValue('');
});
// Edge case: radio trigger shows/hides field
test('shows field based on radio button selection', async ({ page }) => {
await page.getByRole('radio', { name: '{{radioTriggerLabel}}' }).check();
await expect(page.getByRole('textbox', { name: /{{conditionalFieldLabel}}/i })).toBeVisible();
await page.getByRole('radio', { name: '{{radioOtherLabel}}' }).check();
await expect(page.getByRole('textbox', { name: /{{conditionalFieldLabel}}/i })).toBeHidden();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Conditional Fields', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{formPath}}');
});
test('shows conditional field when trigger selected', async ({ page }) => {
await expect(page.getByRole('textbox', { name: /{{conditionalFieldLabel}}/i })).toBeHidden();
await page.getByRole('combobox', { name: /{{triggerField}}/i }).selectOption('{{triggerValue}}');
await expect(page.getByRole('textbox', { name: /{{conditionalFieldLabel}}/i })).toBeVisible();
});
test('validates visible conditional field on submit', async ({ page }) => {
await page.getByRole('combobox', { name: /{{triggerField}}/i }).selectOption('{{triggerValue}}');
await page.getByRole('button', { name: /submit/i }).click();
await expect(page.getByText(/{{conditionalFieldLabel}}.*required/i)).toBeVisible();
});
test('does not validate hidden conditional field', async ({ page }) => {
await page.getByRole('combobox', { name: /{{triggerField}}/i }).selectOption('{{nonTriggerValue}}');
await page.getByRole('button', { name: /submit/i }).click();
await expect(page.getByText(/{{conditionalFieldLabel}}.*required/i)).toBeHidden();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Show on trigger | Selecting value reveals hidden field |
| Hide on change | Changing back hides field again |
| Submit with field | Visible field filled → success |
| Validate visible | Visible empty field → required error |
| Skip hidden | Hidden field not validated |
| Clear on hide | Value cleared when field hidden |
| Radio trigger | Radio button controls field visibility |

View File

@@ -0,0 +1,136 @@
# File Upload Template
Tests single file, multiple files, drag-and-drop, and upload progress.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- Test files available: `{{testFilePath}}`, `{{largeFilePath}}`
- Accepted types: `{{acceptedMimeTypes}}` (e.g. image/jpeg, application/pdf)
- Max file size: `{{maxFileSizeMb}}` MB
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
import path from 'path';
const testFile = path.resolve('{{testFilePath}}');
const largeFile = path.resolve('{{largeFilePath}}');
test.describe('File Upload', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{uploadPath}}');
});
// Happy path: single file upload
test('uploads a single file successfully', async ({ page }) => {
await page.getByRole('button', { name: /choose file|browse/i }).setInputFiles(testFile);
await expect(page.getByText(/{{testFileName}}/)).toBeVisible();
await page.getByRole('button', { name: /upload/i }).click();
await expect(page.getByRole('progressbar')).toBeVisible();
await expect(page.getByRole('alert')).toContainText(/upload.*complete|uploaded successfully/i);
});
// Happy path: multiple files
test('uploads multiple files', async ({ page }) => {
const input = page.getByRole('button', { name: /choose file|browse/i });
await input.setInputFiles([testFile, testFile]);
await expect(page.getByText(/2 files?|{{testFileName}}/i)).toBeVisible();
await page.getByRole('button', { name: /upload/i }).click();
await expect(page.getByRole('alert')).toContainText(/2.*uploaded/i);
});
// Happy path: drag and drop
test('uploads file via drag and drop', async ({ page }) => {
const dropzone = page.getByRole('region', { name: /drop.*files|drag.*here/i });
await expect(dropzone).toBeVisible();
// Use DataTransfer to simulate drag-drop
const dataTransfer = await page.evaluateHandle(() => new DataTransfer());
await dropzone.dispatchEvent('drop', { dataTransfer });
// Alternatively, use setInputFiles on the hidden input if dropzone wraps one
await page.locator('input[type="file"]').setInputFiles(testFile);
await expect(page.getByText(/{{testFileName}}/)).toBeVisible();
});
// Happy path: remove file from queue before upload
test('removes file from queue', async ({ page }) => {
await page.locator('input[type="file"]').setInputFiles(testFile);
await page.getByRole('button', { name: /remove.*{{testFileName}}|×/i }).click();
await expect(page.getByText(/{{testFileName}}/)).toBeHidden();
});
// Error case: file too large
test('shows error for oversized file', async ({ page }) => {
await page.locator('input[type="file"]').setInputFiles(largeFile);
await expect(page.getByText(/too large|exceeds.*{{maxFileSizeMb}}|max.*size/i)).toBeVisible();
});
// Error case: wrong file type
test('shows error for unsupported file type', async ({ page }) => {
const wrongTypeFile = { name: 'test.exe', mimeType: 'application/octet-stream', buffer: Buffer.from('data') };
await page.locator('input[type="file"]').setInputFiles(wrongTypeFile);
await expect(page.getByText(/unsupported.*type|{{acceptedMimeTypes}}.*only/i)).toBeVisible();
});
// Edge case: upload progress shown and completed
test('shows progress bar during upload', async ({ page }) => {
await page.locator('input[type="file"]').setInputFiles(testFile);
await page.getByRole('button', { name: /upload/i }).click();
const progress = page.getByRole('progressbar');
await expect(progress).toBeVisible();
await expect(progress).toBeHidden(); // completes and hides
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
const path = require('path');
test.describe('File Upload', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{uploadPath}}');
});
test('uploads single file', async ({ page }) => {
await page.locator('input[type="file"]').setInputFiles('{{testFilePath}}');
await page.getByRole('button', { name: /upload/i }).click();
await expect(page.getByRole('alert')).toContainText(/uploaded successfully/i);
});
test('shows error for oversized file', async ({ page }) => {
await page.locator('input[type="file"]').setInputFiles('{{largeFilePath}}');
await expect(page.getByText(/too large|exceeds/i)).toBeVisible();
});
test('shows error for wrong file type', async ({ page }) => {
await page.locator('input[type="file"]').setInputFiles({
name: 'bad.exe',
mimeType: 'application/octet-stream',
buffer: Buffer.from('x'),
});
await expect(page.getByText(/unsupported.*type/i)).toBeVisible();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Single file | File picker → upload → success |
| Multiple files | Two files queued and uploaded |
| Drag-and-drop | Drop event populates queue |
| Remove from queue | File removed before upload |
| Oversized | Error shown, upload blocked |
| Wrong type | Mime-type error shown |
| Progress bar | Progressbar visible during upload |

View File

@@ -0,0 +1,137 @@
# Multi-Step Form (Wizard) Template
Tests wizard step navigation, validation per step, and final submission.
## Prerequisites
- Form wizard at `{{baseUrl}}/{{wizardPath}}`
- Steps: Step 1 (personal), Step 2 (details), Step 3 (review)
---
## TypeScript
```typescript
import { test, expect, Page } from '@playwright/test';
async function completeStep1(page: Page): Promise<void> {
await page.getByRole('textbox', { name: /first name/i }).fill('{{firstName}}');
await page.getByRole('textbox', { name: /last name/i }).fill('{{lastName}}');
await page.getByRole('textbox', { name: /email/i }).fill('{{email}}');
await page.getByRole('button', { name: /next/i }).click();
}
async function completeStep2(page: Page): Promise<void> {
await page.getByRole('combobox', { name: /{{step2Field}}/i }).selectOption('{{step2Value}}');
await page.getByRole('textbox', { name: /{{step2TextField}}/i }).fill('{{step2TextValue}}');
await page.getByRole('button', { name: /next/i }).click();
}
test.describe('Multi-Step Form', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{wizardPath}}');
});
// Happy path: complete all steps
test('completes all wizard steps and submits', async ({ page }) => {
await expect(page.getByText(/step 1/i)).toBeVisible();
await completeStep1(page);
await expect(page.getByText(/step 2/i)).toBeVisible();
await completeStep2(page);
await expect(page.getByText(/review|step 3/i)).toBeVisible();
// Review page shows entered data
await expect(page.getByText('{{firstName}}')).toBeVisible();
await page.getByRole('button', { name: /submit|finish/i }).click();
await expect(page).toHaveURL(/\/{{successPath}}/);
});
// Happy path: step indicator updates
test('step indicator reflects current step', async ({ page }) => {
const step1 = page.getByRole('listitem', { name: /step 1/i });
await expect(step1).toHaveAttribute('aria-current', 'step');
await completeStep1(page);
const step2 = page.getByRole('listitem', { name: /step 2/i });
await expect(step2).toHaveAttribute('aria-current', 'step');
});
// Happy path: back navigation
test('navigates back to previous step without losing data', async ({ page }) => {
await completeStep1(page);
await page.getByRole('button', { name: /back|previous/i }).click();
await expect(page.getByRole('textbox', { name: /first name/i })).toHaveValue('{{firstName}}');
});
// Happy path: completed steps accessible via indicator
test('clicking completed step in indicator navigates back', async ({ page }) => {
await completeStep1(page);
await page.getByRole('button', { name: /step 1/i }).click();
await expect(page.getByRole('textbox', { name: /first name/i })).toBeVisible();
});
// Error case: cannot proceed with invalid step 1 data
test('stays on step 1 when required field missing', async ({ page }) => {
await page.getByRole('button', { name: /next/i }).click();
await expect(page.getByText(/first name.*required|required/i)).toBeVisible();
await expect(page.getByText(/step 1/i)).toBeVisible();
});
// Error case: future step not accessible directly
test('cannot access step 3 without completing step 2', async ({ page }) => {
await expect(page.getByRole('button', { name: /step 3/i })).toBeDisabled();
});
// Edge case: browser back button handled
test('browser back from step 2 returns to step 1 with data', async ({ page }) => {
await completeStep1(page);
await page.goBack();
await expect(page.getByRole('textbox', { name: /first name/i })).toHaveValue('{{firstName}}');
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Multi-Step Form', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{wizardPath}}');
});
test('completes all wizard steps and submits', async ({ page }) => {
await page.getByRole('textbox', { name: /first name/i }).fill('{{firstName}}');
await page.getByRole('textbox', { name: /email/i }).fill('{{email}}');
await page.getByRole('button', { name: /next/i }).click();
await page.getByRole('combobox', { name: /{{step2Field}}/i }).selectOption('{{step2Value}}');
await page.getByRole('button', { name: /next/i }).click();
await page.getByRole('button', { name: /submit|finish/i }).click();
await expect(page).toHaveURL(/\/{{successPath}}/);
});
test('stays on step 1 when required field missing', async ({ page }) => {
await page.getByRole('button', { name: /next/i }).click();
await expect(page.getByText(/required/i)).toBeVisible();
});
test('navigates back without losing data', async ({ page }) => {
await page.getByRole('textbox', { name: /first name/i }).fill('{{firstName}}');
await page.getByRole('textbox', { name: /email/i }).fill('{{email}}');
await page.getByRole('button', { name: /next/i }).click();
await page.getByRole('button', { name: /back|previous/i }).click();
await expect(page.getByRole('textbox', { name: /first name/i })).toHaveValue('{{firstName}}');
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Full completion | All steps filled → submit → success URL |
| Step indicator | aria-current updates per step |
| Back navigation | Data preserved on back |
| Completed step click | Step indicator link works |
| Validation | Required field blocks Next |
| Locked future step | Step 3 button disabled until step 2 done |
| Browser back | History navigation preserves data |

View File

@@ -0,0 +1,124 @@
# Single-Step Form Template
Tests simple form submission with success and validation scenarios.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- Form at `{{baseUrl}}/{{formPath}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Single-Step Form — {{formName}}', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{formPath}}');
});
// Happy path: successful submission
test('submits form with valid data', async ({ page }) => {
await page.getByRole('textbox', { name: /{{field1Label}}/i }).fill('{{field1Value}}');
await page.getByRole('textbox', { name: /{{field2Label}}/i }).fill('{{field2Value}}');
await page.getByRole('combobox', { name: /{{field3Label}}/i }).selectOption('{{field3Value}}');
await page.getByRole('button', { name: /submit|save/i }).click();
await expect(page.getByRole('alert')).toContainText(/submitted|saved successfully/i);
});
// Happy path: success redirect
test('redirects to success page after submission', async ({ page }) => {
await page.getByRole('textbox', { name: /{{field1Label}}/i }).fill('{{field1Value}}');
await page.getByRole('button', { name: /submit|save/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/{{successPath}}');
});
// Happy path: reset clears form
test('reset button clears all fields', async ({ page }) => {
await page.getByRole('textbox', { name: /{{field1Label}}/i }).fill('some value');
await page.getByRole('button', { name: /reset|clear/i }).click();
await expect(page.getByRole('textbox', { name: /{{field1Label}}/i })).toHaveValue('');
});
// Error case: required field missing
test('shows required field error', async ({ page }) => {
await page.getByRole('button', { name: /submit|save/i }).click();
await expect(page.getByText(/{{field1Label}}.*required|required/i)).toBeVisible();
await expect(page.getByRole('textbox', { name: /{{field1Label}}/i })).toBeFocused();
});
// Error case: invalid email format
test('shows format error for invalid email', async ({ page }) => {
await page.getByRole('textbox', { name: /email/i }).fill('not-an-email');
await page.getByRole('button', { name: /submit|save/i }).click();
await expect(page.getByText(/valid.*email|invalid.*email/i)).toBeVisible();
});
// Error case: server error on submit
test('shows generic error when server returns 500', async ({ page }) => {
await page.route('{{baseUrl}}/api/{{formEndpoint}}', route =>
route.fulfill({ status: 500, body: JSON.stringify({ error: 'Server Error' }) })
);
await page.getByRole('textbox', { name: /{{field1Label}}/i }).fill('{{field1Value}}');
await page.getByRole('button', { name: /submit|save/i }).click();
await expect(page.getByRole('alert')).toContainText(/error|something went wrong/i);
});
// Edge case: double submit prevented
test('disables submit button after first click', async ({ page }) => {
await page.getByRole('textbox', { name: /{{field1Label}}/i }).fill('{{field1Value}}');
const btn = page.getByRole('button', { name: /submit|save/i });
await btn.click();
await expect(btn).toBeDisabled();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Single-Step Form — {{formName}}', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{formPath}}');
});
test('submits form with valid data', async ({ page }) => {
await page.getByRole('textbox', { name: /{{field1Label}}/i }).fill('{{field1Value}}');
await page.getByRole('textbox', { name: /{{field2Label}}/i }).fill('{{field2Value}}');
await page.getByRole('button', { name: /submit|save/i }).click();
await expect(page.getByRole('alert')).toContainText(/submitted|saved/i);
});
test('shows required error for empty submission', async ({ page }) => {
await page.getByRole('button', { name: /submit|save/i }).click();
await expect(page.getByText(/required/i)).toBeVisible();
});
test('disables submit after click (prevents double submit)', async ({ page }) => {
await page.getByRole('textbox', { name: /{{field1Label}}/i }).fill('{{field1Value}}');
const btn = page.getByRole('button', { name: /submit|save/i });
await btn.click();
await expect(btn).toBeDisabled();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Valid submit | All fields filled → success message |
| Success redirect | Navigates to success URL |
| Reset | All fields cleared |
| Required field | Empty submit → first error focused |
| Invalid email | Format error shown |
| Server 500 | Generic error alert |
| Double submit | Button disabled after first click |

View File

@@ -0,0 +1,141 @@
# Form Validation Template
Tests required fields, format validation, and inline error messages.
## Prerequisites
- Form at `{{baseUrl}}/{{formPath}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Form Validation', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{formPath}}');
});
// Happy path: all errors resolved on re-submit
test('clears errors when valid data entered', async ({ page }) => {
await page.getByRole('button', { name: /submit/i }).click();
await expect(page.getByText(/required/i)).toBeVisible();
await page.getByRole('textbox', { name: /{{requiredField}}/i }).fill('{{validValue}}');
await page.getByRole('button', { name: /submit/i }).click();
await expect(page.getByText(/required/i)).toBeHidden();
});
// Error case: required fields
test('shows required error for each empty required field', async ({ page }) => {
await page.getByRole('button', { name: /submit/i }).click();
const requiredErrors = page.getByText(/is required|required field/i);
await expect(requiredErrors.first()).toBeVisible();
});
// Error case: invalid email format
test('shows error for invalid email format', async ({ page }) => {
await page.getByRole('textbox', { name: /email/i }).fill('bad@');
await page.getByRole('textbox', { name: /email/i }).blur();
await expect(page.getByText(/valid.*email|enter.*valid email/i)).toBeVisible();
});
// Error case: invalid phone format
test('shows error for invalid phone number', async ({ page }) => {
await page.getByRole('textbox', { name: /phone/i }).fill('123');
await page.getByRole('textbox', { name: /phone/i }).blur();
await expect(page.getByText(/valid.*phone|invalid phone/i)).toBeVisible();
});
// Error case: password too short
test('shows error when password is too short', async ({ page }) => {
await page.getByRole('textbox', { name: /^password$/i }).fill('abc');
await page.getByRole('textbox', { name: /^password$/i }).blur();
await expect(page.getByText(/at least \d+ characters/i)).toBeVisible();
});
// Error case: passwords do not match
test('shows error when confirm password does not match', async ({ page }) => {
await page.getByRole('textbox', { name: /^password$/i }).fill('{{validPassword}}');
await page.getByRole('textbox', { name: /confirm password/i }).fill('different');
await page.getByRole('textbox', { name: /confirm password/i }).blur();
await expect(page.getByText(/passwords.*do not match/i)).toBeVisible();
});
// Error case: inline error on blur (not on submit)
test('shows inline error on blur for invalid value', async ({ page }) => {
await page.getByRole('textbox', { name: /email/i }).fill('invalid');
await page.getByRole('textbox', { name: /email/i }).blur();
// Error shown immediately, not waiting for submit
await expect(page.getByText(/valid.*email/i)).toBeVisible();
});
// Error case: field-level errors tied to field via aria-describedby
test('error message is associated with field via aria', async ({ page }) => {
await page.getByRole('button', { name: /submit/i }).click();
const emailField = page.getByRole('textbox', { name: /email/i });
const errorId = await emailField.getAttribute('aria-describedby');
expect(errorId).toBeTruthy();
await expect(page.locator(`#${errorId}`)).toBeVisible();
});
// Edge case: field max-length validation
test('shows error when input exceeds max length', async ({ page }) => {
await page.getByRole('textbox', { name: /{{field}}/i }).fill('A'.repeat({{maxLength}} + 1));
await page.getByRole('textbox', { name: /{{field}}/i }).blur();
await expect(page.getByText(/max.*{{maxLength}}|too long/i)).toBeVisible();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Form Validation', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{formPath}}');
});
test('shows required errors on empty submit', async ({ page }) => {
await page.getByRole('button', { name: /submit/i }).click();
await expect(page.getByText(/is required|required field/i).first()).toBeVisible();
});
test('shows error for invalid email on blur', async ({ page }) => {
await page.getByRole('textbox', { name: /email/i }).fill('bad@');
await page.getByRole('textbox', { name: /email/i }).blur();
await expect(page.getByText(/valid.*email/i)).toBeVisible();
});
test('passwords mismatch error shown', async ({ page }) => {
await page.getByRole('textbox', { name: /^password$/i }).fill('{{validPassword}}');
await page.getByRole('textbox', { name: /confirm password/i }).fill('other');
await page.getByRole('textbox', { name: /confirm password/i }).blur();
await expect(page.getByText(/do not match/i)).toBeVisible();
});
test('clears errors when valid data entered', async ({ page }) => {
await page.getByRole('button', { name: /submit/i }).click();
await page.getByRole('textbox', { name: /{{requiredField}}/i }).fill('{{validValue}}');
await page.getByRole('button', { name: /submit/i }).click();
await expect(page.getByText(/required/i)).toBeHidden();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Error cleared | Valid input → errors removed on next submit |
| Required fields | Empty submit → at least one required error |
| Email format | Blur with bad email → inline error |
| Phone format | Invalid phone → inline error |
| Password length | Too short → character count error |
| Password match | Mismatch → confirmation error |
| Blur validation | Error shown on blur, not just submit |
| aria-describedby | Error programmatically linked to field |
| Max length | Exceeded length → error shown |

View File

@@ -0,0 +1,125 @@
# In-App Notifications Template
Tests notification badge count, dropdown, and mark-as-read behaviour.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- At least `{{unreadCount}}` unread notifications seeded
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('In-App Notifications', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
// Happy path: badge shows unread count
test('shows unread notification count badge', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await expect(page.getByRole('status', { name: /notification.*count/i }))
.toContainText('{{unreadCount}}');
});
// Happy path: dropdown opens on bell click
test('opens notification dropdown on bell click', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /notifications/i }).click();
await expect(page.getByRole('menu', { name: /notifications/i })).toBeVisible();
const items = page.getByRole('menuitem');
await expect(items.first()).toBeVisible();
});
// Happy path: mark single notification as read
test('marks notification as read', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /notifications/i }).click();
const firstNotif = page.getByRole('menuitem').first();
await firstNotif.getByRole('button', { name: /mark as read/i }).click();
await expect(firstNotif).toHaveAttribute('aria-label', /read/i);
// Badge count decremented
await expect(page.getByRole('status', { name: /notification.*count/i }))
.toContainText(`${{{unreadCount}} - 1}`);
});
// Happy path: mark all as read
test('marks all notifications as read', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /notifications/i }).click();
await page.getByRole('button', { name: /mark all.*read/i }).click();
await expect(page.getByRole('status', { name: /notification.*count/i })).toBeHidden();
});
// Happy path: clicking notification navigates to context
test('clicking notification navigates to relevant page', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /notifications/i }).click();
await page.getByRole('menuitem').first().click();
await expect(page).toHaveURL(/\/{{notificationTargetPath}}/);
});
// Error case: notification dropdown empty state
test('shows empty state when no notifications', async ({ page }) => {
await page.route('{{baseUrl}}/api/notifications*', route =>
route.fulfill({ status: 200, body: JSON.stringify({ items: [], unread: 0 }) })
);
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /notifications/i }).click();
await expect(page.getByText(/no notifications|all caught up/i)).toBeVisible();
});
// Edge case: dropdown closes on outside click
test('closes notification dropdown on outside click', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /notifications/i }).click();
await expect(page.getByRole('menu', { name: /notifications/i })).toBeVisible();
await page.getByRole('heading', { name: /dashboard/i }).click();
await expect(page.getByRole('menu', { name: /notifications/i })).toBeHidden();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('In-App Notifications', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('badge shows unread count', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await expect(page.getByRole('status', { name: /notification.*count/i }))
.toContainText('{{unreadCount}}');
});
test('opens dropdown on bell click', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /notifications/i }).click();
await expect(page.getByRole('menu', { name: /notifications/i })).toBeVisible();
});
test('marks all as read clears badge', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /notifications/i }).click();
await page.getByRole('button', { name: /mark all.*read/i }).click();
await expect(page.getByRole('status', { name: /notification.*count/i })).toBeHidden();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Badge count | Unread count shown in badge |
| Dropdown open | Bell click → notification list |
| Mark single read | Item marked, badge decremented |
| Mark all read | Badge hidden |
| Notification click | Navigates to context page |
| Empty state | No-notifications message |
| Outside click | Dropdown closes |

View File

@@ -0,0 +1,128 @@
# Notification Center Template
Tests full notification list, filtering, and bulk clear.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- Mix of read/unread notifications seeded
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Notification Center', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/notifications');
});
// Happy path: notification list visible
test('displays notification list', async ({ page }) => {
await expect(page.getByRole('heading', { name: /notifications/i })).toBeVisible();
await expect(page.getByRole('list', { name: /notifications/i })).toBeVisible();
const items = page.getByRole('listitem');
await expect(items.first()).toBeVisible();
});
// Happy path: filter by unread
test('filters to show only unread notifications', async ({ page }) => {
await page.getByRole('button', { name: /unread/i }).click();
const items = page.getByRole('listitem');
const count = await items.count();
for (let i = 0; i < count; i++) {
await expect(items.nth(i)).toHaveAttribute('aria-label', /unread/i);
}
});
// Happy path: filter by type
test('filters notifications by type', async ({ page }) => {
await page.getByRole('combobox', { name: /type|category/i }).selectOption('{{notificationType}}');
const items = page.getByRole('listitem');
await expect(items.first()).toContainText(/{{notificationTypeLabel}}/i);
});
// Happy path: mark single as read
test('marks individual notification as read', async ({ page }) => {
const first = page.getByRole('listitem').first();
await first.getByRole('button', { name: /mark.*read/i }).click();
await expect(first).not.toHaveAttribute('data-unread', 'true');
});
// Happy path: clear all notifications
test('clears all notifications', async ({ page }) => {
await page.getByRole('button', { name: /clear all/i }).click();
await page.getByRole('dialog').getByRole('button', { name: /confirm/i }).click();
await expect(page.getByText(/no notifications|all cleared/i)).toBeVisible();
await expect(page.getByRole('listitem')).toHaveCount(0);
});
// Happy path: pagination / load more
test('loads more notifications on scroll or button click', async ({ page }) => {
const initialCount = await page.getByRole('listitem').count();
await page.getByRole('button', { name: /load more/i }).click();
const newCount = await page.getByRole('listitem').count();
expect(newCount).toBeGreaterThan(initialCount);
});
// Error case: empty state after clearing
test('shows empty state after clearing all', async ({ page }) => {
await page.getByRole('button', { name: /clear all/i }).click();
await page.getByRole('dialog').getByRole('button', { name: /confirm/i }).click();
await expect(page.getByText(/no notifications/i)).toBeVisible();
});
// Edge case: notification links to source
test('clicking notification navigates to source', async ({ page }) => {
await page.getByRole('listitem').first().getByRole('link').click();
await expect(page).not.toHaveURL('{{baseUrl}}/notifications');
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Notification Center', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('displays notification list', async ({ page }) => {
await page.goto('{{baseUrl}}/notifications');
await expect(page.getByRole('list', { name: /notifications/i })).toBeVisible();
await expect(page.getByRole('listitem').first()).toBeVisible();
});
test('filters to unread only', async ({ page }) => {
await page.goto('{{baseUrl}}/notifications');
await page.getByRole('button', { name: /unread/i }).click();
await expect(page.getByRole('listitem').first()).toHaveAttribute('aria-label', /unread/i);
});
test('clears all notifications', async ({ page }) => {
await page.goto('{{baseUrl}}/notifications');
await page.getByRole('button', { name: /clear all/i }).click();
await page.getByRole('dialog').getByRole('button', { name: /confirm/i }).click();
await expect(page.getByText(/no notifications/i)).toBeVisible();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| List display | Notification items visible |
| Unread filter | Only unread items shown |
| Type filter | Category filter scopes list |
| Mark single read | Item marked, styling changes |
| Clear all | Confirmation → empty state |
| Load more | Additional items appended |
| Empty state | No-notifications message post-clear |
| Source link | Click navigates away from center |

View File

@@ -0,0 +1,139 @@
# Toast Messages Template
Tests success, error, and warning toasts with auto-dismiss and manual close.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Toast Messages', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
// Happy path: success toast on action
test('shows success toast after save action', async ({ page }) => {
await page.goto('{{baseUrl}}/{{formPath}}');
await page.getByRole('textbox', { name: /{{fieldLabel}}/i }).fill('{{validValue}}');
await page.getByRole('button', { name: /save/i }).click();
const toast = page.getByRole('alert').filter({ hasText: /saved|success/i });
await expect(toast).toBeVisible();
});
// Happy path: error toast on failure
test('shows error toast when action fails', async ({ page }) => {
await page.route('{{baseUrl}}/api/{{endpoint}}*', route =>
route.fulfill({ status: 500, body: '{}' })
);
await page.goto('{{baseUrl}}/{{formPath}}');
await page.getByRole('button', { name: /save/i }).click();
const toast = page.getByRole('alert').filter({ hasText: /error|failed/i });
await expect(toast).toBeVisible();
});
// Happy path: warning toast shown
test('shows warning toast', async ({ page }) => {
await page.goto('{{baseUrl}}/{{warningTriggerPath}}');
await page.getByRole('button', { name: /{{warningAction}}/i }).click();
const toast = page.getByRole('alert').filter({ hasText: /warning|attention/i });
await expect(toast).toBeVisible();
});
// Happy path: toast auto-dismisses
test('toast auto-dismisses after timeout', async ({ page }) => {
await page.goto('{{baseUrl}}/{{formPath}}');
await page.clock.install();
await page.getByRole('textbox', { name: /{{fieldLabel}}/i }).fill('{{validValue}}');
await page.getByRole('button', { name: /save/i }).click();
const toast = page.getByRole('alert').filter({ hasText: /saved/i });
await expect(toast).toBeVisible();
await page.clock.fastForward({{toastDurationMs}});
await expect(toast).toBeHidden();
});
// Happy path: toast manually dismissed
test('dismisses toast via close button', async ({ page }) => {
await page.goto('{{baseUrl}}/{{formPath}}');
await page.getByRole('button', { name: /save/i }).click();
const toast = page.getByRole('alert').filter({ hasText: /saved/i });
await expect(toast).toBeVisible();
await toast.getByRole('button', { name: /close|dismiss|×/i }).click();
await expect(toast).toBeHidden();
});
// Happy path: multiple toasts stack
test('stacks multiple toasts', async ({ page }) => {
await page.goto('{{baseUrl}}/{{formPath}}');
// Trigger two saves quickly
await page.getByRole('button', { name: /save/i }).click();
await page.getByRole('button', { name: /save/i }).click();
const toasts = page.getByRole('alert');
const count = await toasts.count();
expect(count).toBeGreaterThanOrEqual(2);
});
// Edge case: toast announces to screen readers
test('toast has live region role for accessibility', async ({ page }) => {
await page.goto('{{baseUrl}}/{{formPath}}');
await page.getByRole('button', { name: /save/i }).click();
const toast = page.getByRole('alert').first();
await expect(toast).toBeVisible();
// role="alert" implies aria-live="assertive"
const role = await toast.getAttribute('role');
expect(role).toMatch(/alert|status/);
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Toast Messages', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('shows success toast after save', async ({ page }) => {
await page.goto('{{baseUrl}}/{{formPath}}');
await page.getByRole('textbox', { name: /{{fieldLabel}}/i }).fill('{{validValue}}');
await page.getByRole('button', { name: /save/i }).click();
await expect(page.getByRole('alert').filter({ hasText: /saved|success/i })).toBeVisible();
});
test('toast auto-dismisses after timeout', async ({ page }) => {
await page.goto('{{baseUrl}}/{{formPath}}');
await page.clock.install();
await page.getByRole('button', { name: /save/i }).click();
const toast = page.getByRole('alert').filter({ hasText: /saved/i });
await expect(toast).toBeVisible();
await page.clock.fastForward({{toastDurationMs}});
await expect(toast).toBeHidden();
});
test('dismisses toast via close button', async ({ page }) => {
await page.goto('{{baseUrl}}/{{formPath}}');
await page.getByRole('button', { name: /save/i }).click();
const toast = page.getByRole('alert').filter({ hasText: /saved/i });
await toast.getByRole('button', { name: /close|×/i }).click();
await expect(toast).toBeHidden();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Success toast | Save → green/success alert visible |
| Error toast | 500 → red/error alert visible |
| Warning toast | Trigger action → warning alert |
| Auto-dismiss | Toast hidden after N ms (clock-controlled) |
| Manual dismiss | Close button hides toast |
| Stacked toasts | Multiple alerts visible simultaneously |
| Accessible | role=alert or role=status present |

View File

@@ -0,0 +1,118 @@
# Email Verification Template
Tests email verification link, resend flow, and expired token handling.
## Prerequisites
- Registered but unverified account: `{{unverifiedEmail}}`
- Valid token: `{{verificationToken}}`
- Expired token: `{{expiredVerificationToken}}`
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Email Verification', () => {
// Happy path: valid verification link
test('verifies email with valid token', async ({ page }) => {
await page.goto('{{baseUrl}}/verify-email?token={{verificationToken}}');
await expect(page.getByRole('heading', { name: /email verified|verified/i })).toBeVisible();
await expect(page.getByRole('link', { name: /continue|go to dashboard/i })).toBeVisible();
});
// Happy path: continues to app after verification
test('redirects to dashboard after clicking continue', async ({ page }) => {
await page.goto('{{baseUrl}}/verify-email?token={{verificationToken}}');
await page.getByRole('link', { name: /continue|go to dashboard/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
});
// Happy path: resend verification email
test('resends verification email', async ({ page }) => {
await page.goto('{{baseUrl}}/verify-email/resend');
await page.getByRole('textbox', { name: /email/i }).fill('{{unverifiedEmail}}');
await page.getByRole('button', { name: /resend/i }).click();
await expect(page.getByRole('alert')).toContainText(/sent|check your email/i);
});
// Happy path: verification prompt on login for unverified user
test('shows verification prompt when unverified user logs in', async ({ page }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('textbox', { name: /email/i }).fill('{{unverifiedEmail}}');
await page.getByRole('textbox', { name: /password/i }).fill('{{password}}');
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page.getByText(/verify.*email|check.*inbox/i)).toBeVisible();
await expect(page.getByRole('button', { name: /resend.*verification/i })).toBeVisible();
});
// Error case: expired token
test('shows error for expired verification token', async ({ page }) => {
await page.goto('{{baseUrl}}/verify-email?token={{expiredVerificationToken}}');
await expect(page.getByRole('heading', { name: /link.*expired|verification.*failed/i })).toBeVisible();
await expect(page.getByRole('link', { name: /resend|request new/i })).toBeVisible();
});
// Error case: invalid token
test('shows error for invalid verification token', async ({ page }) => {
await page.goto('{{baseUrl}}/verify-email?token=invalid-token-xyz');
await expect(page.getByRole('heading', { name: /invalid|failed/i })).toBeVisible();
});
// Edge case: already verified user hitting link
test('shows already verified message for used token', async ({ page }) => {
await page.goto('{{baseUrl}}/verify-email?token={{usedVerificationToken}}');
await expect(page.getByText(/already verified|email.*confirmed/i)).toBeVisible();
await expect(page.getByRole('link', { name: /sign in/i })).toBeVisible();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Email Verification', () => {
test('verifies email with valid token', async ({ page }) => {
await page.goto('{{baseUrl}}/verify-email?token={{verificationToken}}');
await expect(page.getByRole('heading', { name: /email verified/i })).toBeVisible();
});
test('shows error for expired token', async ({ page }) => {
await page.goto('{{baseUrl}}/verify-email?token={{expiredVerificationToken}}');
await expect(page.getByRole('heading', { name: /link.*expired/i })).toBeVisible();
await expect(page.getByRole('link', { name: /resend|request new/i })).toBeVisible();
});
test('resends verification email', async ({ page }) => {
await page.goto('{{baseUrl}}/verify-email/resend');
await page.getByRole('textbox', { name: /email/i }).fill('{{unverifiedEmail}}');
await page.getByRole('button', { name: /resend/i }).click();
await expect(page.getByRole('alert')).toContainText(/sent/i);
});
test('shows verification prompt on login for unverified user', async ({ page }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('textbox', { name: /email/i }).fill('{{unverifiedEmail}}');
await page.getByRole('textbox', { name: /password/i }).fill('{{password}}');
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page.getByText(/verify.*email/i)).toBeVisible();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Valid token | Email verified heading + continue link |
| Continue CTA | Navigates to dashboard |
| Resend | Sends new email, success alert |
| Login prompt | Unverified login shows resend button |
| Expired token | Error heading + resend link |
| Invalid token | Generic error heading |
| Already verified | "Already verified" with login link |

View File

@@ -0,0 +1,130 @@
# First-Time Setup Template
Tests initial configuration wizard and profile completion after registration.
## Prerequisites
- Newly registered session via `{{newUserStorageStatePath}}`
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('First-Time Setup', () => {
test.use({ storageState: '{{newUserStorageStatePath}}' });
// Happy path: setup wizard shown on first login
test('shows setup wizard on first login', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await expect(page).toHaveURL(/\/setup|\/onboarding/);
await expect(page.getByRole('heading', { name: /set up.*account|get started/i })).toBeVisible();
});
// Happy path: complete organisation setup step
test('completes organisation details step', async ({ page }) => {
await page.goto('{{baseUrl}}/setup');
await page.getByRole('textbox', { name: /organisation.*name|company/i }).fill('{{orgName}}');
await page.getByRole('combobox', { name: /industry/i }).selectOption('{{industry}}');
await page.getByRole('spinbutton', { name: /team size/i }).fill('{{teamSize}}');
await page.getByRole('button', { name: /next|continue/i }).click();
await expect(page.getByText(/step 2|preferences/i)).toBeVisible();
});
// Happy path: complete preferences step
test('completes preferences step', async ({ page }) => {
await page.goto('{{baseUrl}}/setup/preferences');
await page.getByRole('combobox', { name: /timezone/i }).selectOption('{{timezone}}');
await page.getByRole('combobox', { name: /language/i }).selectOption('{{language}}');
await page.getByRole('button', { name: /next|continue/i }).click();
await expect(page.getByText(/step 3|invite|done/i)).toBeVisible();
});
// Happy path: full wizard completion redirects to dashboard
test('completes all setup steps and lands on dashboard', async ({ page }) => {
await page.goto('{{baseUrl}}/setup');
// Step 1
await page.getByRole('textbox', { name: /organisation.*name/i }).fill('{{orgName}}');
await page.getByRole('button', { name: /next/i }).click();
// Step 2
await page.getByRole('combobox', { name: /timezone/i }).selectOption('{{timezone}}');
await page.getByRole('button', { name: /next/i }).click();
// Final step
await page.getByRole('button', { name: /finish|go to dashboard/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
});
// Happy path: setup completion percentage shown
test('progress indicator updates on each step', async ({ page }) => {
await page.goto('{{baseUrl}}/setup');
await expect(page.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0');
await page.getByRole('textbox', { name: /organisation.*name/i }).fill('{{orgName}}');
await page.getByRole('button', { name: /next/i }).click();
await expect(page.getByRole('progressbar')).not.toHaveAttribute('aria-valuenow', '0');
});
// Error case: required setup field missing
test('shows validation when required field missing', async ({ page }) => {
await page.goto('{{baseUrl}}/setup');
await page.getByRole('button', { name: /next/i }).click();
await expect(page.getByText(/organisation.*required|required/i)).toBeVisible();
});
// Edge case: setup not required on subsequent login
test('skips setup on second login', async ({ page }) => {
// Complete setup
await page.goto('{{baseUrl}}/setup');
await page.getByRole('textbox', { name: /organisation.*name/i }).fill('{{orgName}}');
await page.getByRole('button', { name: /next/i }).click();
await page.getByRole('button', { name: /finish/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
// Reload — setup not re-triggered
await page.reload();
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('First-Time Setup', () => {
test.use({ storageState: '{{newUserStorageStatePath}}' });
test('redirects to setup wizard on first login', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await expect(page).toHaveURL(/\/setup|\/onboarding/);
});
test('shows validation for missing required field', async ({ page }) => {
await page.goto('{{baseUrl}}/setup');
await page.getByRole('button', { name: /next/i }).click();
await expect(page.getByText(/required/i)).toBeVisible();
});
test('completes setup and lands on dashboard', async ({ page }) => {
await page.goto('{{baseUrl}}/setup');
await page.getByRole('textbox', { name: /organisation.*name/i }).fill('{{orgName}}');
await page.getByRole('button', { name: /next/i }).click();
await page.getByRole('button', { name: /finish|go to dashboard/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Setup on first login | Redirected to /setup wizard |
| Org details step | Company name + industry filled |
| Preferences step | Timezone + language selected |
| Full completion | All steps → dashboard |
| Progress bar | Progressbar value updates per step |
| Required field | Empty step blocked with error |
| Skip on re-login | Setup not triggered again |

View File

@@ -0,0 +1,131 @@
# Registration Template
Tests signup form submission, validation, and post-registration flow.
## Prerequisites
- Unique test email for each run: `{{newUserEmail}}`
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
const uniqueEmail = `test+${Date.now()}@example.com`;
test.describe('Registration', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/register');
});
// Happy path: successful registration
test('registers new user with valid data', async ({ page }) => {
await page.getByRole('textbox', { name: /first name/i }).fill('{{firstName}}');
await page.getByRole('textbox', { name: /last name/i }).fill('{{lastName}}');
await page.getByRole('textbox', { name: /email/i }).fill(uniqueEmail);
await page.getByRole('textbox', { name: /^password$/i }).fill('{{newPassword}}');
await page.getByRole('textbox', { name: /confirm.*password/i }).fill('{{newPassword}}');
await page.getByRole('checkbox', { name: /terms/i }).check();
await page.getByRole('button', { name: /sign up|register|create account/i }).click();
await expect(page).toHaveURL(/\/verify-email|\/dashboard|\/onboarding/);
});
// Happy path: success message or redirect
test('shows confirmation after registration', async ({ page }) => {
await page.getByRole('textbox', { name: /email/i }).fill(uniqueEmail);
await page.getByRole('textbox', { name: /^password$/i }).fill('{{newPassword}}');
await page.getByRole('checkbox', { name: /terms/i }).check();
await page.getByRole('button', { name: /sign up|register/i }).click();
await expect(page.getByText(/check your email|account created|welcome/i)).toBeVisible();
});
// Error case: email already registered
test('shows error for already registered email', async ({ page }) => {
await page.getByRole('textbox', { name: /email/i }).fill('{{existingUserEmail}}');
await page.getByRole('textbox', { name: /^password$/i }).fill('{{newPassword}}');
await page.getByRole('checkbox', { name: /terms/i }).check();
await page.getByRole('button', { name: /sign up|register/i }).click();
await expect(page.getByRole('alert')).toContainText(/already.*registered|email.*taken/i);
});
// Error case: terms not accepted
test('blocks registration if terms not accepted', async ({ page }) => {
await page.getByRole('textbox', { name: /email/i }).fill(uniqueEmail);
await page.getByRole('textbox', { name: /^password$/i }).fill('{{newPassword}}');
await page.getByRole('button', { name: /sign up|register/i }).click();
await expect(page.getByText(/accept.*terms|terms.*required/i)).toBeVisible();
});
// Error case: weak password
test('shows error for weak password', async ({ page }) => {
await page.getByRole('textbox', { name: /^password$/i }).fill('123');
await page.getByRole('textbox', { name: /^password$/i }).blur();
await expect(page.getByText(/at least \d+ characters|too weak/i)).toBeVisible();
});
// Error case: passwords mismatch
test('shows error when passwords do not match', async ({ page }) => {
await page.getByRole('textbox', { name: /^password$/i }).fill('{{newPassword}}');
await page.getByRole('textbox', { name: /confirm.*password/i }).fill('different');
await page.getByRole('textbox', { name: /confirm.*password/i }).blur();
await expect(page.getByText(/do not match/i)).toBeVisible();
});
// Edge case: already logged-in user redirected
test('redirects to dashboard when already authenticated', async ({ page, context }) => {
await context.addCookies([{ name: '{{sessionCookieName}}', value: '{{validSession}}', domain: '{{cookieDomain}}', path: '/' }]);
await page.goto('{{baseUrl}}/register');
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Registration', () => {
test('registers with valid data', async ({ page }) => {
const email = `test+${Date.now()}@example.com`;
await page.goto('{{baseUrl}}/register');
await page.getByRole('textbox', { name: /email/i }).fill(email);
await page.getByRole('textbox', { name: /^password$/i }).fill('{{newPassword}}');
await page.getByRole('checkbox', { name: /terms/i }).check();
await page.getByRole('button', { name: /sign up|register/i }).click();
await expect(page).toHaveURL(/\/verify-email|\/dashboard|\/onboarding/);
});
test('shows error for existing email', async ({ page }) => {
await page.goto('{{baseUrl}}/register');
await page.getByRole('textbox', { name: /email/i }).fill('{{existingUserEmail}}');
await page.getByRole('textbox', { name: /^password$/i }).fill('{{newPassword}}');
await page.getByRole('checkbox', { name: /terms/i }).check();
await page.getByRole('button', { name: /sign up|register/i }).click();
await expect(page.getByRole('alert')).toContainText(/already.*registered/i);
});
test('requires terms acceptance', async ({ page }) => {
await page.goto('{{baseUrl}}/register');
await page.getByRole('textbox', { name: /email/i }).fill(`t${Date.now()}@example.com`);
await page.getByRole('textbox', { name: /^password$/i }).fill('{{newPassword}}');
await page.getByRole('button', { name: /sign up|register/i }).click();
await expect(page.getByText(/accept.*terms/i)).toBeVisible();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Valid registration | All fields → redirect or success message |
| Confirmation | Email check or welcome shown |
| Existing email | Error alert |
| Terms not accepted | Validation error |
| Weak password | Strength error on blur |
| Password mismatch | Confirm error |
| Already authed | Redirected to dashboard |

View File

@@ -0,0 +1,128 @@
# Welcome Tour Template
Tests step-by-step onboarding tour, skip, and completion behaviour.
## Prerequisites
- Newly registered session (first login) via `{{newUserStorageStatePath}}`
- Tour has `{{tourStepCount}}` steps
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Welcome Tour', () => {
test.use({ storageState: '{{newUserStorageStatePath}}' });
// Happy path: tour shown on first login
test('shows welcome tour on first login', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await expect(page.getByRole('dialog', { name: /welcome|tour/i })).toBeVisible();
await expect(page.getByText(/step 1 of {{tourStepCount}}/i)).toBeVisible();
});
// Happy path: advance through all steps
test('advances through all tour steps', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
for (let i = 1; i <= {{tourStepCount}}; i++) {
await expect(page.getByText(new RegExp(`step ${i} of {{tourStepCount}}`, 'i'))).toBeVisible();
if (i < {{tourStepCount}}) {
await page.getByRole('button', { name: /next/i }).click();
} else {
await page.getByRole('button', { name: /finish|done|get started/i }).click();
}
}
await expect(page.getByRole('dialog', { name: /welcome|tour/i })).toBeHidden();
});
// Happy path: back navigation within tour
test('navigates back to previous step', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /next/i }).click();
await expect(page.getByText(/step 2 of {{tourStepCount}}/i)).toBeVisible();
await page.getByRole('button', { name: /back|previous/i }).click();
await expect(page.getByText(/step 1 of {{tourStepCount}}/i)).toBeVisible();
});
// Happy path: skip tour
test('skips tour and dismisses overlay', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /skip.*tour|skip/i }).click();
await expect(page.getByRole('dialog', { name: /welcome|tour/i })).toBeHidden();
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
});
// Happy path: tour not shown on subsequent logins
test('tour not shown on second login', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
// Complete or skip tour
await page.getByRole('button', { name: /skip.*tour|skip/i }).click();
// Simulate re-login by reloading
await page.reload();
await expect(page.getByRole('dialog', { name: /welcome|tour/i })).toBeHidden();
});
// Happy path: tooltip highlights correct element
test('tour tooltip highlights the correct UI element', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
const tooltip = page.getByRole('tooltip').or(page.getByRole('dialog', { name: /tour/i }));
await expect(tooltip).toBeVisible();
const targetEl = page.getByRole('{{tourStep1TargetRole}}', { name: /{{tourStep1TargetName}}/i });
await expect(targetEl).toBeVisible();
});
// Edge case: close button (×) dismisses tour
test('× button dismisses tour', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('dialog', { name: /welcome|tour/i })
.getByRole('button', { name: /close|×/i }).click();
await expect(page.getByRole('dialog', { name: /welcome|tour/i })).toBeHidden();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Welcome Tour', () => {
test.use({ storageState: '{{newUserStorageStatePath}}' });
test('shows welcome tour on first login', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await expect(page.getByRole('dialog', { name: /welcome|tour/i })).toBeVisible();
});
test('skips tour on button click', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /skip/i }).click();
await expect(page.getByRole('dialog', { name: /tour/i })).toBeHidden();
});
test('advances through all steps to completion', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
for (let i = 1; i < {{tourStepCount}}; i++) {
await page.getByRole('button', { name: /next/i }).click();
}
await page.getByRole('button', { name: /finish|done|get started/i }).click();
await expect(page.getByRole('dialog', { name: /tour/i })).toBeHidden();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Tour on first login | Dialog shown with step 1 of N |
| Full completion | All steps advanced → tour dismissed |
| Back navigation | Previous step accessible |
| Skip tour | Dismissed immediately |
| Not shown again | Tour absent on subsequent visits |
| Tooltip target | Tour highlights correct element |
| Close button | × closes tour |

View File

@@ -0,0 +1,118 @@
# Basic Search Template
Tests search input, query submission, and results display.
## Prerequisites
- At least one indexed item matching `{{searchQuery}}`
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Basic Search', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}');
});
// Happy path: search returns results
test('displays results for valid search query', async ({ page }) => {
await page.getByRole('searchbox', { name: /search/i }).fill('{{searchQuery}}');
await page.getByRole('button', { name: /search/i }).click();
await expect(page).toHaveURL(/[?&]q={{searchQuery}}/);
await expect(page.getByRole('list', { name: /results/i })).toBeVisible();
const results = page.getByRole('listitem').filter({ hasText: /{{searchQuery}}/i });
await expect(results.first()).toBeVisible();
});
// Happy path: search via Enter key
test('submits search on Enter key', async ({ page }) => {
await page.getByRole('searchbox', { name: /search/i }).fill('{{searchQuery}}');
await page.keyboard.press('Enter');
await expect(page).toHaveURL(/[?&]q=/);
await expect(page.getByRole('list', { name: /results/i })).toBeVisible();
});
// Happy path: result count shown
test('shows result count in heading', async ({ page }) => {
await page.getByRole('searchbox', { name: /search/i }).fill('{{searchQuery}}');
await page.getByRole('button', { name: /search/i }).click();
await expect(page.getByText(/\d+\s+results? for/i)).toBeVisible();
});
// Happy path: clicking result navigates to detail
test('clicking result navigates to detail page', async ({ page }) => {
await page.getByRole('searchbox', { name: /search/i }).fill('{{searchQuery}}');
await page.getByRole('button', { name: /search/i }).click();
await page.getByRole('listitem').first().getByRole('link').click();
await expect(page).toHaveURL(/\/{{entityName}}s\/\d+/);
});
// Happy path: query pre-filled from URL
test('pre-fills search box from URL query param', async ({ page }) => {
await page.goto(`{{baseUrl}}/search?q={{searchQuery}}`);
await expect(page.getByRole('searchbox', { name: /search/i })).toHaveValue('{{searchQuery}}');
});
// Error case: no results
test('shows no-results message for unmatched query', async ({ page }) => {
await page.getByRole('searchbox', { name: /search/i }).fill('xyzzy-no-match-12345');
await page.getByRole('button', { name: /search/i }).click();
await expect(page.getByText(/no results|nothing found/i)).toBeVisible();
});
// Edge case: special characters handled safely
test('handles special characters in query', async ({ page }) => {
await page.getByRole('searchbox', { name: /search/i }).fill('<script>alert(1)</script>');
await page.getByRole('button', { name: /search/i }).click();
await expect(page.getByRole('alert')).toBeHidden();
await expect(page.getByText(/no results/i)).toBeVisible();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Basic Search', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}');
});
test('displays results for valid query', async ({ page }) => {
await page.getByRole('searchbox', { name: /search/i }).fill('{{searchQuery}}');
await page.getByRole('button', { name: /search/i }).click();
await expect(page.getByRole('list', { name: /results/i })).toBeVisible();
});
test('shows no-results for unmatched query', async ({ page }) => {
await page.getByRole('searchbox', { name: /search/i }).fill('xyzzy-no-match');
await page.getByRole('button', { name: /search/i }).click();
await expect(page.getByText(/no results|nothing found/i)).toBeVisible();
});
test('submits on Enter key', async ({ page }) => {
await page.getByRole('searchbox', { name: /search/i }).fill('{{searchQuery}}');
await page.keyboard.press('Enter');
await expect(page).toHaveURL(/[?&]q=/);
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Valid query | Results list visible, count shown |
| Enter key | Search submitted without clicking button |
| Result count | Heading shows N results for query |
| Result click | Navigates to entity detail |
| URL pre-fill | Query param populates search box |
| No results | Empty state message |
| Special chars | XSS input handled, no script execution |

View File

@@ -0,0 +1,109 @@
# Empty State Template
Tests no-results messaging and clear-filters behaviour.
## Prerequisites
- App running at `{{baseUrl}}`
- Query that returns no results: `{{emptySearchQuery}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Empty State', () => {
// Happy path: no results message
test('shows no-results message for unmatched query', async ({ page }) => {
await page.goto('{{baseUrl}}/search?q={{emptySearchQuery}}');
await expect(page.getByRole('heading', { name: /no results|nothing found/i })).toBeVisible();
await expect(page.getByText(/try.*different|adjust.*search/i)).toBeVisible();
});
// Happy path: clear filters CTA shown in empty state
test('shows "clear filters" button when filters applied with no results', async ({ page }) => {
await page.goto('{{baseUrl}}/search?q={{searchQuery}}&category={{nonExistentCategory}}');
await expect(page.getByText(/no results/i)).toBeVisible();
await expect(page.getByRole('button', { name: /clear.*filter/i })).toBeVisible();
});
// Happy path: clearing filters restores results
test('clearing filters from empty state restores results', async ({ page }) => {
await page.goto('{{baseUrl}}/search?q={{searchQuery}}&category={{nonExistentCategory}}');
await page.getByRole('button', { name: /clear.*filter/i }).click();
await expect(page.getByRole('listitem').first()).toBeVisible();
await expect(page.getByText(/no results/i)).toBeHidden();
});
// Happy path: search suggestions shown in empty state
test('shows related search suggestions', async ({ page }) => {
await page.goto('{{baseUrl}}/search?q={{emptySearchQuery}}');
const suggestions = page.getByRole('list', { name: /suggestions|similar/i });
if (await suggestions.isVisible()) {
await expect(suggestions.getByRole('listitem').first()).toBeVisible();
}
});
// Happy path: empty list view (not search)
test('shows empty state on entity list with no data', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s?filter={{emptyFilter}}');
await expect(page.getByText(/no {{entityName}}s|empty/i)).toBeVisible();
await expect(page.getByRole('button', { name: /create|add new/i })).toBeVisible();
});
// Error case: network error shows error state not empty state
test('distinguishes network error from no-results', async ({ page }) => {
await page.route('{{baseUrl}}/api/search*', route => route.abort('failed'));
await page.goto('{{baseUrl}}/search?q={{searchQuery}}');
await expect(page.getByText(/error|something went wrong/i)).toBeVisible();
await expect(page.getByText(/no results/i)).toBeHidden();
});
// Edge case: empty state after removing last item
test('shows empty state after deleting last item in list', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s');
const row = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') }).last();
await row.getByRole('button', { name: /delete/i }).click();
await page.getByRole('dialog').getByRole('button', { name: /confirm/i }).click();
await expect(page.getByText(/no {{entityName}}s|empty/i)).toBeVisible();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Empty State', () => {
test('shows no-results message for unmatched query', async ({ page }) => {
await page.goto('{{baseUrl}}/search?q={{emptySearchQuery}}');
await expect(page.getByRole('heading', { name: /no results|nothing found/i })).toBeVisible();
});
test('shows clear-filters button in no-results state', async ({ page }) => {
await page.goto('{{baseUrl}}/search?q={{searchQuery}}&category={{nonExistentCategory}}');
await expect(page.getByRole('button', { name: /clear.*filter/i })).toBeVisible();
});
test('clearing filters restores results', async ({ page }) => {
await page.goto('{{baseUrl}}/search?q={{searchQuery}}&category={{nonExistentCategory}}');
await page.getByRole('button', { name: /clear.*filter/i }).click();
await expect(page.getByRole('listitem').first()).toBeVisible();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| No-results query | Heading + suggestion text shown |
| Filter no-results | Clear-filters CTA displayed |
| Clear filters | Removes filter, results return |
| Search suggestions | Related terms listed when available |
| Empty list view | Entity list empty state with create CTA |
| Network error | Error state distinct from no-results |
| Last item deleted | Empty state shown after deletion |

View File

@@ -0,0 +1,128 @@
# Search Filters Template
Tests category filter, price range, and checkbox filters.
## Prerequisites
- Search results available for `{{searchQuery}}`
- Category `{{filterCategory}}` with items
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Search Filters', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/search?q={{searchQuery}}');
});
// Happy path: category filter
test('filters results by category', async ({ page }) => {
await page.getByRole('checkbox', { name: '{{filterCategory}}' }).check();
await expect(page).toHaveURL(/category={{filterCategory}}/);
const results = page.getByRole('listitem');
await expect(results.first()).toContainText('{{filterCategory}}');
const count = await results.count();
expect(count).toBeGreaterThan(0);
});
// Happy path: price range filter
test('filters results by price range', async ({ page }) => {
const minInput = page.getByRole('spinbutton', { name: /min.*price/i });
const maxInput = page.getByRole('spinbutton', { name: /max.*price/i });
await minInput.fill('{{minPrice}}');
await maxInput.fill('{{maxPrice}}');
await page.getByRole('button', { name: /apply|filter/i }).click();
await expect(page).toHaveURL(/min_price={{minPrice}}/);
// Verify no results exceed max price
const prices = page.getByTestId('item-price');
const priceCount = await prices.count();
for (let i = 0; i < priceCount; i++) {
const text = await prices.nth(i).textContent() ?? '';
const value = parseFloat(text.replace(/[^0-9.]/g, ''));
expect(value).toBeLessThanOrEqual({{maxPrice}});
}
});
// Happy path: multiple checkboxes combine filters
test('applies multiple checkbox filters simultaneously', async ({ page }) => {
await page.getByRole('checkbox', { name: '{{filterOption1}}' }).check();
await page.getByRole('checkbox', { name: '{{filterOption2}}' }).check();
await expect(page).toHaveURL(/{{filterParam1}}.*{{filterParam2}}|{{filterParam2}}.*{{filterParam1}}/);
});
// Happy path: active filters shown as chips
test('shows active filter chips', async ({ page }) => {
await page.getByRole('checkbox', { name: '{{filterCategory}}' }).check();
await expect(page.getByRole('button', { name: /remove.*{{filterCategory}}/i })).toBeVisible();
});
// Happy path: clear individual filter chip
test('removes filter by clicking chip close', async ({ page }) => {
await page.getByRole('checkbox', { name: '{{filterCategory}}' }).check();
await page.getByRole('button', { name: /remove.*{{filterCategory}}/i }).click();
await expect(page.getByRole('checkbox', { name: '{{filterCategory}}' })).not.toBeChecked();
});
// Happy path: clear all filters
test('clears all filters', async ({ page }) => {
await page.getByRole('checkbox', { name: '{{filterCategory}}' }).check();
await page.getByRole('button', { name: /clear all filters/i }).click();
await expect(page.getByRole('checkbox', { name: '{{filterCategory}}' })).not.toBeChecked();
await expect(page).not.toHaveURL(/category=/);
});
// Error case: no results for filter combination
test('shows empty state when filters yield no results', async ({ page }) => {
await page.getByRole('spinbutton', { name: /min.*price/i }).fill('999999');
await page.getByRole('button', { name: /apply|filter/i }).click();
await expect(page.getByText(/no results/i)).toBeVisible();
await expect(page.getByRole('button', { name: /clear.*filter/i })).toBeVisible();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Search Filters', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/search?q={{searchQuery}}');
});
test('filters results by category', async ({ page }) => {
await page.getByRole('checkbox', { name: '{{filterCategory}}' }).check();
await expect(page).toHaveURL(/category={{filterCategory}}/);
await expect(page.getByRole('listitem').first()).toBeVisible();
});
test('shows active filter chips', async ({ page }) => {
await page.getByRole('checkbox', { name: '{{filterCategory}}' }).check();
await expect(page.getByRole('button', { name: /remove.*{{filterCategory}}/i })).toBeVisible();
});
test('clears all filters', async ({ page }) => {
await page.getByRole('checkbox', { name: '{{filterCategory}}' }).check();
await page.getByRole('button', { name: /clear all filters/i }).click();
await expect(page.getByRole('checkbox', { name: '{{filterCategory}}' })).not.toBeChecked();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Category filter | Checkbox → results scoped to category |
| Price range | Min/max filter applied, prices verified |
| Multi-filter | Multiple checkboxes combine in URL |
| Filter chips | Active filters shown as removable chips |
| Remove chip | Chip close → filter unchecked |
| Clear all | All filters removed at once |
| No-results combo | Filter combination yields empty state |

View File

@@ -0,0 +1,123 @@
# Pagination Template
Tests page navigation, items-per-page selector, and URL state.
## Prerequisites
- Search results for `{{searchQuery}}` spanning multiple pages
- At least `{{totalItemCount}}` items total
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Pagination', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/search?q={{searchQuery}}');
});
// Happy path: navigate to next page
test('navigates to next page and updates URL', async ({ page }) => {
const firstItem = await page.getByRole('listitem').first().textContent();
await page.getByRole('button', { name: /next page/i }).click();
await expect(page).toHaveURL(/page=2/);
await expect(page.getByRole('listitem').first()).not.toHaveText(firstItem!);
});
// Happy path: navigate to previous page
test('navigates to previous page', async ({ page }) => {
await page.goto('{{baseUrl}}/search?q={{searchQuery}}&page=2');
const secondPageFirst = await page.getByRole('listitem').first().textContent();
await page.getByRole('button', { name: /previous page/i }).click();
await expect(page).toHaveURL(/page=1/);
await expect(page.getByRole('listitem').first()).not.toHaveText(secondPageFirst!);
});
// Happy path: jump to specific page
test('jumps to specific page number', async ({ page }) => {
await page.getByRole('button', { name: '3' }).click();
await expect(page).toHaveURL(/page=3/);
await expect(page.getByRole('button', { name: '3' })).toHaveAttribute('aria-current', 'page');
});
// Happy path: items per page selector
test('changes items per page', async ({ page }) => {
await page.getByRole('combobox', { name: /per page/i }).selectOption('50');
await expect(page).toHaveURL(/per_page=50/);
const items = page.getByRole('listitem');
await expect(items).toHaveCount(Math.min(50, {{totalItemCount}}));
});
// Happy path: page info text
test('shows correct page info text', async ({ page }) => {
await expect(page.getByText(/showing \d+.+of\s+{{totalItemCount}}/i)).toBeVisible();
});
// Error case: first page has no previous button
test('previous page button disabled on first page', async ({ page }) => {
await expect(page.getByRole('button', { name: /previous page/i })).toBeDisabled();
});
// Error case: last page has no next button
test('next page button disabled on last page', async ({ page }) => {
const lastPage = Math.ceil({{totalItemCount}} / {{defaultPageSize}});
await page.goto(`{{baseUrl}}/search?q={{searchQuery}}&page=${lastPage}`);
await expect(page.getByRole('button', { name: /next page/i })).toBeDisabled();
});
// Edge case: out-of-range page redirects to last page
test('out-of-range page parameter redirects gracefully', async ({ page }) => {
await page.goto('{{baseUrl}}/search?q={{searchQuery}}&page=99999');
await expect(page.getByRole('listitem').first()).toBeVisible();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Pagination', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/search?q={{searchQuery}}');
});
test('navigates to next page', async ({ page }) => {
await page.getByRole('button', { name: /next page/i }).click();
await expect(page).toHaveURL(/page=2/);
});
test('previous page disabled on first page', async ({ page }) => {
await expect(page.getByRole('button', { name: /previous page/i })).toBeDisabled();
});
test('next page disabled on last page', async ({ page }) => {
const last = Math.ceil({{totalItemCount}} / {{defaultPageSize}});
await page.goto(`{{baseUrl}}/search?q={{searchQuery}}&page=${last}`);
await expect(page.getByRole('button', { name: /next page/i })).toBeDisabled();
});
test('changes items per page', async ({ page }) => {
await page.getByRole('combobox', { name: /per page/i }).selectOption('50');
await expect(page).toHaveURL(/per_page=50/);
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Next page | Items change, URL updates page=2 |
| Previous page | Back to page 1 |
| Jump to page | Clicking page number sets aria-current |
| Items per page | Selector changes count of visible items |
| Page info | "Showing X-Y of N" text |
| First page prev | Previous button disabled |
| Last page next | Next button disabled |
| Out-of-range | Graceful fallback |

View File

@@ -0,0 +1,131 @@
# Search Sorting Template
Tests sorting results by name, date, and price.
## Prerequisites
- Search results for `{{searchQuery}}` with multiple items
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Search Sorting', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/search?q={{searchQuery}}');
});
// Happy path: sort by name A-Z
test('sorts results alphabetically A-Z', async ({ page }) => {
await page.getByRole('combobox', { name: /sort by/i }).selectOption('name_asc');
await expect(page).toHaveURL(/sort=name_asc/);
const names = page.getByTestId('result-name');
const first = await names.first().textContent();
const second = await names.nth(1).textContent();
expect(first!.localeCompare(second!)).toBeLessThanOrEqual(0);
});
// Happy path: sort by name Z-A
test('sorts results alphabetically Z-A', async ({ page }) => {
await page.getByRole('combobox', { name: /sort by/i }).selectOption('name_desc');
const names = page.getByTestId('result-name');
const first = await names.first().textContent();
const second = await names.nth(1).textContent();
expect(first!.localeCompare(second!)).toBeGreaterThanOrEqual(0);
});
// Happy path: sort by date newest
test('sorts results by newest date first', async ({ page }) => {
await page.getByRole('combobox', { name: /sort by/i }).selectOption('date_desc');
await expect(page).toHaveURL(/sort=date_desc/);
const dates = page.getByTestId('result-date');
const firstDate = new Date(await dates.first().getAttribute('datetime') ?? '');
const secondDate = new Date(await dates.nth(1).getAttribute('datetime') ?? '');
expect(firstDate.getTime()).toBeGreaterThanOrEqual(secondDate.getTime());
});
// Happy path: sort by price low-high
test('sorts by price low to high', async ({ page }) => {
await page.getByRole('combobox', { name: /sort by/i }).selectOption('price_asc');
const prices = page.getByTestId('result-price');
const firstText = await prices.first().textContent() ?? '';
const secondText = await prices.nth(1).textContent() ?? '';
const first = parseFloat(firstText.replace(/[^0-9.]/g, ''));
const second = parseFloat(secondText.replace(/[^0-9.]/g, ''));
expect(first).toBeLessThanOrEqual(second);
});
// Happy path: sort by price high-low
test('sorts by price high to low', async ({ page }) => {
await page.getByRole('combobox', { name: /sort by/i }).selectOption('price_desc');
const prices = page.getByTestId('result-price');
const firstText = await prices.first().textContent() ?? '';
const secondText = await prices.nth(1).textContent() ?? '';
const first = parseFloat(firstText.replace(/[^0-9.]/g, ''));
const second = parseFloat(secondText.replace(/[^0-9.]/g, ''));
expect(first).toBeGreaterThanOrEqual(second);
});
// Happy path: sort persists with filters
test('sort selection persists when filter applied', async ({ page }) => {
await page.getByRole('combobox', { name: /sort by/i }).selectOption('price_asc');
await page.getByRole('checkbox', { name: '{{filterCategory}}' }).check();
await expect(page).toHaveURL(/sort=price_asc/);
await expect(page.getByRole('combobox', { name: /sort by/i })).toHaveValue('price_asc');
});
// Edge case: default sort is relevance
test('default sort is relevance', async ({ page }) => {
await expect(page.getByRole('combobox', { name: /sort by/i })).toHaveValue('relevance');
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Search Sorting', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/search?q={{searchQuery}}');
});
test('sorts alphabetically A-Z', async ({ page }) => {
await page.getByRole('combobox', { name: /sort by/i }).selectOption('name_asc');
await expect(page).toHaveURL(/sort=name_asc/);
const names = page.getByTestId('result-name');
const first = await names.first().textContent();
const second = await names.nth(1).textContent();
expect(first.localeCompare(second)).toBeLessThanOrEqual(0);
});
test('sorts by price low to high', async ({ page }) => {
await page.getByRole('combobox', { name: /sort by/i }).selectOption('price_asc');
const prices = page.getByTestId('result-price');
const a = parseFloat((await prices.first().textContent()).replace(/[^0-9.]/g, ''));
const b = parseFloat((await prices.nth(1).textContent()).replace(/[^0-9.]/g, ''));
expect(a).toBeLessThanOrEqual(b);
});
test('default sort is relevance', async ({ page }) => {
await expect(page.getByRole('combobox', { name: /sort by/i })).toHaveValue('relevance');
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Name A-Z | First result ≤ second alphabetically |
| Name Z-A | First result ≥ second alphabetically |
| Date newest | Dates in descending order |
| Price low-high | Prices in ascending order |
| Price high-low | Prices in descending order |
| Sort + filter | Sort param persists when filter applied |
| Default sort | Relevance selected by default |

View File

@@ -0,0 +1,136 @@
# Account Delete Template
Tests account deletion flow with confirmation and data warning.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- Disposable test account (deletion is irreversible)
- Settings at `{{baseUrl}}/settings/account`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Account Delete', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/settings/account');
});
// Happy path: delete button opens confirmation
test('clicking delete account shows confirmation dialog', async ({ page }) => {
await page.getByRole('button', { name: /delete.*account/i }).click();
const dialog = page.getByRole('dialog', { name: /delete account/i });
await expect(dialog).toBeVisible();
await expect(dialog).toContainText(/irreversible|cannot be undone/i);
await expect(dialog).toContainText(/{{dataWarningText}}/i);
});
// Happy path: cancel preserves account
test('cancel keeps account intact', async ({ page }) => {
await page.getByRole('button', { name: /delete.*account/i }).click();
await page.getByRole('dialog').getByRole('button', { name: /cancel/i }).click();
await expect(page.getByRole('dialog')).toBeHidden();
await expect(page).toHaveURL('{{baseUrl}}/settings/account');
});
// Happy path: type-to-confirm gates deletion
test('confirm button disabled until account email typed', async ({ page }) => {
await page.getByRole('button', { name: /delete.*account/i }).click();
const dialog = page.getByRole('dialog');
const confirmBtn = dialog.getByRole('button', { name: /delete.*account|confirm/i });
await expect(confirmBtn).toBeDisabled();
await dialog.getByRole('textbox', { name: /type.*email/i }).fill('{{username}}');
await expect(confirmBtn).toBeEnabled();
});
// Happy path: successful deletion redirects to login
test('deletes account and redirects to login', async ({ page }) => {
await page.getByRole('button', { name: /delete.*account/i }).click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('textbox', { name: /type.*email/i }).fill('{{username}}');
await dialog.getByRole('button', { name: /delete.*account|confirm/i }).click();
await expect(page).toHaveURL(/\/login/);
await expect(page.getByText(/account.*deleted|successfully deleted/i)).toBeVisible();
});
// Error case: wrong email in confirmation box
test('shows error when wrong email typed in confirmation', async ({ page }) => {
await page.getByRole('button', { name: /delete.*account/i }).click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('textbox', { name: /type.*email/i }).fill('wrong@email.com');
const confirmBtn = dialog.getByRole('button', { name: /delete.*account|confirm/i });
await expect(confirmBtn).toBeDisabled();
await expect(dialog.getByText(/does not match/i)).toBeVisible();
});
// Error case: deletion fails server-side
test('shows error when account deletion fails', async ({ page }) => {
await page.route('{{baseUrl}}/api/account', route =>
route.fulfill({ status: 500, body: JSON.stringify({ error: 'Deletion failed' }) })
);
await page.getByRole('button', { name: /delete.*account/i }).click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('textbox', { name: /type.*email/i }).fill('{{username}}');
await dialog.getByRole('button', { name: /confirm/i }).click();
await expect(page.getByRole('alert')).toContainText(/failed|error/i);
await expect(page).toHaveURL('{{baseUrl}}/settings/account');
});
// Edge case: data export offered before deletion
test('shows data export option in deletion dialog', async ({ page }) => {
await page.getByRole('button', { name: /delete.*account/i }).click();
await expect(page.getByRole('link', { name: /export.*data|download.*data/i })).toBeVisible();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Account Delete', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('shows confirmation dialog on delete', async ({ page }) => {
await page.goto('{{baseUrl}}/settings/account');
await page.getByRole('button', { name: /delete.*account/i }).click();
await expect(page.getByRole('dialog', { name: /delete account/i })).toBeVisible();
await expect(page.getByRole('dialog')).toContainText(/irreversible/i);
});
test('confirm button disabled until email typed', async ({ page }) => {
await page.goto('{{baseUrl}}/settings/account');
await page.getByRole('button', { name: /delete.*account/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByRole('button', { name: /confirm/i })).toBeDisabled();
await dialog.getByRole('textbox', { name: /type.*email/i }).fill('{{username}}');
await expect(dialog.getByRole('button', { name: /confirm/i })).toBeEnabled();
});
test('cancel preserves account', async ({ page }) => {
await page.goto('{{baseUrl}}/settings/account');
await page.getByRole('button', { name: /delete.*account/i }).click();
await page.getByRole('dialog').getByRole('button', { name: /cancel/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/settings/account');
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Dialog opens | Delete button → confirmation with warning |
| Cancel | Dialog closed, account preserved |
| Type-to-confirm | Button enabled only with correct email |
| Successful delete | Account deleted → /login |
| Wrong email | Input mismatch → button stays disabled |
| Server error | Deletion fails → error alert |
| Data export | Export link offered in dialog |

View File

@@ -0,0 +1,139 @@
# Notification Preferences Template
Tests toggling notification channels and saving preferences.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- Settings page at `{{baseUrl}}/settings/notifications`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Notification Preferences', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/settings/notifications');
});
// Happy path: enable email notifications
test('enables email notifications', async ({ page }) => {
const emailToggle = page.getByRole('switch', { name: /email notifications/i });
if (!(await emailToggle.isChecked())) {
await emailToggle.click();
}
await expect(emailToggle).toBeChecked();
await page.getByRole('button', { name: /save|update/i }).click();
await expect(page.getByRole('alert')).toContainText(/preferences.*saved|updated/i);
});
// Happy path: disable push notifications
test('disables push notifications', async ({ page }) => {
const pushToggle = page.getByRole('switch', { name: /push notifications/i });
if (await pushToggle.isChecked()) {
await pushToggle.click();
}
await expect(pushToggle).not.toBeChecked();
await page.getByRole('button', { name: /save/i }).click();
await expect(page.getByRole('alert')).toContainText(/saved/i);
});
// Happy path: preferences persist after reload
test('saved preferences persist after page reload', async ({ page }) => {
const emailToggle = page.getByRole('switch', { name: /email notifications/i });
const wasChecked = await emailToggle.isChecked();
await emailToggle.click();
await page.getByRole('button', { name: /save/i }).click();
await expect(page.getByRole('alert')).toContainText(/saved/i);
await page.reload();
if (wasChecked) {
await expect(emailToggle).not.toBeChecked();
} else {
await expect(emailToggle).toBeChecked();
}
});
// Happy path: notification frequency selector
test('changes notification frequency', async ({ page }) => {
await page.getByRole('combobox', { name: /frequency|digest/i }).selectOption('{{frequency}}');
await page.getByRole('button', { name: /save/i }).click();
await expect(page.getByRole('alert')).toContainText(/saved/i);
await page.reload();
await expect(page.getByRole('combobox', { name: /frequency|digest/i })).toHaveValue('{{frequency}}');
});
// Error case: save fails — preferences not changed
test('shows error when save fails', async ({ page }) => {
await page.route('{{baseUrl}}/api/settings/notifications*', route =>
route.fulfill({ status: 500, body: JSON.stringify({ error: 'Server error' }) })
);
await page.getByRole('switch', { name: /email notifications/i }).click();
await page.getByRole('button', { name: /save/i }).click();
await expect(page.getByRole('alert')).toContainText(/error|failed to save/i);
});
// Edge case: unsubscribe all shows confirmation
test('shows confirmation before unsubscribing all', async ({ page }) => {
await page.getByRole('button', { name: /unsubscribe all/i }).click();
await expect(page.getByRole('dialog', { name: /unsubscribe/i })).toBeVisible();
await page.getByRole('button', { name: /cancel/i }).click();
// Still subscribed
await expect(page.getByRole('switch', { name: /email notifications/i })).toBeChecked();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Notification Preferences', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('saves notification preferences', async ({ page }) => {
await page.goto('{{baseUrl}}/settings/notifications');
const toggle = page.getByRole('switch', { name: /email notifications/i });
await toggle.click();
await page.getByRole('button', { name: /save/i }).click();
await expect(page.getByRole('alert')).toContainText(/saved/i);
});
test('preferences persist after reload', async ({ page }) => {
await page.goto('{{baseUrl}}/settings/notifications');
const toggle = page.getByRole('switch', { name: /email notifications/i });
const was = await toggle.isChecked();
await toggle.click();
await page.getByRole('button', { name: /save/i }).click();
await page.reload();
was
? await expect(toggle).not.toBeChecked()
: await expect(toggle).toBeChecked();
});
test('shows error when save fails', async ({ page }) => {
await page.goto('{{baseUrl}}/settings/notifications');
await page.route('{{baseUrl}}/api/settings/notifications*', r =>
r.fulfill({ status: 500, body: '{}' })
);
await page.getByRole('button', { name: /save/i }).click();
await expect(page.getByRole('alert')).toContainText(/error|failed/i);
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Enable email | Toggle on → saved → success |
| Disable push | Toggle off → saved |
| Persists reload | Saved state survives page reload |
| Frequency selector | Dropdown value saved and restored |
| Save error | Server error → error alert |
| Unsubscribe all | Confirmation dialog before all disabled |

View File

@@ -0,0 +1,143 @@
# Password Change Template
Tests current password verification, new password validation, and success flow.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- Current password: `{{currentPassword}}`
- New password: `{{newPassword}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Password Change', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/settings/security');
});
// Happy path: successful password change
test('changes password with valid inputs', async ({ page }) => {
await page.getByRole('textbox', { name: /current password/i }).fill('{{currentPassword}}');
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: /change.*password|update password/i }).click();
await expect(page.getByRole('alert')).toContainText(/password.*changed|updated successfully/i);
});
// Happy path: can log in with new password
test('new password accepted on next login', async ({ page, context }) => {
// Change password
await page.getByRole('textbox', { name: /current password/i }).fill('{{currentPassword}}');
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: /change.*password/i }).click();
await expect(page.getByRole('alert')).toContainText(/changed/i);
// Log out and back in
await page.getByRole('button', { name: /user menu/i }).click();
await page.getByRole('menuitem', { name: /sign out/i }).click();
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: wrong current password
test('shows error when current password is wrong', async ({ page }) => {
await page.getByRole('textbox', { name: /current password/i }).fill('wrong-password');
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: /change.*password/i }).click();
await expect(page.getByRole('alert')).toContainText(/current password.*incorrect|wrong password/i);
});
// Error case: new passwords do not match
test('shows error when confirmation does not match', async ({ page }) => {
await page.getByRole('textbox', { name: /current password/i }).fill('{{currentPassword}}');
await page.getByRole('textbox', { name: /^new password$/i }).fill('{{newPassword}}');
await page.getByRole('textbox', { name: /confirm.*password/i }).fill('mismatch');
await page.getByRole('button', { name: /change.*password/i }).click();
await expect(page.getByText(/passwords.*do not match/i)).toBeVisible();
});
// Error case: new password too weak
test('shows strength error for weak new password', async ({ page }) => {
await page.getByRole('textbox', { name: /current password/i }).fill('{{currentPassword}}');
await page.getByRole('textbox', { name: /^new password$/i }).fill('123');
await page.getByRole('textbox', { name: /^new password$/i }).blur();
await expect(page.getByText(/too weak|at least \d+ characters/i)).toBeVisible();
});
// Error case: new password same as current
test('shows error when new password matches current', async ({ page }) => {
await page.getByRole('textbox', { name: /current password/i }).fill('{{currentPassword}}');
await page.getByRole('textbox', { name: /^new password$/i }).fill('{{currentPassword}}');
await page.getByRole('textbox', { name: /confirm.*password/i }).fill('{{currentPassword}}');
await page.getByRole('button', { name: /change.*password/i }).click();
await expect(page.getByText(/same as.*current|choose.*different/i)).toBeVisible();
});
// Edge case: password strength meter updates on input
test('strength meter reacts to new password input', async ({ page }) => {
await page.getByRole('textbox', { name: /^new password$/i }).fill('weak');
await expect(page.getByRole('meter', { name: /strength/i })).toHaveAttribute('aria-valuenow', '1');
await page.getByRole('textbox', { name: /^new password$/i }).fill('Str0ng!Pass#2026');
await expect(page.getByRole('meter', { name: /strength/i })).toHaveAttribute('aria-valuenow', '4');
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Password Change', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('changes password with valid inputs', async ({ page }) => {
await page.goto('{{baseUrl}}/settings/security');
await page.getByRole('textbox', { name: /current password/i }).fill('{{currentPassword}}');
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: /change.*password/i }).click();
await expect(page.getByRole('alert')).toContainText(/changed|updated/i);
});
test('shows error for wrong current password', async ({ page }) => {
await page.goto('{{baseUrl}}/settings/security');
await page.getByRole('textbox', { name: /current password/i }).fill('wrong');
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: /change.*password/i }).click();
await expect(page.getByRole('alert')).toContainText(/incorrect|wrong/i);
});
test('shows mismatch error', async ({ page }) => {
await page.goto('{{baseUrl}}/settings/security');
await page.getByRole('textbox', { name: /current password/i }).fill('{{currentPassword}}');
await page.getByRole('textbox', { name: /^new password$/i }).fill('{{newPassword}}');
await page.getByRole('textbox', { name: /confirm.*password/i }).fill('nope');
await page.getByRole('button', { name: /change.*password/i }).click();
await expect(page.getByText(/do not match/i)).toBeVisible();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Success | All fields valid → success alert |
| Login with new pw | New password accepted at login |
| Wrong current | Incorrect current → error alert |
| Mismatch | Confirm ≠ new → validation error |
| Weak password | Short password → strength error |
| Same as current | Reuse blocked with error |
| Strength meter | Meter aria-valuenow updates on input |

View File

@@ -0,0 +1,130 @@
# Profile Update Template
Tests updating name, email, and avatar in user profile settings.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- Current name: `{{currentName}}`, email: `{{currentEmail}}`
- Test avatar image: `{{avatarFilePath}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Profile Update', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/settings/profile');
});
// Happy path: update display name
test('updates display name', async ({ page }) => {
const nameField = page.getByRole('textbox', { name: /display name|full name/i });
await nameField.clear();
await nameField.fill('{{newName}}');
await page.getByRole('button', { name: /save|update/i }).click();
await expect(page.getByRole('alert')).toContainText(/profile updated|saved/i);
await expect(page.getByRole('textbox', { name: /display name|full name/i })).toHaveValue('{{newName}}');
});
// Happy path: update email
test('updates email address', async ({ page }) => {
const emailField = page.getByRole('textbox', { name: /email/i });
await emailField.clear();
await emailField.fill('{{newEmail}}');
await page.getByRole('button', { name: /save|update/i }).click();
await expect(page.getByRole('alert')).toContainText(/verification.*sent|email updated/i);
});
// Happy path: upload avatar
test('uploads new avatar image', async ({ page }) => {
await page.getByRole('button', { name: /change.*avatar|upload.*photo/i }).click();
await page.locator('input[type="file"]').setInputFiles('{{avatarFilePath}}');
await expect(page.getByRole('img', { name: /avatar preview/i })).toBeVisible();
await page.getByRole('button', { name: /save|apply/i }).click();
await expect(page.getByRole('alert')).toContainText(/avatar updated|photo saved/i);
});
// Happy path: avatar crop dialog
test('shows crop dialog after avatar upload', async ({ page }) => {
await page.locator('input[type="file"]').setInputFiles('{{avatarFilePath}}');
await expect(page.getByRole('dialog', { name: /crop/i })).toBeVisible();
await page.getByRole('button', { name: /apply crop/i }).click();
await expect(page.getByRole('dialog', { name: /crop/i })).toBeHidden();
});
// Error case: invalid email format
test('shows error for invalid email format', async ({ page }) => {
await page.getByRole('textbox', { name: /email/i }).clear();
await page.getByRole('textbox', { name: /email/i }).fill('bad-email');
await page.getByRole('button', { name: /save|update/i }).click();
await expect(page.getByText(/valid.*email/i)).toBeVisible();
});
// Error case: email already taken
test('shows error when email is already in use', async ({ page }) => {
await page.getByRole('textbox', { name: /email/i }).clear();
await page.getByRole('textbox', { name: /email/i }).fill('{{takenEmail}}');
await page.getByRole('button', { name: /save|update/i }).click();
await expect(page.getByRole('alert')).toContainText(/already in use|taken/i);
});
// Edge case: name reflected in nav after update
test('nav shows updated name after save', async ({ page }) => {
const nameField = page.getByRole('textbox', { name: /display name|full name/i });
await nameField.clear();
await nameField.fill('{{newName}}');
await page.getByRole('button', { name: /save|update/i }).click();
await expect(page.getByRole('navigation').getByText('{{newName}}')).toBeVisible();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Profile Update', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('updates display name', async ({ page }) => {
await page.goto('{{baseUrl}}/settings/profile');
await page.getByRole('textbox', { name: /display name|full name/i }).clear();
await page.getByRole('textbox', { name: /display name|full name/i }).fill('{{newName}}');
await page.getByRole('button', { name: /save|update/i }).click();
await expect(page.getByRole('alert')).toContainText(/profile updated|saved/i);
});
test('shows error for invalid email', async ({ page }) => {
await page.goto('{{baseUrl}}/settings/profile');
await page.getByRole('textbox', { name: /email/i }).fill('bad-email');
await page.getByRole('button', { name: /save|update/i }).click();
await expect(page.getByText(/valid.*email/i)).toBeVisible();
});
test('uploads avatar image', async ({ page }) => {
await page.goto('{{baseUrl}}/settings/profile');
await page.locator('input[type="file"]').setInputFiles('{{avatarFilePath}}');
await page.getByRole('button', { name: /save|apply/i }).click();
await expect(page.getByRole('alert')).toContainText(/avatar updated/i);
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Name update | Name saved, field reflects new value |
| Email update | Email saved, verification notice shown |
| Avatar upload | Image uploaded, success alert |
| Crop dialog | Cropper shown, apply saves |
| Invalid email | Format error shown |
| Taken email | Duplicate error shown |
| Nav update | Navigation reflects new name |