Dev (#255)
* docs: restructure README.md — 2,539 → 209 lines (#247) - Cut from 2,539 lines / 73 sections to 209 lines / 18 sections - Consolidated 4 install methods into one unified section - Moved all skill details to domain-level READMEs (linked from table) - Front-loaded value prop and keywords for SEO - Added POWERFUL tier highlight section - Added skill-security-auditor showcase section - Removed stale Q4 2025 roadmap, outdated ROI claims, duplicate content - Fixed all internal links - Clean heading hierarchy (H2 for main sections only) Closes #233 Co-authored-by: Leo <leo@openclaw.ai> * fix: enhance 5 skills with scripts, references, and Anthropic best practices (#248) * fix(skill): enhance git-worktree-manager with scripts, references, and Anthropic best practices * fix(skill): enhance mcp-server-builder with scripts, references, and Anthropic best practices * fix(skill): enhance changelog-generator with scripts, references, and Anthropic best practices * fix(skill): enhance ci-cd-pipeline-builder with scripts, references, and Anthropic best practices * fix(skill): enhance prompt-engineer-toolkit with scripts, references, and Anthropic best practices * docs: update README, CHANGELOG, and plugin metadata * fix: correct marketing plugin count, expand thin references --------- Co-authored-by: Leo <leo@openclaw.ai> * ci: Add VirusTotal security scan for skills (#252) * Dev (#231) * Improve senior-fullstack skill description and workflow validation - Expand frontmatter description with concrete actions and trigger clauses - Add validation steps to scaffolding workflow (verify scaffold succeeded) - Add re-run verification step to audit workflow (confirm P0 fixes) * chore: sync codex skills symlinks [automated] * fix(skill): normalize senior-fullstack frontmatter to inline format Normalize YAML description from block scalar (>) to inline single-line format matching all other 50+ skills. Align frontmatter trigger phrases with the body's Trigger Phrases section to eliminate duplication. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ci): add GITHUB_TOKEN to checkout + restore corrupted skill descriptions - Add token: ${{ secrets.GITHUB_TOKEN }} to actions/checkout@v4 in sync-codex-skills.yml so git-auto-commit-action can push back to branch (fixes: fatal: could not read Username, exit 128) - Restore correct description for incident-commander (was: 'Skill from engineering-team') - Restore correct description for senior-fullstack (was: '>') * fix(ci): pass PROJECTS_TOKEN to fix automated commits + remove duplicate checkout Fixes PROJECTS_TOKEN passthrough for git-auto-commit-action and removes duplicate checkout step in pr-issue-auto-close workflow. * fix(ci): remove stray merge conflict marker in sync-codex-skills.yml (#221) Co-authored-by: Leo <leo@leo-agent-server> * fix(ci): fix workflow errors + add OpenClaw support (#222) * feat: add 20 new practical skills for professional Claude Code users New skills across 5 categories: Engineering (12): - git-worktree-manager: Parallel dev with port isolation & env sync - ci-cd-pipeline-builder: Generate GitHub Actions/GitLab CI from stack analysis - mcp-server-builder: Build MCP servers from OpenAPI specs - changelog-generator: Conventional commits to structured changelogs - pr-review-expert: Blast radius analysis & security scan for PRs - api-test-suite-builder: Auto-generate test suites from API routes - env-secrets-manager: .env management, leak detection, rotation workflows - database-schema-designer: Requirements to migrations & types - codebase-onboarding: Auto-generate onboarding docs from codebase - performance-profiler: Node/Python/Go profiling & optimization - runbook-generator: Operational runbooks from codebase analysis - monorepo-navigator: Turborepo/Nx/pnpm workspace management Engineering Team (2): - stripe-integration-expert: Subscriptions, webhooks, billing patterns - email-template-builder: React Email/MJML transactional email systems Product Team (3): - saas-scaffolder: Full SaaS project generation from product brief - landing-page-generator: High-converting landing pages with copy frameworks - competitive-teardown: Structured competitive product analysis Business Growth (1): - contract-and-proposal-writer: Contracts, SOWs, NDAs per jurisdiction Marketing (1): - prompt-engineer-toolkit: Systematic prompt development & A/B testing Designed for daily professional use and commercial distribution. * chore: sync codex skills symlinks [automated] * docs: update README with 20 new skills, counts 65→86, new skills section * docs: add commercial distribution plan (Stan Store + Gumroad) * docs: rewrite CHANGELOG.md with v2.0.0 release (65 skills, 9 domains) (#226) * docs: rewrite CHANGELOG.md with v2.0.0 release (65 skills, 9 domains) - Consolidate 191 commits since v1.0.2 into proper v2.0.0 entry - Document 12 POWERFUL-tier skills, 37 refactored skills - Add new domains: business-growth, finance - Document Codex support and marketplace integration - Update version history summary table - Clean up [Unreleased] to only planned work * docs: add 24 POWERFUL-tier skills to plugin, fix counts to 85 across all docs - Add engineering-advanced-skills plugin (24 POWERFUL-tier skills) to marketplace.json - Add 13 missing skills to CHANGELOG v2.0.0 (agent-workflow-designer, api-test-suite-builder, changelog-generator, ci-cd-pipeline-builder, codebase-onboarding, database-schema-designer, env-secrets-manager, git-worktree-manager, mcp-server-builder, monorepo-navigator, performance-profiler, pr-review-expert, runbook-generator) - Fix skill count: 86→85 (excl sample-skill) across README, CHANGELOG, marketplace.json - Fix stale 53→85 references in README - Add engineering-advanced-skills install command to README - Update marketplace.json version to 2.0.0 --------- Co-authored-by: Leo <leo@openclaw.ai> * feat: add skill-security-auditor POWERFUL-tier skill (#230) Security audit and vulnerability scanner for AI agent skills before installation. Scans for: - Code execution risks (eval, exec, os.system, subprocess shell injection) - Data exfiltration (outbound HTTP, credential harvesting, env var extraction) - Prompt injection in SKILL.md (system override, role hijack, safety bypass) - Dependency supply chain (typosquatting, unpinned versions, runtime installs) - File system abuse (boundary violations, binaries, symlinks, hidden files) - Privilege escalation (sudo, SUID, cron manipulation, shell config writes) - Obfuscation (base64, hex encoding, chr chains, codecs) Produces clear PASS/WARN/FAIL verdict with per-finding remediation guidance. Supports local dirs, git repo URLs, JSON output, strict mode, and CI/CD integration. Includes: - scripts/skill_security_auditor.py (1049 lines, zero dependencies) - references/threat-model.md (complete attack vector documentation) - SKILL.md with usage guide and report format Tested against: rag-architect (PASS), agent-designer (PASS), senior-secops (FAIL - correctly flagged eval/exec patterns). Co-authored-by: Leo <leo@openclaw.ai> * docs: add skill-security-auditor to marketplace, README, and CHANGELOG - Add standalone plugin entry for skill-security-auditor in marketplace.json - Update engineering-advanced-skills plugin description to include it - Update skill counts: 85→86 across README, CHANGELOG, marketplace - Add install command to README Quick Install section - Add to CHANGELOG [Unreleased] section --------- Co-authored-by: Baptiste Fernandez <fernandez.baptiste1@gmail.com> Co-authored-by: alirezarezvani <5697919+alirezarezvani@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Leo <leo@leo-agent-server> Co-authored-by: Leo <leo@openclaw.ai> * Dev (#249) * docs: restructure README.md — 2,539 → 209 lines (#247) - Cut from 2,539 lines / 73 sections to 209 lines / 18 sections - Consolidated 4 install methods into one unified section - Moved all skill details to domain-level READMEs (linked from table) - Front-loaded value prop and keywords for SEO - Added POWERFUL tier highlight section - Added skill-security-auditor showcase section - Removed stale Q4 2025 roadmap, outdated ROI claims, duplicate content - Fixed all internal links - Clean heading hierarchy (H2 for main sections only) Closes #233 Co-authored-by: Leo <leo@openclaw.ai> * fix: enhance 5 skills with scripts, references, and Anthropic best practices (#248) * fix(skill): enhance git-worktree-manager with scripts, references, and Anthropic best practices * fix(skill): enhance mcp-server-builder with scripts, references, and Anthropic best practices * fix(skill): enhance changelog-generator with scripts, references, and Anthropic best practices * fix(skill): enhance ci-cd-pipeline-builder with scripts, references, and Anthropic best practices * fix(skill): enhance prompt-engineer-toolkit with scripts, references, and Anthropic best practices * docs: update README, CHANGELOG, and plugin metadata * fix: correct marketing plugin count, expand thin references --------- Co-authored-by: Leo <leo@openclaw.ai> --------- Co-authored-by: Leo <leo@openclaw.ai> * Dev (#250) * docs: restructure README.md — 2,539 → 209 lines (#247) - Cut from 2,539 lines / 73 sections to 209 lines / 18 sections - Consolidated 4 install methods into one unified section - Moved all skill details to domain-level READMEs (linked from table) - Front-loaded value prop and keywords for SEO - Added POWERFUL tier highlight section - Added skill-security-auditor showcase section - Removed stale Q4 2025 roadmap, outdated ROI claims, duplicate content - Fixed all internal links - Clean heading hierarchy (H2 for main sections only) Closes #233 Co-authored-by: Leo <leo@openclaw.ai> * fix: enhance 5 skills with scripts, references, and Anthropic best practices (#248) * fix(skill): enhance git-worktree-manager with scripts, references, and Anthropic best practices * fix(skill): enhance mcp-server-builder with scripts, references, and Anthropic best practices * fix(skill): enhance changelog-generator with scripts, references, and Anthropic best practices * fix(skill): enhance ci-cd-pipeline-builder with scripts, references, and Anthropic best practices * fix(skill): enhance prompt-engineer-toolkit with scripts, references, and Anthropic best practices * docs: update README, CHANGELOG, and plugin metadata * fix: correct marketing plugin count, expand thin references --------- Co-authored-by: Leo <leo@openclaw.ai> --------- Co-authored-by: Leo <leo@openclaw.ai> * ci: add VirusTotal security scan for skills - Scans changed skill directories on PRs to dev/main - Scans all skills on release publish - Posts scan results as PR comment with analysis links - Rate-limited to 4 req/min (free tier compatible) - Appends VirusTotal links to release body on publish * fix: resolve YAML lint errors in virustotal workflow - Add document start marker (---) - Quote 'on' key for truthy lint rule - Remove trailing spaces - Break long lines under 160 char limit --------- Co-authored-by: Baptiste Fernandez <fernandez.baptiste1@gmail.com> Co-authored-by: alirezarezvani <5697919+alirezarezvani@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Leo <leo@leo-agent-server> Co-authored-by: Leo <leo@openclaw.ai> * 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> --------- Co-authored-by: Leo <leo@openclaw.ai> Co-authored-by: Baptiste Fernandez <fernandez.baptiste1@gmail.com> Co-authored-by: alirezarezvani <5697919+alirezarezvani@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Leo <leo@leo-agent-server>
This commit is contained in:
28
engineering-team/playwright-pro/.claude-plugin/plugin.json
Normal file
28
engineering-team/playwright-pro/.claude-plugin/plugin.json
Normal 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"
|
||||
]
|
||||
}
|
||||
27
engineering-team/playwright-pro/.mcp.json
Normal file
27
engineering-team/playwright-pro/.mcp.json
Normal 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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
84
engineering-team/playwright-pro/CLAUDE.md
Normal file
84
engineering-team/playwright-pro/CLAUDE.md
Normal 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
|
||||
21
engineering-team/playwright-pro/LICENSE
Normal file
21
engineering-team/playwright-pro/LICENSE
Normal 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.
|
||||
133
engineering-team/playwright-pro/README.md
Normal file
133
engineering-team/playwright-pro/README.md
Normal 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
|
||||
121
engineering-team/playwright-pro/agents/migration-planner.md
Normal file
121
engineering-team/playwright-pro/agents/migration-planner.md
Normal 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.
|
||||
105
engineering-team/playwright-pro/agents/test-architect.md
Normal file
105
engineering-team/playwright-pro/agents/test-architect.md
Normal 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.
|
||||
117
engineering-team/playwright-pro/agents/test-debugger.md
Normal file
117
engineering-team/playwright-pro/agents/test-debugger.md
Normal 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`
|
||||
```
|
||||
23
engineering-team/playwright-pro/hooks/detect-playwright.sh
Executable file
23
engineering-team/playwright-pro/hooks/detect-playwright.sh
Executable 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."
|
||||
25
engineering-team/playwright-pro/hooks/hooks.json
Normal file
25
engineering-team/playwright-pro/hooks/hooks.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
58
engineering-team/playwright-pro/hooks/validate-test.sh
Executable file
58
engineering-team/playwright-pro/hooks/validate-test.sh
Executable 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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
89
engineering-team/playwright-pro/reference/assertions.md
Normal file
89
engineering-team/playwright-pro/reference/assertions.md
Normal 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
|
||||
```
|
||||
137
engineering-team/playwright-pro/reference/common-pitfalls.md
Normal file
137
engineering-team/playwright-pro/reference/common-pitfalls.md
Normal 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();
|
||||
});
|
||||
```
|
||||
121
engineering-team/playwright-pro/reference/fixtures.md
Normal file
121
engineering-team/playwright-pro/reference/fixtures.md
Normal 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 |
|
||||
56
engineering-team/playwright-pro/reference/flaky-tests.md
Normal file
56
engineering-team/playwright-pro/reference/flaky-tests.md
Normal 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,
|
||||
});
|
||||
```
|
||||
12
engineering-team/playwright-pro/reference/golden-rules.md
Normal file
12
engineering-team/playwright-pro/reference/golden-rules.md
Normal 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
|
||||
77
engineering-team/playwright-pro/reference/locators.md
Normal file
77
engineering-team/playwright-pro/reference/locators.md
Normal 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()
|
||||
```
|
||||
8
engineering-team/playwright-pro/settings.json
Normal file
8
engineering-team/playwright-pro/settings.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx playwright*)",
|
||||
"Bash(npx tsx*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
168
engineering-team/playwright-pro/skills/browserstack/SKILL.md
Normal file
168
engineering-team/playwright-pro/skills/browserstack/SKILL.md
Normal 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
|
||||
98
engineering-team/playwright-pro/skills/coverage/SKILL.md
Normal file
98
engineering-team/playwright-pro/skills/coverage/SKILL.md
Normal 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
|
||||
113
engineering-team/playwright-pro/skills/fix/SKILL.md
Normal file
113
engineering-team/playwright-pro/skills/fix/SKILL.md
Normal 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
|
||||
134
engineering-team/playwright-pro/skills/fix/flaky-taxonomy.md
Normal file
134
engineering-team/playwright-pro/skills/fix/flaky-taxonomy.md
Normal 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
|
||||
```
|
||||
144
engineering-team/playwright-pro/skills/generate/SKILL.md
Normal file
144
engineering-team/playwright-pro/skills/generate/SKILL.md
Normal 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
|
||||
163
engineering-team/playwright-pro/skills/generate/patterns.md
Normal file
163
engineering-team/playwright-pro/skills/generate/patterns.md
Normal 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();
|
||||
});
|
||||
});
|
||||
```
|
||||
201
engineering-team/playwright-pro/skills/init/SKILL.md
Normal file
201
engineering-team/playwright-pro/skills/init/SKILL.md
Normal 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`
|
||||
135
engineering-team/playwright-pro/skills/migrate/SKILL.md
Normal file
135
engineering-team/playwright-pro/skills/migrate/SKILL.md
Normal 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
|
||||
@@ -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` |
|
||||
@@ -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
|
||||
126
engineering-team/playwright-pro/skills/report/SKILL.md
Normal file
126
engineering-team/playwright-pro/skills/report/SKILL.md
Normal 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)
|
||||
102
engineering-team/playwright-pro/skills/review/SKILL.md
Normal file
102
engineering-team/playwright-pro/skills/review/SKILL.md
Normal 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)
|
||||
182
engineering-team/playwright-pro/skills/review/anti-patterns.md
Normal file
182
engineering-team/playwright-pro/skills/review/anti-patterns.md
Normal 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
|
||||
129
engineering-team/playwright-pro/skills/testrail/SKILL.md
Normal file
129
engineering-team/playwright-pro/skills/testrail/SKILL.md
Normal 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
|
||||
123
engineering-team/playwright-pro/templates/README.md
Normal file
123
engineering-team/playwright-pro/templates/README.md
Normal 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 |
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
148
engineering-team/playwright-pro/templates/api/auth-headers.md
Normal file
148
engineering-team/playwright-pro/templates/api/auth-headers.md
Normal 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 |
|
||||
157
engineering-team/playwright-pro/templates/api/error-responses.md
Normal file
157
engineering-team/playwright-pro/templates/api/error-responses.md
Normal 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 |
|
||||
174
engineering-team/playwright-pro/templates/api/graphql.md
Normal file
174
engineering-team/playwright-pro/templates/api/graphql.md
Normal 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 |
|
||||
152
engineering-team/playwright-pro/templates/api/rate-limiting.md
Normal file
152
engineering-team/playwright-pro/templates/api/rate-limiting.md
Normal 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 |
|
||||
152
engineering-team/playwright-pro/templates/api/rest-crud.md
Normal file
152
engineering-team/playwright-pro/templates/api/rest-crud.md
Normal 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 |
|
||||
119
engineering-team/playwright-pro/templates/auth/login.md
Normal file
119
engineering-team/playwright-pro/templates/auth/login.md
Normal 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 |
|
||||
112
engineering-team/playwright-pro/templates/auth/logout.md
Normal file
112
engineering-team/playwright-pro/templates/auth/logout.md
Normal 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 |
|
||||
125
engineering-team/playwright-pro/templates/auth/mfa.md
Normal file
125
engineering-team/playwright-pro/templates/auth/mfa.md
Normal 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 |
|
||||
129
engineering-team/playwright-pro/templates/auth/password-reset.md
Normal file
129
engineering-team/playwright-pro/templates/auth/password-reset.md
Normal 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 |
|
||||
132
engineering-team/playwright-pro/templates/auth/rbac.md
Normal file
132
engineering-team/playwright-pro/templates/auth/rbac.md
Normal 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 |
|
||||
127
engineering-team/playwright-pro/templates/auth/remember-me.md
Normal file
127
engineering-team/playwright-pro/templates/auth/remember-me.md
Normal 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 |
|
||||
@@ -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 |
|
||||
115
engineering-team/playwright-pro/templates/auth/sso.md
Normal file
115
engineering-team/playwright-pro/templates/auth/sso.md
Normal 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 |
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
148
engineering-team/playwright-pro/templates/checkout/payment.md
Normal file
148
engineering-team/playwright-pro/templates/checkout/payment.md
Normal 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 |
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
118
engineering-team/playwright-pro/templates/crud/create.md
Normal file
118
engineering-team/playwright-pro/templates/crud/create.md
Normal 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 |
|
||||
116
engineering-team/playwright-pro/templates/crud/delete.md
Normal file
116
engineering-team/playwright-pro/templates/crud/delete.md
Normal 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 |
|
||||
117
engineering-team/playwright-pro/templates/crud/read.md
Normal file
117
engineering-team/playwright-pro/templates/crud/read.md
Normal 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 |
|
||||
113
engineering-team/playwright-pro/templates/crud/soft-delete.md
Normal file
113
engineering-team/playwright-pro/templates/crud/soft-delete.md
Normal 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 |
|
||||
129
engineering-team/playwright-pro/templates/crud/update.md
Normal file
129
engineering-team/playwright-pro/templates/crud/update.md
Normal 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 |
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
146
engineering-team/playwright-pro/templates/dashboard/export.md
Normal file
146
engineering-team/playwright-pro/templates/dashboard/export.md
Normal 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 |
|
||||
@@ -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 |
|
||||
135
engineering-team/playwright-pro/templates/forms/autosave.md
Normal file
135
engineering-team/playwright-pro/templates/forms/autosave.md
Normal 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 |
|
||||
@@ -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 |
|
||||
136
engineering-team/playwright-pro/templates/forms/file-upload.md
Normal file
136
engineering-team/playwright-pro/templates/forms/file-upload.md
Normal 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 |
|
||||
137
engineering-team/playwright-pro/templates/forms/multi-step.md
Normal file
137
engineering-team/playwright-pro/templates/forms/multi-step.md
Normal 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 |
|
||||
124
engineering-team/playwright-pro/templates/forms/single-step.md
Normal file
124
engineering-team/playwright-pro/templates/forms/single-step.md
Normal 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 |
|
||||
141
engineering-team/playwright-pro/templates/forms/validation.md
Normal file
141
engineering-team/playwright-pro/templates/forms/validation.md
Normal 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 |
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
118
engineering-team/playwright-pro/templates/search/basic-search.md
Normal file
118
engineering-team/playwright-pro/templates/search/basic-search.md
Normal 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 |
|
||||
109
engineering-team/playwright-pro/templates/search/empty-state.md
Normal file
109
engineering-team/playwright-pro/templates/search/empty-state.md
Normal 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 |
|
||||
128
engineering-team/playwright-pro/templates/search/filters.md
Normal file
128
engineering-team/playwright-pro/templates/search/filters.md
Normal 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 |
|
||||
123
engineering-team/playwright-pro/templates/search/pagination.md
Normal file
123
engineering-team/playwright-pro/templates/search/pagination.md
Normal 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 |
|
||||
131
engineering-team/playwright-pro/templates/search/sorting.md
Normal file
131
engineering-team/playwright-pro/templates/search/sorting.md
Normal 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 |
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
Reference in New Issue
Block a user