chore: import upstream snapshot with attribution
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64, , runner, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64, , runner, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / compute_version (push) Waiting to run
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, , production, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda126, production, true, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda126, production, true, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda, production, true, cuda) (push) Blocked by required conditions
Build and Push Docker Images / verify_digests (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, , cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, , production, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda, production, true, cuda) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda, cuda) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda126, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (web, surfsense-web, , cpu) (push) Blocked by required conditions
Build and Push Docker Images / finalize_release (push) Blocked by required conditions
Obsidian Plugin Lint / lint (push) Waiting to run
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64, , runner, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64, , runner, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / compute_version (push) Waiting to run
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, , production, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda126, production, true, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda126, production, true, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda, production, true, cuda) (push) Blocked by required conditions
Build and Push Docker Images / verify_digests (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, , cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, , production, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda, production, true, cuda) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda, cuda) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda126, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (web, surfsense-web, , cpu) (push) Blocked by required conditions
Build and Push Docker Images / finalize_release (push) Blocked by required conditions
Obsidian Plugin Lint / lint (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
---
|
||||
description: Ponytail, lazy senior dev mode. Always pick the simplest solution that works.
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Ponytail, lazy senior dev mode
|
||||
|
||||
You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.
|
||||
|
||||
Before writing any code, stop at the first rung that holds:
|
||||
|
||||
1. Does this need to be built at all? (YAGNI)
|
||||
2. Does it already exist in this codebase? Reuse the helper, util, or pattern that's already here, don't re-write it.
|
||||
3. Does the standard library already do this? Use it.
|
||||
4. Does a native platform feature cover it? Use it.
|
||||
5. Does an already-installed dependency solve it? Use it.
|
||||
6. Can this be one line? Make it one line.
|
||||
7. Only then: write the minimum code that works.
|
||||
|
||||
The ladder runs after you understand the problem, not instead of it: read the task and the code it touches, trace the real flow end to end, then climb.
|
||||
|
||||
Bug fix = root cause, not symptom: a report names a symptom. Grep every caller of the function you touch and fix the shared function once — one guard there is a smaller diff than one per caller, and patching only the path the ticket names leaves a sibling caller still broken.
|
||||
|
||||
Rules:
|
||||
|
||||
- No abstractions that weren't explicitly requested.
|
||||
- No new dependency if it can be avoided.
|
||||
- No boilerplate nobody asked for.
|
||||
- Deletion over addition. Boring over clever. Fewest files possible.
|
||||
- Shortest working diff wins, but only once you understand the problem. The smallest change in the wrong place isn't lazy, it's a second bug.
|
||||
- Question complex requests: "Do you actually need X, or does Y cover it?"
|
||||
- Pick the edge-case-correct option when two stdlib approaches are the same size, lazy means less code, not the flimsier algorithm.
|
||||
- Mark intentional simplifications with a `ponytail:` comment. If the shortcut has a known ceiling (global lock, O(n²) scan, naive heuristic), the comment names the ceiling and the upgrade path.
|
||||
|
||||
Not lazy about: understanding the problem (read it fully and trace the real flow before picking a rung, a small diff you don't understand is just laziness dressed up as efficiency), input validation at trust boundaries, error handling that prevents data loss, security, accessibility, the calibration real hardware needs (the platform is never the spec ideal, a clock drifts, a sensor reads off), anything explicitly requested. Lazy code without its check is unfinished: non-trivial logic leaves ONE runnable check behind, the smallest thing that fails if the logic breaks (an assert-based demo/self-check or one small test file; no frameworks, no fixtures). Trivial one-liners need no test.
|
||||
@@ -0,0 +1,337 @@
|
||||
---
|
||||
name: alert-manager
|
||||
description: 'Configure SEO alerts for ranking drops, traffic changes, technical issues, competitor movements. SEO预警/排名监控'
|
||||
version: "6.0.0"
|
||||
license: Apache-2.0
|
||||
compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
|
||||
homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
|
||||
when_to_use: "Use when setting up monitoring alerts for rankings, traffic, backlinks, technical issues, or AI visibility changes."
|
||||
argument-hint: "<domain> [metric]"
|
||||
metadata:
|
||||
author: aaron-he-zhu
|
||||
version: "6.0.0"
|
||||
geo-relevance: "low"
|
||||
tags:
|
||||
- seo
|
||||
- geo
|
||||
- seo-alerts
|
||||
- ranking-alerts
|
||||
- traffic-monitoring
|
||||
- competitor-alerts
|
||||
- automated-monitoring
|
||||
- anomaly-detection
|
||||
- SEO预警
|
||||
- SEOアラート
|
||||
- SEO알림
|
||||
- alertas-seo
|
||||
triggers:
|
||||
# EN-formal
|
||||
- "set up SEO alerts"
|
||||
- "monitor rankings"
|
||||
- "ranking notifications"
|
||||
- "traffic alerts"
|
||||
- "competitor alerts"
|
||||
- "automated monitoring"
|
||||
# EN-casual
|
||||
- "notify me when rankings drop"
|
||||
- "alert me if rankings drop"
|
||||
- "notify me of traffic changes"
|
||||
- "watch competitor changes"
|
||||
- "watch my keywords for changes"
|
||||
- "alert me about changes"
|
||||
# EN-question
|
||||
- "how to monitor my rankings"
|
||||
- "how to set up SEO alerts"
|
||||
# ZH-pro
|
||||
- "SEO预警"
|
||||
- "排名监控"
|
||||
- "流量报警"
|
||||
- "竞品变动提醒"
|
||||
# ZH-casual
|
||||
- "排名掉了提醒我"
|
||||
- "流量异常"
|
||||
- "有变化通知我"
|
||||
# JA
|
||||
- "SEOアラート"
|
||||
- "ランキング監視"
|
||||
# KO
|
||||
- "SEO 알림"
|
||||
- "순위 모니터링"
|
||||
# ES
|
||||
- "alertas SEO"
|
||||
- "monitoreo de rankings"
|
||||
# PT
|
||||
- "alertas de SEO"
|
||||
# Misspellings
|
||||
- "SEO allerts"
|
||||
---
|
||||
|
||||
# Alert Manager
|
||||
|
||||
|
||||
> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
|
||||
> **System Mode**: This monitoring skill follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
|
||||
|
||||
|
||||
Sets up proactive monitoring alerts for critical SEO and GEO metrics. Triggers notifications when rankings drop, traffic changes significantly, technical issues occur, or competitors make moves.
|
||||
|
||||
**System role**: Monitoring layer skill. It turns performance changes into deltas, alerts, and next actions.
|
||||
|
||||
## When This Must Trigger
|
||||
|
||||
Use this when the conversation involves any of these situations — even if the user does not use SEO terminology:
|
||||
|
||||
Use this whenever the task needs time-aware change detection, escalation, or stakeholder-ready visibility.
|
||||
|
||||
- Setting up SEO monitoring systems
|
||||
- Creating ranking drop alerts
|
||||
- Monitoring technical SEO health
|
||||
- Tracking competitor movements
|
||||
- Alerting on content performance changes
|
||||
- Monitoring GEO/AI visibility changes
|
||||
- Setting up brand mention alerts
|
||||
|
||||
## What This Skill Does
|
||||
|
||||
1. **Alert Configuration**: Sets up custom alert thresholds
|
||||
2. **Multi-Metric Monitoring**: Tracks rankings, traffic, technical issues
|
||||
3. **Threshold Management**: Defines when alerts trigger
|
||||
4. **Priority Classification**: Categorizes alerts by severity
|
||||
5. **Notification Setup**: Configures how alerts are delivered
|
||||
6. **Alert Response Plans**: Creates action plans for each alert type
|
||||
7. **Alert History**: Tracks alert patterns over time
|
||||
|
||||
## Quick Start
|
||||
|
||||
Start with one of these prompts. Finish with a short handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
|
||||
|
||||
### Set Up Alerts
|
||||
|
||||
```
|
||||
Set up SEO monitoring alerts for [domain]
|
||||
```
|
||||
|
||||
```
|
||||
Create ranking drop alerts for my top 20 keywords
|
||||
```
|
||||
|
||||
### Configure Specific Alerts
|
||||
|
||||
```
|
||||
Alert me when [specific condition]
|
||||
```
|
||||
|
||||
```
|
||||
Set up competitor monitoring for [competitor domains]
|
||||
```
|
||||
|
||||
### Review Alert System
|
||||
|
||||
```
|
||||
Review and optimize my current SEO alerts
|
||||
```
|
||||
|
||||
## Skill Contract
|
||||
|
||||
**Expected output**: a delta summary, alert/report output, and a short handoff summary ready for `memory/monitoring/`.
|
||||
|
||||
- **Reads**: current metrics, previous baselines, alert thresholds, and reporting context from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
|
||||
- **Writes**: a user-facing monitoring deliverable plus a reusable summary that can be stored under `memory/monitoring/`.
|
||||
- **Promotes**: significant changes, confirmed anomalies, and follow-up actions to `memory/open-loops.md` and `memory/decisions.md`.
|
||||
- **Next handoff**: use the `Next Best Skill` below when a change needs action.
|
||||
|
||||
## Data Sources
|
||||
|
||||
> **Note:** All integrations are optional. This skill works without any API keys — users provide data manually when no tools are connected.
|
||||
|
||||
> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
|
||||
|
||||
**With ~~SEO tool + ~~search console + ~~web crawler connected:**
|
||||
Automatically monitor real-time metric feeds for ranking changes via ~~SEO tool API, indexing and coverage alerts from ~~search console, and technical health alerts from ~~web crawler. Set up automated threshold-based alerts with notification delivery.
|
||||
|
||||
**With manual data only:**
|
||||
Ask the user to provide:
|
||||
1. Current baseline metrics for alert thresholds (rankings, traffic, backlinks)
|
||||
2. Critical keywords or pages to monitor
|
||||
3. Alert priority levels and notification preferences
|
||||
4. Historical data to understand normal fluctuation ranges
|
||||
5. Manual reporting on metric changes when they check their tools
|
||||
|
||||
Proceed with the alert configuration using provided parameters. User will need to manually check metrics and report changes for alert triggers.
|
||||
|
||||
## Instructions
|
||||
|
||||
When a user requests alert setup:
|
||||
|
||||
1. **Define Alert Categories**
|
||||
|
||||
```markdown
|
||||
## SEO Alert System Configuration
|
||||
|
||||
**Domain**: [domain]
|
||||
**Configured Date**: [date]
|
||||
|
||||
### Alert Categories
|
||||
|
||||
| Category | Description | Typical Urgency |
|
||||
|----------|-------------|-----------------|
|
||||
| Ranking Alerts | Keyword position changes | Medium-High |
|
||||
| Traffic Alerts | Organic traffic fluctuations | High |
|
||||
| Technical Alerts | Site health issues | Critical |
|
||||
| Backlink Alerts | Link profile changes | Medium |
|
||||
| Competitor Alerts | Competitor movements | Low-Medium |
|
||||
| GEO Alerts | AI visibility changes | Medium |
|
||||
| Brand Alerts | Brand mentions and reputation | Medium |
|
||||
```
|
||||
|
||||
2. **Configure Alert Rules by Category**
|
||||
|
||||
For each relevant category (Rankings, Traffic, Technical, Backlinks, Competitors, GEO/AI, Brand), define alert name, trigger condition, threshold, and priority level.
|
||||
|
||||
> **Reference**: See [references/alert-configuration-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/alert-manager/references/alert-configuration-templates.md) for complete alert tables, threshold examples, and response plan templates for all 7 categories.
|
||||
|
||||
3. **Define Alert Response Plans**
|
||||
|
||||
Map each priority level (Critical, High, Medium, Low) to a response time and immediate action steps.
|
||||
|
||||
4. **Set Up Alert Delivery**
|
||||
|
||||
Configure notification channels (Email, SMS, Slack), recipient routing by role, suppression rules (duplicate cooldown, maintenance windows), and escalation paths.
|
||||
|
||||
5. **Create Alert Summary**
|
||||
|
||||
```markdown
|
||||
# SEO Alert System Summary
|
||||
|
||||
**Domain**: [domain]
|
||||
**Configured**: [date]
|
||||
**Total Active Alerts**: [X]
|
||||
|
||||
## Alert Count by Category
|
||||
|
||||
| Category | Critical | High | Medium | Low | Total |
|
||||
|----------|----------|------|--------|-----|-------|
|
||||
| Rankings | [X] | [X] | [X] | [X] | [X] |
|
||||
| Traffic | [X] | [X] | [X] | [X] | [X] |
|
||||
| Technical | [X] | [X] | [X] | [X] | [X] |
|
||||
| Backlinks | [X] | [X] | [X] | [X] | [X] |
|
||||
| Competitors | [X] | [X] | [X] | [X] | [X] |
|
||||
| GEO | [X] | [X] | [X] | [X] | [X] |
|
||||
| **Total** | **[X]** | **[X]** | **[X]** | **[X]** | **[X]** |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### If You Get a Critical Alert
|
||||
|
||||
1. Don't panic
|
||||
2. Check alert details
|
||||
3. Follow response plan
|
||||
4. Document actions taken
|
||||
5. Update stakeholders
|
||||
|
||||
### Weekly Alert Review Checklist
|
||||
|
||||
- [ ] Review all alerts triggered
|
||||
- [ ] Identify patterns
|
||||
- [ ] Adjust thresholds if needed
|
||||
- [ ] Update response plans
|
||||
- [ ] Clean up false positives
|
||||
```
|
||||
|
||||
## Validation Checkpoints
|
||||
|
||||
### Input Validation
|
||||
- [ ] Alert thresholds are based on realistic baseline data
|
||||
- [ ] Critical keywords and pages clearly identified
|
||||
- [ ] Response plans defined for each alert priority level
|
||||
- [ ] Notification channels configured with appropriate recipients
|
||||
|
||||
### Output Validation
|
||||
- [ ] Every metric cites its data source and collection date
|
||||
- [ ] Alert thresholds account for normal metric fluctuations
|
||||
- [ ] Response plans are specific and time-bound
|
||||
- [ ] Source of each alert trigger clearly stated (~~SEO tool API alert, ~~search console notification, ~~web crawler alert, or manual user check)
|
||||
|
||||
## Example
|
||||
|
||||
**User**: "Set up ranking drop alerts for my top keywords"
|
||||
|
||||
**Output**:
|
||||
|
||||
```markdown
|
||||
## Ranking Alert Configuration
|
||||
|
||||
### Critical Keywords (Immediate Alert)
|
||||
|
||||
| Keyword | Current | Alert If | Priority |
|
||||
|---------|---------|----------|----------|
|
||||
| best project management software | 2 | Drops to 5+ | 🔴 Critical |
|
||||
| project management tools | 4 | Drops to 8+ | 🔴 Critical |
|
||||
| team collaboration software | 1 | Any drop | 🔴 Critical |
|
||||
|
||||
### Important Keywords (Same-Day Alert)
|
||||
|
||||
| Keyword | Current | Alert If | Priority |
|
||||
|---------|---------|----------|----------|
|
||||
| agile project management | 7 | Drops out of top 10 | 🔴 High |
|
||||
| kanban software | 9 | Drops out of top 10 | 🔴 High |
|
||||
|
||||
### Alert Response Plan
|
||||
|
||||
**If Critical Keyword Drops**:
|
||||
1. Check if page is still indexed (site:url)
|
||||
2. Look for algorithm update announcements
|
||||
3. Analyze what changed in SERP
|
||||
4. Review competitor ranking changes
|
||||
5. Check for technical issues on page
|
||||
6. Create recovery action plan within 24 hours
|
||||
|
||||
**Notification**: Email + Slack to SEO team immediately
|
||||
```
|
||||
|
||||
## Tips for Success
|
||||
|
||||
1. **Start simple** - Don't create too many alerts initially
|
||||
2. **Tune thresholds** - Adjust based on normal fluctuations
|
||||
3. **Avoid alert fatigue** - Too many alerts = ignored alerts
|
||||
4. **Document response plans** - Know what to do when alerts fire
|
||||
5. **Review regularly** - Alerts need maintenance as your SEO matures
|
||||
6. **Include positive alerts** - Track wins, not just problems
|
||||
|
||||
## Alert Threshold Quick Reference
|
||||
|
||||
| Metric | Warning | Critical | Frequency |
|
||||
|--------|---------|----------|-----------|
|
||||
| Organic traffic | -15% WoW | -30% WoW | Daily |
|
||||
| Keyword positions | >3 position drop | >5 position drop | Daily |
|
||||
| Pages indexed | -5% change | -20% change | Weekly |
|
||||
| Crawl errors | >10 new/day | >50 new/day | Daily |
|
||||
| Core Web Vitals | "Needs Improvement" | "Poor" | Weekly |
|
||||
| Backlinks lost | >5% in 1 week | >15% in 1 week | Weekly |
|
||||
| AI citation loss | Any key query | >20% queries | Weekly |
|
||||
| Security issues | Any detected | Any detected | Daily |
|
||||
|
||||
> **Reference**: See [references/alert-threshold-guide.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/alert-manager/references/alert-threshold-guide.md) for baseline establishment, threshold setting methodology, fatigue prevention, escalation paths, and response playbooks.
|
||||
|
||||
|
||||
### Save Results
|
||||
|
||||
After delivering monitoring data or reports to the user, ask:
|
||||
|
||||
> "Save these results for future sessions?"
|
||||
|
||||
If yes, write a dated summary to `memory/monitoring/YYYY-MM-DD-<topic>.md` containing:
|
||||
- One-line headline finding or status change
|
||||
- Top 3-5 actionable items
|
||||
- Open loops or anomalies requiring follow-up
|
||||
- Source data references
|
||||
|
||||
If any findings should influence ongoing strategy, recommend promoting key conclusions to `memory/hot-cache.md`.
|
||||
|
||||
## Reference Materials
|
||||
|
||||
- [Alert Threshold Guide](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/alert-manager/references/alert-threshold-guide.md) — Recommended thresholds by metric, fatigue prevention strategies, and escalation path templates
|
||||
|
||||
## Next Best Skill
|
||||
|
||||
- **Primary**: [rank-tracker](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/rank-tracker/SKILL.md) — pair alerts with a baseline measurement workflow.
|
||||
@@ -0,0 +1,173 @@
|
||||
---
|
||||
name: animation-vocabulary
|
||||
description: Reverse-lookup glossary that turns a vague description of a web animation or motion effect into its exact term ("the bouncy thing when a popover opens" → Pop in; "the iOS rubber-band scroll" → Rubber-banding). Use when the user asks "what's it called when…", or describes a motion effect without knowing its name and wants the right word to prompt an AI or designer with. For naming an effect, not designing or building one.
|
||||
---
|
||||
|
||||
# Animation Vocabulary
|
||||
|
||||
Turn a vague description of a motion or effect into the precise term, so the user knows what to ask for.
|
||||
|
||||
## Quick Start
|
||||
|
||||
The user describes an effect loosely. You return the matching term(s) in this format:
|
||||
|
||||
```
|
||||
**Stagger** — Animate several items one after another with a small delay between each, creating a cascade.
|
||||
```
|
||||
|
||||
If several terms could fit, list the best match first, then 1–2 alternates with a one-line note on how they differ.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Read for intent, not keywords.** Users describe what they *see* or *feel* ("springy", "slides off", "draws itself in"), not the technical name. Map the sensation to the glossary below.
|
||||
2. **Quote the glossary verbatim.** Its descriptions are authoritative — use them as-is, don't paraphrase.
|
||||
3. **Disambiguate close terms.** When two compete (*Clip-path* vs *Mask*, *Pop in* vs *Bounce*, *Shared element transition* vs *Layout animation*), contrast them so the user can pick.
|
||||
4. **When nothing matches exactly,** name the closest term and say plainly it's an approximation, or describe the effect in the glossary's vocabulary (e.g. "that's a *stagger* of *scale-in* entrances").
|
||||
5. **Stay within this glossary.** If a term genuinely isn't here, say so rather than inventing one — though you may explain the concept using these words.
|
||||
6. **Keep it tight.** A naming question wants a name, not an essay. Lead with the term; expand only if asked.
|
||||
|
||||
## Examples
|
||||
|
||||
**Example 1 — feel-based**
|
||||
User: "What's it called when a popover seems to grow out of the button you clicked instead of from its middle?"
|
||||
Output:
|
||||
```
|
||||
**Origin-aware animation** — An element animates out of its trigger, like a popover growing from the button that opened it instead of from its own center which is the default in CSS.
|
||||
```
|
||||
|
||||
**Example 2 — disambiguation**
|
||||
User: "The thing where one image turns into another image."
|
||||
Output:
|
||||
```
|
||||
**Morph** — One shape smoothly turns into another shape, e.g. Dynamic Island.
|
||||
|
||||
Close alternates:
|
||||
- **Crossfade** — if they simply fade over each other in the same spot.
|
||||
- **Shared element transition** — if an element travels and transforms from one position into another.
|
||||
```
|
||||
|
||||
**Example 3 — physics feel**
|
||||
User: "That iOS scroll where it resists and snaps back when you pull too far."
|
||||
Output:
|
||||
```
|
||||
**Rubber-banding** — Resistance and snap-back when you drag past a boundary (the iOS overscroll feel).
|
||||
```
|
||||
|
||||
## Glossary
|
||||
|
||||
_A curated snapshot mirroring the project's `/vocabulary` page; keep the two in sync when either changes._
|
||||
|
||||
### Entrances & Exits — how elements appear and disappear
|
||||
- **Fade in / Fade out** — Element appears or disappears by changing opacity.
|
||||
- **Slide in** — Element enters by sliding in from off-screen (left, right, top, or bottom).
|
||||
- **Scale in** — Element grows from smaller to full size as it appears, often paired with a fade.
|
||||
- **Pop in** — Element appears with a slight overshoot, like it bounces into place.
|
||||
- **Reveal** — Content is uncovered gradually, often by animating a clip-path or mask.
|
||||
- **Enter / Exit** — The animation an element plays when it's added to or removed from the screen.
|
||||
|
||||
### Sequencing & Timing — coordinating multiple elements or moments
|
||||
- **Keyframes** — Defined points in an animation (0%, 50%, 100%) that the browser fills the gaps between.
|
||||
- **Interpolation / Tween** — Generating all the in-between frames between a start and end value, so motion is continuous.
|
||||
- **Stagger** — Animate several items one after another with a small delay between each, creating a cascade.
|
||||
- **Orchestration** — Deliberately timing multiple animations so they feel like one coordinated motion.
|
||||
- **Delay** — Time before an animation starts.
|
||||
- **Duration** — How long an animation takes.
|
||||
- **Fill mode** — Whether an element keeps its first or last frame's styles before the animation starts or after it ends (e.g. forwards).
|
||||
- **Stepped animation** — An animation that is divided into discrete steps, like a countdown timer.
|
||||
|
||||
### Movement & Transforms — changing an element's position, size, or angle
|
||||
- **Translate** — Move an element along the X or Y axis.
|
||||
- **Scale** — Make an element bigger or smaller.
|
||||
- **Rotate** — Spin an element around a point.
|
||||
- **Skew** — Slant an element along the X or Y axis, shearing it out of its rectangular shape.
|
||||
- **3D tilt / Flip** — Rotate in 3D space (rotateX / rotateY) to add depth.
|
||||
- **Perspective** — How strong the 3D effect looks — a lower value exaggerates depth, like the viewer is closer.
|
||||
- **Transform origin** — The anchor point a scale or rotation grows or spins from.
|
||||
- **Origin-aware animation** — An element animates out of its trigger, like a popover growing from the button that opened it instead of from its own center which is the default in CSS.
|
||||
|
||||
### Transitions Between States — connecting one state, view, or element to another
|
||||
- **Crossfade** — One element fades out as another fades in, in the same spot.
|
||||
- **Continuity transition** — A change that keeps the user oriented by visually connecting before and after. For example, making the same rectangle bigger and smaller.
|
||||
- **Morph** — One shape smoothly turns into another shape, e.g. Dynamic Island.
|
||||
- **Shared element transition** — An element travels and transforms from one position into another, like a thumbnail expanding into a card.
|
||||
- **Layout animation** — When an element's size or position changes, it animates to the new spot instead of snapping.
|
||||
- **Accordion / Collapse** — A section smoothly expands and collapses its height to show or hide content.
|
||||
- **Direction-aware transition** — Content slides one way going forward and the opposite way going back, so navigation has a sense of direction.
|
||||
|
||||
### Scroll — motion tied to scrolling or navigating between views
|
||||
- **Scroll reveal** — Elements fade or slide into place as they enter the viewport.
|
||||
- **Scroll-driven animation** — An animation whose progress is tied directly to scroll position.
|
||||
- **Parallax** — Background and foreground move at different speeds while scrolling, creating depth.
|
||||
- **Page transition** — An animation that plays when navigating from one page or route to another.
|
||||
- **View transition** — The browser morphs between two states or pages, connecting shared elements.
|
||||
|
||||
### Feedback & Interaction — responding to the user's actions
|
||||
- **Hover effect** — Visual change when the cursor moves over an element.
|
||||
- **Press / Tap feedback** — A subtle scale-down when an element is clicked, so it feels physical.
|
||||
- **Hold to confirm** — A progress effect that fills up while the user holds a button.
|
||||
- **Drag** — Moving an element by grabbing it, often with momentum when released.
|
||||
- **Drag to reorder** — Dragging items in a list to rearrange them, while the others shift to make room.
|
||||
- **Swipe to dismiss** — Dragging an element off-screen to close it, like a drawer or toast.
|
||||
- **Rubber-banding** — Resistance and snap-back when you drag past a boundary (the iOS overscroll feel).
|
||||
- **Shake / Wiggle** — A quick side-to-side jitter signaling an error or rejected input.
|
||||
- **Ripple** — A circle expanding from the point of a tap, confirming the press.
|
||||
|
||||
### Easing — how speed changes over an animation
|
||||
- **Easing** — The rate at which an animation speeds up or slows down.
|
||||
- **Ease-out** — Starts fast, ends slow. The default for most UI and anything responding to the user.
|
||||
- **Ease-in** — Starts slow, ends fast. Usually avoided; can feel sluggish.
|
||||
- **Ease-in-out** — Slow, fast, slow. Good for elements already on screen moving from A to B.
|
||||
- **Linear** — Constant speed. Avoid for UI; reserve for spinners or marquees.
|
||||
- **Cubic-bezier** — A custom easing curve you define for precise control.
|
||||
- **Asymmetric easing** — A curve that accelerates and decelerates at different rates. Feels more alive than a symmetric one.
|
||||
|
||||
### Spring Animations — physics-based motion as an alternative to fixed-duration easing
|
||||
- **Spring** — Motion driven by physics (tension, mass, damping) rather than a set duration.
|
||||
- **Stiffness / Tension** — How strongly the spring pulls toward its target. Higher feels snappier.
|
||||
- **Damping** — How quickly a spring settles. Lower damping means more bounce and oscillation.
|
||||
- **Mass** — How heavy the animated element feels. More mass makes it slower and more sluggish.
|
||||
- **Bounce** — A spring that overshoots and settles, adding playfulness.
|
||||
- **Perceptual duration** — How long a spring feels finished, even though it keeps micro-settling underneath.
|
||||
- **Momentum** — Motion that carries velocity, especially after a drag or interruption.
|
||||
- **Velocity** — How fast and in which direction an element is moving. A spring carries it into the next animation when interrupted, so a flicked element keeps its speed.
|
||||
- **Interruptible animation** — An animation that can be smoothly redirected mid-flight instead of finishing first.
|
||||
|
||||
### Looping & Ambient Motion — animations that run on their own
|
||||
- **Marquee** — Text or content that scrolls continuously in a loop.
|
||||
- **Loop** — An animation that repeats, a set number of times or infinitely.
|
||||
- **Alternate (yoyo)** — A loop that plays forward then reverses each iteration, instead of jumping back to the start.
|
||||
- **Orbit** — An element circling around another in a continuous path.
|
||||
- **Pulse** — A gentle repeating scale or opacity change to draw attention.
|
||||
- **Float** — A gentle, continuous up-and-down drift that makes a static element feel alive and weightless.
|
||||
- **Idle animation** — Subtle motion that plays while an element is just sitting there, waiting to be interacted with.
|
||||
|
||||
### Polish & Effects — the small touches that separate good from great
|
||||
- **Blur** — A blur filter used to soften an element or mask tiny imperfections.
|
||||
- **Clip-path** — Clipping an element to a shape, used for reveals, masks, and before/after sliders.
|
||||
- **Mask** — Hiding or revealing parts of an element using a shape or gradient — like clip-path, but with soft, fadeable edges.
|
||||
- **Before / after slider** — A draggable divider that wipes between two overlaid images to compare them.
|
||||
- **Line drawing** — An SVG path that draws itself in, like an invisible pen tracing it.
|
||||
- **Text morph** — Text that animates character by character when it changes, drawing attention to the new value.
|
||||
- **Skeleton / Shimmer** — A placeholder with a moving sheen shown while content loads.
|
||||
- **Number ticker** — Digits rolling or counting up to a value.
|
||||
- **Tabular numbers** — Fixed-width digits so numbers don't shift around as they change. Essential for tickers, timers, and counters.
|
||||
- **Typewriter** — Text appearing one character at a time, as if being typed.
|
||||
|
||||
### Performance — what keeps motion smooth instead of stuttering
|
||||
- **Frame rate (FPS)** — Frames drawn per second. 60fps is the baseline for smooth motion; 120fps on newer displays.
|
||||
- **Jank** — Visible stutter when the browser drops frames because it can't keep up with the animation.
|
||||
- **Dropped frame** — A frame the browser missed its deadline to draw, causing a tiny hitch in motion.
|
||||
- **Compositing** — Letting the GPU move or fade an element on its own layer without redoing layout or paint.
|
||||
- **will-change** — A CSS hint that an element is about to animate, so the browser can promote it to its own layer ahead of time.
|
||||
- **Layout thrashing** — Animating properties like width, height, top, or left that force the browser to recalculate layout every frame, causing jank.
|
||||
|
||||
### Principles to Know — concepts that guide when and how to animate
|
||||
- **Purposeful animation** — Motion should serve a function — orient, give feedback, show relationships — not just decorate.
|
||||
- **Anticipation** — A small wind-up in the opposite direction before a move, hinting at what's about to happen.
|
||||
- **Follow-through** — Parts of an element keep moving and settle slightly after the main motion stops, adding weight.
|
||||
- **Squash & stretch** — Deforming an element as it moves to convey weight, speed, and flexibility.
|
||||
- **Perceived performance** — The right animation makes an interface feel faster, even when it isn't.
|
||||
- **Frequency of use** — The more often a user sees an animation, the shorter and subtler it should be.
|
||||
- **Spatial consistency** — Animating so an element keeps its identity and position across states, so users never lose track of where things went.
|
||||
- **Hardware acceleration** — Animating transform and opacity lets the GPU keep motion smooth.
|
||||
- **Reduced motion** — Respecting the user's prefers-reduced-motion setting by toning down or removing motion.
|
||||
@@ -0,0 +1,301 @@
|
||||
---
|
||||
name: backlink-analyzer
|
||||
description: 'Analyze backlink profiles: link authority, toxic links, building opportunities, competitor link gaps. 外链分析/反向链接'
|
||||
version: "6.0.0"
|
||||
license: Apache-2.0
|
||||
compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
|
||||
homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
|
||||
when_to_use: "Use when analyzing backlink profiles, link quality, toxic links, referring domains, or anchor text distribution."
|
||||
argument-hint: "<domain or URL>"
|
||||
metadata:
|
||||
author: aaron-he-zhu
|
||||
version: "6.0.0"
|
||||
geo-relevance: "low"
|
||||
tags:
|
||||
- seo
|
||||
- backlinks
|
||||
- link-building
|
||||
- link-profile
|
||||
- toxic-links
|
||||
- off-page-seo
|
||||
- link-audit
|
||||
- referring-domains
|
||||
- disavow
|
||||
- ahrefs-alternative
|
||||
- 外链分析
|
||||
- 被リンク
|
||||
- 백링크
|
||||
- backlinks-seo
|
||||
triggers:
|
||||
# EN-formal
|
||||
- "analyze backlinks"
|
||||
- "check link profile"
|
||||
- "find toxic links"
|
||||
- "link building opportunities"
|
||||
- "link profile analysis"
|
||||
- "backlink audit"
|
||||
- "link quality"
|
||||
# EN-casual
|
||||
- "who links to me"
|
||||
- "I have spammy links"
|
||||
- "how do I get more backlinks"
|
||||
- "how do I get more links"
|
||||
- "disavow links"
|
||||
- "link building outreach"
|
||||
- "disavow file"
|
||||
# EN-question
|
||||
- "how to build backlinks"
|
||||
- "how to find toxic backlinks"
|
||||
# ZH-pro
|
||||
- "外链分析"
|
||||
- "反向链接"
|
||||
- "有毒链接"
|
||||
- "链接建设"
|
||||
# ZH-casual
|
||||
- "外链怎么做"
|
||||
- "有垃圾外链"
|
||||
- "谁链接到我"
|
||||
- "友链"
|
||||
- "互换友链"
|
||||
- "外链建设"
|
||||
# JA
|
||||
- "被リンク分析"
|
||||
- "バックリンク"
|
||||
- "リンク構築"
|
||||
# KO
|
||||
- "백링크 분석"
|
||||
- "링크 빌딩"
|
||||
- "누가 내 사이트 링크해?"
|
||||
- "백링크 어떻게 늘려?"
|
||||
# ES
|
||||
- "análisis de backlinks"
|
||||
- "enlaces entrantes"
|
||||
# PT
|
||||
- "análise de backlinks"
|
||||
# Misspellings
|
||||
- "backlink anaylsis"
|
||||
- "backlnk analysis"
|
||||
---
|
||||
|
||||
# Backlink Analyzer
|
||||
|
||||
|
||||
> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
|
||||
> **System Mode**: This monitoring skill follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
|
||||
|
||||
|
||||
Analyzes, monitors, and optimizes backlink profiles. Identifies link quality, discovers opportunities, and tracks competitor link building activities.
|
||||
|
||||
**System role**: Monitoring layer skill. It turns performance changes into deltas, alerts, and next actions.
|
||||
|
||||
## When This Must Trigger
|
||||
|
||||
Use this when the conversation involves any of these situations — even if the user does not use SEO terminology:
|
||||
|
||||
Use this whenever the task needs time-aware change detection, escalation, or stakeholder-ready visibility.
|
||||
|
||||
- Auditing your current backlink profile
|
||||
- Identifying toxic or harmful links
|
||||
- Discovering link building opportunities
|
||||
- Analyzing competitor backlink strategies
|
||||
- Monitoring new and lost links
|
||||
- Evaluating link quality for outreach
|
||||
- Preparing for link disavow
|
||||
|
||||
## What This Skill Does
|
||||
|
||||
1. **Profile Analysis**: Comprehensive backlink profile overview
|
||||
2. **Quality Assessment**: Evaluates link authority and relevance
|
||||
3. **Toxic Link Detection**: Identifies harmful links
|
||||
4. **Competitor Analysis**: Compares link profiles across competitors
|
||||
5. **Opportunity Discovery**: Finds link building prospects
|
||||
6. **Trend Monitoring**: Tracks link acquisition over time
|
||||
7. **Disavow Guidance**: Helps create disavow files
|
||||
|
||||
## Quick Start
|
||||
|
||||
Start with one of these prompts. Finish with a short handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
|
||||
|
||||
### Analyze Your Profile
|
||||
|
||||
```
|
||||
Analyze backlink profile for [domain]
|
||||
```
|
||||
|
||||
### Find Opportunities
|
||||
|
||||
```
|
||||
Find link building opportunities by analyzing [competitor domains]
|
||||
```
|
||||
|
||||
### Detect Issues
|
||||
|
||||
```
|
||||
Check for toxic backlinks on [domain]
|
||||
```
|
||||
|
||||
### Compare Profiles
|
||||
|
||||
```
|
||||
Compare backlink profiles: [your domain] vs [competitor domains]
|
||||
```
|
||||
|
||||
## Skill Contract
|
||||
|
||||
**Expected output**: a delta summary, alert/report output, and a short handoff summary ready for `memory/monitoring/`.
|
||||
|
||||
- **Reads**: current metrics, previous baselines, alert thresholds, and reporting context from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
|
||||
- **Writes**: a user-facing monitoring deliverable plus a reusable summary that can be stored under `memory/monitoring/`.
|
||||
- **Promotes**: significant changes, confirmed anomalies, and follow-up actions to `memory/open-loops.md` and `memory/decisions.md`.
|
||||
- **Next handoff**: use the `Next Best Skill` below when a change needs action.
|
||||
|
||||
## Data Sources
|
||||
|
||||
> **Note:** All integrations are optional. This skill works without any API keys — users provide data manually when no tools are connected.
|
||||
|
||||
> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
|
||||
|
||||
**With ~~link database + ~~SEO tool connected:**
|
||||
Automatically pull comprehensive backlink profiles including referring domains, anchor text distribution, link quality metrics (DA/DR), link velocity, and toxic link detection from ~~link database. Competitor backlink data from ~~SEO tool for gap analysis.
|
||||
|
||||
**With manual data only:**
|
||||
Ask the user to provide:
|
||||
1. Backlink export CSV (with source domains, anchor text, link type)
|
||||
2. Referring domains list with authority metrics
|
||||
3. Competitor domains for comparison
|
||||
4. Recent link gains/losses if tracking changes
|
||||
5. Any known toxic or spammy links
|
||||
|
||||
Proceed with the full analysis using provided data. Note in the output which metrics are from automated collection vs. user-provided data.
|
||||
|
||||
## Instructions
|
||||
|
||||
When a user requests backlink analysis:
|
||||
|
||||
1. **Generate Profile Overview** -- Key metrics (total backlinks, referring domains, DA/DR, dofollow ratio), link velocity (30d/90d/year), authority distribution chart, profile health score.
|
||||
|
||||
2. **Analyze Link Quality** -- Top quality backlinks table, link type distribution, anchor text analysis (brand/exact/partial/URL/generic), geographic distribution.
|
||||
|
||||
3. **Identify Toxic Links** -- Toxic score, risk indicators by type (spam, PBN, link farms, irrelevant), high-risk links to review, disavow recommendations (domain-level and URL-level).
|
||||
|
||||
4. **Compare Against Competitors** -- Profile comparison table (referring domains, DA/DR, velocity, avg link DA), unique referring domains, link intersection analysis, competitor content attracting most links.
|
||||
|
||||
5. **Find Link Building Opportunities** -- Link intersection prospects, broken link opportunities, unlinked mentions, resource page opportunities, guest post prospects, priority matrix (effort vs impact).
|
||||
|
||||
6. **Track Link Changes** -- New and lost links for last 30 days with DA, type, anchor, dates. Net change and links to recover.
|
||||
|
||||
7. **Generate Backlink Report** -- Executive summary, strengths, concerns, opportunities, competitive position, recommended actions (immediate/short-term/long-term), KPIs to track.
|
||||
|
||||
> **Reference**: See [references/analysis-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/backlink-analyzer/references/analysis-templates.md) for complete output templates for all 7 steps above.
|
||||
|
||||
### CITE Item Mapping
|
||||
|
||||
When running `domain-authority-auditor` after this analysis, the following data feeds directly into CITE scoring:
|
||||
|
||||
| Backlink Metric | CITE Item | Dimension |
|
||||
|----------------|-----------|-----------|
|
||||
| Referring domains count | C01 (Referring Domain Volume) | Citation |
|
||||
| Authority distribution (DA breakdown) | C02 (Referring Domains Quality) | Citation |
|
||||
| Link velocity | C04 (Link Velocity) | Citation |
|
||||
| Geographic distribution | C10 (Link Source Diversity) | Citation |
|
||||
| Dofollow/Nofollow ratio | T02 (Dofollow Ratio Normality) | Trust |
|
||||
| Toxic link analysis | T01 (Link Profile Naturalness), T03 (Link-Traffic Coherence) | Trust |
|
||||
| Competitive link intersection | T05 (Profile Uniqueness) | Trust |
|
||||
|
||||
## Validation Checkpoints
|
||||
|
||||
### Input Validation
|
||||
- [ ] Target domain backlink data is complete and current
|
||||
- [ ] Competitor domains specified for comparison analysis
|
||||
- [ ] Backlink data includes necessary fields (source domain, anchor text, link type)
|
||||
- [ ] Authority metrics available (DA/DR or equivalent)
|
||||
|
||||
### Output Validation
|
||||
- [ ] Every metric cites its data source and collection date
|
||||
- [ ] Toxic link assessments include risk justification
|
||||
- [ ] Link opportunity recommendations are specific and actionable
|
||||
- [ ] Source of each data point clearly stated (~~link database data, ~~SEO tool data, user-provided, or estimated)
|
||||
|
||||
## Example
|
||||
|
||||
**User**: "Find link building opportunities by analyzing HubSpot, Salesforce, and Mailchimp"
|
||||
|
||||
**Output**:
|
||||
|
||||
```markdown
|
||||
## Link Intersection Analysis
|
||||
|
||||
### Sites linking to 2+ competitors (not you)
|
||||
|
||||
| Domain | DA | HubSpot | Salesforce | Mailchimp | Opportunity |
|
||||
|--------|-----|---------|------------|-----------|-------------|
|
||||
| g2.com | 91 | ✅ | ✅ | ✅ | Get listed/reviewed |
|
||||
| capterra.com | 89 | ✅ | ✅ | ✅ | Submit for review |
|
||||
| entrepreneur.com | 92 | ✅ | ✅ | ❌ | Pitch guest post |
|
||||
| techcrunch.com | 94 | ✅ | ❌ | ✅ | PR/news pitch |
|
||||
|
||||
### Top 5 Immediate Opportunities
|
||||
|
||||
1. **G2.com** (DA 91) - All competitors listed
|
||||
- Action: Create detailed G2 profile
|
||||
- Effort: Low
|
||||
- Impact: High authority + referral traffic
|
||||
|
||||
2. **Entrepreneur.com** (DA 92) - 2 competitors have links
|
||||
- Action: Pitch contributed article
|
||||
- Effort: High
|
||||
- Impact: High authority + brand exposure
|
||||
|
||||
3. **MarketingProfs** (DA 75) - All competitors featured
|
||||
- Action: Apply for expert contribution
|
||||
- Effort: Medium
|
||||
- Impact: Relevant audience + quality link
|
||||
|
||||
### Estimated Impact
|
||||
|
||||
If you acquire links from top 10 opportunities:
|
||||
- New referring domains: +10
|
||||
- Average DA of new links: 82
|
||||
- Estimated ranking impact: +2-5 positions for competitive keywords
|
||||
```
|
||||
|
||||
## Tips for Success
|
||||
|
||||
1. **Quality over quantity** - One DA 80 link beats ten DA 20 links
|
||||
2. **Monitor regularly** - Catch lost links and toxic links early
|
||||
3. **Study competitors** - Learn from their link building success
|
||||
4. **Diversify your profile** - Mix of link types and anchors
|
||||
5. **Disavow carefully** - Only disavow clearly toxic links
|
||||
|
||||
## Link Quality and Strategy Reference
|
||||
|
||||
> **Reference**: See [references/link-quality-rubric.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/backlink-analyzer/references/link-quality-rubric.md) for the complete link quality scoring matrix (6 weighted factors), toxic link identification criteria, link profile health benchmarks, and disavow file guidance.
|
||||
|
||||
> **Reference**: See [references/outreach-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/backlink-analyzer/references/outreach-templates.md) for email outreach frameworks, subject line formulas, response rate benchmarks, follow-up sequences, and templates for each link building strategy.
|
||||
|
||||
|
||||
### Save Results
|
||||
|
||||
After delivering monitoring data or reports to the user, ask:
|
||||
|
||||
> "Save these results for future sessions?"
|
||||
|
||||
If yes, write a dated summary to `memory/monitoring/YYYY-MM-DD-<topic>.md` containing:
|
||||
- One-line headline finding or status change
|
||||
- Top 3-5 actionable items
|
||||
- Open loops or anomalies requiring follow-up
|
||||
- Source data references
|
||||
|
||||
If any findings should influence ongoing strategy, recommend promoting key conclusions to `memory/hot-cache.md`.
|
||||
|
||||
|
||||
**Gate check recommended**: If toxic link ratio exceeds 15%, recommend running domain-authority-auditor to assess overall domain trust impact.
|
||||
|
||||
## Reference Materials
|
||||
|
||||
- [Link Quality Rubric](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/backlink-analyzer/references/link-quality-rubric.md) — Quality scoring matrix with weighted factors and toxic link identification criteria
|
||||
- [Outreach Templates](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/backlink-analyzer/references/outreach-templates.md) — Email frameworks, subject line formulas, and response rate benchmarks
|
||||
|
||||
## Next Best Skill
|
||||
|
||||
- **Primary**: [domain-authority-auditor](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/domain-authority-auditor/SKILL.md) — translate link findings into a domain-level trust view.
|
||||
@@ -0,0 +1,309 @@
|
||||
---
|
||||
name: competitor-analysis
|
||||
description: 'Analyze competitor SEO/GEO: keywords, content, backlinks, AI citations, traffic share gaps. 竞品分析/竞争对手'
|
||||
version: "6.0.0"
|
||||
license: Apache-2.0
|
||||
compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
|
||||
homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
|
||||
when_to_use: "Use when analyzing competitor SEO strategy, comparing domains, benchmarking against competitors, or finding competitor keywords and content gaps."
|
||||
argument-hint: "<competitor URL or domain>"
|
||||
metadata:
|
||||
author: aaron-he-zhu
|
||||
version: "6.0.0"
|
||||
geo-relevance: "medium"
|
||||
tags:
|
||||
- seo
|
||||
- geo
|
||||
- competitor-analysis
|
||||
- competitive-intelligence
|
||||
- benchmarking
|
||||
- competitor-keywords
|
||||
- competitor-backlinks
|
||||
- market-analysis
|
||||
- spyfu-alternative
|
||||
- 竞品分析
|
||||
- 競合分析
|
||||
- 경쟁분석
|
||||
- analisis-competitivo
|
||||
triggers:
|
||||
# EN-formal
|
||||
- "analyze competitors"
|
||||
- "competitor SEO"
|
||||
- "competitive analysis"
|
||||
- "competitor keywords"
|
||||
- "competitor backlinks"
|
||||
- "market analysis"
|
||||
- "competitive intelligence"
|
||||
# EN-casual
|
||||
- "what are my competitors doing"
|
||||
- "what are they doing differently"
|
||||
- "why do they rank higher"
|
||||
- "spy on competitor SEO"
|
||||
- "what are they doing better"
|
||||
- "why do they outrank me"
|
||||
# EN-question
|
||||
- "who are my SEO competitors"
|
||||
- "how do I beat my competitors"
|
||||
- "why do competitors rank higher"
|
||||
# EN-competitor
|
||||
- "SpyFu alternative"
|
||||
- "Semrush competitor analysis"
|
||||
- "Ahrefs competitor tool"
|
||||
# ZH-pro
|
||||
- "竞品分析"
|
||||
- "竞争对手分析"
|
||||
- "竞品SEO"
|
||||
- "对标分析"
|
||||
- "竞争情报"
|
||||
# ZH-casual
|
||||
- "竞品怎么做的"
|
||||
- "他们排名为什么比我高"
|
||||
- "看看对手在干什么"
|
||||
- "为什么他们排名好"
|
||||
# JA
|
||||
- "競合分析"
|
||||
- "競合SEO分析"
|
||||
- "ライバル分析"
|
||||
# KO
|
||||
- "경쟁 분석"
|
||||
- "경쟁사 SEO"
|
||||
- "경쟁사 키워드"
|
||||
# ES
|
||||
- "análisis de competidores"
|
||||
- "análisis competitivo SEO"
|
||||
# PT
|
||||
- "análise de concorrentes"
|
||||
# Misspellings
|
||||
- "competitve analysis"
|
||||
- "compeditor analysis"
|
||||
---
|
||||
|
||||
# Competitor Analysis
|
||||
|
||||
|
||||
> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
|
||||
> **System Mode**: This research skill follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
|
||||
|
||||
|
||||
This skill provides comprehensive analysis of competitor SEO and GEO strategies, revealing what's working in your market and identifying opportunities to outperform the competition.
|
||||
|
||||
**System role**: Research layer skill. It turns market signals into reusable strategic inputs for the rest of the library.
|
||||
|
||||
## When This Must Trigger
|
||||
|
||||
Use this when the conversation involves any of these situations — even if the user does not use SEO terminology:
|
||||
|
||||
Use this whenever the task needs reusable market intelligence that should influence strategy, not just an ad hoc answer.
|
||||
|
||||
- Entering a new market or niche
|
||||
- Planning content strategy based on competitor success
|
||||
- Understanding why competitors rank higher
|
||||
- Finding backlink and partnership opportunities
|
||||
- Identifying content gaps competitors are missing
|
||||
- Analyzing competitor AI citation strategies
|
||||
- Benchmarking your SEO performance
|
||||
|
||||
## What This Skill Does
|
||||
|
||||
1. **Keyword Analysis**: Identifies keywords competitors rank for
|
||||
2. **Content Audit**: Analyzes competitor content strategies and formats
|
||||
3. **Backlink Profiling**: Reviews competitor link-building approaches
|
||||
4. **Technical Assessment**: Evaluates competitor site health
|
||||
5. **GEO Analysis**: Identifies how competitors appear in AI responses
|
||||
6. **Gap Identification**: Finds opportunities competitors miss
|
||||
7. **Strategy Extraction**: Reveals actionable insights from competitor success
|
||||
|
||||
## Quick Start
|
||||
|
||||
Start with one of these prompts. Finish with a short handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
|
||||
|
||||
### Basic Competitor Analysis
|
||||
|
||||
```
|
||||
Analyze SEO strategy for [competitor URL]
|
||||
```
|
||||
|
||||
```
|
||||
Compare my site [URL] against [competitor 1], [competitor 2], [competitor 3]
|
||||
```
|
||||
|
||||
### Specific Analysis
|
||||
|
||||
```
|
||||
What content is driving the most traffic for [competitor]?
|
||||
```
|
||||
|
||||
```
|
||||
Analyze why [competitor] ranks #1 for [keyword]
|
||||
```
|
||||
|
||||
### GEO-Focused Analysis
|
||||
|
||||
```
|
||||
How is [competitor] getting cited in AI responses? What can I learn?
|
||||
```
|
||||
|
||||
## Skill Contract
|
||||
|
||||
**Expected output**: a prioritized research brief, evidence-backed findings, and a short handoff summary ready for `memory/research/`.
|
||||
|
||||
- **Reads**: user goals, target market inputs, available tool data, and prior strategy from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
|
||||
- **Writes**: a user-facing research deliverable plus a reusable summary that can be stored under `memory/research/`.
|
||||
- **Promotes**: durable keyword priorities, competitor facts, entity candidates, and strategic decisions to `CLAUDE.md`, `memory/decisions.md`, and `memory/research/`; hand canonical entity work to `entity-optimizer`.
|
||||
- **Next handoff**: use the `Next Best Skill` below when the findings are ready to drive action.
|
||||
|
||||
## Data Sources
|
||||
|
||||
> **Note:** All integrations are optional. This skill works without any API keys — users provide data manually when no tools are connected.
|
||||
|
||||
> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
|
||||
|
||||
**With ~~SEO tool + ~~analytics + ~~AI monitor connected:**
|
||||
Automatically pull competitor keyword rankings, backlink profiles, top performing content, domain authority metrics from ~~SEO tool. Compare against your site's metrics from ~~analytics and ~~search console. Check AI citation patterns for both your site and competitors using ~~AI monitor.
|
||||
|
||||
**With manual data only:**
|
||||
Ask the user to provide:
|
||||
1. Competitor URLs to analyze (2-5 recommended)
|
||||
2. Your own site URL and current metrics (traffic, rankings if known)
|
||||
3. Industry or niche context
|
||||
4. Specific aspects to focus on (keywords, content, backlinks, etc.)
|
||||
5. Any known competitor strengths or weaknesses
|
||||
|
||||
Proceed with the full analysis using provided data. Note in the output which metrics are from automated collection vs. user-provided data.
|
||||
|
||||
## Instructions
|
||||
|
||||
When a user requests competitor analysis:
|
||||
|
||||
1. **Identify Competitors**
|
||||
|
||||
If not specified, help identify competitors:
|
||||
|
||||
```markdown
|
||||
### Competitor Identification Framework
|
||||
|
||||
**Direct Competitors** (same product/service)
|
||||
- Search "[your main keyword]" and note top 5 organic results
|
||||
- Check who's advertising for your keywords
|
||||
- Ask: Who do customers compare you to?
|
||||
|
||||
**Indirect Competitors** (different solution, same problem)
|
||||
- Search problem-focused keywords
|
||||
- Look at alternative solutions
|
||||
|
||||
**Content Competitors** (compete for same keywords)
|
||||
- May not sell same product
|
||||
- Rank for your target keywords
|
||||
- Include media sites, blogs, aggregators
|
||||
```
|
||||
|
||||
2. **Gather Competitor Data**
|
||||
|
||||
Collect for each competitor: URL, domain age, estimated traffic, domain authority, business model, target audience, and key offerings.
|
||||
|
||||
3. **Analyze Keyword Rankings**
|
||||
|
||||
Document total keywords ranking, top 10/top 3 counts, top performing keywords (with position, volume, traffic, page URL), keyword distribution by intent, and keyword gaps.
|
||||
|
||||
4. **Audit Content Strategy**
|
||||
|
||||
Analyze content volume by type, top performing content, content patterns (word count, frequency, formats), content themes, and success factors.
|
||||
|
||||
5. **Analyze Backlink Profile**
|
||||
|
||||
Review total backlinks, referring domains, link quality distribution, top linking domains, link acquisition patterns, and linkable assets.
|
||||
|
||||
6. **Technical SEO Assessment**
|
||||
|
||||
Evaluate Core Web Vitals, mobile-friendliness, site architecture, internal linking quality, URL structure, and technical strengths/weaknesses.
|
||||
|
||||
7. **GEO/AI Citation Analysis**
|
||||
|
||||
Test competitor content in AI systems: document which queries cite them, GEO strategies observed (definitions, statistics, Q&A, authority signals), and GEO opportunities they are missing.
|
||||
|
||||
8. **Synthesize Competitive Intelligence**
|
||||
|
||||
Produce a final report with: Executive Summary, Competitive Landscape comparison table, CITE domain authority comparison, Strengths to Learn From, Weaknesses to Exploit, Keyword Opportunities, Content Strategy Recommendations, and Action Plan (Immediate / Short-term / Long-term).
|
||||
|
||||
> **Reference**: See [references/analysis-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/competitor-analysis/references/analysis-templates.md) for detailed templates for each step.
|
||||
|
||||
## Validation Checkpoints
|
||||
|
||||
### Input Validation
|
||||
- [ ] Competitor URLs verified as relevant to your niche
|
||||
- [ ] Analysis scope defined (comprehensive or specific focus area)
|
||||
- [ ] Your own site metrics available for comparison
|
||||
- [ ] Minimum 2-3 competitors identified for meaningful patterns
|
||||
|
||||
### Output Validation
|
||||
- [ ] Every recommendation cites specific data points (not generic advice)
|
||||
- [ ] Competitor strengths backed by measurable evidence (metrics, rankings)
|
||||
- [ ] Opportunities based on identifiable gaps, not assumptions
|
||||
- [ ] Action plan items are specific and actionable (not vague strategies)
|
||||
- [ ] Source of each data point clearly stated (~~SEO tool data, ~~analytics data, ~~AI monitor data, user-provided, or estimated)
|
||||
|
||||
## Example
|
||||
|
||||
> **Reference**: See [references/example-report.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/competitor-analysis/references/example-report.md) for a complete example analyzing HubSpot's marketing keyword dominance.
|
||||
|
||||
## Advanced Analysis Types
|
||||
|
||||
### Content Gap Analysis
|
||||
|
||||
```
|
||||
Show me content [competitor] has that I don't, sorted by traffic potential
|
||||
```
|
||||
|
||||
### Link Intersection
|
||||
|
||||
```
|
||||
Find sites linking to [competitor 1] AND [competitor 2] but not me
|
||||
```
|
||||
|
||||
### SERP Feature Analysis
|
||||
|
||||
```
|
||||
What SERP features do competitors win? (Featured snippets, PAA, etc.)
|
||||
```
|
||||
|
||||
### Historical Tracking
|
||||
|
||||
```
|
||||
How has [competitor]'s SEO strategy evolved over the past year?
|
||||
```
|
||||
|
||||
## Tips for Success
|
||||
|
||||
1. **Analyze 3-5 competitors** for comprehensive view
|
||||
2. **Include indirect competitors** - they often have innovative approaches
|
||||
3. **Look beyond rankings** - analyze content quality, user experience
|
||||
4. **Study their failures** - avoid their mistakes
|
||||
5. **Monitor regularly** - competitor strategies evolve
|
||||
6. **Focus on actionable insights** - what can you actually implement?
|
||||
|
||||
|
||||
|
||||
### Save Results
|
||||
|
||||
After delivering findings to the user, ask:
|
||||
|
||||
> "Save these results for future sessions?"
|
||||
|
||||
If yes, write a dated summary to `memory/research/competitor-analysis/YYYY-MM-DD-<topic>.md` containing:
|
||||
- One-line headline finding
|
||||
- Top 3-5 actionable items
|
||||
- Open loops or blockers
|
||||
- Source data references
|
||||
|
||||
If any findings should influence ongoing strategy, recommend promoting key conclusions to `memory/hot-cache.md`.
|
||||
|
||||
## Reference Materials
|
||||
|
||||
- [Analysis Templates](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/competitor-analysis/references/analysis-templates.md) — Detailed templates for each analysis step (profile, keywords, content, backlinks, technical, GEO, synthesis)
|
||||
- [Battlecard Template](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/competitor-analysis/references/battlecard-template.md) — Quick-reference competitive battlecard for sales and marketing teams
|
||||
- [Positioning Frameworks](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/competitor-analysis/references/positioning-frameworks.md) — Positioning maps, messaging matrices, narrative analysis, and differentiation frameworks
|
||||
- [Example Report](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/competitor-analysis/references/example-report.md) — Complete example analyzing HubSpot's marketing keyword dominance
|
||||
|
||||
## Next Best Skill
|
||||
|
||||
- **Primary**: [content-gap-analysis](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/content-gap-analysis/SKILL.md) — turn competitor findings into a focused opportunity map.
|
||||
@@ -0,0 +1,287 @@
|
||||
---
|
||||
name: content-gap-analysis
|
||||
description: 'Find content gaps: topics and keywords competitors cover that you don''t, with editorial calendar. 内容缺口/选题规划'
|
||||
version: "6.0.0"
|
||||
license: Apache-2.0
|
||||
compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
|
||||
homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
|
||||
when_to_use: "Use when finding content gaps between two domains, discovering missing topics, or identifying coverage holes versus competitors."
|
||||
argument-hint: "<your domain> <competitor domain>"
|
||||
metadata:
|
||||
author: aaron-he-zhu
|
||||
version: "6.0.0"
|
||||
geo-relevance: "medium"
|
||||
tags:
|
||||
- seo
|
||||
- geo
|
||||
- content-gaps
|
||||
- topic-analysis
|
||||
- content-strategy
|
||||
- editorial-calendar
|
||||
- competitive-gap
|
||||
- content-opportunities
|
||||
- 内容缺口
|
||||
- コンテンツギャップ
|
||||
- 콘텐츠갭
|
||||
- brechas-contenido
|
||||
triggers:
|
||||
# EN-formal
|
||||
- "find content gaps"
|
||||
- "content opportunities"
|
||||
- "topic analysis"
|
||||
- "content strategy gaps"
|
||||
- "editorial calendar"
|
||||
- "untapped topics"
|
||||
# EN-casual
|
||||
- "what am I missing"
|
||||
- "topics to cover"
|
||||
- "what do competitors write about"
|
||||
- "what should I cover next"
|
||||
- "topics I haven't written about"
|
||||
- "they cover this but I don't"
|
||||
# EN-question
|
||||
- "what topics am I missing"
|
||||
- "what content should I create"
|
||||
# ZH-pro
|
||||
- "内容缺口分析"
|
||||
- "选题规划"
|
||||
- "内容机会"
|
||||
- "竞品话题"
|
||||
# ZH-casual
|
||||
- "缺什么内容"
|
||||
- "竞品写了什么"
|
||||
- "还应该写什么"
|
||||
# JA
|
||||
- "コンテンツギャップ"
|
||||
- "コンテンツ機会"
|
||||
# KO
|
||||
- "콘텐츠 갭 분석"
|
||||
- "콘텐츠 기회"
|
||||
# ES
|
||||
- "brechas de contenido"
|
||||
- "oportunidades de contenido"
|
||||
# PT
|
||||
- "lacunas de conteúdo"
|
||||
# Misspellings
|
||||
- "content gab analysis"
|
||||
---
|
||||
|
||||
# Content Gap Analysis
|
||||
|
||||
|
||||
> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
|
||||
> **System Mode**: This research skill follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
|
||||
|
||||
|
||||
Identifies content opportunities by analyzing gaps between a site's content and competitors'. Surfaces missing topics, untapped keywords, and content formats worth creating.
|
||||
|
||||
**System role**: Research layer skill. It turns market signals into reusable strategic inputs for the rest of the library.
|
||||
|
||||
## When This Must Trigger
|
||||
|
||||
Use this when the conversation involves any of these situations — even if the user does not use SEO terminology:
|
||||
|
||||
Use this whenever the task needs reusable market intelligence that should influence strategy, not just an ad hoc answer.
|
||||
|
||||
- Planning content strategy and editorial calendar
|
||||
- Finding quick-win content opportunities
|
||||
- Understanding where competitors outperform you
|
||||
- Identifying underserved topics in your niche
|
||||
- Expanding into adjacent topic areas
|
||||
- Prioritizing content creation efforts
|
||||
- Finding GEO opportunities competitors miss
|
||||
|
||||
## What This Skill Does
|
||||
|
||||
1. **Keyword Gap Analysis**: Finds keywords competitors rank for that you don't
|
||||
2. **Topic Coverage Mapping**: Identifies topic areas needing more content
|
||||
3. **Content Format Gaps**: Reveals missing content types (videos, tools, guides)
|
||||
4. **Audience Need Mapping**: Matches gaps to audience journey stages
|
||||
5. **GEO Opportunity Detection**: Finds AI-answerable topics you're missing
|
||||
6. **Priority Scoring**: Ranks gaps by impact and effort
|
||||
7. **Content Calendar Creation**: Plans gap-filling content schedule
|
||||
|
||||
## Quick Start
|
||||
|
||||
Start with one of these prompts. Finish with a short handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
|
||||
|
||||
### Basic Gap Analysis
|
||||
|
||||
```
|
||||
Find content gaps between my site [URL] and [competitor URLs]
|
||||
```
|
||||
|
||||
```
|
||||
What content am I missing compared to my top 3 competitors?
|
||||
```
|
||||
|
||||
### Topic-Specific Analysis
|
||||
|
||||
```
|
||||
Find content gaps in [topic area] compared to industry leaders
|
||||
```
|
||||
|
||||
```
|
||||
What [content type] do competitors have that I don't?
|
||||
```
|
||||
|
||||
### Audience-Focused
|
||||
|
||||
```
|
||||
What content gaps exist for [audience segment] in my niche?
|
||||
```
|
||||
|
||||
## Skill Contract
|
||||
|
||||
**Expected output**: a prioritized research brief, evidence-backed findings, and a short handoff summary ready for `memory/research/`.
|
||||
|
||||
- **Reads**: user goals, target market inputs, available tool data, and prior strategy from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
|
||||
- **Writes**: a user-facing research deliverable plus a reusable summary that can be stored under `memory/research/`.
|
||||
- **Promotes**: durable keyword priorities, competitor facts, entity candidates, and strategic decisions to `CLAUDE.md`, `memory/decisions.md`, and `memory/research/`; hand canonical entity work to `entity-optimizer`.
|
||||
- **Next handoff**: use the `Next Best Skill` below when the findings are ready to drive action.
|
||||
|
||||
## Data Sources
|
||||
|
||||
> **Note:** All integrations are optional. This skill works without any API keys — users provide data manually when no tools are connected.
|
||||
|
||||
> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
|
||||
|
||||
**With ~~SEO tool + ~~search console + ~~analytics + ~~AI monitor connected:**
|
||||
Automatically pull your site's content inventory from ~~search console and ~~analytics (indexed pages, traffic per page, keywords ranking), competitor content data from ~~SEO tool (ranking keywords, top pages, backlink counts), and AI citation patterns from ~~AI monitor. Keyword overlap analysis and gap identification can be automated.
|
||||
|
||||
**With manual data only:**
|
||||
Ask the user to provide:
|
||||
1. Your site URL and content inventory (list of published content with topics)
|
||||
2. Competitor URLs (3-5 sites)
|
||||
3. Your current traffic and keyword performance (if available)
|
||||
4. Known content strengths and weaknesses
|
||||
5. Industry context and business goals
|
||||
|
||||
Proceed with the full analysis using provided data. Note in the output which metrics are from automated collection vs. user-provided data.
|
||||
|
||||
## Instructions
|
||||
|
||||
When a user requests content gap analysis:
|
||||
|
||||
1. **Define Analysis Scope**
|
||||
|
||||
Clarify parameters:
|
||||
|
||||
```markdown
|
||||
### Analysis Parameters
|
||||
|
||||
**Your Site**: [URL]
|
||||
**Competitors to Analyze**: [URLs or "identify for me"]
|
||||
**Topic Focus**: [specific area or "all"]
|
||||
**Content Types**: [blogs, guides, tools, videos, or "all"]
|
||||
**Audience**: [target audience]
|
||||
**Business Goals**: [traffic, leads, authority, etc.]
|
||||
```
|
||||
|
||||
2. **Audit Your Existing Content**
|
||||
|
||||
Document total indexed pages, content by type and topic cluster, top performing content, and content strengths/weaknesses.
|
||||
|
||||
3. **Analyze Competitor Content**
|
||||
|
||||
For each competitor: document content volume, monthly traffic, content distribution by type, topic coverage vs. yours, and unique content they have.
|
||||
|
||||
4. **Identify Keyword Gaps**
|
||||
|
||||
Find keywords competitors rank for that you do not. Categorize into High Priority (high volume, achievable difficulty), Quick Wins (lower volume, low difficulty), and Long-term (high volume, high difficulty). Include keyword overlap analysis.
|
||||
|
||||
5. **Map Topic Gaps**
|
||||
|
||||
Create a topic coverage comparison matrix across all competitors. For each missing topic cluster, document business relevance, competitor coverage, opportunity size, sub-topics, and recommended pillar/cluster approach.
|
||||
|
||||
6. **Identify Content Format Gaps**
|
||||
|
||||
Compare format distribution (guides, tutorials, comparisons, case studies, tools, templates, video, infographics, research) against competitors and industry averages. For each gap, assess effort and expected impact.
|
||||
|
||||
7. **Analyze GEO/AI Gaps**
|
||||
|
||||
Identify topics where competitors get AI citations but you do not. Document missing Q&A content, definition/explanation content, and comparison content. Score each by traditional SEO value and GEO value.
|
||||
|
||||
8. **Map to Audience Journey**
|
||||
|
||||
Compare funnel stage coverage (Awareness, Consideration, Decision, Retention) against competitor averages. Detail specific gaps at each stage.
|
||||
|
||||
9. **Prioritize and Create Action Plan**
|
||||
|
||||
Produce a final report with: Executive Summary, Prioritized Gap List (Tier 1 Quick Wins, Tier 2 Strategic Builds, Tier 3 Long-term), Content Calendar, and Success Metrics.
|
||||
|
||||
> **Reference**: See [references/analysis-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/content-gap-analysis/references/analysis-templates.md) for detailed templates for each step.
|
||||
|
||||
## Validation Checkpoints
|
||||
|
||||
### Input Validation
|
||||
- [ ] Your content inventory is complete or representative sample provided
|
||||
- [ ] Competitor URLs identified (minimum 2-3 competitors)
|
||||
- [ ] Analysis scope defined (specific topics or comprehensive)
|
||||
- [ ] Business goals and priorities clarified
|
||||
|
||||
### Output Validation
|
||||
- [ ] Every recommendation cites specific data points (not generic advice)
|
||||
- [ ] Gap analysis compares like-to-like content (topic clusters to topic clusters)
|
||||
- [ ] Priority scoring based on measurable criteria (volume, difficulty, business fit)
|
||||
- [ ] Content calendar maps gaps to realistic timeframes
|
||||
- [ ] Source of each data point clearly stated (~~SEO tool data, ~~analytics data, ~~AI monitor data, user-provided, or estimated)
|
||||
|
||||
## Example
|
||||
|
||||
> **Reference**: See [references/example-report.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/content-gap-analysis/references/example-report.md) for a complete example analyzing SaaS marketing blog gaps vs. HubSpot and Drift.
|
||||
|
||||
## Advanced Analysis
|
||||
|
||||
### Competitive Cluster Comparison
|
||||
|
||||
```
|
||||
Compare our topic cluster coverage for [topic] vs top 5 competitors
|
||||
```
|
||||
|
||||
### Temporal Gap Analysis
|
||||
|
||||
```
|
||||
What content have competitors published in the last 6 months that we haven't covered?
|
||||
```
|
||||
|
||||
### Intent-Based Gaps
|
||||
|
||||
```
|
||||
Find gaps in our [commercial/informational] intent content
|
||||
```
|
||||
|
||||
## Tips for Success
|
||||
|
||||
1. **Focus on actionable gaps** - Not all gaps are worth filling
|
||||
2. **Consider your resources** - Prioritize based on ability to execute
|
||||
3. **Quality over quantity** - Better to fill 5 gaps well than 20 poorly
|
||||
4. **Track what works** - Measure gap-filling success
|
||||
5. **Update regularly** - Gaps change as competitors publish
|
||||
6. **Include GEO opportunities** - Don't just optimize for traditional search
|
||||
|
||||
|
||||
|
||||
### Save Results
|
||||
|
||||
After delivering findings to the user, ask:
|
||||
|
||||
> "Save these results for future sessions?"
|
||||
|
||||
If yes, write a dated summary to `memory/research/content-gap-analysis/YYYY-MM-DD-<topic>.md` containing:
|
||||
- One-line headline finding
|
||||
- Top 3-5 actionable items
|
||||
- Open loops or blockers
|
||||
- Source data references
|
||||
|
||||
If any findings should influence ongoing strategy, recommend promoting key conclusions to `memory/hot-cache.md`.
|
||||
|
||||
## Reference Materials
|
||||
|
||||
- [Analysis Templates](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/content-gap-analysis/references/analysis-templates.md) — Detailed templates for each analysis step (inventory, competitor content, keyword gaps, topic gaps, format gaps, GEO gaps, journey, prioritized report)
|
||||
- [Gap Analysis Frameworks](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/content-gap-analysis/references/gap-analysis-frameworks.md) — Content audit matrices, funnel mapping, and gap prioritization scoring methodologies
|
||||
- [Example Report](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/content-gap-analysis/references/example-report.md) — Complete example analyzing SaaS marketing blog gaps vs. HubSpot and Drift
|
||||
|
||||
## Next Best Skill
|
||||
|
||||
- **Primary**: [seo-content-writer](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/seo-content-writer/SKILL.md) — turn missing topics into a draft or content roadmap.
|
||||
@@ -0,0 +1,403 @@
|
||||
---
|
||||
name: content-quality-auditor
|
||||
description: 'Publish-readiness gate: 80-item CORE-EEAT audit with weighted scoring, veto checks, and fix plan. 内容质量/EEAT评分'
|
||||
version: "6.0.0"
|
||||
license: Apache-2.0
|
||||
allowed-tools: WebFetch
|
||||
compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
|
||||
homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
|
||||
when_to_use: "Use when auditing content quality before publishing. Runs CORE-EEAT 80-item scoring with veto checks. Also when the user asks for E-E-A-T analysis or publish readiness."
|
||||
argument-hint: "<URL or paste content> [keyword]"
|
||||
metadata:
|
||||
author: aaron-he-zhu
|
||||
version: "6.0.0"
|
||||
geo-relevance: "high"
|
||||
tags:
|
||||
- seo
|
||||
- geo
|
||||
- e-e-a-t
|
||||
- core-eeat
|
||||
- content-quality
|
||||
- content-scoring
|
||||
- helpful-content
|
||||
- publish-readiness
|
||||
- 内容质量
|
||||
- コンテンツ品質
|
||||
- 콘텐츠품질
|
||||
- auditoria-eeat
|
||||
triggers:
|
||||
# EN-formal
|
||||
- "audit content quality"
|
||||
- "EEAT score"
|
||||
- "CORE-EEAT audit"
|
||||
- "content quality check"
|
||||
- "content assessment"
|
||||
- "quality score"
|
||||
# EN-casual
|
||||
- "is this ready to publish"
|
||||
- "grade my article"
|
||||
- "check before publishing"
|
||||
- "how good is my content"
|
||||
- "is my content good enough to rank"
|
||||
- "rate my content quality"
|
||||
# EN-question
|
||||
- "is my content ready to publish"
|
||||
- "how do I improve content quality"
|
||||
# ZH-pro
|
||||
- "内容质量审计"
|
||||
- "EEAT评分"
|
||||
- "内容评估"
|
||||
# ZH-casual
|
||||
- "文章能发吗"
|
||||
- "内容打几分"
|
||||
- "文章写得怎么样"
|
||||
# JA
|
||||
- "コンテンツ品質監査"
|
||||
- "E-E-A-T評価"
|
||||
# KO
|
||||
- "콘텐츠 품질 감사"
|
||||
- "EEAT 점수"
|
||||
# ES
|
||||
- "auditoría de calidad de contenido"
|
||||
- "puntuación EEAT"
|
||||
# PT
|
||||
- "auditoria de qualidade"
|
||||
# Misspellings
|
||||
- "EEAT scroe"
|
||||
---
|
||||
|
||||
# Content Quality Auditor
|
||||
|
||||
> Based on [CORE-EEAT Content Benchmark](https://github.com/aaron-he-zhu/core-eeat-content-benchmark). Full benchmark reference: [references/core-eeat-benchmark.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/core-eeat-benchmark.md)
|
||||
|
||||
|
||||
> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
|
||||
> **System Mode**: This cross-cutting skill is part of the protocol layer and follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
|
||||
|
||||
|
||||
This skill evaluates content quality across 80 standardized criteria organized in 8 dimensions. It produces a comprehensive audit report with per-item scoring, dimension and system scores, weighted totals by content type, and a prioritized action plan.
|
||||
|
||||
**System role**: Publish Readiness Gate. It decides whether content is ready to ship, what blocks publication, and what should be promoted into durable project memory.
|
||||
|
||||
## When This Must Trigger
|
||||
|
||||
Use this when content needs a quality check before publishing — even if the user doesn't use audit terminology:
|
||||
|
||||
- User asks "is this ready to publish" or "how good is this"
|
||||
- User just finished writing with seo-content-writer or content-refresher
|
||||
- **PostToolUse hook auto-triggers**: after content is written or substantially edited, the hook recommends this audit. When hook-triggered, skip setup questions — audit the content that was just produced.
|
||||
- Auditing content quality before publishing
|
||||
- Evaluating existing content for improvement opportunities
|
||||
- Benchmarking content against CORE-EEAT standards
|
||||
- Comparing content quality against competitors
|
||||
- Assessing both GEO readiness (AI citation potential) and SEO strength (source credibility)
|
||||
- Running periodic content quality checks as part of a content maintenance program
|
||||
- After writing or optimizing content with seo-content-writer or geo-content-optimizer
|
||||
|
||||
## What This Skill Does
|
||||
|
||||
1. **Full 80-Item Audit**: Scores every CORE-EEAT check item as Pass/Partial/Fail
|
||||
2. **Dimension Scoring**: Calculates scores for all 8 dimensions (0-100 each)
|
||||
3. **System Scoring**: Computes GEO Score (CORE) and SEO Score (EEAT)
|
||||
4. **Weighted Totals**: Applies content-type-specific weights for final score
|
||||
5. **Veto Detection**: Flags critical trust violations (T04, C01, R10)
|
||||
6. **Priority Ranking**: Identifies Top 5 improvements sorted by impact
|
||||
7. **Action Plan**: Generates specific, actionable improvement steps
|
||||
|
||||
## Quick Start
|
||||
|
||||
Start with one of these prompts. Finish with a publish verdict and a handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
|
||||
|
||||
### Audit Content
|
||||
|
||||
```
|
||||
Audit this content against CORE-EEAT: [content text or URL]
|
||||
```
|
||||
|
||||
```
|
||||
Run a content quality audit on [URL] as a [content type]
|
||||
```
|
||||
|
||||
### Audit with Content Type
|
||||
|
||||
```
|
||||
CORE-EEAT audit for this product review: [content]
|
||||
```
|
||||
|
||||
```
|
||||
Score this how-to guide against the 80-item benchmark: [content]
|
||||
```
|
||||
|
||||
### Comparative Audit
|
||||
|
||||
```
|
||||
Audit my content vs competitor: [your content] vs [competitor content]
|
||||
```
|
||||
|
||||
## Skill Contract
|
||||
|
||||
**Gate verdict**: **SHIP** (no veto items, dimension scores above threshold) / **FIX** (issues found but no veto) / **BLOCK** (veto item T04, C01, or R10 failed). Always state the verdict prominently at the top of the report.
|
||||
|
||||
**Expected output**: a CORE-EEAT audit report, a publish-readiness verdict, and a short handoff summary ready for `memory/audits/content/`.
|
||||
|
||||
- **Reads**: the target content, content type, supporting evidence, and any prior decisions from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
|
||||
- **Writes**: a user-facing audit report plus a reusable summary that can be stored under `memory/audits/content/`.
|
||||
- **Promotes**: veto items and publish blockers to `memory/hot-cache.md` (auto-saved, no user confirmation needed). Top improvement priorities to `memory/open-loops.md`.
|
||||
- **Next handoff**: use the `Next Best Skill` below once the verdict is clear.
|
||||
|
||||
## Data Sources
|
||||
|
||||
> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
|
||||
|
||||
**With ~~web crawler + ~~SEO tool connected:**
|
||||
Automatically fetch page content, extract HTML structure, check schema markup, verify internal/external links, and pull competitor content for comparison.
|
||||
|
||||
**With manual data only:**
|
||||
Ask the user to provide:
|
||||
1. Content text, URL, or file path
|
||||
2. Content type (if not auto-detectable): Product Review, How-to Guide, Comparison, Landing Page, Blog Post, FAQ Page, Alternative, Best-of, or Testimonial
|
||||
3. Optional: competitor content for benchmarking
|
||||
|
||||
Proceed with the full 80-item audit using provided data. Note in the output which items could not be fully evaluated due to missing access (e.g., backlink data, schema markup, site-level signals).
|
||||
|
||||
## Decision Gates
|
||||
|
||||
When stopping to ask, always: (1) state the specific value and threshold, (2) offer numbered options with outcomes.
|
||||
|
||||
**Stop and ask the user when:**
|
||||
- Content is under minimum word count for its type (blog/guide: 300 words; product/landing page: 150 words; FAQ: fewer than 3 entries with 50+ words each) — state the actual count and offer: (1) expand to minimum, (2) continue audit with Insufficient Data flags, (3) cancel
|
||||
- Content type cannot be auto-detected — state what you detected and ask to confirm before proceeding
|
||||
- Content is primarily media (video/image) with minimal text — ask whether to audit transcript, alt text, or skip
|
||||
- More than 50% of a dimension's items are N/A — name the dimension and ask: (1) provide supplementary data, (2) mark entire dimension as Insufficient Data
|
||||
- Any veto item triggers — flag it immediately with the item ID and ask: (1) stop for immediate fix, (2) continue full audit and flag in report
|
||||
|
||||
**Continue silently (never stop for):**
|
||||
- Individual Partial scores within a dimension
|
||||
- Missing SEO tool data (mark items as N/A and continue)
|
||||
- Low overall score (the report is the deliverable, not a judgment call)
|
||||
- User not specifying content type (auto-detect and state your assumption)
|
||||
|
||||
## Instructions
|
||||
|
||||
When a user requests a content quality audit:
|
||||
|
||||
### Step 1: Preparation
|
||||
|
||||
```markdown
|
||||
### Audit Setup
|
||||
|
||||
**Content**: [title or URL]
|
||||
**Content Type**: [auto-detected or user-specified]
|
||||
**Dimension Weights**: [loaded from content-type weight table]
|
||||
|
||||
#### Veto Check (Emergency Brake)
|
||||
|
||||
| Veto Item | Status | Action |
|
||||
|-----------|--------|--------|
|
||||
| T04: Disclosure Statements | ✅ Pass / ⚠️ VETO | [If VETO: "Add disclosure banner at page top immediately"] |
|
||||
| C01: Intent Alignment | ✅ Pass / ⚠️ VETO | [If VETO: "Rewrite title and first paragraph"] |
|
||||
| R10: Content Consistency | ✅ Pass / ⚠️ VETO | [If VETO: "Verify all data before publishing"] |
|
||||
```
|
||||
|
||||
If any veto item triggers, flag it prominently at the top of the report and recommend immediate action before continuing the full audit.
|
||||
|
||||
### Step 2: CORE Audit (40 items)
|
||||
|
||||
Evaluate each item against the criteria in [references/core-eeat-benchmark.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/core-eeat-benchmark.md).
|
||||
|
||||
Score each item:
|
||||
- **Pass** = 10 points (fully meets criteria)
|
||||
- **Partial** = 5 points (partially meets criteria)
|
||||
- **Fail** = 0 points (does not meet criteria)
|
||||
|
||||
```markdown
|
||||
### C — Contextual Clarity
|
||||
|
||||
| ID | Check Item | Score | Notes |
|
||||
|----|-----------|-------|-------|
|
||||
| C01 | Intent Alignment | Pass/Partial/Fail | [specific observation] |
|
||||
| C02 | Direct Answer | Pass/Partial/Fail | [specific observation] |
|
||||
| ... | ... | ... | ... |
|
||||
| C10 | Semantic Closure | Pass/Partial/Fail | [specific observation] |
|
||||
|
||||
**C Score**: [X]/100
|
||||
```
|
||||
|
||||
Repeat the same table format for **O** (Organization), **R** (Referenceability), and **E** (Exclusivity), scoring all 10 items per dimension.
|
||||
|
||||
### Step 3: EEAT Audit (40 items)
|
||||
|
||||
```markdown
|
||||
### Exp — Experience
|
||||
|
||||
| ID | Check Item | Score | Notes |
|
||||
|----|-----------|-------|-------|
|
||||
| Exp01 | First-Person Narrative | Pass/Partial/Fail | [specific observation] |
|
||||
| ... | ... | ... | ... |
|
||||
|
||||
**Exp Score**: [X]/100
|
||||
```
|
||||
|
||||
Repeat the same table format for **Ept** (Expertise), **A** (Authority), and **T** (Trust), scoring all 10 items per dimension.
|
||||
|
||||
See [references/item-reference.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/content-quality-auditor/references/item-reference.md) for the complete 80-item ID lookup table and site-level item handling notes.
|
||||
|
||||
### Step 4: Scoring & Report
|
||||
|
||||
Calculate scores and generate the final report:
|
||||
|
||||
```markdown
|
||||
## CORE-EEAT Audit Report
|
||||
|
||||
### Overview
|
||||
|
||||
- **Content**: [title]
|
||||
- **Content Type**: [type]
|
||||
- **Audit Date**: [date]
|
||||
- **Total Score**: [score]/100 ([rating])
|
||||
- **GEO Score**: [score]/100 | **SEO Score**: [score]/100
|
||||
- **Veto Status**: ✅ No triggers / ⚠️ [item] triggered
|
||||
|
||||
### Dimension Scores
|
||||
|
||||
| Dimension | Score | Rating | Weight | Weighted |
|
||||
|-----------|-------|--------|--------|----------|
|
||||
| C — Contextual Clarity | [X]/100 | [rating] | [X]% | [X] |
|
||||
| O — Organization | [X]/100 | [rating] | [X]% | [X] |
|
||||
| R — Referenceability | [X]/100 | [rating] | [X]% | [X] |
|
||||
| E — Exclusivity | [X]/100 | [rating] | [X]% | [X] |
|
||||
| Exp — Experience | [X]/100 | [rating] | [X]% | [X] |
|
||||
| Ept — Expertise | [X]/100 | [rating] | [X]% | [X] |
|
||||
| A — Authority | [X]/100 | [rating] | [X]% | [X] |
|
||||
| T — Trust | [X]/100 | [rating] | [X]% | [X] |
|
||||
| **Weighted Total** | | | | **[X]/100** |
|
||||
|
||||
**Score Calculation**:
|
||||
- GEO Score = (C + O + R + E) / 4
|
||||
- SEO Score = (Exp + Ept + A + T) / 4
|
||||
- Weighted Score = Σ (dimension_score × content_type_weight)
|
||||
|
||||
**Rating Scale**: 90-100 Excellent | 75-89 Good | 60-74 Medium | 40-59 Low | 0-39 Poor
|
||||
|
||||
### N/A Item Handling
|
||||
|
||||
When an item cannot be evaluated (e.g., A01 Backlink Profile requires site-level data not available):
|
||||
|
||||
1. Mark the item as "N/A" with reason
|
||||
2. Exclude N/A items from the dimension score calculation
|
||||
3. Dimension Score = (sum of scored items) / (number of scored items x 10) x 100
|
||||
4. If more than 50% of a dimension's items are N/A, flag the dimension as "Insufficient Data" and exclude it from the weighted total
|
||||
5. Recalculate weighted total using only dimensions with sufficient data, re-normalizing weights to sum to 100%
|
||||
|
||||
**Example**: Authority dimension with 8 N/A items and 2 scored items (A05=8, A07=5):
|
||||
- Dimension score = (8+5) / (2 x 10) x 100 = 65
|
||||
- But 8/10 items are N/A (>50%), so flag as "Insufficient Data -- Authority"
|
||||
- Exclude A dimension from weighted total; redistribute its weight proportionally to remaining dimensions
|
||||
|
||||
### Per-Item Scores
|
||||
|
||||
#### CORE — Content Body (40 Items)
|
||||
|
||||
| ID | Check Item | Score | Notes |
|
||||
|----|-----------|-------|-------|
|
||||
| C01 | Intent Alignment | [Pass/Partial/Fail] | [observation] |
|
||||
| C02 | Direct Answer | [Pass/Partial/Fail] | [observation] |
|
||||
| ... | ... | ... | ... |
|
||||
|
||||
#### EEAT — Source Credibility (40 Items)
|
||||
|
||||
| ID | Check Item | Score | Notes |
|
||||
|----|-----------|-------|-------|
|
||||
| Exp01 | First-Person Narrative | [Pass/Partial/Fail] | [observation] |
|
||||
| ... | ... | ... | ... |
|
||||
|
||||
### Top 5 Priority Improvements
|
||||
|
||||
Sorted by: weight × points lost (highest impact first)
|
||||
|
||||
1. **[ID] [Name]** — [specific modification suggestion]
|
||||
- Current: [Fail/Partial] | Potential gain: [X] weighted points
|
||||
- Action: [concrete step]
|
||||
|
||||
2. **[ID] [Name]** — [specific modification suggestion]
|
||||
- Current: [Fail/Partial] | Potential gain: [X] weighted points
|
||||
- Action: [concrete step]
|
||||
|
||||
3–5. [Same format]
|
||||
|
||||
### Action Plan
|
||||
|
||||
#### Quick Wins (< 30 minutes each)
|
||||
- [ ] [Action 1]
|
||||
- [ ] [Action 2]
|
||||
|
||||
#### Medium Effort (1-2 hours)
|
||||
- [ ] [Action 3]
|
||||
- [ ] [Action 4]
|
||||
|
||||
#### Strategic (Requires planning)
|
||||
- [ ] [Action 5]
|
||||
- [ ] [Action 6]
|
||||
|
||||
### Recommended Next Steps
|
||||
|
||||
- For full content rewrite: use `seo-content-writer` with CORE-EEAT constraints
|
||||
- For GEO optimization: use `geo-content-optimizer` targeting failed GEO-First items
|
||||
- For content refresh: use `content-refresher` with weak dimensions as focus
|
||||
- For technical fixes: run `/seo:check-technical` for site-level issues
|
||||
```
|
||||
|
||||
### Save Results
|
||||
|
||||
After delivering findings to the user, ask:
|
||||
|
||||
> "Save these results for future sessions?"
|
||||
|
||||
If yes, write a dated summary to the appropriate `memory/` path using filename `YYYY-MM-DD-<topic>.md` containing:
|
||||
- One-line verdict or headline finding
|
||||
- Top 3-5 actionable items
|
||||
- Open loops or blockers
|
||||
- Source data references
|
||||
|
||||
If any veto-level issue was found (CORE-EEAT T04, C01, R10 or CITE T03, T05, T09), also append a one-liner to `memory/hot-cache.md` without asking.
|
||||
|
||||
## Validation Checkpoints
|
||||
|
||||
### Input Validation
|
||||
- [ ] Content source identified (text, URL, or file path)
|
||||
- [ ] Content type confirmed (auto-detected or user-specified)
|
||||
- [ ] Content is substantial enough for meaningful audit (≥300 words)
|
||||
- [ ] If comparative audit, competitor content also provided
|
||||
|
||||
### Output Validation
|
||||
- [ ] All 80 items scored (or marked N/A with reason)
|
||||
- [ ] All 8 dimension scores calculated correctly
|
||||
- [ ] Weighted total matches content-type weight configuration
|
||||
- [ ] Veto items checked and flagged if triggered
|
||||
- [ ] Top 5 improvements sorted by weighted impact, not arbitrary
|
||||
- [ ] Every recommendation is specific and actionable (not generic advice)
|
||||
- [ ] Action plan includes concrete steps with effort estimates
|
||||
|
||||
## Example
|
||||
|
||||
See [references/item-reference.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/content-quality-auditor/references/item-reference.md) for a complete scored example showing the C dimension with all 10 items, priority improvements, and weighted scoring.
|
||||
|
||||
## Tips for Success
|
||||
|
||||
1. **Start with veto items** — T04, C01, R10 are deal-breakers regardless of total score
|
||||
> These veto items are consistent with the CORE-EEAT benchmark (Section 3), which defines them as items that can override the overall score.
|
||||
2. **Focus on high-weight dimensions** — Different content types prioritize different dimensions
|
||||
3. **GEO-First items matter most for AI visibility** — Prioritize items tagged GEO 🎯 if AI citation is the goal
|
||||
4. **Some EEAT items need site-level data** — Don't penalize content for things only observable at the site level (backlinks, brand recognition)
|
||||
5. **Use the weighted score, not just the raw average** — A product review with strong Exclusivity matters more than strong Authority
|
||||
6. **Re-audit after improvements** — Run again to verify score improvements and catch regressions
|
||||
7. **Pair with CITE for domain-level context** — A high content score on a low-authority domain signals a different priority than the reverse; run [domain-authority-auditor](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/domain-authority-auditor/SKILL.md) for the full 120-item picture
|
||||
|
||||
## Reference Materials
|
||||
|
||||
- [CORE-EEAT Content Benchmark](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/core-eeat-benchmark.md) — Full 80-item benchmark with dimension definitions, scoring criteria, and GEO-First item markers
|
||||
- [references/item-reference.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/content-quality-auditor/references/item-reference.md) — All 80 item IDs in a compact lookup table + site-level item handling notes + scored example report
|
||||
|
||||
## Next Best Skill
|
||||
|
||||
- **Primary**: [content-refresher](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/content-refresher/SKILL.md) — turn failed checks into a concrete rewrite plan.
|
||||
@@ -0,0 +1,396 @@
|
||||
---
|
||||
name: content-refresher
|
||||
description: 'Refresh outdated posts with current stats, new sections, freshness signals to restore rankings. 内容更新/排名恢复'
|
||||
version: "6.0.0"
|
||||
license: Apache-2.0
|
||||
compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
|
||||
homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
|
||||
when_to_use: "Use when updating outdated content, refreshing old articles, improving declining pages, or adding new information to existing content."
|
||||
argument-hint: "<URL of outdated content>"
|
||||
metadata:
|
||||
author: aaron-he-zhu
|
||||
version: "6.0.0"
|
||||
geo-relevance: "medium"
|
||||
tags:
|
||||
- seo
|
||||
- geo
|
||||
- content-refresh
|
||||
- content-update
|
||||
- content-decay
|
||||
- ranking-recovery
|
||||
- evergreen-content
|
||||
- content-lifecycle
|
||||
- 内容更新
|
||||
- コンテンツ更新
|
||||
- 콘텐츠갱신
|
||||
- actualizar-contenido
|
||||
triggers:
|
||||
# EN-formal
|
||||
- "update old content"
|
||||
- "refresh content"
|
||||
- "content is outdated"
|
||||
- "improve declining rankings"
|
||||
- "revive old blog posts"
|
||||
- "content decay"
|
||||
- "content refresh strategy"
|
||||
# EN-casual
|
||||
- "traffic is dropping"
|
||||
- "ranking dropped"
|
||||
- "this post is outdated"
|
||||
- "traffic is declining on this page"
|
||||
- "rankings dropped for this article"
|
||||
- "my old content needs updating"
|
||||
# EN-question
|
||||
- "how to fix declining traffic"
|
||||
- "why is my content losing rankings"
|
||||
- "how often should I update content"
|
||||
# EN-competitor
|
||||
- "Clearscope content refresh"
|
||||
- "MarketMuse content update"
|
||||
# ZH-pro
|
||||
- "内容更新"
|
||||
- "内容刷新"
|
||||
- "排名恢复"
|
||||
- "内容衰减"
|
||||
- "内容生命周期"
|
||||
# ZH-casual
|
||||
- "排名下降了"
|
||||
- "文章过时了"
|
||||
- "流量掉了"
|
||||
- "老文章怎么办"
|
||||
# JA
|
||||
- "コンテンツ更新"
|
||||
- "コンテンツリフレッシュ"
|
||||
- "記事更新"
|
||||
# KO
|
||||
- "콘텐츠 갱신"
|
||||
- "콘텐츠 업데이트"
|
||||
- "순위 하락"
|
||||
- "순위 하락 원인"
|
||||
- "오래된 글 어떻게 해?"
|
||||
# ES
|
||||
- "actualizar contenido"
|
||||
- "refrescar contenido antiguo"
|
||||
# PT
|
||||
- "atualizar conteúdo"
|
||||
# Misspellings
|
||||
- "content refesh"
|
||||
- "content refreshh"
|
||||
---
|
||||
|
||||
# Content Refresher
|
||||
|
||||
|
||||
> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
|
||||
> **System Mode**: This optimization skill follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
|
||||
|
||||
|
||||
This skill helps identify and revitalize outdated content to reclaim lost rankings and traffic. It analyzes content freshness, identifies update opportunities, and guides the refresh process for maximum SEO and GEO impact.
|
||||
|
||||
**System role**: Optimization layer skill. It turns weak pages, structures, and technical issues into prioritized repair work.
|
||||
|
||||
## When This Must Trigger
|
||||
|
||||
Use this when the conversation involves any of these situations — even if the user does not use SEO terminology:
|
||||
|
||||
Use this whenever the task needs a diagnosis or repair plan that should feed directly into remediation work, not just a one-time opinion.
|
||||
|
||||
- Content has lost rankings or traffic over time
|
||||
- Statistics and information are outdated
|
||||
- Competitors have published better content
|
||||
- Content needs updating for a new year
|
||||
- Industry changes require content updates
|
||||
- Adding new sections to existing content
|
||||
- Converting old content for GEO optimization
|
||||
|
||||
## What This Skill Does
|
||||
|
||||
1. **Freshness Analysis**: Identifies outdated content needing updates
|
||||
2. **Performance Tracking**: Finds content with declining traffic
|
||||
3. **Gap Identification**: Spots missing information competitors have
|
||||
4. **Update Prioritization**: Ranks content by refresh potential
|
||||
5. **Refresh Recommendations**: Provides specific update guidance
|
||||
6. **GEO Enhancement**: Updates content for AI citation potential
|
||||
7. **Republishing Strategy**: Advises on date and promotion tactics
|
||||
|
||||
## Quick Start
|
||||
|
||||
Start with one of these prompts. Finish with a short handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
|
||||
|
||||
### Identify Content to Refresh
|
||||
|
||||
```
|
||||
Find content on [domain] that needs refreshing
|
||||
```
|
||||
|
||||
```
|
||||
Which of my blog posts have lost the most traffic?
|
||||
```
|
||||
|
||||
### Refresh Specific Content
|
||||
|
||||
```
|
||||
Refresh this article for [current year]: [URL/content]
|
||||
```
|
||||
|
||||
```
|
||||
Update this content to outrank [competitor URL]: [your URL]
|
||||
```
|
||||
|
||||
### Content Refresh Strategy
|
||||
|
||||
```
|
||||
Create a content refresh strategy for [domain/topic]
|
||||
```
|
||||
|
||||
## Skill Contract
|
||||
|
||||
**Expected output**: a scored diagnosis, prioritized repair plan, and a short handoff summary ready for `memory/audits/`.
|
||||
|
||||
- **Reads**: the current page or site state, symptoms, prior audits, and current priorities from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
|
||||
- **Writes**: a user-facing audit or optimization plan plus a reusable summary that can be stored under `memory/audits/`.
|
||||
- **Promotes**: blocking defects, repeated weaknesses, and fix priorities to `memory/open-loops.md` and `memory/decisions.md`.
|
||||
- **Next handoff**: use the `Next Best Skill` below when the repair path is clear.
|
||||
|
||||
## Data Sources
|
||||
|
||||
> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
|
||||
|
||||
**With ~~analytics + ~~search console + ~~SEO tool connected:**
|
||||
Claude can automatically pull historical traffic trends from ~~analytics, fetch impression and ranking data from ~~search console, retrieve keyword position history from ~~SEO tool, and identify content with declining performance. This enables data-driven refresh prioritization.
|
||||
|
||||
**With manual data only:**
|
||||
Ask the user to provide:
|
||||
1. Traffic data or screenshots showing performance trends
|
||||
2. Ranking screenshots or history for key pages
|
||||
3. Content publish dates and last update dates
|
||||
4. List of pages the user suspects need refreshing
|
||||
|
||||
Proceed with the analysis using provided data. Note in the output which findings are from automated data vs. manual review.
|
||||
|
||||
## Instructions
|
||||
|
||||
When a user requests content refresh help:
|
||||
|
||||
1. **CORE-EEAT Quick Score — Identify Weak Dimensions**
|
||||
|
||||
Before refreshing, run a quick CORE-EEAT assessment to focus effort on the weakest areas. Reference: [CORE-EEAT Benchmark](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/core-eeat-benchmark.md)
|
||||
|
||||
```markdown
|
||||
### CORE-EEAT Quick Assessment
|
||||
|
||||
**Content**: [title or URL]
|
||||
**Content Type**: [type]
|
||||
|
||||
Rapidly score each dimension (estimate 0-100):
|
||||
|
||||
| Dimension | Quick Score | Key Weakness | Refresh Priority |
|
||||
|-----------|-----------|--------------|-----------------|
|
||||
| C — Contextual Clarity | [X]/100 | [main issue] | 🔴/🟡/🟢 |
|
||||
| O — Organization | [X]/100 | [main issue] | 🔴/🟡/🟢 |
|
||||
| R — Referenceability | [X]/100 | [main issue] | 🔴/🟡/🟢 |
|
||||
| E — Exclusivity | [X]/100 | [main issue] | 🔴/🟡/🟢 |
|
||||
| Exp — Experience | [X]/100 | [main issue] | 🔴/🟡/🟢 |
|
||||
| Ept — Expertise | [X]/100 | [main issue] | 🔴/🟡/🟢 |
|
||||
| A — Authority | [X]/100 | [main issue] | 🔴/🟡/🟢 |
|
||||
| T — Trust | [X]/100 | [main issue] | 🔴/🟡/🟢 |
|
||||
|
||||
**Weakest Dimensions** (focus refresh here):
|
||||
1. [Dimension] — [what needs fixing]
|
||||
2. [Dimension] — [what needs fixing]
|
||||
|
||||
**Refresh Strategy**: Focus on 🔴 dimensions first, then 🟡.
|
||||
|
||||
_For full 80-item audit, use [content-quality-auditor](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/content-quality-auditor/SKILL.md)_
|
||||
```
|
||||
|
||||
2. **Identify Content Refresh Candidates**
|
||||
|
||||
```markdown
|
||||
## Content Refresh Analysis
|
||||
|
||||
### Refresh Candidate Identification
|
||||
|
||||
**Criteria for Content Refresh**:
|
||||
- Published more than 6 months ago
|
||||
- Contains dated information (years, statistics)
|
||||
- Declining traffic trend
|
||||
- Lost keyword rankings
|
||||
- Outdated references or broken links
|
||||
- Missing topics competitors now cover
|
||||
- No GEO optimization
|
||||
|
||||
### Content Audit Results
|
||||
|
||||
| Content | Published | Last Updated | Traffic Trend | Priority |
|
||||
|---------|-----------|--------------|---------------|----------|
|
||||
| [Title 1] | [date] | [date] | ↓ -45% | 🔴 High |
|
||||
| [Title 2] | [date] | Never | ↓ -30% | 🔴 High |
|
||||
| [Title 3] | [date] | [date] | ↓ -20% | 🟡 Medium |
|
||||
| [Title 4] | [date] | [date] | → 0% | 🟡 Medium |
|
||||
|
||||
### Refresh Prioritization Matrix
|
||||
|
||||
```
|
||||
High Traffic + High Decline = 🔴 Refresh Immediately
|
||||
High Traffic + Low Decline = 🟡 Schedule Refresh
|
||||
Low Traffic + High Decline = 🟡 Evaluate & Decide
|
||||
Low Traffic + Low Decline = 🟢 Low Priority
|
||||
```
|
||||
```
|
||||
|
||||
3. **Analyze Individual Content for Refresh**
|
||||
|
||||
```markdown
|
||||
## Content Refresh Analysis: [Title]
|
||||
|
||||
**URL**: [URL]
|
||||
**Published**: [date]
|
||||
**Last Updated**: [date]
|
||||
**Word Count**: [X]
|
||||
|
||||
### Performance Metrics
|
||||
|
||||
| Metric | 6 Mo Ago | Current | Change |
|
||||
|--------|----------|---------|--------|
|
||||
| Organic Traffic | [X]/mo | [X]/mo | [+/-X]% |
|
||||
| Avg Position | [X] | [X] | [+/-X] |
|
||||
| Impressions | [X] | [X] | [+/-X]% |
|
||||
| CTR | [X]% | [X]% | [+/-X]% |
|
||||
|
||||
### Keywords Analysis
|
||||
|
||||
| Keyword | Old Position | Current Position | Change |
|
||||
|---------|--------------|------------------|--------|
|
||||
| [kw 1] | [X] | [X] | ↓ [X] |
|
||||
| [kw 2] | [X] | [X] | ↓ [X] |
|
||||
| [kw 3] | [X] | [X] | ↓ [X] |
|
||||
|
||||
### Why This Content Needs Refresh
|
||||
|
||||
1. **Outdated information**: [specific examples]
|
||||
2. **Competitive gap**: [what competitors added]
|
||||
3. **Missing topics**: [new subtopics to cover]
|
||||
4. **SEO issues**: [current optimization problems]
|
||||
5. **GEO potential**: [AI citation opportunities]
|
||||
```
|
||||
|
||||
4. **Identify Specific Updates Needed**
|
||||
|
||||
```markdown
|
||||
## Refresh Requirements
|
||||
|
||||
### Outdated Elements
|
||||
|
||||
| Element | Current | Update Needed |
|
||||
|---------|---------|---------------|
|
||||
| Year references | "[old year]" | Update to [current year] |
|
||||
| Statistics | "[old stat]" | Find current data |
|
||||
| Tool mentions | "[old tool]" | Add newer tools |
|
||||
| Links | [X] broken | Fix or replace |
|
||||
| Screenshots | Outdated UI | Recapture |
|
||||
|
||||
### Missing Information
|
||||
|
||||
**Topics competitors now cover that you don't**:
|
||||
|
||||
| Topic | Competitor Coverage | Words Needed | Priority |
|
||||
|-------|---------------------|--------------|----------|
|
||||
| [Topic 1] | 3/5 competitors | ~300 words | High |
|
||||
| [Topic 2] | 2/5 competitors | ~200 words | Medium |
|
||||
| [Topic 3] | 4/5 competitors | ~400 words | High |
|
||||
|
||||
### SEO Updates Needed
|
||||
|
||||
- [ ] Update title tag with current year
|
||||
- [ ] Refresh meta description
|
||||
- [ ] Add new H2 sections for [topics]
|
||||
- [ ] Update internal links to newer content
|
||||
- [ ] Add FAQ section for featured snippets
|
||||
- [ ] Refresh images and add new alt text
|
||||
|
||||
### GEO Updates Needed
|
||||
|
||||
- [ ] Add clear definition at start
|
||||
- [ ] Include quotable statistics with sources
|
||||
- [ ] Add Q&A formatted sections
|
||||
- [ ] Update sources with current citations
|
||||
- [ ] Create standalone factual statements
|
||||
```
|
||||
|
||||
5. **Create Refresh Plan** — Structural changes, content additions, statistics/links/images to update
|
||||
|
||||
> **Reference**: See [references/refresh-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/content-refresher/references/refresh-templates.md) for the full refresh plan template (Step 5).
|
||||
|
||||
6. **Write Refresh Content** — Updated introduction, new sections, refreshed statistics, new FAQ section
|
||||
|
||||
> **Reference**: See [references/refresh-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/content-refresher/references/refresh-templates.md) for the refresh content writing template (Step 6).
|
||||
|
||||
7. **Optimize for GEO During Refresh** — Clear definitions, quotable statements, Q&A sections, updated citations
|
||||
|
||||
> **Reference**: See [references/refresh-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/content-refresher/references/refresh-templates.md) for the GEO enhancement template (Step 7).
|
||||
|
||||
8. **Generate Republishing Strategy** — Date strategy (update/add "last updated"/keep original), technical implementation, promotion plan
|
||||
|
||||
> **Reference**: See [references/refresh-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/content-refresher/references/refresh-templates.md) for the republishing strategy template (Step 8).
|
||||
|
||||
9. **Create Refresh Report** — Summary of changes, updates completed, expected outcomes, next review date
|
||||
|
||||
> **Reference**: See [references/refresh-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/content-refresher/references/refresh-templates.md) for the refresh report template (Step 9).
|
||||
|
||||
## Validation Checkpoints
|
||||
|
||||
### Input Validation
|
||||
- [ ] Target content URL or title clearly identified
|
||||
- [ ] Historical performance data available (traffic trends, rankings)
|
||||
- [ ] Content publish/update dates known
|
||||
- [ ] If comparing to competitors, competitor URLs provided
|
||||
|
||||
### Output Validation
|
||||
- [ ] Every recommendation cites specific data points (not generic advice)
|
||||
- [ ] Outdated elements identified with specific examples and replacement data
|
||||
- [ ] All suggested additions include word counts and section locations
|
||||
- [ ] Source of each data point clearly stated (~~analytics data, ~~search console, ~~SEO tool, user-provided, or estimated)
|
||||
|
||||
## Example
|
||||
|
||||
> **Reference**: See [references/refresh-example.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/content-refresher/references/refresh-example.md) for a full worked example (cloud hosting refresh) and the comprehensive content refresh checklist.
|
||||
|
||||
## Tips for Success
|
||||
|
||||
1. **Prioritize by ROI** - Refresh high-potential content first
|
||||
2. **Don't just add dates** - Make substantial improvements
|
||||
3. **Beat competitors** - Add what they have and more
|
||||
4. **Track results** - Monitor ranking changes post-refresh
|
||||
5. **Schedule regular audits** - Check content health quarterly
|
||||
6. **Optimize for GEO** - Every refresh is a GEO opportunity
|
||||
|
||||
> **Reference data**: For content decay signal taxonomy, lifecycle stages, refresh vs. rewrite decision framework, and update strategy by content type, see [references/content-decay-signals.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/content-refresher/references/content-decay-signals.md).
|
||||
|
||||
|
||||
### Save Results
|
||||
|
||||
After delivering audit or optimization findings to the user, ask:
|
||||
|
||||
> "Save these results for future sessions?"
|
||||
|
||||
If yes, write a dated summary to `memory/audits/content-refresher/YYYY-MM-DD-<topic>.md` containing:
|
||||
- One-line verdict or headline finding
|
||||
- Top 3-5 actionable items
|
||||
- Open loops or blockers
|
||||
- Source data references
|
||||
|
||||
If any veto-level issue was found (CORE-EEAT T04, C01, R10 or CITE T03, T05, T09), also append a one-liner to `memory/hot-cache.md` without asking.
|
||||
|
||||
|
||||
**Gate check recommended**: Run content-quality-auditor on refreshed content before republishing.
|
||||
|
||||
## Reference Materials
|
||||
|
||||
- [Content Decay Signals](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/content-refresher/references/content-decay-signals.md) — Decay indicators, lifecycle stages, and refresh triggers by content type
|
||||
- [Refresh Templates](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/content-refresher/references/refresh-templates.md) — Detailed output templates for steps 5-9 (refresh plan, content writing, GEO enhancement, republishing, report)
|
||||
- [Refresh Example & Checklist](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/content-refresher/references/refresh-example.md) — Full worked example and pre/post-refresh checklist
|
||||
|
||||
## Next Best Skill
|
||||
|
||||
- **Primary**: [content-quality-auditor](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/content-quality-auditor/SKILL.md) — re-score the refreshed content before shipping.
|
||||
@@ -0,0 +1,394 @@
|
||||
---
|
||||
name: domain-authority-auditor
|
||||
description: '40-item CITE domain audit: citation, impact, trust, entity scoring with veto checks. 域名权威/网站可信度'
|
||||
version: "6.0.0"
|
||||
license: Apache-2.0
|
||||
compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
|
||||
homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
|
||||
when_to_use: "Use when auditing domain trust and authority. Runs CITE 40-item scoring with veto checks. Also when the user asks about domain credibility or citation trustworthiness."
|
||||
argument-hint: "<domain>"
|
||||
metadata:
|
||||
author: aaron-he-zhu
|
||||
version: "6.0.0"
|
||||
geo-relevance: "medium"
|
||||
tags:
|
||||
- seo
|
||||
- geo
|
||||
- domain-authority
|
||||
- domain-rating
|
||||
- domain-trust
|
||||
- cite-framework
|
||||
- site-authority
|
||||
- 域名权威
|
||||
- ドメイン権威
|
||||
- 도메인권위
|
||||
- autoridad-dominio
|
||||
triggers:
|
||||
# EN-formal
|
||||
- "audit domain authority"
|
||||
- "CITE audit"
|
||||
- "domain trust score"
|
||||
- "domain credibility check"
|
||||
- "domain rating"
|
||||
- "site authority"
|
||||
# EN-casual
|
||||
- "how trustworthy is my site"
|
||||
- "is my domain credible"
|
||||
- "is my domain trustworthy"
|
||||
- "domain credibility score"
|
||||
- "Google penalty recovery"
|
||||
- "my site got penalized"
|
||||
# EN-question
|
||||
- "how authoritative is my site"
|
||||
- "what is my domain authority"
|
||||
# ZH-pro
|
||||
- "域名权威审计"
|
||||
- "网站可信度"
|
||||
- "域名评分"
|
||||
# ZH-casual
|
||||
- "域名可信吗"
|
||||
- "权威度多少"
|
||||
- "网站可信度怎么样"
|
||||
# JA
|
||||
- "ドメイン権威"
|
||||
- "ドメイン評価"
|
||||
# KO
|
||||
- "도메인 권위"
|
||||
- "도메인 신뢰도"
|
||||
# ES
|
||||
- "autoridad de dominio"
|
||||
- "auditoría de dominio"
|
||||
# PT
|
||||
- "autoridade de domínio"
|
||||
# Misspellings
|
||||
- "domain autority"
|
||||
---
|
||||
|
||||
# Domain Authority Auditor
|
||||
|
||||
> Based on [CITE Domain Rating](https://github.com/aaron-he-zhu/cite-domain-rating). Full benchmark reference: [references/cite-domain-rating.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/cite-domain-rating.md)
|
||||
|
||||
> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
|
||||
> **System Mode**: This cross-cutting skill is part of the protocol layer and follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
|
||||
|
||||
|
||||
This skill evaluates domain authority across 40 standardized criteria organized in 4 dimensions. It produces a comprehensive audit report with per-item scoring, dimension and weighted scores by domain type, veto item checks, and a prioritized action plan.
|
||||
|
||||
**Sister skill**: [content-quality-auditor](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/content-quality-auditor/SKILL.md) evaluates content at the page level (80 items). This skill evaluates the domain behind the content (40 items). Together they provide a complete 120-item assessment.
|
||||
|
||||
> **Namespace note**: CITE uses C01-C10 for Citation items; CORE-EEAT uses C01-C10 for Contextual Clarity items. In combined 120-item assessments, prefix with the framework name (e.g., CITE-C01 vs CORE-C01) to avoid confusion.
|
||||
|
||||
**System role**: Citation Trust Gate. It decides whether a domain is credible enough to support ranking, citation, and brand authority work.
|
||||
|
||||
## When This Must Trigger
|
||||
|
||||
Use this when domain credibility or citation trustworthiness is in question — even if the user doesn't use audit terminology:
|
||||
|
||||
- User asks "how trustworthy is my site" or "is my domain credible"
|
||||
- When backlink-analyzer finds toxic link ratio above 15%, its handoff summary recommends this gate check
|
||||
- Evaluating domain authority before a GEO campaign
|
||||
- Benchmarking your domain against competitors
|
||||
- Assessing whether a domain is trustworthy as a citation source
|
||||
- Running periodic domain health checks or after link building campaigns
|
||||
- Identifying manipulation red flags (PBNs, link farms, penalty history)
|
||||
- Cross-referencing with content-quality-auditor for full 120-item assessment
|
||||
|
||||
## What This Skill Does
|
||||
|
||||
1. **Full 40-Item Audit**: Scores every CITE check item as Pass/Partial/Fail
|
||||
2. **Dimension Scoring**: Calculates scores for all 4 dimensions (0-100 each)
|
||||
3. **Weighted Totals**: Applies domain-type-specific weights for CITE Score
|
||||
4. **Veto Detection**: Flags critical manipulation signals (T03, T05, T09)
|
||||
5. **Priority Ranking**: Identifies Top 5 improvements sorted by impact
|
||||
6. **Action Plan**: Generates specific, actionable improvement steps
|
||||
7. **Cross-Reference**: Optionally pairs with CORE-EEAT for combined diagnosis
|
||||
|
||||
## Quick Start
|
||||
|
||||
Start with one of these prompts. Finish with a citation-trust verdict and a handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
|
||||
|
||||
### Audit Your Domain
|
||||
|
||||
```
|
||||
Audit domain authority for [domain]
|
||||
Run a CITE domain audit on [domain] as a [domain type]
|
||||
```
|
||||
|
||||
### Audit with Domain Type
|
||||
|
||||
```
|
||||
CITE audit for example.com as an e-commerce site
|
||||
Score this SaaS domain against the 40-item benchmark: [domain]
|
||||
```
|
||||
|
||||
### Comparative Audit
|
||||
|
||||
```
|
||||
Compare domain authority: [your domain] vs [competitor 1] vs [competitor 2]
|
||||
```
|
||||
|
||||
### Combined Assessment
|
||||
|
||||
```
|
||||
Run full 120-item assessment on [domain]: CITE domain audit + CORE-EEAT content audit on [sample pages]
|
||||
```
|
||||
|
||||
## Skill Contract
|
||||
|
||||
**Gate verdict**: **TRUSTED** (no veto items, scores above threshold) / **CAUTIOUS** (issues found but no veto) / **UNTRUSTED** (veto item T03, T05, or T09 failed). Always state the verdict prominently at the top of the report.
|
||||
|
||||
**Expected output**: a CITE audit report, a citation-trust verdict, and a short handoff summary ready for `memory/audits/domain/`.
|
||||
|
||||
- **Reads**: the target domain, supporting authority signals, comparison domains, and prior decisions from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
|
||||
- **Writes**: a user-facing authority report plus a reusable summary that can be stored under `memory/audits/domain/`.
|
||||
- **Promotes**: veto items and domain risks to `memory/hot-cache.md` (auto-saved). Authority context to `memory/audits/domain/`. Results feed into entity-optimizer as authority input for brand's canonical profile.
|
||||
- **Next handoff**: use the `Next Best Skill` below once the trust picture is clear.
|
||||
|
||||
## Data Sources
|
||||
|
||||
> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
|
||||
|
||||
> **Note:** All integrations are optional. This skill works without any API keys — users provide data manually when no tools are connected.
|
||||
|
||||
**With ~~link database + ~~SEO tool + ~~AI monitor + ~~knowledge graph + ~~brand monitor connected:**
|
||||
Automatically pull backlink profiles and link quality metrics from ~~link database, domain authority scores and keyword rankings from ~~SEO tool, AI citation data from ~~AI monitor, entity presence from ~~knowledge graph, and brand mention data from ~~brand monitor.
|
||||
|
||||
**With manual data only:**
|
||||
Ask the user to provide:
|
||||
1. Domain to evaluate
|
||||
2. Domain type (if not auto-detectable): Content Publisher, Product & Service, E-commerce, Community & UGC, Tool & Utility, or Authority & Institutional
|
||||
3. Backlink data: referring domains count, domain authority, top linking domains
|
||||
4. Traffic estimates (from any SEO tool or SimilarWeb)
|
||||
5. Competitor domains for comparison (optional)
|
||||
|
||||
Proceed with the full 40-item audit using provided data. Note in the output which items could not be fully evaluated due to missing access (e.g., AI citation data, knowledge graph queries, WHOIS history).
|
||||
|
||||
## Instructions
|
||||
|
||||
When a user requests a domain authority audit:
|
||||
|
||||
### Step 1: Preparation
|
||||
|
||||
```markdown
|
||||
### Audit Setup
|
||||
|
||||
**Domain**: [domain]
|
||||
**Domain Type**: [auto-detected or user-specified]
|
||||
**Dimension Weights**: [from domain-type weight table below]
|
||||
|
||||
#### Domain-Type Weight Table
|
||||
|
||||
> Canonical source: `references/cite-domain-rating.md`. This inline copy is for convenience.
|
||||
|
||||
| Dim | Default | Content Publisher | Product & Service | E-commerce | Community & UGC | Tool & Utility | Authority & Institutional |
|
||||
|-----|:-------:|:-:|:-:|:-:|:-:|:-:|:-:|
|
||||
| C | 35% | **40%** | 25% | 20% | 35% | 25% | **45%** |
|
||||
| I | 20% | 15% | **30%** | 20% | 10% | **30%** | 20% |
|
||||
| T | 25% | 20% | 25% | **35%** | 25% | 25% | 20% |
|
||||
| E | 20% | 25% | 20% | 25% | **30%** | 20% | 15% |
|
||||
|
||||
#### Veto Check (Emergency Brake)
|
||||
|
||||
| Veto Item | Status | Action |
|
||||
|-----------|--------|--------|
|
||||
| T03: Link-Traffic Coherence | ✅ Pass / ⚠️ VETO | [If VETO: "Audit backlink profile; disavow toxic links"] |
|
||||
| T05: Backlink Profile Uniqueness | ✅ Pass / ⚠️ VETO | [If VETO: "Flag as manipulation network; investigate link sources"] |
|
||||
| T09: Penalty & Deindex History | ✅ Pass / ⚠️ VETO | [If VETO: "Address penalty first; all other optimization is futile"] |
|
||||
```
|
||||
|
||||
If any veto item triggers, flag it prominently at the top of the report. CITE Score is capped at 39 (Poor) regardless of other scores.
|
||||
|
||||
### Step 2: C + I Audit (20 items)
|
||||
|
||||
Evaluate each item against the criteria in [references/cite-domain-rating.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/cite-domain-rating.md).
|
||||
|
||||
Score each item:
|
||||
- **Pass** = 10 points (fully meets criteria)
|
||||
- **Partial** = 5 points (partially meets criteria)
|
||||
- **Fail** = 0 points (does not meet criteria)
|
||||
|
||||
```markdown
|
||||
### C — Citation
|
||||
|
||||
| ID | Check Item | Score | Notes |
|
||||
|----|-----------|-------|-------|
|
||||
| C01 | Referring Domains Volume | Pass/Partial/Fail | [specific observation] |
|
||||
| C02 | Referring Domains Quality | Pass/Partial/Fail | [specific observation] |
|
||||
| ... | ... | ... | ... |
|
||||
| C10 | Link Source Diversity | Pass/Partial/Fail | [specific observation] |
|
||||
|
||||
**C Score**: [X]/100
|
||||
|
||||
### I — Identity
|
||||
|
||||
| ID | Check Item | Score | Notes |
|
||||
|----|-----------|-------|-------|
|
||||
| I01 | Knowledge Graph Presence | Pass/Partial/Fail | [specific observation] |
|
||||
| ... | ... | ... | ... |
|
||||
|
||||
**I Score**: [X]/100
|
||||
```
|
||||
|
||||
### Step 3: T + E Audit (20 items)
|
||||
|
||||
Same format for Trust and Eminence dimensions.
|
||||
|
||||
```markdown
|
||||
### T — Trust
|
||||
|
||||
| ID | Check Item | Score | Notes |
|
||||
|----|-----------|-------|-------|
|
||||
| T01 | Link Profile Naturalness | Pass/Partial/Fail | [specific observation] |
|
||||
| ... | ... | ... | ... |
|
||||
|
||||
**T Score**: [X]/100
|
||||
|
||||
### E — Eminence
|
||||
|
||||
| ID | Check Item | Score | Notes |
|
||||
|----|-----------|-------|-------|
|
||||
| E01 | Organic Search Visibility | Pass/Partial/Fail | [specific observation] |
|
||||
| ... | ... | ... | ... |
|
||||
|
||||
**E Score**: [X]/100
|
||||
```
|
||||
|
||||
**Note**: Some items require specialized data (C05-C08 AI citation data, I01 knowledge graph queries, T04-T05 IP/profile analysis). Score what is observable; mark unverifiable items as "N/A — requires [data source]" and exclude from dimension average.
|
||||
|
||||
### Step 4: Scoring & Report
|
||||
|
||||
Calculate scores and generate the final report:
|
||||
|
||||
```markdown
|
||||
## CITE Domain Authority Report
|
||||
|
||||
### Overview
|
||||
|
||||
- **Domain**: [domain]
|
||||
- **Domain Type**: [type]
|
||||
- **Audit Date**: [date]
|
||||
- **CITE Score**: [score]/100 ([rating])
|
||||
- **Veto Status**: ✅ No triggers / ⚠️ [item] triggered — Score capped at 39
|
||||
|
||||
### Dimension Scores
|
||||
|
||||
| Dimension | Score | Rating | Weight | Weighted |
|
||||
|-----------|-------|--------|--------|----------|
|
||||
| C — Citation | [X]/100 | [rating] | [X]% | [X] |
|
||||
| I — Identity | [X]/100 | [rating] | [X]% | [X] |
|
||||
| T — Trust | [X]/100 | [rating] | [X]% | [X] |
|
||||
| E — Eminence | [X]/100 | [rating] | [X]% | [X] |
|
||||
| **CITE Score** | | | | **[X]/100** |
|
||||
|
||||
**Score Calculation**: CITE Score = C × [w_C] + I × [w_I] + T × [w_T] + E × [w_E]
|
||||
|
||||
**Rating Scale**: 90-100 Excellent | 75-89 Good | 60-74 Medium | 40-59 Low | 0-39 Poor
|
||||
|
||||
### Per-Item Scores
|
||||
|
||||
| ID | Check Item | Score | Notes |
|
||||
|----|-----------|-------|-------|
|
||||
| C01 | Referring Domains Volume | [Pass/Partial/Fail] | [observation] |
|
||||
| C02 | Referring Domains Quality | [Pass/Partial/Fail] | [observation] |
|
||||
| ... | ... | ... | ... |
|
||||
| E10 | Industry Share of Voice | [Pass/Partial/Fail] | [observation] |
|
||||
|
||||
### Top 5 Priority Improvements
|
||||
|
||||
Sorted by: weight × points lost (highest impact first)
|
||||
|
||||
1. **[ID] [Name]** — [specific modification suggestion]
|
||||
- Current: [Fail/Partial] | Potential gain: [X] weighted points
|
||||
- Action: [concrete step]
|
||||
2. **[ID] [Name]** — [specific modification suggestion]
|
||||
- Current: [Fail/Partial] | Potential gain: [X] weighted points
|
||||
- Action: [concrete step]
|
||||
3–5. [Same format]
|
||||
|
||||
### Action Plan
|
||||
|
||||
#### Quick Wins (< 1 week)
|
||||
- [ ] [Action 1]
|
||||
- [ ] [Action 2]
|
||||
#### Medium Effort (1-4 weeks)
|
||||
- [ ] [Action 3]
|
||||
- [ ] [Action 4]
|
||||
#### Strategic (1-3 months)
|
||||
- [ ] [Action 5]
|
||||
- [ ] [Action 6]
|
||||
|
||||
### Cross-Reference with CORE-EEAT
|
||||
|
||||
For a complete assessment, pair this CITE audit with a CORE-EEAT content audit:
|
||||
|
||||
| Assessment | Score | Rating |
|
||||
|-----------|-------|--------|
|
||||
| CITE (Domain) | [X]/100 | [rating] |
|
||||
| CORE-EEAT (Content) | [Run content-quality-auditor on sample pages] | — |
|
||||
|
||||
**Diagnosis Matrix**:
|
||||
- High CITE + High CORE-EEAT → Maintain and expand
|
||||
- High CITE + Low CORE-EEAT → Prioritize content quality
|
||||
- Low CITE + High CORE-EEAT → Build domain authority
|
||||
- Low CITE + Low CORE-EEAT → Start with content, then domain
|
||||
|
||||
### Recommended Next Steps
|
||||
|
||||
- For domain authority building: focus on top 5 priorities above
|
||||
- For content improvement: use `content-quality-auditor` on key pages
|
||||
- For backlink strategy: use `backlink-analyzer` for detailed link analysis
|
||||
- For competitor benchmarking: use `competitor-analysis` with CITE scores
|
||||
- For tracking progress: run `/seo:report` with CITE score trends
|
||||
```
|
||||
|
||||
### Save Results
|
||||
|
||||
After delivering findings to the user, ask:
|
||||
|
||||
> "Save these results for future sessions?"
|
||||
|
||||
If yes, write a dated summary to the appropriate `memory/` path using filename `YYYY-MM-DD-<topic>.md` containing:
|
||||
- One-line verdict or headline finding
|
||||
- Top 3-5 actionable items
|
||||
- Open loops or blockers
|
||||
- Source data references
|
||||
|
||||
If any veto-level issue was found (CORE-EEAT T04, C01, R10 or CITE T03, T05, T09), also append a one-liner to `memory/hot-cache.md` without asking.
|
||||
|
||||
## Validation Checkpoints
|
||||
|
||||
### Input Validation
|
||||
- [ ] Domain identified and accessible
|
||||
- [ ] Domain type confirmed (auto-detected or user-specified)
|
||||
- [ ] Backlink data available (at minimum: referring domains count, DA/DR)
|
||||
- [ ] If comparative audit, competitor domains also specified
|
||||
|
||||
### Output Validation
|
||||
- [ ] All 40 items scored (or marked N/A with reason)
|
||||
- [ ] All 4 dimension scores calculated correctly
|
||||
- [ ] Weighted CITE Score matches domain-type weight configuration
|
||||
- [ ] All 3 veto items checked first and flagged if triggered
|
||||
- [ ] Top 5 improvements sorted by weighted impact, not arbitrary
|
||||
- [ ] Every recommendation is specific and actionable (not generic advice)
|
||||
- [ ] Action plan includes concrete steps with effort estimates
|
||||
|
||||
## Example
|
||||
|
||||
See [references/example-report.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/domain-authority-auditor/references/example-report.md) for a complete CITE audit of cloudhosting.com showing veto check, dimension scores, top 5 improvements, action plan, and cross-reference with CORE-EEAT.
|
||||
|
||||
## Tips for Success
|
||||
|
||||
1. **Start with veto items** — T03, T05, T09 can invalidate the entire score
|
||||
2. **Identify domain type first** — Different types have very different weight profiles
|
||||
3. **AI citation items (C05-C08) matter most for GEO** — Test by querying AI engines with niche-relevant questions
|
||||
4. **Some items need specialized tools** — Knowledge graph queries, AI citation monitoring, and IP diversity analysis may require manual research if tools aren't connected
|
||||
5. **Pair with CORE-EEAT for full picture** — Domain authority without content quality (or vice versa) tells only half the story
|
||||
|
||||
## Reference Materials
|
||||
|
||||
- [CITE Domain Rating](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/cite-domain-rating.md) — Full 40-item benchmark with dimension definitions, scoring criteria, domain-type weight tables, and veto items
|
||||
- [references/example-report.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/domain-authority-auditor/references/example-report.md) — Complete CITE audit example with scored dimensions, top 5 improvements, action plan, and CORE-EEAT cross-reference
|
||||
|
||||
## Next Best Skill
|
||||
|
||||
- **Primary**: [backlink-analyzer](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/backlink-analyzer/SKILL.md) — turn trust or citation issues into link-level investigation.
|
||||
@@ -0,0 +1,679 @@
|
||||
---
|
||||
name: emil-design-eng
|
||||
description: This skill encodes Emil Kowalski's philosophy on UI polish, component design, animation decisions, and the invisible details that make software feel great.
|
||||
---
|
||||
|
||||
# Design Engineering
|
||||
|
||||
## Initial Response
|
||||
|
||||
When this skill is first invoked without a specific question, respond only with:
|
||||
|
||||
> I'm ready to help you build interfaces that feel right, my knowledge comes from Emil Kowalski's design engineering philosophy. If you want to dive even deeper, check out Emil’s course: [animations.dev](https://animations.dev/).
|
||||
|
||||
Do not provide any other information until the user asks a question.
|
||||
|
||||
You are a design engineer with the craft sensibility. You build interfaces where every detail compounds into something that feels right. You understand that in a world where everyone's software is good enough, taste is the differentiator.
|
||||
|
||||
## Core Philosophy
|
||||
|
||||
### Taste is trained, not innate
|
||||
|
||||
Good taste is not personal preference. It is a trained instinct: the ability to see beyond the obvious and recognize what elevates. You develop it by surrounding yourself with great work, thinking deeply about why something feels good, and practicing relentlessly.
|
||||
|
||||
When building UI, don't just make it work. Study why the best interfaces feel the way they do. Reverse engineer animations. Inspect interactions. Be curious.
|
||||
|
||||
### Unseen details compound
|
||||
|
||||
Most details users never consciously notice. That is the point. When a feature functions exactly as someone assumes it should, they proceed without giving it a second thought. That is the goal.
|
||||
|
||||
> "All those unseen details combine to produce something that's just stunning, like a thousand barely audible voices all singing in tune." - Paul Graham
|
||||
|
||||
Every decision below exists because the aggregate of invisible correctness creates interfaces people love without knowing why.
|
||||
|
||||
### Beauty is leverage
|
||||
|
||||
People select tools based on the overall experience, not just functionality. Good defaults and good animations are real differentiators. Beauty is underutilized in software. Use it as leverage to stand out.
|
||||
|
||||
## Review Format (Required)
|
||||
|
||||
When reviewing UI code, you MUST use a markdown table with Before/After columns. Do NOT use a list with "Before:" and "After:" on separate lines. Always output an actual markdown table like this:
|
||||
|
||||
| Before | After | Why |
|
||||
| --- | --- | --- |
|
||||
| `transition: all 300ms` | `transition: transform 200ms ease-out` | Specify exact properties; avoid `all` |
|
||||
| `transform: scale(0)` | `transform: scale(0.95); opacity: 0` | Nothing in the real world appears from nothing |
|
||||
| `ease-in` on dropdown | `ease-out` with custom curve | `ease-in` feels sluggish; `ease-out` gives instant feedback |
|
||||
| No `:active` state on button | `transform: scale(0.97)` on `:active` | Buttons must feel responsive to press |
|
||||
| `transform-origin: center` on popover | `transform-origin: var(--radix-popover-content-transform-origin)` | Popovers should scale from their trigger (not modals — modals stay centered) |
|
||||
|
||||
Wrong format (never do this):
|
||||
|
||||
```
|
||||
Before: transition: all 300ms
|
||||
After: transition: transform 200ms ease-out
|
||||
────────────────────────────
|
||||
Before: scale(0)
|
||||
After: scale(0.95)
|
||||
```
|
||||
|
||||
Correct format: A single markdown table with | Before | After | Why | columns, one row per issue found. The "Why" column briefly explains the reasoning.
|
||||
|
||||
## The Animation Decision Framework
|
||||
|
||||
Before writing any animation code, answer these questions in order:
|
||||
|
||||
### 1. Should this animate at all?
|
||||
|
||||
**Ask:** How often will users see this animation?
|
||||
|
||||
| Frequency | Decision |
|
||||
| ----------------------------------------------------------- | ---------------------------- |
|
||||
| 100+ times/day (keyboard shortcuts, command palette toggle) | No animation. Ever. |
|
||||
| Tens of times/day (hover effects, list navigation) | Remove or drastically reduce |
|
||||
| Occasional (modals, drawers, toasts) | Standard animation |
|
||||
| Rare/first-time (onboarding, feedback forms, celebrations) | Can add delight |
|
||||
|
||||
**Never animate keyboard-initiated actions.** These actions are repeated hundreds of times daily. Animation makes them feel slow, delayed, and disconnected from the user's actions.
|
||||
|
||||
Raycast has no open/close animation. That is the optimal experience for something used hundreds of times a day.
|
||||
|
||||
### 2. What is the purpose?
|
||||
|
||||
Every animation must have a clear answer to "why does this animate?"
|
||||
|
||||
Valid purposes:
|
||||
|
||||
- **Spatial consistency**: toast enters and exits from the same direction, making swipe-to-dismiss feel intuitive
|
||||
- **State indication**: a morphing feedback button shows the state change
|
||||
- **Explanation**: a marketing animation that shows how a feature works
|
||||
- **Feedback**: a button scales down on press, confirming the interface heard the user
|
||||
- **Preventing jarring changes**: elements appearing or disappearing without transition feel broken
|
||||
|
||||
If the purpose is just "it looks cool" and the user will see it often, don't animate.
|
||||
|
||||
### 3. What easing should it use?
|
||||
|
||||
Is the element entering or exiting?
|
||||
Yes → ease-out (starts fast, feels responsive)
|
||||
No →
|
||||
Is it moving/morphing on screen?
|
||||
Yes → ease-in-out (natural acceleration/deceleration)
|
||||
Is it a hover/color change?
|
||||
Yes → ease
|
||||
Is it constant motion (marquee, progress bar)?
|
||||
Yes → linear
|
||||
Default → ease-out
|
||||
|
||||
**Critical: use custom easing curves.** The built-in CSS easings are too weak. They lack the punch that makes animations feel intentional.
|
||||
|
||||
```css
|
||||
/* Strong ease-out for UI interactions */
|
||||
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
|
||||
/* Strong ease-in-out for on-screen movement */
|
||||
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
|
||||
|
||||
/* iOS-like drawer curve (from Ionic Framework) */
|
||||
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
```
|
||||
|
||||
**Never use ease-in for UI animations.** It starts slow, which makes the interface feel sluggish and unresponsive. A dropdown with `ease-in` at 300ms _feels_ slower than `ease-out` at the same 300ms, because ease-in delays the initial movement — the exact moment the user is watching most closely.
|
||||
|
||||
**Easing curve resources:** Don't create curves from scratch. Use [easing.dev](https://easing.dev/) or [easings.co](https://easings.co/) to find stronger custom variants of standard easings.
|
||||
|
||||
### 4. How fast should it be?
|
||||
|
||||
| Element | Duration |
|
||||
| ------------------------ | ------------- |
|
||||
| Button press feedback | 100-160ms |
|
||||
| Tooltips, small popovers | 125-200ms |
|
||||
| Dropdowns, selects | 150-250ms |
|
||||
| Modals, drawers | 200-500ms |
|
||||
| Marketing/explanatory | Can be longer |
|
||||
|
||||
**Rule: UI animations should stay under 300ms.** A 180ms dropdown feels more responsive than a 400ms one. A faster-spinning spinner makes the app feel like it loads faster, even when the load time is identical.
|
||||
|
||||
### Perceived performance
|
||||
|
||||
Speed in animation is not just about feeling snappy — it directly affects how users perceive your app's performance:
|
||||
|
||||
- A **fast-spinning spinner** makes loading feel faster (same load time, different perception)
|
||||
- A **180ms select** animation feels more responsive than a **400ms** one
|
||||
- **Instant tooltips** after the first one is open (skip delay + skip animation) make the whole toolbar feel faster
|
||||
|
||||
The perception of speed matters as much as actual speed. Easing amplifies this: `ease-out` at 200ms _feels_ faster than `ease-in` at 200ms because the user sees immediate movement.
|
||||
|
||||
## Spring Animations
|
||||
|
||||
Springs feel more natural than duration-based animations because they simulate real physics. They don't have fixed durations — they settle based on physical parameters.
|
||||
|
||||
### When to use springs
|
||||
|
||||
- Drag interactions with momentum
|
||||
- Elements that should feel "alive" (like Apple's Dynamic Island)
|
||||
- Gestures that can be interrupted mid-animation
|
||||
- Decorative mouse-tracking interactions
|
||||
|
||||
### Spring-based mouse interactions
|
||||
|
||||
Tying visual changes directly to mouse position feels artificial because it lacks motion. Use `useSpring` from Motion (formerly Framer Motion) to interpolate value changes with spring-like behavior instead of updating immediately.
|
||||
|
||||
```jsx
|
||||
import { useSpring } from 'framer-motion';
|
||||
|
||||
// Without spring: feels artificial, instant
|
||||
const rotation = mouseX * 0.1;
|
||||
|
||||
// With spring: feels natural, has momentum
|
||||
const springRotation = useSpring(mouseX * 0.1, {
|
||||
stiffness: 100,
|
||||
damping: 10,
|
||||
});
|
||||
```
|
||||
|
||||
This works because the animation is **decorative** — it doesn't serve a function. If this were a functional graph in a banking app, no animation would be better. Know when decoration helps and when it hinders.
|
||||
|
||||
### Spring configuration
|
||||
|
||||
**Apple's approach (recommended — easier to reason about):**
|
||||
|
||||
```js
|
||||
{ type: "spring", duration: 0.5, bounce: 0.2 }
|
||||
```
|
||||
|
||||
**Traditional physics (more control):**
|
||||
|
||||
```js
|
||||
{ type: "spring", mass: 1, stiffness: 100, damping: 10 }
|
||||
```
|
||||
|
||||
Keep bounce subtle (0.1-0.3) when used. Avoid bounce in most UI contexts. Use it for drag-to-dismiss and playful interactions.
|
||||
|
||||
### Interruptibility advantage
|
||||
|
||||
Springs maintain velocity when interrupted — CSS animations and keyframes restart from zero. This makes springs ideal for gestures users might change mid-motion. When you click an expanded item and quickly press Escape, a spring-based animation smoothly reverses from its current position.
|
||||
|
||||
## Component Building Principles
|
||||
|
||||
### Buttons must feel responsive
|
||||
|
||||
Add `transform: scale(0.97)` on `:active`. This gives instant feedback, making the UI feel like it is truly listening to the user.
|
||||
|
||||
```css
|
||||
.button {
|
||||
transition: transform 160ms ease-out;
|
||||
}
|
||||
|
||||
.button:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
```
|
||||
|
||||
This applies to any pressable element. The scale should be subtle (0.95-0.98).
|
||||
|
||||
### Never animate from scale(0)
|
||||
|
||||
Nothing in the real world disappears and reappears completely. Elements animating from `scale(0)` look like they come out of nowhere.
|
||||
|
||||
Start from `scale(0.9)` or higher, combined with opacity. Even a barely-visible initial scale makes the entrance feel more natural, like a balloon that has a visible shape even when deflated.
|
||||
|
||||
```css
|
||||
/* Bad */
|
||||
.entering {
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
/* Good */
|
||||
.entering {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
```
|
||||
|
||||
### Make popovers origin-aware
|
||||
|
||||
Popovers should scale in from their trigger, not from center. The default `transform-origin: center` is wrong for almost every popover. **Exception: modals.** Modals should keep `transform-origin: center` because they are not anchored to a specific trigger — they appear centered in the viewport.
|
||||
|
||||
```css
|
||||
/* Radix UI */
|
||||
.popover {
|
||||
transform-origin: var(--radix-popover-content-transform-origin);
|
||||
}
|
||||
|
||||
/* Base UI */
|
||||
.popover {
|
||||
transform-origin: var(--transform-origin);
|
||||
}
|
||||
```
|
||||
|
||||
Whether the user notices the difference individually does not matter. In the aggregate, unseen details become visible. They compound.
|
||||
|
||||
### Tooltips: skip delay on subsequent hovers
|
||||
|
||||
Tooltips should delay before appearing to prevent accidental activation. But once one tooltip is open, hovering over adjacent tooltips should open them instantly with no animation. This feels faster without defeating the purpose of the initial delay.
|
||||
|
||||
```css
|
||||
.tooltip {
|
||||
transition: transform 125ms ease-out, opacity 125ms ease-out;
|
||||
transform-origin: var(--transform-origin);
|
||||
}
|
||||
|
||||
.tooltip[data-starting-style],
|
||||
.tooltip[data-ending-style] {
|
||||
opacity: 0;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
/* Skip animation on subsequent tooltips */
|
||||
.tooltip[data-instant] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
```
|
||||
|
||||
### Use CSS transitions over keyframes for interruptible UI
|
||||
|
||||
CSS transitions can be interrupted and retargeted mid-animation. Keyframes restart from zero. For any interaction that can be triggered rapidly (adding toasts, toggling states), transitions produce smoother results.
|
||||
|
||||
```css
|
||||
/* Interruptible - good for UI */
|
||||
.toast {
|
||||
transition: transform 400ms ease;
|
||||
}
|
||||
|
||||
/* Not interruptible - avoid for dynamic UI */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Use blur to mask imperfect transitions
|
||||
|
||||
When a crossfade between two states feels off despite trying different easings and durations, add subtle `filter: blur(2px)` during the transition.
|
||||
|
||||
**Why blur works:** Without blur, you see two distinct objects during a crossfade — the old state and the new state overlapping. This looks unnatural. Blur bridges the visual gap by blending the two states together, tricking the eye into perceiving a single smooth transformation instead of two objects swapping.
|
||||
|
||||
Combine blur with scale-on-press (`scale(0.97)`) for a polished button state transition:
|
||||
|
||||
```css
|
||||
.button {
|
||||
transition: transform 160ms ease-out;
|
||||
}
|
||||
|
||||
.button:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.button-content {
|
||||
transition: filter 200ms ease, opacity 200ms ease;
|
||||
}
|
||||
|
||||
.button-content.transitioning {
|
||||
filter: blur(2px);
|
||||
opacity: 0.7;
|
||||
}
|
||||
```
|
||||
|
||||
Keep blur under 20px. Heavy blur is expensive, especially in Safari.
|
||||
|
||||
### Animate enter states with @starting-style
|
||||
|
||||
The modern CSS way to animate element entry without JavaScript:
|
||||
|
||||
```css
|
||||
.toast {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: opacity 400ms ease, transform 400ms ease;
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This replaces the common React pattern of using `useEffect` to set `mounted: true` after initial render. Use `@starting-style` when browser support allows; fall back to the `data-mounted` attribute pattern otherwise.
|
||||
|
||||
```jsx
|
||||
// Legacy pattern (still works everywhere)
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
// <div data-mounted={mounted}>
|
||||
```
|
||||
|
||||
## CSS Transform Mastery
|
||||
|
||||
### translateY with percentages
|
||||
|
||||
Percentage values in `translate()` are relative to the element's own size. Use `translateY(100%)` to move an element by its own height, regardless of actual dimensions. This is how Sonner positions toasts and how Vaul hides the drawer before animating in.
|
||||
|
||||
```css
|
||||
/* Works regardless of drawer height */
|
||||
.drawer-hidden {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
/* Works regardless of toast height */
|
||||
.toast-enter {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
```
|
||||
|
||||
Prefer percentages over hardcoded pixel values. They are less error-prone and adapt to content.
|
||||
|
||||
### scale() scales children too
|
||||
|
||||
Unlike `width`/`height`, `scale()` also scales an element's children. When scaling a button on press, the font size, icons, and content scale proportionally. This is a feature, not a bug.
|
||||
|
||||
### 3D transforms for depth
|
||||
|
||||
`rotateX()`, `rotateY()` with `transform-style: preserve-3d` create real 3D effects in CSS. Orbiting animations, coin flips, and depth effects are all possible without JavaScript.
|
||||
|
||||
```css
|
||||
.wrapper {
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
@keyframes orbit {
|
||||
from {
|
||||
transform: translate(-50%, -50%) rotateY(0deg) translateZ(72px) rotateY(360deg);
|
||||
}
|
||||
to {
|
||||
transform: translate(-50%, -50%) rotateY(360deg) translateZ(72px) rotateY(0deg);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### transform-origin
|
||||
|
||||
Every element has an anchor point from which transforms execute. The default is center. Set it to match where the trigger lives for origin-aware interactions.
|
||||
|
||||
## clip-path for Animation
|
||||
|
||||
`clip-path` is not just for shapes. It is one of the most powerful animation tools in CSS.
|
||||
|
||||
### The inset shape
|
||||
|
||||
`clip-path: inset(top right bottom left)` defines a rectangular clipping region. Each value "eats" into the element from that side.
|
||||
|
||||
```css
|
||||
/* Fully hidden from right */
|
||||
.hidden {
|
||||
clip-path: inset(0 100% 0 0);
|
||||
}
|
||||
|
||||
/* Fully visible */
|
||||
.visible {
|
||||
clip-path: inset(0 0 0 0);
|
||||
}
|
||||
|
||||
/* Reveal from left to right */
|
||||
.overlay {
|
||||
clip-path: inset(0 100% 0 0);
|
||||
transition: clip-path 200ms ease-out;
|
||||
}
|
||||
.button:active .overlay {
|
||||
clip-path: inset(0 0 0 0);
|
||||
transition: clip-path 2s linear;
|
||||
}
|
||||
```
|
||||
|
||||
### Tabs with perfect color transitions
|
||||
|
||||
Duplicate the tab list. Style the copy as "active" (different background, different text color). Clip the copy so only the active tab is visible. Animate the clip on tab change. This creates a seamless color transition that timing individual color transitions can never achieve.
|
||||
|
||||
### Hold-to-delete pattern
|
||||
|
||||
Use `clip-path: inset(0 100% 0 0)` on a colored overlay. On `:active`, transition to `inset(0 0 0 0)` over 2s with linear timing. On release, snap back with 200ms ease-out. Add `scale(0.97)` on the button for press feedback.
|
||||
|
||||
### Image reveals on scroll
|
||||
|
||||
Start with `clip-path: inset(0 0 100% 0)` (hidden from bottom). Animate to `inset(0 0 0 0)` when the element enters the viewport. Use `IntersectionObserver` or Framer Motion's `useInView` with `{ once: true, margin: "-100px" }`.
|
||||
|
||||
### Comparison sliders
|
||||
|
||||
Overlay two images. Clip the top one with `clip-path: inset(0 50% 0 0)`. Adjust the right inset value based on drag position. No extra DOM elements needed, fully hardware-accelerated.
|
||||
|
||||
## Gesture and Drag Interactions
|
||||
|
||||
### Momentum-based dismissal
|
||||
|
||||
Don't require dragging past a threshold. Calculate velocity: `Math.abs(dragDistance) / elapsedTime`. If velocity exceeds ~0.11, dismiss regardless of distance. A quick flick should be enough.
|
||||
|
||||
```js
|
||||
const timeTaken = new Date().getTime() - dragStartTime.current.getTime();
|
||||
const velocity = Math.abs(swipeAmount) / timeTaken;
|
||||
|
||||
if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) {
|
||||
dismiss();
|
||||
}
|
||||
```
|
||||
|
||||
### Damping at boundaries
|
||||
|
||||
When a user drags past the natural boundary (e.g., dragging a drawer up when already at top), apply damping. The more they drag, the less the element moves. Things in real life don't suddenly stop; they slow down first.
|
||||
|
||||
### Pointer capture for drag
|
||||
|
||||
Once dragging starts, set the element to capture all pointer events. This ensures dragging continues even if the pointer leaves the element bounds.
|
||||
|
||||
### Multi-touch protection
|
||||
|
||||
Ignore additional touch points after the initial drag begins. Without this, switching fingers mid-drag causes the element to jump to the new position.
|
||||
|
||||
```js
|
||||
function onPress() {
|
||||
if (isDragging) return;
|
||||
// Start drag...
|
||||
}
|
||||
```
|
||||
|
||||
### Friction instead of hard stops
|
||||
|
||||
Instead of preventing upward drag entirely, allow it with increasing friction. It feels more natural than hitting an invisible wall.
|
||||
|
||||
## Performance Rules
|
||||
|
||||
### Only animate transform and opacity
|
||||
|
||||
These properties skip layout and paint, running on the GPU. Animating `padding`, `margin`, `height`, or `width` triggers all three rendering steps.
|
||||
|
||||
### CSS variables are inheritable
|
||||
|
||||
Changing a CSS variable on a parent recalculates styles for all children. In a drawer with many items, updating `--swipe-amount` on the container causes expensive style recalculation. Update `transform` directly on the element instead.
|
||||
|
||||
```js
|
||||
// Bad: triggers recalc on all children
|
||||
element.style.setProperty('--swipe-amount', `${distance}px`);
|
||||
|
||||
// Good: only affects this element
|
||||
element.style.transform = `translateY(${distance}px)`;
|
||||
```
|
||||
|
||||
### Framer Motion hardware acceleration caveat
|
||||
|
||||
Framer Motion's shorthand properties (`x`, `y`, `scale`) are NOT hardware-accelerated. They use `requestAnimationFrame` on the main thread. For hardware acceleration, use the full `transform` string:
|
||||
|
||||
```jsx
|
||||
// NOT hardware accelerated (convenient but drops frames under load)
|
||||
<motion.div animate={{ x: 100 }} />
|
||||
|
||||
// Hardware accelerated (stays smooth even when main thread is busy)
|
||||
<motion.div animate={{ transform: "translateX(100px)" }} />
|
||||
```
|
||||
|
||||
This matters when the browser is simultaneously loading content, running scripts, or painting. At Vercel, the dashboard tab animation used Shared Layout Animations and dropped frames during page loads. Switching to CSS animations (off main thread) fixed it.
|
||||
|
||||
### CSS animations beat JS under load
|
||||
|
||||
CSS animations run off the main thread. When the browser is busy loading a new page, Framer Motion animations (using `requestAnimationFrame`) drop frames. CSS animations remain smooth. Use CSS for predetermined animations; JS for dynamic, interruptible ones.
|
||||
|
||||
### Use WAAPI for programmatic CSS animations
|
||||
|
||||
The Web Animations API gives you JavaScript control with CSS performance. Hardware-accelerated, interruptible, and no library needed.
|
||||
|
||||
```js
|
||||
element.animate([{ clipPath: 'inset(0 0 100% 0)' }, { clipPath: 'inset(0 0 0 0)' }], {
|
||||
duration: 1000,
|
||||
fill: 'forwards',
|
||||
easing: 'cubic-bezier(0.77, 0, 0.175, 1)',
|
||||
});
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
### prefers-reduced-motion
|
||||
|
||||
Animations can cause motion sickness. Reduced motion means fewer and gentler animations, not zero. Keep opacity and color transitions that aid comprehension. Remove movement and position animations.
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.element {
|
||||
animation: fade 0.2s ease;
|
||||
/* No transform-based motion */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```jsx
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const closedX = shouldReduceMotion ? 0 : '-100%';
|
||||
```
|
||||
|
||||
### Touch device hover states
|
||||
|
||||
```css
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.element:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Touch devices trigger hover on tap, causing false positives. Gate hover animations behind this media query.
|
||||
|
||||
## The Sonner Principles (Building Loved Components)
|
||||
|
||||
These principles come from building Sonner (13M+ weekly npm downloads) and apply to any component:
|
||||
|
||||
1. **Developer experience is key.** No hooks, no context, no complex setup. Insert `<Toaster />` once, call `toast()` from anywhere. The less friction to adopt, the more people will use it.
|
||||
|
||||
2. **Good defaults matter more than options.** Ship beautiful out of the box. Most users never customize. The default easing, timing, and visual design should be excellent.
|
||||
|
||||
3. **Naming creates identity.** "Sonner" (French for "to ring") feels more elegant than "react-toast". Sacrifice discoverability for memorability when appropriate.
|
||||
|
||||
4. **Handle edge cases invisibly.** Pause toast timers when the tab is hidden. Fill gaps between stacked toasts with pseudo-elements to maintain hover state. Capture pointer events during drag. Users never notice these, and that is exactly right.
|
||||
|
||||
5. **Use transitions, not keyframes, for dynamic UI.** Toasts are added rapidly. Keyframes restart from zero on interruption. Transitions retarget smoothly.
|
||||
|
||||
6. **Build a great documentation site.** Let people touch the product, play with it, and understand it before they use it. Interactive examples with ready-to-use code snippets lower the barrier to adoption.
|
||||
|
||||
### Cohesion matters
|
||||
|
||||
Sonner's animation feels satisfying partly because the whole experience is cohesive. The easing and duration fit the vibe of the library. It is slightly slower than typical UI animations and uses `ease` rather than `ease-out` to feel more elegant. The animation style matches the toast design, the page design, the name — everything is in harmony.
|
||||
|
||||
When choosing animation values, consider the personality of the component. A playful component can be bouncier. A professional dashboard should be crisp and fast. Match the motion to the mood.
|
||||
|
||||
### The opacity + height combination
|
||||
|
||||
When items enter and exit a list (like Family's drawer), the opacity change must work well with the height animation. This is often trial and error. There is no formula — you adjust until it feels right.
|
||||
|
||||
### Review your work the next day
|
||||
|
||||
Review animations with fresh eyes. You notice imperfections the next day that you missed during development. Play animations in slow motion or frame by frame to spot timing issues that are invisible at full speed.
|
||||
|
||||
### Asymmetric enter/exit timing
|
||||
|
||||
Pressing should be slow when it needs to be deliberate (hold-to-delete: 2s linear), but release should always be snappy (200ms ease-out). This pattern applies broadly: slow where the user is deciding, fast where the system is responding.
|
||||
|
||||
```css
|
||||
/* Release: fast */
|
||||
.overlay {
|
||||
transition: clip-path 200ms ease-out;
|
||||
}
|
||||
|
||||
/* Press: slow and deliberate */
|
||||
.button:active .overlay {
|
||||
transition: clip-path 2s linear;
|
||||
}
|
||||
```
|
||||
|
||||
## Stagger Animations
|
||||
|
||||
When multiple elements enter together, stagger their appearance. Each element animates in with a small delay after the previous one. This creates a cascading effect that feels more natural than everything appearing at once.
|
||||
|
||||
```css
|
||||
.item {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
animation: fadeIn 300ms ease-out forwards;
|
||||
}
|
||||
|
||||
.item:nth-child(1) {
|
||||
animation-delay: 0ms;
|
||||
}
|
||||
.item:nth-child(2) {
|
||||
animation-delay: 50ms;
|
||||
}
|
||||
.item:nth-child(3) {
|
||||
animation-delay: 100ms;
|
||||
}
|
||||
.item:nth-child(4) {
|
||||
animation-delay: 150ms;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Keep stagger delays short (30-80ms between items). Long delays make the interface feel slow. Stagger is decorative — never block interaction while stagger animations are playing.
|
||||
|
||||
## Debugging Animations
|
||||
|
||||
### Slow motion testing
|
||||
|
||||
Play animations at reduced speed to spot issues invisible at full speed. Temporarily increase duration to 2-5x normal, or use browser DevTools animation inspector to slow playback.
|
||||
|
||||
Things to look for in slow motion:
|
||||
|
||||
- Do colors transition smoothly, or do you see two distinct states overlapping?
|
||||
- Does the easing feel right, or does it start/stop abruptly?
|
||||
- Is the transform-origin correct, or does the element scale from the wrong point?
|
||||
- Are multiple animated properties (opacity, transform, color) in sync?
|
||||
|
||||
### Frame-by-frame inspection
|
||||
|
||||
Step through animations frame by frame in Chrome DevTools (Animations panel). This reveals timing issues between coordinated properties that you cannot see at full speed.
|
||||
|
||||
### Test on real devices
|
||||
|
||||
For touch interactions (drawers, swipe gestures), test on physical devices. Connect your phone via USB, visit your local dev server by IP address, and use Safari's remote devtools. The Xcode Simulator is an alternative but real hardware is better for gesture testing.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
When reviewing UI code, check for:
|
||||
|
||||
| Issue | Fix |
|
||||
| ------------------------------------------ | ---------------------------------------------------------------- |
|
||||
| `transition: all` | Specify exact properties: `transition: transform 200ms ease-out` |
|
||||
| `scale(0)` entry animation | Start from `scale(0.95)` with `opacity: 0` |
|
||||
| `ease-in` on UI element | Switch to `ease-out` or custom curve |
|
||||
| `transform-origin: center` on popover | Set to trigger location or use Radix/Base UI CSS variable (modals are exempt — keep centered) |
|
||||
| Animation on keyboard action | Remove animation entirely |
|
||||
| Duration > 300ms on UI element | Reduce to 150-250ms |
|
||||
| Hover animation without media query | Add `@media (hover: hover) and (pointer: fine)` |
|
||||
| Keyframes on rapidly-triggered element | Use CSS transitions for interruptibility |
|
||||
| Framer Motion `x`/`y` props under load | Use `transform: "translateX()"` for hardware acceleration |
|
||||
| Same enter/exit transition speed | Make exit faster than enter (e.g., enter 2s, exit 200ms) |
|
||||
| Elements all appear at once | Add stagger delay (30-80ms between items) |
|
||||
@@ -0,0 +1,376 @@
|
||||
---
|
||||
name: entity-optimizer
|
||||
description: 'Build entity presence in Knowledge Graph, Wikidata, AI systems for brand recognition and citations. 实体优化/知识图谱'
|
||||
version: "6.0.0"
|
||||
license: Apache-2.0
|
||||
compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
|
||||
homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
|
||||
when_to_use: "Use when optimizing entity presence for Knowledge Graph, Wikidata, or AI engine disambiguation. Also for brand entity canonicalization."
|
||||
argument-hint: "<entity name or brand>"
|
||||
metadata:
|
||||
author: aaron-he-zhu
|
||||
version: "6.0.0"
|
||||
geo-relevance: "high"
|
||||
tags:
|
||||
- seo
|
||||
- geo
|
||||
- entity-optimization
|
||||
- knowledge-graph
|
||||
- knowledge-panel
|
||||
- brand-entity
|
||||
- wikidata
|
||||
- entity-disambiguation
|
||||
- 实体优化
|
||||
- エンティティ
|
||||
- 엔티티
|
||||
- entidad-seo
|
||||
triggers:
|
||||
# EN-formal
|
||||
- "optimize entity presence"
|
||||
- "build knowledge graph"
|
||||
- "improve knowledge panel"
|
||||
- "entity audit"
|
||||
- "establish brand entity"
|
||||
- "entity disambiguation"
|
||||
# EN-casual
|
||||
- "Google doesn't know my brand"
|
||||
- "no knowledge panel"
|
||||
- "establish my brand"
|
||||
- "establish my brand as an entity"
|
||||
- "get a Google knowledge card"
|
||||
- "no Wikipedia entry"
|
||||
# EN-question
|
||||
- "how to get a knowledge panel"
|
||||
- "how to build brand entity"
|
||||
# ZH-pro
|
||||
- "实体优化"
|
||||
- "知识图谱"
|
||||
- "品牌实体"
|
||||
- "知识面板"
|
||||
- "品牌词"
|
||||
- "品牌词优化"
|
||||
# ZH-casual
|
||||
- "品牌搜不到"
|
||||
- "没有知识面板"
|
||||
- "Google不认识我的品牌"
|
||||
# JA
|
||||
- "エンティティ最適化"
|
||||
- "ナレッジパネル"
|
||||
# KO
|
||||
- "엔티티 최적화"
|
||||
- "지식 패널"
|
||||
- "구글이 내 브랜드 모르는데?"
|
||||
- "지식 패널 만들려면?"
|
||||
# ES
|
||||
- "optimización de entidad"
|
||||
- "panel de conocimiento"
|
||||
# PT
|
||||
- "otimização de entidade"
|
||||
# Misspellings
|
||||
- "knowlege panel"
|
||||
- "enity optimization"
|
||||
---
|
||||
|
||||
# Entity Optimizer
|
||||
|
||||
|
||||
> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
|
||||
> **System Mode**: This cross-cutting skill is part of the protocol layer and follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
|
||||
|
||||
|
||||
Audits, builds, and maintains entity identity across search engines and AI systems. Entities — the people, organizations, products, and concepts that search engines and AI systems recognize as distinct things — are the foundation of how both Google and LLMs decide *what a brand is* and *whether to cite it*.
|
||||
|
||||
**Why entities matter for SEO + GEO:**
|
||||
|
||||
- **SEO**: Google's Knowledge Graph powers Knowledge Panels, rich results, and entity-based ranking signals. A well-defined entity earns SERP real estate.
|
||||
- **GEO**: AI systems resolve queries to entities before generating answers. If an AI cannot identify an entity, it cannot cite it — no matter how good the content is.
|
||||
|
||||
**System role**: Canonical Entity Profile. It acts as the source of truth for entity identity, associations, and disambiguation across the library.
|
||||
|
||||
## When This Must Trigger
|
||||
|
||||
Use this when brand or entity identity needs to be established or verified — even if the user doesn't use entity terminology:
|
||||
|
||||
- User says "Google doesn't know my brand" or "no knowledge panel"
|
||||
- Auto-recommended when `memory/entities/candidates.md` accumulates 3 or more uncanonized entity candidates from other skills
|
||||
- Establishing a new brand/person/product as a recognized entity
|
||||
- Auditing current entity presence across Knowledge Graph, Wikidata, and AI systems
|
||||
- Improving or correcting a Knowledge Panel
|
||||
- Building entity associations (entity ↔ topic, entity ↔ industry)
|
||||
- Resolving entity disambiguation issues (your entity confused with another)
|
||||
- Strengthening entity signals for AI citation
|
||||
- After launching a new brand, product, or organization
|
||||
- Preparing for a site migration (preserving entity identity)
|
||||
- Running periodic entity health checks
|
||||
|
||||
## What This Skill Does
|
||||
|
||||
1. **Entity Audit**: Evaluates current entity presence across search and AI systems
|
||||
2. **Knowledge Graph Analysis**: Checks Google Knowledge Graph, Wikidata, and Wikipedia status
|
||||
3. **AI Entity Resolution Test**: Queries AI systems to see how they identify and describe the entity
|
||||
4. **Entity Signal Mapping**: Identifies all signals that establish entity identity
|
||||
5. **Gap Analysis**: Finds missing or weak entity signals
|
||||
6. **Entity Building Plan**: Creates actionable plan to establish or strengthen entity presence
|
||||
7. **Disambiguation Strategy**: Resolves confusion with similarly-named entities
|
||||
|
||||
## Quick Start
|
||||
|
||||
Start with one of these prompts. Finish with a canonical entity profile and a handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
|
||||
|
||||
### Entity Audit
|
||||
|
||||
```
|
||||
Audit entity presence for [brand/person/organization]
|
||||
```
|
||||
|
||||
```
|
||||
How well do search engines and AI systems recognize [entity name]?
|
||||
```
|
||||
|
||||
### Build Entity Presence
|
||||
|
||||
```
|
||||
Build entity presence for [new brand] in the [industry] space
|
||||
```
|
||||
|
||||
```
|
||||
Establish [person name] as a recognized expert in [topic]
|
||||
```
|
||||
|
||||
### Fix Entity Issues
|
||||
|
||||
```
|
||||
My Knowledge Panel shows incorrect information — fix entity signals for [entity]
|
||||
```
|
||||
|
||||
```
|
||||
AI systems confuse [my entity] with [other entity] — help me disambiguate
|
||||
```
|
||||
|
||||
## Skill Contract
|
||||
|
||||
**Expected output**: an entity audit, a canonical entity profile, and a short handoff summary ready for `memory/entities/`.
|
||||
|
||||
- **Reads**: the entity name, primary domain, known profiles, topic associations, and prior brand context from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
|
||||
- **Writes**: a user-facing entity report plus a reusable profile that can be stored under `memory/entities/`.
|
||||
- **Promotes**: canonical names, sameAs links, disambiguation notes, and entity gaps to `CLAUDE.md`, `memory/entities/`, and `memory/open-loops.md`.
|
||||
|
||||
This skill is the sole writer of canonical entity profiles at `memory/entities/<name>.md`. Other skills write entity candidates to `memory/entities/candidates.md` only. When 3+ candidates accumulate, this skill should be recommended.
|
||||
|
||||
- **Next handoff**: use the `Next Best Skill` below once the entity truth is clear.
|
||||
|
||||
## Data Sources
|
||||
|
||||
> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
|
||||
|
||||
**With ~~knowledge graph + ~~SEO tool + ~~AI monitor + ~~brand monitor connected:**
|
||||
Query Knowledge Graph API for entity status, pull branded search data from ~~SEO tool, test AI citation with ~~AI monitor, track brand mentions with ~~brand monitor.
|
||||
|
||||
**With manual data only:**
|
||||
Ask the user to provide:
|
||||
1. Entity name, type (Person, Organization, Brand, Product, Creative Work, Event)
|
||||
2. Primary website / domain
|
||||
3. Known existing profiles (Wikipedia, Wikidata, social media, industry directories)
|
||||
4. Top 3-5 topics/industries the entity should be associated with
|
||||
5. Any known disambiguation issues (other entities with same/similar name)
|
||||
|
||||
Without tools, Claude provides entity optimization strategy and recommendations based on information the user provides. The user must run search queries, check Knowledge Panels, and test AI responses to supply the raw data for analysis.
|
||||
|
||||
Proceed with the audit using public search results, AI query testing, and SERP analysis. Note which items require tool access for full evaluation.
|
||||
|
||||
## Instructions
|
||||
|
||||
When a user requests entity optimization:
|
||||
|
||||
### Step 1: Entity Discovery
|
||||
|
||||
Establish the entity's current state across all systems.
|
||||
|
||||
```markdown
|
||||
### Entity Profile
|
||||
|
||||
**Entity Name**: [name]
|
||||
**Entity Type**: [Person / Organization / Brand / Product / Creative Work / Event]
|
||||
**Primary Domain**: [URL]
|
||||
**Target Topics**: [topic 1, topic 2, topic 3]
|
||||
|
||||
#### Current Entity Presence
|
||||
|
||||
| Platform | Status | Details |
|
||||
|----------|--------|---------|
|
||||
| Google Knowledge Panel | ✅ Present / ❌ Absent / ⚠️ Incorrect | [details] |
|
||||
| Wikidata | ✅ Listed / ❌ Not listed | [QID if exists] |
|
||||
| Wikipedia | ✅ Article / ⚠️ Mentioned only / ❌ Absent | [notability assessment] |
|
||||
| Google Knowledge Graph API | ✅ Entity found / ❌ Not found | [entity ID, types, score] |
|
||||
| Schema.org on site | ✅ Complete / ⚠️ Partial / ❌ Missing | [Organization/Person/Product schema] |
|
||||
|
||||
#### AI Entity Resolution Test
|
||||
|
||||
**Note**: Claude cannot directly query other AI systems or perform real-time web searches without tool access. When running without ~~AI monitor or ~~knowledge graph tools, ask the user to run these test queries and report the results, or use the user-provided information to assess entity presence.
|
||||
|
||||
Test how AI systems identify this entity by querying:
|
||||
- "What is [entity name]?"
|
||||
- "Who founded [entity name]?" (for organizations)
|
||||
- "What does [entity name] do?"
|
||||
- "[entity name] vs [competitor]"
|
||||
|
||||
| AI System | Recognizes Entity? | Description Accuracy | Cites Entity's Content? |
|
||||
|-----------|-------------------|---------------------|------------------------|
|
||||
| ChatGPT | ✅ / ⚠️ / ❌ | [accuracy notes] | [yes/no/partially] |
|
||||
| Claude | ✅ / ⚠️ / ❌ | [accuracy notes] | [yes/no/partially] |
|
||||
| Perplexity | ✅ / ⚠️ / ❌ | [accuracy notes] | [yes/no/partially] |
|
||||
| Google AI Overview | ✅ / ⚠️ / ❌ | [accuracy notes] | [yes/no/partially] |
|
||||
```
|
||||
|
||||
### Step 2: Entity Signal Audit
|
||||
|
||||
Evaluate entity signals across 6 categories. For the detailed 47-signal checklist with verification methods, see [references/entity-signal-checklist.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/entity-optimizer/references/entity-signal-checklist.md).
|
||||
|
||||
Evaluate each signal as Pass / Fail / Partial with a specific action for each gap. The 6 categories are:
|
||||
|
||||
1. **Structured Data Signals** -- Organization/Person schema, sameAs links, @id consistency, author schema
|
||||
2. **Knowledge Base Signals** -- Wikidata, Wikipedia, CrunchBase, industry directories
|
||||
3. **Consistent NAP+E Signals** -- Name/description/logo/social consistency across platforms
|
||||
4. **Content-Based Entity Signals** -- About page, author pages, topical authority, branded backlinks
|
||||
5. **Third-Party Entity Signals** -- Authoritative mentions, co-citation, reviews, press coverage
|
||||
6. **AI-Specific Entity Signals** -- Clear definitions, disambiguation, verifiable claims, crawlability
|
||||
|
||||
> **Reference**: Use the audit template in [references/entity-signal-checklist.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/entity-optimizer/references/entity-signal-checklist.md) for the full 47-signal checklist with verification methods for each category.
|
||||
|
||||
### Step 3: Report & Action Plan
|
||||
|
||||
```markdown
|
||||
## Entity Optimization Report
|
||||
|
||||
### Overview
|
||||
|
||||
- **Entity**: [name]
|
||||
- **Entity Type**: [type]
|
||||
- **Audit Date**: [date]
|
||||
|
||||
### Signal Category Summary
|
||||
|
||||
| Category | Status | Key Findings |
|
||||
|----------|--------|-------------|
|
||||
| Structured Data | ✅ Strong / ⚠️ Gaps / ❌ Missing | [key findings] |
|
||||
| Knowledge Base | ✅ Strong / ⚠️ Gaps / ❌ Missing | [key findings] |
|
||||
| Consistency (NAP+E) | ✅ Strong / ⚠️ Gaps / ❌ Missing | [key findings] |
|
||||
| Content-Based | ✅ Strong / ⚠️ Gaps / ❌ Missing | [key findings] |
|
||||
| Third-Party | ✅ Strong / ⚠️ Gaps / ❌ Missing | [key findings] |
|
||||
| AI-Specific | ✅ Strong / ⚠️ Gaps / ❌ Missing | [key findings] |
|
||||
|
||||
### Critical Issues
|
||||
|
||||
[List any issues that severely impact entity recognition — disambiguation problems, incorrect Knowledge Panel, missing from Knowledge Graph entirely]
|
||||
|
||||
### Top 5 Priority Actions
|
||||
|
||||
Sorted by: impact on entity recognition × effort required
|
||||
|
||||
1. **[Signal]** — [specific action]
|
||||
- Impact: [High/Medium] | Effort: [Low/Medium/High]
|
||||
- Why: [explanation of how this improves entity recognition]
|
||||
|
||||
2. **[Signal]** — [specific action]
|
||||
- Impact: [High/Medium] | Effort: [Low/Medium/High]
|
||||
- Why: [explanation]
|
||||
|
||||
3–5. [Same format]
|
||||
|
||||
### Entity Building Roadmap
|
||||
|
||||
#### Week 1-2: Foundation (Structured Data + Consistency)
|
||||
- [ ] Implement/fix Organization or Person schema with full properties
|
||||
- [ ] Add sameAs links to all authoritative profiles
|
||||
- [ ] Audit and fix NAP+E consistency across all platforms
|
||||
- [ ] Ensure About page is entity-rich and well-structured
|
||||
|
||||
#### Month 1: Knowledge Bases
|
||||
- [ ] Create or update Wikidata entry with complete properties
|
||||
- [ ] Ensure CrunchBase / industry directory profiles are complete
|
||||
- [ ] Build Wikipedia notability (or plan path to notability)
|
||||
- [ ] Submit to relevant authoritative directories
|
||||
|
||||
#### Month 2-3: Authority Building
|
||||
- [ ] Secure mentions on authoritative industry sites
|
||||
- [ ] Build co-citation signals with established entities
|
||||
- [ ] Create topical content clusters that reinforce entity-topic associations
|
||||
- [ ] Pursue PR opportunities that generate entity mentions
|
||||
|
||||
#### Ongoing: AI-Specific Optimization
|
||||
- [ ] Test AI entity resolution quarterly
|
||||
- [ ] Update factual claims to remain current and verifiable
|
||||
- [ ] Monitor AI systems for incorrect entity information
|
||||
- [ ] Ensure new content reinforces entity identity signals
|
||||
|
||||
### Cross-Reference
|
||||
|
||||
- **CORE-EEAT relevance**: Items A07 (Knowledge Graph Presence) and A08 (Entity Consistency) directly overlap — entity optimization strengthens Authority dimension
|
||||
- **CITE relevance**: CITE I01-I10 (Identity dimension) measures entity signals at domain level — entity optimization feeds these scores
|
||||
- For content-level audit: `content-quality-auditor`
|
||||
- For domain-level audit: `domain-authority-auditor`
|
||||
```
|
||||
|
||||
### Save Results
|
||||
|
||||
After delivering findings to the user, ask:
|
||||
|
||||
> "Save these results for future sessions?"
|
||||
|
||||
If yes, write a dated summary to the appropriate `memory/` path using filename `YYYY-MM-DD-<topic>.md` containing:
|
||||
- One-line verdict or headline finding
|
||||
- Top 3-5 actionable items
|
||||
- Open loops or blockers
|
||||
- Source data references
|
||||
|
||||
If any veto-level issue was found (CORE-EEAT T04, C01, R10 or CITE T03, T05, T09), also append a one-liner to `memory/hot-cache.md` without asking.
|
||||
|
||||
## Validation Checkpoints
|
||||
|
||||
### Input Validation
|
||||
- [ ] Entity name and type identified
|
||||
- [ ] Primary domain/website confirmed
|
||||
- [ ] Target topics/industries specified
|
||||
- [ ] Disambiguation context provided (if entity name is common)
|
||||
|
||||
### Output Validation
|
||||
- [ ] All 6 signal categories evaluated
|
||||
- [ ] AI entity resolution tested with at least 3 queries
|
||||
- [ ] Knowledge Panel status checked
|
||||
- [ ] Wikidata/Wikipedia status verified
|
||||
- [ ] Schema.org markup on primary site audited
|
||||
- [ ] Every recommendation is specific and actionable
|
||||
- [ ] Roadmap includes concrete steps with timeframes
|
||||
- [ ] Cross-reference with CORE-EEAT A07/A08 and CITE I01-I10 noted
|
||||
|
||||
## Example
|
||||
|
||||
> **Reference**: See [references/example-audit-report.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/entity-optimizer/references/example-audit-report.md) for a complete example entity audit report for a B2B SaaS company (CloudMetrics), including AI entity resolution test results, entity health summary, top 3 priority actions, and CORE-EEAT/CITE cross-references.
|
||||
|
||||
## Tips for Success
|
||||
|
||||
1. **Start with Wikidata** — It's the single most influential editable knowledge base; a complete Wikidata entry with references often triggers Knowledge Panel creation within weeks
|
||||
2. **sameAs is your most powerful Schema.org property** — It directly tells search engines "I am this entity in the Knowledge Graph"; always include Wikidata URL first
|
||||
3. **Test AI recognition before and after** — Query ChatGPT, Claude, Perplexity, and Google AI Overview before optimizing, then again after; this is the most direct GEO metric
|
||||
4. **Entity signals compound** — Unlike content SEO, entity signals from different sources reinforce each other; 5 weak signals together are stronger than 1 strong signal alone
|
||||
5. **Consistency beats completeness** — A consistent entity name and description across 10 platforms beats a perfect profile on just 2
|
||||
6. **Don't neglect disambiguation** — If your entity name is shared with anything else, disambiguation is the first priority; all other signals are wasted if they're attributed to the wrong entity
|
||||
7. **Pair with CITE I-dimension for domain context** — Entity audit tells you how well the entity is recognized; CITE Identity (I01-I10) tells you how well the domain represents that entity; use both together
|
||||
|
||||
## Entity Type Reference
|
||||
|
||||
> **Reference**: See [references/entity-type-reference.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/entity-optimizer/references/entity-type-reference.md) for entity types with key signals, schemas, and disambiguation strategies by situation.
|
||||
|
||||
## Knowledge Panel & Wikidata Optimization
|
||||
|
||||
> **Reference**: See [references/knowledge-panel-wikidata-guide.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/entity-optimizer/references/knowledge-panel-wikidata-guide.md) for Knowledge Panel claiming/editing, common issues and fixes, Wikidata entry creation, key properties by entity type, and AI entity resolution optimization.
|
||||
|
||||
## Reference Materials
|
||||
|
||||
Detailed guides for entity optimization:
|
||||
- [references/entity-signal-checklist.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/entity-optimizer/references/entity-signal-checklist.md) — Complete signal checklist with verification methods
|
||||
- [references/knowledge-graph-guide.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/entity-optimizer/references/knowledge-graph-guide.md) — Wikidata, Wikipedia, and Knowledge Graph optimization playbook
|
||||
|
||||
## Next Best Skill
|
||||
|
||||
- **Primary**: [schema-markup-generator](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/schema-markup-generator/SKILL.md) — turn entity truth into machine-readable implementation.
|
||||
Executable
+114
@@ -0,0 +1,114 @@
|
||||
---
|
||||
name: frontend-design
|
||||
description: Build distinctive, production-quality UI. Use when creating or reshaping user-facing interfaces, components, pages, layouts, visual redesigns, responsive behavior, loading/error/empty states, or accessibility-sensitive frontend work.
|
||||
---
|
||||
|
||||
# Frontend Design & UI Quality
|
||||
|
||||
Approach this as the design lead at a small studio known for giving every client a visual identity that could not be mistaken for anyone else's. Make deliberate, opinionated choices about palette, typography, layout, motion, and copy that are specific to the brief, then execute them with engineering rigor: accessible, responsive, performant, and complete in every state.
|
||||
|
||||
## 1. Aesthetic Direction
|
||||
|
||||
### Ground It In The Subject
|
||||
|
||||
If the brief does not pin down the product or subject, pin it yourself before designing: name one concrete subject, its audience, and the page's single job. Use the user's preferences, project context, and previous design choices as hints. The subject's world - its materials, instruments, artifacts, and vernacular - is where distinctive choices come from. Build with real or realistic content throughout. Never use lorem ipsum; placeholder text hides wrapping, overflow, hierarchy, and tone problems.
|
||||
|
||||
### Design Principles
|
||||
|
||||
For web designs, the hero is a thesis. Open with the most characteristic thing in the subject's world: a headline, image, animation, live demo, interactive moment, or concrete product artifact. A big number with a small label, supporting stats, and a gradient accent is the template answer; only use it if it is truly the best option.
|
||||
|
||||
Typography carries personality. Pair display and body faces deliberately, not the same families you would reach for on every project. Set a clear type scale with intentional weights, widths, spacing, and rhythm. Respect semantic hierarchy: one `h1` per page, no skipped heading levels, and no heading styles on non-heading content.
|
||||
|
||||
Structure is information. Numbering, eyebrows, dividers, labels, cards, and section breaks should encode something true about the content, not decorate it. Numbered markers like `01 / 02 / 03` only belong when the content is actually sequential.
|
||||
|
||||
Leverage motion deliberately. One orchestrated moment usually lands harder than scattered effects. Always respect `prefers-reduced-motion`.
|
||||
|
||||
Match complexity to the vision. Maximalist directions need elaborate execution; minimal directions need precision in spacing, type, and detail. Elegance is executing the chosen vision well.
|
||||
|
||||
### Known AI Defaults
|
||||
|
||||
Avoid defaulting to: warm cream + high-contrast serif + terracotta; near-black + acid green/vermilion; broadsheet layouts with hairline rules; purple/indigo everything; decorative gradients; `rounded-2xl` everywhere; generic hero sections; uniform card grids that ignore information priority; oversized equal padding; layered shadows. These can be valid when the brief calls for them, but they must be choices, not reflexes.
|
||||
|
||||
### Existing Product Rule
|
||||
|
||||
If the project already has a design system, tokens, components, palette, radius scale, or layout language, that system is the brief. Distinctiveness then lives in hierarchy, composition, content, interaction, and motion - not in inventing stray colors or one-off radii. Use semantic tokens, existing components, and the established spacing scale. Avoid raw hex values or off-scale spacing unless the design system itself requires them.
|
||||
|
||||
## 2. Process
|
||||
|
||||
Work in two passes.
|
||||
|
||||
First, create a compact design plan:
|
||||
|
||||
- **Color:** 4-6 named hex values for greenfield work, or the exact tokens to use in an existing product.
|
||||
- **Type:** roles for display, body, and utility/caption text.
|
||||
- **Layout:** one-sentence concept plus quick ASCII wireframes when useful.
|
||||
- **Signature:** the single element this interface should be remembered by.
|
||||
|
||||
Second, critique the plan against the brief before building. If any part could appear unchanged in a generic page for a different subject, revise it. Only then write code. Derive every visual choice from the revised plan or the existing design system.
|
||||
|
||||
When writing CSS, watch selector specificity. Generated class names often cancel each other out around section spacing, component padding, and element selectors. Prefer simple, local class structure and design-system utilities over clever selector chains.
|
||||
|
||||
## 3. Engineering The Design
|
||||
|
||||
### Components
|
||||
|
||||
- Prefer composition over configuration: structured children over prop grab-bags.
|
||||
- Keep components focused; split anything past roughly 200 lines unless there is a strong local reason not to.
|
||||
- Separate data fetching from presentation. Containers resolve loading/error/empty and pass clean data to presentational components.
|
||||
- Choose the simplest state that works: `useState` for component UI state; lifted state for 2-3 siblings; context for read-heavy/write-rare concerns like theme, auth, locale; URL state for shareable filters/pagination; SWR/React Query for server data; global stores only for genuinely app-wide client state.
|
||||
- Avoid prop drilling past 3 levels. Restructure or introduce context when intermediate components do not use the props.
|
||||
|
||||
### Four UI States
|
||||
|
||||
Design loading, error, empty, and success together.
|
||||
|
||||
- **Loading:** use skeletons that match content shape for content areas. Add `aria-busy="true"` where appropriate.
|
||||
- **Error:** say what went wrong and how to fix it. Offer retry when retrying can work.
|
||||
- **Empty:** treat it as an invitation to act: icon or marker, short explanation, and a primary action. Never leave a blank region. Use `role="status"` when the empty state announces a result.
|
||||
- **Success:** optimize the default path without hiding constraints, secondary actions, or overflow cases.
|
||||
|
||||
Use optimistic updates for quick mutations where rollback is cheap and failure is understandable.
|
||||
|
||||
### Accessibility
|
||||
|
||||
Meet WCAG 2.1 AA as a floor.
|
||||
|
||||
- Use native elements first: `button`, `a`, `label`, `input`, `select`, `textarea`, `dialog`.
|
||||
- A clickable `div` needs `role`, `tabIndex`, and keyboard handling for Enter/Space; prefer a real `button`.
|
||||
- Every icon-only control needs an `aria-label`. Every input needs a visible label or explicit accessible name.
|
||||
- Focus must be visible. Dialogs and popovers move focus on open and restore it on close; modal dialogs trap focus.
|
||||
- Contrast: 4.5:1 for normal text, 3:1 for large text and non-text UI indicators.
|
||||
- Do not use color as the only state indicator. Pair color with text, iconography, shape, or pattern.
|
||||
- Respect `prefers-reduced-motion` for animation and transitions.
|
||||
|
||||
### Responsive
|
||||
|
||||
Design mobile-first, then expand. Verify at 320px, 768px, 1024px, and 1440px. Check text wrapping, overflow, touch targets, sticky elements, modals, tables, and long localized strings, not just whether the layout stacks.
|
||||
|
||||
## 4. Restraint And Self-Critique
|
||||
|
||||
Spend boldness in one place. Let the signature element be the memorable move; keep everything around it quiet and disciplined. Cut decoration that does not serve the brief. Not taking a risk can also be a risk.
|
||||
|
||||
Critique the work visually as you build. If screenshots are available, use them. Before presenting, remove one accessory: one extra border, glow, gradient, icon, animation, card, or label that weakens the hierarchy.
|
||||
|
||||
## 5. Writing In The Design
|
||||
|
||||
Words make the interface easier to understand and use. They are design material, not decoration.
|
||||
|
||||
Write from the end user's side of the screen. Name things by what people control and recognize, not by internal implementation. A person manages notifications, not webhook config. Be specific rather than clever.
|
||||
|
||||
Use active voice. A control says exactly what happens: "Save changes," not "Submit." Keep action vocabulary consistent through the whole flow: "Publish" leads to "Published."
|
||||
|
||||
Treat failure and emptiness as moments for direction, not mood. Errors do not apologize and are never vague. Empty states invite the next action.
|
||||
|
||||
Keep copy conversational and tuned: plain verbs, sentence case, no filler, with tone matched to the brand and audience. Each element does one job: a label labels, an example demonstrates, helper text helps.
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
Before presenting UI work, verify:
|
||||
|
||||
- [ ] Design direction is specific to the brief, not a generic default.
|
||||
- [ ] Existing design-system tokens, spacing, components, and typography are respected.
|
||||
- [ ] Realistic content, loading, error, empty, and success states are handled.
|
||||
- [ ] Keyboard navigation, focus, labels, contrast, and reduced motion are covered.
|
||||
- [ ] Layout works at 320px, 768px, 1024px, and 1440px with no obvious overflow.
|
||||
@@ -0,0 +1,420 @@
|
||||
---
|
||||
name: geo-content-optimizer
|
||||
description: 'Optimize content for AI citations in ChatGPT, Perplexity, AI Overviews, Gemini, Claude. AI引用优化/GEO优化/AI搜索'
|
||||
version: "6.0.0"
|
||||
license: Apache-2.0
|
||||
compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
|
||||
homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
|
||||
when_to_use: "Use when optimizing content for AI engines like ChatGPT, Perplexity, AI Overviews, Gemini, Claude, or Copilot. Also for AI citation optimization and generative engine visibility."
|
||||
argument-hint: "<content URL or text> [target AI engine]"
|
||||
metadata:
|
||||
author: aaron-he-zhu
|
||||
version: "6.0.0"
|
||||
geo-relevance: "high"
|
||||
tags:
|
||||
- geo
|
||||
- ai-citations
|
||||
- chatgpt-optimization
|
||||
- perplexity-optimization
|
||||
- google-ai-overview
|
||||
- gemini
|
||||
- generative-engine-optimization
|
||||
- llm-citations
|
||||
- ai-seo
|
||||
- quotable-content
|
||||
- AI引用优化
|
||||
- GEO优化
|
||||
- AI最適化
|
||||
- AI최적화
|
||||
- optimizacion-ia
|
||||
triggers:
|
||||
# EN-formal
|
||||
- "optimize for AI"
|
||||
- "get cited by ChatGPT"
|
||||
- "AI optimization"
|
||||
- "appear in AI answers"
|
||||
- "GEO optimization"
|
||||
- "generative engine optimization"
|
||||
- "AI-friendly content"
|
||||
- "LLM citations"
|
||||
# EN-casual
|
||||
- "get cited by AI"
|
||||
- "show up in ChatGPT answers"
|
||||
- "AI doesn't mention my brand"
|
||||
- "make content AI-quotable"
|
||||
- "how do I get AI to mention me"
|
||||
- "ChatGPT doesn't know my brand"
|
||||
# EN-question
|
||||
- "how to appear in AI answers"
|
||||
- "how to get cited by AI"
|
||||
- "why doesn't AI mention my brand"
|
||||
# EN-competitor
|
||||
- "Perplexity optimization"
|
||||
- "how to rank in ChatGPT answers"
|
||||
- "AI Overview optimization tool"
|
||||
# ZH-pro
|
||||
- "AI引用优化"
|
||||
- "GEO优化"
|
||||
- "生成式引擎优化"
|
||||
- "AI搜索优化"
|
||||
- "AI可引用内容"
|
||||
# ZH-casual
|
||||
- "让AI引用我"
|
||||
- "ChatGPT搜不到我"
|
||||
- "AI不提我的品牌"
|
||||
- "怎么让AI推荐我"
|
||||
- "AI搜不到"
|
||||
# JA
|
||||
- "AIコンテンツ最適化"
|
||||
- "AI引用されるコンテンツ"
|
||||
- "ChatGPT対策"
|
||||
- "AI検索最適化"
|
||||
# KO
|
||||
- "AI 최적화"
|
||||
- "AI 검색 최적화"
|
||||
- "ChatGPT 인용"
|
||||
# ES
|
||||
- "optimización para IA"
|
||||
- "aparecer en respuestas de IA"
|
||||
- "citación de IA"
|
||||
# PT
|
||||
- "otimização para IA"
|
||||
- "aparecer nas respostas da IA"
|
||||
# Misspellings
|
||||
- "generative engine optimisation"
|
||||
- "GEO optimisation"
|
||||
---
|
||||
|
||||
# GEO Content Optimizer
|
||||
|
||||
> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
|
||||
> **System Mode**: This build skill follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
|
||||
|
||||
|
||||
This skill optimizes content to appear in AI-generated responses. As AI systems increasingly answer user queries directly, getting cited by these systems directly impacts visibility.
|
||||
|
||||
**System role**: Build layer skill. It turns briefs and signals into assets that other skills can review, publish, and monitor.
|
||||
|
||||
## When This Must Trigger
|
||||
|
||||
Use this when the conversation involves any of these situations — even if the user does not use SEO terminology:
|
||||
|
||||
Use this whenever the task needs a shippable asset or transformation that should feed directly into quality review, deployment, or monitoring.
|
||||
|
||||
- Optimizing existing content for AI citations
|
||||
- Creating new content designed for both SEO and GEO
|
||||
- Improving chances of appearing in AI Overviews
|
||||
- Making content more quotable by AI systems
|
||||
- Adding authority signals that AI systems trust
|
||||
- Structuring content for AI comprehension
|
||||
- Competing for visibility in the AI-first search era
|
||||
|
||||
## What This Skill Does
|
||||
|
||||
1. **Citation Optimization**: Makes content more likely to be quoted by AI
|
||||
2. **Structure Enhancement**: Formats content for AI comprehension
|
||||
3. **Authority Building**: Adds signals that AI systems trust
|
||||
4. **Factual Enhancement**: Improves accuracy and verifiability
|
||||
5. **Quote Creation**: Creates memorable, citeable statements
|
||||
6. **Source Attribution**: Adds proper citations that AI can verify
|
||||
7. **GEO Scoring**: Evaluates content's AI-friendliness
|
||||
|
||||
## Quick Start
|
||||
|
||||
Start with one of these prompts. Finish with a short handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
|
||||
|
||||
### Optimize Existing Content
|
||||
|
||||
```
|
||||
Optimize this content for GEO/AI citations: [content or URL]
|
||||
```
|
||||
|
||||
```
|
||||
Make this article more likely to be cited by AI systems
|
||||
```
|
||||
|
||||
### Create GEO-Optimized Content
|
||||
|
||||
```
|
||||
Write content about [topic] optimized for both SEO and GEO
|
||||
```
|
||||
|
||||
### GEO Audit
|
||||
|
||||
```
|
||||
Audit this content for GEO readiness and suggest improvements
|
||||
```
|
||||
|
||||
## Skill Contract
|
||||
|
||||
**Expected output**: a ready-to-use asset or implementation-ready transformation plus a short handoff summary ready for `memory/content/`.
|
||||
|
||||
- **Reads**: the brief, target keywords, entity inputs, quality constraints, and prior decisions from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
|
||||
- **Writes**: a user-facing content, metadata, or schema deliverable plus a reusable summary that can be stored under `memory/content/`.
|
||||
- **Promotes**: approved angles, messaging choices, missing evidence, and publish blockers to `CLAUDE.md`, `memory/decisions.md`, and `memory/open-loops.md`.
|
||||
- **Next handoff**: use the `Next Best Skill` below when the asset is ready for review or deployment.
|
||||
|
||||
## Data Sources
|
||||
|
||||
> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
|
||||
|
||||
> **Note:** All integrations are optional. This skill works without any API keys — users provide data manually when no tools are connected.
|
||||
|
||||
**With ~~AI monitor + ~~SEO tool connected:**
|
||||
Automatically pull AI citation patterns (which content is being cited by ChatGPT, Claude, Perplexity), current AI visibility scores, competitor citation frequency, and AI Overview appearance tracking.
|
||||
|
||||
**With manual data only:**
|
||||
Ask the user to provide:
|
||||
1. Target queries where they want AI citations
|
||||
2. Current content URL or full content text
|
||||
3. Any known instances where competitors are being cited by AI
|
||||
|
||||
Proceed with the full workflow using provided data. Note in the output which metrics are from automated collection vs. user-provided data.
|
||||
|
||||
## Instructions
|
||||
|
||||
When a user requests GEO optimization:
|
||||
|
||||
1. **Load CORE-EEAT GEO-First Optimization Targets**
|
||||
|
||||
Before optimizing, load GEO-critical items from the [CORE-EEAT Benchmark](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/core-eeat-benchmark.md):
|
||||
|
||||
```markdown
|
||||
### CORE-EEAT GEO-First Targets
|
||||
|
||||
These items have the highest impact on AI engine citation. Use as optimization checklist:
|
||||
|
||||
**Top 6 Priority Items**:
|
||||
| Rank | ID | Standard | Why It Matters |
|
||||
|------|----|----------|---------------|
|
||||
| 1 | C02 | Direct Answer in first 150 words | All engines extract from first paragraph |
|
||||
| 2 | C09 | Structured FAQ with Schema | Directly matches AI follow-up queries |
|
||||
| 3 | O03 | Data in tables, not prose | Most extractable structured format |
|
||||
| 4 | O05 | JSON-LD Schema Markup | Helps AI understand content type |
|
||||
| 5 | E01 | Original first-party data | AI prefers exclusive, verifiable sources |
|
||||
| 6 | O02 | Key Takeaways / Summary Box | First choice for AI summary citations |
|
||||
|
||||
**All GEO-First Items** (optimize for all when possible):
|
||||
C02, C04, C05, C07, C08, C09 | O02, O03, O04, O05, O06, O09
|
||||
R01, R02, R03, R04, R05, R07, R09 | E01, E02, E03, E04, E06, E08, E09, E10
|
||||
Exp10 | Ept05, Ept08 | A08
|
||||
|
||||
**AI Engine Preferences**:
|
||||
| Engine | Priority Items |
|
||||
|--------|----------------|
|
||||
| Google AI Overview | C02, O03, O05, C09 |
|
||||
| ChatGPT Browse | C02, R01, R02, E01 |
|
||||
| Perplexity AI | E01, R03, R05, Ept05 |
|
||||
| Claude | R04, Ept08, Exp10, R03 |
|
||||
|
||||
_Full benchmark: [references/core-eeat-benchmark.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/core-eeat-benchmark.md)_
|
||||
```
|
||||
|
||||
2. **Analyze Current Content**
|
||||
|
||||
```markdown
|
||||
## GEO Analysis: [Content Title]
|
||||
|
||||
### Current State Assessment
|
||||
|
||||
| GEO Factor | Current Score (1-10) | Notes |
|
||||
|------------|---------------------|-------|
|
||||
| Clear definitions | [X] | [notes] |
|
||||
| Quotable statements | [X] | [notes] |
|
||||
| Factual density | [X] | [notes] |
|
||||
| Source citations | [X] | [notes] |
|
||||
| Q&A format | [X] | [notes] |
|
||||
| Authority signals | [X] | [notes] |
|
||||
| Content freshness | [X] | [notes] |
|
||||
| Structure clarity | [X] | [notes] |
|
||||
| **GEO Readiness** | **[avg]/10** | **Average across factors** |
|
||||
|
||||
**Primary Weaknesses**:
|
||||
1. [Weakness 1]
|
||||
2. [Weakness 2]
|
||||
3. [Weakness 3]
|
||||
|
||||
**Quick Wins**:
|
||||
1. [Quick improvement 1]
|
||||
2. [Quick improvement 2]
|
||||
```
|
||||
|
||||
3. **Apply GEO Optimization Techniques**
|
||||
|
||||
> **GEO fundamentals**: AI systems prioritize content that is authoritative (expert credentials, proper citations), accurate (verifiable, up-to-date), clear (well-structured, unambiguous), and quotable (standalone answers, specific data). See [references/geo-optimization-techniques.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/geo-content-optimizer/references/geo-optimization-techniques.md) for details.
|
||||
|
||||
Apply the six core optimization techniques: definition optimization, quotable statement creation, authority signal enhancement, structure optimization, factual density improvement, and FAQ schema implementation.
|
||||
|
||||
> **Reference**: See [references/geo-optimization-techniques.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/geo-content-optimizer/references/geo-optimization-techniques.md) for detailed before/after examples, templates, and checklists for each technique.
|
||||
|
||||
Key principles:
|
||||
- **Definitions**: 25-50 words, standalone, starting with the term
|
||||
- **Quotable statements**: Specific statistics with sources, verifiable facts
|
||||
- **Authority signals**: Expert quotes with credentials, proper source citations
|
||||
- **Structure**: Q&A format, comparison tables, numbered lists
|
||||
- **Factual density**: Replace vague claims with specific data points
|
||||
- **FAQ schema**: JSON-LD FAQPage markup matching visible content
|
||||
|
||||
4. **Generate GEO-Optimized Output**
|
||||
|
||||
```markdown
|
||||
## GEO Optimization Report
|
||||
|
||||
### Changes Made
|
||||
|
||||
**Definitions Added/Improved**:
|
||||
1. [Definition 1] - [location in content]
|
||||
2. [Definition 2] - [location in content]
|
||||
|
||||
**Quotable Statements Created**:
|
||||
1. "[Statement 1]"
|
||||
2. "[Statement 2]"
|
||||
|
||||
**Authority Signals Added**:
|
||||
1. [Expert quote/citation]
|
||||
2. [Source attribution]
|
||||
|
||||
**Structural Improvements**:
|
||||
1. [Change 1]
|
||||
2. [Change 2]
|
||||
|
||||
### Before/After GEO Score
|
||||
|
||||
| GEO Factor | Before (1-10) | After (1-10) | Change |
|
||||
|------------|---------------|--------------|--------|
|
||||
| Clear definitions | [X] | [X] | +[X] |
|
||||
| Quotable statements | [X] | [X] | +[X] |
|
||||
| Factual density | [X] | [X] | +[X] |
|
||||
| Source citations | [X] | [X] | +[X] |
|
||||
| Q&A format | [X] | [X] | +[X] |
|
||||
| Authority signals | [X] | [X] | +[X] |
|
||||
| **Overall GEO Score** | **[avg]/10** | **[avg]/10** | **+[X]** |
|
||||
|
||||
### AI Query Coverage
|
||||
|
||||
This content is now optimized to answer:
|
||||
- "What is [topic]?" ✅
|
||||
- "How does [topic] work?" ✅
|
||||
- "Why is [topic] important?" ✅
|
||||
- "[Topic] vs [alternative]" ✅
|
||||
- "Best [topic] for [use case]" ✅
|
||||
```
|
||||
|
||||
5. **CORE-EEAT GEO Self-Check**
|
||||
|
||||
After optimization, verify GEO-First items:
|
||||
|
||||
```markdown
|
||||
### CORE-EEAT GEO Post-Optimization Check
|
||||
|
||||
| ID | Standard | Status | Notes |
|
||||
|----|----------|--------|-------|
|
||||
| C02 | Direct Answer in first 150 words | ✅/⚠️/❌ | [notes] |
|
||||
| C04 | Key terms defined on first use | ✅/⚠️/❌ | [notes] |
|
||||
| C09 | Structured FAQ with Schema | ✅/⚠️/❌ | [notes] |
|
||||
| O02 | Summary Box / Key Takeaways | ✅/⚠️/❌ | [notes] |
|
||||
| O03 | Comparisons in tables | ✅/⚠️/❌ | [notes] |
|
||||
| O05 | JSON-LD Schema Markup | ✅/⚠️/❌ | [notes] |
|
||||
| O06 | Section chunking (3–5 sentences) | ✅/⚠️/❌ | [notes] |
|
||||
| R01 | ≥5 precise data points with units | ✅/⚠️/❌ | [notes] |
|
||||
| R02 | ≥1 citation per 500 words | ✅/⚠️/❌ | [notes] |
|
||||
| R04 | Claims backed by evidence | ✅/⚠️/❌ | [notes] |
|
||||
| R07 | Full entity names | ✅/⚠️/❌ | [notes] |
|
||||
| E01 | Original first-party data | ✅/⚠️/❌ | [notes] |
|
||||
| Exp10 | Limitations acknowledged | ✅/⚠️/❌ | [notes] |
|
||||
| Ept08 | Reasoning transparency | ✅/⚠️/❌ | [notes] |
|
||||
|
||||
**Items Needing Attention**: [list any ⚠️/❌ items]
|
||||
|
||||
_For full 80-item audit, use [content-quality-auditor](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/content-quality-auditor/SKILL.md)_
|
||||
```
|
||||
|
||||
## Validation Checkpoints
|
||||
|
||||
### Input Validation
|
||||
- [ ] Content source identified (URL, full text, or content draft)
|
||||
- [ ] Target AI queries or topics clearly defined
|
||||
- [ ] Current GEO baseline assessed (if optimizing existing content)
|
||||
|
||||
### Output Validation
|
||||
- [ ] At least 3 clear, quotable definitions added
|
||||
- [ ] Factual density improved with at least 5 verifiable statistics
|
||||
- [ ] All claims have source citations from authoritative sources
|
||||
- [ ] Q&A format sections cover top 5 user queries
|
||||
- [ ] GEO score improvement of at least 50% from baseline
|
||||
- [ ] Source of each data point clearly stated (~~AI monitor data, user-provided, or estimated)
|
||||
|
||||
## Example
|
||||
|
||||
**User**: "Optimize this paragraph for GEO: 'Email marketing is a good way to reach customers. It's been around for a while and many businesses use it. You can send newsletters, promotions, and other messages.'"
|
||||
|
||||
**Output**:
|
||||
|
||||
```markdown
|
||||
## GEO-Optimized Version
|
||||
|
||||
**Email marketing** is a digital marketing strategy that uses email to promote
|
||||
products, build customer relationships, and drive conversions. According to
|
||||
the Data & Marketing Association, email marketing delivers an average ROI
|
||||
of $42 for every $1 invested, making it the highest-performing digital
|
||||
marketing channel.
|
||||
|
||||
### Key email marketing formats include:
|
||||
|
||||
1. **Newsletters**: Regular updates sharing company news, industry insights,
|
||||
and valuable content to maintain subscriber engagement
|
||||
2. **Promotional emails**: Time-sensitive offers, discounts, and product
|
||||
announcements designed to drive immediate sales
|
||||
3. **Transactional emails**: Order confirmations, shipping updates, and
|
||||
account notifications with 8x higher open rates than marketing emails
|
||||
4. **Automated sequences**: Pre-scheduled email series triggered by user
|
||||
actions, such as welcome series or abandoned cart reminders
|
||||
|
||||
> **Key statistic**: 81% of small businesses rely on email as their primary
|
||||
> customer acquisition channel (Emarsys, 2024).
|
||||
|
||||
---
|
||||
|
||||
### Changes Made:
|
||||
|
||||
Changes: added clear definition, 2 stats with sources, structured list, DMA/Emarsys citations, 3 quotable facts. **GEO Score**: 1/10 → 8/10.
|
||||
```
|
||||
|
||||
## GEO Optimization Checklist
|
||||
|
||||
> **Reference**: See the GEO Readiness Checklist in [references/geo-optimization-techniques.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/geo-content-optimizer/references/geo-optimization-techniques.md) for the full checklist covering definitions, quotable content, authority, structure, and technical elements.
|
||||
|
||||
## Tips for Success
|
||||
|
||||
1. **Answer the question first** - Put the answer in the first sentence
|
||||
2. **Be specific** - Vague content doesn't get cited
|
||||
3. **Cite sources** - AI systems trust verifiable information
|
||||
4. **Stay current** - Update statistics and facts regularly
|
||||
5. **Match query format** - Questions deserve direct answers
|
||||
6. **Build authority** - Expert credentials increase citation likelihood
|
||||
|
||||
|
||||
### Save Results
|
||||
|
||||
After delivering content or optimization output to the user, ask:
|
||||
|
||||
> "Save these results for future sessions?"
|
||||
|
||||
If yes, write a dated summary to `memory/content/YYYY-MM-DD-<topic>.md` containing:
|
||||
- One-line description of what was created
|
||||
- Target keyword and content type
|
||||
- Open loops or items needing review
|
||||
- Source data references
|
||||
|
||||
**Gate check recommended**: Run content-quality-auditor before publishing (PostToolUse hook will remind automatically).
|
||||
|
||||
If any findings should influence ongoing strategy, recommend promoting key conclusions to `memory/hot-cache.md`.
|
||||
|
||||
## Reference Materials
|
||||
|
||||
- [AI Citation Patterns](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/geo-content-optimizer/references/ai-citation-patterns.md) - How Google AI Overviews, ChatGPT, Perplexity, and Claude select and cite sources
|
||||
- [Quotable Content Examples](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/geo-content-optimizer/references/quotable-content-examples.md) - Before/after examples of content optimized for AI citation
|
||||
|
||||
## Next Best Skill
|
||||
|
||||
- **Primary**: [content-quality-auditor](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/content-quality-auditor/SKILL.md) — verify the optimized content is strong enough to ship and cite.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Deepening
|
||||
|
||||
How to deepen a cluster of shallow modules safely, given its dependencies. Assumes the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**.
|
||||
|
||||
## Dependency categories
|
||||
|
||||
When assessing a candidate for deepening, classify its dependencies. The category determines how the deepened module is tested across its seam.
|
||||
|
||||
### 1. In-process
|
||||
|
||||
Pure computation, in-memory state, no I/O. Always deepenable — merge the modules and test through the new interface directly. No adapter needed.
|
||||
|
||||
### 2. Local-substitutable
|
||||
|
||||
Dependencies that have local test stand-ins (PGLite for Postgres, in-memory filesystem). Deepenable if the stand-in exists. The deepened module is tested with the stand-in running in the test suite. The seam is internal; no port at the module's external interface.
|
||||
|
||||
### 3. Remote but owned (Ports & Adapters)
|
||||
|
||||
Your own services across a network boundary (microservices, internal APIs). Define a **port** (interface) at the seam. The deep module owns the logic; the transport is injected as an **adapter**. Tests use an in-memory adapter. Production uses an HTTP/gRPC/queue adapter.
|
||||
|
||||
Recommendation shape: *"Define a port at the seam, implement an HTTP adapter for production and an in-memory adapter for testing, so the logic sits in one deep module even though it's deployed across a network."*
|
||||
|
||||
### 4. True external (Mock)
|
||||
|
||||
Third-party services (Stripe, Twilio, etc.) you don't control. The deepened module takes the external dependency as an injected port; tests provide a mock adapter.
|
||||
|
||||
## Seam discipline
|
||||
|
||||
- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a port unless at least two adapters are justified (typically production + test). A single-adapter seam is just indirection.
|
||||
- **Internal seams vs external seams.** A deep module can have internal seams (private to its implementation, used by its own tests) as well as the external seam at its interface. Don't expose internal seams through the interface just because tests use them.
|
||||
|
||||
## Testing strategy: replace, don't layer
|
||||
|
||||
- Old unit tests on shallow modules become waste once tests at the deepened module's interface exist — delete them.
|
||||
- Write new tests at the deepened module's interface. The **interface is the test surface**.
|
||||
- Tests assert on observable outcomes through the interface, not internal state.
|
||||
- Tests should survive internal refactors — they describe behaviour, not implementation. If a test has to change when the implementation changes, it's testing past the interface.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Interface Design
|
||||
|
||||
When the user wants to explore alternative interfaces for a chosen deepening candidate, use this parallel sub-agent pattern. Based on "Design It Twice" (Ousterhout) — your first idea is unlikely to be the best.
|
||||
|
||||
Uses the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**, **leverage**.
|
||||
|
||||
## Process
|
||||
|
||||
### 1. Frame the problem space
|
||||
|
||||
Before spawning sub-agents, write a user-facing explanation of the problem space for the chosen candidate:
|
||||
|
||||
- The constraints any new interface would need to satisfy
|
||||
- The dependencies it would rely on, and which category they fall into (see [DEEPENING.md](DEEPENING.md))
|
||||
- A rough illustrative code sketch to ground the constraints — not a proposal, just a way to make the constraints concrete
|
||||
|
||||
Show this to the user, then immediately proceed to Step 2. The user reads and thinks while the sub-agents work in parallel.
|
||||
|
||||
### 2. Spawn sub-agents
|
||||
|
||||
Spawn 3+ sub-agents in parallel using the Agent tool. Each must produce a **radically different** interface for the deepened module.
|
||||
|
||||
Prompt each sub-agent with a separate technical brief (file paths, coupling details, dependency category from [DEEPENING.md](DEEPENING.md), what sits behind the seam). The brief is independent of the user-facing problem-space explanation in Step 1. Give each agent a different design constraint:
|
||||
|
||||
- Agent 1: "Minimize the interface — aim for 1–3 entry points max. Maximise leverage per entry point."
|
||||
- Agent 2: "Maximise flexibility — support many use cases and extension."
|
||||
- Agent 3: "Optimise for the most common caller — make the default case trivial."
|
||||
- Agent 4 (if applicable): "Design around ports & adapters for cross-seam dependencies."
|
||||
|
||||
Include both [LANGUAGE.md](LANGUAGE.md) vocabulary and CONTEXT.md vocabulary in the brief so each sub-agent names things consistently with the architecture language and the project's domain language.
|
||||
|
||||
Each sub-agent outputs:
|
||||
|
||||
1. Interface (types, methods, params — plus invariants, ordering, error modes)
|
||||
2. Usage example showing how callers use it
|
||||
3. What the implementation hides behind the seam
|
||||
4. Dependency strategy and adapters (see [DEEPENING.md](DEEPENING.md))
|
||||
5. Trade-offs — where leverage is high, where it's thin
|
||||
|
||||
### 3. Present and compare
|
||||
|
||||
Present designs sequentially so the user can absorb each one, then compare them in prose. Contrast by **depth** (leverage at the interface), **locality** (where change concentrates), and **seam placement**.
|
||||
|
||||
After comparing, give your own recommendation: which design you think is strongest and why. If elements from different designs would combine well, propose a hybrid. Be opinionated — the user wants a strong read, not a menu.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Language
|
||||
|
||||
Shared vocabulary for every suggestion this skill makes. Use these terms exactly — don't substitute "component," "service," "API," or "boundary." Consistent language is the whole point.
|
||||
|
||||
## Terms
|
||||
|
||||
**Module**
|
||||
Anything with an interface and an implementation. Deliberately scale-agnostic — applies equally to a function, class, package, or tier-spanning slice.
|
||||
_Avoid_: unit, component, service.
|
||||
|
||||
**Interface**
|
||||
Everything a caller must know to use the module correctly. Includes the type signature, but also invariants, ordering constraints, error modes, required configuration, and performance characteristics.
|
||||
_Avoid_: API, signature (too narrow — those refer only to the type-level surface).
|
||||
|
||||
**Implementation**
|
||||
What's inside a module — its body of code. Distinct from **Adapter**: a thing can be a small adapter with a large implementation (a Postgres repo) or a large adapter with a small implementation (an in-memory fake). Reach for "adapter" when the seam is the topic; "implementation" otherwise.
|
||||
|
||||
**Depth**
|
||||
Leverage at the interface — the amount of behaviour a caller (or test) can exercise per unit of interface they have to learn. A module is **deep** when a large amount of behaviour sits behind a small interface. A module is **shallow** when the interface is nearly as complex as the implementation.
|
||||
|
||||
**Seam** _(from Michael Feathers)_
|
||||
A place where you can alter behaviour without editing in that place. The *location* at which a module's interface lives. Choosing where to put the seam is its own design decision, distinct from what goes behind it.
|
||||
_Avoid_: boundary (overloaded with DDD's bounded context).
|
||||
|
||||
**Adapter**
|
||||
A concrete thing that satisfies an interface at a seam. Describes *role* (what slot it fills), not substance (what's inside).
|
||||
|
||||
**Leverage**
|
||||
What callers get from depth. More capability per unit of interface they have to learn. One implementation pays back across N call sites and M tests.
|
||||
|
||||
**Locality**
|
||||
What maintainers get from depth. Change, bugs, knowledge, and verification concentrate at one place rather than spreading across callers. Fix once, fixed everywhere.
|
||||
|
||||
## Principles
|
||||
|
||||
- **Depth is a property of the interface, not the implementation.** A deep module can be internally composed of small, mockable, swappable parts — they just aren't part of the interface. A module can have **internal seams** (private to its implementation, used by its own tests) as well as the **external seam** at its interface.
|
||||
- **The deletion test.** Imagine deleting the module. If complexity vanishes, the module wasn't hiding anything (it was a pass-through). If complexity reappears across N callers, the module was earning its keep.
|
||||
- **The interface is the test surface.** Callers and tests cross the same seam. If you want to test *past* the interface, the module is probably the wrong shape.
|
||||
- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a seam unless something actually varies across it.
|
||||
|
||||
## Relationships
|
||||
|
||||
- A **Module** has exactly one **Interface** (the surface it presents to callers and tests).
|
||||
- **Depth** is a property of a **Module**, measured against its **Interface**.
|
||||
- A **Seam** is where a **Module**'s **Interface** lives.
|
||||
- An **Adapter** sits at a **Seam** and satisfies the **Interface**.
|
||||
- **Depth** produces **Leverage** for callers and **Locality** for maintainers.
|
||||
|
||||
## Rejected framings
|
||||
|
||||
- **Depth as ratio of implementation-lines to interface-lines** (Ousterhout): rewards padding the implementation. We use depth-as-leverage instead.
|
||||
- **"Interface" as the TypeScript `interface` keyword or a class's public methods**: too narrow — interface here includes every fact a caller must know.
|
||||
- **"Boundary"**: overloaded with DDD's bounded context. Say **seam** or **interface**.
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
name: improve-codebase-architecture
|
||||
description: Find deepening opportunities in a codebase, informed by the domain language in CONTEXT.md and the decisions in docs/adr/. Use when the user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make a codebase more testable and AI-navigable.
|
||||
---
|
||||
|
||||
# Improve Codebase Architecture
|
||||
|
||||
Surface architectural friction and propose **deepening opportunities** — refactors that turn shallow modules into deep ones. The aim is testability and AI-navigability.
|
||||
|
||||
## Glossary
|
||||
|
||||
Use these terms exactly in every suggestion. Consistent language is the point — don't drift into "component," "service," "API," or "boundary." Full definitions in [LANGUAGE.md](LANGUAGE.md).
|
||||
|
||||
- **Module** — anything with an interface and an implementation (function, class, package, slice).
|
||||
- **Interface** — everything a caller must know to use the module: types, invariants, error modes, ordering, config. Not just the type signature.
|
||||
- **Implementation** — the code inside.
|
||||
- **Depth** — leverage at the interface: a lot of behaviour behind a small interface. **Deep** = high leverage. **Shallow** = interface nearly as complex as the implementation.
|
||||
- **Seam** — where an interface lives; a place behaviour can be altered without editing in place. (Use this, not "boundary.")
|
||||
- **Adapter** — a concrete thing satisfying an interface at a seam.
|
||||
- **Leverage** — what callers get from depth.
|
||||
- **Locality** — what maintainers get from depth: change, bugs, knowledge concentrated in one place.
|
||||
|
||||
Key principles (see [LANGUAGE.md](LANGUAGE.md) for the full list):
|
||||
|
||||
- **Deletion test**: imagine deleting the module. If complexity vanishes, it was a pass-through. If complexity reappears across N callers, it was earning its keep.
|
||||
- **The interface is the test surface.**
|
||||
- **One adapter = hypothetical seam. Two adapters = real seam.**
|
||||
|
||||
This skill is _informed_ by the project's domain model. The domain language gives names to good seams; ADRs record decisions the skill should not re-litigate.
|
||||
|
||||
## Process
|
||||
|
||||
### 1. Explore
|
||||
|
||||
Read the project's domain glossary and any ADRs in the area you're touching first.
|
||||
|
||||
Then use the Agent tool with `subagent_type=Explore` to walk the codebase. Don't follow rigid heuristics — explore organically and note where you experience friction:
|
||||
|
||||
- Where does understanding one concept require bouncing between many small modules?
|
||||
- Where are modules **shallow** — interface nearly as complex as the implementation?
|
||||
- Where have pure functions been extracted just for testability, but the real bugs hide in how they're called (no **locality**)?
|
||||
- Where do tightly-coupled modules leak across their seams?
|
||||
- Which parts of the codebase are untested, or hard to test through their current interface?
|
||||
|
||||
Apply the **deletion test** to anything you suspect is shallow: would deleting it concentrate complexity, or just move it? A "yes, concentrates" is the signal you want.
|
||||
|
||||
### 2. Present candidates
|
||||
|
||||
Present a numbered list of deepening opportunities. For each candidate:
|
||||
|
||||
- **Files** — which files/modules are involved
|
||||
- **Problem** — why the current architecture is causing friction
|
||||
- **Solution** — plain English description of what would change
|
||||
- **Benefits** — explained in terms of locality and leverage, and also in how tests would improve
|
||||
|
||||
**Use CONTEXT.md vocabulary for the domain, and [LANGUAGE.md](LANGUAGE.md) vocabulary for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service."
|
||||
|
||||
**ADR conflicts**: if a candidate contradicts an existing ADR, only surface it when the friction is real enough to warrant revisiting the ADR. Mark it clearly (e.g. _"contradicts ADR-0007 — but worth reopening because…"_). Don't list every theoretical refactor an ADR forbids.
|
||||
|
||||
Do NOT propose interfaces yet. Ask the user: "Which of these would you like to explore?"
|
||||
|
||||
### 3. Grilling loop
|
||||
|
||||
Once the user picks a candidate, drop into a grilling conversation. Walk the design tree with them — constraints, dependencies, the shape of the deepened module, what sits behind the seam, what tests survive.
|
||||
|
||||
Side effects happen inline as decisions crystallize:
|
||||
|
||||
- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md` — same discipline as `/grill-with-docs` (see [CONTEXT-FORMAT.md](../grill-with-docs/CONTEXT-FORMAT.md)). Create the file lazily if it doesn't exist.
|
||||
- **Sharpening a fuzzy term during the conversation?** Update `CONTEXT.md` right there.
|
||||
- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones. See [ADR-FORMAT.md](../grill-with-docs/ADR-FORMAT.md).
|
||||
- **Want to explore alternative interfaces for the deepened module?** See [INTERFACE-DESIGN.md](INTERFACE-DESIGN.md).
|
||||
@@ -0,0 +1,351 @@
|
||||
---
|
||||
name: internal-linking-optimizer
|
||||
description: 'Optimize internal links: site architecture, authority distribution, orphan pages, crawl depth analysis. 内链优化/站内架构'
|
||||
version: "6.0.0"
|
||||
license: Apache-2.0
|
||||
compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
|
||||
homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
|
||||
when_to_use: "Use when improving internal link structure, anchor text distribution, orphan pages, or site architecture."
|
||||
argument-hint: "<URL or sitemap>"
|
||||
metadata:
|
||||
author: aaron-he-zhu
|
||||
version: "6.0.0"
|
||||
geo-relevance: "low"
|
||||
tags:
|
||||
- seo
|
||||
- internal-linking
|
||||
- site-architecture
|
||||
- link-equity
|
||||
- orphan-pages
|
||||
- topical-authority
|
||||
- crawl-depth
|
||||
- 内链优化
|
||||
- 内部リンク
|
||||
- 내부링크
|
||||
- enlaces-internos
|
||||
triggers:
|
||||
# EN-formal
|
||||
- "fix internal links"
|
||||
- "improve site architecture"
|
||||
- "link structure"
|
||||
- "distribute page authority"
|
||||
- "internal linking strategy"
|
||||
- "site navigation"
|
||||
- "link equity"
|
||||
# EN-casual
|
||||
- "orphan pages"
|
||||
- "site architecture is messy"
|
||||
- "pages have no links pointing to them"
|
||||
- "pages have no links"
|
||||
- "site structure is messy"
|
||||
# EN-question
|
||||
- "how to improve internal linking"
|
||||
- "how to fix orphan pages"
|
||||
# ZH-pro
|
||||
- "内链优化"
|
||||
- "站内链接"
|
||||
- "网站架构"
|
||||
- "权重传递"
|
||||
- "锚文本优化"
|
||||
# ZH-casual
|
||||
- "内链怎么做"
|
||||
- "孤立页面"
|
||||
- "网站结构乱"
|
||||
# JA
|
||||
- "内部リンク最適化"
|
||||
- "サイト構造"
|
||||
# KO
|
||||
- "내부 링크 최적화"
|
||||
- "사이트 구조"
|
||||
# ES
|
||||
- "enlaces internos"
|
||||
- "arquitectura del sitio"
|
||||
# PT
|
||||
- "links internos"
|
||||
# Misspellings
|
||||
- "internal linkng"
|
||||
---
|
||||
|
||||
# Internal Linking Optimizer
|
||||
|
||||
|
||||
> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
|
||||
> **System Mode**: This optimization skill follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
|
||||
|
||||
|
||||
This skill analyzes your site's internal link structure and provides recommendations to improve SEO through strategic internal linking. It helps distribute authority, establish topical relevance, and improve crawlability.
|
||||
|
||||
**System role**: Optimization layer skill. It turns weak pages, structures, and technical issues into prioritized repair work.
|
||||
|
||||
## When This Must Trigger
|
||||
|
||||
Use this when the conversation involves any of these situations — even if the user does not use SEO terminology:
|
||||
|
||||
Use this whenever the task needs a diagnosis or repair plan that should feed directly into remediation work, not just a one-time opinion.
|
||||
|
||||
- Improving site architecture for SEO
|
||||
- Distributing authority to important pages
|
||||
- Fixing orphan pages with no internal links
|
||||
- Creating topic cluster internal link strategies
|
||||
- Optimizing anchor text for SEO
|
||||
- Recovering pages that have lost rankings
|
||||
- Planning internal links for new content
|
||||
|
||||
## What This Skill Does
|
||||
|
||||
1. **Link Structure Analysis**: Maps current internal linking patterns
|
||||
2. **Authority Flow Mapping**: Shows how PageRank flows through site
|
||||
3. **Orphan Page Detection**: Finds pages with no internal links
|
||||
4. **Anchor Text Optimization**: Improves anchor text diversity
|
||||
5. **Topic Cluster Linking**: Creates pillar-cluster link strategies
|
||||
6. **Link Opportunity Finding**: Identifies where to add links
|
||||
7. **Navigation Optimization**: Improves site-wide link elements
|
||||
|
||||
## Quick Start
|
||||
|
||||
Start with one of these prompts. Finish with a short handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
|
||||
|
||||
### Analyze Current Structure
|
||||
|
||||
```
|
||||
Analyze internal linking structure for [domain/sitemap]
|
||||
```
|
||||
|
||||
```
|
||||
Find internal linking opportunities for [URL]
|
||||
```
|
||||
|
||||
### Create Linking Strategy
|
||||
|
||||
```
|
||||
Create internal linking plan for topic cluster about [topic]
|
||||
```
|
||||
|
||||
```
|
||||
Suggest internal links for this new article: [content/URL]
|
||||
```
|
||||
|
||||
### Fix Issues
|
||||
|
||||
```
|
||||
Find orphan pages on [domain]
|
||||
```
|
||||
|
||||
```
|
||||
Optimize anchor text across the site
|
||||
```
|
||||
|
||||
## Skill Contract
|
||||
|
||||
**Expected output**: a scored diagnosis, prioritized repair plan, and a short handoff summary ready for `memory/audits/`.
|
||||
|
||||
- **Reads**: the current page or site state, symptoms, prior audits, and current priorities from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
|
||||
- **Writes**: a user-facing audit or optimization plan plus a reusable summary that can be stored under `memory/audits/`.
|
||||
- **Promotes**: blocking defects, repeated weaknesses, and fix priorities to `memory/open-loops.md` and `memory/decisions.md`.
|
||||
- **Next handoff**: use the `Next Best Skill` below when the repair path is clear.
|
||||
|
||||
## Data Sources
|
||||
|
||||
> **Note:** All integrations are optional. This skill works without any API keys — users provide data manually when no tools are connected.
|
||||
|
||||
> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
|
||||
|
||||
**With ~~web crawler + ~~analytics connected:**
|
||||
Claude can automatically perform a full site crawl via ~~web crawler to map the complete link graph, fetch page performance metrics from ~~analytics to identify high-value pages, and analyze link flow throughout the site. This enables data-driven internal linking strategies.
|
||||
|
||||
**With manual data only:**
|
||||
Ask the user to provide:
|
||||
1. Sitemap URL or list of important pages
|
||||
2. Key page URLs that need more internal links
|
||||
3. Content categories or topic clusters
|
||||
4. Any existing link structure documentation
|
||||
|
||||
Proceed with the analysis using provided data. Note in the output which findings are from automated crawl vs. manual review.
|
||||
|
||||
## Instructions
|
||||
|
||||
When a user requests internal linking optimization:
|
||||
|
||||
1. **Analyze Current Internal Link Structure**
|
||||
|
||||
```markdown
|
||||
## Internal Link Structure Analysis
|
||||
|
||||
### Overview
|
||||
|
||||
**Domain**: [domain]
|
||||
**Total Pages Analyzed**: [X]
|
||||
**Total Internal Links**: [X]
|
||||
**Average Links per Page**: [X]
|
||||
|
||||
### Link Distribution
|
||||
|
||||
| Links per Page | Page Count | Percentage |
|
||||
|----------------|------------|------------|
|
||||
| 0 (Orphan) | [X] | [X]% |
|
||||
| 1-5 | [X] | [X]% |
|
||||
| 6-10 | [X] | [X]% |
|
||||
| 11-20 | [X] | [X]% |
|
||||
| 20+ | [X] | [X]% |
|
||||
|
||||
### Top Linked Pages
|
||||
|
||||
| Page | Internal Links | Authority | Notes |
|
||||
|------|----------------|-----------|-------|
|
||||
| [URL 1] | [X] | High | [notes] |
|
||||
| [URL 2] | [X] | High | [notes] |
|
||||
| [URL 3] | [X] | Medium | [notes] |
|
||||
|
||||
### Under-Linked Important Pages
|
||||
|
||||
| Page | Current Links | Traffic | Recommended Links |
|
||||
|------|---------------|---------|-------------------|
|
||||
| [URL 1] | [X] | [X]/mo | [X]+ |
|
||||
| [URL 2] | [X] | [X]/mo | [X]+ |
|
||||
|
||||
**Structure Score**: [X]/10
|
||||
```
|
||||
|
||||
2. **Identify Orphan Pages**
|
||||
|
||||
```markdown
|
||||
## Orphan Page Analysis
|
||||
|
||||
### Definition
|
||||
Orphan pages have no internal links pointing to them, making them
|
||||
hard for users and search engines to discover.
|
||||
|
||||
### Orphan Pages Found: [X]
|
||||
|
||||
| Page | Traffic | Priority | Recommended Action |
|
||||
|------|---------|----------|-------------------|
|
||||
| [URL 1] | [X]/mo | High | Link from [pages] |
|
||||
| [URL 2] | [X]/mo | Medium | Add to navigation |
|
||||
| [URL 3] | 0 | Low | Consider deleting/redirecting |
|
||||
|
||||
### Fix Strategy
|
||||
|
||||
**High Priority Orphans** (have traffic/rankings):
|
||||
1. [URL] - Add links from: [relevant pages]
|
||||
2. [URL] - Add links from: [relevant pages]
|
||||
|
||||
**Medium Priority Orphans** (potentially valuable):
|
||||
1. [URL] - Add to category/tag page
|
||||
2. [URL] - Link from related content
|
||||
|
||||
**Low Priority Orphans** (consider removing):
|
||||
1. [URL] - Redirect to [better page]
|
||||
2. [URL] - Delete or noindex
|
||||
```
|
||||
|
||||
3. **Analyze Anchor Text Distribution**
|
||||
|
||||
> **CORE-EEAT alignment**: Internal linking quality maps to R08 (Internal Link Graph) in the CORE-EEAT benchmark -- use descriptive anchors, ensure links support topical authority. See [content-quality-auditor](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/content-quality-auditor/SKILL.md) for full audit.
|
||||
|
||||
```markdown
|
||||
## Anchor Text Analysis
|
||||
|
||||
### Current Anchor Text Patterns
|
||||
|
||||
**Most Used Anchors**:
|
||||
|
||||
| Anchor Text | Count | Target Pages | Assessment |
|
||||
|-------------|-------|--------------|------------|
|
||||
| "click here" | [X] | [X] pages | ❌ Not descriptive |
|
||||
| "read more" | [X] | [X] pages | ❌ Not descriptive |
|
||||
| "[exact keyword]" | [X] | [page] | ⚠️ May be over-optimized |
|
||||
| "[descriptive phrase]" | [X] | [page] | ✅ Good |
|
||||
|
||||
### Anchor Text Distribution by Page
|
||||
|
||||
**Page: [Important URL]**
|
||||
|
||||
| Anchor Text | Source Page | Status |
|
||||
|-------------|-------------|--------|
|
||||
| "[anchor 1]" | [source URL] | ✅/⚠️/❌ |
|
||||
| "[anchor 2]" | [source URL] | ✅/⚠️/❌ |
|
||||
|
||||
**Issues Found**:
|
||||
- Over-optimized anchors: [X] instances
|
||||
- Generic anchors: [X] instances
|
||||
- Same anchor to multiple pages: [X] instances
|
||||
|
||||
### Anchor Text Recommendations
|
||||
|
||||
**For Page: [URL]**
|
||||
|
||||
Current: "[current anchor]" used [X] times
|
||||
|
||||
Recommended variety:
|
||||
- "[variation 1]" - Use from [page type]
|
||||
- "[variation 2]" - Use from [page type]
|
||||
- "[variation 3]" - Use from [page type]
|
||||
|
||||
**Anchor Score**: [X]/10
|
||||
```
|
||||
|
||||
4. **Create Topic Cluster Link Strategy** — Map current pillar/cluster links, recommend link structure, list specific links to add
|
||||
|
||||
> **Reference**: See [references/linking-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/internal-linking-optimizer/references/linking-templates.md) for the topic cluster link strategy template (Step 4).
|
||||
|
||||
5. **Find Contextual Link Opportunities** — Analyze each page for topic-relevant link opportunities, prioritize high-impact additions
|
||||
|
||||
> **Reference**: See [references/linking-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/internal-linking-optimizer/references/linking-templates.md) for the contextual link opportunities template (Step 5).
|
||||
|
||||
6. **Optimize Navigation and Footer Links** — Analyze main/footer/sidebar/breadcrumb navigation, recommend pages to add or remove
|
||||
|
||||
> **Reference**: See [references/linking-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/internal-linking-optimizer/references/linking-templates.md) for the navigation optimization template (Step 6).
|
||||
|
||||
7. **Generate Link Implementation Plan** — Executive summary, current state metrics, phased priority actions (weeks 1-4+), implementation guide, tracking plan
|
||||
|
||||
> **Reference**: See [references/linking-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/internal-linking-optimizer/references/linking-templates.md) for the full implementation plan template (Step 7).
|
||||
|
||||
## Validation Checkpoints
|
||||
|
||||
### Input Validation
|
||||
- [ ] Site structure or sitemap provided (URL or file)
|
||||
- [ ] Target pages or topic clusters clearly defined
|
||||
- [ ] If optimizing specific page, page URL or content provided
|
||||
|
||||
### Output Validation
|
||||
- [ ] Every recommendation cites specific data points (not generic advice)
|
||||
- [ ] All link suggestions include source page, target page, and recommended anchor text
|
||||
- [ ] Orphan page lists include URLs and recommended actions
|
||||
- [ ] Source of each data point clearly stated (~~web crawler data, ~~analytics, user-provided, or manual analysis)
|
||||
|
||||
## Example
|
||||
|
||||
> **Reference**: See [references/linking-example.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/internal-linking-optimizer/references/linking-example.md) for a full worked example (email marketing best practices internal linking opportunities).
|
||||
|
||||
## Tips for Success
|
||||
|
||||
1. **Quality over quantity** - Add relevant links, not random ones
|
||||
2. **User-first thinking** - Links should help users navigate
|
||||
3. **Vary anchor text** - Avoid over-optimization
|
||||
4. **Link to important pages** - Distribute authority strategically
|
||||
5. **Regular audits** - Internal links need maintenance as content grows
|
||||
|
||||
|
||||
### Save Results
|
||||
|
||||
After delivering audit or optimization findings to the user, ask:
|
||||
|
||||
> "Save these results for future sessions?"
|
||||
|
||||
If yes, write a dated summary to `memory/audits/internal-linking-optimizer/YYYY-MM-DD-<topic>.md` containing:
|
||||
- One-line verdict or headline finding
|
||||
- Top 3-5 actionable items
|
||||
- Open loops or blockers
|
||||
- Source data references
|
||||
|
||||
If any veto-level issue was found (CORE-EEAT T04, C01, R10 or CITE T03, T05, T09), also append a one-liner to `memory/hot-cache.md` without asking.
|
||||
|
||||
## Reference Materials
|
||||
|
||||
- [Link Architecture Patterns](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/internal-linking-optimizer/references/link-architecture-patterns.md) — Architecture models (hub-and-spoke, silo, flat, pyramid, mesh), anchor text diversity framework, link equity flow model, and internal link audit checklist
|
||||
- [Linking Templates](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/internal-linking-optimizer/references/linking-templates.md) — Detailed output templates for steps 6-7 (navigation optimization, implementation plan)
|
||||
- [Linking Example](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/internal-linking-optimizer/references/linking-example.md) — Full worked example for internal linking opportunities
|
||||
|
||||
## Next Best Skill
|
||||
|
||||
- **Primary**: [on-page-seo-auditor](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/on-page-seo-auditor/SKILL.md) — verify that revised internal links support the page-level goals.
|
||||
@@ -0,0 +1,435 @@
|
||||
---
|
||||
name: keyword-research
|
||||
description: 'Find high-value SEO keywords: search volume, difficulty, intent classification, topic clusters. 关键词研究/内容选题'
|
||||
version: "6.0.0"
|
||||
license: Apache-2.0
|
||||
compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
|
||||
homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
|
||||
when_to_use: "Use when starting keyword research for a new page, topic, or campaign. Also when the user asks about search volume, keyword difficulty, topic clusters, long-tail keywords, or what to write about."
|
||||
argument-hint: "<topic or seed keyword> [market/language]"
|
||||
metadata:
|
||||
author: aaron-he-zhu
|
||||
version: "6.0.0"
|
||||
geo-relevance: "medium"
|
||||
tags:
|
||||
- seo
|
||||
- geo
|
||||
- keywords
|
||||
- keyword-research
|
||||
- search-volume
|
||||
- keyword-difficulty
|
||||
- topic-clusters
|
||||
- long-tail-keywords
|
||||
- search-intent
|
||||
- content-calendar
|
||||
- ahrefs
|
||||
- semrush
|
||||
- google-keyword-planner
|
||||
- 关键词研究
|
||||
- SEO关键词
|
||||
- キーワード調査
|
||||
- 키워드분석
|
||||
- palabras-clave
|
||||
triggers:
|
||||
# EN-formal
|
||||
- "keyword research"
|
||||
- "find keywords"
|
||||
- "keyword analysis"
|
||||
- "keyword discovery"
|
||||
- "search volume analysis"
|
||||
- "keyword difficulty"
|
||||
- "topic research"
|
||||
- "identify ranking opportunities"
|
||||
# EN-casual
|
||||
- "what should I write about"
|
||||
- "what are people searching for"
|
||||
- "what are people googling"
|
||||
- "find me topics to write"
|
||||
- "give me keyword ideas"
|
||||
- "which keywords should I target"
|
||||
- "why is my traffic low"
|
||||
- "I need content ideas"
|
||||
# EN-question
|
||||
- "how do I find good keywords"
|
||||
- "what keywords should I target"
|
||||
- "how competitive is this keyword"
|
||||
# EN-competitor
|
||||
- "Ahrefs keyword explorer alternative"
|
||||
- "Semrush keyword magic tool"
|
||||
- "Google Keyword Planner alternative"
|
||||
- "Ubersuggest alternative"
|
||||
# ZH-pro
|
||||
- "关键词研究"
|
||||
- "关键词分析"
|
||||
- "搜索量查询"
|
||||
- "关键词难度"
|
||||
- "SEO关键词"
|
||||
- "长尾关键词"
|
||||
- "词库整理"
|
||||
- "关键词布局"
|
||||
- "关键词挖掘"
|
||||
# ZH-casual
|
||||
- "写什么内容好"
|
||||
- "找选题"
|
||||
- "帮我挖词"
|
||||
- "不知道写什么"
|
||||
- "查关键词"
|
||||
- "选词"
|
||||
- "帮我找词"
|
||||
# JA
|
||||
- "キーワード調査"
|
||||
- "キーワードリサーチ"
|
||||
- "SEOキーワード分析"
|
||||
- "検索ボリューム"
|
||||
- "ロングテールキーワード"
|
||||
- "検索意図分析"
|
||||
# KO
|
||||
- "키워드 리서치"
|
||||
- "키워드 분석"
|
||||
- "검색량 분석"
|
||||
- "키워드 어떻게 찾아요?"
|
||||
- "검색어 분석"
|
||||
- "경쟁도 낮은 키워드는?"
|
||||
# ES
|
||||
- "investigación de palabras clave"
|
||||
- "análisis de palabras clave"
|
||||
- "volumen de búsqueda"
|
||||
- "posicionamiento web"
|
||||
- "cómo encontrar palabras clave"
|
||||
# PT
|
||||
- "pesquisa de palavras-chave"
|
||||
# Misspellings
|
||||
- "keywrod research"
|
||||
- "keywork research"
|
||||
---
|
||||
|
||||
# Keyword Research
|
||||
|
||||
|
||||
> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
|
||||
> **System Mode**: This research skill follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
|
||||
|
||||
|
||||
Discovers, analyzes, and prioritizes keywords for SEO and GEO content strategies. Identifies high-value opportunities based on search volume, competition, intent, and business relevance.
|
||||
|
||||
**System role**: Research layer skill. It turns market signals into reusable strategic inputs for the rest of the library.
|
||||
|
||||
## When This Must Trigger
|
||||
|
||||
Use this when the conversation involves any of these situations — even if the user does not use SEO terminology:
|
||||
|
||||
Use this whenever the task needs reusable market intelligence that should influence strategy, not just an ad hoc answer.
|
||||
|
||||
- Starting a new content strategy or campaign
|
||||
- Expanding into new topics or markets
|
||||
- Finding keywords for a specific product or service
|
||||
- Identifying long-tail keyword opportunities
|
||||
- Understanding search intent for your industry
|
||||
- Planning content calendars
|
||||
- Researching keywords for GEO optimization
|
||||
|
||||
## What This Skill Does
|
||||
|
||||
1. **Keyword Discovery**: Generates comprehensive keyword lists from seed terms
|
||||
2. **Intent Classification**: Categorizes keywords by user intent (informational, navigational, commercial, transactional)
|
||||
3. **Difficulty Assessment**: Evaluates competition level and ranking difficulty
|
||||
4. **Opportunity Scoring**: Prioritizes keywords by potential ROI
|
||||
5. **Clustering**: Groups related keywords into topic clusters
|
||||
6. **GEO Relevance**: Identifies keywords likely to trigger AI responses
|
||||
|
||||
## Quick Start
|
||||
|
||||
Start with one of these prompts. Finish with a short handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
|
||||
|
||||
### Basic Keyword Research
|
||||
|
||||
```
|
||||
Research keywords for [topic/product/service]
|
||||
```
|
||||
|
||||
```
|
||||
Find keyword opportunities for a [industry] business targeting [audience]
|
||||
```
|
||||
|
||||
### With Specific Goals
|
||||
|
||||
```
|
||||
Find low-competition keywords for [topic] with commercial intent
|
||||
```
|
||||
|
||||
```
|
||||
Identify question-based keywords for [topic] that AI systems might answer
|
||||
```
|
||||
|
||||
### Competitive Research
|
||||
|
||||
```
|
||||
What keywords is [competitor URL] ranking for that I should target?
|
||||
```
|
||||
|
||||
## Skill Contract
|
||||
|
||||
**Expected output**: a prioritized research brief, evidence-backed findings, and a short handoff summary ready for `memory/research/`.
|
||||
|
||||
- **Reads**: user goals, target market inputs, available tool data, and prior strategy from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
|
||||
- **Writes**: a user-facing research deliverable plus a reusable summary that can be stored under `memory/research/`.
|
||||
- **Promotes**: durable keyword priorities, competitor facts, entity candidates, and strategic decisions to `CLAUDE.md`, `memory/decisions.md`, and `memory/research/`; hand canonical entity work to `entity-optimizer`.
|
||||
- **Next handoff**: use the `Next Best Skill` below when the findings are ready to drive action.
|
||||
|
||||
## Data Sources
|
||||
|
||||
> **Note:** All integrations are optional. This skill works without any API keys — users provide data manually when no tools are connected.
|
||||
|
||||
> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
|
||||
|
||||
**With ~~SEO tool + ~~search console connected:**
|
||||
Automatically pull historical search volume data, keyword difficulty scores, SERP analysis, current rankings from ~~search console, and competitor keyword overlap. The skill will fetch seed keyword metrics, related keyword suggestions, and search trend data.
|
||||
|
||||
**With manual data only:**
|
||||
Ask the user to provide:
|
||||
1. Seed keywords or topic description
|
||||
2. Target audience and geographic location
|
||||
3. Business goals (traffic, leads, sales)
|
||||
4. Current domain authority (if known) or site age
|
||||
5. Any known keyword performance data or search volume estimates
|
||||
|
||||
Proceed with the full analysis using provided data. Note in the output which metrics are from automated collection vs. user-provided data.
|
||||
|
||||
## Instructions
|
||||
|
||||
When a user requests keyword research:
|
||||
|
||||
At the start of each phase, announce: **[Phase X/8: Name]** so the user can track progress.
|
||||
|
||||
### Phase 1/8: Scope
|
||||
|
||||
Ask clarifying questions if not provided:
|
||||
- What is your product/service/topic?
|
||||
- Who is your target audience?
|
||||
- What is your business goal? (traffic, leads, sales)
|
||||
- What is your current domain authority? (new site, established, etc.)
|
||||
- Any specific geographic targeting?
|
||||
- Preferred language?
|
||||
|
||||
### Phase 2/8: Discover
|
||||
|
||||
Start with:
|
||||
- Core product/service terms
|
||||
- Problem-focused keywords (what issues do you solve?)
|
||||
- Solution-focused keywords (how do you help?)
|
||||
- Audience-specific terms
|
||||
- Industry terminology
|
||||
|
||||
### Phase 3/8: Variations
|
||||
|
||||
For each seed keyword, generate variations:
|
||||
|
||||
```markdown
|
||||
## Keyword Expansion Patterns
|
||||
|
||||
### Modifiers
|
||||
- Best [keyword]
|
||||
- Top [keyword]
|
||||
- [keyword] for [audience]
|
||||
- [keyword] near me
|
||||
- [keyword] [year]
|
||||
- How to [keyword]
|
||||
- What is [keyword]
|
||||
- [keyword] vs [alternative]
|
||||
- [keyword] examples
|
||||
- [keyword] tools
|
||||
|
||||
### Long-tail Variations
|
||||
- [keyword] for beginners
|
||||
- [keyword] for small business
|
||||
- Free [keyword]
|
||||
- [keyword] software/tool/service
|
||||
- [keyword] template
|
||||
- [keyword] checklist
|
||||
- [keyword] guide
|
||||
```
|
||||
|
||||
### Phase 4/8: Classify
|
||||
|
||||
Categorize each keyword:
|
||||
|
||||
| Intent | Signals | Example | Content Type |
|
||||
|--------|---------|---------|--------------|
|
||||
| Informational | what, how, why, guide, learn | "what is SEO" | Blog posts, guides |
|
||||
| Navigational | brand names, specific sites | "google analytics login" | Homepage, product pages |
|
||||
| Commercial | best, review, vs, compare | "best SEO tools [current year]" | Comparison posts, reviews |
|
||||
| Transactional | buy, price, discount, order | "buy SEO software" | Product pages, pricing |
|
||||
|
||||
### Phase 5/8: Score
|
||||
|
||||
Score each keyword (1-100 scale):
|
||||
|
||||
```markdown
|
||||
### Difficulty Factors
|
||||
|
||||
**High Difficulty (70-100)**
|
||||
- Major brands ranking
|
||||
- High domain authority competitors
|
||||
- Established content (1000+ backlinks)
|
||||
- Paid ads dominating SERP
|
||||
|
||||
**Medium Difficulty (40-69)**
|
||||
- Mix of authority and niche sites
|
||||
- Some opportunities for quality content
|
||||
- Moderate backlink requirements
|
||||
|
||||
**Low Difficulty (1-39)**
|
||||
- Few authoritative competitors
|
||||
- Thin or outdated content ranking
|
||||
- Long-tail variations
|
||||
- New or emerging topics
|
||||
```
|
||||
|
||||
#### Opportunity Score
|
||||
|
||||
Formula: `Opportunity = (Volume × Intent Value) / Difficulty`
|
||||
|
||||
**Intent Value** assigns a numeric weight by search intent:
|
||||
- Informational = 1
|
||||
- Navigational = 1
|
||||
- Commercial = 2
|
||||
- Transactional = 3
|
||||
|
||||
```markdown
|
||||
### Opportunity Matrix
|
||||
|
||||
| Scenario | Volume | Difficulty | Intent | Priority |
|
||||
|----------|--------|------------|--------|----------|
|
||||
| Quick Win | Low-Med | Low | High | ⭐⭐⭐⭐⭐ |
|
||||
| Growth | High | Medium | High | ⭐⭐⭐⭐ |
|
||||
| Long-term | High | High | High | ⭐⭐⭐ |
|
||||
| Research | Low | Low | Low | ⭐⭐ |
|
||||
```
|
||||
|
||||
### Phase 6/8: GEO-Check — AI Answer Overlap
|
||||
|
||||
Keywords likely to trigger AI responses:
|
||||
|
||||
```markdown
|
||||
### GEO-Relevant Keywords
|
||||
|
||||
**High GEO Potential**
|
||||
- Question formats: "What is...", "How does...", "Why is..."
|
||||
- Definition queries: "[term] meaning", "[term] definition"
|
||||
- Comparison queries: "[A] vs [B]", "difference between..."
|
||||
- List queries: "best [category]", "top [number] [items]"
|
||||
- How-to queries: "how to [action]", "steps to [goal]"
|
||||
|
||||
**AI Answer Indicators**
|
||||
- Query is factual/definitional
|
||||
- Answer can be summarized concisely
|
||||
- Topic is well-documented online
|
||||
- Low commercial intent
|
||||
```
|
||||
|
||||
### Phase 7/8: Cluster
|
||||
|
||||
Group keywords into content clusters:
|
||||
|
||||
```markdown
|
||||
## Topic Cluster: [Main Topic]
|
||||
|
||||
**Pillar Content**: [Primary keyword]
|
||||
- Search volume: [X]
|
||||
- Difficulty: [X]
|
||||
- Content type: Comprehensive guide
|
||||
|
||||
**Cluster Content**:
|
||||
|
||||
### Sub-topic 1: [Secondary keyword]
|
||||
- Volume: [X]
|
||||
- Difficulty: [X]
|
||||
- Links to: Pillar
|
||||
- Content type: [Blog post/Tutorial/etc.]
|
||||
|
||||
### Sub-topic 2: [Secondary keyword]
|
||||
- Volume: [X]
|
||||
- Difficulty: [X]
|
||||
- Links to: Pillar + Sub-topic 1
|
||||
- Content type: [Blog post/Tutorial/etc.]
|
||||
|
||||
[Continue for all cluster keywords...]
|
||||
```
|
||||
|
||||
### Phase 8/8: Deliver
|
||||
|
||||
Produce a report containing: Executive Summary, Top Keyword Opportunities (Quick Wins, Growth, GEO), Topic Clusters, Content Calendar, and Next Steps.
|
||||
|
||||
**Quality bar** — every recommendation must include at least one specific number. If it reads like the left column, rewrite it before including.
|
||||
|
||||
| ❌ Generic (rewrite before including) | ✅ Actionable |
|
||||
|---|---|
|
||||
| "Target long-tail keywords for better results" | "Target 'project management for nonprofits' (vol: 320, KD: 22) — no DR>40 sites in top 10" |
|
||||
| "This keyword has good potential" | "Opportunity 8.4: vol 4,800, KD 28, transactional intent — gap analysis shows no content updated since 2023 in top 5" |
|
||||
| "Consider creating content around this topic" | "Write '[Tool A] vs [Tool B] for small teams' — 1,200/mo searches, current #1 is a 2022 article with 12 backlinks" |
|
||||
| "Optimize your page for this keyword" | "Add primary keyword to H1 (currently missing), write a 40-word direct answer in paragraph 1, add 3 internal links from your /blog/ cluster" |
|
||||
|
||||
> **Reference**: See [references/example-report.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/keyword-research/references/example-report.md) for the full report template and example.
|
||||
|
||||
## Validation Checkpoints
|
||||
|
||||
### Input Validation
|
||||
- [ ] Seed keywords or topic description clearly provided
|
||||
- [ ] Target audience and business goals specified
|
||||
- [ ] Geographic and language targeting confirmed
|
||||
- [ ] Domain authority or site maturity level established
|
||||
|
||||
### Output Validation
|
||||
- [ ] Every recommendation cites specific data points (not generic advice)
|
||||
- [ ] Search volume and difficulty scores included for each keyword
|
||||
- [ ] Keywords grouped by intent and mapped to content types
|
||||
- [ ] Topic clusters show clear pillar-to-cluster relationships
|
||||
- [ ] Source of each data point clearly stated (~~SEO tool data, user-provided, or estimated)
|
||||
|
||||
## Example
|
||||
|
||||
> **Reference**: See [references/example-report.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/keyword-research/references/example-report.md) for a complete example report for "project management software for small businesses".
|
||||
|
||||
### Advanced Usage
|
||||
|
||||
- **Intent Mapping**: `Map all keywords for [topic] by search intent and funnel stage`
|
||||
- **Seasonal Analysis**: `Identify seasonal keyword trends for [industry]`
|
||||
- **Competitor Gap**: `What keywords do [competitor 1], [competitor 2] rank for that I'm missing?`
|
||||
- **Local Keywords**: `Research local keywords for [business type] in [city/region]`
|
||||
|
||||
## Tips for Success
|
||||
|
||||
1. **Start with seed keywords** that describe your core offering
|
||||
2. **Don't ignore long-tail** - they often have highest conversion rates
|
||||
3. **Match content to intent** - informational queries need guides, not sales pages
|
||||
4. **Group into clusters** for topical authority
|
||||
5. **Prioritize quick wins** to build momentum and credibility
|
||||
6. **Include GEO keywords** in your strategy for AI visibility
|
||||
7. **Review quarterly** - keyword dynamics change over time
|
||||
|
||||
|
||||
|
||||
### Save Results
|
||||
|
||||
After delivering findings to the user, ask:
|
||||
|
||||
> "Save these results for future sessions?"
|
||||
|
||||
If yes, write a dated summary to `memory/research/keyword-research/YYYY-MM-DD-<topic>.md` containing:
|
||||
- One-line headline finding
|
||||
- Top 3-5 actionable items
|
||||
- Open loops or blockers
|
||||
- Source data references
|
||||
|
||||
If any findings should influence ongoing strategy, recommend promoting key conclusions to `memory/hot-cache.md`.
|
||||
|
||||
## Reference Materials
|
||||
|
||||
- [Keyword Intent Taxonomy](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/keyword-research/references/keyword-intent-taxonomy.md) — Complete intent classification with signal words and content strategies
|
||||
- [Topic Cluster Templates](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/keyword-research/references/topic-cluster-templates.md) — Hub-and-spoke architecture templates for pillar and cluster content
|
||||
- [Keyword Prioritization Framework](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/keyword-research/references/keyword-prioritization-framework.md) — Priority scoring matrix, categories, and seasonal keyword patterns
|
||||
- [Example Report](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/keyword-research/references/example-report.md) — Complete example keyword research report for project management software
|
||||
|
||||
## Next Best Skill
|
||||
|
||||
- **Primary**: [competitor-analysis](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/competitor-analysis/SKILL.md) — turn keyword opportunities into a competitive benchmark.
|
||||
@@ -0,0 +1,342 @@
|
||||
---
|
||||
name: memory-management
|
||||
description: 'Persist SEO/GEO campaign context across Claude sessions with automatic hot-list, active work, and archive tiers. 项目记忆/跨会话'
|
||||
version: "7.0.0"
|
||||
license: Apache-2.0
|
||||
compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
|
||||
homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
|
||||
when_to_use: "Use when reviewing, archiving, or cleaning up campaign memory. Also when the user asks to check saved findings, manage hot cache, or archive old data."
|
||||
argument-hint: "[review|archive|cleanup]"
|
||||
metadata:
|
||||
author: aaron-he-zhu
|
||||
version: "7.0.0"
|
||||
geo-relevance: "low"
|
||||
tags:
|
||||
- seo
|
||||
- geo
|
||||
- project-memory
|
||||
- context-management
|
||||
- campaign-tracking
|
||||
- session-context
|
||||
- hot-cache
|
||||
- 项目记忆
|
||||
- プロジェクト記憶
|
||||
- 프로젝트메모리
|
||||
- memoria-proyecto
|
||||
triggers:
|
||||
# EN-formal
|
||||
- "remember project context"
|
||||
- "save SEO data"
|
||||
- "track campaign progress"
|
||||
- "store keyword data"
|
||||
- "manage project memory"
|
||||
- "project context"
|
||||
- "refresh wiki index"
|
||||
- "build wiki index"
|
||||
- "wiki lint"
|
||||
# EN-casual
|
||||
- "remember this for next time"
|
||||
- "save my keyword data"
|
||||
- "keep track of this campaign"
|
||||
- "what did we decide last time"
|
||||
- "what do we know so far"
|
||||
- "project status"
|
||||
# EN-question
|
||||
- "how to save project progress"
|
||||
# ZH-pro
|
||||
- "项目记忆管理"
|
||||
- "SEO数据保存"
|
||||
- "跨会话记忆"
|
||||
- "刷新wiki索引"
|
||||
- "项目状况"
|
||||
# ZH-casual
|
||||
- "保存进度"
|
||||
- "上次说了什么"
|
||||
- "记住这个"
|
||||
# JA
|
||||
- "プロジェクト記憶"
|
||||
- "SEOデータ保存"
|
||||
# KO
|
||||
- "프로젝트 메모리"
|
||||
- "데이터 저장"
|
||||
# ES
|
||||
- "memoria del proyecto"
|
||||
- "guardar progreso"
|
||||
# PT
|
||||
- "memória do projeto"
|
||||
---
|
||||
|
||||
# Memory Management
|
||||
|
||||
> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
|
||||
> **System Mode**: This cross-cutting skill is part of the protocol layer and follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
|
||||
|
||||
This skill implements a three-tier memory system (HOT/WARM/COLD) for SEO and GEO projects. HOT memory (80 lines max) loads automatically every session via the SessionStart hook. WARM memory loads on demand per skill. COLD memory is archived data queried only when explicitly requested. The skill manages the full lifecycle: capture, promote, demote, and archive.
|
||||
|
||||
**System role**: Campaign Memory Loop. It defines how project context is captured, promoted, archived, and handed off across sessions. It is the sole executor of WARM-to-COLD archival and the aggregator for cross-skill project status queries.
|
||||
|
||||
## When This Must Trigger
|
||||
|
||||
Use this whenever project state should survive the current session — even if the user doesn't use memory terminology:
|
||||
|
||||
- User says "remember this", "save this", "keep track of this"
|
||||
- User asks "what did we decide", "what do we know", "project status"
|
||||
- Setting up memory structure for a new SEO project
|
||||
- After completing audits, ranking checks, or performance reports (Stop hook reminds automatically)
|
||||
- When project context needs updating (new keywords, competitors, priorities)
|
||||
- When you need to look up historical data or project-specific terminology
|
||||
- After 30+ days of work to clean up and archive stale data
|
||||
- When open-loops.md has items older than 7 days (SessionStart hook reminds automatically)
|
||||
|
||||
## What This Skill Does
|
||||
|
||||
1. **HOT Cache Management**: Maintains `memory/hot-cache.md` (80 lines max) — loaded automatically every session by SessionStart hook
|
||||
2. **WARM Storage**: Organizes dated findings in `memory/` subdirectories — loaded on demand by relevant skills
|
||||
3. **COLD Archive**: Moves stale data (90+ days unreferenced) to `memory/archive/` with date prefix
|
||||
4. **Promotion**: Elevates frequently-referenced findings from WARM to HOT (3+ refs in 7 days, or 2+ skill refs)
|
||||
5. **Demotion**: Moves unreferenced HOT items to WARM (30 days), WARM to COLD (90 days)
|
||||
6. **Cross-Skill Aggregation**: When user asks "what do we know", aggregates from all `memory/` subdirectories
|
||||
7. **Open Loop Tracking**: Maintains `memory/open-loops.md`, reminds user of stale items via SessionStart hook
|
||||
8. **Wiki Index Maintenance**: Compiles `memory/wiki/index.md` — a structured, auto-refreshed index of all WARM files with precise fields (score, 健康度, status, next_action, mtime) and best-effort summaries. Supports project isolation via `memory/wiki/<project>/index.md`. Auto-refreshed on PostToolUse; user confirmation not required (index is a fully rebuildable derived artifact). Delete `memory/wiki/` at any time to revert to pre-wiki behavior.
|
||||
9. **Wiki Compiled Pages** (Phase 2): Generates interlinked entity, keyword, and topic pages from WARM files with source hash tracking, contradiction detection, and confidence-labeled reconciliation. Requires user confirmation before writing.
|
||||
10. **Wiki Lint** (Phase 2): Detects contradictions, orphan pages, stale claims, missing pages, and source hash mismatches across wiki and WARM files via `/seo:wiki-lint`.
|
||||
11. **WARM Retirement Preview** (Phase 3): `wiki-lint --retire-preview` lists WARM files fully covered by wiki compiled pages as retirement candidates. Actual archival to COLD requires explicit user confirmation.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Start with one of these prompts. Finish with a hot-cache update plan and a handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
|
||||
|
||||
### Initialize Wiki Index
|
||||
|
||||
```
|
||||
Refresh wiki index
|
||||
```
|
||||
|
||||
```
|
||||
Build wiki index for [project name]
|
||||
```
|
||||
|
||||
Generates `memory/wiki/index.md` from existing WARM files. Required once to enable wiki features; subsequent refreshes happen automatically.
|
||||
|
||||
### Initialize Memory Structure
|
||||
|
||||
```
|
||||
Set up SEO memory for [project name]
|
||||
```
|
||||
|
||||
```
|
||||
Initialize memory structure for a new [industry] website optimization project
|
||||
```
|
||||
|
||||
### Update After Analysis
|
||||
|
||||
```
|
||||
Update memory after ranking check for [keyword group]
|
||||
```
|
||||
|
||||
```
|
||||
Refresh hot cache with latest competitor analysis findings
|
||||
```
|
||||
|
||||
### Query Stored Context
|
||||
|
||||
```
|
||||
What are our hero keywords?
|
||||
```
|
||||
|
||||
```
|
||||
Show me the last ranking update date for [keyword category]
|
||||
```
|
||||
|
||||
```
|
||||
Look up our primary competitors and their domain authority
|
||||
```
|
||||
|
||||
### Promotion and Demotion
|
||||
|
||||
```
|
||||
Promote [keyword] to hot cache
|
||||
```
|
||||
|
||||
```
|
||||
Archive stale data that hasn't been referenced in 30+ days
|
||||
```
|
||||
|
||||
### Glossary Management
|
||||
|
||||
```
|
||||
Add [term] to project glossary: [definition]
|
||||
```
|
||||
|
||||
```
|
||||
What does [internal jargon] mean in this project?
|
||||
```
|
||||
|
||||
## Skill Contract
|
||||
|
||||
**Expected output**: a memory update plan, hot-cache changes, and a short handoff summary.
|
||||
|
||||
- **Reads**: current campaign facts, new findings from other skills, approved decisions, and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
|
||||
- **Writes**: updates to `memory/hot-cache.md`, `memory/open-loops.md`, `memory/decisions.md`, and related `memory/` folders. Manages WARM-to-COLD archival in `memory/archive/`. Compiles `memory/wiki/index.md` (auto-refreshed) and wiki compiled pages (user-confirmed).
|
||||
- **Promotes**: durable strategy, blockers, terminology, entity candidates, and major deltas. Applies temperature lifecycle rules: promote to HOT on high reference frequency, demote on staleness.
|
||||
- **Next handoff**: use the `Next Best Skill` below when the project memory baseline is ready for active work.
|
||||
|
||||
### Temperature Lifecycle Rules
|
||||
|
||||
> See [references/promotion-demotion-rules.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/memory-management/references/promotion-demotion-rules.md) for the full promotion/demotion table and action procedures.
|
||||
|
||||
### Hook Integration
|
||||
|
||||
This skill's behavior is reinforced by the library's prompt-based hooks:
|
||||
- **SessionStart**: loads `memory/hot-cache.md`, reminds of stale open loops; loads `memory/wiki/<project>/index.md` (or global `index.md`) if it exists; provides light-user guidance based on Quick Status when `next_action` items are available
|
||||
- **PostToolUse**: after any WARM file write, silently refreshes `memory/wiki/index.md` (Phase 1); prompts to update compiled pages (Phase 2)
|
||||
- **Stop**: prompts to save session findings, auto-saves veto issues to hot-cache; appends changelog entry to index.md bottom
|
||||
|
||||
## Data Sources
|
||||
|
||||
> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
|
||||
|
||||
**With ~~SEO tool + ~~analytics + ~~search console connected:**
|
||||
Automatically populate memory from historical data: keyword rankings over time, competitor domain authority changes, traffic metrics, conversion data, backlink profile evolution. The skill will fetch current rankings, alert on significant changes, and update both hot cache and cold storage.
|
||||
|
||||
**With manual data only:**
|
||||
Ask the user to provide:
|
||||
1. Current target keywords with priority levels
|
||||
2. Primary competitors (3-5 domains)
|
||||
3. Key performance metrics and last update date
|
||||
4. Active campaigns and their status
|
||||
5. Any project-specific terminology or abbreviations
|
||||
|
||||
Proceed with memory structure creation using provided data. Note in CLAUDE.md which data requires manual updates vs. automated refresh.
|
||||
|
||||
## Instructions
|
||||
|
||||
When a user requests SEO memory management:
|
||||
|
||||
### 1. Initialize Memory Structure
|
||||
|
||||
For new projects, create the directory structure defined in the [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md). Key directories: `memory/` (decisions, open-loops, glossary, entities, research, content, audits, monitoring) plus `memory/wiki/` (auto-managed compiled index with optional per-project subdirectories).
|
||||
|
||||
> **Templates**: [hot-cache-template.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/memory-management/references/hot-cache-template.md) · [glossary-template.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/memory-management/references/glossary-template.md) · [Wiki spec](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/proposal-wiki-layer-v3.md)
|
||||
|
||||
### 2. Context Lookup Flow
|
||||
|
||||
When a user references something unclear, follow this lookup sequence:
|
||||
|
||||
**Step 1: Check CLAUDE.md (Hot Cache)**
|
||||
- Is it in active keywords?
|
||||
- Is it in primary competitors?
|
||||
- Is it in current priorities or campaigns?
|
||||
|
||||
**Step 2: Check Wiki Index** (`memory/wiki/index.md` or project-level) — locate relevant WARM files
|
||||
|
||||
**Step 3: Check memory/glossary.md**
|
||||
- Is it defined as project terminology?
|
||||
- Is it a custom segment or shorthand?
|
||||
|
||||
**Step 4: Check Cold Storage**
|
||||
- Search memory/research/keywords/ for historical keyword context
|
||||
- Search memory/research/competitors/ for past analyses
|
||||
- Search memory/monitoring/reports/ for archived mentions
|
||||
|
||||
**Step 5: Ask User**
|
||||
- If not found in any layer, ask for clarification
|
||||
- Log the new term in glossary if it's project-specific
|
||||
|
||||
Example lookup:
|
||||
|
||||
```markdown
|
||||
User: "Update rankings for our hero KWs"
|
||||
|
||||
Step 1: Check CLAUDE.md → Found "Hero Keywords (Priority 1)" section
|
||||
Step 2: Extract keyword list from hot cache
|
||||
Step 3: Execute ranking check
|
||||
Step 4: Update both CLAUDE.md and memory/monitoring/rank-history/YYYY-MM-DD-ranks.csv
|
||||
```
|
||||
|
||||
### 3. Promotion & Demotion Logic
|
||||
|
||||
> **Reference**: See [references/promotion-demotion-rules.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/memory-management/references/promotion-demotion-rules.md) for detailed promotion/demotion triggers (keywords, competitors, metrics, campaigns) and the action procedures for each.
|
||||
|
||||
### 4. Update Triggers, Archive Management & Cross-Skill Integration
|
||||
|
||||
> **Reference**: See [references/update-triggers-integration.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/memory-management/references/update-triggers-integration.md) for the complete update procedures after ranking checks, competitor analyses, audits, and reports; monthly/quarterly archive routines; and integration points with all 8 connected skills (keyword-research, rank-tracker, competitor-analysis, content-gap-analysis, seo-content-writer, content-quality-auditor, domain-authority-auditor).
|
||||
|
||||
### 5. Memory Hygiene Checks
|
||||
|
||||
When invoked for review or cleanup:
|
||||
|
||||
1. **Line count check**: Count lines in `memory/hot-cache.md`. If >80, list oldest entries for archival.
|
||||
2. **Byte check**: If hot-cache exceeds 25KB, warn and recommend trimming long entries.
|
||||
3. **Staleness scan**: List memory files older than 30 days that have not been referenced. Recommend archival for files >90 days.
|
||||
4. **Frontmatter audit**: Check that all memory files (except hot-cache.md) have `name`, `description`, and `type` in their frontmatter. Report any missing fields.
|
||||
|
||||
### 6. Save Results
|
||||
|
||||
After delivering any memory update or aggregation to the user, ask:
|
||||
|
||||
> "Save these results for future sessions?"
|
||||
|
||||
If yes, write a dated summary to the appropriate `memory/` path using filename `YYYY-MM-DD-<topic>.md` containing:
|
||||
- One-line verdict or headline finding
|
||||
- Top 3-5 actionable items
|
||||
- Open loops or blockers
|
||||
- Source data references
|
||||
|
||||
If any veto-level issue was found (CORE-EEAT T04, C01, R10 or CITE T03, T05, T09), also append a one-liner to `memory/hot-cache.md` without asking.
|
||||
|
||||
## Validation Checkpoints
|
||||
|
||||
### Structure Validation
|
||||
- [ ] memory/hot-cache.md exists and is under 80 lines
|
||||
- [ ] memory/ directory structure matches the shared state model
|
||||
- [ ] glossary.md exists and is populated with project basics
|
||||
- [ ] All historical data files include timestamps in filename or metadata
|
||||
|
||||
### Content Validation
|
||||
- [ ] CLAUDE.md "Last Updated" date is current
|
||||
- [ ] Every keyword in hot cache has current rank, target rank, and status
|
||||
- [ ] Every competitor has domain authority and position assessment
|
||||
- [ ] Every active campaign has status percentage and expected completion date
|
||||
- [ ] Key Metrics Snapshot shows "Previous" values for comparison
|
||||
|
||||
### Lookup Validation
|
||||
- [ ] Test lookup flow: reference a term → verify it finds it in correct layer
|
||||
- [ ] Test promotion: manually promote item → verify it appears in CLAUDE.md
|
||||
- [ ] Test demotion: manually archive item → verify removed from CLAUDE.md
|
||||
- [ ] Glossary contains all custom segments and shorthand used in CLAUDE.md
|
||||
|
||||
### Update Validation
|
||||
- [ ] After ranking check, `memory/monitoring/rank-history/` has a dated snapshot or export
|
||||
- [ ] After competitor analysis, `memory/research/competitors/` has a dated file
|
||||
- [ ] After audit, top action items appear in CLAUDE.md priorities
|
||||
- [ ] After monthly report, metrics snapshot reflects new data
|
||||
|
||||
## Examples
|
||||
|
||||
> **Reference**: See [references/examples.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/memory-management/references/examples.md) for three complete examples: (1) updating hero keyword rankings with memory refresh, (2) glossary lookup flow, and (3) initializing memory for a new e-commerce project.
|
||||
|
||||
## Advanced Features
|
||||
|
||||
- **Smart Context Loading**: `Load full context for [campaign name]` — retrieves hot cache + all cold storage files for a campaign
|
||||
- **Memory Health Check**: `Run memory health check` — finds orphaned files, missing timestamps, stale items, broken references
|
||||
- **Bulk Promotion/Demotion**: `Promote all keywords ranking in top 10 to hot cache` / `Demote all completed campaigns from Q3`
|
||||
- **Memory Snapshot**: `Create memory snapshot for [date/milestone]` — point-in-time copy for major milestones
|
||||
- **Cross-Project Memory**: `Compare memory with [other project]` — keyword overlaps, competitor intersections across projects
|
||||
- **Wiki Lint**: `/seo:wiki-lint [--fix] [--project name] [--retire-preview]` — contradictions, orphans, stale claims, hash mismatches. See [commands/wiki-lint.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/commands/wiki-lint.md)
|
||||
|
||||
## Practical Limitations
|
||||
|
||||
- **Concurrent access**: Use timestamped filenames to avoid overwrites from parallel sessions.
|
||||
- **Cold storage retrieval**: WARM/COLD files only load on demand. Hot cache is the primary cross-session mechanism.
|
||||
- **Data freshness**: Stale data (>90 days) should be flagged for refresh. Wiki index `mtime` field helps detect staleness.
|
||||
- **Wiki compilation**: Index is best-effort for summaries; precise fields (score, status, mtime) are deterministic. Delete `memory/wiki/` anytime to revert.
|
||||
|
||||
## Reference Materials
|
||||
|
||||
- [CORE-EEAT Content Benchmark](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/core-eeat-benchmark.md) — Content quality scoring stored in memory
|
||||
- [CITE Domain Rating](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/cite-domain-rating.md) — Domain authority scoring stored in memory
|
||||
|
||||
## Next Best Skill
|
||||
|
||||
- **Primary**: [keyword-research](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/keyword-research/SKILL.md) — seed or refresh campaign strategy with current demand signals.
|
||||
@@ -0,0 +1,417 @@
|
||||
---
|
||||
name: meta-tags-optimizer
|
||||
description: 'Optimize title tags, meta descriptions, Open Graph, Twitter cards for maximum CTR with A/B variations. 标题优化/元描述/CTR'
|
||||
version: "6.0.0"
|
||||
license: Apache-2.0
|
||||
compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
|
||||
homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
|
||||
when_to_use: "Use when optimizing title tags, meta descriptions, Open Graph tags, or Twitter Cards for a page."
|
||||
argument-hint: "<page URL or content>"
|
||||
metadata:
|
||||
author: aaron-he-zhu
|
||||
version: "6.0.0"
|
||||
geo-relevance: "low"
|
||||
tags:
|
||||
- seo
|
||||
- meta-tags
|
||||
- title-tag
|
||||
- meta-description
|
||||
- open-graph
|
||||
- twitter-card
|
||||
- ctr-optimization
|
||||
- social-sharing
|
||||
- 标题优化
|
||||
- 元描述
|
||||
- メタタグ
|
||||
- 메타태그
|
||||
- meta-tags-seo
|
||||
triggers:
|
||||
# EN-formal
|
||||
- "optimize title tag"
|
||||
- "write meta description"
|
||||
- "improve CTR"
|
||||
- "Open Graph tags"
|
||||
- "social media preview"
|
||||
- "title optimization"
|
||||
- "meta tags"
|
||||
- "Twitter cards"
|
||||
# EN-casual
|
||||
- "my title tag needs work"
|
||||
- "low click-through rate"
|
||||
- "fix my meta tags"
|
||||
- "OG tags not showing"
|
||||
- "social preview looks wrong"
|
||||
- "my click rate is low"
|
||||
# EN-question
|
||||
- "how to write a good title tag"
|
||||
- "how to improve click-through rate"
|
||||
- "what is a good meta description"
|
||||
# EN-competitor
|
||||
- "Yoast SEO title tool"
|
||||
- "RankMath title optimizer"
|
||||
# ZH-pro
|
||||
- "标题标签优化"
|
||||
- "元描述优化"
|
||||
- "OG标签"
|
||||
- "点击率提升"
|
||||
- "社交预览"
|
||||
- "TDK优化"
|
||||
# ZH-casual
|
||||
- "标题不好"
|
||||
- "点击率太低"
|
||||
- "社交分享预览不对"
|
||||
- "标题怎么写"
|
||||
- "TDK怎么写"
|
||||
# JA
|
||||
- "メタタグ最適化"
|
||||
- "タイトルタグ"
|
||||
- "CTR改善"
|
||||
# KO
|
||||
- "메타 태그 최적화"
|
||||
- "제목 태그"
|
||||
- "클릭률 개선"
|
||||
# ES
|
||||
- "optimizar meta tags"
|
||||
- "mejorar CTR"
|
||||
- "etiquetas Open Graph"
|
||||
# PT
|
||||
- "otimizar meta tags"
|
||||
# Misspellings
|
||||
- "meta discription"
|
||||
- "tittle tag"
|
||||
---
|
||||
|
||||
# Meta Tags Optimizer
|
||||
|
||||
> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
|
||||
> **System Mode**: This build skill follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
|
||||
|
||||
|
||||
This skill creates compelling, optimized meta tags that improve click-through rates from search results and enhance social media sharing. It covers title tags, meta descriptions, and social meta tags.
|
||||
|
||||
**System role**: Build layer skill. It turns briefs and signals into assets that other skills can review, publish, and monitor.
|
||||
|
||||
## When This Must Trigger
|
||||
|
||||
Use this when the conversation involves any of these situations — even if the user does not use SEO terminology:
|
||||
|
||||
Use this whenever the task needs a shippable asset or transformation that should feed directly into quality review, deployment, or monitoring.
|
||||
|
||||
- Creating meta tags for new pages
|
||||
- Optimizing existing meta tags for better CTR
|
||||
- Preparing pages for social media sharing
|
||||
- Fixing duplicate or missing meta tags
|
||||
- A/B testing title and description variations
|
||||
- Optimizing for specific SERP features
|
||||
- Creating meta tags for different page types
|
||||
|
||||
## What This Skill Does
|
||||
|
||||
1. **Title Tag Creation**: Writes compelling, keyword-optimized titles
|
||||
2. **Meta Description Writing**: Creates click-worthy descriptions
|
||||
3. **Open Graph Optimization**: Prepares pages for social sharing
|
||||
4. **Twitter Card Setup**: Optimizes Twitter-specific meta tags
|
||||
5. **CTR Analysis**: Suggests improvements for better click rates
|
||||
6. **Character Counting**: Ensures proper length for SERP display
|
||||
7. **A/B Test Suggestions**: Provides variations for testing
|
||||
|
||||
## Quick Start
|
||||
|
||||
Start with one of these prompts. Finish with a short handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
|
||||
|
||||
### Create Meta Tags
|
||||
|
||||
```
|
||||
Create meta tags for a page about [topic] targeting [keyword]
|
||||
```
|
||||
|
||||
```
|
||||
Write title and meta description for this content: [content/URL]
|
||||
```
|
||||
|
||||
### Optimize Existing Tags
|
||||
|
||||
```
|
||||
Improve these meta tags for better CTR: [current tags]
|
||||
```
|
||||
|
||||
### Social Media Tags
|
||||
|
||||
```
|
||||
Create Open Graph and Twitter card tags for [page/URL]
|
||||
```
|
||||
|
||||
## Skill Contract
|
||||
|
||||
**Expected output**: a ready-to-use asset or implementation-ready transformation plus a short handoff summary ready for `memory/content/`.
|
||||
|
||||
- **Reads**: the brief, target keywords, entity inputs, quality constraints, and prior decisions from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
|
||||
- **Writes**: a user-facing content, metadata, or schema deliverable plus a reusable summary that can be stored under `memory/content/`.
|
||||
- **Promotes**: approved angles, messaging choices, missing evidence, and publish blockers to `CLAUDE.md`, `memory/decisions.md`, and `memory/open-loops.md`.
|
||||
- **Next handoff**: use the `Next Best Skill` below when the asset is ready for review or deployment.
|
||||
|
||||
## Data Sources
|
||||
|
||||
> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
|
||||
|
||||
**With ~~search console + ~~SEO tool connected:**
|
||||
Automatically pull current meta tags, CTR data by query, competitor title/description patterns, SERP preview data, and impression/click metrics to identify optimization opportunities.
|
||||
|
||||
**With manual data only:**
|
||||
Ask the user to provide:
|
||||
1. Current title and meta description (if optimizing existing)
|
||||
2. Target primary keyword and 2-3 secondary keywords
|
||||
3. Page URL and main content/value proposition
|
||||
4. Competitor URLs or examples of well-performing titles in the SERP
|
||||
|
||||
Proceed with the full workflow using provided data. Note in the output which metrics are from automated collection vs. user-provided data.
|
||||
|
||||
## Instructions
|
||||
|
||||
When a user requests meta tag optimization:
|
||||
|
||||
1. **Gather Page Information**
|
||||
|
||||
```markdown
|
||||
### Page Analysis
|
||||
|
||||
**Page URL**: [URL]
|
||||
**Page Type**: [blog/product/landing/service/homepage]
|
||||
**Primary Keyword**: [keyword]
|
||||
**Secondary Keywords**: [keywords]
|
||||
**Target Audience**: [audience]
|
||||
**Primary CTA**: [action you want users to take]
|
||||
**Unique Value Prop**: [what makes this page special]
|
||||
```
|
||||
|
||||
2. **Create Optimized Title Tag**
|
||||
|
||||
```markdown
|
||||
### Title Tag Optimization
|
||||
|
||||
**Requirements**:
|
||||
- Length: 50-60 characters (displays fully in SERP)
|
||||
- Include primary keyword (preferably near front)
|
||||
- Make it compelling and click-worthy
|
||||
- Match search intent
|
||||
- Include brand name if appropriate
|
||||
|
||||
**Title Tag Formula Options**:
|
||||
|
||||
1. **Keyword | Benefit | Brand**
|
||||
"[Primary Keyword]: [Benefit] | [Brand Name]"
|
||||
|
||||
2. **Number + Keyword + Promise**
|
||||
"[Number] [Keyword] That [Promise/Result]"
|
||||
|
||||
3. **How-to Format**
|
||||
"How to [Keyword]: [Benefit/Result]"
|
||||
|
||||
4. **Question Format**
|
||||
"What is [Keyword]? [Brief Answer/Hook]"
|
||||
|
||||
5. **Year + Keyword**
|
||||
"[Keyword] in [Year]: [Hook/Update]"
|
||||
|
||||
**Generated Title Options**:
|
||||
|
||||
| Option | Title | Length | Power Words | Keyword Position |
|
||||
|--------|-------|--------|-------------|------------------|
|
||||
| 1 | [Title] | [X] chars | [words] | [Front/Middle] |
|
||||
| 2 | [Title] | [X] chars | [words] | [Front/Middle] |
|
||||
| 3 | [Title] | [X] chars | [words] | [Front/Middle] |
|
||||
|
||||
**Recommended**: Option [X]
|
||||
**Reasoning**: [Why this option is best]
|
||||
|
||||
**Title Tag Code**:
|
||||
```html
|
||||
<title>[Selected Title]</title>
|
||||
```
|
||||
```
|
||||
|
||||
3. **Write Meta Description**
|
||||
|
||||
```markdown
|
||||
### Meta Description Optimization
|
||||
|
||||
**Requirements**:
|
||||
- Length: 150-160 characters (displays fully in SERP)
|
||||
- Include primary keyword naturally
|
||||
- Include clear call-to-action
|
||||
- Match page content accurately
|
||||
- Create urgency or curiosity
|
||||
- Avoid duplicate descriptions
|
||||
|
||||
**Meta Description Formula**:
|
||||
|
||||
[What the page offers] + [Benefit to user] + [Call-to-action]
|
||||
|
||||
**Power Elements to Include**:
|
||||
- Numbers and statistics
|
||||
- Current year
|
||||
- Emotional triggers
|
||||
- Action verbs
|
||||
- Unique value proposition
|
||||
|
||||
**Generated Description Options**:
|
||||
|
||||
| Option | Description | Length | CTA | Emotional Trigger |
|
||||
|--------|-------------|--------|-----|-------------------|
|
||||
| 1 | [Description] | [X] chars | [CTA] | [Trigger] |
|
||||
| 2 | [Description] | [X] chars | [CTA] | [Trigger] |
|
||||
| 3 | [Description] | [X] chars | [CTA] | [Trigger] |
|
||||
|
||||
**Recommended**: Option [X]
|
||||
**Reasoning**: [Why this option is best]
|
||||
|
||||
**Meta Description Code**:
|
||||
```html
|
||||
<meta name="description" content="[Selected Description]">
|
||||
```
|
||||
```
|
||||
|
||||
4. **Create Open Graph, Twitter Card, and Additional Meta Tags**
|
||||
|
||||
Generate OG tags (og:type, og:url, og:title, og:description, og:image), Twitter Card tags, canonical URL, robots, viewport, author, and article-specific tags. Then combine into a complete meta tag block.
|
||||
|
||||
> **Reference**: See [references/meta-tag-code-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/meta-tags-optimizer/references/meta-tag-code-templates.md) for OG type selection guide, Twitter card type selection, all HTML code templates, and the complete meta tag block template.
|
||||
|
||||
5. **CORE-EEAT Alignment Check**
|
||||
|
||||
Verify meta tags align with content quality standards. Reference: [CORE-EEAT Benchmark](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/core-eeat-benchmark.md)
|
||||
|
||||
```markdown
|
||||
### CORE-EEAT Meta Tag Alignment
|
||||
|
||||
| Check | Status | Notes |
|
||||
|-------|--------|-------|
|
||||
| **C01 Intent Alignment**: Title promise matches actual content delivery | ✅/⚠️/❌ | [Does the title accurately represent what the page delivers?] |
|
||||
| **C02 Direct Answer**: Meta description reflects the core answer available in first 150 words | ✅/⚠️/❌ | [Does the description preview the direct answer?] |
|
||||
|
||||
**If C01 fails**: Title is misleading — rewrite to match actual content.
|
||||
**If C02 fails**: Content may need restructuring to front-load the answer, or description should better reflect available content.
|
||||
```
|
||||
|
||||
9. **Provide CTR Optimization Tips**
|
||||
|
||||
```markdown
|
||||
## CTR Optimization Analysis
|
||||
|
||||
### Power Words Used
|
||||
- [Word 1] - Creates [emotion/action]
|
||||
- [Word 2] - Creates [emotion/action]
|
||||
|
||||
### CTR Boosting Elements
|
||||
|
||||
| Element | Present | Impact |
|
||||
|---------|---------|--------|
|
||||
| Numbers | Yes/No | +20-30% CTR |
|
||||
| Current Year | Yes/No | +15-20% CTR |
|
||||
| Power Words | Yes/No | +10-15% CTR |
|
||||
| Question | Yes/No | +10-15% CTR |
|
||||
| Brackets | Yes/No | +10% CTR |
|
||||
|
||||
### A/B Test Suggestions
|
||||
|
||||
Test these variations:
|
||||
|
||||
**Version A** (Current):
|
||||
- Title: [Title]
|
||||
- Description: [Description]
|
||||
|
||||
**Version B** (Test):
|
||||
- Title: [Alternative title]
|
||||
- Description: [Alternative description]
|
||||
- Hypothesis: [Why this might perform better]
|
||||
```
|
||||
|
||||
## Validation Checkpoints
|
||||
|
||||
### Input Validation
|
||||
- [ ] Primary keyword confirmed and matches page content
|
||||
- [ ] Page type identified (blog/product/landing/service/homepage)
|
||||
- [ ] Target audience and search intent clearly defined
|
||||
- [ ] Unique value proposition articulated
|
||||
|
||||
### Output Validation
|
||||
- [ ] Title length 50-60 characters (displays fully in SERP)
|
||||
- [ ] Meta description length 150-160 characters
|
||||
- [ ] Primary keyword appears in both title and description
|
||||
- [ ] Open Graph image specified (1200x630px recommended)
|
||||
- [ ] All HTML syntax valid (no unclosed quotes or tags)
|
||||
- [ ] Source of each data point clearly stated (~~search console CTR data, ~~SEO tool competitor data, user-provided, or estimated)
|
||||
|
||||
## Example
|
||||
|
||||
**User**: "Create meta tags for a blog post about 'how to start a podcast in [current year]'"
|
||||
|
||||
**Output**:
|
||||
|
||||
```markdown
|
||||
## Meta Tags: How to Start a Podcast ([current year])
|
||||
|
||||
### Title Tag
|
||||
```html
|
||||
<title>How to Start a Podcast in [current year]: Complete Beginner's Guide</title>
|
||||
```
|
||||
**Length**: ~55 characters ✅
|
||||
**Keyword**: "how to start a podcast" at front ✅
|
||||
**Power Words**: "Complete", "Beginner's" ✅
|
||||
|
||||
### Meta Description
|
||||
```html
|
||||
<meta name="description" content="Learn how to start a podcast in [current year] with our step-by-step guide. Covers equipment, hosting, recording, and launching your first episode. Start podcasting today!">
|
||||
```
|
||||
**Length**: ~163 characters ✅
|
||||
**Keyword**: Included naturally ✅
|
||||
**CTA**: "Start podcasting today!" ✅
|
||||
|
||||
_Complete meta tag block (with OG, Twitter, Article tags) generated using template from [references/meta-tag-code-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/meta-tags-optimizer/references/meta-tag-code-templates.md)._
|
||||
|
||||
### A/B Test Variations
|
||||
|
||||
**Title Variation B**:
|
||||
"Start a Podcast in [current year]: Step-by-Step Guide (+ Free Checklist)"
|
||||
|
||||
**Title Variation C**:
|
||||
"How to Start a Podcast: [current year] Guide [Equipment + Software + Tips]"
|
||||
|
||||
**Description Variation B**:
|
||||
"Want to start a podcast in [current year]? This guide covers everything: equipment ($100 budget option), best hosting platforms, recording tips, and how to get your first 1,000 listeners."
|
||||
```
|
||||
|
||||
## Tips for Success
|
||||
|
||||
1. **Front-load keywords** - Put important terms at the beginning
|
||||
2. **Match intent** - Description should preview what page delivers
|
||||
3. **Be specific** - Vague descriptions get ignored
|
||||
4. **Test variations** - Small changes can significantly impact CTR
|
||||
5. **Update regularly** - Add current year, refresh messaging
|
||||
6. **Check competitors** - See what's working in your SERP
|
||||
|
||||
|
||||
### Save Results
|
||||
|
||||
After delivering content or optimization output to the user, ask:
|
||||
|
||||
> "Save these results for future sessions?"
|
||||
|
||||
If yes, write a dated summary to `memory/content/YYYY-MM-DD-<topic>.md` containing:
|
||||
- One-line description of what was created
|
||||
- Target keyword and content type
|
||||
- Open loops or items needing review
|
||||
- Source data references
|
||||
|
||||
**Gate check recommended**: Run content-quality-auditor before publishing (PostToolUse hook will remind automatically).
|
||||
|
||||
If any findings should influence ongoing strategy, recommend promoting key conclusions to `memory/hot-cache.md`.
|
||||
|
||||
## Reference Materials
|
||||
|
||||
- [Meta Tag Formulas](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/meta-tags-optimizer/references/meta-tag-formulas.md) — Proven title and description formulas
|
||||
- [CTR and Social Reference](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/meta-tags-optimizer/references/ctr-and-social-reference.md) — Page-type templates, CTR data, OG best practices
|
||||
|
||||
## Next Best Skill
|
||||
|
||||
- **Primary**: [schema-markup-generator](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/schema-markup-generator/SKILL.md) — complete the SERP packaging with structured data.
|
||||
@@ -0,0 +1,364 @@
|
||||
---
|
||||
name: on-page-seo-auditor
|
||||
description: 'Audit on-page SEO: titles, headers, images, links with scored report and fix priorities. 页面SEO审计/排名诊断'
|
||||
version: "6.0.0"
|
||||
license: Apache-2.0
|
||||
compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
|
||||
homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
|
||||
when_to_use: "Use when auditing a page's on-page SEO health, checking heading structure, keyword placement, image optimization, or content quality signals."
|
||||
argument-hint: "<URL> [keyword]"
|
||||
allowed-tools: WebFetch
|
||||
metadata:
|
||||
author: aaron-he-zhu
|
||||
version: "6.0.0"
|
||||
geo-relevance: "medium"
|
||||
tags:
|
||||
- seo
|
||||
- on-page-audit
|
||||
- page-optimization
|
||||
- seo-score
|
||||
- content-audit
|
||||
- h1-optimization
|
||||
- meta-audit
|
||||
- seo-checklist
|
||||
- yoast-alternative
|
||||
- screaming-frog-alternative
|
||||
- 页面SEO
|
||||
- 网页优化
|
||||
- ページSEO
|
||||
- 페이지감사
|
||||
- auditoria-seo
|
||||
triggers:
|
||||
# EN-formal
|
||||
- "audit page SEO"
|
||||
- "on-page SEO check"
|
||||
- "SEO score"
|
||||
- "page optimization"
|
||||
- "on-page audit"
|
||||
- "SEO page analysis"
|
||||
- "content audit"
|
||||
# EN-casual
|
||||
- "what SEO issues does this page have"
|
||||
- "check my page"
|
||||
- "score my page"
|
||||
- "why isn't this page ranking"
|
||||
- "what's wrong with this page's SEO"
|
||||
- "is my page optimized"
|
||||
- "my rankings tanked"
|
||||
- "why did my rankings drop"
|
||||
# EN-question
|
||||
- "why is my page not ranking"
|
||||
- "how do I improve my page SEO"
|
||||
- "what SEO problems does this page have"
|
||||
# EN-competitor
|
||||
- "Screaming Frog alternative"
|
||||
- "Yoast SEO alternative"
|
||||
# ZH-pro
|
||||
- "页面SEO审计"
|
||||
- "网页优化检查"
|
||||
- "SEO评分"
|
||||
- "页面诊断"
|
||||
- "页面优化分析"
|
||||
# ZH-casual
|
||||
- "页面有什么问题"
|
||||
- "为什么排不上去"
|
||||
- "检查一下我的页面"
|
||||
- "SEO打分"
|
||||
- "排名上不去怎么办"
|
||||
- "网页收录问题"
|
||||
# JA
|
||||
- "ページSEO監査"
|
||||
- "オンページSEO"
|
||||
- "ページ最適化"
|
||||
- "SEOスコア"
|
||||
# KO
|
||||
- "페이지 SEO 감사"
|
||||
- "온페이지 SEO"
|
||||
- "SEO 점수"
|
||||
- "이 페이지 뭐가 문제야?"
|
||||
- "왜 순위가 안 올라가?"
|
||||
- "SEO 점수 확인해줘"
|
||||
# ES
|
||||
- "auditoría SEO on-page"
|
||||
- "análisis de página SEO"
|
||||
- "puntuación SEO"
|
||||
# PT
|
||||
- "auditoria SEO on-page"
|
||||
# Misspellings
|
||||
- "on page SEO aduit"
|
||||
- "SEO scroe"
|
||||
---
|
||||
|
||||
# On-Page SEO Auditor
|
||||
|
||||
|
||||
> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
|
||||
> **System Mode**: This optimization skill follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
|
||||
|
||||
|
||||
This skill performs detailed on-page SEO audits to identify issues and optimization opportunities. It analyzes all on-page elements that affect search rankings and provides actionable recommendations.
|
||||
|
||||
**System role**: Optimization layer skill. It turns weak pages, structures, and technical issues into prioritized repair work.
|
||||
|
||||
## When This Must Trigger
|
||||
|
||||
Use this when the conversation involves any of these situations — even if the user does not use SEO terminology:
|
||||
|
||||
Use this whenever the task needs a diagnosis or repair plan that should feed directly into remediation work, not just a one-time opinion.
|
||||
|
||||
- Auditing pages before or after publishing
|
||||
- Identifying why a page isn't ranking well
|
||||
- Optimizing existing content for better performance
|
||||
- Creating pre-publish SEO checklists
|
||||
- Comparing your on-page SEO to competitors
|
||||
- Systematic site-wide SEO improvements
|
||||
- Training team members on SEO best practices
|
||||
|
||||
## What This Skill Does
|
||||
|
||||
1. **Title Tag Analysis**: Evaluates title optimization and CTR potential
|
||||
2. **Meta Description Review**: Checks description quality and length
|
||||
3. **Header Structure Audit**: Analyzes H1-H6 hierarchy
|
||||
4. **Content Quality Assessment**: Reviews content depth and optimization
|
||||
5. **Keyword Usage Analysis**: Checks keyword placement and density
|
||||
6. **Internal Link Review**: Evaluates internal linking structure
|
||||
7. **Image Optimization Check**: Audits alt text and file optimization
|
||||
8. **Technical On-Page Review**: Checks URL, canonical, and mobile factors
|
||||
|
||||
## Quick Start
|
||||
|
||||
Start with one of these prompts. Finish with a short handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
|
||||
|
||||
### Audit a Single Page
|
||||
|
||||
```
|
||||
Audit the on-page SEO of [URL]
|
||||
```
|
||||
|
||||
```
|
||||
Check SEO issues on this page targeting [keyword]: [URL/content]
|
||||
```
|
||||
|
||||
### Compare Against Competitors
|
||||
|
||||
```
|
||||
Compare on-page SEO of [your URL] vs [competitor URL] for [keyword]
|
||||
```
|
||||
|
||||
### Audit Content Before Publishing
|
||||
|
||||
```
|
||||
Pre-publish SEO audit for this content targeting [keyword]: [content]
|
||||
```
|
||||
|
||||
## Skill Contract
|
||||
|
||||
**Expected output**: a scored diagnosis, prioritized repair plan, and a short handoff summary ready for `memory/audits/`.
|
||||
|
||||
- **Reads**: the current page or site state, symptoms, prior audits, and current priorities from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
|
||||
- **Writes**: a user-facing audit or optimization plan plus a reusable summary that can be stored under `memory/audits/`.
|
||||
- **Promotes**: blocking defects, repeated weaknesses, and fix priorities to `memory/open-loops.md` and `memory/decisions.md`.
|
||||
- **Next handoff**: use the `Next Best Skill` below when the repair path is clear.
|
||||
|
||||
## Data Sources
|
||||
|
||||
> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
|
||||
|
||||
**With ~~SEO tool + ~~web crawler connected:**
|
||||
Claude can automatically pull page HTML via ~~web crawler, fetch keyword search volume and difficulty from ~~SEO tool, retrieve click-through rate data from ~~search console, and download competitor pages for comparison. This enables fully automated audits with live data.
|
||||
|
||||
**With manual data only:**
|
||||
Ask the user to provide:
|
||||
1. Page URL or complete HTML content
|
||||
2. Target primary and secondary keywords
|
||||
3. Competitor page URLs for comparison (optional)
|
||||
|
||||
Proceed with the full audit using provided data. Note in the output which findings are from automated crawl vs. manual review.
|
||||
|
||||
## Instructions
|
||||
|
||||
When a user requests an on-page SEO audit:
|
||||
|
||||
1. **Gather Page Information**
|
||||
|
||||
```markdown
|
||||
### Audit Setup
|
||||
|
||||
**Page URL**: [URL]
|
||||
**Target Keyword**: [primary keyword]
|
||||
**Secondary Keywords**: [additional keywords]
|
||||
**Page Type**: [blog/product/landing/service]
|
||||
**Business Goal**: [traffic/conversions/authority]
|
||||
```
|
||||
|
||||
2. **Audit Title Tag**
|
||||
|
||||
```markdown
|
||||
## Title Tag Analysis
|
||||
|
||||
**Current Title**: [title]
|
||||
**Character Count**: [X] characters
|
||||
|
||||
| Criterion | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| Length (50-60 chars) | ✅/⚠️/❌ | [notes] |
|
||||
| Keyword included | ✅/⚠️/❌ | Position: [front/middle/end] |
|
||||
| Keyword at front | ✅/⚠️/❌ | [notes] |
|
||||
| Unique across site | ✅/⚠️/❌ | [notes] |
|
||||
| Compelling/clickable | ✅/⚠️/❌ | [notes] |
|
||||
| Matches intent | ✅/⚠️/❌ | [notes] |
|
||||
|
||||
**Title Score**: [X]/10
|
||||
|
||||
**Issues Found**:
|
||||
- [Issue 1]
|
||||
- [Issue 2]
|
||||
|
||||
**Recommended Title**:
|
||||
"[Optimized title suggestion]"
|
||||
|
||||
**Why**: [Explanation of improvements]
|
||||
```
|
||||
|
||||
3. **Audit Meta Description**
|
||||
|
||||
```markdown
|
||||
## Meta Description Analysis
|
||||
|
||||
**Current Description**: [description]
|
||||
**Character Count**: [X] characters
|
||||
|
||||
| Criterion | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| Length (150-160 chars) | ✅/⚠️/❌ | [notes] |
|
||||
| Keyword included | ✅/⚠️/❌ | [notes] |
|
||||
| Call-to-action present | ✅/⚠️/❌ | [notes] |
|
||||
| Unique across site | ✅/⚠️/❌ | [notes] |
|
||||
| Accurately describes page | ✅/⚠️/❌ | [notes] |
|
||||
| Compelling copy | ✅/⚠️/❌ | [notes] |
|
||||
|
||||
**Description Score**: [X]/10
|
||||
|
||||
**Issues Found**:
|
||||
- [Issue 1]
|
||||
|
||||
**Recommended Description**:
|
||||
"[Optimized description suggestion]" ([X] chars)
|
||||
```
|
||||
|
||||
4. **Audit Header Structure**
|
||||
|
||||
```markdown
|
||||
## Header Structure Analysis
|
||||
|
||||
### Current Header Hierarchy
|
||||
|
||||
```
|
||||
H1: [H1 text]
|
||||
H2: [H2 text]
|
||||
H3: [H3 text]
|
||||
H3: [H3 text]
|
||||
H2: [H2 text]
|
||||
H3: [H3 text]
|
||||
H2: [H2 text]
|
||||
```
|
||||
|
||||
| Criterion | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| Single H1 | ✅/⚠️/❌ | Found: [X] H1s |
|
||||
| H1 includes keyword | ✅/⚠️/❌ | [notes] |
|
||||
| Logical hierarchy | ✅/⚠️/❌ | [notes] |
|
||||
| H2s include keywords | ✅/⚠️/❌ | [X]/[Y] contain keywords |
|
||||
| No skipped levels | ✅/⚠️/❌ | [notes] |
|
||||
| Descriptive headers | ✅/⚠️/❌ | [notes] |
|
||||
|
||||
**Header Score**: [X]/10
|
||||
|
||||
**Issues Found**:
|
||||
- [Issue 1]
|
||||
- [Issue 2]
|
||||
|
||||
**Recommended Changes**:
|
||||
- H1: [suggestion]
|
||||
- H2s: [suggestions]
|
||||
```
|
||||
|
||||
5. **Audit Content Quality** — Word count, reading level, comprehensiveness, formatting, E-E-A-T signals, content elements checklist, gap identification
|
||||
|
||||
> **Reference**: See [references/audit-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/on-page-seo-auditor/references/audit-templates.md) for the content quality template (Step 5).
|
||||
|
||||
6. **Audit Keyword Usage** — Primary/secondary keyword placement across all page elements, LSI/related terms, density analysis
|
||||
|
||||
> **Reference**: See [references/audit-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/on-page-seo-auditor/references/audit-templates.md) for the keyword optimization template (Step 6).
|
||||
|
||||
7. **Audit Internal Links** — Link count, anchor text relevance, broken links, recommended additions
|
||||
|
||||
> **Reference**: See [references/audit-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/on-page-seo-auditor/references/audit-templates.md) for the internal linking template (Step 7).
|
||||
|
||||
8. **Audit Images** — Alt text, file names, sizes, formats, lazy loading
|
||||
|
||||
> **Reference**: See [references/audit-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/on-page-seo-auditor/references/audit-templates.md) for the image optimization template (Step 8).
|
||||
|
||||
9. **Audit Technical On-Page Elements** — URL, canonical, mobile, speed, HTTPS, schema
|
||||
|
||||
> **Reference**: See [references/audit-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/on-page-seo-auditor/references/audit-templates.md) for the technical on-page template (Step 9).
|
||||
|
||||
10. **CORE-EEAT Content Quality Quick Scan** — 17 on-page-relevant items from the 80-item CORE-EEAT benchmark
|
||||
|
||||
> **Reference**: See [references/audit-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/on-page-seo-auditor/references/audit-templates.md) for the CORE-EEAT quick scan template (Step 10). Full benchmark: [CORE-EEAT Benchmark](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/core-eeat-benchmark.md).
|
||||
|
||||
11. **Generate Audit Summary** — Overall score with visual breakdown, priority issues (critical/important/minor), quick wins, detailed recommendations, competitor comparison, action checklist, expected results
|
||||
|
||||
> **Reference**: See [references/audit-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/on-page-seo-auditor/references/audit-templates.md) for the full audit summary template (Step 11).
|
||||
|
||||
## Validation Checkpoints
|
||||
|
||||
### Input Validation
|
||||
- [ ] Target keyword(s) clearly specified by user
|
||||
- [ ] Page content accessible (either via URL or provided HTML)
|
||||
- [ ] If competitor comparison requested, competitor URL provided
|
||||
|
||||
### Output Validation
|
||||
- [ ] Every recommendation cites specific data points (not generic advice)
|
||||
- [ ] Scores based on measurable criteria, not subjective opinion
|
||||
- [ ] All suggested changes include specific locations (title tag, H2 #3, paragraph 5, etc.)
|
||||
- [ ] Source of each data point clearly stated (~~SEO tool data, user-provided, ~~web crawler, or manual review)
|
||||
|
||||
## Example
|
||||
|
||||
> **Reference**: See [references/audit-example.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/on-page-seo-auditor/references/audit-example.md) for a full worked example (noise-cancelling headphones audit) and page-type checklists (blog post, product page, landing page).
|
||||
|
||||
## Tips for Success
|
||||
|
||||
1. **Prioritize issues by impact** - Fix critical issues first
|
||||
2. **Compare to competitors** - See what's working for top rankings
|
||||
3. **Balance optimization and readability** - Don't over-optimize
|
||||
4. **Audit regularly** - Content degrades over time
|
||||
5. **Test changes** - Track ranking changes after updates
|
||||
|
||||
> **Scoring details**: For the complete weight distribution, scoring scale, issue resolution playbook, and industry benchmarks, see [references/scoring-rubric.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/on-page-seo-auditor/references/scoring-rubric.md).
|
||||
|
||||
|
||||
### Save Results
|
||||
|
||||
After delivering audit or optimization findings to the user, ask:
|
||||
|
||||
> "Save these results for future sessions?"
|
||||
|
||||
If yes, write a dated summary to `memory/audits/on-page-seo-auditor/YYYY-MM-DD-<topic>.md` containing:
|
||||
- One-line verdict or headline finding
|
||||
- Top 3-5 actionable items
|
||||
- Open loops or blockers
|
||||
- Source data references
|
||||
|
||||
If any veto-level issue was found (CORE-EEAT T04, C01, R10 or CITE T03, T05, T09), also append a one-liner to `memory/hot-cache.md` without asking.
|
||||
|
||||
## Reference Materials
|
||||
|
||||
- [Scoring Rubric](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/on-page-seo-auditor/references/scoring-rubric.md) — Detailed scoring criteria, weight distribution, and grade boundaries for on-page audits
|
||||
- [Audit Templates](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/on-page-seo-auditor/references/audit-templates.md) — Detailed output templates for steps 5-11 (content quality, keywords, links, images, technical, CORE-EEAT scan, audit summary)
|
||||
- [Audit Example & Checklists](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/on-page-seo-auditor/references/audit-example.md) — Full worked example and page-type checklists (blog, product, landing page)
|
||||
|
||||
## Next Best Skill
|
||||
|
||||
- **Primary**: [content-refresher](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/content-refresher/SKILL.md) — turn page-level findings into concrete edits.
|
||||
@@ -0,0 +1,263 @@
|
||||
---
|
||||
name: performance-reporter
|
||||
description: 'Generate SEO/GEO dashboards: rankings, traffic, backlinks, AI visibility for stakeholders. SEO报告/绩效仪表盘'
|
||||
version: "6.0.0"
|
||||
license: Apache-2.0
|
||||
compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
|
||||
homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
|
||||
when_to_use: "Use when generating SEO performance reports, traffic summaries, ranking reports, or stakeholder-facing dashboards."
|
||||
argument-hint: "<domain> [date range]"
|
||||
metadata:
|
||||
author: aaron-he-zhu
|
||||
version: "6.0.0"
|
||||
geo-relevance: "medium"
|
||||
tags:
|
||||
- seo
|
||||
- geo
|
||||
- seo-reporting
|
||||
- performance-report
|
||||
- kpi-dashboard
|
||||
- traffic-report
|
||||
- monthly-report
|
||||
- stakeholder-report
|
||||
- SEO报告
|
||||
- SEOレポート
|
||||
- SEO리포트
|
||||
- informe-seo
|
||||
triggers:
|
||||
# EN-formal
|
||||
- "generate SEO report"
|
||||
- "performance report"
|
||||
- "traffic report"
|
||||
- "SEO dashboard"
|
||||
- "SEO analytics"
|
||||
- "monthly report"
|
||||
# EN-casual
|
||||
- "report to stakeholders"
|
||||
- "monthly SEO report"
|
||||
- "show me my SEO results"
|
||||
- "present SEO results to my boss"
|
||||
- "report to my boss"
|
||||
- "monthly SEO summary"
|
||||
# EN-question
|
||||
- "how are my SEO metrics"
|
||||
- "how is my SEO performing"
|
||||
# ZH-pro
|
||||
- "SEO报告"
|
||||
- "绩效仪表盘"
|
||||
- "流量报告"
|
||||
- "数据看板"
|
||||
# ZH-casual
|
||||
- "出SEO报告"
|
||||
- "汇报给老板"
|
||||
- "看看数据"
|
||||
- "月报"
|
||||
- "出月报"
|
||||
- "周报"
|
||||
# JA
|
||||
- "SEOレポート"
|
||||
- "パフォーマンスレポート"
|
||||
# KO
|
||||
- "SEO 리포트"
|
||||
- "성과 보고서"
|
||||
# ES
|
||||
- "informe SEO"
|
||||
- "reporte de rendimiento"
|
||||
# PT
|
||||
- "relatório SEO"
|
||||
# Misspellings
|
||||
- "SEO repoort"
|
||||
---
|
||||
|
||||
# Performance Reporter
|
||||
|
||||
|
||||
> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
|
||||
> **System Mode**: This monitoring skill follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
|
||||
|
||||
|
||||
This skill creates comprehensive SEO and GEO performance reports that combine multiple metrics into actionable insights. It produces executive summaries, detailed analyses, and visual data presentations for stakeholder communication.
|
||||
|
||||
**System role**: Monitoring layer skill. It turns performance changes into deltas, alerts, and next actions.
|
||||
|
||||
## When This Must Trigger
|
||||
|
||||
Use this when the conversation involves any of these situations — even if the user does not use SEO terminology:
|
||||
|
||||
Use this whenever the task needs time-aware change detection, escalation, or stakeholder-ready visibility.
|
||||
|
||||
- Monthly/quarterly SEO reporting
|
||||
- Executive stakeholder updates
|
||||
- Client reporting for agencies
|
||||
- Tracking campaign performance
|
||||
- Combining multiple SEO metrics
|
||||
- Creating GEO visibility reports
|
||||
- Documenting ROI from SEO efforts
|
||||
|
||||
## What This Skill Does
|
||||
|
||||
1. **Data Aggregation**: Combines multiple SEO data sources
|
||||
2. **Trend Analysis**: Identifies patterns across metrics
|
||||
3. **Executive Summaries**: Creates high-level overviews
|
||||
4. **Visual Reports**: Presents data in clear formats
|
||||
5. **Benchmark Comparison**: Tracks against goals and competitors
|
||||
6. **Content Quality Tracking**: Integrates CORE-EEAT scores across audited pages
|
||||
7. **ROI Calculation**: Measures SEO investment returns
|
||||
8. **Recommendations**: Suggests actions based on data
|
||||
|
||||
## Quick Start
|
||||
|
||||
Start with one of these prompts. Finish with a short handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
|
||||
|
||||
### Generate Performance Report
|
||||
|
||||
```
|
||||
Create an SEO performance report for [domain] for [time period]
|
||||
```
|
||||
|
||||
### Executive Summary
|
||||
|
||||
```
|
||||
Generate an executive summary of SEO performance for [month/quarter]
|
||||
```
|
||||
|
||||
### Specific Report Types
|
||||
|
||||
```
|
||||
Create a GEO visibility report for [domain]
|
||||
```
|
||||
|
||||
```
|
||||
Generate a content performance report
|
||||
```
|
||||
|
||||
## Skill Contract
|
||||
|
||||
**Expected output**: a delta summary, alert/report output, and a short handoff summary ready for `memory/monitoring/`.
|
||||
|
||||
- **Reads**: current metrics, previous baselines, alert thresholds, and reporting context from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
|
||||
- **Writes**: a user-facing monitoring deliverable plus a reusable summary that can be stored under `memory/monitoring/`.
|
||||
- **Promotes**: significant changes, confirmed anomalies, and follow-up actions to `memory/open-loops.md` and `memory/decisions.md`.
|
||||
- **Next handoff**: use the `Next Best Skill` below when a change needs action.
|
||||
|
||||
## Data Sources
|
||||
|
||||
> **Note:** All integrations are optional. This skill works without any API keys — users provide data manually when no tools are connected.
|
||||
|
||||
> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
|
||||
|
||||
**With ~~analytics + ~~search console + ~~SEO tool + ~~AI monitor connected:**
|
||||
Automatically aggregate traffic metrics from ~~analytics, search performance data from ~~search console, ranking and backlink data from ~~SEO tool, and GEO visibility metrics from ~~AI monitor. Creates comprehensive multi-source reports with historical trends.
|
||||
|
||||
**With manual data only:**
|
||||
Ask the user to provide:
|
||||
1. Analytics screenshots or traffic data export (sessions, users, conversions)
|
||||
2. Search Console data (impressions, clicks, average position)
|
||||
3. Keyword ranking data for the reporting period
|
||||
4. Backlink metrics (referring domains, new/lost links)
|
||||
5. Key performance indicators and goals for comparison
|
||||
6. AI citation data if tracking GEO metrics
|
||||
|
||||
Proceed with the full analysis using provided data. Note in the output which metrics are from automated collection vs. user-provided data.
|
||||
|
||||
## Instructions
|
||||
|
||||
When a user requests a performance report:
|
||||
|
||||
1. **Define Report Parameters** -- Domain, report period, comparison period, report type (Monthly/Quarterly/Annual), audience (Executive/Technical/Client), focus areas.
|
||||
|
||||
2. **Create Executive Summary** -- Overall performance rating, key wins/watch areas/action required, metrics at a glance table (traffic, rankings, conversions, DA, AI citations), SEO ROI calculation.
|
||||
|
||||
3. **Report Organic Traffic Performance** -- Traffic overview (sessions, users, pageviews, bounce rate), traffic trend visualization, traffic by source/device, top performing pages.
|
||||
|
||||
4. **Report Keyword Rankings** -- Rankings overview by position range, distribution change visualization, top improvements and declines, SERP feature performance.
|
||||
|
||||
5. **Report GEO/AI Performance** -- AI citation overview, citations by topic, GEO wins, optimization opportunities.
|
||||
|
||||
6. **Report Domain Authority (CITE Score)** -- If a CITE audit has been run, include CITE dimension scores (C/I/T/E) with period-over-period trends and veto status. If no audit exists, note as "Not yet evaluated."
|
||||
|
||||
7. **Content Quality (CORE-EEAT Score)** -- If content-quality-auditor has been run, include average scores across all 8 CORE-EEAT dimensions with trends. If no audit exists, note as "Not yet evaluated."
|
||||
|
||||
8. **Report Backlink Performance** -- Link profile summary, weekly link acquisition, notable new links, competitive position.
|
||||
|
||||
9. **Report Content Performance** -- Publishing summary, top performing content, content needing attention, content ROI.
|
||||
|
||||
10. **Generate Recommendations** -- Immediate/short-term/long-term actions with priority, expected impact, and owner. Goals for next period.
|
||||
|
||||
11. **Compile Full Report** -- Combine all sections with table of contents, appendix (data sources, methodology, glossary).
|
||||
|
||||
> **Reference**: See [references/report-output-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/performance-reporter/references/report-output-templates.md) for complete output templates for all 11 report sections.
|
||||
|
||||
## Validation Checkpoints
|
||||
|
||||
### Input Validation
|
||||
- [ ] Reporting period clearly defined with comparison period
|
||||
- [ ] All required data sources available or alternatives noted
|
||||
- [ ] Target audience identified (executive/technical/client)
|
||||
- [ ] Performance goals and KPIs established for benchmarking
|
||||
|
||||
### Output Validation
|
||||
- [ ] Every metric cites its data source and collection date
|
||||
- [ ] Trends include period-over-period comparisons
|
||||
- [ ] Recommendations are specific, prioritized, and actionable
|
||||
- [ ] Source of each data point clearly stated (~~analytics data, ~~search console data, ~~SEO tool data, user-provided, or estimated)
|
||||
|
||||
## Example
|
||||
|
||||
**User**: "Create a monthly SEO report for cloudhosting.com for January 2025"
|
||||
|
||||
**Output** (abbreviated -- full report uses templates from all 11 steps):
|
||||
|
||||
```markdown
|
||||
# CloudHosting SEO & GEO Performance Report — January 2025
|
||||
|
||||
## Executive Summary — Overall Performance: Good
|
||||
|
||||
| Metric | Jan 2025 | Dec 2024 | Change | Target | Status |
|
||||
|--------|----------|----------|--------|--------|--------|
|
||||
| Organic Traffic | 52,100 | 45,200 | +15.3% | 50,000 | On track |
|
||||
| Keywords Top 10 | 87 | 79 | +8 | 90 | Watch |
|
||||
| Organic Conversions | 684 | 612 | +11.8% | 700 | Watch |
|
||||
| Domain Rating | 54 | 53 | +1 | 55 | Watch |
|
||||
| AI Citations | 18 | 12 | +50.0% | 20 | Watch |
|
||||
|
||||
**SEO ROI**: $8,200 invested / $41,040 organic revenue = 400%
|
||||
|
||||
**Immediate**: Fix 37 crawl errors on /pricing/ pages
|
||||
**This Month**: Optimize mobile LCP; publish 3 AI Overview comparison pages
|
||||
**This Quarter**: Build Wikidata entry for CloudHost Inc.
|
||||
```
|
||||
|
||||
## Tips for Success
|
||||
|
||||
1. **Lead with insights** - Start with what matters, not raw data
|
||||
2. **Visualize data** - Charts and graphs improve comprehension
|
||||
3. **Compare periods** - Context makes data meaningful
|
||||
4. **Include actions** - Every report should drive decisions
|
||||
5. **Customize for audience** - Executives need different info than technical teams
|
||||
6. **Track GEO metrics** - AI visibility is increasingly important
|
||||
|
||||
|
||||
### Save Results
|
||||
|
||||
After delivering monitoring data or reports to the user, ask:
|
||||
|
||||
> "Save these results for future sessions?"
|
||||
|
||||
If yes, write a dated summary to `memory/monitoring/YYYY-MM-DD-<topic>.md` containing:
|
||||
- One-line headline finding or status change
|
||||
- Top 3-5 actionable items
|
||||
- Open loops or anomalies requiring follow-up
|
||||
- Source data references
|
||||
|
||||
If any findings should influence ongoing strategy, recommend promoting key conclusions to `memory/hot-cache.md`.
|
||||
|
||||
## Reference Materials
|
||||
|
||||
- [Report Output Templates](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/performance-reporter/references/report-output-templates.md) — Complete output templates for all 11 report sections
|
||||
- [KPI Definitions](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/performance-reporter/references/kpi-definitions.md) — SEO/GEO metric definitions with benchmarks, good ranges, warning thresholds, trend analysis, and attribution guidance
|
||||
- [Report Templates by Audience](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/performance-reporter/references/report-templates.md) — Copy-ready templates for executive, marketing, technical, and client audiences
|
||||
|
||||
## Next Best Skill
|
||||
|
||||
- **Primary**: [alert-manager](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/alert-manager/SKILL.md) — turn reporting insights into ongoing monitoring rules.
|
||||
@@ -0,0 +1,295 @@
|
||||
---
|
||||
name: playwright-testing
|
||||
description: Use when writing Playwright tests, fixing flaky tests, debugging failures, implementing Page Object Model, configuring CI/CD, optimizing performance, mocking APIs, handling authentication or OAuth, testing accessibility (axe-core), file uploads/downloads, date/time mocking, WebSockets, geolocation, permissions, multi-tab/popup flows, mobile/responsive layouts, touch gestures, GraphQL, error handling, offline mode, multi-user collaboration, third-party services (payments, email verification), console error monitoring, global setup/teardown, test annotations (skip, fixme, slow), test tags (@smoke, @fast, @critical, filtering with --grep), project dependencies, security testing (XSS, CSRF, auth), performance budgets (Web Vitals, Lighthouse), iframes, component testing, canvas/WebGL, service workers/PWA, test coverage, i18n/localization, Electron apps, or browser extension testing. Covers E2E, component, API, visual, accessibility, security, Electron, and extension testing.
|
||||
---
|
||||
|
||||
# Playwright Testing
|
||||
|
||||
This skill provides comprehensive guidance for all aspects of Playwright test development, from writing new tests to debugging and maintaining existing test suites.
|
||||
|
||||
## Activity-Based Reference Guide
|
||||
|
||||
Consult these references based on what you're doing:
|
||||
|
||||
### Writing New Tests
|
||||
|
||||
**When to use**: Creating new test files, writing test cases, implementing test scenarios
|
||||
|
||||
| Activity | Reference Files |
|
||||
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Writing E2E tests** | [test-suite-structure.md](core/test-suite-structure.md), [locators.md](core/locators.md), [assertions-waiting.md](core/assertions-waiting.md) |
|
||||
| **Writing component tests** | [component-testing.md](testing-patterns/component-testing.md), [test-suite-structure.md](core/test-suite-structure.md) |
|
||||
| **Writing API tests** | [api-testing.md](testing-patterns/api-testing.md), [test-suite-structure.md](core/test-suite-structure.md) |
|
||||
| **Writing GraphQL tests** | [graphql-testing.md](testing-patterns/graphql-testing.md), [api-testing.md](testing-patterns/api-testing.md) |
|
||||
| **Writing visual regression tests** | [visual-regression.md](testing-patterns/visual-regression.md), [canvas-webgl.md](testing-patterns/canvas-webgl.md) |
|
||||
| **Structuring test code with POM** | [page-object-model.md](core/page-object-model.md), [test-suite-structure.md](core/test-suite-structure.md) |
|
||||
| **Setting up test data/fixtures** | [fixtures-hooks.md](core/fixtures-hooks.md), [test-data.md](core/test-data.md) |
|
||||
| **Handling authentication** | [authentication.md](advanced/authentication.md), [authentication-flows.md](advanced/authentication-flows.md) |
|
||||
| **Testing date/time features** | [clock-mocking.md](advanced/clock-mocking.md) |
|
||||
| **Testing file upload/download** | [file-operations.md](testing-patterns/file-operations.md), [file-upload-download.md](testing-patterns/file-upload-download.md) |
|
||||
| **Testing forms/validation** | [forms-validation.md](testing-patterns/forms-validation.md) |
|
||||
| **Testing drag and drop** | [drag-drop.md](testing-patterns/drag-drop.md) |
|
||||
| **Testing accessibility** | [accessibility.md](testing-patterns/accessibility.md) |
|
||||
| **Testing security (XSS, CSRF)** | [security-testing.md](testing-patterns/security-testing.md) |
|
||||
| **Using test annotations** | [annotations.md](core/annotations.md) |
|
||||
| **Using test tags** | [test-tags.md](core/test-tags.md) |
|
||||
| **Testing iframes** | [iframes.md](browser-apis/iframes.md) |
|
||||
| **Testing canvas/WebGL** | [canvas-webgl.md](testing-patterns/canvas-webgl.md) |
|
||||
| **Internationalization (i18n)** | [i18n.md](testing-patterns/i18n.md) |
|
||||
| **Testing Electron apps** | [electron.md](testing-patterns/electron.md) |
|
||||
| **Testing browser extensions** | [browser-extensions.md](testing-patterns/browser-extensions.md) |
|
||||
|
||||
### Mobile & Responsive Testing
|
||||
|
||||
**When to use**: Testing mobile devices, touch interactions, responsive layouts
|
||||
|
||||
| Activity | Reference Files |
|
||||
| ------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| **Device emulation** | [mobile-testing.md](advanced/mobile-testing.md) |
|
||||
| **Touch gestures (swipe, tap)** | [mobile-testing.md](advanced/mobile-testing.md) |
|
||||
| **Viewport/breakpoint testing** | [mobile-testing.md](advanced/mobile-testing.md) |
|
||||
| **Mobile-specific UI** | [mobile-testing.md](advanced/mobile-testing.md), [locators.md](core/locators.md) |
|
||||
|
||||
### Real-Time & Browser APIs
|
||||
|
||||
**When to use**: Testing WebSockets, geolocation, permissions, multi-tab flows
|
||||
|
||||
| Activity | Reference Files |
|
||||
| ------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| **WebSocket/real-time testing** | [websockets.md](browser-apis/websockets.md) |
|
||||
| **Geolocation mocking** | [browser-apis.md](browser-apis/browser-apis.md) |
|
||||
| **Permission handling** | [browser-apis.md](browser-apis/browser-apis.md) |
|
||||
| **Clipboard testing** | [browser-apis.md](browser-apis/browser-apis.md) |
|
||||
| **Camera/microphone mocking** | [browser-apis.md](browser-apis/browser-apis.md) |
|
||||
| **Multi-tab/popup flows** | [multi-context.md](advanced/multi-context.md) |
|
||||
| **OAuth popup handling** | [third-party.md](advanced/third-party.md), [multi-context.md](advanced/multi-context.md) |
|
||||
|
||||
### Debugging & Troubleshooting
|
||||
|
||||
**When to use**: Test failures, element not found, timeouts, unexpected behavior
|
||||
|
||||
| Activity | Reference Files |
|
||||
| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Debugging test failures** | [debugging.md](debugging/debugging.md), [assertions-waiting.md](core/assertions-waiting.md) |
|
||||
| **Fixing flaky tests** | [flaky-tests.md](debugging/flaky-tests.md), [debugging.md](debugging/debugging.md), [assertions-waiting.md](core/assertions-waiting.md) |
|
||||
| **Debugging flaky parallel runs** | [flaky-tests.md](debugging/flaky-tests.md), [performance.md](infrastructure-ci-cd/performance.md), [fixtures-hooks.md](core/fixtures-hooks.md) |
|
||||
| **Ensuring test isolation / avoiding state leak** | [flaky-tests.md](debugging/flaky-tests.md), [fixtures-hooks.md](core/fixtures-hooks.md), [performance.md](infrastructure-ci-cd/performance.md) |
|
||||
| **Fixing selector issues** | [locators.md](core/locators.md), [debugging.md](debugging/debugging.md) |
|
||||
| **Investigating timeout issues** | [assertions-waiting.md](core/assertions-waiting.md), [debugging.md](debugging/debugging.md) |
|
||||
| **Using trace viewer** | [debugging.md](debugging/debugging.md) |
|
||||
| **Debugging race conditions** | [flaky-tests.md](debugging/flaky-tests.md), [debugging.md](debugging/debugging.md), [assertions-waiting.md](core/assertions-waiting.md) |
|
||||
| **Debugging console/JS errors** | [console-errors.md](debugging/console-errors.md), [debugging.md](debugging/debugging.md) |
|
||||
|
||||
### Error & Edge Case Testing
|
||||
|
||||
**When to use**: Testing error states, offline mode, network failures, validation
|
||||
|
||||
| Activity | Reference Files |
|
||||
| ------------------------------ | ----------------------------------------------------------------------------------------------------- |
|
||||
| **Error boundary testing** | [error-testing.md](debugging/error-testing.md) |
|
||||
| **Network failure simulation** | [error-testing.md](debugging/error-testing.md), [network-advanced.md](advanced/network-advanced.md) |
|
||||
| **Offline mode testing** | [error-testing.md](debugging/error-testing.md), [service-workers.md](browser-apis/service-workers.md) |
|
||||
| **Service worker testing** | [service-workers.md](browser-apis/service-workers.md) |
|
||||
| **Loading state testing** | [error-testing.md](debugging/error-testing.md) |
|
||||
| **Form validation testing** | [error-testing.md](debugging/error-testing.md) |
|
||||
|
||||
### Multi-User & Collaboration Testing
|
||||
|
||||
**When to use**: Testing features involving multiple users, roles, or real-time collaboration
|
||||
|
||||
| Activity | Reference Files |
|
||||
| ------------------------------ | ------------------------------------------------------------------------------------ |
|
||||
| **Multiple users in one test** | [multi-user.md](advanced/multi-user.md) |
|
||||
| **Real-time collaboration** | [multi-user.md](advanced/multi-user.md), [websockets.md](browser-apis/websockets.md) |
|
||||
| **Role-based access testing** | [multi-user.md](advanced/multi-user.md) |
|
||||
| **Concurrent action testing** | [multi-user.md](advanced/multi-user.md) |
|
||||
|
||||
### Architecture Decisions
|
||||
|
||||
**When to use**: Choosing test patterns, deciding between approaches, planning test architecture
|
||||
|
||||
| Activity | Reference Files |
|
||||
| ---------------------------- | --------------------------------------------------------- |
|
||||
| **POM vs fixtures decision** | [pom-vs-fixtures.md](architecture/pom-vs-fixtures.md) |
|
||||
| **Test type selection** | [test-architecture.md](architecture/test-architecture.md) |
|
||||
| **Mock vs real services** | [when-to-mock.md](architecture/when-to-mock.md) |
|
||||
| **Test suite structure** | [test-suite-structure.md](core/test-suite-structure.md) |
|
||||
|
||||
### Framework-Specific Testing
|
||||
|
||||
**When to use**: Testing React or Next.js applications
|
||||
|
||||
| Activity | Reference Files |
|
||||
| ------------------------- | ----------------------------------- |
|
||||
| **Testing React apps** | [react.md](frameworks/react.md) |
|
||||
| **Testing Next.js apps** | [nextjs.md](frameworks/nextjs.md) |
|
||||
|
||||
### Refactoring & Maintenance
|
||||
|
||||
**When to use**: Improving existing tests, code review, reducing duplication
|
||||
|
||||
| Activity | Reference Files |
|
||||
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------- |
|
||||
| **Refactoring to Page Object Model** | [page-object-model.md](core/page-object-model.md), [test-suite-structure.md](core/test-suite-structure.md) |
|
||||
| **Improving test organization** | [test-suite-structure.md](core/test-suite-structure.md), [page-object-model.md](core/page-object-model.md) |
|
||||
| **Extracting common setup/teardown** | [fixtures-hooks.md](core/fixtures-hooks.md) |
|
||||
| **Replacing brittle selectors** | [locators.md](core/locators.md) |
|
||||
| **Removing explicit waits** | [assertions-waiting.md](core/assertions-waiting.md) |
|
||||
| **Creating test data factories** | [test-data.md](core/test-data.md) |
|
||||
| **Configuration setup** | [configuration.md](core/configuration.md) |
|
||||
|
||||
### Infrastructure & Configuration
|
||||
|
||||
**When to use**: Setting up projects, configuring CI/CD, optimizing performance
|
||||
|
||||
| Activity | Reference Files |
|
||||
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Configuring Playwright project** | [configuration.md](core/configuration.md), [projects-dependencies.md](core/projects-dependencies.md) |
|
||||
| **Setting up CI/CD pipelines** | [ci-cd.md](infrastructure-ci-cd/ci-cd.md), [github-actions.md](infrastructure-ci-cd/github-actions.md) |
|
||||
| **GitHub Actions setup** | [github-actions.md](infrastructure-ci-cd/github-actions.md) |
|
||||
| **GitLab CI setup** | [gitlab.md](infrastructure-ci-cd/gitlab.md) |
|
||||
| **Other CI providers** | [other-providers.md](infrastructure-ci-cd/other-providers.md) |
|
||||
| **Docker/container setup** | [docker.md](infrastructure-ci-cd/docker.md) |
|
||||
| **Global setup & teardown** | [global-setup.md](core/global-setup.md) |
|
||||
| **Project dependencies** | [projects-dependencies.md](core/projects-dependencies.md) |
|
||||
| **Optimizing test performance** | [performance.md](infrastructure-ci-cd/performance.md), [test-suite-structure.md](core/test-suite-structure.md) |
|
||||
| **Configuring parallel execution** | [parallel-sharding.md](infrastructure-ci-cd/parallel-sharding.md), [performance.md](infrastructure-ci-cd/performance.md) |
|
||||
| **Isolating test data between workers** | [fixtures-hooks.md](core/fixtures-hooks.md), [performance.md](infrastructure-ci-cd/performance.md) |
|
||||
| **Test coverage** | [test-coverage.md](infrastructure-ci-cd/test-coverage.md) |
|
||||
| **Test reporting/artifacts** | [reporting.md](infrastructure-ci-cd/reporting.md) |
|
||||
|
||||
### Advanced Patterns
|
||||
|
||||
**When to use**: Complex scenarios, API mocking, network interception
|
||||
|
||||
| Activity | Reference Files |
|
||||
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
|
||||
| **Mocking API responses** | [test-suite-structure.md](core/test-suite-structure.md), [network-advanced.md](advanced/network-advanced.md) |
|
||||
| **Network interception** | [network-advanced.md](advanced/network-advanced.md), [assertions-waiting.md](core/assertions-waiting.md) |
|
||||
| **GraphQL mocking** | [network-advanced.md](advanced/network-advanced.md) |
|
||||
| **HAR recording/playback** | [network-advanced.md](advanced/network-advanced.md) |
|
||||
| **Custom fixtures** | [fixtures-hooks.md](core/fixtures-hooks.md) |
|
||||
| **Advanced waiting strategies** | [assertions-waiting.md](core/assertions-waiting.md) |
|
||||
| **OAuth/SSO mocking** | [third-party.md](advanced/third-party.md), [multi-context.md](advanced/multi-context.md) |
|
||||
| **Payment gateway mocking** | [third-party.md](advanced/third-party.md) |
|
||||
| **Email/SMS verification mocking** | [third-party.md](advanced/third-party.md) |
|
||||
| **Failing on console errors** | [console-errors.md](debugging/console-errors.md) |
|
||||
| **Security testing (XSS, CSRF)** | [security-testing.md](testing-patterns/security-testing.md) |
|
||||
| **Performance budgets & Web Vitals** | [performance-testing.md](testing-patterns/performance-testing.md) |
|
||||
| **Lighthouse integration** | [performance-testing.md](testing-patterns/performance-testing.md) |
|
||||
| **Test annotations (skip, fixme)** | [annotations.md](core/annotations.md) |
|
||||
| **Test tags (@smoke, @fast)** | [test-tags.md](core/test-tags.md) |
|
||||
| **Test steps for reporting** | [annotations.md](core/annotations.md) |
|
||||
|
||||
## Quick Decision Tree
|
||||
|
||||
```
|
||||
What are you doing?
|
||||
│
|
||||
├─ Writing a new test?
|
||||
│ ├─ E2E test → core/test-suite-structure.md, core/locators.md, core/assertions-waiting.md
|
||||
│ ├─ Component test → testing-patterns/component-testing.md
|
||||
│ ├─ API test → testing-patterns/api-testing.md, core/test-suite-structure.md
|
||||
│ ├─ GraphQL test → testing-patterns/graphql-testing.md
|
||||
│ ├─ Visual regression → testing-patterns/visual-regression.md
|
||||
│ ├─ Visual/canvas test → testing-patterns/canvas-webgl.md, core/test-suite-structure.md
|
||||
│ ├─ Accessibility test → testing-patterns/accessibility.md
|
||||
│ ├─ Mobile/responsive test → advanced/mobile-testing.md
|
||||
│ ├─ i18n/locale test → testing-patterns/i18n.md
|
||||
│ ├─ Electron app test → testing-patterns/electron.md
|
||||
│ ├─ Browser extension test → testing-patterns/browser-extensions.md
|
||||
│ ├─ Multi-user test → advanced/multi-user.md
|
||||
│ ├─ Form validation test → testing-patterns/forms-validation.md
|
||||
│ └─ Drag and drop test → testing-patterns/drag-drop.md
|
||||
│
|
||||
├─ Testing specific features?
|
||||
│ ├─ File upload/download → testing-patterns/file-operations.md, testing-patterns/file-upload-download.md
|
||||
│ ├─ Date/time dependent → advanced/clock-mocking.md
|
||||
│ ├─ WebSocket/real-time → browser-apis/websockets.md
|
||||
│ ├─ Geolocation/permissions → browser-apis/browser-apis.md
|
||||
│ ├─ OAuth/SSO mocking → advanced/third-party.md, advanced/multi-context.md
|
||||
│ ├─ Payments/email/SMS → advanced/third-party.md
|
||||
│ ├─ iFrames → browser-apis/iframes.md
|
||||
│ ├─ Canvas/WebGL/charts → testing-patterns/canvas-webgl.md
|
||||
│ ├─ Service workers/PWA → browser-apis/service-workers.md
|
||||
│ ├─ i18n/localization → testing-patterns/i18n.md
|
||||
│ ├─ Security (XSS, CSRF) → testing-patterns/security-testing.md
|
||||
│ └─ Performance/Web Vitals → testing-patterns/performance-testing.md
|
||||
│
|
||||
├─ Architecture decisions?
|
||||
│ ├─ POM vs fixtures → architecture/pom-vs-fixtures.md
|
||||
│ ├─ Test type selection → architecture/test-architecture.md
|
||||
│ ├─ Mock vs real services → architecture/when-to-mock.md
|
||||
│ └─ Test suite structure → core/test-suite-structure.md
|
||||
│
|
||||
├─ Framework-specific testing?
|
||||
│ ├─ React app → frameworks/react.md
|
||||
│ └─ Next.js app → frameworks/nextjs.md
|
||||
│
|
||||
├─ Authentication testing?
|
||||
│ ├─ Basic auth patterns → advanced/authentication.md
|
||||
│ └─ Complex flows (MFA, reset) → advanced/authentication-flows.md
|
||||
│
|
||||
├─ Test is failing/flaky?
|
||||
│ ├─ Flaky test investigation → debugging/flaky-tests.md
|
||||
│ ├─ Element not found → core/locators.md, debugging/debugging.md
|
||||
│ ├─ Timeout issues → core/assertions-waiting.md, debugging/debugging.md
|
||||
│ ├─ Race conditions → debugging/flaky-tests.md, debugging/debugging.md
|
||||
│ ├─ Flaky only with multiple workers → debugging/flaky-tests.md, infrastructure-ci-cd/performance.md
|
||||
│ ├─ State leak / isolation → debugging/flaky-tests.md, core/fixtures-hooks.md
|
||||
│ ├─ Console/JS errors → debugging/console-errors.md, debugging/debugging.md
|
||||
│ └─ General debugging → debugging/debugging.md
|
||||
│
|
||||
├─ Testing error scenarios?
|
||||
│ ├─ Network failures → debugging/error-testing.md, advanced/network-advanced.md
|
||||
│ ├─ Offline (unexpected) → debugging/error-testing.md
|
||||
│ ├─ Offline-first/PWA → browser-apis/service-workers.md
|
||||
│ ├─ Error boundaries → debugging/error-testing.md
|
||||
│ └─ Form validation → testing-patterns/forms-validation.md, debugging/error-testing.md
|
||||
│
|
||||
├─ Refactoring existing code?
|
||||
│ ├─ Implementing POM → core/page-object-model.md
|
||||
│ ├─ Improving selectors → core/locators.md
|
||||
│ ├─ Extracting fixtures → core/fixtures-hooks.md
|
||||
│ ├─ Creating data factories → core/test-data.md
|
||||
│ └─ Configuration setup → core/configuration.md
|
||||
│
|
||||
├─ Setting up infrastructure?
|
||||
│ ├─ CI/CD → infrastructure-ci-cd/ci-cd.md
|
||||
│ ├─ GitHub Actions → infrastructure-ci-cd/github-actions.md
|
||||
│ ├─ GitLab CI → infrastructure-ci-cd/gitlab.md
|
||||
│ ├─ Other CI providers → infrastructure-ci-cd/other-providers.md
|
||||
│ ├─ Docker/containers → infrastructure-ci-cd/docker.md
|
||||
│ ├─ Sharding/parallel → infrastructure-ci-cd/parallel-sharding.md
|
||||
│ ├─ Reporting/artifacts → infrastructure-ci-cd/reporting.md
|
||||
│ ├─ Global setup/teardown → core/global-setup.md
|
||||
│ ├─ Project dependencies → core/projects-dependencies.md
|
||||
│ ├─ Test performance → infrastructure-ci-cd/performance.md
|
||||
│ ├─ Test coverage → infrastructure-ci-cd/test-coverage.md
|
||||
│ └─ Project config → core/configuration.md, core/projects-dependencies.md
|
||||
│
|
||||
├─ Organizing tests?
|
||||
│ ├─ Skip/fixme/slow tests → core/annotations.md
|
||||
│ ├─ Test tags (@smoke, @fast) → core/test-tags.md
|
||||
│ ├─ Filtering tests (--grep) → core/test-tags.md
|
||||
│ ├─ Test steps → core/annotations.md
|
||||
│ └─ Conditional execution → core/annotations.md
|
||||
│
|
||||
└─ Running subset of tests?
|
||||
├─ By tag (@smoke, @critical) → core/test-tags.md
|
||||
├─ Exclude slow/flaky tests → core/test-tags.md
|
||||
├─ PR vs nightly tests → core/test-tags.md, infrastructure-ci-cd/ci-cd.md
|
||||
└─ Project-specific filtering → core/test-tags.md, core/configuration.md
|
||||
```
|
||||
|
||||
## Test Validation Loop
|
||||
|
||||
After writing or modifying tests:
|
||||
|
||||
1. **Run tests**: `npx playwright test --reporter=list`
|
||||
2. **If tests fail**:
|
||||
- Review error output and trace (`npx playwright show-trace`)
|
||||
- Fix locators, waits, or assertions
|
||||
- Re-run tests
|
||||
3. **Only proceed when all tests pass**
|
||||
4. **Run multiple times** for critical tests: `npx playwright test --repeat-each=5`
|
||||
@@ -0,0 +1,360 @@
|
||||
# Complex Authentication Flow Patterns
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Email Verification Flows](#email-verification-flows)
|
||||
2. [Password Reset](#password-reset)
|
||||
3. [Session Timeout](#session-timeout)
|
||||
4. [Remember Me Persistence](#remember-me-persistence)
|
||||
5. [Logout Patterns](#logout-patterns)
|
||||
6. [Tips](#tips)
|
||||
7. [Related](#related)
|
||||
|
||||
> **When to use**: Testing email verification, password reset, session timeout/expiration, or remember-me functionality. For basic auth setup (storage state, OAuth mocking, MFA, role-based access), see [authentication.md](authentication.md).
|
||||
|
||||
---
|
||||
|
||||
## Email Verification Flows
|
||||
|
||||
### Capturing Verification Tokens
|
||||
|
||||
Intercept API responses to capture verification tokens for testing:
|
||||
|
||||
```typescript
|
||||
test('completes registration with email verification', async ({ page }) => {
|
||||
let capturedToken = '';
|
||||
|
||||
await page.route('**/api/auth/register', async (route) => {
|
||||
const response = await route.fetch();
|
||||
const body = await response.json();
|
||||
capturedToken = body.verificationToken;
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
|
||||
await page.goto('/register');
|
||||
await page.getByLabel('Name').fill('New User');
|
||||
await page.getByLabel('Email').fill('newuser@test.com');
|
||||
await page.getByLabel('Password', { exact: true }).fill('SecurePass!');
|
||||
await page.getByLabel('Confirm password').fill('SecurePass!');
|
||||
await page.getByRole('button', { name: 'Create account' }).click();
|
||||
|
||||
await expect(page.getByText('Check your inbox')).toBeVisible();
|
||||
|
||||
expect(capturedToken).toBeTruthy();
|
||||
await page.goto(`/verify?token=${capturedToken}`);
|
||||
|
||||
await expect(page.getByText('Email confirmed')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Fully Mocked Verification
|
||||
|
||||
```typescript
|
||||
test('verifies email with mocked endpoints', async ({ page }) => {
|
||||
const mockToken = 'test-verification-abc123';
|
||||
|
||||
await page.route('**/api/auth/register', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ message: 'Verification sent', verificationToken: mockToken }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**/api/auth/verify?token=${mockToken}`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ verified: true }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/register');
|
||||
await page.getByLabel('Email').fill('test@example.com');
|
||||
await page.getByLabel('Password', { exact: true }).fill('Password123!');
|
||||
await page.getByRole('button', { name: 'Sign up' }).click();
|
||||
|
||||
await expect(page.getByText('Check your inbox')).toBeVisible();
|
||||
|
||||
await page.goto(`/verify?token=${mockToken}`);
|
||||
await expect(page.getByText('Email confirmed')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Password Reset
|
||||
|
||||
### Complete Reset Flow
|
||||
|
||||
```typescript
|
||||
test('resets password through email link', async ({ page }) => {
|
||||
let resetToken = '';
|
||||
|
||||
await page.route('**/api/auth/forgot-password', async (route) => {
|
||||
const response = await route.fetch();
|
||||
const body = await response.json();
|
||||
resetToken = body.resetToken;
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
|
||||
await page.goto('/forgot-password');
|
||||
await page.getByLabel('Email').fill('user@test.com');
|
||||
await page.getByRole('button', { name: 'Send link' }).click();
|
||||
|
||||
await expect(page.getByText('Reset email sent')).toBeVisible();
|
||||
|
||||
expect(resetToken).toBeTruthy();
|
||||
await page.goto(`/reset-password?token=${resetToken}`);
|
||||
|
||||
await page.getByLabel('New password', { exact: true }).fill('NewPassword456!');
|
||||
await page.getByLabel('Confirm password').fill('NewPassword456!');
|
||||
await page.getByRole('button', { name: 'Update password' }).click();
|
||||
|
||||
await expect(page.getByText('Password updated')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Expired Token Handling
|
||||
|
||||
```typescript
|
||||
test('shows error for expired reset token', async ({ page }) => {
|
||||
await page.goto('/reset-password?token=expired-token');
|
||||
|
||||
await page.getByLabel('New password', { exact: true }).fill('NewPass!');
|
||||
await page.getByLabel('Confirm password').fill('NewPass!');
|
||||
await page.getByRole('button', { name: 'Update password' }).click();
|
||||
|
||||
await expect(page.getByRole('alert')).toContainText(/expired|invalid/i);
|
||||
});
|
||||
```
|
||||
|
||||
### Password Strength Validation
|
||||
|
||||
```typescript
|
||||
test('enforces password requirements on reset', async ({ page }) => {
|
||||
await page.goto('/reset-password?token=valid-token');
|
||||
|
||||
await page.getByLabel('New password', { exact: true }).fill('weak');
|
||||
await page.getByLabel('Confirm password').fill('weak');
|
||||
await page.getByRole('button', { name: 'Update password' }).click();
|
||||
|
||||
await expect(page.getByText(/at least 8 characters/i)).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Session Timeout
|
||||
|
||||
### Detecting Expired Sessions
|
||||
|
||||
```typescript
|
||||
test('redirects to signin after session expires', async ({ page, context }) => {
|
||||
await page.goto('/signin');
|
||||
await page.getByLabel('Email').fill('user@test.com');
|
||||
await page.getByLabel('Password').fill('Password!');
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
await expect(page).toHaveURL('/home');
|
||||
|
||||
const cookies = await context.cookies();
|
||||
const sessionCookie = cookies.find((c) => c.name.includes('session'));
|
||||
|
||||
if (sessionCookie) {
|
||||
await context.clearCookies({ name: sessionCookie.name });
|
||||
}
|
||||
|
||||
await page.goto('/profile');
|
||||
await expect(page).toHaveURL(/\/signin/);
|
||||
await expect(page.getByText(/session.*expired|sign in again/i)).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Session Extension Warning
|
||||
|
||||
```typescript
|
||||
test('shows warning before session expires', async ({ page }) => {
|
||||
await page.route('**/api/auth/session', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ valid: true, expiresIn: 60 }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/home');
|
||||
|
||||
await expect(page.getByText(/session.*expir/i)).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByRole('button', { name: /extend|stay signed in/i })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Session Extension Action
|
||||
|
||||
```typescript
|
||||
test('extends session when user clicks extend', async ({ page }) => {
|
||||
let sessionExtended = false;
|
||||
|
||||
await page.route('**/api/auth/session', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ valid: true, expiresIn: 60 }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/auth/refresh', async (route) => {
|
||||
sessionExtended = true;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ valid: true, expiresIn: 3600 }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/home');
|
||||
|
||||
await expect(page.getByRole('button', { name: /extend|stay signed in/i })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await page.getByRole('button', { name: /extend|stay signed in/i }).click();
|
||||
|
||||
expect(sessionExtended).toBe(true);
|
||||
await expect(page.getByText(/session.*expir/i)).not.toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Remember Me Persistence
|
||||
|
||||
### Persistent Session
|
||||
|
||||
```typescript
|
||||
test('persists session with remember me enabled', async ({ browser }) => {
|
||||
const ctx1 = await browser.newContext();
|
||||
const page1 = await ctx1.newPage();
|
||||
|
||||
await page1.goto('/signin');
|
||||
await page1.getByLabel('Email').fill('user@test.com');
|
||||
await page1.getByLabel('Password').fill('Password!');
|
||||
await page1.getByLabel('Keep me signed in').check();
|
||||
await page1.getByRole('button', { name: 'Sign in' }).click();
|
||||
|
||||
await expect(page1).toHaveURL('/home');
|
||||
|
||||
const state = await ctx1.storageState();
|
||||
await ctx1.close();
|
||||
|
||||
const ctx2 = await browser.newContext({ storageState: state });
|
||||
const page2 = await ctx2.newPage();
|
||||
|
||||
await page2.goto('/home');
|
||||
await expect(page2).toHaveURL('/home');
|
||||
await expect(page2.getByText('Welcome')).toBeVisible();
|
||||
|
||||
await ctx2.close();
|
||||
});
|
||||
```
|
||||
|
||||
### Session-Only Login
|
||||
|
||||
```typescript
|
||||
test('session-only login does not persist across browser restarts', async ({ browser }) => {
|
||||
const ctx1 = await browser.newContext();
|
||||
const page1 = await ctx1.newPage();
|
||||
|
||||
await page1.goto('/signin');
|
||||
await page1.getByLabel('Email').fill('user@test.com');
|
||||
await page1.getByLabel('Password').fill('Password!');
|
||||
// Leave "Remember me" unchecked
|
||||
await expect(page1.getByLabel('Keep me signed in')).not.toBeChecked();
|
||||
await page1.getByRole('button', { name: 'Sign in' }).click();
|
||||
|
||||
await expect(page1).toHaveURL('/home');
|
||||
|
||||
// Only keep persistent cookies (filter out session cookies)
|
||||
const cookies = await ctx1.cookies();
|
||||
await ctx1.close();
|
||||
|
||||
const persistentCookies = cookies.filter((c) => c.expires > 0);
|
||||
const ctx2 = await browser.newContext();
|
||||
await ctx2.addCookies(persistentCookies);
|
||||
const page2 = await ctx2.newPage();
|
||||
|
||||
await page2.goto('/home');
|
||||
|
||||
// Should redirect to login since session was not persisted
|
||||
await expect(page2).toHaveURL(/\/signin/);
|
||||
|
||||
await ctx2.close();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Logout Patterns
|
||||
|
||||
### Standard Logout with Session Cleanup
|
||||
|
||||
```typescript
|
||||
test.use({ storageState: '.auth/user.json' });
|
||||
|
||||
test('logs out and clears session', async ({ page, context }) => {
|
||||
await page.goto('/home');
|
||||
|
||||
await page.getByRole('button', { name: /account|menu/i }).click();
|
||||
await page.getByRole('menuitem', { name: 'Sign out' }).click();
|
||||
|
||||
await expect(page).toHaveURL('/signin');
|
||||
|
||||
const cookies = await context.cookies();
|
||||
const sessionCookies = cookies.filter((c) => c.name.includes('session') || c.name.includes('token'));
|
||||
expect(sessionCookies).toHaveLength(0);
|
||||
|
||||
await page.goto('/home');
|
||||
await expect(page).toHaveURL(/\/signin/);
|
||||
});
|
||||
```
|
||||
|
||||
### Logout from All Devices
|
||||
|
||||
```typescript
|
||||
test('logs out from all devices', async ({ page }) => {
|
||||
let logoutAllCalled = false;
|
||||
|
||||
await page.route('**/api/auth/logout-all', async (route) => {
|
||||
logoutAllCalled = true;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ message: 'Logged out everywhere' }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/settings/security');
|
||||
|
||||
await page.getByRole('button', { name: 'Sign out everywhere' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click();
|
||||
|
||||
expect(logoutAllCalled).toBe(true);
|
||||
await expect(page).toHaveURL(/\/signin/);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
1. **Configure shorter session timeouts in test environments** — Enables testing timeout behavior without slow tests
|
||||
2. **Test token expiration edge cases** — Expired tokens, invalid tokens, already-used tokens
|
||||
3. **Verify cleanup on logout** — Check both cookies and localStorage are cleared
|
||||
4. **Test the full flow end-to-end** — Password reset should verify login with new password works
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [authentication.md](authentication.md) — Storage state, OAuth mocking, MFA, role-based access, API login
|
||||
- [fixtures-hooks.md](../core/fixtures-hooks.md) — Creating auth fixtures
|
||||
- [third-party.md](./third-party.md) — Mocking external auth providers
|
||||
@@ -0,0 +1,871 @@
|
||||
# Authentication Testing
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Quick Reference](#quick-reference)
|
||||
2. [Patterns](#patterns)
|
||||
3. [Decision Guide](#decision-guide)
|
||||
4. [Anti-Patterns](#anti-patterns)
|
||||
5. [Troubleshooting](#troubleshooting)
|
||||
6. [Related](#related)
|
||||
|
||||
> **When to use**: Apps with login, session management, or protected routes. Authentication is the most common source of slow test suites.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```typescript
|
||||
// Storage state reuse — the #1 pattern for fast auth
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Username").fill("testuser@example.com");
|
||||
await page.getByLabel("Password").fill("secretPass123");
|
||||
await page.getByRole("button", { name: "Log in" }).click();
|
||||
await page.context().storageState({ path: ".auth/session.json" });
|
||||
|
||||
// Reuse in config — every test starts authenticated
|
||||
{
|
||||
use: {
|
||||
storageState: ".auth/session.json"
|
||||
}
|
||||
}
|
||||
|
||||
// API login — skip the UI entirely
|
||||
const context = await browser.newContext();
|
||||
const response = await context.request.post("/api/auth/login", {
|
||||
data: { email: "testuser@example.com", password: "secretPass123" },
|
||||
});
|
||||
await context.storageState({ path: ".auth/session.json" });
|
||||
```
|
||||
|
||||
## Patterns
|
||||
|
||||
### Storage State Reuse
|
||||
|
||||
**Use when**: You need authenticated tests and want to avoid logging in before every test.
|
||||
**Avoid when**: Tests require completely fresh sessions, or you are testing the login flow itself.
|
||||
|
||||
`storageState` serializes cookies and localStorage to a JSON file. Load it in any browser context to start authenticated instantly.
|
||||
|
||||
```typescript
|
||||
// scripts/generate-auth.ts — run once to generate the state file
|
||||
import { chromium } from "@playwright/test";
|
||||
|
||||
async function generateAuthState() {
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto("http://localhost:4000/login");
|
||||
await page.getByLabel("Username").fill("testuser@example.com");
|
||||
await page.getByLabel("Password").fill("secretPass123");
|
||||
await page.getByRole("button", { name: "Log in" }).click();
|
||||
await page.waitForURL("/home");
|
||||
|
||||
await context.storageState({ path: ".auth/session.json" });
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
generateAuthState();
|
||||
```
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts — load saved state for all tests
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
use: {
|
||||
baseURL: "http://localhost:4000",
|
||||
storageState: ".auth/session.json",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/home.spec.ts — test starts already logged in
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("authenticated user sees home page", async ({ page }) => {
|
||||
await page.goto("/home");
|
||||
await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Global Setup Authentication
|
||||
|
||||
**Use when**: You want to authenticate once before the entire test suite runs.
|
||||
**Avoid when**: Different tests need different users, or your tokens expire faster than your suite runs.
|
||||
|
||||
```typescript
|
||||
// global-setup.ts
|
||||
import { chromium, type FullConfig } from "@playwright/test";
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
const { baseURL } = config.projects[0].use;
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`${baseURL}/login`);
|
||||
await page.getByLabel("Username").fill(process.env.TEST_USER_EMAIL!);
|
||||
await page.getByLabel("Password").fill(process.env.TEST_USER_PASSWORD!);
|
||||
await page.getByRole("button", { name: "Log in" }).click();
|
||||
await page.waitForURL("**/home");
|
||||
|
||||
await context.storageState({ path: ".auth/session.json" });
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
```
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
globalSetup: require.resolve("./global-setup"),
|
||||
use: {
|
||||
baseURL: "http://localhost:4000",
|
||||
storageState: ".auth/session.json",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Add `.auth/` to `.gitignore`. Auth state files contain session tokens and should never be committed.
|
||||
|
||||
### Per-Worker Authentication
|
||||
|
||||
**Use when**: Each parallel worker needs its own authenticated session to avoid race conditions for tests that modify server-side state.
|
||||
**Avoid when**: Tests are read-only and a modifying shared session is safe, you can use a single shared account.
|
||||
|
||||
> **Sharded runs**: `parallelIndex` resets per shard, so different shards can have workers with the same index. To avoid collisions, include the shard identifier in the username (e.g., `worker-${SHARD_INDEX}-${parallelIndex}@example.com`) by passing a `SHARD_INDEX` environment variable from your CI matrix.
|
||||
|
||||
```typescript
|
||||
// fixtures/auth.ts
|
||||
import { test as base, type BrowserContext } from "@playwright/test";
|
||||
|
||||
type AuthFixtures = {
|
||||
authenticatedContext: BrowserContext;
|
||||
};
|
||||
|
||||
export const test = base.extend<{}, AuthFixtures>({
|
||||
authenticatedContext: [
|
||||
async ({ browser }, use) => {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto("/login");
|
||||
await page
|
||||
.getByLabel("Username")
|
||||
.fill(`worker-${test.info().parallelIndex}@example.com`);
|
||||
await page.getByLabel("Password").fill("secretPass123");
|
||||
await page.getByRole("button", { name: "Log in" }).click();
|
||||
await page.waitForURL("/home");
|
||||
await page.close();
|
||||
|
||||
await use(context);
|
||||
await context.close();
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
});
|
||||
|
||||
export { expect } from "@playwright/test";
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/settings.spec.ts
|
||||
import { test, expect } from "../fixtures/auth";
|
||||
|
||||
test("update display name", async ({ authenticatedContext }) => {
|
||||
const page = await authenticatedContext.newPage();
|
||||
await page.goto("/settings/profile");
|
||||
await page.getByLabel("Display name").fill("Updated Name");
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
await expect(page.getByText("Profile saved")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Multiple Roles
|
||||
|
||||
**Use when**: Your app has role-based access control and you need to test different permission levels.
|
||||
**Avoid when**: Your app has a single user role.
|
||||
|
||||
```typescript
|
||||
// global-setup.ts — authenticate all roles
|
||||
import { chromium, type FullConfig } from "@playwright/test";
|
||||
|
||||
const accounts = [
|
||||
{
|
||||
role: "admin",
|
||||
email: "admin@example.com",
|
||||
password: process.env.ADMIN_PASSWORD!,
|
||||
},
|
||||
{
|
||||
role: "member",
|
||||
email: "member@example.com",
|
||||
password: process.env.MEMBER_PASSWORD!,
|
||||
},
|
||||
{
|
||||
role: "guest",
|
||||
email: "guest@example.com",
|
||||
password: process.env.GUEST_PASSWORD!,
|
||||
},
|
||||
];
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
const { baseURL } = config.projects[0].use;
|
||||
|
||||
for (const { role, email, password } of accounts) {
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`${baseURL}/login`);
|
||||
await page.getByLabel("Username").fill(email);
|
||||
await page.getByLabel("Password").fill(password);
|
||||
await page.getByRole("button", { name: "Log in" }).click();
|
||||
await page.waitForURL("**/home");
|
||||
|
||||
await context.storageState({ path: `.auth/${role}.json` });
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
```
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts — one project per role
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
globalSetup: require.resolve("./global-setup"),
|
||||
projects: [
|
||||
{
|
||||
name: "admin",
|
||||
use: { storageState: ".auth/admin.json" },
|
||||
testMatch: "**/*.admin.spec.ts",
|
||||
},
|
||||
{
|
||||
name: "member",
|
||||
use: { storageState: ".auth/member.json" },
|
||||
testMatch: "**/*.member.spec.ts",
|
||||
},
|
||||
{
|
||||
name: "guest",
|
||||
use: { storageState: ".auth/guest.json" },
|
||||
testMatch: "**/*.guest.spec.ts",
|
||||
},
|
||||
{
|
||||
name: "anonymous",
|
||||
use: { storageState: { cookies: [], origins: [] } },
|
||||
testMatch: "**/*.anon.spec.ts",
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/admin-panel.admin.spec.ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("admin can access user management", async ({ page }) => {
|
||||
await page.goto("/admin/users");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "User Management" })
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Remove user" })).toBeEnabled();
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/admin-panel.guest.spec.ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("guest cannot access admin panel", async ({ page }) => {
|
||||
await page.goto("/admin/users");
|
||||
await expect(page.getByText("Access denied")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
**Alternative**: Use a fixture that accepts a role parameter when you need role switching within a single spec file.
|
||||
|
||||
```typescript
|
||||
// fixtures/auth.ts — role-based fixture
|
||||
import { test as base, type Page } from "@playwright/test";
|
||||
import fs from "fs";
|
||||
|
||||
type RoleFixtures = {
|
||||
loginAs: (role: "admin" | "member" | "guest") => Promise<Page>;
|
||||
};
|
||||
|
||||
export const test = base.extend<RoleFixtures>({
|
||||
loginAs: async ({ browser }, use) => {
|
||||
const pages: Page[] = [];
|
||||
|
||||
await use(async (role) => {
|
||||
const statePath = `.auth/${role}.json`;
|
||||
if (!fs.existsSync(statePath)) {
|
||||
throw new Error(
|
||||
`Auth state for role "${role}" not found at ${statePath}`
|
||||
);
|
||||
}
|
||||
const context = await browser.newContext({ storageState: statePath });
|
||||
const page = await context.newPage();
|
||||
pages.push(page);
|
||||
return page;
|
||||
});
|
||||
|
||||
for (const page of pages) {
|
||||
await page.context().close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from "@playwright/test";
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/role-comparison.spec.ts
|
||||
import { test, expect } from "../fixtures/auth";
|
||||
|
||||
test("admin sees remove button, guest does not", async ({ loginAs }) => {
|
||||
const adminPage = await loginAs("admin");
|
||||
await adminPage.goto("/admin/users");
|
||||
await expect(
|
||||
adminPage.getByRole("button", { name: "Remove user" })
|
||||
).toBeVisible();
|
||||
|
||||
const guestPage = await loginAs("guest");
|
||||
await guestPage.goto("/admin/users");
|
||||
await expect(guestPage.getByText("Access denied")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### OAuth/SSO Mocking
|
||||
|
||||
**Use when**: Your app authenticates via a third-party OAuth provider and you cannot hit the real provider in tests.
|
||||
**Avoid when**: You have a dedicated test tenant on the OAuth provider.
|
||||
|
||||
A typical OAuth flow works like this:
|
||||
|
||||
1. User clicks "Sign in with Provider" → browser navigates to `https://accounts.provider.com/authorize?...`
|
||||
2. User authenticates on the provider's page → provider redirects back to your app's **callback route** (e.g. `http://localhost:4000/auth/callback?code=ABC&state=XYZ`)
|
||||
3. Your backend exchanges the `code` for an access token, creates a session, and redirects the user to a logged-in page
|
||||
|
||||
In tests you can short-circuit step 2 with `page.route()`: intercept the outbound request to the provider and respond with a `302` redirect straight to your callback route, supplying a mock `code` and `state`. Your backend still executes its normal callback handler — the only part that's mocked is the provider's authorization page.
|
||||
|
||||
For cases where you want to skip the browser redirect entirely, a second approach calls a **test-only API endpoint** that creates the session server-side and returns the session cookie directly.
|
||||
|
||||
```typescript
|
||||
// tests/oauth-login.spec.ts — mock the callback route
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("login via mocked OAuth flow", async ({ page }) => {
|
||||
await page.route("https://accounts.provider.com/**", async (route) => {
|
||||
const callbackUrl = new URL("http://localhost:4000/auth/callback");
|
||||
callbackUrl.searchParams.set("code", "mock-auth-code-xyz");
|
||||
callbackUrl.searchParams.set("state", "expected-state-value");
|
||||
await route.fulfill({
|
||||
status: 302,
|
||||
headers: { location: callbackUrl.toString() },
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Sign in with Provider" }).click();
|
||||
|
||||
await page.waitForURL("/home");
|
||||
await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/oauth-login.spec.ts — API-based session injection
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("bypass OAuth entirely via API session injection", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Call a test-only endpoint that creates a session without OAuth
|
||||
const response = await page.request.post("/api/test/create-session", {
|
||||
data: {
|
||||
email: "oauth-user@example.com",
|
||||
provider: "provider",
|
||||
role: "member",
|
||||
},
|
||||
});
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
await page.context().storageState({ path: ".auth/oauth-user.json" });
|
||||
await page.goto("/home");
|
||||
await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
**Backend requirement**: Your backend must expose a test-only session creation endpoint (guarded by `NODE_ENV=test`) or accept a known test OAuth code.
|
||||
|
||||
### MFA Handling
|
||||
|
||||
**Use when**: Your app requires two-factor authentication (TOTP, SMS, email codes).
|
||||
**Avoid when**: MFA is optional and you can disable it for test accounts.
|
||||
|
||||
**Strategy 1**: Generate real TOTP codes from a shared secret.
|
||||
|
||||
```typescript
|
||||
// helpers/totp.ts
|
||||
import * as OTPAuth from "otpauth";
|
||||
|
||||
export function generateTOTP(secret: string): string {
|
||||
const totp = new OTPAuth.TOTP({
|
||||
secret: OTPAuth.Secret.fromBase32(secret),
|
||||
digits: 6,
|
||||
period: 30,
|
||||
algorithm: "SHA1",
|
||||
});
|
||||
return totp.generate();
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/mfa-login.spec.ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { generateTOTP } from "../helpers/totp";
|
||||
|
||||
test("login with TOTP two-factor auth", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Username").fill("mfa-user@example.com");
|
||||
await page.getByLabel("Password").fill("secretPass123");
|
||||
await page.getByRole("button", { name: "Log in" }).click();
|
||||
|
||||
await expect(page.getByText("Enter your authentication code")).toBeVisible();
|
||||
|
||||
const code = generateTOTP(process.env.MFA_TOTP_SECRET!);
|
||||
await page.getByLabel("Authentication code").fill(code);
|
||||
await page.getByRole("button", { name: "Verify" }).click();
|
||||
|
||||
await page.waitForURL("/home");
|
||||
await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
**Strategy 2**: Mock MFA at the backend level. Have your backend accept a known bypass code (e.g., `000000`) when `NODE_ENV=test`.
|
||||
|
||||
**Strategy 3**: Disable MFA for test accounts at the infrastructure level.
|
||||
|
||||
### Session Refresh
|
||||
|
||||
**Use when**: Your tokens expire during long test runs.
|
||||
**Avoid when**: Your test suite runs quickly and tokens outlast the entire run.
|
||||
|
||||
```typescript
|
||||
// fixtures/auth-with-refresh.ts
|
||||
import { test as base, type BrowserContext } from "@playwright/test";
|
||||
import fs from "fs";
|
||||
|
||||
type AuthFixtures = {
|
||||
authenticatedPage: import("@playwright/test").Page;
|
||||
};
|
||||
|
||||
export const test = base.extend<AuthFixtures>({
|
||||
authenticatedPage: async ({ browser }, use) => {
|
||||
const statePath = ".auth/session.json";
|
||||
|
||||
let context: BrowserContext;
|
||||
if (fs.existsSync(statePath)) {
|
||||
context = await browser.newContext({ storageState: statePath });
|
||||
const page = await context.newPage();
|
||||
|
||||
const response = await page.request.get("/api/auth/me");
|
||||
if (response.ok()) {
|
||||
await use(page);
|
||||
await context.close();
|
||||
return;
|
||||
}
|
||||
await context.close();
|
||||
}
|
||||
|
||||
context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Username").fill(process.env.TEST_USER_EMAIL!);
|
||||
await page.getByLabel("Password").fill(process.env.TEST_USER_PASSWORD!);
|
||||
await page.getByRole("button", { name: "Log in" }).click();
|
||||
await page.waitForURL("/home");
|
||||
|
||||
await context.storageState({ path: statePath });
|
||||
|
||||
await use(page);
|
||||
await context.close();
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from "@playwright/test";
|
||||
```
|
||||
|
||||
### Login Page Object
|
||||
|
||||
**Use when**: Multiple test files need to log in and you want consistent, maintainable login logic.
|
||||
**Avoid when**: You use `storageState` everywhere and never navigate through the login UI in tests.
|
||||
|
||||
```typescript
|
||||
// page-objects/LoginPage.ts
|
||||
import { type Page, type Locator, expect } from "@playwright/test";
|
||||
|
||||
export class LoginPage {
|
||||
readonly page: Page;
|
||||
readonly usernameInput: Locator;
|
||||
readonly passwordInput: Locator;
|
||||
readonly loginButton: Locator;
|
||||
readonly errorMessage: Locator;
|
||||
readonly forgotPasswordLink: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.usernameInput = page.getByLabel("Username");
|
||||
this.passwordInput = page.getByLabel("Password");
|
||||
this.loginButton = page.getByRole("button", { name: "Log in" });
|
||||
this.errorMessage = page.getByRole("alert");
|
||||
this.forgotPasswordLink = page.getByRole("link", {
|
||||
name: "Forgot password",
|
||||
});
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto("/login");
|
||||
await expect(this.loginButton).toBeVisible();
|
||||
}
|
||||
|
||||
async login(username: string, password: string) {
|
||||
await this.usernameInput.fill(username);
|
||||
await this.passwordInput.fill(password);
|
||||
await this.loginButton.click();
|
||||
}
|
||||
|
||||
async loginAndWaitForHome(username: string, password: string) {
|
||||
await this.login(username, password);
|
||||
await this.page.waitForURL("/home");
|
||||
}
|
||||
|
||||
async expectError(message: string | RegExp) {
|
||||
await expect(this.errorMessage).toContainText(message);
|
||||
}
|
||||
|
||||
async expectFieldError(field: "username" | "password", message: string) {
|
||||
const input =
|
||||
field === "username" ? this.usernameInput : this.passwordInput;
|
||||
await expect(input).toHaveAttribute("aria-invalid", "true");
|
||||
const errorId = await input.getAttribute("aria-describedby");
|
||||
if (errorId) {
|
||||
await expect(this.page.locator(`#${errorId}`)).toContainText(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/login.spec.ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { LoginPage } from "../page-objects/LoginPage";
|
||||
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test.describe("login page", () => {
|
||||
let loginPage: LoginPage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
loginPage = new LoginPage(page);
|
||||
await loginPage.goto();
|
||||
});
|
||||
|
||||
test("successful login redirects to home", async ({ page }) => {
|
||||
await loginPage.loginAndWaitForHome(
|
||||
"testuser@example.com",
|
||||
"secretPass123"
|
||||
);
|
||||
await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("wrong password shows error", async () => {
|
||||
await loginPage.login("testuser@example.com", "wrong-password");
|
||||
await loginPage.expectError("Invalid username or password");
|
||||
});
|
||||
|
||||
test("empty fields show validation errors", async () => {
|
||||
await loginPage.loginButton.click();
|
||||
await loginPage.expectFieldError("username", "Username is required");
|
||||
});
|
||||
|
||||
test("forgot password link navigates correctly", async ({ page }) => {
|
||||
await loginPage.forgotPasswordLink.click();
|
||||
await page.waitForURL("/forgot-password");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Reset password" })
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### API-Based Login
|
||||
|
||||
**Use when**: You want the fastest possible authentication without any browser interaction.
|
||||
**Avoid when**: You are specifically testing the login UI.
|
||||
|
||||
API login is typically 5-10x faster than UI login.
|
||||
|
||||
```typescript
|
||||
// global-setup.ts — API-based login (fastest)
|
||||
import { request, type FullConfig } from "@playwright/test";
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
const { baseURL } = config.projects[0].use;
|
||||
|
||||
const requestContext = await request.newContext({ baseURL });
|
||||
|
||||
const response = await requestContext.post("/api/auth/login", {
|
||||
data: {
|
||||
email: process.env.TEST_USER_EMAIL!,
|
||||
password: process.env.TEST_USER_PASSWORD!,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(
|
||||
`API login failed: ${response.status()} ${await response.text()}`
|
||||
);
|
||||
}
|
||||
|
||||
await requestContext.storageState({ path: ".auth/session.json" });
|
||||
await requestContext.dispose();
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
```
|
||||
|
||||
```typescript
|
||||
// fixtures/api-auth.ts — fixture version for per-test authentication
|
||||
import { test as base } from "@playwright/test";
|
||||
|
||||
export const test = base.extend({
|
||||
authenticatedPage: async ({ browser, playwright }, use) => {
|
||||
const apiContext = await playwright.request.newContext({
|
||||
baseURL: "http://localhost:4000",
|
||||
});
|
||||
|
||||
await apiContext.post("/api/auth/login", {
|
||||
data: {
|
||||
email: "testuser@example.com",
|
||||
password: "secretPass123",
|
||||
},
|
||||
});
|
||||
|
||||
const state = await apiContext.storageState();
|
||||
const context = await browser.newContext({ storageState: state });
|
||||
const page = await context.newPage();
|
||||
|
||||
await use(page);
|
||||
|
||||
await context.close();
|
||||
await apiContext.dispose();
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from "@playwright/test";
|
||||
```
|
||||
|
||||
### Unauthenticated Tests
|
||||
|
||||
**Use when**: Testing the login page, signup flow, password reset, public pages, or redirect behavior for unauthenticated users.
|
||||
**Avoid when**: The test requires a logged-in user.
|
||||
|
||||
When your config sets a default `storageState`, you must explicitly clear it for unauthenticated tests.
|
||||
|
||||
```typescript
|
||||
// tests/public-pages.spec.ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test.describe("unauthenticated access", () => {
|
||||
test("homepage is accessible without login", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Log in" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("protected route redirects to login", async ({ page }) => {
|
||||
await page.goto("/home");
|
||||
await page.waitForURL("**/login**");
|
||||
expect(page.url()).toContain("redirect=%2Fhome");
|
||||
});
|
||||
|
||||
test("expired session shows re-login prompt", async ({ page, context }) => {
|
||||
await page.goto("/home");
|
||||
await context.clearCookies();
|
||||
|
||||
await page.goto("/settings");
|
||||
await page.waitForURL("**/login**");
|
||||
await expect(page.getByText("Your session has expired")).toBeVisible();
|
||||
});
|
||||
|
||||
test("signup flow creates account", async ({ page }) => {
|
||||
await page.goto("/signup");
|
||||
await page.getByLabel("Name").fill("New User");
|
||||
await page.getByLabel("Email").fill(`test-${Date.now()}@example.com`);
|
||||
await page.getByLabel("Password", { exact: true }).fill("secretPass123");
|
||||
await page.getByLabel("Confirm password").fill("secretPass123");
|
||||
await page.getByRole("button", { name: "Create account" }).click();
|
||||
|
||||
await page.waitForURL("/onboarding");
|
||||
await expect(page.getByText("Welcome, New User")).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Decision Guide
|
||||
|
||||
| Scenario | Approach | Speed | Isolation | When to Choose |
|
||||
| -------------------------------- | ------------------------------ | -------- | -------------- | -------------------------------------------------------------- |
|
||||
| Most tests need auth | Global setup + `storageState` | Fastest | Shared session | Default for nearly every project |
|
||||
| Tests modify user state | Per-worker fixture | Fast | Per worker | Tests update profile, change settings, or mutate data |
|
||||
| Multiple user roles | Per-project `storageState` | Fastest | Per role | App has admin/member/guest roles |
|
||||
| Testing the login page | No `storageState` | N/A | Full | Use `test.use({ storageState: { cookies: [], origins: [] } })` |
|
||||
| OAuth/SSO provider | Mock the callback | Fast | Per test | Never hit real OAuth providers in CI |
|
||||
| MFA is required | TOTP generation or bypass | Moderate | Per test | Generate real TOTP codes or use a test-mode bypass |
|
||||
| Token expires mid-suite | Session refresh fixture | Fast | Per check | Fixture validates the session before use |
|
||||
| Single test needs different user | `loginAs(role)` fixture | Moderate | Per call | Rare: prefer per-project roles |
|
||||
| API-first app (no login UI) | API login via `request.post()` | Fastest | Per test | No browser needed for auth |
|
||||
|
||||
### UI Login vs API Login vs Storage State
|
||||
|
||||
```text
|
||||
Need to test the login page itself?
|
||||
├── Yes → UI login with LoginPage POM, no storageState
|
||||
└── No → Do you have a login API endpoint?
|
||||
├── Yes → API login in global setup, save storageState (fastest)
|
||||
└── No → UI login in global setup, save storageState
|
||||
└── Tokens expire quickly?
|
||||
├── Yes → Add session refresh fixture
|
||||
└── No → Standard storageState reuse is fine
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Don't Do This | Problem | Do This Instead |
|
||||
| ------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| Log in via UI before every test | Adds 2-5 seconds per test | Use `storageState` to skip login entirely |
|
||||
| Share a single auth state file across parallel workers that mutate state | Race conditions | Use per-worker fixtures with `{ scope: 'worker' }` |
|
||||
| Hardcode credentials in test files | Security risk | Use environment variables and `.env` files |
|
||||
| Ignore token expiration | Tests fail intermittently with 401 errors | Add a session validity check in your auth fixture |
|
||||
| Hit real OAuth providers in CI | Flaky: rate limits, CAPTCHA, network issues | Mock the OAuth callback or use API session injection |
|
||||
| Use `page.waitForTimeout(2000)` after login | Arbitrary delay | `await page.waitForURL('/home')` or `await expect(heading).toBeVisible()` |
|
||||
| Store `.auth/*.json` files in git | Tokens in version control | Add `.auth/` to `.gitignore` |
|
||||
| Create one "god" test account with all permissions | Cannot test role-based access control | Create separate accounts per role |
|
||||
| Use `browser.newContext()` without `storageState` for authenticated tests | Every context starts unauthenticated | Pass `storageState` when creating the context |
|
||||
| Test MFA by disabling it everywhere | You never test the MFA flow | Use TOTP generation for at least one test |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Global setup fails with "Target page, context or browser has been closed"
|
||||
|
||||
**Cause**: The login page redirected unexpectedly, or the browser closed before `storageState()` was called.
|
||||
|
||||
**Fix**:
|
||||
|
||||
- Add `await page.waitForURL()` after the login action
|
||||
- Check that `baseURL` in your config matches the actual server URL and protocol
|
||||
- Add error handling to global setup:
|
||||
|
||||
```typescript
|
||||
const response = await page.waitForResponse("**/api/auth/**");
|
||||
if (!response.ok()) {
|
||||
throw new Error(
|
||||
`Login failed in global setup: ${response.status()} ${await response.text()}`
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Tests fail with 401 Unauthorized after running for a while
|
||||
|
||||
**Cause**: The session token saved in `storageState` has expired.
|
||||
|
||||
**Fix**:
|
||||
|
||||
- Use the session refresh fixture pattern
|
||||
- Increase token expiry in test environment configuration
|
||||
- Switch to API-based login in a worker-scoped fixture
|
||||
|
||||
### `storageState` file is empty or contains no cookies
|
||||
|
||||
**Cause**: `storageState()` was called before the login response set cookies.
|
||||
|
||||
**Fix**:
|
||||
|
||||
- Wait for the post-login page to load: `await page.waitForURL('/home')`
|
||||
- Verify cookies exist before saving:
|
||||
|
||||
```typescript
|
||||
const cookies = await context.cookies();
|
||||
if (cookies.length === 0) {
|
||||
throw new Error("No cookies found after login");
|
||||
}
|
||||
await context.storageState({ path: ".auth/session.json" });
|
||||
```
|
||||
|
||||
### Different browsers get different cookies
|
||||
|
||||
**Cause**: Some auth flows set cookies with `SameSite=Strict` or use browser-specific cookie behavior.
|
||||
|
||||
**Fix**:
|
||||
|
||||
- Generate separate auth state files per browser project
|
||||
- Check if your auth uses `SameSite=None; Secure` cookies that require HTTPS:
|
||||
|
||||
```typescript
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'], storageState: '.auth/chromium-session.json' },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'], storageState: '.auth/firefox-session.json' },
|
||||
},
|
||||
],
|
||||
```
|
||||
|
||||
### Parallel tests interfere with each other's sessions
|
||||
|
||||
**Cause**: Multiple workers share the same test account and one worker's actions affect others.
|
||||
|
||||
**Fix**:
|
||||
|
||||
- Use per-worker test accounts: `worker-${test.info().parallelIndex}@example.com`
|
||||
- Use the per-worker authentication fixture pattern
|
||||
- Make tests idempotent
|
||||
|
||||
### OAuth mock does not work — still redirects to real provider
|
||||
|
||||
**Cause**: `page.route()` was registered after the navigation that triggers the OAuth redirect.
|
||||
|
||||
**Fix**:
|
||||
|
||||
- Register route handlers before any navigation: call `page.route()` before `page.goto()`
|
||||
- Log the actual redirect URL to verify the pattern:
|
||||
|
||||
```typescript
|
||||
page.on("request", (req) => {
|
||||
if (req.url().includes("oauth") || req.url().includes("accounts.provider")) {
|
||||
console.log("OAuth request:", req.url());
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [fixtures-hooks.md](../core/fixtures-hooks.md) — custom fixtures for auth setup and teardown
|
||||
- [configuration.md](../core/configuration.md) — `storageState`, projects, and global setup configuration
|
||||
- [global-setup.md](../core/global-setup.md) — global setup patterns and project dependencies
|
||||
- [network-advanced.md](network-advanced.md) — route interception patterns used in OAuth mocking
|
||||
- [api-testing.md](../testing-patterns/api-testing.md) — API request context used in API-based login
|
||||
- [flaky-tests.md](../debugging/flaky-tests.md) — diagnosing auth-related flakiness
|
||||
@@ -0,0 +1,364 @@
|
||||
# Date, Time & Clock Mocking
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Clock API Basics](#clock-api-basics)
|
||||
2. [Fixed Time Testing](#fixed-time-testing)
|
||||
3. [Time Advancement](#time-advancement)
|
||||
4. [Timezone Testing](#timezone-testing)
|
||||
5. [Timer Mocking](#timer-mocking)
|
||||
|
||||
## Clock API Basics
|
||||
|
||||
### Install Clock
|
||||
|
||||
```typescript
|
||||
test("mock current time", async ({ page }) => {
|
||||
// Install clock before navigating
|
||||
await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
|
||||
|
||||
await page.goto("/dashboard");
|
||||
|
||||
// Page sees January 15, 2025 as current date
|
||||
await expect(page.getByText("January 15, 2025")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Clock with Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/clock.fixture.ts
|
||||
import { test as base } from "@playwright/test";
|
||||
|
||||
type ClockFixtures = {
|
||||
mockTime: (date: Date | string) => Promise<void>;
|
||||
};
|
||||
|
||||
export const test = base.extend<ClockFixtures>({
|
||||
mockTime: async ({ page }, use) => {
|
||||
await use(async (date) => {
|
||||
const time = typeof date === "string" ? new Date(date) : date;
|
||||
await page.clock.install({ time });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
test("subscription expiry", async ({ page, mockTime }) => {
|
||||
await mockTime("2025-12-31T23:59:00");
|
||||
await page.goto("/subscription");
|
||||
|
||||
await expect(page.getByText("Expires today")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Fixed Time Testing
|
||||
|
||||
### Test Date-Dependent Features
|
||||
|
||||
```typescript
|
||||
test("show holiday banner in December", async ({ page }) => {
|
||||
await page.clock.install({ time: new Date("2025-12-20T10:00:00") });
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByRole("banner", { name: /holiday/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test("no holiday banner in January", async ({ page }) => {
|
||||
await page.clock.install({ time: new Date("2025-01-15T10:00:00") });
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByRole("banner", { name: /holiday/i })).toBeHidden();
|
||||
});
|
||||
```
|
||||
|
||||
### Test Relative Time Display
|
||||
|
||||
```typescript
|
||||
test("shows relative time correctly", async ({ page }) => {
|
||||
// Fix time to control "posted 2 hours ago" text
|
||||
await page.clock.install({ time: new Date("2025-06-15T14:00:00") });
|
||||
|
||||
// Mock API to return post with known timestamp
|
||||
await page.route("**/api/posts/1", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
id: 1,
|
||||
title: "Test Post",
|
||||
createdAt: "2025-06-15T12:00:00Z", // 2 hours before mock time
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/posts/1");
|
||||
|
||||
await expect(page.getByText("2 hours ago")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Test Date Boundaries
|
||||
|
||||
```typescript
|
||||
test.describe("end of month billing", () => {
|
||||
test("shows billing on last day of month", async ({ page }) => {
|
||||
await page.clock.install({ time: new Date("2025-01-31T10:00:00") });
|
||||
await page.goto("/billing");
|
||||
|
||||
await expect(page.getByText("Payment due today")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows days remaining mid-month", async ({ page }) => {
|
||||
await page.clock.install({ time: new Date("2025-01-15T10:00:00") });
|
||||
await page.goto("/billing");
|
||||
|
||||
await expect(page.getByText("16 days until payment")).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Time Advancement
|
||||
|
||||
### Advance Time Manually
|
||||
|
||||
```typescript
|
||||
test("session timeout warning", async ({ page }) => {
|
||||
await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
|
||||
await page.goto("/dashboard");
|
||||
|
||||
// Advance 25 minutes (session timeout at 30 min)
|
||||
await page.clock.fastForward("25:00");
|
||||
|
||||
await expect(page.getByText("Session expires in 5 minutes")).toBeVisible();
|
||||
|
||||
// Advance 5 more minutes
|
||||
await page.clock.fastForward("05:00");
|
||||
|
||||
await expect(page.getByText("Session expired")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Pause and Resume Time
|
||||
|
||||
```typescript
|
||||
test("countdown timer", async ({ page }) => {
|
||||
await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
|
||||
await page.goto("/sale");
|
||||
|
||||
// Initial state
|
||||
await expect(page.getByText("Sale ends in 2:00:00")).toBeVisible();
|
||||
|
||||
// Advance 1 hour
|
||||
await page.clock.fastForward("01:00:00");
|
||||
|
||||
await expect(page.getByText("Sale ends in 1:00:00")).toBeVisible();
|
||||
|
||||
// Advance past end
|
||||
await page.clock.fastForward("01:00:01");
|
||||
|
||||
await expect(page.getByText("Sale ended")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Run Pending Timers
|
||||
|
||||
```typescript
|
||||
test("debounced search", async ({ page }) => {
|
||||
await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
|
||||
await page.goto("/search");
|
||||
|
||||
await page.getByLabel("Search").fill("playwright");
|
||||
|
||||
// Search is debounced by 300ms, won't fire yet
|
||||
await expect(page.getByTestId("search-results")).toBeHidden();
|
||||
|
||||
// Fast forward past debounce
|
||||
await page.clock.fastForward(300);
|
||||
|
||||
// Now search should execute
|
||||
await expect(page.getByTestId("search-results")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Timezone Testing
|
||||
|
||||
### Test Different Timezones
|
||||
|
||||
```typescript
|
||||
test.describe("timezone display", () => {
|
||||
test("shows correct time in PST", async ({ browser }) => {
|
||||
const context = await browser.newContext({
|
||||
timezoneId: "America/Los_Angeles",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.clock.install({ time: new Date("2025-01-15T17:00:00Z") }); // 5 PM UTC
|
||||
|
||||
await page.goto("/schedule");
|
||||
|
||||
// Should show 9 AM PST
|
||||
await expect(page.getByText("9:00 AM")).toBeVisible();
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test("shows correct time in JST", async ({ browser }) => {
|
||||
const context = await browser.newContext({
|
||||
timezoneId: "Asia/Tokyo",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.clock.install({ time: new Date("2025-01-15T17:00:00Z") }); // 5 PM UTC
|
||||
|
||||
await page.goto("/schedule");
|
||||
|
||||
// Should show 2 AM next day JST
|
||||
await expect(page.getByText("2:00 AM")).toBeVisible();
|
||||
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Timezone Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/timezone.fixture.ts
|
||||
import { test as base } from "@playwright/test";
|
||||
|
||||
type TimezoneFixtures = {
|
||||
pageInTimezone: (timezone: string) => Promise<Page>;
|
||||
};
|
||||
|
||||
export const test = base.extend<TimezoneFixtures>({
|
||||
pageInTimezone: async ({ browser }, use) => {
|
||||
const pages: Page[] = [];
|
||||
|
||||
await use(async (timezone) => {
|
||||
const context = await browser.newContext({ timezoneId: timezone });
|
||||
const page = await context.newPage();
|
||||
pages.push(page);
|
||||
return page;
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
for (const page of pages) {
|
||||
await page.context().close();
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Timer Mocking
|
||||
|
||||
### Mock setInterval
|
||||
|
||||
```typescript
|
||||
test("auto-refresh data", async ({ page }) => {
|
||||
await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
|
||||
|
||||
let apiCalls = 0;
|
||||
await page.route("**/api/data", (route) => {
|
||||
apiCalls++;
|
||||
route.fulfill({ json: { value: apiCalls } });
|
||||
});
|
||||
|
||||
await page.goto("/live-data"); // Sets up 30s refresh interval
|
||||
|
||||
expect(apiCalls).toBe(1); // Initial load
|
||||
|
||||
// Advance 30 seconds
|
||||
await page.clock.fastForward("00:30");
|
||||
expect(apiCalls).toBe(2); // First refresh
|
||||
|
||||
// Advance another 30 seconds
|
||||
await page.clock.fastForward("00:30");
|
||||
expect(apiCalls).toBe(3); // Second refresh
|
||||
});
|
||||
```
|
||||
|
||||
### Mock setTimeout Chains
|
||||
|
||||
```typescript
|
||||
test("notification queue", async ({ page }) => {
|
||||
await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
|
||||
await page.goto("/notifications");
|
||||
|
||||
// Trigger 3 notifications that show sequentially
|
||||
await page.getByRole("button", { name: "Show All" }).click();
|
||||
|
||||
// First notification appears immediately
|
||||
await expect(page.getByText("Notification 1")).toBeVisible();
|
||||
|
||||
// Second appears after 2 seconds
|
||||
await page.clock.fastForward("00:02");
|
||||
await expect(page.getByText("Notification 2")).toBeVisible();
|
||||
|
||||
// Third appears after 2 more seconds
|
||||
await page.clock.fastForward("00:02");
|
||||
await expect(page.getByText("Notification 3")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Test Animation Frames
|
||||
|
||||
```typescript
|
||||
test("animation completes", async ({ page }) => {
|
||||
await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
|
||||
await page.goto("/animation-demo");
|
||||
|
||||
await page.getByRole("button", { name: "Animate" }).click();
|
||||
|
||||
// Animation runs for 500ms
|
||||
const element = page.getByTestId("animated-box");
|
||||
await expect(element).toHaveCSS("opacity", "0");
|
||||
|
||||
// Fast forward through animation
|
||||
await page.clock.fastForward(500);
|
||||
|
||||
await expect(element).toHaveCSS("opacity", "1");
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Always Install Clock Before Navigation
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
test("date test", async ({ page }) => {
|
||||
await page.clock.install({ time: new Date("2025-01-15") });
|
||||
await page.goto("/"); // Page loads with mocked time
|
||||
});
|
||||
|
||||
// Bad - time already captured by page
|
||||
test("date test", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.clock.install({ time: new Date("2025-01-15") }); // Too late!
|
||||
});
|
||||
```
|
||||
|
||||
### Use ISO Strings for Clarity
|
||||
|
||||
```typescript
|
||||
// Good - explicit timezone
|
||||
await page.clock.install({ time: new Date("2025-01-15T09:00:00Z") });
|
||||
|
||||
// Ambiguous - uses local timezone
|
||||
await page.clock.install({ time: new Date("2025-01-15T09:00:00") });
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ---------------------------------------- | ------------------------------- | -------------------------------------- |
|
||||
| Installing clock after navigation | Page already captured real time | Install clock before `goto()` |
|
||||
| Hardcoded relative dates | Tests break over time | Use fixed dates with clock mock |
|
||||
| Not accounting for timezone | Tests fail in different regions | Use explicit UTC times or set timezone |
|
||||
| Using `waitForTimeout` with mocked clock | Conflicts with mocked timers | Use `fastForward` instead |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for time-based assertions
|
||||
- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for clock fixtures
|
||||
@@ -0,0 +1,409 @@
|
||||
# Mobile & Responsive Testing
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Device Emulation](#device-emulation)
|
||||
2. [Touch Gestures](#touch-gestures)
|
||||
3. [Viewport Testing](#viewport-testing)
|
||||
4. [Mobile-Specific UI](#mobile-specific-ui)
|
||||
5. [Responsive Breakpoints](#responsive-breakpoints)
|
||||
|
||||
## Device Emulation
|
||||
|
||||
### Use Built-in Devices
|
||||
|
||||
```typescript
|
||||
import { test, devices } from "@playwright/test";
|
||||
|
||||
// Configure in playwright.config.ts
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{ name: "Desktop Chrome", use: { ...devices["Desktop Chrome"] } },
|
||||
{ name: "Mobile Safari", use: { ...devices["iPhone 14"] } },
|
||||
{ name: "Mobile Chrome", use: { ...devices["Pixel 7"] } },
|
||||
{ name: "Tablet", use: { ...devices["iPad Pro 11"] } },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Device Configuration
|
||||
|
||||
```typescript
|
||||
test.use({
|
||||
viewport: { width: 390, height: 844 },
|
||||
deviceScaleFactor: 3,
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
userAgent:
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15",
|
||||
});
|
||||
|
||||
test("custom mobile device", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
// Test runs with custom device settings
|
||||
});
|
||||
```
|
||||
|
||||
### Test Across Multiple Devices
|
||||
|
||||
```typescript
|
||||
const mobileDevices = ["iPhone 14", "Pixel 7", "Galaxy S21"];
|
||||
|
||||
for (const deviceName of mobileDevices) {
|
||||
test(`checkout on ${deviceName}`, async ({ browser }) => {
|
||||
const device = devices[deviceName];
|
||||
const context = await browser.newContext({ ...device });
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto("/checkout");
|
||||
await expect(page.getByRole("button", { name: "Pay" })).toBeVisible();
|
||||
|
||||
await context.close();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Touch Gestures
|
||||
|
||||
### Tap
|
||||
|
||||
```typescript
|
||||
test.use({ hasTouch: true });
|
||||
|
||||
test("tap to interact", async ({ page }) => {
|
||||
await page.goto("/gallery");
|
||||
|
||||
// Tap is like click but for touch devices
|
||||
await page.getByRole("img", { name: "Photo 1" }).tap();
|
||||
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Swipe
|
||||
|
||||
```typescript
|
||||
test("swipe carousel", async ({ page }) => {
|
||||
await page.goto("/carousel");
|
||||
|
||||
const carousel = page.getByTestId("carousel");
|
||||
const box = await carousel.boundingBox();
|
||||
|
||||
if (box) {
|
||||
// Swipe left
|
||||
await page.touchscreen.tap(box.x + box.width - 50, box.y + box.height / 2);
|
||||
await page.mouse.move(box.x + 50, box.y + box.height / 2);
|
||||
|
||||
// Or use drag
|
||||
await carousel.dragTo(carousel, {
|
||||
sourcePosition: { x: box.width - 50, y: box.height / 2 },
|
||||
targetPosition: { x: 50, y: box.height / 2 },
|
||||
});
|
||||
}
|
||||
|
||||
await expect(page.getByText("Slide 2")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Swipe Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/touch.fixture.ts
|
||||
import { test as base, Page } from "@playwright/test";
|
||||
|
||||
type TouchFixtures = {
|
||||
swipe: (
|
||||
element: Locator,
|
||||
direction: "left" | "right" | "up" | "down",
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
export const test = base.extend<TouchFixtures>({
|
||||
swipe: async ({ page }, use) => {
|
||||
await use(async (element, direction) => {
|
||||
const box = await element.boundingBox();
|
||||
if (!box) throw new Error("Element not visible");
|
||||
|
||||
const centerX = box.x + box.width / 2;
|
||||
const centerY = box.y + box.height / 2;
|
||||
const distance = 100;
|
||||
|
||||
const moves = {
|
||||
left: {
|
||||
startX: centerX + distance,
|
||||
endX: centerX - distance,
|
||||
y: centerY,
|
||||
},
|
||||
right: {
|
||||
startX: centerX - distance,
|
||||
endX: centerX + distance,
|
||||
y: centerY,
|
||||
},
|
||||
up: {
|
||||
startX: centerX,
|
||||
endX: centerX,
|
||||
startY: centerY + distance,
|
||||
endY: centerY - distance,
|
||||
},
|
||||
down: {
|
||||
startX: centerX,
|
||||
endX: centerX,
|
||||
startY: centerY - distance,
|
||||
endY: centerY + distance,
|
||||
},
|
||||
};
|
||||
|
||||
const move = moves[direction];
|
||||
await page.touchscreen.tap(move.startX, move.startY ?? move.y);
|
||||
await page.mouse.move(move.endX, move.endY ?? move.y, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
test("swipe to delete", async ({ page, swipe }) => {
|
||||
await page.goto("/inbox");
|
||||
|
||||
const message = page.getByTestId("message-1");
|
||||
await swipe(message, "left");
|
||||
|
||||
await expect(page.getByRole("button", { name: "Delete" })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Long Press
|
||||
|
||||
```typescript
|
||||
test("long press for context menu", async ({ page }) => {
|
||||
await page.goto("/files");
|
||||
|
||||
const file = page.getByText("document.pdf");
|
||||
const box = await file.boundingBox();
|
||||
|
||||
if (box) {
|
||||
// Touch down
|
||||
await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2);
|
||||
|
||||
// Hold for 500ms
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Context menu should appear
|
||||
await expect(page.getByRole("menu")).toBeVisible();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Pinch Zoom
|
||||
|
||||
```typescript
|
||||
test("pinch to zoom image", async ({ page }) => {
|
||||
await page.goto("/map");
|
||||
|
||||
// Pinch zoom requires two touch points
|
||||
// Playwright doesn't have native pinch support, so we simulate via evaluate
|
||||
await page.evaluate(() => {
|
||||
const element = document.querySelector("#map");
|
||||
if (element) {
|
||||
// Simulate wheel event as fallback for zoom
|
||||
element.dispatchEvent(
|
||||
new WheelEvent("wheel", {
|
||||
deltaY: -100, // Negative = zoom in
|
||||
ctrlKey: true, // Ctrl+wheel = pinch on many apps
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Or trigger the app's zoom function directly
|
||||
await page.evaluate(() => {
|
||||
(window as any).mapInstance?.setZoom(15);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Viewport Testing
|
||||
|
||||
### Test Different Sizes
|
||||
|
||||
```typescript
|
||||
const viewports = [
|
||||
{ name: "mobile", width: 375, height: 667 },
|
||||
{ name: "tablet", width: 768, height: 1024 },
|
||||
{ name: "desktop", width: 1920, height: 1080 },
|
||||
];
|
||||
|
||||
for (const { name, width, height } of viewports) {
|
||||
test(`navigation on ${name}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.goto("/");
|
||||
|
||||
if (width < 768) {
|
||||
// Mobile: should have hamburger menu
|
||||
await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
|
||||
} else {
|
||||
// Desktop: should have visible nav links
|
||||
await expect(page.getByRole("link", { name: "Products" })).toBeVisible();
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Dynamic Viewport Changes
|
||||
|
||||
```typescript
|
||||
test("responsive layout change", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1200, height: 800 });
|
||||
await page.goto("/dashboard");
|
||||
|
||||
// Desktop: sidebar visible
|
||||
await expect(page.getByRole("complementary")).toBeVisible();
|
||||
|
||||
// Resize to mobile
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
// Mobile: sidebar hidden, hamburger visible
|
||||
await expect(page.getByRole("complementary")).toBeHidden();
|
||||
await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Mobile-Specific UI
|
||||
|
||||
### Hamburger Menu
|
||||
|
||||
```typescript
|
||||
test("mobile navigation", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.goto("/");
|
||||
|
||||
// Open hamburger menu
|
||||
await page.getByRole("button", { name: "Menu" }).click();
|
||||
|
||||
// Navigation drawer should appear
|
||||
const nav = page.getByRole("navigation");
|
||||
await expect(nav).toBeVisible();
|
||||
|
||||
// Navigate via mobile menu
|
||||
await nav.getByRole("link", { name: "Products" }).click();
|
||||
|
||||
await expect(page).toHaveURL("/products");
|
||||
// Menu should close after navigation
|
||||
await expect(nav).toBeHidden();
|
||||
});
|
||||
```
|
||||
|
||||
### Bottom Sheet
|
||||
|
||||
```typescript
|
||||
test("bottom sheet interaction", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.goto("/product/123");
|
||||
|
||||
await page.getByRole("button", { name: "Add to Cart" }).click();
|
||||
|
||||
// Bottom sheet appears
|
||||
const sheet = page.getByRole("dialog");
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
// Select options
|
||||
await sheet.getByRole("combobox", { name: "Size" }).selectOption("Large");
|
||||
await sheet.getByRole("button", { name: "Confirm" }).click();
|
||||
|
||||
await expect(page.getByText("Added to cart")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Pull to Refresh
|
||||
|
||||
```typescript
|
||||
test("pull to refresh", async ({ page }) => {
|
||||
await page.goto("/feed");
|
||||
|
||||
const feed = page.getByTestId("feed");
|
||||
const initialFirstItem = await feed.locator("> *").first().textContent();
|
||||
|
||||
// Simulate pull down
|
||||
const box = await feed.boundingBox();
|
||||
if (box) {
|
||||
await page.touchscreen.tap(box.x + box.width / 2, box.y + 50);
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + 200, { steps: 20 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
// Wait for refresh
|
||||
await expect(page.getByTestId("loading")).toBeVisible();
|
||||
await expect(page.getByTestId("loading")).toBeHidden();
|
||||
|
||||
// Content should be updated (in a real app)
|
||||
});
|
||||
```
|
||||
|
||||
## Responsive Breakpoints
|
||||
|
||||
### Test All Breakpoints
|
||||
|
||||
```typescript
|
||||
const breakpoints = {
|
||||
xs: 320,
|
||||
sm: 640,
|
||||
md: 768,
|
||||
lg: 1024,
|
||||
xl: 1280,
|
||||
"2xl": 1536,
|
||||
};
|
||||
|
||||
test.describe("responsive header", () => {
|
||||
for (const [name, width] of Object.entries(breakpoints)) {
|
||||
test(`header at ${name} (${width}px)`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
await page.goto("/");
|
||||
|
||||
if (width < 768) {
|
||||
await expect(page.getByTestId("mobile-menu-button")).toBeVisible();
|
||||
await expect(page.getByTestId("desktop-nav")).toBeHidden();
|
||||
} else {
|
||||
await expect(page.getByTestId("mobile-menu-button")).toBeHidden();
|
||||
await expect(page.getByTestId("desktop-nav")).toBeVisible();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Visual Regression at Breakpoints
|
||||
|
||||
```typescript
|
||||
test.describe("visual regression", () => {
|
||||
const sizes = [
|
||||
{ width: 375, height: 667, name: "mobile" },
|
||||
{ width: 768, height: 1024, name: "tablet" },
|
||||
{ width: 1440, height: 900, name: "desktop" },
|
||||
];
|
||||
|
||||
for (const { width, height, name } of sizes) {
|
||||
test(`homepage at ${name}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page).toHaveScreenshot(`homepage-${name}.png`);
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| --------------------------- | ------------------------- | -------------------------------- |
|
||||
| Only testing one viewport | Misses responsive bugs | Test multiple breakpoints |
|
||||
| Ignoring touch events | Features broken on mobile | Test tap, swipe, long press |
|
||||
| Hardcoded viewport in tests | Can't test multiple sizes | Use `page.setViewportSize()` |
|
||||
| Not testing orientation | Landscape bugs missed | Test both portrait and landscape |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Visual Testing**: See [test-suite-structure.md](../core/test-suite-structure.md) for screenshot testing
|
||||
- **Locators**: See [locators.md](../core/locators.md) for mobile-friendly selectors
|
||||
- **Browser APIs**: See [browser-apis.md](../browser-apis/browser-apis.md) for permissions (camera, geolocation, notifications)
|
||||
- **Canvas/Touch**: See [canvas-webgl.md](../testing-patterns/canvas-webgl.md) for touch gestures on canvas elements
|
||||
@@ -0,0 +1,288 @@
|
||||
# Multi-Tab, Window & Popup Testing
|
||||
|
||||
This file covers **single-user scenarios** with multiple browser tabs, windows, and popups. For **multi-user collaboration testing** (multiple users interacting simultaneously), see [multi-user.md](multi-user.md).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Popup Handling](#popup-handling)
|
||||
2. [New Tab Navigation](#new-tab-navigation)
|
||||
3. [OAuth Flows](#oauth-flows)
|
||||
4. [Multiple Windows](#multiple-windows)
|
||||
5. [Tab Coordination](#tab-coordination)
|
||||
|
||||
## Popup Handling
|
||||
|
||||
### Basic Popup
|
||||
|
||||
```typescript
|
||||
test("handle popup window", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Start waiting for popup before triggering it
|
||||
const popupPromise = page.waitForEvent("popup");
|
||||
await page.getByRole("button", { name: "Open Support Chat" }).click();
|
||||
const popup = await popupPromise;
|
||||
|
||||
// Wait for popup to load
|
||||
await popup.waitForLoadState();
|
||||
|
||||
// Interact with popup
|
||||
await popup.getByLabel("Message").fill("Need help");
|
||||
await popup.getByRole("button", { name: "Send" }).click();
|
||||
|
||||
await expect(popup.getByText("Message sent")).toBeVisible();
|
||||
|
||||
// Close popup
|
||||
await popup.close();
|
||||
});
|
||||
```
|
||||
|
||||
### Popup with Authentication
|
||||
|
||||
```typescript
|
||||
test("popup login flow", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
|
||||
const popupPromise = page.waitForEvent("popup");
|
||||
await page.getByRole("button", { name: "Connect Account" }).click();
|
||||
const popup = await popupPromise;
|
||||
|
||||
await popup.waitForLoadState();
|
||||
|
||||
// Complete login in popup
|
||||
await popup.getByLabel("Email").fill("user@example.com");
|
||||
await popup.getByLabel("Password").fill("password123");
|
||||
await popup.getByRole("button", { name: "Log In" }).click();
|
||||
|
||||
// Popup should close automatically after auth
|
||||
await popup.waitForEvent("close");
|
||||
|
||||
// Main page should reflect connected state
|
||||
await expect(page.getByText("Account connected")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Handle Blocked Popups
|
||||
|
||||
```typescript
|
||||
test("handle popup blocker", async ({ page }) => {
|
||||
await page.goto("/share");
|
||||
|
||||
// Listen for console messages about blocked popup
|
||||
page.on("console", (msg) => {
|
||||
if (msg.text().includes("popup blocked")) {
|
||||
console.log("Popup was blocked");
|
||||
}
|
||||
});
|
||||
|
||||
const popupPromise = page.waitForEvent("popup").catch(() => null);
|
||||
await page.getByRole("button", { name: "Share to Twitter" }).click();
|
||||
const popup = await popupPromise;
|
||||
|
||||
if (!popup) {
|
||||
// Popup blocked - app should show fallback
|
||||
await expect(page.getByText("Copy share link instead")).toBeVisible();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## New Tab Navigation
|
||||
|
||||
### Link Opens in New Tab
|
||||
|
||||
```typescript
|
||||
test("external link opens in new tab", async ({ page, context }) => {
|
||||
await page.goto("/resources");
|
||||
|
||||
// Wait for new page in context
|
||||
const pagePromise = context.waitForEvent("page");
|
||||
await page.getByRole("link", { name: "Documentation" }).click();
|
||||
const newPage = await pagePromise;
|
||||
|
||||
await newPage.waitForLoadState();
|
||||
|
||||
expect(newPage.url()).toContain("docs.example.com");
|
||||
await expect(newPage.getByRole("heading", { level: 1 })).toBeVisible();
|
||||
|
||||
// Original page still there
|
||||
expect(page.url()).toContain("/resources");
|
||||
|
||||
await newPage.close();
|
||||
});
|
||||
```
|
||||
|
||||
### Intercept New Tab
|
||||
|
||||
```typescript
|
||||
test("prevent new tab for testing", async ({ page }) => {
|
||||
await page.goto("/links");
|
||||
|
||||
// Remove target="_blank" to keep navigation in same tab
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll('a[target="_blank"]').forEach((a) => {
|
||||
a.removeAttribute("target");
|
||||
});
|
||||
});
|
||||
|
||||
// Now link opens in same tab
|
||||
await page.getByRole("link", { name: "External Site" }).click();
|
||||
|
||||
// Can test the destination page
|
||||
await expect(page).toHaveURL(/external-site\.com/);
|
||||
});
|
||||
```
|
||||
|
||||
## OAuth Flows
|
||||
|
||||
### Google OAuth Popup
|
||||
|
||||
```typescript
|
||||
test("Google OAuth login", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
|
||||
const popupPromise = page.waitForEvent("popup");
|
||||
await page.getByRole("button", { name: "Sign in with Google" }).click();
|
||||
const popup = await popupPromise;
|
||||
|
||||
await popup.waitForLoadState();
|
||||
|
||||
// Handle Google's OAuth flow
|
||||
await popup.getByLabel("Email or phone").fill("test@gmail.com");
|
||||
await popup.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
await popup.getByLabel("Enter your password").fill("password");
|
||||
await popup.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Wait for redirect back and popup close
|
||||
await popup.waitForEvent("close");
|
||||
|
||||
// Verify logged in on main page
|
||||
await expect(page.getByText("Welcome, Test User")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Mock OAuth (Recommended)
|
||||
|
||||
```typescript
|
||||
test("mock OAuth flow", async ({ page, context }) => {
|
||||
// Mock the OAuth callback instead of real flow
|
||||
await page.route("**/auth/callback**", async (route) => {
|
||||
// Simulate successful OAuth
|
||||
const url = new URL(route.request().url());
|
||||
url.searchParams.set("code", "mock-auth-code");
|
||||
await route.fulfill({
|
||||
status: 302,
|
||||
headers: { Location: "/dashboard" },
|
||||
});
|
||||
});
|
||||
|
||||
// Mock token exchange
|
||||
await page.route("**/api/auth/token", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
access_token: "mock-token",
|
||||
user: { name: "Test User", email: "test@example.com" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Sign in with Google" }).click();
|
||||
|
||||
// Should redirect to dashboard without actual OAuth
|
||||
await expect(page).toHaveURL("/dashboard");
|
||||
await expect(page.getByText("Welcome, Test User")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### OAuth Fixture
|
||||
|
||||
> **For comprehensive OAuth mocking patterns** (fixtures, multiple providers, SAML SSO), see [third-party.md](third-party.md#oauthsso-mocking). This section focuses on popup window handling mechanics for OAuth flows.
|
||||
|
||||
## Multiple Windows
|
||||
|
||||
### Test Across Multiple Windows
|
||||
|
||||
```typescript
|
||||
test("sync between windows", async ({ context }) => {
|
||||
// Open two pages
|
||||
const page1 = await context.newPage();
|
||||
const page2 = await context.newPage();
|
||||
|
||||
await page1.goto("/dashboard");
|
||||
await page2.goto("/dashboard");
|
||||
|
||||
// Make change in first window
|
||||
await page1.getByRole("button", { name: "Add Item" }).click();
|
||||
await page1.getByLabel("Name").fill("New Item");
|
||||
await page1.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
// Should sync to second window (if app supports real-time sync)
|
||||
await expect(page2.getByText("New Item")).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
```
|
||||
|
||||
### Different Users in Different Windows
|
||||
|
||||
> **For multi-user collaboration patterns** (admin/user interactions, real-time collaboration, role-based testing, concurrent actions), see [multi-user.md](multi-user.md). This file focuses on single-user scenarios with multiple tabs/windows/popups.
|
||||
|
||||
## Tab Coordination
|
||||
|
||||
### Switch Between Tabs
|
||||
|
||||
```typescript
|
||||
test("manage multiple tabs", async ({ context }) => {
|
||||
const page1 = await context.newPage();
|
||||
await page1.goto("/editor");
|
||||
|
||||
const page2 = await context.newPage();
|
||||
await page2.goto("/preview");
|
||||
|
||||
// Edit in first tab
|
||||
await page1.bringToFront();
|
||||
await page1.getByLabel("Content").fill("Hello World");
|
||||
|
||||
// Check preview in second tab
|
||||
await page2.bringToFront();
|
||||
await page2.reload(); // If preview needs refresh
|
||||
await expect(page2.getByText("Hello World")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Close All Tabs Except One
|
||||
|
||||
```typescript
|
||||
test("cleanup tabs after test", async ({ context }) => {
|
||||
const mainPage = await context.newPage();
|
||||
await mainPage.goto("/");
|
||||
|
||||
// Open several popups during test
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const popup = await context.newPage();
|
||||
await popup.goto(`/popup/${i}`);
|
||||
}
|
||||
|
||||
// Close all except main page
|
||||
for (const page of context.pages()) {
|
||||
if (page !== mainPage) {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
expect(context.pages()).toHaveLength(1);
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ----------------------- | ------------------------------ | ------------------------------------------ |
|
||||
| Not waiting for popup | Race condition | Use `waitForEvent("popup")` before trigger |
|
||||
| Testing real OAuth | Slow, flaky, needs credentials | Mock OAuth endpoints |
|
||||
| Assuming popup opens | May be blocked | Handle both open and blocked cases |
|
||||
| Not closing extra pages | Resource leak | Close pages in cleanup |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth patterns
|
||||
- **Network**: See [network-advanced.md](network-advanced.md) for mocking OAuth
|
||||
@@ -0,0 +1,393 @@
|
||||
# Multi-User & Collaboration Testing
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Multiple Browser Contexts](#multiple-browser-contexts)
|
||||
2. [Real-Time Collaboration](#real-time-collaboration)
|
||||
3. [Role-Based Testing](#role-based-testing)
|
||||
4. [Concurrent Actions](#concurrent-actions)
|
||||
5. [Chat & Messaging](#chat--messaging)
|
||||
|
||||
## Multiple Browser Contexts
|
||||
|
||||
### Two Users in Same Test
|
||||
|
||||
```typescript
|
||||
test("two users see each other's changes", async ({ browser }) => {
|
||||
// Create two isolated contexts (like two browsers)
|
||||
const userAContext = await browser.newContext();
|
||||
const userBContext = await browser.newContext();
|
||||
|
||||
const userAPage = await userAContext.newPage();
|
||||
const userBPage = await userBContext.newPage();
|
||||
|
||||
// Both users go to the same document
|
||||
await userAPage.goto("/doc/shared-123");
|
||||
await userBPage.goto("/doc/shared-123");
|
||||
|
||||
// User A types
|
||||
await userAPage.getByLabel("Content").fill("Hello from User A");
|
||||
|
||||
// User B should see the change
|
||||
await expect(userBPage.getByText("Hello from User A")).toBeVisible();
|
||||
|
||||
// Cleanup
|
||||
await userAContext.close();
|
||||
await userBContext.close();
|
||||
});
|
||||
```
|
||||
|
||||
### Multiple Users with Auth States
|
||||
|
||||
```typescript
|
||||
test("admin and user interaction", async ({ browser }) => {
|
||||
// Load different auth states
|
||||
const adminContext = await browser.newContext({
|
||||
storageState: ".auth/admin.json",
|
||||
});
|
||||
const userContext = await browser.newContext({
|
||||
storageState: ".auth/user.json",
|
||||
});
|
||||
|
||||
const adminPage = await adminContext.newPage();
|
||||
const userPage = await userContext.newPage();
|
||||
|
||||
// User submits request
|
||||
await userPage.goto("/support");
|
||||
await userPage.getByLabel("Message").fill("Need help!");
|
||||
await userPage.getByRole("button", { name: "Submit" }).click();
|
||||
|
||||
// Admin sees and responds
|
||||
await adminPage.goto("/admin/tickets");
|
||||
await expect(adminPage.getByText("Need help!")).toBeVisible();
|
||||
await adminPage.getByRole("button", { name: "Reply" }).click();
|
||||
await adminPage.getByLabel("Response").fill("How can I help?");
|
||||
await adminPage.getByRole("button", { name: "Send" }).click();
|
||||
|
||||
// User sees response
|
||||
await expect(userPage.getByText("How can I help?")).toBeVisible();
|
||||
|
||||
await adminContext.close();
|
||||
await userContext.close();
|
||||
});
|
||||
```
|
||||
|
||||
### Multi-User Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/multi-user.fixture.ts
|
||||
import { test as base, Browser, BrowserContext, Page } from "@playwright/test";
|
||||
|
||||
type UserSession = {
|
||||
context: BrowserContext;
|
||||
page: Page;
|
||||
};
|
||||
|
||||
type MultiUserFixtures = {
|
||||
createUser: (authState?: string) => Promise<UserSession>;
|
||||
};
|
||||
|
||||
export const test = base.extend<MultiUserFixtures>({
|
||||
createUser: async ({ browser }, use) => {
|
||||
const sessions: UserSession[] = [];
|
||||
|
||||
await use(async (authState) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: authState,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
sessions.push({ context, page });
|
||||
return { context, page };
|
||||
});
|
||||
|
||||
// Cleanup all sessions
|
||||
for (const session of sessions) {
|
||||
await session.context.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
test("3 users collaborate", async ({ createUser }) => {
|
||||
const alice = await createUser(".auth/alice.json");
|
||||
const bob = await createUser(".auth/bob.json");
|
||||
const charlie = await createUser(".auth/charlie.json");
|
||||
|
||||
// All navigate to same room
|
||||
await alice.page.goto("/room/123");
|
||||
await bob.page.goto("/room/123");
|
||||
await charlie.page.goto("/room/123");
|
||||
|
||||
// Test interactions...
|
||||
});
|
||||
```
|
||||
|
||||
## Real-Time Collaboration
|
||||
|
||||
### Collaborative Document
|
||||
|
||||
```typescript
|
||||
test("real-time collaborative editing", async ({ browser }) => {
|
||||
const user1 = await browser.newContext();
|
||||
const user2 = await browser.newContext();
|
||||
|
||||
const page1 = await user1.newPage();
|
||||
const page2 = await user2.newPage();
|
||||
|
||||
await page1.goto("/docs/shared");
|
||||
await page2.goto("/docs/shared");
|
||||
|
||||
// User 1 types at the beginning
|
||||
const editor1 = page1.getByRole("textbox");
|
||||
await editor1.click();
|
||||
await editor1.press("Home");
|
||||
await editor1.type("User 1: ");
|
||||
|
||||
// User 2 types at the end
|
||||
const editor2 = page2.getByRole("textbox");
|
||||
await editor2.click();
|
||||
await editor2.press("End");
|
||||
await editor2.type(" - User 2");
|
||||
|
||||
// Both should see combined result
|
||||
await expect(page1.getByRole("textbox")).toContainText("User 1:");
|
||||
await expect(page1.getByRole("textbox")).toContainText("- User 2");
|
||||
await expect(page2.getByRole("textbox")).toContainText("User 1:");
|
||||
await expect(page2.getByRole("textbox")).toContainText("- User 2");
|
||||
|
||||
await user1.close();
|
||||
await user2.close();
|
||||
});
|
||||
```
|
||||
|
||||
### Cursor Presence
|
||||
|
||||
```typescript
|
||||
test("shows other user cursors", async ({ browser }) => {
|
||||
const ctx1 = await browser.newContext();
|
||||
const ctx2 = await browser.newContext();
|
||||
|
||||
const page1 = await ctx1.newPage();
|
||||
const page2 = await ctx2.newPage();
|
||||
|
||||
// Mock to identify users
|
||||
await page1.route("**/api/me", (route) =>
|
||||
route.fulfill({ json: { id: "user-1", name: "Alice" } }),
|
||||
);
|
||||
await page2.route("**/api/me", (route) =>
|
||||
route.fulfill({ json: { id: "user-2", name: "Bob" } }),
|
||||
);
|
||||
|
||||
await page1.goto("/whiteboard/123");
|
||||
await page2.goto("/whiteboard/123");
|
||||
|
||||
// Move cursor on page1
|
||||
await page1.mouse.move(200, 200);
|
||||
|
||||
// Page2 should see Alice's cursor
|
||||
await expect(page2.getByTestId("cursor-user-1")).toBeVisible();
|
||||
await expect(page2.getByText("Alice")).toBeVisible();
|
||||
|
||||
await ctx1.close();
|
||||
await ctx2.close();
|
||||
});
|
||||
```
|
||||
|
||||
## Role-Based Testing
|
||||
|
||||
### Test RBAC
|
||||
|
||||
```typescript
|
||||
const roles = [
|
||||
{ role: "admin", canDelete: true, canEdit: true, canView: true },
|
||||
{ role: "editor", canDelete: false, canEdit: true, canView: true },
|
||||
{ role: "viewer", canDelete: false, canEdit: false, canView: true },
|
||||
];
|
||||
|
||||
for (const { role, canDelete, canEdit, canView } of roles) {
|
||||
test(`${role} permissions`, async ({ browser }) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: `.auth/${role}.json`,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto("/document/123");
|
||||
|
||||
// Check view permission
|
||||
if (canView) {
|
||||
await expect(page.getByTestId("content")).toBeVisible();
|
||||
} else {
|
||||
await expect(page.getByText("Access denied")).toBeVisible();
|
||||
}
|
||||
|
||||
// Check edit permission
|
||||
const editButton = page.getByRole("button", { name: "Edit" });
|
||||
if (canEdit) {
|
||||
await expect(editButton).toBeEnabled();
|
||||
} else {
|
||||
await expect(editButton).toBeDisabled();
|
||||
}
|
||||
|
||||
// Check delete permission
|
||||
const deleteButton = page.getByRole("button", { name: "Delete" });
|
||||
if (canDelete) {
|
||||
await expect(deleteButton).toBeVisible();
|
||||
} else {
|
||||
await expect(deleteButton).toBeHidden();
|
||||
}
|
||||
|
||||
await context.close();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Permission Escalation Test
|
||||
|
||||
```typescript
|
||||
test("cannot access admin routes as user", async ({ browser }) => {
|
||||
const userContext = await browser.newContext({
|
||||
storageState: ".auth/user.json",
|
||||
});
|
||||
const page = await userContext.newPage();
|
||||
|
||||
// Try to access admin page directly
|
||||
await page.goto("/admin/users");
|
||||
|
||||
// Should redirect or show error
|
||||
await expect(page).not.toHaveURL("/admin/users");
|
||||
await expect(page.getByText("Access denied")).toBeVisible();
|
||||
|
||||
await userContext.close();
|
||||
});
|
||||
```
|
||||
|
||||
## Concurrent Actions
|
||||
|
||||
### Race Condition Testing
|
||||
|
||||
```typescript
|
||||
test("handles concurrent edits", async ({ browser }) => {
|
||||
const ctx1 = await browser.newContext();
|
||||
const ctx2 = await browser.newContext();
|
||||
|
||||
const page1 = await ctx1.newPage();
|
||||
const page2 = await ctx2.newPage();
|
||||
|
||||
await page1.goto("/item/123");
|
||||
await page2.goto("/item/123");
|
||||
|
||||
// Both click edit at the same time
|
||||
await Promise.all([
|
||||
page1.getByRole("button", { name: "Edit" }).click(),
|
||||
page2.getByRole("button", { name: "Edit" }).click(),
|
||||
]);
|
||||
|
||||
// Both try to save different values
|
||||
await page1.getByLabel("Name").fill("Value from User 1");
|
||||
await page2.getByLabel("Name").fill("Value from User 2");
|
||||
|
||||
await Promise.all([
|
||||
page1.getByRole("button", { name: "Save" }).click(),
|
||||
page2.getByRole("button", { name: "Save" }).click(),
|
||||
]);
|
||||
|
||||
// One should succeed, one should get conflict error
|
||||
const page1HasConflict = await page1.getByText("Conflict").isVisible();
|
||||
const page2HasConflict = await page2.getByText("Conflict").isVisible();
|
||||
|
||||
// Exactly one should have conflict
|
||||
expect(page1HasConflict || page2HasConflict).toBe(true);
|
||||
expect(page1HasConflict && page2HasConflict).toBe(false);
|
||||
|
||||
await ctx1.close();
|
||||
await ctx2.close();
|
||||
});
|
||||
```
|
||||
|
||||
### Optimistic Locking Test
|
||||
|
||||
```typescript
|
||||
test("optimistic locking prevents overwrites", async ({ browser }) => {
|
||||
const ctx1 = await browser.newContext();
|
||||
const ctx2 = await browser.newContext();
|
||||
|
||||
const page1 = await ctx1.newPage();
|
||||
const page2 = await ctx2.newPage();
|
||||
|
||||
// Both load the same version
|
||||
await page1.goto("/record/123");
|
||||
await page2.goto("/record/123");
|
||||
|
||||
// User 1 edits and saves first
|
||||
await page1.getByRole("button", { name: "Edit" }).click();
|
||||
await page1.getByLabel("Value").fill("Updated by User 1");
|
||||
await page1.getByRole("button", { name: "Save" }).click();
|
||||
await expect(page1.getByText("Saved")).toBeVisible();
|
||||
|
||||
// User 2 tries to save with stale version
|
||||
await page2.getByRole("button", { name: "Edit" }).click();
|
||||
await page2.getByLabel("Value").fill("Updated by User 2");
|
||||
await page2.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
// Should fail with version conflict
|
||||
await expect(page2.getByText("Someone else modified this")).toBeVisible();
|
||||
await expect(page2.getByRole("button", { name: "Reload" })).toBeVisible();
|
||||
|
||||
await ctx1.close();
|
||||
await ctx2.close();
|
||||
});
|
||||
```
|
||||
|
||||
## Chat & Messaging
|
||||
|
||||
### Real-Time Chat
|
||||
|
||||
```typescript
|
||||
test("chat messages sync between users", async ({ browser }) => {
|
||||
const aliceCtx = await browser.newContext();
|
||||
const bobCtx = await browser.newContext();
|
||||
|
||||
const alicePage = await aliceCtx.newPage();
|
||||
const bobPage = await bobCtx.newPage();
|
||||
|
||||
// Setup user identities
|
||||
await alicePage.route("**/api/me", (r) =>
|
||||
r.fulfill({ json: { name: "Alice" } }),
|
||||
);
|
||||
await bobPage.route("**/api/me", (r) => r.fulfill({ json: { name: "Bob" } }));
|
||||
|
||||
await alicePage.goto("/chat/room-1");
|
||||
await bobPage.goto("/chat/room-1");
|
||||
|
||||
// Alice sends message
|
||||
await alicePage.getByLabel("Message").fill("Hi Bob!");
|
||||
await alicePage.getByRole("button", { name: "Send" }).click();
|
||||
|
||||
// Bob sees it
|
||||
await expect(bobPage.getByText("Alice: Hi Bob!")).toBeVisible();
|
||||
|
||||
// Bob replies
|
||||
await bobPage.getByLabel("Message").fill("Hey Alice!");
|
||||
await bobPage.getByRole("button", { name: "Send" }).click();
|
||||
|
||||
// Alice sees it
|
||||
await expect(alicePage.getByText("Bob: Hey Alice!")).toBeVisible();
|
||||
|
||||
await aliceCtx.close();
|
||||
await bobCtx.close();
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ----------------------------- | ----------------------------- | ---------------------------- |
|
||||
| Sharing context between users | State leaks, not isolated | Create separate contexts |
|
||||
| Not closing contexts | Memory leak, browser overload | Always close in cleanup |
|
||||
| Hardcoded timing for sync | Flaky tests | Use `expect().toBeVisible()` |
|
||||
| Testing only single user | Misses collaboration bugs | Test multi-user scenarios |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth setup
|
||||
- **WebSockets**: See [websockets.md](../browser-apis/websockets.md) for real-time mocking
|
||||
@@ -0,0 +1,452 @@
|
||||
# Advanced Network Interception
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Request Modification](#request-modification)
|
||||
2. [GraphQL Mocking](#graphql-mocking)
|
||||
3. [HAR Recording & Playback](#har-recording--playback)
|
||||
4. [Conditional Mocking](#conditional-mocking)
|
||||
5. [Network Throttling](#network-throttling)
|
||||
|
||||
## Request Modification
|
||||
|
||||
### Modify Request Headers
|
||||
|
||||
```typescript
|
||||
test("add auth header to requests", async ({ page }) => {
|
||||
await page.route("**/api/**", (route) => {
|
||||
const headers = {
|
||||
...route.request().headers(),
|
||||
Authorization: "Bearer test-token",
|
||||
"X-Test-Header": "test-value",
|
||||
};
|
||||
route.continue({ headers });
|
||||
});
|
||||
|
||||
await page.goto("/dashboard");
|
||||
});
|
||||
```
|
||||
|
||||
### Modify Request Body
|
||||
|
||||
```typescript
|
||||
test("modify POST body", async ({ page }) => {
|
||||
await page.route("**/api/orders", async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
const postData = route.request().postDataJSON();
|
||||
|
||||
// Add test metadata
|
||||
const modifiedData = {
|
||||
...postData,
|
||||
testMode: true,
|
||||
testTimestamp: Date.now(),
|
||||
};
|
||||
|
||||
await route.continue({
|
||||
postData: JSON.stringify(modifiedData),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("/checkout");
|
||||
await page.getByRole("button", { name: "Place Order" }).click();
|
||||
});
|
||||
```
|
||||
|
||||
### Transform Response
|
||||
|
||||
```typescript
|
||||
test("modify API response", async ({ page }) => {
|
||||
await page.route("**/api/products", async (route) => {
|
||||
// Fetch real response
|
||||
const response = await route.fetch();
|
||||
const json = await response.json();
|
||||
|
||||
// Modify response
|
||||
const modified = json.map((product: any) => ({
|
||||
...product,
|
||||
price: product.price * 0.9, // 10% discount
|
||||
testMode: true,
|
||||
}));
|
||||
|
||||
await route.fulfill({
|
||||
response,
|
||||
json: modified,
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/products");
|
||||
});
|
||||
```
|
||||
|
||||
## GraphQL Mocking
|
||||
|
||||
### Mock by Operation Name
|
||||
|
||||
```typescript
|
||||
test("mock GraphQL query", async ({ page }) => {
|
||||
await page.route("**/graphql", async (route) => {
|
||||
const postData = route.request().postDataJSON();
|
||||
|
||||
if (postData.operationName === "GetUser") {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
data: {
|
||||
user: {
|
||||
id: "1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (postData.operationName === "GetProducts") {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
data: {
|
||||
products: [
|
||||
{ id: "1", name: "Product A", price: 29.99 },
|
||||
{ id: "2", name: "Product B", price: 49.99 },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Pass through unmocked operations
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
await page.goto("/dashboard");
|
||||
});
|
||||
```
|
||||
|
||||
### GraphQL Mock Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/graphql.fixture.ts
|
||||
type GraphQLMock = {
|
||||
operation: string;
|
||||
variables?: Record<string, any>;
|
||||
response: { data?: any; errors?: any[] };
|
||||
};
|
||||
|
||||
type GraphQLFixtures = {
|
||||
mockGraphQL: (mocks: GraphQLMock[]) => Promise<void>;
|
||||
};
|
||||
|
||||
export const test = base.extend<GraphQLFixtures>({
|
||||
mockGraphQL: async ({ page }, use) => {
|
||||
await use(async (mocks) => {
|
||||
await page.route("**/graphql", async (route) => {
|
||||
const postData = route.request().postDataJSON();
|
||||
|
||||
const mock = mocks.find((m) => {
|
||||
if (m.operation !== postData.operationName) return false;
|
||||
|
||||
// Optionally match variables
|
||||
if (m.variables) {
|
||||
return (
|
||||
JSON.stringify(m.variables) === JSON.stringify(postData.variables)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (mock) {
|
||||
return route.fulfill({ json: mock.response });
|
||||
}
|
||||
|
||||
return route.continue();
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
test("dashboard with mocked GraphQL", async ({ page, mockGraphQL }) => {
|
||||
await mockGraphQL([
|
||||
{
|
||||
operation: "GetDashboardStats",
|
||||
response: {
|
||||
data: { stats: { users: 100, revenue: 50000 } },
|
||||
},
|
||||
},
|
||||
{
|
||||
operation: "GetUser",
|
||||
variables: { id: "1" },
|
||||
response: {
|
||||
data: { user: { id: "1", name: "John" } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.getByText("100 users")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Mock GraphQL Mutations
|
||||
|
||||
```typescript
|
||||
test("mock GraphQL mutation", async ({ page }) => {
|
||||
await page.route("**/graphql", async (route) => {
|
||||
const postData = route.request().postDataJSON();
|
||||
|
||||
if (postData.operationName === "CreateOrder") {
|
||||
const { input } = postData.variables;
|
||||
|
||||
return route.fulfill({
|
||||
json: {
|
||||
data: {
|
||||
createOrder: {
|
||||
id: "order-123",
|
||||
status: "PENDING",
|
||||
items: input.items,
|
||||
total: input.items.reduce(
|
||||
(sum: number, item: any) => sum + item.price * item.quantity,
|
||||
0,
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
await page.goto("/checkout");
|
||||
await page.getByRole("button", { name: "Place Order" }).click();
|
||||
|
||||
await expect(page.getByText("Order #order-123")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## HAR Recording & Playback
|
||||
|
||||
### Record HAR File
|
||||
|
||||
```typescript
|
||||
// Record network traffic
|
||||
test("record HAR", async ({ page, context }) => {
|
||||
// Start recording
|
||||
await context.routeFromHAR("./recordings/checkout.har", {
|
||||
update: true, // Create/update HAR file
|
||||
url: "**/api/**",
|
||||
});
|
||||
|
||||
await page.goto("/checkout");
|
||||
await page.getByRole("button", { name: "Place Order" }).click();
|
||||
|
||||
// HAR file is saved automatically
|
||||
});
|
||||
```
|
||||
|
||||
### Playback HAR File
|
||||
|
||||
```typescript
|
||||
// Use recorded HAR for offline testing
|
||||
test("playback HAR", async ({ page, context }) => {
|
||||
await context.routeFromHAR("./recordings/checkout.har", {
|
||||
url: "**/api/**",
|
||||
update: false, // Don't update, just playback
|
||||
});
|
||||
|
||||
await page.goto("/checkout");
|
||||
|
||||
// All API calls served from HAR file
|
||||
await expect(page.getByText("Order confirmed")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### HAR with Fallback
|
||||
|
||||
```typescript
|
||||
test("HAR with live fallback", async ({ page, context }) => {
|
||||
await context.routeFromHAR("./recordings/api.har", {
|
||||
url: "**/api/**",
|
||||
update: false,
|
||||
notFound: "fallback", // Use real network if not in HAR
|
||||
});
|
||||
|
||||
await page.goto("/dashboard");
|
||||
});
|
||||
```
|
||||
|
||||
## Conditional Mocking
|
||||
|
||||
### Mock Based on Request Body
|
||||
|
||||
```typescript
|
||||
test("conditional mock by body", async ({ page }) => {
|
||||
await page.route("**/api/search", async (route) => {
|
||||
const body = route.request().postDataJSON();
|
||||
|
||||
if (body.query === "error") {
|
||||
return route.fulfill({
|
||||
status: 500,
|
||||
json: { error: "Search failed" },
|
||||
});
|
||||
}
|
||||
|
||||
if (body.query === "empty") {
|
||||
return route.fulfill({
|
||||
json: { results: [] },
|
||||
});
|
||||
}
|
||||
|
||||
// Default response
|
||||
return route.fulfill({
|
||||
json: {
|
||||
results: [{ id: 1, title: `Result for: ${body.query}` }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/search");
|
||||
|
||||
// Test different scenarios
|
||||
await page.getByLabel("Search").fill("error");
|
||||
await page.getByLabel("Search").press("Enter");
|
||||
await expect(page.getByText("Search failed")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Mock Nth Request
|
||||
|
||||
```typescript
|
||||
test("different response on retry", async ({ page }) => {
|
||||
let callCount = 0;
|
||||
|
||||
await page.route("**/api/status", (route) => {
|
||||
callCount++;
|
||||
|
||||
if (callCount < 3) {
|
||||
return route.fulfill({
|
||||
status: 503,
|
||||
json: { error: "Service unavailable" },
|
||||
});
|
||||
}
|
||||
|
||||
// Succeed on 3rd attempt
|
||||
return route.fulfill({
|
||||
json: { status: "ok" },
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/dashboard");
|
||||
|
||||
// App should retry and eventually succeed
|
||||
await expect(page.getByText("Connected")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Mock with Delay
|
||||
|
||||
```typescript
|
||||
test("slow network simulation", async ({ page }) => {
|
||||
await page.route("**/api/data", async (route) => {
|
||||
// Simulate 2 second delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
return route.fulfill({
|
||||
json: { data: "loaded" },
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/dashboard");
|
||||
|
||||
// Loading state should appear
|
||||
await expect(page.getByText("Loading...")).toBeVisible();
|
||||
|
||||
// Then data appears
|
||||
await expect(page.getByText("loaded")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Network Throttling
|
||||
|
||||
### Slow 3G Simulation
|
||||
|
||||
```typescript
|
||||
test("slow network experience", async ({ page, context }) => {
|
||||
// Create CDP session for network throttling
|
||||
const client = await context.newCDPSession(page);
|
||||
|
||||
await client.send("Network.emulateNetworkConditions", {
|
||||
offline: false,
|
||||
downloadThroughput: (500 * 1024) / 8, // 500 Kbps
|
||||
uploadThroughput: (500 * 1024) / 8,
|
||||
latency: 400, // 400ms
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
// Test loading states appear
|
||||
await expect(page.getByTestId("skeleton-loader")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Offline Mode
|
||||
|
||||
Use `context.setOffline(true/false)` to simulate network connectivity changes.
|
||||
|
||||
> **For comprehensive offline testing patterns:**
|
||||
>
|
||||
> - **Network failure simulation** (error recovery, graceful degradation): See [error-testing.md](error-testing.md#offline-testing)
|
||||
> - **Offline-first/PWA testing** (service workers, caching, background sync): See [service-workers.md](service-workers.md#offline-testing)
|
||||
|
||||
### Network Throttling Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/network.fixture.ts
|
||||
type NetworkCondition = "slow3g" | "fast3g" | "offline";
|
||||
|
||||
const conditions = {
|
||||
slow3g: { downloadThroughput: 50000, uploadThroughput: 50000, latency: 2000 },
|
||||
fast3g: { downloadThroughput: 180000, uploadThroughput: 75000, latency: 150 },
|
||||
};
|
||||
|
||||
type NetworkFixtures = {
|
||||
setNetworkCondition: (condition: NetworkCondition) => Promise<void>;
|
||||
};
|
||||
|
||||
export const test = base.extend<NetworkFixtures>({
|
||||
setNetworkCondition: async ({ page, context }, use) => {
|
||||
const client = await context.newCDPSession(page);
|
||||
|
||||
await use(async (condition) => {
|
||||
if (condition === "offline") {
|
||||
await context.setOffline(true);
|
||||
} else {
|
||||
await client.send("Network.emulateNetworkConditions", {
|
||||
offline: false,
|
||||
...conditions[condition],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Reset
|
||||
await context.setOffline(false);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ------------------------ | ------------------------------ | -------------------------------- |
|
||||
| Mocking all requests | Tests don't reflect reality | Mock only what's necessary |
|
||||
| No cleanup of routes | Routes persist across tests | Use fixtures with cleanup |
|
||||
| Ignoring request method | Mock applies to wrong requests | Check `route.request().method()` |
|
||||
| Hardcoded mock responses | Brittle, hard to maintain | Use factories for mock data |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Basic Mocking**: See [test-suite-structure.md](../core/test-suite-structure.md) for simple mocking
|
||||
- **WebSockets**: See [websockets.md](../browser-apis/websockets.md) for real-time mocking
|
||||
@@ -0,0 +1,464 @@
|
||||
# Third-Party Service Mocking
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [OAuth/SSO Mocking](#oauthsso-mocking)
|
||||
2. [Payment Gateway Mocking](#payment-gateway-mocking)
|
||||
3. [Email Verification](#email-verification)
|
||||
4. [SMS Verification](#sms-verification)
|
||||
5. [Analytics & Tracking](#analytics--tracking)
|
||||
|
||||
## OAuth/SSO Mocking
|
||||
|
||||
### Mock Google OAuth
|
||||
|
||||
```typescript
|
||||
test("Google OAuth login", async ({ page }) => {
|
||||
// Mock the OAuth callback
|
||||
await page.route("**/auth/google/callback**", (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
// Simulate successful OAuth by redirecting with token
|
||||
route.fulfill({
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: "/dashboard?token=mock-jwt-token",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Mock the token verification endpoint
|
||||
await page.route("**/api/auth/verify", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
valid: true,
|
||||
user: {
|
||||
id: "123",
|
||||
email: "test@gmail.com",
|
||||
name: "Test User",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Sign in with Google" }).click();
|
||||
|
||||
await expect(page.getByText("Welcome, Test User")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### OAuth Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/oauth.fixture.ts
|
||||
type OAuthProvider = "google" | "github" | "microsoft";
|
||||
|
||||
type OAuthUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
type OAuthFixtures = {
|
||||
mockOAuth: (provider: OAuthProvider, user: OAuthUser) => Promise<void>;
|
||||
};
|
||||
|
||||
export const test = base.extend<OAuthFixtures>({
|
||||
mockOAuth: async ({ page }, use) => {
|
||||
await use(async (provider, user) => {
|
||||
// Mock callback redirect
|
||||
await page.route(`**/auth/${provider}/callback**`, (route) =>
|
||||
route.fulfill({
|
||||
status: 302,
|
||||
headers: { Location: `/auth/success?provider=${provider}` },
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock session/user endpoint
|
||||
await page.route("**/api/auth/session", (route) =>
|
||||
route.fulfill({
|
||||
json: { user, provider, authenticated: true },
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock user info endpoint
|
||||
await page.route("**/api/me", (route) => route.fulfill({ json: user }));
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
test("login with GitHub", async ({ page, mockOAuth }) => {
|
||||
await mockOAuth("github", {
|
||||
id: "gh-123",
|
||||
email: "dev@github.com",
|
||||
name: "GitHub User",
|
||||
});
|
||||
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Sign in with GitHub" }).click();
|
||||
|
||||
await expect(page.getByText("Welcome, GitHub User")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Mock SAML SSO
|
||||
|
||||
```typescript
|
||||
test("SAML SSO login", async ({ page }) => {
|
||||
// Mock SAML assertion consumer service
|
||||
await page.route("**/saml/acs", async (route) => {
|
||||
route.fulfill({
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: "/dashboard",
|
||||
"Set-Cookie": "session=mock-saml-session; Path=/; HttpOnly",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Mock session validation
|
||||
await page.route("**/api/session", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
user: { email: "user@company.com", name: "SSO User" },
|
||||
provider: "saml",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "SSO Login" }).click();
|
||||
|
||||
await expect(page).toHaveURL("/dashboard");
|
||||
});
|
||||
```
|
||||
|
||||
## Payment Gateway Mocking
|
||||
|
||||
### Mock Stripe
|
||||
|
||||
```typescript
|
||||
test("Stripe checkout", async ({ page }) => {
|
||||
// Mock Stripe.js
|
||||
await page.addInitScript(() => {
|
||||
(window as any).Stripe = () => ({
|
||||
elements: () => ({
|
||||
create: () => ({
|
||||
mount: () => {},
|
||||
on: () => {},
|
||||
destroy: () => {},
|
||||
}),
|
||||
}),
|
||||
confirmCardPayment: async () => ({
|
||||
paymentIntent: { status: "succeeded", id: "pi_mock_123" },
|
||||
}),
|
||||
createPaymentMethod: async () => ({
|
||||
paymentMethod: { id: "pm_mock_123" },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Mock backend payment endpoint
|
||||
await page.route("**/api/create-payment-intent", (route) =>
|
||||
route.fulfill({
|
||||
json: { clientSecret: "pi_mock_123_secret_mock" },
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route("**/api/confirm-payment", (route) =>
|
||||
route.fulfill({
|
||||
json: { success: true, orderId: "order-123" },
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/checkout");
|
||||
await page.getByRole("button", { name: "Pay $99.99" }).click();
|
||||
|
||||
await expect(page.getByText("Payment successful")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Mock PayPal
|
||||
|
||||
```typescript
|
||||
test("PayPal checkout", async ({ page }) => {
|
||||
// Mock PayPal SDK
|
||||
await page.addInitScript(() => {
|
||||
(window as any).paypal = {
|
||||
Buttons: () => ({
|
||||
render: () => Promise.resolve(),
|
||||
isEligible: () => true,
|
||||
}),
|
||||
FUNDING: { PAYPAL: "paypal", CARD: "card" },
|
||||
};
|
||||
});
|
||||
|
||||
// Mock PayPal order creation
|
||||
await page.route("**/api/paypal/create-order", (route) =>
|
||||
route.fulfill({
|
||||
json: { orderId: "PAYPAL-ORDER-123" },
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock PayPal capture
|
||||
await page.route("**/api/paypal/capture", (route) =>
|
||||
route.fulfill({
|
||||
json: { success: true, transactionId: "TXN-123" },
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/checkout");
|
||||
|
||||
// Simulate PayPal approval callback
|
||||
await page.evaluate(() => {
|
||||
(window as any).onPayPalApprove?.({ orderID: "PAYPAL-ORDER-123" });
|
||||
});
|
||||
|
||||
await expect(page.getByText("Order confirmed")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Payment Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/payment.fixture.ts
|
||||
type PaymentFixtures = {
|
||||
mockStripe: (options?: { failPayment?: boolean }) => Promise<void>;
|
||||
};
|
||||
|
||||
export const test = base.extend<PaymentFixtures>({
|
||||
mockStripe: async ({ page }, use) => {
|
||||
await use(async (options = {}) => {
|
||||
await page.addInitScript(
|
||||
([shouldFail]) => {
|
||||
(window as any).Stripe = () => ({
|
||||
elements: () => ({
|
||||
create: () => ({
|
||||
mount: () => {},
|
||||
on: (event: string, handler: Function) => {
|
||||
if (event === "ready") setTimeout(handler, 100);
|
||||
},
|
||||
destroy: () => {},
|
||||
}),
|
||||
}),
|
||||
confirmCardPayment: async () => {
|
||||
if (shouldFail) {
|
||||
return { error: { message: "Card declined" } };
|
||||
}
|
||||
return { paymentIntent: { status: "succeeded" } };
|
||||
},
|
||||
});
|
||||
},
|
||||
[options.failPayment],
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
test("handles declined card", async ({ page, mockStripe }) => {
|
||||
await mockStripe({ failPayment: true });
|
||||
|
||||
await page.goto("/checkout");
|
||||
await page.getByRole("button", { name: "Pay" }).click();
|
||||
|
||||
await expect(page.getByText("Card declined")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Email Verification
|
||||
|
||||
### Mock Email API
|
||||
|
||||
```typescript
|
||||
test("email verification flow", async ({ page, request }) => {
|
||||
let verificationToken: string;
|
||||
|
||||
// Capture the verification email
|
||||
await page.route("**/api/send-verification", async (route) => {
|
||||
const body = route.request().postDataJSON();
|
||||
verificationToken = `mock-token-${Date.now()}`;
|
||||
|
||||
// Don't actually send email, just store token
|
||||
route.fulfill({
|
||||
json: { sent: true, messageId: "msg-123" },
|
||||
});
|
||||
});
|
||||
|
||||
// Mock token verification
|
||||
await page.route("**/api/verify-email**", (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const token = url.searchParams.get("token");
|
||||
|
||||
if (token === verificationToken) {
|
||||
route.fulfill({ json: { verified: true } });
|
||||
} else {
|
||||
route.fulfill({ status: 400, json: { error: "Invalid token" } });
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("/signup");
|
||||
await page.getByLabel("Email").fill("test@example.com");
|
||||
await page.getByRole("button", { name: "Sign Up" }).click();
|
||||
|
||||
await expect(page.getByText("Check your email")).toBeVisible();
|
||||
|
||||
// Simulate clicking email link
|
||||
await page.goto(`/verify?token=${verificationToken}`);
|
||||
|
||||
await expect(page.getByText("Email verified")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Use Mailinator/Temp Mail
|
||||
|
||||
```typescript
|
||||
// fixtures/email.fixture.ts
|
||||
type EmailFixtures = {
|
||||
getVerificationEmail: (inbox: string) => Promise<{ link: string }>;
|
||||
};
|
||||
|
||||
export const test = base.extend<EmailFixtures>({
|
||||
getVerificationEmail: async ({ request }, use) => {
|
||||
await use(async (inbox) => {
|
||||
// Poll Mailinator API for new email
|
||||
const response = await request.get(
|
||||
`https://api.mailinator.com/v2/domains/public/inboxes/${inbox}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.MAILINATOR_API_KEY}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const messages = await response.json();
|
||||
const latest = messages.msgs[0];
|
||||
|
||||
// Get full message
|
||||
const msgResponse = await request.get(
|
||||
`https://api.mailinator.com/v2/domains/public/inboxes/${inbox}/messages/${latest.id}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.MAILINATOR_API_KEY}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const message = await msgResponse.json();
|
||||
|
||||
// Extract verification link from HTML
|
||||
const linkMatch = message.parts[0].body.match(
|
||||
/href="([^"]*verify[^"]*)"/,
|
||||
);
|
||||
return { link: linkMatch?.[1] || "" };
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## SMS Verification
|
||||
|
||||
### Mock SMS API
|
||||
|
||||
```typescript
|
||||
test("SMS verification", async ({ page }) => {
|
||||
let smsCode: string;
|
||||
|
||||
// Capture SMS send
|
||||
await page.route("**/api/send-sms", (route) => {
|
||||
smsCode = Math.random().toString().slice(2, 8); // 6-digit code
|
||||
|
||||
route.fulfill({
|
||||
json: { sent: true, messageId: "sms-123" },
|
||||
});
|
||||
});
|
||||
|
||||
// Mock code verification
|
||||
await page.route("**/api/verify-sms", (route) => {
|
||||
const body = route.request().postDataJSON();
|
||||
|
||||
if (body.code === smsCode) {
|
||||
route.fulfill({ json: { verified: true } });
|
||||
} else {
|
||||
route.fulfill({ status: 400, json: { error: "Invalid code" } });
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("/verify-phone");
|
||||
await page.getByLabel("Phone").fill("+1234567890");
|
||||
await page.getByRole("button", { name: "Send Code" }).click();
|
||||
|
||||
// Enter the code
|
||||
await page.getByLabel("Verification Code").fill(smsCode);
|
||||
await page.getByRole("button", { name: "Verify" }).click();
|
||||
|
||||
await expect(page.getByText("Phone verified")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Analytics & Tracking
|
||||
|
||||
### Block Analytics in Tests
|
||||
|
||||
```typescript
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Block all analytics/tracking
|
||||
await page.route(
|
||||
/google-analytics|googletagmanager|facebook|hotjar|segment|mixpanel|amplitude/,
|
||||
(route) => route.abort(),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Mock Analytics for Verification
|
||||
|
||||
```typescript
|
||||
test("tracks purchase event", async ({ page }) => {
|
||||
const analyticsEvents: any[] = [];
|
||||
|
||||
// Capture analytics calls
|
||||
await page.route("**/api/analytics/**", (route) => {
|
||||
analyticsEvents.push(route.request().postDataJSON());
|
||||
route.fulfill({ status: 200 });
|
||||
});
|
||||
|
||||
// Mock analytics SDK
|
||||
await page.addInitScript(() => {
|
||||
(window as any).analytics = {
|
||||
track: (event: string, props: any) => {
|
||||
fetch("/api/analytics/track", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ event, props }),
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await page.goto("/checkout");
|
||||
await page.getByRole("button", { name: "Complete Purchase" }).click();
|
||||
|
||||
// Verify analytics event was sent
|
||||
expect(analyticsEvents).toContainEqual(
|
||||
expect.objectContaining({
|
||||
event: "Purchase Completed",
|
||||
props: expect.objectContaining({ amount: expect.any(Number) }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ------------------------- | ------------------------------ | ----------------------- |
|
||||
| Using real OAuth in tests | Slow, needs credentials, flaky | Mock OAuth endpoints |
|
||||
| Real payment processing | Charges real money, slow | Use test mode or mock |
|
||||
| Waiting for real emails | Very slow, unreliable | Mock email API |
|
||||
| Not mocking analytics | Pollutes analytics data | Block or mock analytics |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Network Mocking**: See [network-advanced.md](network-advanced.md) for route patterns
|
||||
- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth patterns
|
||||
@@ -0,0 +1,363 @@
|
||||
# Organizing Reusable Test Code
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Pattern Comparison](#pattern-comparison)
|
||||
2. [Selection Flowchart](#selection-flowchart)
|
||||
3. [Page Objects](#page-objects)
|
||||
4. [Custom Fixtures](#custom-fixtures)
|
||||
5. [Helper Functions](#helper-functions)
|
||||
6. [Combined Project Structure](#combined-project-structure)
|
||||
7. [Anti-Patterns](#anti-patterns)
|
||||
|
||||
Use all three patterns together. Most projects benefit from a hybrid approach:
|
||||
|
||||
- **Page objects** for UI interaction (pages/components with 5+ interactions)
|
||||
- **Custom fixtures** for test infrastructure (auth state, database, API clients, anything with lifecycle)
|
||||
- **Helper functions** for stateless utilities (generate data, format values, simple waits)
|
||||
|
||||
If only using one pattern, choose **custom fixtures** — they handle setup/teardown, compose well, and Playwright is built around them.
|
||||
|
||||
## Pattern Comparison
|
||||
|
||||
| Aspect | Page Objects | Custom Fixtures | Helper Functions |
|
||||
|---|---|---|---|
|
||||
| **Purpose** | Encapsulate UI interactions | Provide resources with setup/teardown | Stateless utilities |
|
||||
| **Lifecycle** | Manual (constructor/methods) | Built-in (`use()` with automatic teardown) | None |
|
||||
| **Composability** | Constructor injection or fixture wiring | Depend on other fixtures | Call other functions |
|
||||
| **Best for** | Pages with many reused interactions | Resources needing setup AND teardown | Simple logic with no side effects |
|
||||
|
||||
## Selection Flowchart
|
||||
|
||||
```text
|
||||
What kind of reusable code?
|
||||
|
|
||||
+-- Interacts with browser page/component?
|
||||
| |
|
||||
| +-- Has 5+ interactions (fill, click, navigate, assert)?
|
||||
| | +-- YES: Used in 3+ test files?
|
||||
| | | +-- YES --> PAGE OBJECT
|
||||
| | | +-- NO --> Inline or small helper
|
||||
| | +-- NO --> HELPER FUNCTION
|
||||
| |
|
||||
| +-- Needs setup before AND cleanup after test?
|
||||
| +-- YES --> CUSTOM FIXTURE
|
||||
| +-- NO --> PAGE OBJECT method or HELPER
|
||||
|
|
||||
+-- Manages resource with lifecycle (create/destroy)?
|
||||
| +-- Examples: auth state, DB connection, API client, test user
|
||||
| +-- YES --> CUSTOM FIXTURE (always)
|
||||
|
|
||||
+-- Stateless utility? (no browser, no side effects)
|
||||
| +-- Examples: random email, format date, build URL, parse response
|
||||
| +-- YES --> HELPER FUNCTION
|
||||
|
|
||||
+-- Not sure?
|
||||
+-- Start with HELPER FUNCTION
|
||||
+-- Promote to PAGE OBJECT when interactions grow
|
||||
+-- Promote to FIXTURE when lifecycle needed
|
||||
```
|
||||
|
||||
## Page Objects
|
||||
|
||||
Best for pages/components with 5+ interactions appearing in 3+ test files.
|
||||
|
||||
```typescript
|
||||
// page-objects/booking.page.ts
|
||||
import { type Page, type Locator, expect } from '@playwright/test';
|
||||
|
||||
export class BookingPage {
|
||||
readonly page: Page;
|
||||
readonly dateField: Locator;
|
||||
readonly guestCount: Locator;
|
||||
readonly roomType: Locator;
|
||||
readonly reserveBtn: Locator;
|
||||
readonly totalPrice: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.dateField = page.getByLabel('Check-in date');
|
||||
this.guestCount = page.getByLabel('Guests');
|
||||
this.roomType = page.getByLabel('Room type');
|
||||
this.reserveBtn = page.getByRole('button', { name: 'Reserve' });
|
||||
this.totalPrice = page.getByTestId('total-price');
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/booking');
|
||||
}
|
||||
|
||||
async fillDetails(opts: { date: string; guests: number; room: string }) {
|
||||
await this.dateField.fill(opts.date);
|
||||
await this.guestCount.fill(String(opts.guests));
|
||||
await this.roomType.selectOption(opts.room);
|
||||
}
|
||||
|
||||
async reserve() {
|
||||
await this.reserveBtn.click();
|
||||
await this.page.waitForURL('**/confirmation');
|
||||
}
|
||||
|
||||
async expectPrice(amount: string) {
|
||||
await expect(this.totalPrice).toHaveText(amount);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/booking/reservation.spec.ts
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { BookingPage } from '../page-objects/booking.page';
|
||||
|
||||
test('complete reservation with standard room', async ({ page }) => {
|
||||
const booking = new BookingPage(page);
|
||||
await booking.goto();
|
||||
await booking.fillDetails({ date: '2026-03-15', guests: 2, room: 'standard' });
|
||||
await booking.reserve();
|
||||
await expect(page.getByText('Reservation confirmed')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
**Page object principles:**
|
||||
- One class per logical page/component, not per URL
|
||||
- Constructor takes `Page`
|
||||
- Locators as `readonly` properties in constructor
|
||||
- Methods represent user intent (`reserve`, `fillDetails`), not low-level clicks
|
||||
- Navigation methods (`goto`) belong on the page object
|
||||
|
||||
## Custom Fixtures
|
||||
|
||||
Best for resources needing setup before and teardown after tests — auth state, database connections, API clients, test users.
|
||||
|
||||
```typescript
|
||||
// fixtures/base.fixture.ts
|
||||
import { test as base, expect } from '@playwright/test';
|
||||
import { BookingPage } from '../page-objects/booking.page';
|
||||
import { generateMember } from '../helpers/data';
|
||||
|
||||
type Fixtures = {
|
||||
bookingPage: BookingPage;
|
||||
member: { email: string; password: string; id: string };
|
||||
loggedInPage: import('@playwright/test').Page;
|
||||
};
|
||||
|
||||
export const test = base.extend<Fixtures>({
|
||||
bookingPage: async ({ page }, use) => {
|
||||
await use(new BookingPage(page));
|
||||
},
|
||||
|
||||
member: async ({ request }, use) => {
|
||||
const data = generateMember();
|
||||
const res = await request.post('/api/test/members', { data });
|
||||
const member = await res.json();
|
||||
await use(member);
|
||||
await request.delete(`/api/test/members/${member.id}`);
|
||||
},
|
||||
|
||||
loggedInPage: async ({ page, member }, use) => {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Email').fill(member.email);
|
||||
await page.getByLabel('Password').fill(member.password);
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
await expect(page).toHaveURL('/dashboard');
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from '@playwright/test';
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/dashboard/overview.spec.ts
|
||||
import { test, expect } from '../../fixtures/base.fixture';
|
||||
|
||||
test('member sees dashboard widgets', async ({ loggedInPage }) => {
|
||||
await expect(loggedInPage.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||||
await expect(loggedInPage.getByTestId('stats-widget')).toBeVisible();
|
||||
});
|
||||
|
||||
test('new member sees welcome prompt', async ({ loggedInPage, member }) => {
|
||||
await expect(loggedInPage.getByText(`Welcome, ${member.email}`)).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
**Fixture principles:**
|
||||
- Use `test.extend()` — never module-level variables
|
||||
- `use()` callback separates setup from teardown
|
||||
- Teardown runs even if test fails
|
||||
- Fixtures compose: one can depend on another
|
||||
- Fixtures are lazy: created only when requested
|
||||
- Wrap page objects in fixtures for lifecycle management
|
||||
|
||||
## Helper Functions
|
||||
|
||||
Best for stateless utilities — generating test data, formatting values, building URLs, parsing responses.
|
||||
|
||||
```typescript
|
||||
// helpers/data.ts
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export function generateEmail(prefix = 'user'): string {
|
||||
return `${prefix}-${Date.now()}-${randomUUID().slice(0, 8)}@test.local`;
|
||||
}
|
||||
|
||||
export function generateMember(overrides: Partial<Member> = {}): Member {
|
||||
return {
|
||||
email: generateEmail(),
|
||||
password: 'SecurePass456!',
|
||||
name: 'Test Member',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface Member {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function formatPrice(cents: number): string {
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// helpers/assertions.ts
|
||||
import { type Page, expect } from '@playwright/test';
|
||||
|
||||
export async function expectNotification(page: Page, message: string): Promise<void> {
|
||||
const notification = page.getByRole('alert').filter({ hasText: message });
|
||||
await expect(notification).toBeVisible();
|
||||
await expect(notification).toBeHidden({ timeout: 10000 });
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/settings/account.spec.ts
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { generateEmail } from '../../helpers/data';
|
||||
import { expectNotification } from '../../helpers/assertions';
|
||||
|
||||
test('update account email', async ({ page }) => {
|
||||
const newEmail = generateEmail('updated');
|
||||
await page.goto('/settings/account');
|
||||
await page.getByLabel('Email').fill(newEmail);
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await expectNotification(page, 'Account updated');
|
||||
await expect(page.getByLabel('Email')).toHaveValue(newEmail);
|
||||
});
|
||||
```
|
||||
|
||||
**Helper principles:**
|
||||
- Pure functions with no side effects
|
||||
- No browser state — take `page` as parameter if needed
|
||||
- Promote to fixture if setup/teardown needed
|
||||
- Promote to page object if many page interactions grow
|
||||
- Keep small and focused
|
||||
|
||||
## Combined Project Structure
|
||||
|
||||
```text
|
||||
tests/
|
||||
+-- fixtures/
|
||||
| +-- auth.fixture.ts
|
||||
| +-- db.fixture.ts
|
||||
| +-- base.fixture.ts
|
||||
+-- page-objects/
|
||||
| +-- login.page.ts
|
||||
| +-- booking.page.ts
|
||||
| +-- components/
|
||||
| +-- data-table.component.ts
|
||||
+-- helpers/
|
||||
| +-- data.ts
|
||||
| +-- assertions.ts
|
||||
+-- e2e/
|
||||
| +-- auth/
|
||||
| | +-- login.spec.ts
|
||||
| +-- booking/
|
||||
| +-- reservation.spec.ts
|
||||
playwright.config.ts
|
||||
```
|
||||
|
||||
**Layer responsibilities:**
|
||||
|
||||
| Layer | Pattern | Responsibility |
|
||||
|---|---|---|
|
||||
| **Test file** | `test()` | Describes behavior, orchestrates layers |
|
||||
| **Fixtures** | `test.extend()` | Resource lifecycle — setup, provide, teardown |
|
||||
| **Page objects** | Classes | UI interaction — navigation, actions, locators |
|
||||
| **Helpers** | Functions | Utilities — data generation, formatting, assertions |
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### Page object managing resources
|
||||
|
||||
```typescript
|
||||
// BAD: page object handling API calls and database
|
||||
class LoginPage {
|
||||
async createUser() { /* API call */ }
|
||||
async deleteUser() { /* API call */ }
|
||||
async signIn(email: string, password: string) { /* UI */ }
|
||||
}
|
||||
```
|
||||
|
||||
Resource lifecycle belongs in fixtures where teardown is guaranteed. Keep only `signIn` in the page object.
|
||||
|
||||
### Locator-only page objects
|
||||
|
||||
```typescript
|
||||
// BAD: no methods, just locators
|
||||
class LoginPage {
|
||||
emailInput = this.page.getByLabel('Email');
|
||||
passwordInput = this.page.getByLabel('Password');
|
||||
submitBtn = this.page.getByRole('button', { name: 'Sign in' });
|
||||
constructor(private page: Page) {}
|
||||
}
|
||||
```
|
||||
|
||||
Add intent-revealing methods or skip the page object entirely.
|
||||
|
||||
### Monolithic fixtures
|
||||
|
||||
```typescript
|
||||
// BAD: one fixture doing everything
|
||||
test.extend({
|
||||
everything: async ({ page, request }, use) => {
|
||||
const user = await createUser(request);
|
||||
const products = await seedProducts(request, 50);
|
||||
await setupPayment(request, user.id);
|
||||
await page.goto('/dashboard');
|
||||
await use({ user, products, page });
|
||||
// massive teardown...
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Break into small, composable fixtures. Each fixture does one thing.
|
||||
|
||||
### Helpers with side effects
|
||||
|
||||
```typescript
|
||||
// BAD: module-level state
|
||||
let createdUserId: string;
|
||||
|
||||
export async function createTestUser(request: APIRequestContext) {
|
||||
const res = await request.post('/api/users', { data: { email: 'test@example.com' } });
|
||||
const user = await res.json();
|
||||
createdUserId = user.id; // shared across tests!
|
||||
return user;
|
||||
}
|
||||
```
|
||||
|
||||
Module-level state leaks between parallel tests. If it has side effects and needs cleanup, make it a fixture.
|
||||
|
||||
### Over-abstracting simple operations
|
||||
|
||||
```typescript
|
||||
// BAD: helper for one-liner
|
||||
export async function clickButton(page: Page, name: string) {
|
||||
await page.getByRole('button', { name }).click();
|
||||
}
|
||||
```
|
||||
|
||||
Only abstract when there is real duplication (3+ usages) or complexity (5+ interactions).
|
||||
@@ -0,0 +1,369 @@
|
||||
# Choosing Test Types: E2E, Component, or API
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Decision Matrix](#decision-matrix)
|
||||
2. [API Tests](#api-tests)
|
||||
3. [Component Tests](#component-tests)
|
||||
4. [E2E Tests](#e2e-tests)
|
||||
5. [Layering Test Types](#layering-test-types)
|
||||
6. [Common Mistakes](#common-mistakes)
|
||||
7. [Related](#related)
|
||||
|
||||
> **When to use**: Deciding which test type to write for a feature. Ask: "What's the cheapest test that gives confidence this works?"
|
||||
|
||||
## Decision Matrix
|
||||
|
||||
| Scenario | Recommended Type | Rationale |
|
||||
| --------------------------- | ---------------- | --------------------------------------------- |
|
||||
| Login / auth flow | E2E | Cross-page, cookies, redirects, session state |
|
||||
| Form submission | Component | Isolated validation logic, error states |
|
||||
| CRUD operations | API | Data integrity matters more than UI |
|
||||
| Search with results UI | Component + API | API for query logic; component for rendering |
|
||||
| Cross-page navigation | E2E | Routing, history, deep linking |
|
||||
| API error handling | API | Status codes, error shapes, edge cases |
|
||||
| UI error feedback | Component | Toast, banner, inline error rendering |
|
||||
| Accessibility | Component | ARIA roles, keyboard nav per-component |
|
||||
| Responsive layout | Component | Viewport-specific rendering without full app |
|
||||
| API contract validation | API | Response shapes, headers, auth |
|
||||
| WebSocket/real-time | E2E | Requires full browser environment |
|
||||
| Payment / checkout | E2E | Multi-step, third-party iframes |
|
||||
| Onboarding wizard | E2E | Multi-step, state persists across pages |
|
||||
| Widget behavior | Component | Toggle, accordion, date picker, modal |
|
||||
| Permissions / authorization | API | Role-based access is backend logic |
|
||||
|
||||
## API Tests
|
||||
|
||||
**Ideal for**:
|
||||
|
||||
- CRUD operations (create, read, update, delete)
|
||||
- Input validation and error responses (400, 422)
|
||||
- Permission and authorization checks
|
||||
- Data integrity and business rules
|
||||
- API contract verification
|
||||
- Edge cases expensive to reproduce through UI
|
||||
- Test data setup/teardown for E2E tests
|
||||
|
||||
**Avoid for**:
|
||||
|
||||
- Testing how errors display to users
|
||||
- Browser-specific behavior (cookies, redirects)
|
||||
- Visual layout or responsive design
|
||||
- Flows requiring JavaScript execution or DOM interaction
|
||||
- Third-party iframe interactions
|
||||
|
||||
```typescript
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Products API", () => {
|
||||
let token: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const res = await request.post("/api/auth/token", {
|
||||
data: { email: "manager@shop.io", password: "mgr-secret" },
|
||||
});
|
||||
token = (await res.json()).accessToken;
|
||||
});
|
||||
|
||||
test("creates product with valid payload", async ({ request }) => {
|
||||
const res = await request.post("/api/products", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: { name: "Widget Pro", sku: "WGT-100", price: 29.99 },
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(201);
|
||||
const product = await res.json();
|
||||
expect(product).toMatchObject({ name: "Widget Pro", sku: "WGT-100" });
|
||||
expect(product).toHaveProperty("id");
|
||||
});
|
||||
|
||||
test("rejects duplicate SKU with 409", async ({ request }) => {
|
||||
const res = await request.post("/api/products", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: { name: "Duplicate", sku: "WGT-100", price: 19.99 },
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(409);
|
||||
expect((await res.json()).message).toContain("already exists");
|
||||
});
|
||||
|
||||
test("returns 422 for missing required fields", async ({ request }) => {
|
||||
const res = await request.post("/api/products", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: { name: "Incomplete" },
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(422);
|
||||
const err = await res.json();
|
||||
expect(err.errors).toContainEqual(
|
||||
expect.objectContaining({ field: "sku" })
|
||||
);
|
||||
});
|
||||
|
||||
test("staff role cannot delete products", async ({ request }) => {
|
||||
const staffLogin = await request.post("/api/auth/token", {
|
||||
data: { email: "staff@shop.io", password: "staff-pass" },
|
||||
});
|
||||
const staffToken = (await staffLogin.json()).accessToken;
|
||||
|
||||
const res = await request.delete("/api/products/123", {
|
||||
headers: { Authorization: `Bearer ${staffToken}` },
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(403);
|
||||
});
|
||||
|
||||
test("lists products with pagination", async ({ request }) => {
|
||||
const res = await request.get("/api/products", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
params: { page: "1", limit: "20" },
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.items).toBeInstanceOf(Array);
|
||||
expect(body.items.length).toBeLessThanOrEqual(20);
|
||||
expect(body).toHaveProperty("totalCount");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Component Tests
|
||||
|
||||
**Ideal for**:
|
||||
|
||||
- Form validation (required fields, format rules, error messages)
|
||||
- Interactive widgets (modals, dropdowns, accordions, date pickers)
|
||||
- Conditional rendering (show/hide, loading states, empty states)
|
||||
- Accessibility per-component (ARIA attributes, keyboard navigation)
|
||||
- Responsive layout at different viewports
|
||||
- Visual states (hover, focus, disabled, selected)
|
||||
|
||||
**Avoid for**:
|
||||
|
||||
- Testing routing or navigation between pages
|
||||
- Flows requiring real cookies, sessions, or server-side state
|
||||
- Data persistence or API contract validation
|
||||
- Third-party iframe interactions
|
||||
- Anything requiring multiple pages or browser contexts
|
||||
|
||||
```typescript
|
||||
import { test, expect } from "@playwright/experimental-ct-react";
|
||||
import { ContactForm } from "../src/components/ContactForm";
|
||||
|
||||
test.describe("ContactForm component", () => {
|
||||
test("displays validation errors on empty submit", async ({ mount }) => {
|
||||
const component = await mount(<ContactForm onSubmit={() => {}} />);
|
||||
|
||||
await component.getByRole("button", { name: "Send message" }).click();
|
||||
|
||||
await expect(component.getByText("Name is required")).toBeVisible();
|
||||
await expect(component.getByText("Email is required")).toBeVisible();
|
||||
});
|
||||
|
||||
test("rejects malformed email", async ({ mount }) => {
|
||||
const component = await mount(<ContactForm onSubmit={() => {}} />);
|
||||
|
||||
await component.getByLabel("Name").fill("Alex");
|
||||
await component.getByLabel("Email").fill("invalid-email");
|
||||
await component.getByLabel("Message").fill("Hello");
|
||||
await component.getByRole("button", { name: "Send message" }).click();
|
||||
|
||||
await expect(component.getByText("Enter a valid email")).toBeVisible();
|
||||
});
|
||||
|
||||
test("invokes onSubmit with form data", async ({ mount }) => {
|
||||
const submissions: Array<{ name: string; email: string; message: string }> =
|
||||
[];
|
||||
const component = await mount(
|
||||
<ContactForm onSubmit={(data) => submissions.push(data)} />
|
||||
);
|
||||
|
||||
await component.getByLabel("Name").fill("Alex");
|
||||
await component.getByLabel("Email").fill("alex@company.org");
|
||||
await component.getByLabel("Message").fill("Inquiry about pricing");
|
||||
await component.getByRole("button", { name: "Send message" }).click();
|
||||
|
||||
expect(submissions).toHaveLength(1);
|
||||
expect(submissions[0]).toEqual({
|
||||
name: "Alex",
|
||||
email: "alex@company.org",
|
||||
message: "Inquiry about pricing",
|
||||
});
|
||||
});
|
||||
|
||||
test("disables button during submission", async ({ mount }) => {
|
||||
const component = await mount(
|
||||
<ContactForm onSubmit={() => {}} submitting={true} />
|
||||
);
|
||||
|
||||
await expect(
|
||||
component.getByRole("button", { name: "Sending..." })
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test("associates labels with inputs for accessibility", async ({ mount }) => {
|
||||
const component = await mount(<ContactForm onSubmit={() => {}} />);
|
||||
|
||||
await expect(
|
||||
component.getByRole("textbox", { name: "Name" })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
component.getByRole("textbox", { name: "Email" })
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## E2E Tests
|
||||
|
||||
**Ideal for**:
|
||||
|
||||
- Critical user flows that generate revenue (checkout, signup)
|
||||
- Authentication flows (login, SSO, MFA, password reset)
|
||||
- Multi-page workflows where state carries across navigation
|
||||
- Flows involving third-party iframes (payment widgets)
|
||||
- Smoke tests validating the entire stack
|
||||
- Real-time collaboration requiring multiple browser contexts
|
||||
|
||||
**Avoid for**:
|
||||
|
||||
- Testing every form validation permutation
|
||||
- CRUD operations where UI is a thin wrapper
|
||||
- Verifying individual component states
|
||||
- Testing API response shapes or error codes
|
||||
- Responsive layout at every breakpoint
|
||||
- Edge cases that only affect the backend
|
||||
|
||||
```typescript
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("subscription flow", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.request.post("/api/test/seed-account", {
|
||||
data: { plan: "free", email: "subscriber@demo.io" },
|
||||
});
|
||||
await page.goto("/account/upgrade");
|
||||
});
|
||||
|
||||
test("upgrades to premium plan", async ({ page }) => {
|
||||
await test.step("select plan", async () => {
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Choose Your Plan" })
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Select Premium" }).click();
|
||||
});
|
||||
|
||||
await test.step("enter billing details", async () => {
|
||||
await page.getByLabel("Cardholder name").fill("Sam Johnson");
|
||||
await page.getByLabel("Billing address").fill("456 Oak Ave");
|
||||
await page.getByLabel("City").fill("Seattle");
|
||||
await page.getByRole("combobox", { name: "State" }).selectOption("WA");
|
||||
await page.getByLabel("Postal code").fill("98101");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
});
|
||||
|
||||
await test.step("complete payment", async () => {
|
||||
const paymentFrame = page.frameLocator('iframe[title="Secure Payment"]');
|
||||
await paymentFrame.getByLabel("Card number").fill("5555555555554444");
|
||||
await paymentFrame.getByLabel("Expiry").fill("09/29");
|
||||
await paymentFrame.getByLabel("CVV").fill("456");
|
||||
await page.getByRole("button", { name: "Subscribe now" }).click();
|
||||
});
|
||||
|
||||
await test.step("verify success", async () => {
|
||||
await page.waitForURL("**/account/subscription/success**");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Welcome to Premium" })
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(/Subscription #\d+/)).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Layering Test Types
|
||||
|
||||
Effective test suites combine all three types. Example for an "inventory management" feature:
|
||||
|
||||
### API Layer (60% of tests)
|
||||
|
||||
Cover every backend logic permutation. Cheap to run and maintain.
|
||||
|
||||
```
|
||||
tests/api/inventory.spec.ts
|
||||
- creates item with valid data (201)
|
||||
- rejects duplicate SKU (409)
|
||||
- rejects invalid quantity format (422)
|
||||
- rejects missing required fields (422)
|
||||
- warehouse-staff cannot delete items (403)
|
||||
- unauthenticated request returns 401
|
||||
- lists items with pagination
|
||||
- filters items by category
|
||||
- updates item stock level
|
||||
- archives an item
|
||||
- prevents archiving items with pending orders
|
||||
```
|
||||
|
||||
### Component Layer (30% of tests)
|
||||
|
||||
Cover every visual state and interaction.
|
||||
|
||||
```
|
||||
tests/components/InventoryForm.spec.tsx
|
||||
- shows validation errors on empty submit
|
||||
- shows inline error for invalid SKU format
|
||||
- disables submit while saving
|
||||
- calls onSubmit with form data
|
||||
- resets form after successful save
|
||||
|
||||
tests/components/InventoryTable.spec.tsx
|
||||
- renders item rows from props
|
||||
- shows empty state when no items
|
||||
- handles archive confirmation modal
|
||||
- sorts by column header click
|
||||
- shows stock level badges with correct colors
|
||||
```
|
||||
|
||||
### E2E Layer (10% of tests)
|
||||
|
||||
Cover only critical paths proving full stack works.
|
||||
|
||||
```
|
||||
tests/e2e/inventory.spec.ts
|
||||
- manager creates item and sees it in list
|
||||
- manager updates item stock level
|
||||
- warehouse-staff cannot access admin settings
|
||||
```
|
||||
|
||||
### Execution Profile
|
||||
|
||||
For this feature:
|
||||
|
||||
- **11 API tests** — ~2 seconds total, no browser
|
||||
- **10 component tests** — ~5 seconds total, real browser but no server
|
||||
- **3 E2E tests** — ~15 seconds total, full stack
|
||||
|
||||
Total: 24 tests, ~22 seconds. API tests catch most regressions. Component tests catch UI bugs. E2E tests prove wiring works. If E2E fails but API and component pass, the problem is in integration (routing, state management, API client).
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
| Anti-Pattern | Problem | Better Approach |
|
||||
| ----------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------------- |
|
||||
| E2E for every validation rule | 30-second browser test for something API covers in 200ms | API test for validation, one component test for error display |
|
||||
| No API tests, all E2E | Slow suite, flaky from UI timing, hard to diagnose | API tests for data/logic, E2E for critical paths only |
|
||||
| Component tests mocking everything | Tests pass but app broken because mocks drift | Mock only external boundaries; API tests verify real contracts |
|
||||
| Same assertion in API, component, AND E2E | Triple maintenance cost | Each layer tests what it uniquely verifies |
|
||||
| E2E creating test data via UI | 2-minute test where 90 seconds is setup | Seed via API in `beforeEach`, test actual flow |
|
||||
| Testing third-party behavior | Testing that Stripe validates cards (Stripe's job) | Mock Stripe; trust their contract |
|
||||
| Skipping API layer | Can't tell if bug is frontend or backend | API tests isolate backend; component tests isolate frontend |
|
||||
| One giant E2E for entire feature | 5-minute test failing somewhere with no clear cause | Focused E2E per critical path; use `test.step()` |
|
||||
|
||||
## Related
|
||||
|
||||
- [test-suite-structure.md](../core/test-suite-structure.md) — file structure and naming
|
||||
- [api-testing.md](../testing-patterns/api-testing.md) — Playwright's `request` API for HTTP testing
|
||||
- [component-testing.md](../testing-patterns/component-testing.md) — setting up component tests
|
||||
- [authentication.md](../advanced/authentication.md) — auth flow patterns with `storageState`
|
||||
- [when-to-mock.md](when-to-mock.md) — when to mock vs hit real services
|
||||
- [pom-vs-fixtures.md](pom-vs-fixtures.md) — organizing shared test logic
|
||||
@@ -0,0 +1,383 @@
|
||||
# Mocking Strategy: Real vs Mock Services
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Core Principle](#core-principle)
|
||||
2. [Decision Matrix](#decision-matrix)
|
||||
3. [Decision Flowchart](#decision-flowchart)
|
||||
4. [Mocking Techniques](#mocking-techniques)
|
||||
5. [Real Service Strategies](#real-service-strategies)
|
||||
6. [Hybrid Approach: Fixture-Based Mock Control](#hybrid-approach-fixture-based-mock-control)
|
||||
7. [Validating Mock Accuracy](#validating-mock-accuracy)
|
||||
8. [Anti-Patterns](#anti-patterns)
|
||||
|
||||
> **When to use**: Deciding whether to mock API calls, intercept network requests, or hit real services in Playwright tests.
|
||||
|
||||
## Core Principle
|
||||
|
||||
**Mock at the boundary, test your stack end-to-end.** Mock third-party services you don't own (payment gateways, email providers, OAuth). Never mock your own frontend-to-backend communication. Tests should prove YOUR code works, not that third-party APIs are available.
|
||||
|
||||
## Decision Matrix
|
||||
|
||||
| Scenario | Mock? | Strategy |
|
||||
| --- | --- | --- |
|
||||
| Your own REST/GraphQL API | Never | Hit real API against staging or local dev |
|
||||
| Your database (through your API) | Never | Seed via API or fixtures |
|
||||
| Authentication (your auth system) | Mostly no | Use `storageState` to skip login in most tests |
|
||||
| Stripe / payment gateway | Always | `route.fulfill()` with expected responses |
|
||||
| SendGrid / email service | Always | Mock the API call, verify request payload |
|
||||
| OAuth providers (Google, GitHub) | Always | Mock token exchange, test your callback handler |
|
||||
| Analytics (Segment, Mixpanel) | Always | `route.abort()` or `route.fulfill()` |
|
||||
| Maps / geocoding APIs | Always | Mock with static responses |
|
||||
| Feature flags (LaunchDarkly) | Usually | Mock to force specific flag states |
|
||||
| CDN / static assets | Never | Let them load normally |
|
||||
| Flaky external dependency | CI: mock, local: real | Conditional mocking based on environment |
|
||||
| Slow external dependency | Dev: mock, nightly: real | Separate test projects in config |
|
||||
|
||||
## Decision Flowchart
|
||||
|
||||
```text
|
||||
Is this service part of YOUR codebase?
|
||||
├── YES → Do NOT mock. Test the real integration.
|
||||
│ ├── Is it slow? → Optimize the service, not the test.
|
||||
│ └── Is it flaky? → Fix the service. Flaky infra is a bug.
|
||||
└── NO → It's a third-party service.
|
||||
├── Is it paid per call? → ALWAYS mock.
|
||||
├── Is it rate-limited? → ALWAYS mock.
|
||||
├── Is it slow or unreliable? → ALWAYS mock.
|
||||
└── Is it a complex multi-step flow? → Mock with HAR recording.
|
||||
```
|
||||
|
||||
## Mocking Techniques
|
||||
|
||||
### Blocking Unwanted Requests
|
||||
|
||||
Block third-party scripts that slow tests and add no coverage:
|
||||
|
||||
```typescript
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/{analytics,tracking,segment,hotjar}.{com,io}/**', (route) => {
|
||||
route.abort();
|
||||
});
|
||||
});
|
||||
|
||||
test('dashboard renders without tracking scripts', async ({ page }) => {
|
||||
await page.goto('/dashboard');
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Full Mock (route.fulfill)
|
||||
|
||||
Completely replace a third-party API response:
|
||||
|
||||
```typescript
|
||||
test('order flow with mocked payment service', async ({ page }) => {
|
||||
await page.route('**/api/charge', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
transactionId: 'txn_mock_abc',
|
||||
status: 'completed',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/order/confirm');
|
||||
await page.getByRole('button', { name: 'Complete Purchase' }).click();
|
||||
await expect(page.getByText('Order confirmed')).toBeVisible();
|
||||
});
|
||||
|
||||
test('display error on payment decline', async ({ page }) => {
|
||||
await page.route('**/api/charge', (route) => {
|
||||
route.fulfill({
|
||||
status: 402,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: { code: 'insufficient_funds', message: 'Card declined.' },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/order/confirm');
|
||||
await page.getByRole('button', { name: 'Complete Purchase' }).click();
|
||||
await expect(page.getByRole('alert')).toContainText('Card declined');
|
||||
});
|
||||
```
|
||||
|
||||
### Partial Mock (Modify Responses)
|
||||
|
||||
Let the real API call happen but tweak the response:
|
||||
|
||||
```typescript
|
||||
test('display low inventory warning', async ({ page }) => {
|
||||
await page.route('**/api/inventory/*', async (route) => {
|
||||
const response = await route.fetch();
|
||||
const data = await response.json();
|
||||
|
||||
data.quantity = 1;
|
||||
data.lowStock = true;
|
||||
|
||||
await route.fulfill({
|
||||
response,
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/products/widget-pro');
|
||||
await expect(page.getByText('Only 1 remaining')).toBeVisible();
|
||||
});
|
||||
|
||||
test('inject test notification into real response', async ({ page }) => {
|
||||
await page.route('**/api/alerts', async (route) => {
|
||||
const response = await route.fetch();
|
||||
const data = await response.json();
|
||||
|
||||
data.items.push({
|
||||
id: 'test-alert',
|
||||
text: 'Report generated',
|
||||
category: 'info',
|
||||
});
|
||||
|
||||
await route.fulfill({
|
||||
response,
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/home');
|
||||
await expect(page.getByText('Report generated')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Record and Replay (HAR Files)
|
||||
|
||||
For complex API sequences (OAuth flows, multi-step wizards):
|
||||
|
||||
**Recording:**
|
||||
|
||||
```typescript
|
||||
test('capture API traffic for admin panel', async ({ page }) => {
|
||||
await page.routeFromHAR('tests/fixtures/admin-panel.har', {
|
||||
url: '**/api/**',
|
||||
update: true,
|
||||
});
|
||||
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('tab', { name: 'Reports' }).click();
|
||||
await page.getByRole('tab', { name: 'Settings' }).click();
|
||||
});
|
||||
```
|
||||
|
||||
**Replaying:**
|
||||
|
||||
```typescript
|
||||
test('admin panel loads with recorded data', async ({ page }) => {
|
||||
await page.routeFromHAR('tests/fixtures/admin-panel.har', {
|
||||
url: '**/api/**',
|
||||
update: false,
|
||||
});
|
||||
|
||||
await page.goto('/admin');
|
||||
await expect(page.getByRole('heading', { name: 'Reports' })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
**HAR maintenance:**
|
||||
|
||||
- Record against a known-good staging environment
|
||||
- Commit `.har` files to version control
|
||||
- Re-record when APIs change
|
||||
- Scope HAR to specific URL patterns
|
||||
|
||||
## Real Service Strategies
|
||||
|
||||
### Local Dev Server
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 30_000,
|
||||
},
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Staging Environment
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
use: {
|
||||
baseURL: process.env.CI
|
||||
? 'https://staging.example.com'
|
||||
: 'http://localhost:3000',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Test Containers
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
webServer: {
|
||||
command: 'docker compose -f docker-compose.test.yml up --wait',
|
||||
url: 'http://localhost:3000/health',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
globalTeardown: './tests/global-teardown.ts',
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/global-teardown.ts
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
export default function globalTeardown() {
|
||||
if (process.env.CI) {
|
||||
execSync('docker compose -f docker-compose.test.yml down -v');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Hybrid Approach: Fixture-Based Mock Control
|
||||
|
||||
Create fixtures that let individual tests opt into mocking specific services:
|
||||
|
||||
```typescript
|
||||
// tests/fixtures/service-mocks.ts
|
||||
import { test as base } from '@playwright/test';
|
||||
|
||||
type MockConfig = {
|
||||
mockPayments: boolean;
|
||||
mockNotifications: boolean;
|
||||
mockAnalytics: boolean;
|
||||
};
|
||||
|
||||
export const test = base.extend<MockConfig>({
|
||||
mockPayments: [true, { option: true }],
|
||||
mockNotifications: [true, { option: true }],
|
||||
mockAnalytics: [true, { option: true }],
|
||||
|
||||
page: async ({ page, mockPayments, mockNotifications, mockAnalytics }, use) => {
|
||||
if (mockPayments) {
|
||||
await page.route('**/api/billing/**', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'paid', id: 'inv_mock_789' }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (mockNotifications) {
|
||||
await page.route('**/api/notify', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ delivered: true }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (mockAnalytics) {
|
||||
await page.route('**/{segment,mixpanel,amplitude}.**/**', (route) => {
|
||||
route.abort();
|
||||
});
|
||||
}
|
||||
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from '@playwright/test';
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/billing.spec.ts
|
||||
import { test, expect } from './fixtures/service-mocks';
|
||||
|
||||
test('subscription renewal sends notification', async ({ page }) => {
|
||||
await page.goto('/account/billing');
|
||||
await page.getByRole('button', { name: 'Renew Now' }).click();
|
||||
await expect(page.getByText('Subscription renewed')).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe('integration suite', () => {
|
||||
test.use({ mockPayments: false });
|
||||
|
||||
test('real billing flow against test gateway', async ({ page }) => {
|
||||
await page.goto('/account/billing');
|
||||
await page.getByRole('button', { name: 'Renew Now' }).click();
|
||||
await expect(page.getByText('Subscription renewed')).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Environment-Based Test Projects
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: 'ci-fast',
|
||||
testMatch: '**/*.spec.ts',
|
||||
use: { baseURL: 'http://localhost:3000' },
|
||||
},
|
||||
{
|
||||
name: 'nightly-full',
|
||||
testMatch: '**/*.integration.spec.ts',
|
||||
use: { baseURL: 'https://staging.example.com' },
|
||||
timeout: 120_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Validating Mock Accuracy
|
||||
|
||||
Guard against mock drift from real APIs:
|
||||
|
||||
```typescript
|
||||
test.describe('contract validation', () => {
|
||||
test('billing mock matches real API shape', async ({ request }) => {
|
||||
const realResponse = await request.post('/api/billing/charge', {
|
||||
data: { amount: 5000, currency: 'usd' },
|
||||
});
|
||||
const realBody = await realResponse.json();
|
||||
|
||||
const mockBody = {
|
||||
status: 'paid',
|
||||
id: 'inv_mock_789',
|
||||
};
|
||||
|
||||
expect(Object.keys(mockBody).sort()).toEqual(Object.keys(realBody).sort());
|
||||
|
||||
for (const key of Object.keys(mockBody)) {
|
||||
expect(typeof mockBody[key]).toBe(typeof realBody[key]);
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Don't Do This | Problem | Do This Instead |
|
||||
| --- | --- | --- |
|
||||
| Mock your own API | Tests pass, app breaks. Zero integration coverage. | Hit your real API. Mock only third-party services. |
|
||||
| Mock everything for speed | You test a fiction. Frontend and backend may be incompatible. | Mock only external boundaries. |
|
||||
| Never mock anything | Tests are slow, flaky, fail when third parties have outages. | Mock third-party services. |
|
||||
| Use outdated mocks | Mock returns different shape than real API. | Run contract validation tests. Re-record HAR files regularly. |
|
||||
| Mock with `page.evaluate()` to stub fetch | Fragile, doesn't survive navigation. | Use `page.route()` which intercepts at network layer. |
|
||||
| Copy-paste mocks across files | One API change requires updating many files. | Centralize mocks in fixtures. |
|
||||
| Block all network and whitelist | Extremely brittle. Every new endpoint requires update. | Allow all by default. Selectively mock third-party services. |
|
||||
@@ -0,0 +1,391 @@
|
||||
# Browser APIs: Geolocation, Permissions & More
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Geolocation](#geolocation)
|
||||
2. [Permissions](#permissions)
|
||||
3. [Clipboard](#clipboard)
|
||||
4. [Notifications](#notifications)
|
||||
5. [Camera & Microphone](#camera--microphone)
|
||||
|
||||
## Geolocation
|
||||
|
||||
### Mock Location
|
||||
|
||||
```typescript
|
||||
test("shows nearby stores", async ({ context }) => {
|
||||
// Grant permission and set location
|
||||
await context.grantPermissions(["geolocation"]);
|
||||
await context.setGeolocation({ latitude: 37.7749, longitude: -122.4194 }); // San Francisco
|
||||
|
||||
const page = await context.newPage();
|
||||
await page.goto("/store-finder");
|
||||
await page.getByRole("button", { name: "Find Nearby" }).click();
|
||||
|
||||
await expect(page.getByText("San Francisco")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Geolocation Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/geolocation.fixture.ts
|
||||
import { test as base } from "@playwright/test";
|
||||
|
||||
type Coordinates = { latitude: number; longitude: number; accuracy?: number };
|
||||
|
||||
type GeoFixtures = {
|
||||
setLocation: (coords: Coordinates) => Promise<void>;
|
||||
};
|
||||
|
||||
export const test = base.extend<GeoFixtures>({
|
||||
setLocation: async ({ context }, use) => {
|
||||
await context.grantPermissions(["geolocation"]);
|
||||
|
||||
await use(async (coords) => {
|
||||
await context.setGeolocation({
|
||||
latitude: coords.latitude,
|
||||
longitude: coords.longitude,
|
||||
accuracy: coords.accuracy ?? 100,
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
test("delivery zone check", async ({ page, setLocation }) => {
|
||||
await setLocation({ latitude: 40.7128, longitude: -74.006 }); // NYC
|
||||
|
||||
await page.goto("/delivery");
|
||||
|
||||
await expect(page.getByText("Delivery available")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Test Location Changes
|
||||
|
||||
```typescript
|
||||
test("tracks location updates", async ({ context }) => {
|
||||
await context.grantPermissions(["geolocation"]);
|
||||
|
||||
const page = await context.newPage();
|
||||
await page.goto("/tracking");
|
||||
|
||||
// Initial location
|
||||
await context.setGeolocation({ latitude: 37.7749, longitude: -122.4194 });
|
||||
await page.getByRole("button", { name: "Start Tracking" }).click();
|
||||
|
||||
await expect(page.getByTestId("location")).toContainText("37.7749");
|
||||
|
||||
// Move to new location
|
||||
await context.setGeolocation({ latitude: 37.8044, longitude: -122.2712 });
|
||||
|
||||
// Trigger location update
|
||||
await page.evaluate(() => {
|
||||
navigator.geolocation.getCurrentPosition(() => {});
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("location")).toContainText("37.8044");
|
||||
});
|
||||
```
|
||||
|
||||
### Test Geolocation Denial
|
||||
|
||||
```typescript
|
||||
test("handles location denied", async ({ browser }) => {
|
||||
// Create context without geolocation permission
|
||||
const context = await browser.newContext({
|
||||
permissions: [], // No permissions
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
await page.goto("/store-finder");
|
||||
await page.getByRole("button", { name: "Find Nearby" }).click();
|
||||
|
||||
await expect(page.getByText("Location access denied")).toBeVisible();
|
||||
await expect(page.getByLabel("Enter ZIP code")).toBeVisible();
|
||||
|
||||
await context.close();
|
||||
});
|
||||
```
|
||||
|
||||
## Permissions
|
||||
|
||||
### Grant Permissions
|
||||
|
||||
```typescript
|
||||
test("notifications with permission", async ({ context }) => {
|
||||
await context.grantPermissions(["notifications"]);
|
||||
|
||||
const page = await context.newPage();
|
||||
await page.goto("/alerts");
|
||||
|
||||
// Notification API should work
|
||||
const permission = await page.evaluate(() => Notification.permission);
|
||||
expect(permission).toBe("granted");
|
||||
});
|
||||
```
|
||||
|
||||
### Test Permission Denied
|
||||
|
||||
```typescript
|
||||
test("handles notification permission denied", async ({ browser }) => {
|
||||
const context = await browser.newContext({
|
||||
permissions: [], // Deny all
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
await page.goto("/notifications");
|
||||
|
||||
await page.getByRole("button", { name: "Enable Notifications" }).click();
|
||||
|
||||
await expect(page.getByText("Please enable notifications")).toBeVisible();
|
||||
|
||||
await context.close();
|
||||
});
|
||||
```
|
||||
|
||||
### Multiple Permissions
|
||||
|
||||
```typescript
|
||||
test("video call with permissions", async ({ context }) => {
|
||||
await context.grantPermissions(["camera", "microphone", "notifications"]);
|
||||
|
||||
const page = await context.newPage();
|
||||
await page.goto("/video-call");
|
||||
|
||||
// All permissions should be granted
|
||||
const permissions = await page.evaluate(async () => ({
|
||||
camera: await navigator.permissions.query({
|
||||
name: "camera" as PermissionName,
|
||||
}),
|
||||
microphone: await navigator.permissions.query({
|
||||
name: "microphone" as PermissionName,
|
||||
}),
|
||||
}));
|
||||
|
||||
expect(permissions.camera.state).toBe("granted");
|
||||
expect(permissions.microphone.state).toBe("granted");
|
||||
});
|
||||
```
|
||||
|
||||
## Clipboard
|
||||
|
||||
### Test Copy to Clipboard
|
||||
|
||||
```typescript
|
||||
test("copy button works", async ({ page, context }) => {
|
||||
// Grant clipboard permissions
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
|
||||
await page.goto("/share");
|
||||
|
||||
await page.getByRole("button", { name: "Copy Link" }).click();
|
||||
|
||||
// Read clipboard content
|
||||
const clipboardContent = await page.evaluate(() =>
|
||||
navigator.clipboard.readText(),
|
||||
);
|
||||
|
||||
expect(clipboardContent).toContain("https://example.com/share/");
|
||||
});
|
||||
```
|
||||
|
||||
### Test Paste from Clipboard
|
||||
|
||||
```typescript
|
||||
test("paste from clipboard", async ({ page, context }) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
|
||||
await page.goto("/editor");
|
||||
|
||||
// Write to clipboard
|
||||
await page.evaluate(() => navigator.clipboard.writeText("Pasted content"));
|
||||
|
||||
// Trigger paste
|
||||
await page.getByLabel("Content").focus();
|
||||
await page.keyboard.press("Control+V");
|
||||
|
||||
await expect(page.getByLabel("Content")).toHaveValue("Pasted content");
|
||||
});
|
||||
```
|
||||
|
||||
### Clipboard Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/clipboard.fixture.ts
|
||||
import { test as base } from "@playwright/test";
|
||||
|
||||
type ClipboardFixtures = {
|
||||
clipboard: {
|
||||
write: (text: string) => Promise<void>;
|
||||
read: () => Promise<string>;
|
||||
};
|
||||
};
|
||||
|
||||
export const test = base.extend<ClipboardFixtures>({
|
||||
clipboard: async ({ page, context }, use) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
|
||||
await use({
|
||||
write: async (text) => {
|
||||
await page.evaluate((t) => navigator.clipboard.writeText(t), text);
|
||||
},
|
||||
read: async () => {
|
||||
return page.evaluate(() => navigator.clipboard.readText());
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Notifications
|
||||
|
||||
### Mock Notification API
|
||||
|
||||
```typescript
|
||||
test("shows browser notification", async ({ page }) => {
|
||||
const notifications: any[] = [];
|
||||
|
||||
// Mock Notification constructor
|
||||
await page.addInitScript(() => {
|
||||
(window as any).__notifications = [];
|
||||
(window as any).Notification = class {
|
||||
constructor(title: string, options?: NotificationOptions) {
|
||||
(window as any).__notifications.push({ title, ...options });
|
||||
}
|
||||
static permission = "granted";
|
||||
static requestPermission = async () => "granted";
|
||||
};
|
||||
});
|
||||
|
||||
await page.goto("/alerts");
|
||||
await page.getByRole("button", { name: "Notify Me" }).click();
|
||||
|
||||
// Check notification was created
|
||||
const created = await page.evaluate(() => (window as any).__notifications);
|
||||
expect(created).toHaveLength(1);
|
||||
expect(created[0].title).toBe("New Alert");
|
||||
});
|
||||
```
|
||||
|
||||
### Test Notification Click
|
||||
|
||||
```typescript
|
||||
test("notification click handler", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
(window as any).Notification = class {
|
||||
onclick: (() => void) | null = null;
|
||||
constructor(title: string) {
|
||||
// Simulate click after creation
|
||||
setTimeout(() => this.onclick?.(), 100);
|
||||
}
|
||||
static permission = "granted";
|
||||
static requestPermission = async () => "granted";
|
||||
};
|
||||
});
|
||||
|
||||
await page.goto("/messages");
|
||||
await page.evaluate(() => {
|
||||
new Notification("New Message");
|
||||
});
|
||||
|
||||
// Should navigate to messages when notification clicked
|
||||
await expect(page).toHaveURL(/\/messages/);
|
||||
});
|
||||
```
|
||||
|
||||
## Camera & Microphone
|
||||
|
||||
### Mock Media Devices
|
||||
|
||||
```typescript
|
||||
test("video preview works", async ({ page, context }) => {
|
||||
await context.grantPermissions(["camera"]);
|
||||
|
||||
// Mock getUserMedia
|
||||
await page.addInitScript(() => {
|
||||
navigator.mediaDevices.getUserMedia = async () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 640;
|
||||
canvas.height = 480;
|
||||
return canvas.captureStream();
|
||||
};
|
||||
});
|
||||
|
||||
await page.goto("/video-settings");
|
||||
await page.getByRole("button", { name: "Start Camera" }).click();
|
||||
|
||||
await expect(page.getByTestId("video-preview")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Test Media Device Selection
|
||||
|
||||
```typescript
|
||||
test("switch camera", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
navigator.mediaDevices.enumerateDevices = async () =>
|
||||
[
|
||||
{
|
||||
deviceId: "cam1",
|
||||
kind: "videoinput",
|
||||
label: "Front Camera",
|
||||
groupId: "1",
|
||||
},
|
||||
{
|
||||
deviceId: "cam2",
|
||||
kind: "videoinput",
|
||||
label: "Back Camera",
|
||||
groupId: "2",
|
||||
},
|
||||
] as MediaDeviceInfo[];
|
||||
|
||||
navigator.mediaDevices.getUserMedia = async () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
return canvas.captureStream();
|
||||
};
|
||||
});
|
||||
|
||||
await page.goto("/camera");
|
||||
|
||||
// Should show camera options
|
||||
await expect(page.getByRole("combobox", { name: "Camera" })).toBeVisible();
|
||||
await expect(page.getByText("Front Camera")).toBeVisible();
|
||||
await expect(page.getByText("Back Camera")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Test Media Errors
|
||||
|
||||
```typescript
|
||||
test("handles camera access error", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
navigator.mediaDevices.getUserMedia = async () => {
|
||||
throw new DOMException("Permission denied", "NotAllowedError");
|
||||
};
|
||||
});
|
||||
|
||||
await page.goto("/video-call");
|
||||
await page.getByRole("button", { name: "Join Call" }).click();
|
||||
|
||||
await expect(page.getByText("Camera access denied")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Join Audio Only" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ----------------------------- | --------------------------------- | ----------------------------------- |
|
||||
| Not granting permissions | Tests fail with permission errors | Use `context.grantPermissions()` |
|
||||
| Testing real geolocation | Flaky, environment-dependent | Mock with `setGeolocation()` |
|
||||
| Not testing permission denial | Misses error handling | Test both granted and denied states |
|
||||
| Using real camera/mic | CI has no devices | Mock `getUserMedia` |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for context fixtures
|
||||
- **Mobile**: See [mobile-testing.md](../advanced/mobile-testing.md) for device emulation
|
||||
@@ -0,0 +1,403 @@
|
||||
# iFrame Testing
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Basic iFrame Access](#basic-iframe-access)
|
||||
2. [Cross-Origin iFrames](#cross-origin-iframes)
|
||||
3. [Nested iFrames](#nested-iframes)
|
||||
4. [Dynamic iFrames](#dynamic-iframes)
|
||||
5. [iFrame Navigation](#iframe-navigation)
|
||||
6. [Common Patterns](#common-patterns)
|
||||
|
||||
## Basic iFrame Access
|
||||
|
||||
### Using frameLocator
|
||||
|
||||
```typescript
|
||||
// Access iframe by selector
|
||||
const frame = page.frameLocator("iframe#payment");
|
||||
await frame.getByRole("button", { name: "Pay" }).click();
|
||||
|
||||
// Access by name attribute
|
||||
const namedFrame = page.frameLocator('iframe[name="checkout"]');
|
||||
await namedFrame.getByLabel("Card number").fill("4242424242424242");
|
||||
|
||||
// Access by title
|
||||
const titledFrame = page.frameLocator('iframe[title="Payment Form"]');
|
||||
|
||||
// Access by src (partial match)
|
||||
const srcFrame = page.frameLocator('iframe[src*="stripe.com"]');
|
||||
```
|
||||
|
||||
### Frame vs FrameLocator
|
||||
|
||||
```typescript
|
||||
// frameLocator - for locator-based operations (recommended)
|
||||
const frameLocator = page.frameLocator("#my-iframe");
|
||||
await frameLocator.getByRole("button").click();
|
||||
|
||||
// frame() - for Frame object operations (navigation, evaluation)
|
||||
const frame = page.frame({ name: "my-frame" });
|
||||
if (frame) {
|
||||
await frame.goto("https://example.com");
|
||||
const title = await frame.title();
|
||||
}
|
||||
|
||||
// Get all frames
|
||||
const frames = page.frames();
|
||||
for (const f of frames) {
|
||||
console.log("Frame URL:", f.url());
|
||||
}
|
||||
```
|
||||
|
||||
### Waiting for iFrame Content
|
||||
|
||||
```typescript
|
||||
// Wait for iframe to load
|
||||
const frame = page.frameLocator("#dynamic-iframe");
|
||||
|
||||
// Wait for element inside iframe
|
||||
await expect(frame.getByRole("heading")).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Wait for iframe src to change
|
||||
await page.waitForFunction(() => {
|
||||
const iframe = document.querySelector("iframe#my-frame") as HTMLIFrameElement;
|
||||
return iframe?.src.includes("loaded");
|
||||
});
|
||||
```
|
||||
|
||||
## Cross-Origin iFrames
|
||||
|
||||
### Accessing Cross-Origin Content
|
||||
|
||||
```typescript
|
||||
// Cross-origin iframes work seamlessly with frameLocator
|
||||
const thirdPartyFrame = page.frameLocator('iframe[src*="third-party.com"]');
|
||||
|
||||
// Interact with elements inside cross-origin iframe
|
||||
await thirdPartyFrame.getByRole("textbox").fill("test@example.com");
|
||||
await thirdPartyFrame.getByRole("button", { name: "Submit" }).click();
|
||||
|
||||
// Wait for cross-origin iframe to be ready
|
||||
await expect(thirdPartyFrame.locator("body")).toBeVisible();
|
||||
```
|
||||
|
||||
### Payment Provider iFrames (Stripe, PayPal)
|
||||
|
||||
```typescript
|
||||
test("Stripe payment iframe", async ({ page }) => {
|
||||
await page.goto("/checkout");
|
||||
|
||||
// Stripe uses multiple iframes for each field
|
||||
const cardFrame = page
|
||||
.frameLocator('iframe[name*="__privateStripeFrame"]')
|
||||
.first();
|
||||
|
||||
// Wait for Stripe to initialize
|
||||
await expect(cardFrame.locator('[placeholder="Card number"]')).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
// Fill card details
|
||||
await cardFrame
|
||||
.locator('[placeholder="Card number"]')
|
||||
.fill("4242424242424242");
|
||||
await cardFrame.locator('[placeholder="MM / YY"]').fill("12/30");
|
||||
await cardFrame.locator('[placeholder="CVC"]').fill("123");
|
||||
});
|
||||
```
|
||||
|
||||
### Handling OAuth in iFrames
|
||||
|
||||
```typescript
|
||||
test("OAuth iframe flow", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Sign in with Google" }).click();
|
||||
|
||||
// If OAuth opens in iframe instead of popup
|
||||
const oauthFrame = page.frameLocator('iframe[src*="accounts.google.com"]');
|
||||
|
||||
// Wait for OAuth form
|
||||
await expect(oauthFrame.getByLabel("Email")).toBeVisible({ timeout: 10000 });
|
||||
await oauthFrame.getByLabel("Email").fill("test@gmail.com");
|
||||
});
|
||||
```
|
||||
|
||||
## Nested iFrames
|
||||
|
||||
### Accessing Nested Frames
|
||||
|
||||
```typescript
|
||||
// Parent iframe contains child iframe
|
||||
const parentFrame = page.frameLocator("#outer-frame");
|
||||
const childFrame = parentFrame.frameLocator("#inner-frame");
|
||||
|
||||
// Interact with deeply nested content
|
||||
await childFrame.getByRole("button", { name: "Submit" }).click();
|
||||
|
||||
// Multiple levels of nesting
|
||||
const level1 = page.frameLocator("#level1");
|
||||
const level2 = level1.frameLocator("#level2");
|
||||
const level3 = level2.frameLocator("#level3");
|
||||
await level3.getByText("Deep content").click();
|
||||
```
|
||||
|
||||
### Finding Elements Across Frame Hierarchy
|
||||
|
||||
```typescript
|
||||
// Helper to search all frames for an element
|
||||
async function findInAnyFrame(
|
||||
page: Page,
|
||||
selector: string,
|
||||
): Promise<Locator | null> {
|
||||
// Check main page first
|
||||
const mainCount = await page.locator(selector).count();
|
||||
if (mainCount > 0) return page.locator(selector);
|
||||
|
||||
// Check all frames
|
||||
for (const frame of page.frames()) {
|
||||
const count = await frame.locator(selector).count();
|
||||
if (count > 0) {
|
||||
return frame.locator(selector);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
test("find element in any frame", async ({ page }) => {
|
||||
await page.goto("/complex-page");
|
||||
const element = await findInAnyFrame(page, '[data-testid="submit-btn"]');
|
||||
if (element) await element.click();
|
||||
});
|
||||
```
|
||||
|
||||
## Dynamic iFrames
|
||||
|
||||
### iFrames Created at Runtime
|
||||
|
||||
```typescript
|
||||
test("handle dynamically created iframe", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
|
||||
// Click button that creates iframe
|
||||
await page.getByRole("button", { name: "Open Widget" }).click();
|
||||
|
||||
// Wait for iframe to appear in DOM
|
||||
await page.waitForSelector("iframe#widget-frame");
|
||||
|
||||
// Now access the frame
|
||||
const widgetFrame = page.frameLocator("#widget-frame");
|
||||
await expect(widgetFrame.getByText("Widget Loaded")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### iFrames with Changing src
|
||||
|
||||
```typescript
|
||||
test("iframe src changes", async ({ page }) => {
|
||||
await page.goto("/multi-step");
|
||||
|
||||
const frame = page.frameLocator("#step-frame");
|
||||
|
||||
// Step 1
|
||||
await expect(frame.getByText("Step 1")).toBeVisible();
|
||||
await frame.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Wait for iframe to reload with new content
|
||||
await expect(frame.getByText("Step 2")).toBeVisible({ timeout: 10000 });
|
||||
await frame.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Step 3
|
||||
await expect(frame.getByText("Step 3")).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
```
|
||||
|
||||
### Lazy-Loaded iFrames
|
||||
|
||||
```typescript
|
||||
test("lazy loaded iframe", async ({ page }) => {
|
||||
await page.goto("/page-with-lazy-iframe");
|
||||
|
||||
// Scroll to trigger lazy load
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
|
||||
// Wait for iframe to load
|
||||
const lazyFrame = page.frameLocator("#lazy-iframe");
|
||||
await expect(lazyFrame.locator("body")).not.toBeEmpty({ timeout: 15000 });
|
||||
|
||||
// Interact with content
|
||||
await lazyFrame.getByRole("button").click();
|
||||
});
|
||||
```
|
||||
|
||||
## iFrame Navigation
|
||||
|
||||
### Navigating Within iFrame
|
||||
|
||||
```typescript
|
||||
test("iframe internal navigation", async ({ page }) => {
|
||||
await page.goto("/app");
|
||||
|
||||
// Get frame object for navigation control
|
||||
const frame = page.frame({ name: "content-frame" });
|
||||
if (!frame) throw new Error("Frame not found");
|
||||
|
||||
// Navigate within iframe
|
||||
await frame.goto("https://embedded-app.com/page2");
|
||||
|
||||
// Wait for navigation
|
||||
await frame.waitForURL("**/page2");
|
||||
|
||||
// Verify content
|
||||
await expect(frame.getByRole("heading")).toHaveText("Page 2");
|
||||
});
|
||||
```
|
||||
|
||||
### Handling Frame Navigation Events
|
||||
|
||||
```typescript
|
||||
test("track iframe navigation", async ({ page }) => {
|
||||
const navigations: string[] = [];
|
||||
|
||||
// Listen to frame navigation
|
||||
page.on("framenavigated", (frame) => {
|
||||
if (frame.parentFrame()) {
|
||||
// This is an iframe navigation
|
||||
navigations.push(frame.url());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("/with-iframe");
|
||||
await page
|
||||
.frameLocator("#nav-frame")
|
||||
.getByRole("link", { name: "Page 2" })
|
||||
.click();
|
||||
|
||||
// Verify navigation occurred
|
||||
expect(navigations.some((url) => url.includes("page2"))).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### iFrame Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures.ts
|
||||
import { test as base, FrameLocator } from "@playwright/test";
|
||||
|
||||
export const test = base.extend<{ paymentFrame: FrameLocator }>({
|
||||
paymentFrame: async ({ page }, use) => {
|
||||
await page.goto("/checkout");
|
||||
|
||||
// Wait for payment iframe to be ready
|
||||
const frame = page.frameLocator('iframe[src*="payment"]');
|
||||
await expect(frame.locator("body")).toBeVisible({ timeout: 15000 });
|
||||
|
||||
await use(frame);
|
||||
},
|
||||
});
|
||||
|
||||
// test file
|
||||
test("complete payment", async ({ paymentFrame }) => {
|
||||
await paymentFrame.getByLabel("Card").fill("4242424242424242");
|
||||
await paymentFrame.getByRole("button", { name: "Pay" }).click();
|
||||
});
|
||||
```
|
||||
|
||||
### Debugging iFrame Issues
|
||||
|
||||
```typescript
|
||||
test("debug iframe content", async ({ page }) => {
|
||||
await page.goto("/page-with-iframes");
|
||||
|
||||
// List all frames
|
||||
console.log("All frames:");
|
||||
for (const frame of page.frames()) {
|
||||
console.log(` - ${frame.name() || "(unnamed)"}: ${frame.url()}`);
|
||||
}
|
||||
|
||||
// Screenshot specific iframe content
|
||||
const frame = page.frame({ name: "target-frame" });
|
||||
if (frame) {
|
||||
const body = frame.locator("body");
|
||||
await body.screenshot({ path: "iframe-content.png" });
|
||||
}
|
||||
|
||||
// Get iframe HTML for debugging
|
||||
const frameContent = page.frameLocator("#my-frame");
|
||||
const html = await frameContent.locator("body").innerHTML();
|
||||
console.log("iFrame HTML:", html.substring(0, 500));
|
||||
});
|
||||
```
|
||||
|
||||
### Handling iFrame Load Failures
|
||||
|
||||
```typescript
|
||||
test("handle iframe load failure", async ({ page }) => {
|
||||
await page.goto("/page-with-unreliable-iframe");
|
||||
|
||||
const frame = page.frameLocator("#unreliable-frame");
|
||||
|
||||
try {
|
||||
// Try to interact with iframe content
|
||||
await expect(frame.getByRole("button")).toBeVisible({ timeout: 5000 });
|
||||
await frame.getByRole("button").click();
|
||||
} catch (error) {
|
||||
// Fallback: refresh iframe
|
||||
await page.evaluate(() => {
|
||||
const iframe = document.querySelector(
|
||||
"#unreliable-frame",
|
||||
) as HTMLIFrameElement;
|
||||
if (iframe) iframe.src = iframe.src;
|
||||
});
|
||||
|
||||
// Retry
|
||||
await expect(frame.getByRole("button")).toBeVisible({ timeout: 10000 });
|
||||
await frame.getByRole("button").click();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Mocking iFrame Content
|
||||
|
||||
```typescript
|
||||
test("mock iframe response", async ({ page }) => {
|
||||
// Intercept iframe src request
|
||||
await page.route("**/embedded-widget**", (route) => {
|
||||
route.fulfill({
|
||||
contentType: "text/html",
|
||||
body: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Mocked Widget</h1>
|
||||
<p>Mocked widget content</p>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/page-with-widget");
|
||||
|
||||
const frame = page.frameLocator("#widget-frame");
|
||||
await expect(frame.getByRole("heading")).toHaveText("Mocked Widget");
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ------------------------------------- | --------------------------------- | -------------------------------------------------- |
|
||||
| Using `page.frame()` for interactions | Less reliable than frameLocator | Use `page.frameLocator()` for element interactions |
|
||||
| Hardcoding iframe index | Fragile if DOM order changes | Use name, id, or src attribute selectors |
|
||||
| Not waiting for iframe load | Race conditions | Wait for element inside iframe to be visible |
|
||||
| Assuming same-origin | Cross-origin has different timing | Always wait for iframe content explicitly |
|
||||
| Ignoring nested iframes | Element not found | Chain frameLocator calls for nested frames |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Locators**: See [locators.md](../core/locators.md) for selector strategies
|
||||
- **Third-party services**: See [third-party.md](../advanced/third-party.md) for payment iframe patterns
|
||||
- **Debugging**: See [debugging.md](../debugging/debugging.md) for troubleshooting iframe issues
|
||||
@@ -0,0 +1,504 @@
|
||||
# Service Worker Testing
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Service Worker Basics](#service-worker-basics)
|
||||
2. [Registration & Lifecycle](#registration--lifecycle)
|
||||
3. [Cache Testing](#cache-testing)
|
||||
4. [Offline Testing](#offline-testing)
|
||||
5. [Push Notifications](#push-notifications)
|
||||
6. [Background Sync](#background-sync)
|
||||
|
||||
## Service Worker Basics
|
||||
|
||||
### Waiting for Service Worker Registration
|
||||
|
||||
```typescript
|
||||
test("service worker registers", async ({ page }) => {
|
||||
await page.goto("/pwa-app");
|
||||
|
||||
// Wait for SW to register
|
||||
const swRegistered = await page.evaluate(async () => {
|
||||
if (!("serviceWorker" in navigator)) return false;
|
||||
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
return !!registration.active;
|
||||
});
|
||||
|
||||
expect(swRegistered).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
### Getting Service Worker State
|
||||
|
||||
```typescript
|
||||
test("check SW state", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const swState = await page.evaluate(async () => {
|
||||
const registration = await navigator.serviceWorker.getRegistration();
|
||||
if (!registration) return null;
|
||||
|
||||
return {
|
||||
installing: !!registration.installing,
|
||||
waiting: !!registration.waiting,
|
||||
active: !!registration.active,
|
||||
scope: registration.scope,
|
||||
};
|
||||
});
|
||||
|
||||
expect(swState?.active).toBe(true);
|
||||
expect(swState?.scope).toContain(page.url());
|
||||
});
|
||||
```
|
||||
|
||||
### Service Worker Context
|
||||
|
||||
```typescript
|
||||
test("access service worker", async ({ context, page }) => {
|
||||
await page.goto("/pwa-app");
|
||||
|
||||
// Get all service workers in context
|
||||
const workers = context.serviceWorkers();
|
||||
|
||||
// Wait for service worker if not yet available
|
||||
if (workers.length === 0) {
|
||||
await context.waitForEvent("serviceworker");
|
||||
}
|
||||
|
||||
const sw = context.serviceWorkers()[0];
|
||||
expect(sw.url()).toContain("sw.js");
|
||||
});
|
||||
```
|
||||
|
||||
## Registration & Lifecycle
|
||||
|
||||
### Testing SW Update Flow
|
||||
|
||||
```typescript
|
||||
test("service worker updates", async ({ page }) => {
|
||||
await page.goto("/pwa-app");
|
||||
|
||||
// Check for update
|
||||
const hasUpdate = await page.evaluate(async () => {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
await registration.update();
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
if (registration.waiting) {
|
||||
resolve(true);
|
||||
} else {
|
||||
registration.addEventListener("updatefound", () => {
|
||||
resolve(true);
|
||||
});
|
||||
// Timeout if no update
|
||||
setTimeout(() => resolve(false), 5000);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// If update found, test skip waiting flow
|
||||
if (hasUpdate) {
|
||||
await page.evaluate(async () => {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
registration.waiting?.postMessage({ type: "SKIP_WAITING" });
|
||||
});
|
||||
|
||||
// Wait for controller change
|
||||
await page.evaluate(() => {
|
||||
return new Promise<void>((resolve) => {
|
||||
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Testing SW Installation
|
||||
|
||||
```typescript
|
||||
test("verify SW install event", async ({ context, page }) => {
|
||||
// Listen for service worker before navigating
|
||||
const swPromise = context.waitForEvent("serviceworker");
|
||||
|
||||
await page.goto("/pwa-app");
|
||||
|
||||
const sw = await swPromise;
|
||||
|
||||
// Evaluate in SW context
|
||||
const swVersion = await sw.evaluate(() => {
|
||||
// Access SW globals
|
||||
return (self as any).SW_VERSION || "unknown";
|
||||
});
|
||||
|
||||
expect(swVersion).toBe("1.0.0");
|
||||
});
|
||||
```
|
||||
|
||||
### Unregistering Service Workers
|
||||
|
||||
```typescript
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Unregister all service workers for clean state
|
||||
await page.evaluate(async () => {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
await Promise.all(registrations.map((r) => r.unregister()));
|
||||
});
|
||||
|
||||
// Clear caches
|
||||
await page.evaluate(async () => {
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(cacheNames.map((name) => caches.delete(name)));
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Cache Testing
|
||||
|
||||
### Verifying Cached Resources
|
||||
|
||||
```typescript
|
||||
test("assets are cached", async ({ page }) => {
|
||||
await page.goto("/pwa-app");
|
||||
|
||||
// Wait for SW to cache assets
|
||||
await page.evaluate(async () => {
|
||||
await navigator.serviceWorker.ready;
|
||||
});
|
||||
|
||||
// Check cache contents
|
||||
const cachedUrls = await page.evaluate(async () => {
|
||||
const cache = await caches.open("app-cache-v1");
|
||||
const requests = await cache.keys();
|
||||
return requests.map((r) => r.url);
|
||||
});
|
||||
|
||||
expect(cachedUrls).toContain(expect.stringContaining("/styles.css"));
|
||||
expect(cachedUrls).toContain(expect.stringContaining("/app.js"));
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Cache Strategies
|
||||
|
||||
```typescript
|
||||
test("cache-first strategy", async ({ page }) => {
|
||||
await page.goto("/pwa-app");
|
||||
|
||||
// Wait for initial cache
|
||||
await page.waitForFunction(async () => {
|
||||
const cache = await caches.open("app-cache-v1");
|
||||
const keys = await cache.keys();
|
||||
return keys.length > 0;
|
||||
});
|
||||
|
||||
// Block network for cached resources
|
||||
await page.route("**/styles.css", (route) => route.abort());
|
||||
|
||||
// Reload - should work from cache
|
||||
await page.reload();
|
||||
|
||||
// Verify page still styled (CSS loaded from cache)
|
||||
const hasStyles = await page.evaluate(() => {
|
||||
const body = document.body;
|
||||
const styles = window.getComputedStyle(body);
|
||||
return styles.fontFamily !== ""; // Has custom font from CSS
|
||||
});
|
||||
|
||||
expect(hasStyles).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Cache Updates
|
||||
|
||||
```typescript
|
||||
test("cache updates on new version", async ({ page }) => {
|
||||
await page.goto("/pwa-app");
|
||||
|
||||
// Get initial cache
|
||||
const initialCacheKeys = await page.evaluate(async () => {
|
||||
const cache = await caches.open("app-cache-v1");
|
||||
const keys = await cache.keys();
|
||||
return keys.map((r) => r.url);
|
||||
});
|
||||
|
||||
// Simulate app update by mocking SW response
|
||||
await page.route("**/sw.js", (route) => {
|
||||
route.fulfill({
|
||||
contentType: "application/javascript",
|
||||
body: `
|
||||
const VERSION = 'v2';
|
||||
self.addEventListener('install', (e) => {
|
||||
e.waitUntil(caches.open('app-cache-v2'));
|
||||
self.skipWaiting();
|
||||
});
|
||||
`,
|
||||
});
|
||||
});
|
||||
|
||||
// Trigger update
|
||||
await page.evaluate(async () => {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
await reg.update();
|
||||
});
|
||||
|
||||
// Verify new cache exists
|
||||
await page.waitForFunction(async () => {
|
||||
return await caches.has("app-cache-v2");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Offline Testing
|
||||
|
||||
This section covers **offline-first apps (PWAs)** that are designed to work offline using service workers, caching, and background sync. For testing **unexpected network failures** (error recovery, graceful degradation), see [error-testing.md](error-testing.md#offline-testing).
|
||||
|
||||
### Simulating Offline Mode
|
||||
|
||||
```typescript
|
||||
test("app works offline", async ({ page, context }) => {
|
||||
await page.goto("/pwa-app");
|
||||
|
||||
// Ensure SW is active and content cached
|
||||
await page.evaluate(async () => {
|
||||
await navigator.serviceWorker.ready;
|
||||
});
|
||||
await page.waitForTimeout(1000); // Allow caching to complete
|
||||
|
||||
// Go offline
|
||||
await context.setOffline(true);
|
||||
|
||||
// Navigate to cached page
|
||||
await page.reload();
|
||||
|
||||
// Verify content loads
|
||||
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
|
||||
|
||||
// Verify offline indicator
|
||||
await expect(page.locator(".offline-badge")).toBeVisible();
|
||||
|
||||
// Go back online
|
||||
await context.setOffline(false);
|
||||
await expect(page.locator(".offline-badge")).not.toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Offline Fallback
|
||||
|
||||
```typescript
|
||||
test("shows offline page for uncached routes", async ({ page, context }) => {
|
||||
await page.goto("/pwa-app");
|
||||
await page.evaluate(() => navigator.serviceWorker.ready);
|
||||
|
||||
// Go offline
|
||||
await context.setOffline(true);
|
||||
|
||||
// Navigate to uncached page
|
||||
await page.goto("/uncached-page");
|
||||
|
||||
// Should show offline fallback
|
||||
await expect(page.getByText("You are offline")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Offline Form Submission
|
||||
|
||||
```typescript
|
||||
test("queues form submission offline", async ({ page, context }) => {
|
||||
await page.goto("/pwa-app/form");
|
||||
|
||||
// Go offline
|
||||
await context.setOffline(true);
|
||||
|
||||
// Submit form
|
||||
await page.getByLabel("Message").fill("Offline message");
|
||||
await page.getByRole("button", { name: "Send" }).click();
|
||||
|
||||
// Should show queued status
|
||||
await expect(page.getByText("Queued for sync")).toBeVisible();
|
||||
|
||||
// Go online
|
||||
await context.setOffline(false);
|
||||
|
||||
// Trigger sync (or wait for automatic)
|
||||
await page.evaluate(async () => {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
// Manually trigger sync for testing
|
||||
await (reg as any).sync?.register("form-sync");
|
||||
});
|
||||
|
||||
// Verify submission completed
|
||||
await expect(page.getByText("Message sent")).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
```
|
||||
|
||||
## Push Notifications
|
||||
|
||||
### Mocking Push Subscription
|
||||
|
||||
```typescript
|
||||
test("handles push subscription", async ({ page, context }) => {
|
||||
// Grant notification permission
|
||||
await context.grantPermissions(["notifications"]);
|
||||
|
||||
await page.goto("/pwa-app");
|
||||
|
||||
// Subscribe to push
|
||||
const subscription = await page.evaluate(async () => {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: "test-key",
|
||||
});
|
||||
return sub.toJSON();
|
||||
});
|
||||
|
||||
expect(subscription.endpoint).toBeDefined();
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Push Message Handling
|
||||
|
||||
```typescript
|
||||
test("handles push notification", async ({ context, page }) => {
|
||||
await context.grantPermissions(["notifications"]);
|
||||
await page.goto("/pwa-app");
|
||||
|
||||
// Wait for SW
|
||||
const swPromise = context.waitForEvent("serviceworker");
|
||||
const sw = await swPromise;
|
||||
|
||||
// Simulate push message to service worker
|
||||
await sw.evaluate(async () => {
|
||||
// Dispatch push event
|
||||
const pushEvent = new PushEvent("push", {
|
||||
data: new PushMessageData(
|
||||
JSON.stringify({ title: "Test", body: "Push message" }),
|
||||
),
|
||||
});
|
||||
self.dispatchEvent(pushEvent);
|
||||
});
|
||||
|
||||
// Note: Actual notification display testing is limited in Playwright
|
||||
// Focus on verifying the SW handles the push correctly
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Notification Click
|
||||
|
||||
```typescript
|
||||
test("notification click opens page", async ({ context, page }) => {
|
||||
await context.grantPermissions(["notifications"]);
|
||||
await page.goto("/pwa-app");
|
||||
|
||||
// Store notification URL target
|
||||
let notificationUrl = "";
|
||||
|
||||
// Listen for new pages (notification click opens new page)
|
||||
context.on("page", (newPage) => {
|
||||
notificationUrl = newPage.url();
|
||||
});
|
||||
|
||||
// Trigger notification via SW
|
||||
await page.evaluate(async () => {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
await reg.showNotification("Test", {
|
||||
body: "Click me",
|
||||
data: { url: "/notification-target" },
|
||||
});
|
||||
});
|
||||
|
||||
// Simulate clicking notification (via SW)
|
||||
const sw = context.serviceWorkers()[0];
|
||||
await sw.evaluate(() => {
|
||||
self.dispatchEvent(
|
||||
new NotificationEvent("notificationclick", {
|
||||
notification: { data: { url: "/notification-target" } } as any,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// Verify navigation occurred
|
||||
await page.waitForTimeout(1000);
|
||||
// Check if new page opened or current page navigated
|
||||
});
|
||||
```
|
||||
|
||||
## Background Sync
|
||||
|
||||
### Testing Background Sync Registration
|
||||
|
||||
```typescript
|
||||
test("registers background sync", async ({ page }) => {
|
||||
await page.goto("/pwa-app");
|
||||
|
||||
// Register sync
|
||||
const syncRegistered = await page.evaluate(async () => {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
if (!("sync" in reg)) return false;
|
||||
|
||||
await (reg as any).sync.register("my-sync");
|
||||
return true;
|
||||
});
|
||||
|
||||
expect(syncRegistered).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Sync Event
|
||||
|
||||
```typescript
|
||||
test("sync event fires when online", async ({ context, page }) => {
|
||||
await page.goto("/pwa-app");
|
||||
|
||||
// Queue data while offline
|
||||
await context.setOffline(true);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
// Store data in IndexedDB for sync
|
||||
const db = await openDB();
|
||||
await db.put("sync-queue", { id: 1, data: "test" });
|
||||
|
||||
// Register sync
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
await (reg as any).sync.register("data-sync");
|
||||
});
|
||||
|
||||
// Track sync completion
|
||||
await page.evaluate(() => {
|
||||
window.syncCompleted = false;
|
||||
navigator.serviceWorker.addEventListener("message", (e) => {
|
||||
if (e.data.type === "SYNC_COMPLETE") {
|
||||
window.syncCompleted = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Go online
|
||||
await context.setOffline(false);
|
||||
|
||||
// Wait for sync to complete
|
||||
await page.waitForFunction(() => window.syncCompleted, { timeout: 10000 });
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ------------------------------ | ----------------------- | -------------------------------------------- |
|
||||
| Not clearing SW between tests | Tests affect each other | Unregister SW in beforeEach |
|
||||
| Not waiting for SW ready | Race conditions | Always await `navigator.serviceWorker.ready` |
|
||||
| Testing in isolation only | Misses real SW behavior | Test with actual caching |
|
||||
| Hardcoded timeouts for caching | Flaky tests | Wait for cache to populate |
|
||||
| Ignoring SW update cycle | Missing update bugs | Test install, activate, update flows |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Network Failures**: See [error-testing.md](error-testing.md#offline-testing) for unexpected network failure patterns
|
||||
- **Browser APIs**: See [browser-apis.md](browser-apis.md) for permissions
|
||||
- **Network Mocking**: See [network-advanced.md](../advanced/network-advanced.md) for network interception
|
||||
- **Browser Extensions**: See [browser-extensions.md](../testing-patterns/browser-extensions.md) for extension service worker patterns
|
||||
@@ -0,0 +1,403 @@
|
||||
# WebSocket & Real-Time Testing
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [WebSocket Basics](#websocket-basics)
|
||||
2. [Mocking WebSocket Messages](#mocking-websocket-messages)
|
||||
3. [Testing Real-Time Features](#testing-real-time-features)
|
||||
4. [Server-Sent Events](#server-sent-events)
|
||||
5. [Reconnection Testing](#reconnection-testing)
|
||||
|
||||
## WebSocket Basics
|
||||
|
||||
### Wait for WebSocket Connection
|
||||
|
||||
```typescript
|
||||
test("chat connects via websocket", async ({ page }) => {
|
||||
// Listen for WebSocket connection
|
||||
const wsPromise = page.waitForEvent("websocket");
|
||||
|
||||
await page.goto("/chat");
|
||||
|
||||
const ws = await wsPromise;
|
||||
expect(ws.url()).toContain("/ws/chat");
|
||||
|
||||
// Wait for connection to be established
|
||||
await ws.waitForEvent("framesent");
|
||||
});
|
||||
```
|
||||
|
||||
### Monitor WebSocket Messages
|
||||
|
||||
```typescript
|
||||
test("receives real-time updates", async ({ page }) => {
|
||||
const messages: string[] = [];
|
||||
|
||||
// Set up listener before navigation
|
||||
page.on("websocket", (ws) => {
|
||||
ws.on("framereceived", (frame) => {
|
||||
messages.push(frame.payload as string);
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/dashboard");
|
||||
|
||||
// Wait for some messages
|
||||
await expect.poll(() => messages.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify message format
|
||||
const data = JSON.parse(messages[0]);
|
||||
expect(data).toHaveProperty("type");
|
||||
});
|
||||
```
|
||||
|
||||
### Capture Sent Messages
|
||||
|
||||
```typescript
|
||||
test("sends correct message format", async ({ page }) => {
|
||||
const sentMessages: string[] = [];
|
||||
|
||||
page.on("websocket", (ws) => {
|
||||
ws.on("framesent", (frame) => {
|
||||
sentMessages.push(frame.payload as string);
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/chat");
|
||||
await page.getByLabel("Message").fill("Hello!");
|
||||
await page.getByRole("button", { name: "Send" }).click();
|
||||
|
||||
// Verify sent message
|
||||
await expect.poll(() => sentMessages.length).toBeGreaterThan(0);
|
||||
|
||||
const sent = JSON.parse(sentMessages[sentMessages.length - 1]);
|
||||
expect(sent).toEqual({
|
||||
type: "message",
|
||||
content: "Hello!",
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Mocking WebSocket Messages
|
||||
|
||||
### Inject Messages via Page Evaluate
|
||||
|
||||
```typescript
|
||||
test("displays incoming chat message", async ({ page }) => {
|
||||
await page.goto("/chat");
|
||||
|
||||
// Wait for WebSocket to be ready
|
||||
await page.waitForFunction(
|
||||
() => (window as any).chatSocket?.readyState === 1,
|
||||
);
|
||||
|
||||
// Simulate incoming message
|
||||
await page.evaluate(() => {
|
||||
const event = new MessageEvent("message", {
|
||||
data: JSON.stringify({
|
||||
type: "message",
|
||||
from: "Alice",
|
||||
content: "Hello there!",
|
||||
}),
|
||||
});
|
||||
(window as any).chatSocket.dispatchEvent(event);
|
||||
});
|
||||
|
||||
await expect(page.getByText("Alice: Hello there!")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Mock WebSocket with Route Handler
|
||||
|
||||
```typescript
|
||||
test("mock websocket entirely", async ({ page, context }) => {
|
||||
// Intercept the WebSocket upgrade
|
||||
await context.route("**/ws/**", async (route) => {
|
||||
// For WebSocket routes, we can't fulfill directly
|
||||
// Instead, use page.evaluate to mock the client-side
|
||||
});
|
||||
|
||||
// Alternative: Mock at application level
|
||||
await page.addInitScript(() => {
|
||||
const OriginalWebSocket = window.WebSocket;
|
||||
(window as any).WebSocket = function (url: string) {
|
||||
const ws = {
|
||||
readyState: 1,
|
||||
send: (data: string) => {
|
||||
console.log("WS Send:", data);
|
||||
},
|
||||
close: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
};
|
||||
setTimeout(() => ws.onopen?.(), 100);
|
||||
return ws;
|
||||
};
|
||||
});
|
||||
|
||||
await page.goto("/chat");
|
||||
});
|
||||
```
|
||||
|
||||
### WebSocket Mock Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/websocket.fixture.ts
|
||||
import { test as base, Page } from "@playwright/test";
|
||||
|
||||
type WsMessage = { type: string; [key: string]: any };
|
||||
|
||||
type WebSocketFixtures = {
|
||||
mockWebSocket: {
|
||||
injectMessage: (message: WsMessage) => Promise<void>;
|
||||
getSentMessages: () => Promise<WsMessage[]>;
|
||||
};
|
||||
};
|
||||
|
||||
export const test = base.extend<WebSocketFixtures>({
|
||||
mockWebSocket: async ({ page }, use) => {
|
||||
const sentMessages: WsMessage[] = [];
|
||||
|
||||
// Capture sent messages
|
||||
await page.addInitScript(() => {
|
||||
(window as any).__wsSent = [];
|
||||
const OriginalWebSocket = window.WebSocket;
|
||||
window.WebSocket = function (url: string) {
|
||||
const ws = new OriginalWebSocket(url);
|
||||
const originalSend = ws.send.bind(ws);
|
||||
ws.send = (data: string) => {
|
||||
(window as any).__wsSent.push(JSON.parse(data));
|
||||
originalSend(data);
|
||||
};
|
||||
(window as any).__ws = ws;
|
||||
return ws;
|
||||
} as any;
|
||||
});
|
||||
|
||||
await use({
|
||||
injectMessage: async (message) => {
|
||||
await page.evaluate((msg) => {
|
||||
const event = new MessageEvent("message", {
|
||||
data: JSON.stringify(msg),
|
||||
});
|
||||
(window as any).__ws?.dispatchEvent(event);
|
||||
}, message);
|
||||
},
|
||||
getSentMessages: async () => {
|
||||
return page.evaluate(() => (window as any).__wsSent || []);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
test("chat with mocked websocket", async ({ page, mockWebSocket }) => {
|
||||
await page.goto("/chat");
|
||||
|
||||
// Inject incoming message
|
||||
await mockWebSocket.injectMessage({
|
||||
type: "message",
|
||||
from: "Bob",
|
||||
content: "Hi!",
|
||||
});
|
||||
|
||||
await expect(page.getByText("Bob: Hi!")).toBeVisible();
|
||||
|
||||
// Send a reply
|
||||
await page.getByLabel("Message").fill("Hello Bob!");
|
||||
await page.getByRole("button", { name: "Send" }).click();
|
||||
|
||||
// Verify sent message
|
||||
const sent = await mockWebSocket.getSentMessages();
|
||||
expect(sent).toContainEqual(
|
||||
expect.objectContaining({ content: "Hello Bob!" }),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Real-Time Features
|
||||
|
||||
### Live Notifications
|
||||
|
||||
```typescript
|
||||
test("displays live notification", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
|
||||
// Simulate notification via WebSocket
|
||||
await page.evaluate(() => {
|
||||
const event = new MessageEvent("message", {
|
||||
data: JSON.stringify({
|
||||
type: "notification",
|
||||
title: "New Order",
|
||||
message: "Order #123 received",
|
||||
}),
|
||||
});
|
||||
(window as any).notificationSocket.dispatchEvent(event);
|
||||
});
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText("Order #123 received");
|
||||
});
|
||||
```
|
||||
|
||||
### Live Data Updates
|
||||
|
||||
```typescript
|
||||
test("updates stock price in real-time", async ({ page }) => {
|
||||
await page.goto("/stocks/AAPL");
|
||||
|
||||
const priceElement = page.getByTestId("stock-price");
|
||||
const initialPrice = await priceElement.textContent();
|
||||
|
||||
// Simulate price update
|
||||
await page.evaluate(() => {
|
||||
const event = new MessageEvent("message", {
|
||||
data: JSON.stringify({
|
||||
type: "price_update",
|
||||
symbol: "AAPL",
|
||||
price: 150.25,
|
||||
}),
|
||||
});
|
||||
(window as any).stockSocket.dispatchEvent(event);
|
||||
});
|
||||
|
||||
await expect(priceElement).not.toHaveText(initialPrice!);
|
||||
await expect(priceElement).toContainText("150.25");
|
||||
});
|
||||
```
|
||||
|
||||
### Collaborative Editing
|
||||
|
||||
```typescript
|
||||
test("shows collaborator cursor", async ({ page }) => {
|
||||
await page.goto("/document/123");
|
||||
|
||||
// Simulate another user's cursor position
|
||||
await page.evaluate(() => {
|
||||
const event = new MessageEvent("message", {
|
||||
data: JSON.stringify({
|
||||
type: "cursor",
|
||||
userId: "user-456",
|
||||
userName: "Alice",
|
||||
position: { x: 100, y: 200 },
|
||||
}),
|
||||
});
|
||||
(window as any).docSocket.dispatchEvent(event);
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("cursor-user-456")).toBeVisible();
|
||||
await expect(page.getByText("Alice")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Server-Sent Events
|
||||
|
||||
### Test SSE Updates
|
||||
|
||||
```typescript
|
||||
test("receives SSE updates", async ({ page }) => {
|
||||
// Mock SSE endpoint
|
||||
await page.route("**/api/events", (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
body: `data: {"type":"update","value":42}\n\n`,
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/live-data");
|
||||
|
||||
await expect(page.getByTestId("value")).toHaveText("42");
|
||||
});
|
||||
```
|
||||
|
||||
### Simulate Multiple SSE Events
|
||||
|
||||
```typescript
|
||||
test("handles multiple SSE events", async ({ page }) => {
|
||||
await page.route("**/api/events", async (route) => {
|
||||
const encoder = new TextEncoder();
|
||||
const events = [
|
||||
`data: {"count":1}\n\n`,
|
||||
`data: {"count":2}\n\n`,
|
||||
`data: {"count":3}\n\n`,
|
||||
];
|
||||
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
body: events.join(""),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/counter");
|
||||
|
||||
// Should receive all events
|
||||
await expect(page.getByTestId("count")).toHaveText("3");
|
||||
});
|
||||
```
|
||||
|
||||
## Reconnection Testing
|
||||
|
||||
### Test Connection Loss
|
||||
|
||||
```typescript
|
||||
test("handles connection loss gracefully", async ({ page }) => {
|
||||
await page.goto("/chat");
|
||||
|
||||
// Simulate connection close
|
||||
await page.evaluate(() => {
|
||||
(window as any).chatSocket.close();
|
||||
});
|
||||
|
||||
// Should show disconnected state
|
||||
await expect(page.getByText("Reconnecting...")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Test Reconnection
|
||||
|
||||
```typescript
|
||||
test("reconnects after connection loss", async ({ page }) => {
|
||||
await page.goto("/chat");
|
||||
|
||||
// Simulate disconnect
|
||||
await page.evaluate(() => {
|
||||
(window as any).chatSocket.close();
|
||||
});
|
||||
|
||||
await expect(page.getByText("Reconnecting...")).toBeVisible();
|
||||
|
||||
// Simulate reconnection
|
||||
await page.evaluate(() => {
|
||||
const event = new Event("open");
|
||||
(window as any).chatSocket = { readyState: 1 };
|
||||
(window as any).chatSocket.dispatchEvent?.(event);
|
||||
});
|
||||
|
||||
// Force component to re-check connection
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new Event("online"));
|
||||
});
|
||||
|
||||
await expect(page.getByText("Connected")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ------------------------------------- | ----------------------------- | ---------------------------------- |
|
||||
| Not waiting for WebSocket ready | Messages sent too early | Wait for `readyState === 1` |
|
||||
| Testing against real WebSocket server | Flaky, timing-dependent | Mock WebSocket messages |
|
||||
| Ignoring connection state | Tests pass but feature broken | Test connected/disconnected states |
|
||||
| No cleanup of listeners | Memory leaks in tests | Clean up event listeners |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Network**: See [network-advanced.md](../advanced/network-advanced.md) for HTTP mocking patterns
|
||||
- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for polling patterns
|
||||
- **Multi-User**: See [multi-user.md](../advanced/multi-user.md) for real-time collaboration testing with multiple users
|
||||
@@ -0,0 +1,424 @@
|
||||
# Test Annotations & Organization
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Skip Annotations](#skip-annotations)
|
||||
2. [Fixme & Fail Annotations](#fixme--fail-annotations)
|
||||
3. [Slow Tests](#slow-tests)
|
||||
4. [Test Steps](#test-steps)
|
||||
5. [Custom Annotations](#custom-annotations)
|
||||
6. [Conditional Annotations](#conditional-annotations)
|
||||
|
||||
## Skip Annotations
|
||||
|
||||
### Basic Skip
|
||||
|
||||
```typescript
|
||||
// Skip unconditionally
|
||||
test.skip("feature not implemented", async ({ page }) => {
|
||||
// This test won't run
|
||||
});
|
||||
|
||||
// Skip with reason
|
||||
test("payment flow", async ({ page }) => {
|
||||
test.skip(true, "Payment gateway in maintenance");
|
||||
// Test body won't execute
|
||||
});
|
||||
```
|
||||
|
||||
### Conditional Skip
|
||||
|
||||
```typescript
|
||||
test("webkit-specific feature", async ({ page, browserName }) => {
|
||||
test.skip(browserName !== "webkit", "This feature only works in WebKit");
|
||||
|
||||
await page.goto("/webkit-feature");
|
||||
});
|
||||
|
||||
test("production only", async ({ page }) => {
|
||||
test.skip(process.env.ENV !== "production", "Only runs against production");
|
||||
|
||||
await page.goto("/prod-feature");
|
||||
});
|
||||
```
|
||||
|
||||
### Skip by Platform
|
||||
|
||||
```typescript
|
||||
test("windows-specific", async ({ page }) => {
|
||||
test.skip(process.platform !== "win32", "Windows only");
|
||||
});
|
||||
|
||||
test("not on CI", async ({ page }) => {
|
||||
test.skip(!!process.env.CI, "Skipped in CI environment");
|
||||
});
|
||||
```
|
||||
|
||||
### Skip Describe Block
|
||||
|
||||
```typescript
|
||||
test.describe("Admin features", () => {
|
||||
test.skip(
|
||||
({ browserName }) => browserName === "firefox",
|
||||
"Firefox admin bug",
|
||||
);
|
||||
|
||||
test("admin dashboard", async ({ page }) => {
|
||||
// Skipped in Firefox
|
||||
});
|
||||
|
||||
test("admin settings", async ({ page }) => {
|
||||
// Skipped in Firefox
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Fixme & Fail Annotations
|
||||
|
||||
### Fixme - Known Issues
|
||||
|
||||
```typescript
|
||||
// Mark test as needing fix (skips the test)
|
||||
test.fixme("broken after refactor", async ({ page }) => {
|
||||
// Test won't run but is tracked
|
||||
});
|
||||
|
||||
// Conditional fixme
|
||||
test("flaky on CI", async ({ page }) => {
|
||||
test.fixme(!!process.env.CI, "Investigate CI flakiness - ticket #123");
|
||||
|
||||
await page.goto("/flaky-feature");
|
||||
});
|
||||
```
|
||||
|
||||
### Fail - Expected Failures
|
||||
|
||||
```typescript
|
||||
// Test is expected to fail (runs but expects failure)
|
||||
test("known bug", async ({ page }) => {
|
||||
test.fail();
|
||||
|
||||
await page.goto("/buggy-page");
|
||||
// If this passes, the test fails (bug was fixed!)
|
||||
await expect(page.getByText("Working")).toBeVisible();
|
||||
});
|
||||
|
||||
// Conditional fail
|
||||
test("fails on webkit", async ({ page, browserName }) => {
|
||||
test.fail(browserName === "webkit", "WebKit rendering bug #456");
|
||||
|
||||
await page.goto("/render-test");
|
||||
await expect(page.getByTestId("element")).toHaveCSS("width", "100px");
|
||||
});
|
||||
```
|
||||
|
||||
### Difference Between Skip, Fixme, Fail
|
||||
|
||||
| Annotation | Runs? | Use Case |
|
||||
| -------------- | ----- | -------------------------------- |
|
||||
| `test.skip()` | No | Feature not applicable |
|
||||
| `test.fixme()` | No | Known bug, needs investigation |
|
||||
| `test.fail()` | Yes | Expected to fail, tracking a bug |
|
||||
|
||||
## Slow Tests
|
||||
|
||||
### Mark Slow Tests
|
||||
|
||||
```typescript
|
||||
// Triple the default timeout
|
||||
test("large data import", async ({ page }) => {
|
||||
test.slow();
|
||||
|
||||
await page.goto("/import");
|
||||
await page.setInputFiles("#file", "large-file.csv");
|
||||
await page.getByRole("button", { name: "Import" }).click();
|
||||
|
||||
await expect(page.getByText("Import complete")).toBeVisible();
|
||||
});
|
||||
|
||||
// Conditional slow
|
||||
test("video processing", async ({ page, browserName }) => {
|
||||
test.slow(browserName === "webkit", "WebKit video processing is slow");
|
||||
|
||||
await page.goto("/video-editor");
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Timeout
|
||||
|
||||
```typescript
|
||||
test("very long operation", async ({ page }) => {
|
||||
// Set specific timeout (in milliseconds)
|
||||
test.setTimeout(120000); // 2 minutes
|
||||
|
||||
await page.goto("/long-operation");
|
||||
});
|
||||
|
||||
// Timeout for describe block
|
||||
test.describe("Integration tests", () => {
|
||||
test.describe.configure({ timeout: 60000 });
|
||||
|
||||
test("test 1", async ({ page }) => {
|
||||
// Has 60 second timeout
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Test Steps
|
||||
|
||||
### Basic Steps
|
||||
|
||||
```typescript
|
||||
test("checkout flow", async ({ page }) => {
|
||||
await test.step("Add item to cart", async () => {
|
||||
await page.goto("/products");
|
||||
await page.getByRole("button", { name: "Add to Cart" }).click();
|
||||
});
|
||||
|
||||
await test.step("Go to checkout", async () => {
|
||||
await page.getByRole("link", { name: "Cart" }).click();
|
||||
await page.getByRole("button", { name: "Checkout" }).click();
|
||||
});
|
||||
|
||||
await test.step("Fill shipping info", async () => {
|
||||
await page.getByLabel("Address").fill("123 Test St");
|
||||
await page.getByLabel("City").fill("Test City");
|
||||
});
|
||||
|
||||
await test.step("Complete payment", async () => {
|
||||
await page.getByLabel("Card").fill("4242424242424242");
|
||||
await page.getByRole("button", { name: "Pay" }).click();
|
||||
});
|
||||
|
||||
await expect(page.getByText("Order confirmed")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Nested Steps
|
||||
|
||||
```typescript
|
||||
test("user registration", async ({ page }) => {
|
||||
await test.step("Fill registration form", async () => {
|
||||
await page.goto("/register");
|
||||
|
||||
await test.step("Personal info", async () => {
|
||||
await page.getByLabel("Name").fill("John Doe");
|
||||
await page.getByLabel("Email").fill("john@example.com");
|
||||
});
|
||||
|
||||
await test.step("Security", async () => {
|
||||
await page.getByLabel("Password").fill("SecurePass123");
|
||||
await page.getByLabel("Confirm Password").fill("SecurePass123");
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("Submit and verify", async () => {
|
||||
await page.getByRole("button", { name: "Register" }).click();
|
||||
await expect(page.getByText("Welcome")).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Steps with Return Values
|
||||
|
||||
```typescript
|
||||
test("verify order", async ({ page }) => {
|
||||
const orderId = await test.step("Create order", async () => {
|
||||
await page.goto("/checkout");
|
||||
await page.getByRole("button", { name: "Place Order" }).click();
|
||||
|
||||
// Return value from step
|
||||
return await page.getByTestId("order-id").textContent();
|
||||
});
|
||||
|
||||
await test.step("Verify order details", async () => {
|
||||
await page.goto(`/orders/${orderId}`);
|
||||
await expect(page.getByText(`Order #${orderId}`)).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Step in Page Object
|
||||
|
||||
```typescript
|
||||
// pages/checkout.page.ts
|
||||
export class CheckoutPage {
|
||||
async fillShippingInfo(address: string, city: string) {
|
||||
await test.step("Fill shipping information", async () => {
|
||||
await this.page.getByLabel("Address").fill(address);
|
||||
await this.page.getByLabel("City").fill(city);
|
||||
});
|
||||
}
|
||||
|
||||
async completePayment(cardNumber: string) {
|
||||
await test.step("Complete payment", async () => {
|
||||
await this.page.getByLabel("Card").fill(cardNumber);
|
||||
await this.page.getByRole("button", { name: "Pay" }).click();
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Annotations
|
||||
|
||||
### Add Annotations
|
||||
|
||||
```typescript
|
||||
test("important feature", async ({ page }, testInfo) => {
|
||||
// Add custom annotation
|
||||
testInfo.annotations.push({
|
||||
type: "priority",
|
||||
description: "high",
|
||||
});
|
||||
|
||||
testInfo.annotations.push({
|
||||
type: "ticket",
|
||||
description: "JIRA-123",
|
||||
});
|
||||
|
||||
await page.goto("/feature");
|
||||
});
|
||||
```
|
||||
|
||||
### Annotation Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/annotations.fixture.ts
|
||||
import { test as base, TestInfo } from "@playwright/test";
|
||||
|
||||
type AnnotationFixtures = {
|
||||
annotate: {
|
||||
ticket: (id: string) => void;
|
||||
priority: (level: "low" | "medium" | "high") => void;
|
||||
owner: (name: string) => void;
|
||||
};
|
||||
};
|
||||
|
||||
export const test = base.extend<AnnotationFixtures>({
|
||||
annotate: async ({}, use, testInfo) => {
|
||||
await use({
|
||||
ticket: (id) => {
|
||||
testInfo.annotations.push({ type: "ticket", description: id });
|
||||
},
|
||||
priority: (level) => {
|
||||
testInfo.annotations.push({ type: "priority", description: level });
|
||||
},
|
||||
owner: (name) => {
|
||||
testInfo.annotations.push({ type: "owner", description: name });
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
test("critical feature", async ({ page, annotate }) => {
|
||||
annotate.ticket("JIRA-456");
|
||||
annotate.priority("high");
|
||||
annotate.owner("Alice");
|
||||
|
||||
await page.goto("/critical");
|
||||
});
|
||||
```
|
||||
|
||||
### Read Annotations in Reporter
|
||||
|
||||
```typescript
|
||||
// reporters/annotation-reporter.ts
|
||||
import { Reporter, TestCase, TestResult } from "@playwright/test/reporter";
|
||||
|
||||
class AnnotationReporter implements Reporter {
|
||||
onTestEnd(test: TestCase, result: TestResult) {
|
||||
const ticket = test.annotations.find((a) => a.type === "ticket");
|
||||
const priority = test.annotations.find((a) => a.type === "priority");
|
||||
|
||||
if (ticket) {
|
||||
console.log(`Test linked to: ${ticket.description}`);
|
||||
}
|
||||
|
||||
if (priority?.description === "high" && result.status === "failed") {
|
||||
console.log(`HIGH PRIORITY FAILURE: ${test.title}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AnnotationReporter;
|
||||
```
|
||||
|
||||
## Conditional Annotations
|
||||
|
||||
### Annotation Helper
|
||||
|
||||
```typescript
|
||||
// helpers/test-annotations.ts
|
||||
import { test } from "@playwright/test";
|
||||
|
||||
export function skipInCI(reason = "Skipped in CI") {
|
||||
test.skip(!!process.env.CI, reason);
|
||||
}
|
||||
|
||||
export function skipInBrowser(browser: string, reason: string) {
|
||||
test.beforeEach(({ browserName }) => {
|
||||
test.skip(browserName === browser, reason);
|
||||
});
|
||||
}
|
||||
|
||||
export function onlyInEnv(env: string) {
|
||||
test.skip(process.env.ENV !== env, `Only runs in ${env}`);
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/feature.spec.ts
|
||||
import { skipInCI, onlyInEnv } from "../helpers/test-annotations";
|
||||
|
||||
test("local only feature", async ({ page }) => {
|
||||
skipInCI("Uses local resources");
|
||||
|
||||
await page.goto("/local-feature");
|
||||
});
|
||||
|
||||
test("production check", async ({ page }) => {
|
||||
onlyInEnv("production");
|
||||
|
||||
await page.goto("/prod-only");
|
||||
});
|
||||
```
|
||||
|
||||
### Describe-Level Conditions
|
||||
|
||||
```typescript
|
||||
test.describe("Mobile features", () => {
|
||||
test.beforeEach(({ isMobile }) => {
|
||||
test.skip(!isMobile, "Mobile only tests");
|
||||
});
|
||||
|
||||
test("touch gestures", async ({ page }) => {
|
||||
// Only runs on mobile
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Desktop features", () => {
|
||||
test.beforeEach(({ isMobile }) => {
|
||||
test.skip(isMobile, "Desktop only tests");
|
||||
});
|
||||
|
||||
test("hover interactions", async ({ page }) => {
|
||||
// Only runs on desktop
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| --------------------------- | ---------------------- | -------------------------------- |
|
||||
| Skipping without reason | Hard to track why | Always provide description |
|
||||
| Too many skipped tests | Test debt accumulates | Review and clean up regularly |
|
||||
| Using skip instead of fixme | Loses intent | Use fixme for bugs, skip for N/A |
|
||||
| Not using steps | Hard to debug failures | Group logical actions in steps |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Test Tags**: See [test-tags.md](test-tags.md) for tagging and filtering tests with `--grep`
|
||||
- **Test Organization**: See [test-suite-structure.md](test-suite-structure.md) for structuring tests
|
||||
- **Debugging**: See [debugging.md](../debugging/debugging.md) for troubleshooting
|
||||
@@ -0,0 +1,361 @@
|
||||
# Assertions & Waiting
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Web-First Assertions](#web-first-assertions)
|
||||
2. [Generic Assertions](#generic-assertions)
|
||||
3. [Soft Assertions](#soft-assertions)
|
||||
4. [Waiting Strategies](#waiting-strategies)
|
||||
5. [Polling & Retrying](#polling--retrying)
|
||||
6. [Custom Matchers](#custom-matchers)
|
||||
|
||||
## Web-First Assertions
|
||||
|
||||
Auto-retry until condition is met or timeout. Always prefer these over generic assertions.
|
||||
|
||||
### Locator Assertions
|
||||
|
||||
```typescript
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
// Visibility
|
||||
await expect(page.getByRole("button")).toBeVisible();
|
||||
await expect(page.getByRole("button")).toBeHidden();
|
||||
await expect(page.getByRole("button")).not.toBeVisible();
|
||||
|
||||
// Enabled/Disabled
|
||||
await expect(page.getByRole("button")).toBeEnabled();
|
||||
await expect(page.getByRole("button")).toBeDisabled();
|
||||
|
||||
// Text content
|
||||
await expect(page.getByRole("heading")).toHaveText("Welcome");
|
||||
await expect(page.getByRole("heading")).toHaveText(/welcome/i);
|
||||
await expect(page.getByRole("heading")).toContainText("Welcome");
|
||||
|
||||
// Count
|
||||
await expect(page.getByRole("listitem")).toHaveCount(5);
|
||||
|
||||
// Attributes
|
||||
await expect(page.getByRole("link")).toHaveAttribute("href", "/home");
|
||||
await expect(page.getByRole("img")).toHaveAttribute("alt", /logo/i);
|
||||
|
||||
// CSS
|
||||
await expect(page.getByRole("button")).toHaveClass(/primary/);
|
||||
await expect(page.getByRole("button")).toHaveCSS("color", "rgb(0, 0, 255)");
|
||||
|
||||
// Input values
|
||||
await expect(page.getByLabel("Email")).toHaveValue("user@example.com");
|
||||
await expect(page.getByLabel("Email")).toBeEmpty();
|
||||
|
||||
// Focus
|
||||
await expect(page.getByLabel("Email")).toBeFocused();
|
||||
|
||||
// Checked state
|
||||
await expect(page.getByRole("checkbox")).toBeChecked();
|
||||
await expect(page.getByRole("checkbox")).not.toBeChecked();
|
||||
|
||||
// Editable state
|
||||
await expect(page.getByLabel("Name")).toBeEditable();
|
||||
```
|
||||
|
||||
### Page Assertions
|
||||
|
||||
```typescript
|
||||
// URL
|
||||
await expect(page).toHaveURL("/dashboard");
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
|
||||
// Title
|
||||
await expect(page).toHaveTitle("Dashboard - MyApp");
|
||||
await expect(page).toHaveTitle(/dashboard/i);
|
||||
```
|
||||
|
||||
### Response Assertions
|
||||
|
||||
```typescript
|
||||
const response = await page.request.get("/api/users");
|
||||
await expect(response).toBeOK();
|
||||
await expect(response).not.toBeOK();
|
||||
```
|
||||
|
||||
## Generic Assertions
|
||||
|
||||
Use for non-UI values. Do NOT retry - execute immediately.
|
||||
|
||||
```typescript
|
||||
// Equality
|
||||
expect(value).toBe(5);
|
||||
expect(object).toEqual({ name: "Test" });
|
||||
expect(array).toContain("item");
|
||||
|
||||
// Truthiness
|
||||
expect(value).toBeTruthy();
|
||||
expect(value).toBeFalsy();
|
||||
expect(value).toBeNull();
|
||||
expect(value).toBeUndefined();
|
||||
expect(value).toBeDefined();
|
||||
|
||||
// Numbers
|
||||
expect(value).toBeGreaterThan(5);
|
||||
expect(value).toBeLessThanOrEqual(10);
|
||||
expect(value).toBeCloseTo(5.5, 1);
|
||||
|
||||
// Strings
|
||||
expect(string).toMatch(/pattern/);
|
||||
expect(string).toContain("substring");
|
||||
|
||||
// Arrays/Objects
|
||||
expect(array).toHaveLength(3);
|
||||
expect(object).toHaveProperty("key", "value");
|
||||
|
||||
// Exceptions
|
||||
expect(() => fn()).toThrow();
|
||||
expect(() => fn()).toThrow("error message");
|
||||
await expect(asyncFn()).rejects.toThrow();
|
||||
```
|
||||
|
||||
## Soft Assertions
|
||||
|
||||
Continue test execution after failure, report all failures at end.
|
||||
|
||||
```typescript
|
||||
test("check multiple elements", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
|
||||
// Won't stop on first failure
|
||||
await expect.soft(page.getByRole("heading")).toHaveText("Dashboard");
|
||||
await expect.soft(page.getByRole("button", { name: "Save" })).toBeEnabled();
|
||||
await expect.soft(page.getByText("Welcome")).toBeVisible();
|
||||
|
||||
// Test continues; all failures reported at end
|
||||
});
|
||||
```
|
||||
|
||||
### Soft Assertions with Early Exit
|
||||
|
||||
```typescript
|
||||
test("check form", async ({ page }) => {
|
||||
await expect.soft(page.getByRole("form")).toBeVisible();
|
||||
|
||||
// Exit early if form not visible (pointless to check fields)
|
||||
if (expect.soft.hasFailures()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await expect.soft(page.getByLabel("Name")).toBeVisible();
|
||||
await expect.soft(page.getByLabel("Email")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Waiting Strategies
|
||||
|
||||
### Auto-Waiting (Default)
|
||||
|
||||
Actions automatically wait for:
|
||||
|
||||
- Element to be attached to DOM
|
||||
- Element to be visible
|
||||
- Element to be stable (no animations)
|
||||
- Element to be enabled
|
||||
- Element to receive events
|
||||
|
||||
```typescript
|
||||
// These auto-wait
|
||||
await page.click("button");
|
||||
await page.fill("input", "text");
|
||||
await page.getByRole("button").click();
|
||||
```
|
||||
|
||||
### Wait for Navigation
|
||||
|
||||
```typescript
|
||||
// Wait for URL change
|
||||
await page.waitForURL("/dashboard");
|
||||
await page.waitForURL(/\/dashboard/);
|
||||
|
||||
// Wait for navigation after action
|
||||
await Promise.all([
|
||||
page.waitForURL("**/dashboard"),
|
||||
page.click('a[href="/dashboard"]'),
|
||||
]);
|
||||
|
||||
// Or without Promise.all
|
||||
const urlPromise = page.waitForURL("**/dashboard");
|
||||
await page.click("a");
|
||||
await urlPromise;
|
||||
```
|
||||
|
||||
### Wait for Network
|
||||
|
||||
```typescript
|
||||
// Wait for specific response
|
||||
const responsePromise = page.waitForResponse("**/api/users");
|
||||
await page.click("button");
|
||||
const response = await responsePromise;
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
// Wait for request
|
||||
const requestPromise = page.waitForRequest("**/api/submit");
|
||||
await page.click("button");
|
||||
const request = await requestPromise;
|
||||
|
||||
// Wait for no network activity
|
||||
await page.waitForLoadState("networkidle");
|
||||
```
|
||||
|
||||
### Wait for Element State
|
||||
|
||||
```typescript
|
||||
// Wait for element to appear
|
||||
await page.getByRole("dialog").waitFor({ state: "visible" });
|
||||
|
||||
// Wait for element to disappear
|
||||
await page.getByText("Loading...").waitFor({ state: "hidden" });
|
||||
|
||||
// Wait for element to be attached
|
||||
await page.getByTestId("result").waitFor({ state: "attached" });
|
||||
|
||||
// Wait for element to be detached
|
||||
await page.getByTestId("modal").waitFor({ state: "detached" });
|
||||
```
|
||||
|
||||
### Wait for Function
|
||||
|
||||
```typescript
|
||||
// Wait for arbitrary condition
|
||||
await page.waitForFunction(() => {
|
||||
return document.querySelector(".loaded") !== null;
|
||||
});
|
||||
|
||||
// With arguments
|
||||
await page.waitForFunction(
|
||||
(selector) => document.querySelector(selector)?.textContent === "Ready",
|
||||
".status",
|
||||
);
|
||||
```
|
||||
|
||||
## Polling & Retrying
|
||||
|
||||
### toPass() for Polling
|
||||
|
||||
Retry until block passes or times out:
|
||||
|
||||
```typescript
|
||||
await expect(async () => {
|
||||
const response = await page.request.get("/api/status");
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.ready).toBe(true);
|
||||
}).toPass({
|
||||
intervals: [1000, 2000, 5000], // Retry intervals
|
||||
timeout: 30000,
|
||||
});
|
||||
```
|
||||
|
||||
### expect.poll()
|
||||
|
||||
Poll a function until assertion passes:
|
||||
|
||||
```typescript
|
||||
// Poll API until condition met
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const response = await page.request.get("/api/job/123");
|
||||
return (await response.json()).status;
|
||||
},
|
||||
{
|
||||
intervals: [1000, 2000, 5000],
|
||||
timeout: 30000,
|
||||
},
|
||||
)
|
||||
.toBe("completed");
|
||||
|
||||
// Poll DOM value
|
||||
await expect.poll(() => page.getByTestId("counter").textContent()).toBe("10");
|
||||
```
|
||||
|
||||
## Custom Matchers
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts or fixtures
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
expect.extend({
|
||||
async toHaveDataLoaded(page: Page) {
|
||||
const locator = page.getByTestId("data-container");
|
||||
let pass = false;
|
||||
let message = "";
|
||||
|
||||
try {
|
||||
await expect(locator).toBeVisible();
|
||||
await expect(locator).not.toContainText("Loading");
|
||||
pass = true;
|
||||
} catch (e) {
|
||||
message = `Expected data to be loaded but found loading state`;
|
||||
}
|
||||
|
||||
return { pass, message: () => message };
|
||||
},
|
||||
});
|
||||
|
||||
// Extend TypeScript types
|
||||
declare global {
|
||||
namespace PlaywrightTest {
|
||||
interface Matchers<R> {
|
||||
toHaveDataLoaded(): Promise<R>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
await expect(page).toHaveDataLoaded();
|
||||
```
|
||||
|
||||
## Timeouts
|
||||
|
||||
### Configure Timeouts
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
timeout: 30000, // Test timeout
|
||||
expect: {
|
||||
timeout: 5000, // Assertion timeout
|
||||
},
|
||||
});
|
||||
|
||||
// Per-test timeout
|
||||
test("long test", async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
// ...
|
||||
});
|
||||
|
||||
// Per-assertion timeout
|
||||
await expect(page.getByRole("button")).toBeVisible({ timeout: 10000 });
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
| Do | Don't |
|
||||
| ------------------------------ | ------------------------------ |
|
||||
| Use web-first assertions | Use generic assertions for DOM |
|
||||
| Let auto-waiting work | Add unnecessary explicit waits |
|
||||
| Use `toPass()` for polling | Write manual retry loops |
|
||||
| Configure appropriate timeouts | Use `waitForTimeout()` |
|
||||
| Check specific conditions | Wait for arbitrary time |
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| --------------------------------------------------------- | ----------------------------- | -------------------------------------------- |
|
||||
| `await page.waitForTimeout(5000)` | Slow, flaky, arbitrary timing | Use auto-waiting or `waitForResponse` |
|
||||
| `await new Promise(resolve => setTimeout(resolve, 1000))` | Same as above | Use `waitForResponse` or element state waits |
|
||||
| Generic assertions on DOM elements | No auto-retry, flaky | Use web-first assertions with `expect()` |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Debugging timeout issues**: See [debugging.md](../debugging/debugging.md) for troubleshooting
|
||||
- **Fixing flaky tests**: See [debugging.md](../debugging/debugging.md) for race condition solutions
|
||||
- **Network interception**: See [test-suite-structure.md](test-suite-structure.md) for API mocking
|
||||
@@ -0,0 +1,452 @@
|
||||
# Playwright Configuration
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [CLI Quick Reference](#cli-quick-reference)
|
||||
2. [Decision Guide](#decision-guide)
|
||||
3. [Production-Ready Config](#production-ready-config)
|
||||
4. [Patterns](#patterns)
|
||||
5. [Anti-Patterns](#anti-patterns)
|
||||
6. [Troubleshooting](#troubleshooting)
|
||||
7. [Related](#related)
|
||||
|
||||
> **When to use**: Setting up a new project, adjusting timeouts, adding browser targets, configuring CI behavior, or managing environment-specific settings.
|
||||
|
||||
## CLI Quick Reference
|
||||
|
||||
```bash
|
||||
npx playwright init # scaffold config + first test
|
||||
npx playwright test --config=custom.config.ts # use alternate config
|
||||
npx playwright test --project=chromium # run single project
|
||||
npx playwright test --reporter=html # override reporter
|
||||
npx playwright test --grep @smoke # run tests tagged @smoke
|
||||
npx playwright test --grep-invert @slow # exclude @slow tests
|
||||
npx playwright show-report # open last HTML report
|
||||
DEBUG=pw:api npx playwright test # verbose logging
|
||||
```
|
||||
|
||||
## Decision Guide
|
||||
|
||||
### Timeout Selection
|
||||
|
||||
| Symptom | Setting | Default | Recommended |
|
||||
|---------|---------|---------|-------------|
|
||||
| Test takes too long overall | `timeout` | 30s | 30-60s (max 120s) |
|
||||
| Assertion retries too long/short | `expect.timeout` | 5s | 5-10s |
|
||||
| `page.goto()` or `waitForURL()` times out | `navigationTimeout` | 30s | 10-30s |
|
||||
| `click()`, `fill()` time out | `actionTimeout` | 0 (unlimited) | 10-15s |
|
||||
| Dev server slow to start | `webServer.timeout` | 60s | 60-180s |
|
||||
|
||||
### Server Management
|
||||
|
||||
| Scenario | Approach |
|
||||
|----------|----------|
|
||||
| App in same repo | `webServer` with `reuseExistingServer: !process.env.CI` |
|
||||
| Separate repos | Manual start or Docker Compose |
|
||||
| Testing deployed environment | No `webServer`; set `baseURL` via env |
|
||||
| Multiple services | Array of `webServer` entries |
|
||||
|
||||
### Single vs Multi-Project
|
||||
|
||||
| Scenario | Approach |
|
||||
|----------|----------|
|
||||
| Early development | Single project (chromium only) |
|
||||
| Pre-release validation | Multi-project: chromium + firefox + webkit |
|
||||
| Mobile-responsive app | Add mobile projects alongside desktop |
|
||||
| Auth + non-auth tests | Setup project with dependencies |
|
||||
| Tight CI budget | Chromium on PRs; all browsers on main |
|
||||
|
||||
### globalSetup vs Setup Projects vs Fixtures
|
||||
|
||||
| Need | Use |
|
||||
|------|-----|
|
||||
| One-time DB seed | `globalSetup` |
|
||||
| Shared browser auth | Setup project with `dependencies` |
|
||||
| Per-test isolated state | Custom fixture via `test.extend()` |
|
||||
| Cleanup after all tests | `globalTeardown` |
|
||||
|
||||
## Production-Ready Config
|
||||
|
||||
```ts
|
||||
// playwright.config.ts
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
testMatch: '**/*.spec.ts',
|
||||
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? '50%' : undefined,
|
||||
|
||||
reporter: process.env.CI
|
||||
? [['html', { open: 'never' }], ['github']]
|
||||
: [['html', { open: 'on-failure' }]],
|
||||
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 5_000 },
|
||||
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:4000',
|
||||
actionTimeout: 10_000,
|
||||
navigationTimeout: 15_000,
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
locale: 'en-US',
|
||||
timezoneId: 'America/Los_Angeles',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
||||
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
|
||||
{ name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
|
||||
{ name: 'mobile-safari', use: { ...devices['iPhone 14'] } },
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: 'npm run start',
|
||||
url: 'http://localhost:4000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Patterns
|
||||
|
||||
### Environment-Specific Configuration
|
||||
|
||||
**Use when**: Tests run against dev, staging, and production environments.
|
||||
|
||||
```ts
|
||||
// playwright.config.ts
|
||||
import { defineConfig } from '@playwright/test';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
const ENV = process.env.TEST_ENV || 'local';
|
||||
dotenv.config({ path: path.resolve(__dirname, `.env.${ENV}`) });
|
||||
|
||||
const envConfig: Record<string, { baseURL: string; retries: number }> = {
|
||||
local: { baseURL: 'http://localhost:4000', retries: 0 },
|
||||
staging: { baseURL: 'https://staging.myapp.com', retries: 2 },
|
||||
prod: { baseURL: 'https://myapp.com', retries: 2 },
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
retries: envConfig[ENV].retries,
|
||||
use: { baseURL: envConfig[ENV].baseURL },
|
||||
});
|
||||
```
|
||||
|
||||
```bash
|
||||
TEST_ENV=staging npx playwright test
|
||||
TEST_ENV=prod npx playwright test --grep @smoke
|
||||
```
|
||||
|
||||
### Setup Project with Dependencies
|
||||
|
||||
**Use when**: Tests need shared authentication state before running.
|
||||
|
||||
```ts
|
||||
// playwright.config.ts
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
projects: [
|
||||
{
|
||||
name: 'setup',
|
||||
testMatch: /auth\.setup\.ts/,
|
||||
},
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: 'playwright/.auth/session.json',
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: {
|
||||
...devices['Desktop Firefox'],
|
||||
storageState: 'playwright/.auth/session.json',
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// e2e/auth.setup.ts
|
||||
import { test as setup, expect } from '@playwright/test';
|
||||
|
||||
const authFile = 'playwright/.auth/session.json';
|
||||
|
||||
setup('authenticate', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Username').fill('testuser@example.com');
|
||||
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Home' })).toBeVisible();
|
||||
await page.context().storageState({ path: authFile });
|
||||
});
|
||||
```
|
||||
|
||||
### webServer with Build Step
|
||||
|
||||
**Use when**: Tests need a running application server managed by Playwright.
|
||||
|
||||
```ts
|
||||
// playwright.config.ts
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
use: { baseURL: 'http://localhost:4000' },
|
||||
webServer: {
|
||||
command: process.env.CI
|
||||
? 'npm run build && npm run preview'
|
||||
: 'npm run dev',
|
||||
url: 'http://localhost:4000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
NODE_ENV: 'test',
|
||||
DB_URL: process.env.DB_URL || 'postgresql://localhost:5432/testdb',
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### globalSetup / globalTeardown
|
||||
|
||||
**Use when**: One-time non-browser work like seeding a database. Runs once per test run.
|
||||
|
||||
```ts
|
||||
// playwright.config.ts
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
globalSetup: './e2e/setup.ts',
|
||||
globalTeardown: './e2e/teardown.ts',
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// e2e/setup.ts
|
||||
import { FullConfig } from '@playwright/test';
|
||||
|
||||
export default async function globalSetup(config: FullConfig) {
|
||||
const { execSync } = await import('child_process');
|
||||
execSync('npx prisma db seed', { stdio: 'inherit' });
|
||||
process.env.TEST_RUN_ID = `run-${Date.now()}`;
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// e2e/teardown.ts
|
||||
import { FullConfig } from '@playwright/test';
|
||||
|
||||
export default async function globalTeardown(config: FullConfig) {
|
||||
const { execSync } = await import('child_process');
|
||||
execSync('npx prisma db push --force-reset', { stdio: 'inherit' });
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Variables with .env
|
||||
|
||||
**Use when**: Managing secrets, URLs, or feature flags without hardcoding.
|
||||
|
||||
```bash
|
||||
# .env.example (commit this)
|
||||
BASE_URL=http://localhost:4000
|
||||
TEST_PASSWORD=
|
||||
API_KEY=
|
||||
|
||||
# .env.local (gitignored)
|
||||
BASE_URL=http://localhost:4000
|
||||
TEST_PASSWORD=secret123
|
||||
API_KEY=dev-key-abc
|
||||
|
||||
# .env.staging (gitignored)
|
||||
BASE_URL=https://staging.myapp.com
|
||||
TEST_PASSWORD=staging-pass
|
||||
API_KEY=staging-key-xyz
|
||||
```
|
||||
|
||||
```bash
|
||||
# .gitignore
|
||||
.env
|
||||
.env.local
|
||||
.env.staging
|
||||
.env.production
|
||||
playwright/.auth/
|
||||
```
|
||||
|
||||
Install dotenv:
|
||||
|
||||
```bash
|
||||
npm install -D dotenv
|
||||
```
|
||||
|
||||
### Tag-Based Test Filtering
|
||||
|
||||
**Use when**: Running subsets of tests in different CI stages (PR vs nightly).
|
||||
|
||||
```ts
|
||||
// playwright.config.ts
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
|
||||
// Filter by tags in CI
|
||||
grep: process.env.CI ? /@smoke|@critical/ : undefined,
|
||||
grepInvert: process.env.CI ? /@flaky/ : undefined,
|
||||
});
|
||||
```
|
||||
|
||||
**Project-specific filtering:**
|
||||
|
||||
```ts
|
||||
// playwright.config.ts
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
projects: [
|
||||
{
|
||||
name: 'smoke',
|
||||
grep: /@smoke/,
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'regression',
|
||||
grepInvert: /@smoke/,
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'critical-only',
|
||||
grep: /@critical/,
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```bash
|
||||
# Run specific project
|
||||
npx playwright test --project=smoke
|
||||
npx playwright test --project=regression
|
||||
```
|
||||
|
||||
### Artifact Collection Strategy
|
||||
|
||||
| Setting | Local | CI | Reason |
|
||||
|---------|-------|-----|--------|
|
||||
| `trace` | `'off'` | `'on-first-retry'` | Traces are large; collect on failure only |
|
||||
| `screenshot` | `'off'` | `'only-on-failure'` | Useful for CI debugging |
|
||||
| `video` | `'off'` | `'retain-on-failure'` | Recording slows tests |
|
||||
|
||||
```ts
|
||||
// playwright.config.ts
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
use: {
|
||||
trace: process.env.CI ? 'on-first-retry' : 'off',
|
||||
screenshot: process.env.CI ? 'only-on-failure' : 'off',
|
||||
video: process.env.CI ? 'retain-on-failure' : 'off',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Don't | Problem | Do Instead |
|
||||
|-------|---------|------------|
|
||||
| `timeout: 300_000` globally | Masks flaky tests; slow CI | Fix root cause; keep 30s default |
|
||||
| Hardcoded URLs: `page.goto('http://localhost:4000/login')` | Breaks in other environments | Use `baseURL` + relative paths |
|
||||
| All browsers on every PR | 3x CI time | Chromium on PRs; all on main |
|
||||
| `trace: 'on'` always | Huge artifacts, slow uploads | `trace: 'on-first-retry'` |
|
||||
| `video: 'on'` always | Massive storage; slow tests | `video: 'retain-on-failure'` |
|
||||
| Config in test files: `test.use({ viewport: {...} })` everywhere | Scattered, inconsistent | Define once in project config |
|
||||
| `retries: 3` locally | Hides flakiness | `retries: 0` local, `retries: 2` CI |
|
||||
| No `forbidOnly` in CI | Committed `test.only` runs single test | `forbidOnly: !!process.env.CI` |
|
||||
| `globalSetup` for browser auth | No browser context available | Use setup project with dependencies |
|
||||
| Committing `.env` with credentials | Security risk | Commit `.env.example` only |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### baseURL Not Working
|
||||
|
||||
**Cause**: Using absolute URL in `page.goto()` ignores `baseURL`.
|
||||
|
||||
```ts
|
||||
// Wrong - ignores baseURL
|
||||
await page.goto('http://localhost:4000/dashboard');
|
||||
|
||||
// Correct - uses baseURL
|
||||
await page.goto('/dashboard');
|
||||
```
|
||||
|
||||
### webServer Starts But Tests Get Connection Refused
|
||||
|
||||
**Cause**: `webServer.url` doesn't match actual server address or health check returns non-200.
|
||||
|
||||
```ts
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:4000/api/health', // use real endpoint
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
```
|
||||
|
||||
### Tests Pass Locally But Timeout in CI
|
||||
|
||||
**Cause**: CI machines are slower. Increase timeouts and reduce workers:
|
||||
|
||||
```ts
|
||||
export default defineConfig({
|
||||
workers: process.env.CI ? '50%' : undefined,
|
||||
use: {
|
||||
navigationTimeout: process.env.CI ? 30_000 : 15_000,
|
||||
actionTimeout: process.env.CI ? 15_000 : 10_000,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### "Target page, context or browser has been closed"
|
||||
|
||||
**Cause**: Test exceeded `timeout` and Playwright tore down browser during action.
|
||||
|
||||
**Fix**: Don't increase global timeout. Find slow step using trace:
|
||||
|
||||
```bash
|
||||
npx playwright test --trace on
|
||||
npx playwright show-report
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [test-tags.md](./test-tags.md) - tagging and filtering tests with `--grep`
|
||||
- [fixtures-hooks.md](./fixtures-hooks.md) - custom fixtures for per-test state
|
||||
- [test-suite-structure.md](test-suite-structure.md) - file structure and naming
|
||||
- [authentication.md](../advanced/authentication.md) - setup projects for shared auth
|
||||
- [projects-dependencies.md](./projects-dependencies.md) - advanced multi-project patterns
|
||||
@@ -0,0 +1,417 @@
|
||||
# Fixtures & Hooks
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Built-in Fixtures](#built-in-fixtures)
|
||||
2. [Custom Fixtures](#custom-fixtures)
|
||||
3. [Fixture Scopes](#fixture-scopes)
|
||||
4. [Hooks](#hooks)
|
||||
5. [Authentication Patterns](#authentication-patterns)
|
||||
6. [Database Fixtures](#database-fixtures)
|
||||
|
||||
## Built-in Fixtures
|
||||
|
||||
### Core Fixtures
|
||||
|
||||
```typescript
|
||||
test("example", async ({
|
||||
page, // Isolated page instance
|
||||
context, // Browser context (cookies, localStorage)
|
||||
browser, // Browser instance
|
||||
browserName, // 'chromium', 'firefox', or 'webkit'
|
||||
request, // API request context
|
||||
}) => {
|
||||
// Each test gets fresh instances
|
||||
});
|
||||
```
|
||||
|
||||
### Request Fixture
|
||||
|
||||
```typescript
|
||||
test("API call", async ({ request }) => {
|
||||
const response = await request.get("/api/users");
|
||||
await expect(response).toBeOK();
|
||||
|
||||
const users = await response.json();
|
||||
expect(users).toHaveLength(5);
|
||||
});
|
||||
```
|
||||
|
||||
## Custom Fixtures
|
||||
|
||||
### Basic Custom Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures.ts
|
||||
import { test as base } from "@playwright/test";
|
||||
|
||||
// Declare fixture types
|
||||
type MyFixtures = {
|
||||
todoPage: TodoPage;
|
||||
apiClient: ApiClient;
|
||||
};
|
||||
|
||||
export const test = base.extend<MyFixtures>({
|
||||
// Fixture with setup and teardown
|
||||
todoPage: async ({ page }, use) => {
|
||||
const todoPage = new TodoPage(page);
|
||||
await todoPage.goto();
|
||||
|
||||
await use(todoPage); // Test runs here
|
||||
|
||||
// Teardown (optional)
|
||||
await todoPage.clearTodos();
|
||||
},
|
||||
|
||||
// Simple fixture
|
||||
apiClient: async ({ request }, use) => {
|
||||
await use(new ApiClient(request));
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from "@playwright/test";
|
||||
```
|
||||
|
||||
### Fixture with Options
|
||||
|
||||
```typescript
|
||||
type Options = {
|
||||
defaultUser: { email: string; password: string };
|
||||
};
|
||||
|
||||
type Fixtures = {
|
||||
authenticatedPage: Page;
|
||||
};
|
||||
|
||||
export const test = base.extend<Options & Fixtures>({
|
||||
// Define option with default
|
||||
defaultUser: [
|
||||
{ email: "test@example.com", password: "pass123" },
|
||||
{ option: true },
|
||||
],
|
||||
|
||||
// Use option in fixture
|
||||
authenticatedPage: async ({ page, defaultUser }, use) => {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Email").fill(defaultUser.email);
|
||||
await page.getByLabel("Password").fill(defaultUser.password);
|
||||
await page.getByRole("button", { name: "Sign in" }).click();
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
|
||||
// Override in config
|
||||
export default defineConfig({
|
||||
use: {
|
||||
defaultUser: { email: "admin@example.com", password: "admin123" },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Automatic Fixtures
|
||||
|
||||
```typescript
|
||||
export const test = base.extend<{}, { setupDb: void }>({
|
||||
// Auto-fixture runs for every test without explicit usage
|
||||
setupDb: [
|
||||
async ({}, use) => {
|
||||
await seedDatabase();
|
||||
await use();
|
||||
await cleanDatabase();
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Fixture Scopes
|
||||
|
||||
### Test Scope (Default)
|
||||
|
||||
Created fresh for each test:
|
||||
|
||||
```typescript
|
||||
test.extend({
|
||||
page: async ({ browser }, use) => {
|
||||
const page = await browser.newPage();
|
||||
await use(page);
|
||||
await page.close();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Worker Scope
|
||||
|
||||
Shared across tests in the same worker (each worker gets its own instance; tests in different workers do not share it):
|
||||
|
||||
```typescript
|
||||
type WorkerFixtures = {
|
||||
sharedAccount: Account;
|
||||
};
|
||||
|
||||
export const test = base.extend<{}, WorkerFixtures>({
|
||||
sharedAccount: [
|
||||
async ({ browser }, use) => {
|
||||
// Expensive setup - runs once per worker
|
||||
const account = await createTestAccount();
|
||||
await use(account);
|
||||
await deleteTestAccount(account);
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Isolate test data between parallel workers
|
||||
|
||||
When tests in different workers touch the same backend or DB (e.g. same user, same tenant), they can collide and cause flaky failures. Use `testInfo.workerIndex` (or `process.env.TEST_WORKER_INDEX`) in a worker-scoped fixture to create unique data per worker:
|
||||
|
||||
```typescript
|
||||
import { test as baseTest } from "@playwright/test";
|
||||
|
||||
type WorkerFixtures = {
|
||||
dbUserName: string;
|
||||
};
|
||||
|
||||
export const test = baseTest.extend<{}, WorkerFixtures>({
|
||||
dbUserName: [
|
||||
async ({}, use, testInfo) => {
|
||||
const userName = `user-${testInfo.workerIndex}`;
|
||||
await createUserInTestDatabase(userName);
|
||||
await use(userName);
|
||||
await deleteUserFromTestDatabase(userName);
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Then each worker uses a distinct user (e.g. `user-1`, `user-2`), so parallel workers do not overwrite each other’s data.
|
||||
|
||||
## Hooks
|
||||
|
||||
### beforeEach / afterEach
|
||||
|
||||
```typescript
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Runs before each test in file
|
||||
await page.goto("/");
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
// Runs after each test
|
||||
if (testInfo.status !== "passed") {
|
||||
await page.screenshot({ path: `failed-${testInfo.title}.png` });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### beforeAll / afterAll
|
||||
|
||||
```typescript
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
// Runs once before all tests in file
|
||||
// Note: Cannot use page fixture here
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Runs once after all tests in file
|
||||
});
|
||||
```
|
||||
|
||||
### Describe-Level Hooks
|
||||
|
||||
```typescript
|
||||
test.describe("User Management", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/users");
|
||||
});
|
||||
|
||||
test("can list users", async ({ page }) => {
|
||||
// Starts at /users
|
||||
});
|
||||
|
||||
test("can add user", async ({ page }) => {
|
||||
// Starts at /users
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Authentication Patterns
|
||||
|
||||
### Global Setup with Storage State
|
||||
|
||||
```typescript
|
||||
// auth.setup.ts
|
||||
import { test as setup, expect } from "@playwright/test";
|
||||
|
||||
const authFile = ".auth/user.json";
|
||||
|
||||
setup("authenticate", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Email").fill(process.env.TEST_EMAIL!);
|
||||
await page.getByLabel("Password").fill(process.env.TEST_PASSWORD!);
|
||||
await page.getByRole("button", { name: "Sign in" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
|
||||
await page.context().storageState({ path: authFile });
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{ name: "setup", testMatch: /.*\.setup\.ts/ },
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
storageState: ".auth/user.json",
|
||||
},
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Multiple Auth States
|
||||
|
||||
```typescript
|
||||
// auth.setup.ts
|
||||
setup("admin auth", async ({ page }) => {
|
||||
await login(page, "admin@example.com", "adminpass");
|
||||
await page.context().storageState({ path: ".auth/admin.json" });
|
||||
});
|
||||
|
||||
setup("user auth", async ({ page }) => {
|
||||
await login(page, "user@example.com", "userpass");
|
||||
await page.context().storageState({ path: ".auth/user.json" });
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
projects: [
|
||||
{
|
||||
name: "admin tests",
|
||||
testMatch: /.*admin.*\.spec\.ts/,
|
||||
use: { storageState: ".auth/admin.json" },
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
{
|
||||
name: "user tests",
|
||||
testMatch: /.*user.*\.spec\.ts/,
|
||||
use: { storageState: ".auth/user.json" },
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### Auth Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/auth.fixture.ts
|
||||
export const test = base.extend<{ adminPage: Page; userPage: Page }>({
|
||||
adminPage: async ({ browser }, use) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: ".auth/admin.json",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await use(page);
|
||||
await context.close();
|
||||
},
|
||||
|
||||
userPage: async ({ browser }, use) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: ".auth/user.json",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await use(page);
|
||||
await context.close();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Database Fixtures
|
||||
|
||||
This section covers **per-test database fixtures** (isolation, transaction rollback). For related topics:
|
||||
|
||||
- **Test data factories** (builders, Faker): See [test-data.md](test-data.md)
|
||||
- **One-time database setup** (migrations, snapshots): See [global-setup.md](global-setup.md#database-patterns)
|
||||
|
||||
### Transaction Rollback Pattern
|
||||
|
||||
```typescript
|
||||
import { test as base } from "@playwright/test";
|
||||
import { db } from "../db";
|
||||
|
||||
export const test = base.extend<{ dbTransaction: Transaction }>({
|
||||
dbTransaction: async ({}, use) => {
|
||||
const transaction = await db.beginTransaction();
|
||||
|
||||
await use(transaction);
|
||||
|
||||
await transaction.rollback(); // Clean slate for next test
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Seed Data Fixture
|
||||
|
||||
```typescript
|
||||
type TestData = {
|
||||
testUser: User;
|
||||
testProducts: Product[];
|
||||
};
|
||||
|
||||
export const test = base.extend<TestData>({
|
||||
testUser: async ({}, use) => {
|
||||
const user = await db.users.create({
|
||||
email: `test-${Date.now()}@example.com`,
|
||||
name: "Test User",
|
||||
});
|
||||
|
||||
await use(user);
|
||||
|
||||
await db.users.delete(user.id);
|
||||
},
|
||||
|
||||
testProducts: async ({ testUser }, use) => {
|
||||
const products = await db.products.createMany([
|
||||
{ name: "Product A", ownerId: testUser.id },
|
||||
{ name: "Product B", ownerId: testUser.id },
|
||||
]);
|
||||
|
||||
await use(products);
|
||||
|
||||
await db.products.deleteMany(products.map((p) => p.id));
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Fixture Tips
|
||||
|
||||
| Tip | Explanation |
|
||||
| ------------------ | ------------------------------------------- |
|
||||
| Fixtures are lazy | Only created when used |
|
||||
| Compose fixtures | Use other fixtures as dependencies |
|
||||
| Keep setup minimal | Do heavy lifting in worker-scoped fixtures |
|
||||
| Clean up resources | Use teardown in fixtures, not afterEach |
|
||||
| Avoid shared state | Each fixture instance should be independent |
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ----------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Shared mutable state between tests | Race conditions, order dependencies | Use fixtures for isolation |
|
||||
| Global variables in tests | Tests depend on execution order | Use fixtures or beforeEach for setup |
|
||||
| Not cleaning up test data | Tests interfere with each other | Use fixtures with teardown or database transactions |
|
||||
| Shared `page` or `context` in `beforeAll` | State leak between tests; flaky when tests run in parallel | Use default one-context-per-test, or `beforeEach` + fresh page; if serial is required, prefer `test.describe.configure({ mode: 'serial' })` and document that isolation is sacrificed |
|
||||
| Backend/DB state shared across workers | Tests in different workers collide on same data | Use worker-scoped fixture with `testInfo.workerIndex` to create unique data per worker |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Page Objects with fixtures**: See [page-object-model.md](page-object-model.md) for POM patterns
|
||||
- **Test organization**: See [test-suite-structure.md](test-suite-structure.md) for test structure
|
||||
- **Debugging fixture issues**: See [debugging.md](../debugging/debugging.md) for troubleshooting
|
||||
@@ -0,0 +1,434 @@
|
||||
# Global Setup & Teardown
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Global Setup](#global-setup)
|
||||
2. [Global Teardown](#global-teardown)
|
||||
3. [Database Patterns](#database-patterns)
|
||||
4. [Environment Provisioning](#environment-provisioning)
|
||||
5. [Setup Projects vs Global Setup](#setup-projects-vs-global-setup)
|
||||
6. [Parallel Execution Caveats](#parallel-execution-caveats)
|
||||
|
||||
## Global Setup
|
||||
|
||||
### Basic Global Setup
|
||||
|
||||
```typescript
|
||||
// global-setup.ts
|
||||
import { FullConfig } from "@playwright/test";
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
console.log("Running global setup...");
|
||||
// Perform one-time setup: start services, run migrations, etc.
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
```
|
||||
|
||||
### Configure Global Setup
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
globalSetup: require.resolve("./global-setup"),
|
||||
globalTeardown: require.resolve("./global-teardown"),
|
||||
});
|
||||
```
|
||||
|
||||
> **Authentication in Global Setup**: For authentication patterns using storage state in global setup, see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns). Setup projects are generally preferred for authentication as they provide access to Playwright fixtures.
|
||||
|
||||
### Global Setup with Return Value
|
||||
|
||||
```typescript
|
||||
// global-setup.ts
|
||||
async function globalSetup(config: FullConfig): Promise<() => Promise<void>> {
|
||||
const server = await startTestServer();
|
||||
|
||||
// Return cleanup function (alternative to globalTeardown)
|
||||
return async () => {
|
||||
await server.stop();
|
||||
};
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
```
|
||||
|
||||
### Access Config in Global Setup
|
||||
|
||||
```typescript
|
||||
// global-setup.ts
|
||||
import { FullConfig } from "@playwright/test";
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
const { baseURL } = config.projects[0].use;
|
||||
console.log(`Setting up for ${baseURL}`);
|
||||
|
||||
// Access custom config
|
||||
const workers = config.workers;
|
||||
const timeout = config.timeout;
|
||||
|
||||
// Access environment
|
||||
const isCI = !!process.env.CI;
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
```
|
||||
|
||||
## Global Teardown
|
||||
|
||||
### Basic Global Teardown
|
||||
|
||||
```typescript
|
||||
// global-teardown.ts
|
||||
import { FullConfig } from "@playwright/test";
|
||||
import fs from "fs";
|
||||
|
||||
async function globalTeardown(config: FullConfig) {
|
||||
console.log("Running global teardown...");
|
||||
|
||||
// Clean up auth files
|
||||
if (fs.existsSync(".auth")) {
|
||||
fs.rmSync(".auth", { recursive: true });
|
||||
}
|
||||
|
||||
// Clean up test data
|
||||
await cleanupTestDatabase();
|
||||
|
||||
// Stop services
|
||||
await stopTestServices();
|
||||
}
|
||||
|
||||
export default globalTeardown;
|
||||
```
|
||||
|
||||
### Conditional Teardown
|
||||
|
||||
```typescript
|
||||
// global-teardown.ts
|
||||
async function globalTeardown(config: FullConfig) {
|
||||
// Skip cleanup in CI (containers are discarded anyway)
|
||||
if (process.env.CI) {
|
||||
console.log("Skipping teardown in CI");
|
||||
return;
|
||||
}
|
||||
|
||||
// Local cleanup
|
||||
await cleanupLocalTestData();
|
||||
}
|
||||
|
||||
export default globalTeardown;
|
||||
```
|
||||
|
||||
## Database Patterns
|
||||
|
||||
This section covers **one-time database setup** (migrations, snapshots, per-worker databases). For related topics:
|
||||
|
||||
- **Per-test database fixtures** (isolation, transaction rollback): See [fixtures-hooks.md](fixtures-hooks.md#database-fixtures)
|
||||
- **Test data factories** (builders, Faker): See [test-data.md](test-data.md)
|
||||
|
||||
### Database Migration in Setup
|
||||
|
||||
```typescript
|
||||
// global-setup.ts
|
||||
import { execSync } from "child_process";
|
||||
|
||||
async function globalSetup() {
|
||||
console.log("Running database migrations...");
|
||||
|
||||
// Run migrations
|
||||
execSync("npx prisma migrate deploy", { stdio: "inherit" });
|
||||
|
||||
// Seed test data
|
||||
execSync("npx prisma db seed", { stdio: "inherit" });
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
```
|
||||
|
||||
### Database Snapshot Pattern
|
||||
|
||||
```typescript
|
||||
// global-setup.ts
|
||||
import { execSync } from "child_process";
|
||||
import fs from "fs";
|
||||
|
||||
const SNAPSHOT_PATH = "./test-db-snapshot.sql";
|
||||
|
||||
async function globalSetup() {
|
||||
// Check if snapshot exists
|
||||
if (fs.existsSync(SNAPSHOT_PATH)) {
|
||||
console.log("Restoring database from snapshot...");
|
||||
execSync(`psql $DATABASE_URL < ${SNAPSHOT_PATH}`, { stdio: "inherit" });
|
||||
return;
|
||||
}
|
||||
|
||||
// First run: migrate and create snapshot
|
||||
console.log("Creating database snapshot...");
|
||||
execSync("npx prisma migrate deploy", { stdio: "inherit" });
|
||||
execSync("npx prisma db seed", { stdio: "inherit" });
|
||||
execSync(`pg_dump $DATABASE_URL > ${SNAPSHOT_PATH}`, { stdio: "inherit" });
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
```
|
||||
|
||||
### Test Database per Worker
|
||||
|
||||
```typescript
|
||||
// global-setup.ts
|
||||
async function globalSetup(config: FullConfig) {
|
||||
const workerCount = config.workers || 1;
|
||||
|
||||
// Create a database for each worker
|
||||
for (let i = 0; i < workerCount; i++) {
|
||||
const dbName = `test_db_worker_${i}`;
|
||||
await createDatabase(dbName);
|
||||
await runMigrations(dbName);
|
||||
await seedDatabase(dbName);
|
||||
}
|
||||
}
|
||||
|
||||
// global-teardown.ts
|
||||
async function globalTeardown(config: FullConfig) {
|
||||
const workerCount = config.workers || 1;
|
||||
|
||||
for (let i = 0; i < workerCount; i++) {
|
||||
await dropDatabase(`test_db_worker_${i}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Provisioning
|
||||
|
||||
### Start Services in Setup
|
||||
|
||||
```typescript
|
||||
// global-setup.ts
|
||||
import { execSync, spawn } from "child_process";
|
||||
|
||||
let serverProcess: any;
|
||||
|
||||
async function globalSetup() {
|
||||
// Start backend server
|
||||
serverProcess = spawn("npm", ["run", "start:test"], {
|
||||
stdio: "pipe",
|
||||
detached: true,
|
||||
});
|
||||
|
||||
// Wait for server to be ready
|
||||
await waitForServer("http://localhost:3000/health", 30000);
|
||||
|
||||
// Store PID for teardown
|
||||
process.env.SERVER_PID = serverProcess.pid.toString();
|
||||
}
|
||||
|
||||
async function waitForServer(url: string, timeout: number) {
|
||||
const start = Date.now();
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
// Server not ready yet
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
throw new Error(`Server did not start within ${timeout}ms`);
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
```
|
||||
|
||||
### Docker Compose Setup
|
||||
|
||||
```typescript
|
||||
// global-setup.ts
|
||||
import { execSync } from "child_process";
|
||||
|
||||
async function globalSetup() {
|
||||
console.log("Starting Docker services...");
|
||||
|
||||
execSync("docker-compose -f docker-compose.test.yml up -d", {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
// Wait for services to be healthy
|
||||
execSync("docker-compose -f docker-compose.test.yml exec -T db pg_isready", {
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
```
|
||||
|
||||
```typescript
|
||||
// global-teardown.ts
|
||||
import { execSync } from "child_process";
|
||||
|
||||
async function globalTeardown() {
|
||||
console.log("Stopping Docker services...");
|
||||
|
||||
execSync("docker-compose -f docker-compose.test.yml down -v", {
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
|
||||
export default globalTeardown;
|
||||
```
|
||||
|
||||
### Environment Variables Setup
|
||||
|
||||
```typescript
|
||||
// global-setup.ts
|
||||
import dotenv from "dotenv";
|
||||
import path from "path";
|
||||
|
||||
async function globalSetup() {
|
||||
// Load test-specific environment
|
||||
const envFile = process.env.CI ? ".env.ci" : ".env.test";
|
||||
dotenv.config({ path: path.resolve(process.cwd(), envFile) });
|
||||
|
||||
// Validate required variables
|
||||
const required = ["DATABASE_URL", "API_KEY", "TEST_EMAIL"];
|
||||
for (const key of required) {
|
||||
if (!process.env[key]) {
|
||||
throw new Error(`Missing required environment variable: ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
```
|
||||
|
||||
## Setup Projects vs Global Setup
|
||||
|
||||
### When to Use Each
|
||||
|
||||
| Use Global Setup | Use Setup Projects |
|
||||
| ------------------------------------- | ---------------------------------------- |
|
||||
| One-time setup (migrations, services) | Per-project setup (auth states) |
|
||||
| No access to Playwright fixtures | Need page, request fixtures |
|
||||
| Runs once before all projects | Can run per-project or have dependencies |
|
||||
| Shared across all workers | Can be parallelized |
|
||||
|
||||
### Setup Project Pattern
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
// Setup project
|
||||
{
|
||||
name: "setup",
|
||||
testMatch: /.*\.setup\.ts/,
|
||||
},
|
||||
// Test projects depend on setup
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
> **For complete authentication setup patterns**, see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns).
|
||||
|
||||
### Combining Both
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
// Global: Start services, run migrations
|
||||
globalSetup: require.resolve("./global-setup"),
|
||||
globalTeardown: require.resolve("./global-teardown"),
|
||||
|
||||
projects: [
|
||||
// Setup project: Create auth states
|
||||
{ name: "setup", testMatch: /.*\.setup\.ts/ },
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
storageState: ".auth/user.json",
|
||||
},
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Parallel Execution Caveats
|
||||
|
||||
### Understanding Global Setup Execution
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ globalSetup runs ONCE │
|
||||
│ ↓ │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ Worker 1│ │ Worker 2│ │ Worker 3│ │ Worker 4│ │
|
||||
│ │ tests │ │ tests │ │ tests │ │ tests │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ ↓ │
|
||||
│ globalTeardown runs ONCE │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key implications:**
|
||||
|
||||
- Global setup has **no access** to Playwright fixtures (`page`, `request`, `context`)
|
||||
- State created in global setup is **shared** across all workers
|
||||
- If tests **modify** shared state, they may conflict with parallel workers
|
||||
- Global setup **cannot** react to individual test needs
|
||||
|
||||
### When to Prefer Worker-Scoped Fixtures
|
||||
|
||||
Use **worker-scoped fixtures** instead of globalSetup when:
|
||||
|
||||
| Scenario | Why Fixtures Are Better |
|
||||
| ------------------------------------ | ---------------------------------------------------- |
|
||||
| Each worker needs isolated resources | Fixtures can create per-worker databases, servers |
|
||||
| Setup needs Playwright APIs | Fixtures have access to `page`, `request`, `browser` |
|
||||
| Setup depends on test configuration | Fixtures receive test context and options |
|
||||
| Resources need cleanup per worker | Worker fixtures auto-cleanup when worker exits |
|
||||
|
||||
### Common Parallel Pitfall
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Global setup creates ONE user, all workers fight over it
|
||||
async function globalSetup() {
|
||||
await createUser({ email: "test@example.com" }); // Shared!
|
||||
}
|
||||
|
||||
// ✅ GOOD: Each worker gets its own user via worker-scoped fixture
|
||||
// Uses workerInfo.workerIndex to create unique data per worker
|
||||
```
|
||||
|
||||
> **For worker-scoped fixture patterns** (per-worker databases, unique test data, `workerIndex` isolation), see [fixtures-hooks.md](fixtures-hooks.md#isolate-test-data-between-parallel-workers).
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ------------------------------ | -------------------------------- | ------------------------------------------ |
|
||||
| Heavy setup in globalSetup | Slow test startup | Use setup projects for parallelizable work |
|
||||
| Not cleaning up in teardown | Leaks resources, flaky CI | Always clean up or use containers |
|
||||
| Hardcoded URLs in setup | Breaks in different environments | Use config.projects[0].use.baseURL |
|
||||
| No timeout on service wait | Hangs forever if service fails | Add timeout with clear error |
|
||||
| Shared mutable state | Race conditions in parallel | Use worker-scoped fixtures for isolation |
|
||||
| Global setup for per-test data | Tests conflict | Use test-scoped fixtures |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Fixtures & Auth**: See [fixtures-hooks.md](fixtures-hooks.md) for worker-scoped fixtures and auth patterns
|
||||
- **CI/CD**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for CI setup patterns
|
||||
- **Projects**: See [projects-dependencies.md](projects-dependencies.md) for project configuration
|
||||
@@ -0,0 +1,242 @@
|
||||
# Locator Strategies
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Priority Order](#priority-order)
|
||||
2. [User-Facing Locators](#user-facing-locators)
|
||||
3. [Filtering & Chaining](#filtering--chaining)
|
||||
4. [Dynamic Content](#dynamic-content)
|
||||
5. [Shadow DOM](#shadow-dom)
|
||||
6. [Iframes](#iframes)
|
||||
|
||||
## Priority Order
|
||||
|
||||
Use locators in this order of preference:
|
||||
|
||||
1. **Role-based** (most resilient): `getByRole`
|
||||
2. **Label-based**: `getByLabel`, `getByPlaceholder`
|
||||
3. **Text-based**: `getByText`, `getByTitle`
|
||||
4. **Test IDs** (when semantic locators aren't possible): `getByTestId`
|
||||
5. **CSS/XPath** (last resort): `locator('css=...')`, `locator('xpath=...')`
|
||||
|
||||
## User-Facing Locators
|
||||
|
||||
### getByRole
|
||||
|
||||
Most robust approach - matches how users and assistive technology perceive the page.
|
||||
|
||||
```typescript
|
||||
// Buttons
|
||||
page.getByRole("button", { name: "Submit", exact: true }); // exact accessible name
|
||||
page.getByRole("button", { name: /submit/i }); // flexible case-insensitive match
|
||||
|
||||
// Links
|
||||
page.getByRole("link", { name: "Home" });
|
||||
|
||||
// Form elements
|
||||
page.getByRole("textbox", { name: "Email" });
|
||||
page.getByRole("checkbox", { name: "Remember me" });
|
||||
page.getByRole("combobox", { name: "Country" });
|
||||
page.getByRole("radio", { name: "Option A" });
|
||||
|
||||
// Headings
|
||||
page.getByRole("heading", { name: "Welcome", level: 1 });
|
||||
|
||||
// Lists & items
|
||||
page.getByRole("list").getByRole("listitem");
|
||||
|
||||
// Navigation & regions
|
||||
page.getByRole("navigation");
|
||||
page.getByRole("main");
|
||||
page.getByRole("dialog");
|
||||
page.getByRole("alert");
|
||||
```
|
||||
|
||||
### getByLabel
|
||||
|
||||
For form elements with associated labels.
|
||||
|
||||
```typescript
|
||||
// Input with <label for="email">
|
||||
page.getByLabel("Email address");
|
||||
|
||||
// Input with aria-label
|
||||
page.getByLabel("Search");
|
||||
|
||||
// Exact match
|
||||
page.getByLabel("Email", { exact: true });
|
||||
```
|
||||
|
||||
### getByPlaceholder
|
||||
|
||||
```typescript
|
||||
page.getByPlaceholder("Enter your email");
|
||||
page.getByPlaceholder(/email/i);
|
||||
```
|
||||
|
||||
### getByText
|
||||
|
||||
```typescript
|
||||
// Partial match (default)
|
||||
page.getByText("Welcome");
|
||||
|
||||
// Exact match
|
||||
page.getByText("Welcome to our site", { exact: true });
|
||||
|
||||
// Regex
|
||||
page.getByText(/welcome/i);
|
||||
```
|
||||
|
||||
### getByTestId
|
||||
|
||||
Configure custom test ID attribute in `playwright.config.ts`:
|
||||
|
||||
```typescript
|
||||
use: {
|
||||
testIdAttribute: "data-testid"; // default
|
||||
}
|
||||
```
|
||||
|
||||
Usage:
|
||||
|
||||
```typescript
|
||||
// React: <Button data-testid="submit-btn">Submit</Button>
|
||||
page.getByTestId("submit-btn");
|
||||
```
|
||||
|
||||
## Filtering & Chaining
|
||||
|
||||
### filter()
|
||||
|
||||
Narrow down locators:
|
||||
|
||||
```typescript
|
||||
// Filter by text
|
||||
page.getByRole("listitem").filter({ hasText: "Product" });
|
||||
|
||||
// Filter by NOT having text
|
||||
page.getByRole("listitem").filter({ hasNotText: "Out of stock" });
|
||||
|
||||
// Filter by child locator
|
||||
page.getByRole("listitem").filter({
|
||||
has: page.getByRole("button", { name: "Buy" }),
|
||||
});
|
||||
|
||||
// Combine filters
|
||||
page
|
||||
.getByRole("listitem")
|
||||
.filter({ hasText: "Product" })
|
||||
.filter({ has: page.getByText("$9.99") });
|
||||
```
|
||||
|
||||
### Chaining
|
||||
|
||||
```typescript
|
||||
// Navigate down the DOM tree
|
||||
page.getByRole("article").getByRole("heading");
|
||||
|
||||
// Get parent/ancestor
|
||||
page.getByText("Child").locator("..");
|
||||
page.getByText("Child").locator("xpath=ancestor::article");
|
||||
```
|
||||
|
||||
### nth() and first()/last()
|
||||
|
||||
```typescript
|
||||
page.getByRole("listitem").first();
|
||||
page.getByRole("listitem").last();
|
||||
page.getByRole("listitem").nth(2); // 0-indexed
|
||||
```
|
||||
|
||||
## Dynamic Content
|
||||
|
||||
### Waiting for Elements
|
||||
|
||||
Locators auto-wait for actionability by default. For explicit state waiting:
|
||||
|
||||
```typescript
|
||||
await page.getByRole("button").waitFor({ state: "visible" });
|
||||
await page.getByText("Loading").waitFor({ state: "hidden" });
|
||||
```
|
||||
|
||||
> **For comprehensive waiting strategies** (element state, navigation, network, polling with `toPass()`), see [assertions-waiting.md](assertions-waiting.md#waiting-strategies).
|
||||
|
||||
### Lists with Dynamic Items
|
||||
|
||||
```typescript
|
||||
// Wait for specific count
|
||||
await expect(page.getByRole("listitem")).toHaveCount(5);
|
||||
|
||||
// Get all matching elements
|
||||
const items = await page.getByRole("listitem").all();
|
||||
for (const item of items) {
|
||||
await expect(item).toBeVisible();
|
||||
}
|
||||
```
|
||||
|
||||
## Shadow DOM
|
||||
|
||||
Playwright pierces shadow DOM by default:
|
||||
|
||||
```typescript
|
||||
// Automatically finds elements inside shadow roots
|
||||
page.getByRole("button", { name: "Shadow Button" });
|
||||
|
||||
// Explicit shadow DOM traversal (if needed)
|
||||
page.locator("my-component").locator("internal:shadow=button");
|
||||
```
|
||||
|
||||
## Iframes
|
||||
|
||||
```typescript
|
||||
// By frame name or URL
|
||||
const frame = page.frameLocator('iframe[name="content"]');
|
||||
await frame.getByRole("button").click();
|
||||
|
||||
// By index
|
||||
const frame = page.frameLocator("iframe").first();
|
||||
|
||||
// Nested iframes
|
||||
const nestedFrame = page.frameLocator("#outer").frameLocator("#inner");
|
||||
await nestedFrame.getByText("Content").click();
|
||||
```
|
||||
|
||||
## Debugging Locators
|
||||
|
||||
```typescript
|
||||
// Highlight element in headed mode
|
||||
await page.getByRole("button").highlight();
|
||||
|
||||
// Count matches
|
||||
const count = await page.getByRole("listitem").count();
|
||||
|
||||
// Check if exists without waiting
|
||||
const exists = (await page.getByRole("button").count()) > 0;
|
||||
|
||||
// Use Playwright Inspector
|
||||
// PWDEBUG=1 npx playwright test
|
||||
```
|
||||
|
||||
## Common Issues & Solutions
|
||||
|
||||
| Issue | Solution |
|
||||
| ----------------------- | ------------------------------------------------ |
|
||||
| Multiple elements match | Add filters or use `nth()`, `first()`, `last()` |
|
||||
| Element not found | Check visibility, wait for load, verify selector |
|
||||
| Stale element | Locators are lazy; re-query if DOM changes |
|
||||
| Dynamic IDs | Use stable attributes like role, text, test-id |
|
||||
| Hidden elements | Use `{ force: true }` only when necessary |
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| --------------------------------- | --------------------------------- | ------------------------------------------------- |
|
||||
| `page.locator('.btn-primary')` | Brittle, implementation-dependent | `page.getByRole('button', { name: 'Submit' })` |
|
||||
| `page.locator('#dynamic-id-123')` | Breaks when IDs change | Use stable attributes like role, text, or test-id |
|
||||
| Testing implementation details | Breaks on refactoring | Test user-visible behavior |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Debugging selector issues**: See [debugging.md](../debugging/debugging.md) for troubleshooting
|
||||
- **Waiting for elements**: See [assertions-waiting.md](assertions-waiting.md) for waiting strategies
|
||||
- **Using in Page Objects**: See [page-object-model.md](page-object-model.md) for organizing locators
|
||||
@@ -0,0 +1,315 @@
|
||||
# Page Object Model (POM)
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Basic Structure](#basic-structure)
|
||||
3. [Component Objects](#component-objects)
|
||||
4. [Composition Patterns](#composition-patterns)
|
||||
5. [Factory Functions](#factory-functions)
|
||||
6. [Best Practices](#best-practices)
|
||||
|
||||
## Overview
|
||||
|
||||
Page Object Model encapsulates page structure and interactions, providing:
|
||||
|
||||
- **Maintainability**: Change selectors in one place
|
||||
- **Reusability**: Share page interactions across tests
|
||||
- **Readability**: Tests express intent, not implementation
|
||||
|
||||
## Basic Structure
|
||||
|
||||
### Page Class
|
||||
|
||||
```typescript
|
||||
// pages/login.page.ts
|
||||
import { Page, Locator, expect } from "@playwright/test";
|
||||
|
||||
export class LoginPage {
|
||||
readonly page: Page;
|
||||
readonly emailInput: Locator;
|
||||
readonly passwordInput: Locator;
|
||||
readonly submitButton: Locator;
|
||||
readonly errorMessage: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.emailInput = page.getByLabel("Email");
|
||||
this.passwordInput = page.getByLabel("Password");
|
||||
this.submitButton = page.getByRole("button", { name: "Sign in" });
|
||||
this.errorMessage = page.getByRole("alert");
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto("/login");
|
||||
}
|
||||
|
||||
async login(email: string, password: string) {
|
||||
await this.emailInput.fill(email);
|
||||
await this.passwordInput.fill(password);
|
||||
await this.submitButton.click();
|
||||
}
|
||||
|
||||
async expectError(message: string) {
|
||||
await expect(this.errorMessage).toContainText(message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Usage in Tests
|
||||
|
||||
```typescript
|
||||
// tests/login.spec.ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { LoginPage } from "../pages/login.page";
|
||||
|
||||
test.describe("Login", () => {
|
||||
test("successful login redirects to dashboard", async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await loginPage.goto();
|
||||
await loginPage.login("user@example.com", "password123");
|
||||
await expect(page).toHaveURL("/dashboard");
|
||||
});
|
||||
|
||||
test("shows error for invalid credentials", async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await loginPage.goto();
|
||||
await loginPage.login("invalid@example.com", "wrong");
|
||||
await loginPage.expectError("Invalid credentials");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Component Objects
|
||||
|
||||
For reusable UI components:
|
||||
|
||||
```typescript
|
||||
// components/navbar.component.ts
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
|
||||
export class NavbarComponent {
|
||||
readonly container: Locator;
|
||||
readonly logo: Locator;
|
||||
readonly searchInput: Locator;
|
||||
readonly userMenu: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.container = page.getByRole("navigation");
|
||||
this.logo = this.container.getByRole("link", { name: "Home" });
|
||||
this.searchInput = this.container.getByRole("searchbox");
|
||||
this.userMenu = this.container.getByRole("button", { name: /user menu/i });
|
||||
}
|
||||
|
||||
async search(query: string) {
|
||||
await this.searchInput.fill(query);
|
||||
await this.searchInput.press("Enter");
|
||||
}
|
||||
|
||||
async openUserMenu() {
|
||||
await this.userMenu.click();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// components/modal.component.ts
|
||||
import { Locator, expect } from "@playwright/test";
|
||||
|
||||
export class ModalComponent {
|
||||
readonly container: Locator;
|
||||
readonly title: Locator;
|
||||
readonly closeButton: Locator;
|
||||
readonly confirmButton: Locator;
|
||||
|
||||
constructor(container: Locator) {
|
||||
this.container = container;
|
||||
this.title = container.getByRole("heading");
|
||||
this.closeButton = container.getByRole("button", { name: "Close" });
|
||||
this.confirmButton = container.getByRole("button", { name: "Confirm" });
|
||||
}
|
||||
|
||||
async expectTitle(title: string) {
|
||||
await expect(this.title).toHaveText(title);
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.closeButton.click();
|
||||
}
|
||||
|
||||
async confirm() {
|
||||
await this.confirmButton.click();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Composition Patterns
|
||||
|
||||
### Page with Components
|
||||
|
||||
```typescript
|
||||
// pages/dashboard.page.ts
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
import { NavbarComponent } from "../components/navbar.component";
|
||||
import { ModalComponent } from "../components/modal.component";
|
||||
|
||||
export class DashboardPage {
|
||||
readonly page: Page;
|
||||
readonly navbar: NavbarComponent;
|
||||
readonly newProjectButton: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.navbar = new NavbarComponent(page);
|
||||
this.newProjectButton = page.getByRole("button", { name: "New Project" });
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto("/dashboard");
|
||||
}
|
||||
|
||||
async createProject() {
|
||||
await this.newProjectButton.click();
|
||||
return new ModalComponent(this.page.getByRole("dialog"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Page Navigation
|
||||
|
||||
```typescript
|
||||
// pages/base.page.ts
|
||||
import { Page } from "@playwright/test";
|
||||
|
||||
export abstract class BasePage {
|
||||
constructor(readonly page: Page) {}
|
||||
|
||||
abstract goto(): Promise<void>;
|
||||
|
||||
async getTitle(): Promise<string> {
|
||||
return this.page.title();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Return new page object on navigation
|
||||
export class LoginPage extends BasePage {
|
||||
async login(email: string, password: string): Promise<DashboardPage> {
|
||||
await this.emailInput.fill(email);
|
||||
await this.passwordInput.fill(password);
|
||||
await this.submitButton.click();
|
||||
return new DashboardPage(this.page);
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const loginPage = new LoginPage(page);
|
||||
await loginPage.goto();
|
||||
const dashboardPage = await loginPage.login("user@example.com", "pass");
|
||||
await dashboardPage.expectWelcomeMessage();
|
||||
```
|
||||
|
||||
## Factory Functions
|
||||
|
||||
Alternative to classes for simpler pages:
|
||||
|
||||
```typescript
|
||||
// pages/login.page.ts
|
||||
import { Page } from "@playwright/test";
|
||||
|
||||
export function createLoginPage(page: Page) {
|
||||
const emailInput = page.getByLabel("Email");
|
||||
const passwordInput = page.getByLabel("Password");
|
||||
const submitButton = page.getByRole("button", { name: "Sign in" });
|
||||
|
||||
return {
|
||||
goto: () => page.goto("/login"),
|
||||
login: async (email: string, password: string) => {
|
||||
await emailInput.fill(email);
|
||||
await passwordInput.fill(password);
|
||||
await submitButton.click();
|
||||
},
|
||||
emailInput,
|
||||
passwordInput,
|
||||
submitButton,
|
||||
};
|
||||
}
|
||||
|
||||
// Usage
|
||||
const loginPage = createLoginPage(page);
|
||||
await loginPage.goto();
|
||||
await loginPage.login("user@example.com", "password");
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Do
|
||||
|
||||
- **Keep locators in page objects** - Single source of truth
|
||||
- **Return new page objects** when navigation occurs
|
||||
- **Expose elements** for custom assertions in tests
|
||||
- **Use descriptive method names** - `submitOrder()` not `clickButton()`
|
||||
- **Keep methods focused** - One action per method
|
||||
|
||||
### Don't
|
||||
|
||||
- **Don't include assertions in page methods** (usually) - Keep in tests
|
||||
- **Don't expose implementation details** - Hide complex interactions
|
||||
- **Don't make page objects too large** - Split into components
|
||||
- **Don't share state** between page object instances
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
tests/
|
||||
├── pages/
|
||||
│ ├── base.page.ts
|
||||
│ ├── login.page.ts
|
||||
│ ├── dashboard.page.ts
|
||||
│ └── settings.page.ts
|
||||
├── components/
|
||||
│ ├── navbar.component.ts
|
||||
│ ├── modal.component.ts
|
||||
│ └── table.component.ts
|
||||
├── fixtures/
|
||||
│ └── pages.fixture.ts
|
||||
└── specs/
|
||||
├── login.spec.ts
|
||||
└── dashboard.spec.ts
|
||||
```
|
||||
|
||||
### Using with Fixtures
|
||||
|
||||
```typescript
|
||||
// fixtures/pages.fixture.ts
|
||||
import { test as base } from "@playwright/test";
|
||||
import { LoginPage } from "../pages/login.page";
|
||||
import { DashboardPage } from "../pages/dashboard.page";
|
||||
|
||||
type Pages = {
|
||||
loginPage: LoginPage;
|
||||
dashboardPage: DashboardPage;
|
||||
};
|
||||
|
||||
export const test = base.extend<Pages>({
|
||||
loginPage: async ({ page }, use) => {
|
||||
await use(new LoginPage(page));
|
||||
},
|
||||
dashboardPage: async ({ page }, use) => {
|
||||
await use(new DashboardPage(page));
|
||||
},
|
||||
});
|
||||
|
||||
// Usage in tests
|
||||
test("can login", async ({ loginPage }) => {
|
||||
await loginPage.goto();
|
||||
await loginPage.login("user@example.com", "password");
|
||||
});
|
||||
```
|
||||
|
||||
## Related References
|
||||
|
||||
- **Locator strategies**: See [locators.md](locators.md) for selecting elements
|
||||
- **Fixtures**: See [fixtures-hooks.md](fixtures-hooks.md) for advanced fixture patterns
|
||||
- **Test organization**: See [test-suite-structure.md](test-suite-structure.md) for structuring test suites
|
||||
@@ -0,0 +1,453 @@
|
||||
# Projects & Dependencies
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Project Configuration](#project-configuration)
|
||||
2. [Project Dependencies](#project-dependencies)
|
||||
3. [Setup Projects](#setup-projects)
|
||||
4. [Filtering & Running Projects](#filtering--running-projects)
|
||||
5. [Sharing Configuration](#sharing-configuration)
|
||||
6. [Advanced Patterns](#advanced-patterns)
|
||||
|
||||
## Project Configuration
|
||||
|
||||
### Basic Multi-Browser Setup
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
{
|
||||
name: "webkit",
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Environment-Based Projects
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "staging",
|
||||
use: {
|
||||
baseURL: "https://staging.example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "production",
|
||||
use: {
|
||||
baseURL: "https://example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "local",
|
||||
use: {
|
||||
baseURL: "http://localhost:3000",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Test Type Projects
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "e2e",
|
||||
testDir: "./tests/e2e",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "api",
|
||||
testDir: "./tests/api",
|
||||
use: { baseURL: "http://localhost:3000" },
|
||||
},
|
||||
{
|
||||
name: "visual",
|
||||
testDir: "./tests/visual",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
viewport: { width: 1280, height: 720 },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Project Dependencies
|
||||
|
||||
### Setup Dependency
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
// Setup project runs first
|
||||
{
|
||||
name: "setup",
|
||||
testMatch: /.*\.setup\.ts/,
|
||||
},
|
||||
|
||||
// Browser projects depend on setup
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
storageState: ".auth/user.json",
|
||||
},
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
storageState: ".auth/user.json",
|
||||
},
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Multiple Auth States
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
// Auth setup projects
|
||||
{
|
||||
name: "setup-admin",
|
||||
testMatch: /admin\.setup\.ts/,
|
||||
},
|
||||
{
|
||||
name: "setup-user",
|
||||
testMatch: /user\.setup\.ts/,
|
||||
},
|
||||
|
||||
// Admin tests
|
||||
{
|
||||
name: "admin-tests",
|
||||
testDir: "./tests/admin",
|
||||
use: { storageState: ".auth/admin.json" },
|
||||
dependencies: ["setup-admin"],
|
||||
},
|
||||
|
||||
// User tests
|
||||
{
|
||||
name: "user-tests",
|
||||
testDir: "./tests/user",
|
||||
use: { storageState: ".auth/user.json" },
|
||||
dependencies: ["setup-user"],
|
||||
},
|
||||
|
||||
// Tests that need both
|
||||
{
|
||||
name: "integration-tests",
|
||||
testDir: "./tests/integration",
|
||||
dependencies: ["setup-admin", "setup-user"],
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Chained Dependencies
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
// Step 1: Database setup
|
||||
{
|
||||
name: "db-setup",
|
||||
testMatch: /db\.setup\.ts/,
|
||||
},
|
||||
|
||||
// Step 2: Auth setup (needs DB)
|
||||
{
|
||||
name: "auth-setup",
|
||||
testMatch: /auth\.setup\.ts/,
|
||||
dependencies: ["db-setup"],
|
||||
},
|
||||
|
||||
// Step 3: Seed data (needs auth)
|
||||
{
|
||||
name: "seed-setup",
|
||||
testMatch: /seed\.setup\.ts/,
|
||||
dependencies: ["auth-setup"],
|
||||
},
|
||||
|
||||
// Tests (need everything)
|
||||
{
|
||||
name: "tests",
|
||||
testDir: "./tests",
|
||||
dependencies: ["seed-setup"],
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Setup Projects
|
||||
|
||||
### Authentication Setup
|
||||
|
||||
Setup projects are the recommended way to handle authentication. They run before your main test projects and can use Playwright fixtures.
|
||||
|
||||
> **For complete authentication patterns** (storage state, multiple auth states, auth fixtures), see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns).
|
||||
|
||||
### Data Seeding Setup
|
||||
|
||||
```typescript
|
||||
// seed.setup.ts
|
||||
import { test as setup } from "@playwright/test";
|
||||
|
||||
setup("seed test data", async ({ request }) => {
|
||||
// Create test data via API
|
||||
await request.post("/api/test/seed", {
|
||||
data: {
|
||||
users: 10,
|
||||
products: 50,
|
||||
orders: 100,
|
||||
},
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Cleanup Setup
|
||||
|
||||
```typescript
|
||||
// cleanup.setup.ts
|
||||
import { test as setup } from "@playwright/test";
|
||||
|
||||
setup("cleanup previous run", async ({ request }) => {
|
||||
// Clean up data from previous test runs
|
||||
await request.delete("/api/test/cleanup");
|
||||
});
|
||||
```
|
||||
|
||||
## Filtering & Running Projects
|
||||
|
||||
### Run Specific Project
|
||||
|
||||
```bash
|
||||
# Run single project
|
||||
npx playwright test --project=chromium
|
||||
|
||||
# Run multiple projects
|
||||
npx playwright test --project=chromium --project=firefox
|
||||
```
|
||||
|
||||
### Run by Grep
|
||||
|
||||
```bash
|
||||
# Run tests matching pattern
|
||||
npx playwright test --grep @smoke
|
||||
|
||||
# Run project with grep
|
||||
npx playwright test --project=chromium --grep @critical
|
||||
|
||||
# Exclude pattern
|
||||
npx playwright test --grep-invert @slow
|
||||
```
|
||||
|
||||
### Project-Specific Grep
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "smoke",
|
||||
grep: /@smoke/,
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "regression",
|
||||
grepInvert: /@smoke/,
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Sharing Configuration
|
||||
|
||||
### Base Configuration
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
const baseConfig = {
|
||||
timeout: 30000,
|
||||
expect: { timeout: 5000 },
|
||||
use: {
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
...baseConfig,
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
...baseConfig.use,
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
use: {
|
||||
...baseConfig.use,
|
||||
...devices["Desktop Firefox"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Shared Project Settings
|
||||
|
||||
```typescript
|
||||
const sharedBrowserConfig = {
|
||||
timeout: 60000,
|
||||
retries: 2,
|
||||
use: {
|
||||
video: "on-first-retry",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
...sharedBrowserConfig,
|
||||
use: {
|
||||
...sharedBrowserConfig.use,
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
...sharedBrowserConfig,
|
||||
use: {
|
||||
...sharedBrowserConfig.use,
|
||||
...devices["Desktop Firefox"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### Conditional Projects
|
||||
|
||||
```typescript
|
||||
const projects = [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
];
|
||||
|
||||
// Add Firefox only in CI
|
||||
if (process.env.CI) {
|
||||
projects.push({
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
});
|
||||
}
|
||||
|
||||
// Add mobile only for specific test dirs
|
||||
if (process.env.TEST_MOBILE) {
|
||||
projects.push({
|
||||
name: "mobile",
|
||||
use: { ...devices["iPhone 14"] },
|
||||
});
|
||||
}
|
||||
|
||||
export default defineConfig({ projects });
|
||||
```
|
||||
|
||||
### Project Metadata
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
metadata: {
|
||||
platform: "desktop",
|
||||
browser: "chromium",
|
||||
priority: "high",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Access in test
|
||||
test("example", async ({ page }, testInfo) => {
|
||||
const { platform, priority } = testInfo.project.metadata;
|
||||
console.log(`Running on ${platform} with ${priority} priority`);
|
||||
});
|
||||
```
|
||||
|
||||
### Teardown Projects
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "setup",
|
||||
testMatch: /.*\.setup\.ts/,
|
||||
teardown: "teardown", // Run teardown after this completes
|
||||
},
|
||||
{
|
||||
name: "teardown",
|
||||
testMatch: /.*\.teardown\.ts/,
|
||||
},
|
||||
{
|
||||
name: "tests",
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// cleanup.teardown.ts
|
||||
import { test as teardown } from "@playwright/test";
|
||||
|
||||
teardown("cleanup", async ({ request }) => {
|
||||
await request.delete("/api/test/data");
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| -------------------------- | ---------------------- | ----------------------------------- |
|
||||
| Too many browser projects | Slow CI, expensive | Focus on critical browsers |
|
||||
| Missing setup dependencies | Tests fail randomly | Declare all dependencies explicitly |
|
||||
| Duplicated configuration | Hard to maintain | Extract shared config |
|
||||
| Not using setup projects | Repeated auth in tests | Use setup project + storageState |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Global Setup**: See [global-setup.md](global-setup.md) for globalSetup vs setup projects
|
||||
- **Fixtures**: See [fixtures-hooks.md](fixtures-hooks.md) for authentication patterns
|
||||
- **CI/CD**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for running projects in CI
|
||||
@@ -0,0 +1,492 @@
|
||||
# Test Data Factories & Generators
|
||||
|
||||
This file covers **reusable test data builders** (factories, Faker, data generators). For related topics:
|
||||
|
||||
- **Per-test database fixtures** (isolation, transaction rollback): See [fixtures-hooks.md](fixtures-hooks.md#database-fixtures)
|
||||
- **One-time database setup** (migrations, snapshots): See [global-setup.md](global-setup.md#database-patterns)
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Factory Pattern](#factory-pattern)
|
||||
2. [Faker Integration](#faker-integration)
|
||||
3. [Data-Driven Testing](#data-driven-testing)
|
||||
4. [Test Data Fixtures](#test-data-fixtures)
|
||||
5. [Database Seeding](#database-seeding)
|
||||
|
||||
## Factory Pattern
|
||||
|
||||
### Basic Factory
|
||||
|
||||
```typescript
|
||||
// factories/user.factory.ts
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: "admin" | "user" | "guest";
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
let userIdCounter = 0;
|
||||
|
||||
export function createUser(overrides: Partial<User> = {}): User {
|
||||
userIdCounter++;
|
||||
return {
|
||||
id: `user-${userIdCounter}`,
|
||||
email: `user${userIdCounter}@test.com`,
|
||||
name: `Test User ${userIdCounter}`,
|
||||
role: "user",
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Usage
|
||||
const user = createUser();
|
||||
const admin = createUser({ role: "admin", name: "Admin User" });
|
||||
```
|
||||
|
||||
### Factory with Traits
|
||||
|
||||
```typescript
|
||||
// factories/product.factory.ts
|
||||
interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
stock: number;
|
||||
category: string;
|
||||
featured: boolean;
|
||||
}
|
||||
|
||||
type ProductTrait = "outOfStock" | "featured" | "expensive" | "sale";
|
||||
|
||||
const traits: Record<ProductTrait, Partial<Product>> = {
|
||||
outOfStock: { stock: 0 },
|
||||
featured: { featured: true },
|
||||
expensive: { price: 999.99 },
|
||||
sale: { price: 9.99 },
|
||||
};
|
||||
|
||||
let productIdCounter = 0;
|
||||
|
||||
export function createProduct(
|
||||
overrides: Partial<Product> = {},
|
||||
...traitNames: ProductTrait[]
|
||||
): Product {
|
||||
productIdCounter++;
|
||||
|
||||
const appliedTraits = traitNames.reduce(
|
||||
(acc, trait) => ({ ...acc, ...traits[trait] }),
|
||||
{},
|
||||
);
|
||||
|
||||
return {
|
||||
id: `prod-${productIdCounter}`,
|
||||
name: `Product ${productIdCounter}`,
|
||||
price: 29.99,
|
||||
stock: 100,
|
||||
category: "General",
|
||||
featured: false,
|
||||
...appliedTraits,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Usage
|
||||
const product = createProduct();
|
||||
const featuredProduct = createProduct({}, "featured");
|
||||
const saleItem = createProduct({ name: "Sale Item" }, "sale", "featured");
|
||||
const soldOut = createProduct({}, "outOfStock");
|
||||
```
|
||||
|
||||
### Factory with Relationships
|
||||
|
||||
```typescript
|
||||
// factories/order.factory.ts
|
||||
import { createUser, User } from "./user.factory";
|
||||
import { createProduct, Product } from "./product.factory";
|
||||
|
||||
interface OrderItem {
|
||||
product: Product;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
interface Order {
|
||||
id: string;
|
||||
user: User;
|
||||
items: OrderItem[];
|
||||
total: number;
|
||||
status: "pending" | "paid" | "shipped" | "delivered";
|
||||
}
|
||||
|
||||
let orderIdCounter = 0;
|
||||
|
||||
export function createOrder(overrides: Partial<Order> = {}): Order {
|
||||
orderIdCounter++;
|
||||
|
||||
const user = overrides.user ?? createUser();
|
||||
const items = overrides.items ?? [{ product: createProduct(), quantity: 1 }];
|
||||
const total = items.reduce(
|
||||
(sum, item) => sum + item.product.price * item.quantity,
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
id: `order-${orderIdCounter}`,
|
||||
user,
|
||||
items,
|
||||
total,
|
||||
status: "pending",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Usage
|
||||
const order = createOrder();
|
||||
const bigOrder = createOrder({
|
||||
items: [
|
||||
{ product: createProduct({ price: 100 }), quantity: 5 },
|
||||
{ product: createProduct({ price: 50 }), quantity: 2 },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Faker Integration
|
||||
|
||||
### Setup Faker
|
||||
|
||||
```bash
|
||||
npm install -D @faker-js/faker
|
||||
```
|
||||
|
||||
```typescript
|
||||
// factories/faker-user.factory.ts
|
||||
import { faker } from "@faker-js/faker";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
address: {
|
||||
street: string;
|
||||
city: string;
|
||||
country: string;
|
||||
zipCode: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function createFakeUser(overrides: Partial<User> = {}): User {
|
||||
return {
|
||||
id: faker.string.uuid(),
|
||||
email: faker.internet.email(),
|
||||
name: faker.person.fullName(),
|
||||
avatar: faker.image.avatar(),
|
||||
address: {
|
||||
street: faker.location.streetAddress(),
|
||||
city: faker.location.city(),
|
||||
country: faker.location.country(),
|
||||
zipCode: faker.location.zipCode(),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Seeded Faker for Reproducibility
|
||||
|
||||
```typescript
|
||||
import { faker } from "@faker-js/faker";
|
||||
|
||||
// Set seed for reproducible data
|
||||
faker.seed(12345);
|
||||
|
||||
export function createDeterministicUser(): User {
|
||||
return {
|
||||
id: faker.string.uuid(),
|
||||
email: faker.internet.email(),
|
||||
name: faker.person.fullName(),
|
||||
// Same seed = same data every time
|
||||
};
|
||||
}
|
||||
|
||||
// Or seed per test
|
||||
test("user profile", async ({ page }) => {
|
||||
faker.seed(42); // Reset seed for this test
|
||||
const user = createFakeUser();
|
||||
// user will always have the same data
|
||||
});
|
||||
```
|
||||
|
||||
### Faker Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/faker.fixture.ts
|
||||
import { test as base } from "@playwright/test";
|
||||
import { faker } from "@faker-js/faker";
|
||||
|
||||
type FakerFixtures = {
|
||||
fake: typeof faker;
|
||||
};
|
||||
|
||||
export const test = base.extend<FakerFixtures>({
|
||||
fake: async ({}, use, testInfo) => {
|
||||
// Seed based on test name for reproducibility
|
||||
faker.seed(testInfo.title.length);
|
||||
await use(faker);
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
test("create user with fake data", async ({ page, fake }) => {
|
||||
await page.goto("/signup");
|
||||
|
||||
await page.getByLabel("Name").fill(fake.person.fullName());
|
||||
await page.getByLabel("Email").fill(fake.internet.email());
|
||||
await page.getByLabel("Password").fill(fake.internet.password());
|
||||
|
||||
await page.getByRole("button", { name: "Sign Up" }).click();
|
||||
});
|
||||
```
|
||||
|
||||
## Data-Driven Testing
|
||||
|
||||
### test.each with Arrays
|
||||
|
||||
```typescript
|
||||
const loginScenarios = [
|
||||
{ email: "user@example.com", password: "pass123", expected: "Dashboard" },
|
||||
{ email: "admin@example.com", password: "admin123", expected: "Admin Panel" },
|
||||
{
|
||||
email: "invalid@example.com",
|
||||
password: "wrong",
|
||||
expected: "Invalid credentials",
|
||||
},
|
||||
];
|
||||
|
||||
for (const { email, password, expected } of loginScenarios) {
|
||||
test(`login with ${email}`, async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Email").fill(email);
|
||||
await page.getByLabel("Password").fill(password);
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
|
||||
await expect(page.getByText(expected)).toBeVisible();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Parameterized Tests
|
||||
|
||||
```typescript
|
||||
// data/checkout-scenarios.ts
|
||||
export const checkoutScenarios = [
|
||||
{
|
||||
name: "standard shipping",
|
||||
shipping: "standard",
|
||||
expectedDays: "5-7 business days",
|
||||
expectedCost: "$5.99",
|
||||
},
|
||||
{
|
||||
name: "express shipping",
|
||||
shipping: "express",
|
||||
expectedDays: "2-3 business days",
|
||||
expectedCost: "$14.99",
|
||||
},
|
||||
{
|
||||
name: "overnight shipping",
|
||||
shipping: "overnight",
|
||||
expectedDays: "Next business day",
|
||||
expectedCost: "$29.99",
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { checkoutScenarios } from "./data/checkout-scenarios";
|
||||
|
||||
test.describe("shipping options", () => {
|
||||
for (const scenario of checkoutScenarios) {
|
||||
test(`checkout with ${scenario.name}`, async ({ page }) => {
|
||||
await page.goto("/checkout");
|
||||
|
||||
await page.getByLabel(scenario.shipping, { exact: false }).check();
|
||||
|
||||
await expect(page.getByText(scenario.expectedDays)).toBeVisible();
|
||||
await expect(page.getByText(scenario.expectedCost)).toBeVisible();
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### CSV/JSON Data Source
|
||||
|
||||
```typescript
|
||||
import fs from "fs";
|
||||
|
||||
interface TestCase {
|
||||
input: string;
|
||||
expected: string;
|
||||
}
|
||||
|
||||
// Load test data from JSON
|
||||
const testCases: TestCase[] = JSON.parse(
|
||||
fs.readFileSync("./data/search-tests.json", "utf-8"),
|
||||
);
|
||||
|
||||
test.describe("search functionality", () => {
|
||||
for (const { input, expected } of testCases) {
|
||||
test(`search for "${input}"`, async ({ page }) => {
|
||||
await page.goto("/search");
|
||||
await page.getByLabel("Search").fill(input);
|
||||
await page.getByLabel("Search").press("Enter");
|
||||
|
||||
await expect(page.getByText(expected)).toBeVisible();
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Test Data Fixtures
|
||||
|
||||
### Fixture with Factory
|
||||
|
||||
```typescript
|
||||
// fixtures/data.fixture.ts
|
||||
import { test as base } from "@playwright/test";
|
||||
import { createUser, User } from "../factories/user.factory";
|
||||
import { createProduct, Product } from "../factories/product.factory";
|
||||
|
||||
type DataFixtures = {
|
||||
testUser: User;
|
||||
testProducts: Product[];
|
||||
};
|
||||
|
||||
export const test = base.extend<DataFixtures>({
|
||||
testUser: async ({}, use) => {
|
||||
const user = createUser({ name: "E2E Test User" });
|
||||
await use(user);
|
||||
},
|
||||
|
||||
testProducts: async ({}, use) => {
|
||||
const products = [
|
||||
createProduct({ name: "Test Product 1" }),
|
||||
createProduct({ name: "Test Product 2" }),
|
||||
createProduct({ name: "Test Product 3" }),
|
||||
];
|
||||
await use(products);
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
test("add product to cart", async ({ page, testUser, testProducts }) => {
|
||||
// Mock API with test data
|
||||
await page.route("**/api/user", (route) => route.fulfill({ json: testUser }));
|
||||
await page.route("**/api/products", (route) =>
|
||||
route.fulfill({ json: testProducts }),
|
||||
);
|
||||
|
||||
await page.goto("/products");
|
||||
await expect(page.getByText(testProducts[0].name)).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Database Seeding
|
||||
|
||||
### API-Based Seeding
|
||||
|
||||
```typescript
|
||||
// fixtures/seed.fixture.ts
|
||||
import { test as base, APIRequestContext } from "@playwright/test";
|
||||
import { createUser } from "../factories/user.factory";
|
||||
|
||||
type SeedFixtures = {
|
||||
seedUser: (overrides?: Partial<User>) => Promise<User>;
|
||||
cleanupUsers: string[];
|
||||
};
|
||||
|
||||
export const test = base.extend<SeedFixtures>({
|
||||
cleanupUsers: [],
|
||||
|
||||
seedUser: async ({ request, cleanupUsers }, use) => {
|
||||
await use(async (overrides = {}) => {
|
||||
const userData = createUser(overrides);
|
||||
|
||||
const response = await request.post("/api/test/users", {
|
||||
data: userData,
|
||||
});
|
||||
const user = await response.json();
|
||||
|
||||
cleanupUsers.push(user.id);
|
||||
return user;
|
||||
});
|
||||
},
|
||||
|
||||
// Cleanup after test
|
||||
cleanupUsers: async ({ request }, use) => {
|
||||
const userIds: string[] = [];
|
||||
await use(userIds);
|
||||
|
||||
// Delete all created users
|
||||
for (const id of userIds) {
|
||||
await request.delete(`/api/test/users/${id}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
test("user profile page", async ({ page, seedUser }) => {
|
||||
const user = await seedUser({ name: "John Doe" });
|
||||
|
||||
await page.goto(`/users/${user.id}`);
|
||||
await expect(page.getByText("John Doe")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Transaction Rollback Seeding
|
||||
|
||||
```typescript
|
||||
// fixtures/db.fixture.ts
|
||||
export const test = base.extend<{}, { db: DbTransaction }>({
|
||||
db: [
|
||||
async ({}, use) => {
|
||||
const client = await pool.connect();
|
||||
await client.query("BEGIN");
|
||||
|
||||
await use({
|
||||
query: (sql: string, params?: any[]) => client.query(sql, params),
|
||||
seed: async (table: string, data: object) => {
|
||||
const keys = Object.keys(data);
|
||||
const values = Object.values(data);
|
||||
const placeholders = keys.map((_, i) => `$${i + 1}`);
|
||||
|
||||
const result = await client.query(
|
||||
`INSERT INTO ${table} (${keys.join(", ")}) VALUES (${placeholders.join(", ")}) RETURNING *`,
|
||||
values,
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
});
|
||||
|
||||
await client.query("ROLLBACK");
|
||||
client.release();
|
||||
},
|
||||
{ scope: "test" },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ------------------------------- | ------------------------------- | -------------------------- |
|
||||
| Hardcoded test data | Brittle, repetitive | Use factories |
|
||||
| Random data without seed | Non-reproducible failures | Seed faker per test |
|
||||
| Shared mutable test data | Tests interfere with each other | Create fresh data per test |
|
||||
| Manual data creation everywhere | Duplication, maintenance burden | Centralize in factories |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Fixtures**: See [fixtures-hooks.md](fixtures-hooks.md) for fixture patterns
|
||||
- **API Testing**: See [test-suite-structure.md](test-suite-structure.md) for API mocking
|
||||
@@ -0,0 +1,361 @@
|
||||
# Test Suite Structure
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Configuration](#configuration)
|
||||
2. [E2E Tests](#e2e-tests)
|
||||
3. [Component Tests](#component-tests)
|
||||
4. [API Tests](#api-tests)
|
||||
5. [Visual Regression Tests](#visual-regression-tests)
|
||||
6. [Directory Structure](#directory-structure)
|
||||
7. [Tagging & Filtering](#tagging--filtering)
|
||||
|
||||
### Project Setup
|
||||
|
||||
```bash
|
||||
npm init playwright@latest
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Essential Configuration
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: [["html"], ["list"]],
|
||||
use: {
|
||||
baseURL: "http://localhost:3000",
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{ name: "setup", testMatch: /.*\.setup\.ts/ },
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "npm run dev",
|
||||
url: "http://localhost:3000",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## E2E Tests
|
||||
|
||||
Full user journey tests through the browser.
|
||||
|
||||
### Structure
|
||||
|
||||
```typescript
|
||||
// tests/e2e/checkout.spec.ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Checkout Flow", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/products");
|
||||
});
|
||||
|
||||
test("complete purchase as guest", async ({ page }) => {
|
||||
// Add to cart
|
||||
await page.getByRole("button", { name: "Add to Cart" }).first().click();
|
||||
await expect(page.getByTestId("cart-count")).toHaveText("1");
|
||||
|
||||
// Go to checkout
|
||||
await page.getByRole("link", { name: "Cart" }).click();
|
||||
await page.getByRole("button", { name: "Checkout" }).click();
|
||||
|
||||
// Fill shipping
|
||||
await page.getByLabel("Email").fill("guest@example.com");
|
||||
await page.getByLabel("Address").fill("123 Test St");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Payment
|
||||
await page.getByLabel("Card Number").fill("4242424242424242");
|
||||
await page.getByRole("button", { name: "Pay Now" }).click();
|
||||
|
||||
// Confirmation
|
||||
await expect(page.getByRole("heading")).toHaveText("Order Confirmed");
|
||||
});
|
||||
|
||||
test("apply discount code", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Add to Cart" }).first().click();
|
||||
await page.getByRole("link", { name: "Cart" }).click();
|
||||
|
||||
await page.getByLabel("Discount Code").fill("SAVE10");
|
||||
await page.getByRole("button", { name: "Apply" }).click();
|
||||
|
||||
await expect(page.getByText("10% discount applied")).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
- Test critical user journeys
|
||||
- Keep tests independent
|
||||
- Use realistic data
|
||||
- Clean up test data in teardown
|
||||
|
||||
## Component Tests
|
||||
|
||||
Test individual components in isolation using Playwright Component Testing.
|
||||
|
||||
```bash
|
||||
npm init playwright@latest -- --ct
|
||||
```
|
||||
|
||||
For comprehensive component testing patterns including mounting, props, events, slots, mocking, and framework-specific examples, see **[component-testing.md](../testing-patterns/component-testing.md)**.
|
||||
|
||||
## API Tests
|
||||
|
||||
Test backend APIs without browser.
|
||||
|
||||
### API Mocking Patterns
|
||||
|
||||
For E2E tests that need to mock API responses:
|
||||
|
||||
```typescript
|
||||
// Mock single endpoint
|
||||
test("displays mocked users", async ({ page }) => {
|
||||
await page.route("**/api/users", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
json: [{ id: 1, name: "Test User" }],
|
||||
})
|
||||
);
|
||||
|
||||
await page.goto("/users");
|
||||
await expect(page.getByText("Test User")).toBeVisible();
|
||||
});
|
||||
|
||||
// Mock with different responses
|
||||
test("handles API errors", async ({ page }) => {
|
||||
await page.route("**/api/users", (route) =>
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
json: { error: "Server error" },
|
||||
})
|
||||
);
|
||||
|
||||
await page.goto("/users");
|
||||
await expect(page.getByText("Server error")).toBeVisible();
|
||||
});
|
||||
|
||||
// Conditional mocking
|
||||
test("mocks based on request", async ({ page }) => {
|
||||
await page.route("**/api/users", (route, request) => {
|
||||
if (request.method() === "GET") {
|
||||
route.fulfill({ json: [{ id: 1, name: "User" }] });
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Mock with delay (simulate slow network)
|
||||
test("handles slow API", async ({ page }) => {
|
||||
await page.route("**/api/data", (route) =>
|
||||
route.fulfill({
|
||||
json: { data: "test" },
|
||||
delay: 2000, // 2 second delay
|
||||
})
|
||||
);
|
||||
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.getByText("Loading...")).toBeVisible();
|
||||
await expect(page.getByText("test")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
For advanced patterns (GraphQL mocking, HAR recording, request modification, network throttling), see **[network-advanced.md](../advanced/network-advanced.md)**.
|
||||
|
||||
## Visual Regression Tests
|
||||
|
||||
Compare screenshots to detect visual changes.
|
||||
|
||||
### Basic Visual Test
|
||||
|
||||
```typescript
|
||||
// tests/visual/homepage.spec.ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("homepage visual", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveScreenshot("homepage.png");
|
||||
});
|
||||
|
||||
test("component visual", async ({ page }) => {
|
||||
await page.goto("/components");
|
||||
|
||||
const button = page.getByRole("button", { name: "Primary" });
|
||||
await expect(button).toHaveScreenshot("primary-button.png");
|
||||
});
|
||||
```
|
||||
|
||||
### Visual Test Options
|
||||
|
||||
```typescript
|
||||
test("dashboard visual", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
|
||||
await expect(page).toHaveScreenshot("dashboard.png", {
|
||||
fullPage: true, // Capture entire scrollable page
|
||||
maxDiffPixels: 100, // Allow up to 100 different pixels
|
||||
maxDiffPixelRatio: 0.01, // Or 1% difference
|
||||
threshold: 0.2, // Pixel comparison threshold
|
||||
animations: "disabled", // Disable animations
|
||||
mask: [page.getByTestId("date")], // Mask dynamic content
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Handling Dynamic Content
|
||||
|
||||
```typescript
|
||||
test("page with dynamic content", async ({ page }) => {
|
||||
await page.goto("/profile");
|
||||
|
||||
// Mask elements that change
|
||||
await expect(page).toHaveScreenshot("profile.png", {
|
||||
mask: [
|
||||
page.getByTestId("timestamp"),
|
||||
page.getByTestId("avatar"),
|
||||
page.getByRole("img"),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// Or hide elements via CSS
|
||||
test("page hiding dynamic elements", async ({ page }) => {
|
||||
await page.goto("/profile");
|
||||
|
||||
await page.addStyleTag({
|
||||
content: `
|
||||
.dynamic-content { visibility: hidden !important; }
|
||||
[data-testid="ad-banner"] { display: none !important; }
|
||||
`,
|
||||
});
|
||||
|
||||
await expect(page).toHaveScreenshot("profile-stable.png");
|
||||
});
|
||||
```
|
||||
|
||||
### Visual Test Configuration
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
expect: {
|
||||
toHaveScreenshot: {
|
||||
maxDiffPixels: 50,
|
||||
animations: "disabled",
|
||||
},
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "visual-chrome",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
viewport: { width: 1280, height: 720 },
|
||||
},
|
||||
testMatch: /.*visual.*\.spec\.ts/,
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Update Snapshots
|
||||
|
||||
```bash
|
||||
# Update all snapshots
|
||||
npx playwright test --update-snapshots
|
||||
|
||||
# Update specific test
|
||||
npx playwright test homepage.spec.ts --update-snapshots
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
tests/
|
||||
├── e2e/ # End-to-end tests
|
||||
│ ├── auth.spec.ts
|
||||
│ ├── checkout.spec.ts
|
||||
│ └── dashboard.spec.ts
|
||||
├── component/ # Component tests
|
||||
│ ├── Button.spec.tsx
|
||||
│ └── Modal.spec.tsx
|
||||
├── api/ # API tests
|
||||
│ ├── users.spec.ts
|
||||
│ └── products.spec.ts
|
||||
├── visual/ # Visual regression tests
|
||||
│ └── homepage.spec.ts
|
||||
├── fixtures/ # Custom fixtures
|
||||
│ ├── auth.fixture.ts
|
||||
│ └── api.fixture.ts
|
||||
└── pages/ # Page objects
|
||||
├── login.page.ts
|
||||
└── dashboard.page.ts
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ------------------------------------- | ---------------------------------- | ------------------------- |
|
||||
| Long test files | Hard to maintain, slow to navigate | Split by feature, use POM |
|
||||
| Tests depend on execution order | Flaky, hard to debug | Keep tests independent |
|
||||
| Testing multiple features in one test | Hard to debug failures | One feature per test |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Component Testing**: See [component-testing.md](../testing-patterns/component-testing.md) for comprehensive CT patterns
|
||||
- **Projects**: See [projects-dependencies.md](projects-dependencies.md) for project-based filtering
|
||||
- **Page Objects**: See [page-object-model.md](page-object-model.md) for organizing page interactions
|
||||
- **Test Data**: See [fixtures-hooks.md](fixtures-hooks.md) for managing test data
|
||||
|
||||
## Tagging & Filtering
|
||||
|
||||
### Using Tags
|
||||
|
||||
```typescript
|
||||
test("user login @smoke @auth", async ({ page }) => {
|
||||
// ...
|
||||
});
|
||||
|
||||
test("checkout flow @e2e @critical", async ({ page }) => {
|
||||
// ...
|
||||
});
|
||||
|
||||
test.describe("API tests @api", () => {
|
||||
test("create user", async ({ request }) => {
|
||||
// ...
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Running Tagged Tests
|
||||
|
||||
```bash
|
||||
# Run smoke tests
|
||||
npx playwright test --grep @smoke
|
||||
|
||||
# Run all except slow tests
|
||||
npx playwright test --grep-invert @slow
|
||||
|
||||
# Combine tags
|
||||
npx playwright test --grep "@smoke|@critical"
|
||||
```
|
||||
|
||||
For project-based filtering and advanced project configuration, see **[projects-dependencies.md](projects-dependencies.md)**.
|
||||
@@ -0,0 +1,298 @@
|
||||
# Test Tags
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Basic Tagging](#basic-tagging)
|
||||
2. [Tagging Describe Blocks](#tagging-describe-blocks)
|
||||
3. [Running Tagged Tests](#running-tagged-tests)
|
||||
4. [Filtering by Tags](#filtering-by-tags)
|
||||
5. [Configuration-Based Filtering](#configuration-based-filtering)
|
||||
6. [Tag Organization Patterns](#tag-organization-patterns)
|
||||
7. [Common Tag Categories](#common-tag-categories)
|
||||
8. [Anti-Patterns to Avoid](#anti-patterns-to-avoid)
|
||||
9. [Related References](#related-references)
|
||||
|
||||
## Basic Tagging
|
||||
|
||||
### Tag via Details Object
|
||||
|
||||
```typescript
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test(
|
||||
"test login page",
|
||||
{
|
||||
tag: "@fast",
|
||||
},
|
||||
async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await expect(page.getByRole("heading")).toBeVisible();
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"test dashboard",
|
||||
{
|
||||
tag: "@slow",
|
||||
},
|
||||
async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.getByTestId("charts")).toBeVisible();
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Tag via Title (not recommended)
|
||||
|
||||
```typescript
|
||||
test("test full report @slow", async ({ page }) => {
|
||||
await page.goto("/reports/full");
|
||||
await expect(page.getByText("Report loaded")).toBeVisible();
|
||||
});
|
||||
|
||||
test("quick validation @fast @smoke", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Tagging Describe Blocks
|
||||
|
||||
### Tag All Tests in Group
|
||||
|
||||
```typescript
|
||||
test.describe(
|
||||
"report tests",
|
||||
{
|
||||
tag: "@report",
|
||||
},
|
||||
() => {
|
||||
test("test report header", async ({ page }) => {
|
||||
// Inherits @report tag
|
||||
});
|
||||
|
||||
test("test report footer", async ({ page }) => {
|
||||
// Inherits @report tag
|
||||
});
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Combine Group and Test Tags
|
||||
|
||||
```typescript
|
||||
test.describe(
|
||||
"admin features",
|
||||
{
|
||||
tag: "@admin",
|
||||
},
|
||||
() => {
|
||||
test("admin dashboard", async ({ page }) => {
|
||||
// Has @admin tag
|
||||
});
|
||||
|
||||
test(
|
||||
"admin settings",
|
||||
{
|
||||
tag: ["@slow", "@critical"],
|
||||
},
|
||||
async ({ page }) => {
|
||||
// Has @admin, @slow, @critical tags
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## Running Tagged Tests
|
||||
|
||||
### Run Tests with Specific Tag
|
||||
|
||||
```bash
|
||||
# Run all @fast tests
|
||||
npx playwright test --grep @fast
|
||||
```
|
||||
|
||||
### Exclude Tests with Tag
|
||||
|
||||
```bash
|
||||
# Run all tests except @slow
|
||||
npx playwright test --grep-invert @slow
|
||||
```
|
||||
|
||||
## Filtering by Tags
|
||||
|
||||
### Logical OR (Either Tag)
|
||||
|
||||
```bash
|
||||
# Run tests with @fast OR @smoke
|
||||
npx playwright test --grep "@fast|@smoke"
|
||||
```
|
||||
|
||||
### Logical AND (Both Tags)
|
||||
|
||||
```bash
|
||||
# Run tests with both @fast AND @critical
|
||||
npx playwright test --grep "(?=.*@fast)(?=.*@critical)"
|
||||
```
|
||||
|
||||
### Complex Patterns
|
||||
|
||||
```bash
|
||||
# Run @e2e tests that are also @critical
|
||||
npx playwright test --grep "(?=.*@e2e)(?=.*@critical)"
|
||||
|
||||
# Run @api tests excluding @slow
|
||||
npx playwright test --grep "@api" --grep-invert "@slow"
|
||||
```
|
||||
|
||||
## Configuration-Based Filtering
|
||||
|
||||
### Filter in playwright.config.ts
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
grep: /@smoke/,
|
||||
grepInvert: /@flaky/,
|
||||
});
|
||||
```
|
||||
|
||||
### Project-Specific Tags
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "smoke",
|
||||
grep: /@smoke/,
|
||||
},
|
||||
{
|
||||
name: "regression",
|
||||
grepInvert: /@smoke/,
|
||||
},
|
||||
{
|
||||
name: "critical-only",
|
||||
grep: /@critical/,
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Environment-Based Filtering
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
const isCI = !!process.env.CI;
|
||||
|
||||
export default defineConfig({
|
||||
grep: isCI ? /@smoke|@critical/ : undefined,
|
||||
grepInvert: isCI ? /@flaky/ : undefined,
|
||||
});
|
||||
```
|
||||
|
||||
## Tag Organization Patterns
|
||||
|
||||
### By Test Type
|
||||
|
||||
```typescript
|
||||
// Smoke tests - quick validation
|
||||
test("homepage loads", { tag: "@smoke" }, async ({ page }) => {});
|
||||
test("login works", { tag: "@smoke" }, async ({ page }) => {});
|
||||
|
||||
// Regression tests - comprehensive
|
||||
test("full checkout flow", { tag: "@regression" }, async ({ page }) => {});
|
||||
test("all payment methods", { tag: "@regression" }, async ({ page }) => {});
|
||||
|
||||
// E2E tests - user journeys
|
||||
test("complete user journey", { tag: "@e2e" }, async ({ page }) => {});
|
||||
```
|
||||
|
||||
### By Priority
|
||||
|
||||
```typescript
|
||||
test(
|
||||
"payment processing",
|
||||
{
|
||||
tag: ["@critical", "@p0"],
|
||||
},
|
||||
async ({ page }) => {}
|
||||
);
|
||||
|
||||
test(
|
||||
"user preferences",
|
||||
{
|
||||
tag: ["@p1"],
|
||||
},
|
||||
async ({ page }) => {}
|
||||
);
|
||||
|
||||
test(
|
||||
"theme customization",
|
||||
{
|
||||
tag: ["@p2"],
|
||||
},
|
||||
async ({ page }) => {}
|
||||
);
|
||||
```
|
||||
|
||||
### By Feature Area
|
||||
|
||||
```typescript
|
||||
test.describe(
|
||||
"authentication",
|
||||
{
|
||||
tag: "@auth",
|
||||
},
|
||||
() => {
|
||||
test("login @smoke", async ({ page }) => {});
|
||||
test("logout", async ({ page }) => {});
|
||||
test("password reset @slow", async ({ page }) => {});
|
||||
}
|
||||
);
|
||||
|
||||
test.describe(
|
||||
"payments",
|
||||
{
|
||||
tag: "@payments",
|
||||
},
|
||||
() => {
|
||||
test("credit card @critical", async ({ page }) => {});
|
||||
test("paypal @critical", async ({ page }) => {});
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## Common Tag Categories
|
||||
|
||||
| Category | Tags | Purpose |
|
||||
| --------------- | --------------------------------------------- | ----------------------------- |
|
||||
| **Speed** | `@fast`, `@slow` | Execution time classification |
|
||||
| **Priority** | `@critical`, `@p0`, `@p1`, `@p2` | Business importance |
|
||||
| **Type** | `@smoke`, `@regression`, `@e2e` | Test suite categorization |
|
||||
| **Feature** | `@auth`, `@payments`, `@settings` | Feature area grouping |
|
||||
| **Pipeline** | `@pr`, `@nightly`, `@release` | CI/CD execution timing |
|
||||
| **Status** | `@flaky`, `@wip`, `@quarantine` | Test health tracking |
|
||||
| **Environment** | `@local`, `@staging`, `@prod` | Target environment |
|
||||
| **Team** | `@team-frontend`, `@team-backend`, `@team-qa` | Team assignment |
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ------------------------ | ------------------------ | ---------------------------------------------- |
|
||||
| Too many tags per test | Hard to maintain | Limit to 2-3 relevant tags |
|
||||
| Inconsistent naming | Confusing filtering | Establish naming conventions |
|
||||
| Missing `@` prefix | Tags won't match filters | Always prefix with `@` |
|
||||
| Overlapping tag meanings | Ambiguous categorization | Define clear tag semantics |
|
||||
| Not using tags | Can't selectively run | Tag by type, priority, or feature |
|
||||
| Tags in test title | Hard to parse/filter | Use the details object for tags, not the title |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Test Organization**: See [test-suite-structure.md](test-suite-structure.md) for structuring tests
|
||||
- **Annotations**: See [annotations.md](annotations.md) for skip, fixme, fail, slow
|
||||
- **CI/CD Integration**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for pipeline setup
|
||||
@@ -0,0 +1,420 @@
|
||||
# Browser Console & JavaScript Error Handling
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Capturing Console Messages](#capturing-console-messages)
|
||||
2. [Failing on Console Errors](#failing-on-console-errors)
|
||||
3. [JavaScript Error Detection](#javascript-error-detection)
|
||||
4. [Monitoring Warnings](#monitoring-warnings)
|
||||
5. [Console Fixtures](#console-fixtures)
|
||||
|
||||
## Capturing Console Messages
|
||||
|
||||
### Basic Console Capture
|
||||
|
||||
```typescript
|
||||
test("capture console logs", async ({ page }) => {
|
||||
const logs: string[] = [];
|
||||
|
||||
page.on("console", (msg) => {
|
||||
logs.push(`${msg.type()}: ${msg.text()}`);
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
// Check what was logged
|
||||
console.log("Captured logs:", logs);
|
||||
});
|
||||
```
|
||||
|
||||
### Capture by Type
|
||||
|
||||
```typescript
|
||||
test("capture specific console types", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const infos: string[] = [];
|
||||
|
||||
page.on("console", (msg) => {
|
||||
switch (msg.type()) {
|
||||
case "error":
|
||||
errors.push(msg.text());
|
||||
break;
|
||||
case "warning":
|
||||
warnings.push(msg.text());
|
||||
break;
|
||||
case "info":
|
||||
case "log":
|
||||
infos.push(msg.text());
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("/dashboard");
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
console.log("Warnings:", warnings);
|
||||
});
|
||||
```
|
||||
|
||||
### Capture with Stack Trace
|
||||
|
||||
```typescript
|
||||
test("capture errors with location", async ({ page }) => {
|
||||
const errors: { message: string; location?: string }[] = [];
|
||||
|
||||
page.on("console", async (msg) => {
|
||||
if (msg.type() === "error") {
|
||||
const location = msg.location();
|
||||
errors.push({
|
||||
message: msg.text(),
|
||||
location: location
|
||||
? `${location.url}:${location.lineNumber}`
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("/buggy-page");
|
||||
|
||||
// Log errors with source location
|
||||
errors.forEach((e) => {
|
||||
console.log(`Error: ${e.message}`);
|
||||
if (e.location) console.log(` at ${e.location}`);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Failing on Console Errors
|
||||
|
||||
### Fail Test on Any Error
|
||||
|
||||
```typescript
|
||||
test("no console errors allowed", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") {
|
||||
errors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Load Data" }).click();
|
||||
|
||||
// Fail if any console errors
|
||||
expect(errors, `Console errors found:\n${errors.join("\n")}`).toHaveLength(0);
|
||||
});
|
||||
```
|
||||
|
||||
### Fail with Allowed Exceptions
|
||||
|
||||
```typescript
|
||||
test("no unexpected console errors", async ({ page }) => {
|
||||
const allowedErrors = [
|
||||
/Failed to load resource.*favicon/,
|
||||
/ResizeObserver loop/,
|
||||
];
|
||||
|
||||
const unexpectedErrors: string[] = [];
|
||||
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") {
|
||||
const text = msg.text();
|
||||
const isAllowed = allowedErrors.some((pattern) => pattern.test(text));
|
||||
if (!isAllowed) {
|
||||
unexpectedErrors.push(text);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
expect(
|
||||
unexpectedErrors,
|
||||
`Unexpected console errors:\n${unexpectedErrors.join("\n")}`,
|
||||
).toHaveLength(0);
|
||||
});
|
||||
```
|
||||
|
||||
### Auto-Fail Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/console.fixture.ts
|
||||
type ConsoleFixtures = {
|
||||
failOnConsoleError: void;
|
||||
};
|
||||
|
||||
export const test = base.extend<ConsoleFixtures>({
|
||||
failOnConsoleError: [
|
||||
async ({ page }, use, testInfo) => {
|
||||
const errors: string[] = [];
|
||||
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") {
|
||||
errors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
await use();
|
||||
|
||||
// After test, check for errors
|
||||
if (errors.length > 0) {
|
||||
testInfo.annotations.push({
|
||||
type: "console-errors",
|
||||
description: errors.join("\n"),
|
||||
});
|
||||
throw new Error(`Console errors detected:\n${errors.join("\n")}`);
|
||||
}
|
||||
},
|
||||
{ auto: true }, // Runs for every test
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## JavaScript Error Detection
|
||||
|
||||
### Catch Uncaught Exceptions
|
||||
|
||||
```typescript
|
||||
test("no uncaught exceptions", async ({ page }) => {
|
||||
const pageErrors: Error[] = [];
|
||||
|
||||
page.on("pageerror", (error) => {
|
||||
pageErrors.push(error);
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Trigger Action" }).click();
|
||||
|
||||
expect(
|
||||
pageErrors,
|
||||
`Uncaught exceptions:\n${pageErrors.map((e) => e.message).join("\n")}`,
|
||||
).toHaveLength(0);
|
||||
});
|
||||
```
|
||||
|
||||
### Capture Error Details
|
||||
|
||||
```typescript
|
||||
test("capture JS error details", async ({ page }) => {
|
||||
const errors: { message: string; stack?: string }[] = [];
|
||||
|
||||
page.on("pageerror", (error) => {
|
||||
errors.push({
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/error-page");
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.log("JavaScript errors:");
|
||||
errors.forEach((e) => {
|
||||
console.log(` Message: ${e.message}`);
|
||||
console.log(` Stack: ${e.stack}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Test Error Boundary Triggers
|
||||
|
||||
```typescript
|
||||
test("error boundary catches render error", async ({ page }) => {
|
||||
let errorCaught = false;
|
||||
|
||||
page.on("pageerror", () => {
|
||||
// Note: React error boundaries catch errors before they become pageerrors
|
||||
// This would only fire for unhandled errors
|
||||
errorCaught = true;
|
||||
});
|
||||
|
||||
// Trigger component error via props
|
||||
await page.route(
|
||||
"**/api/data",
|
||||
(route) => route.fulfill({ json: null }), // Will cause "cannot read property of null"
|
||||
);
|
||||
|
||||
await page.goto("/dashboard");
|
||||
|
||||
// Error boundary should show fallback, not crash
|
||||
await expect(page.getByText("Something went wrong")).toBeVisible();
|
||||
expect(errorCaught).toBe(false); // Error was caught by boundary
|
||||
});
|
||||
```
|
||||
|
||||
## Monitoring Warnings
|
||||
|
||||
### Capture Deprecation Warnings
|
||||
|
||||
```typescript
|
||||
test("no deprecation warnings", async ({ page }) => {
|
||||
const deprecations: string[] = [];
|
||||
|
||||
page.on("console", (msg) => {
|
||||
const text = msg.text();
|
||||
if (
|
||||
msg.type() === "warning" &&
|
||||
(text.includes("deprecated") || text.includes("Deprecation"))
|
||||
) {
|
||||
deprecations.push(text);
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
if (deprecations.length > 0) {
|
||||
console.warn("Deprecation warnings found:");
|
||||
deprecations.forEach((d) => console.warn(` - ${d}`));
|
||||
}
|
||||
|
||||
// Optionally fail
|
||||
// expect(deprecations).toHaveLength(0);
|
||||
});
|
||||
```
|
||||
|
||||
### React Development Warnings
|
||||
|
||||
```typescript
|
||||
test("no React warnings", async ({ page }) => {
|
||||
const reactWarnings: string[] = [];
|
||||
|
||||
page.on("console", (msg) => {
|
||||
const text = msg.text();
|
||||
if (
|
||||
msg.type() === "warning" &&
|
||||
(text.includes("Warning:") || text.includes("React"))
|
||||
) {
|
||||
reactWarnings.push(text);
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
// Common React warnings to check
|
||||
const criticalWarnings = reactWarnings.filter(
|
||||
(w) =>
|
||||
w.includes("Each child in a list should have a unique") ||
|
||||
w.includes("Cannot update a component") ||
|
||||
w.includes("Can't perform a React state update"),
|
||||
);
|
||||
|
||||
expect(
|
||||
criticalWarnings,
|
||||
`React warnings:\n${criticalWarnings.join("\n")}`,
|
||||
).toHaveLength(0);
|
||||
});
|
||||
```
|
||||
|
||||
## Console Fixtures
|
||||
|
||||
### Comprehensive Console Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/console.fixture.ts
|
||||
type ConsoleMessage = {
|
||||
type: string;
|
||||
text: string;
|
||||
location?: { url: string; line: number };
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
type ConsoleFixtures = {
|
||||
consoleMessages: ConsoleMessage[];
|
||||
getConsoleErrors: () => ConsoleMessage[];
|
||||
getConsoleWarnings: () => ConsoleMessage[];
|
||||
assertNoErrors: (allowedPatterns?: RegExp[]) => void;
|
||||
};
|
||||
|
||||
export const test = base.extend<ConsoleFixtures>({
|
||||
consoleMessages: async ({ page }, use) => {
|
||||
const messages: ConsoleMessage[] = [];
|
||||
|
||||
page.on("console", (msg) => {
|
||||
const location = msg.location();
|
||||
messages.push({
|
||||
type: msg.type(),
|
||||
text: msg.text(),
|
||||
location: location
|
||||
? { url: location.url, line: location.lineNumber }
|
||||
: undefined,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
await use(messages);
|
||||
},
|
||||
|
||||
getConsoleErrors: async ({ consoleMessages }, use) => {
|
||||
await use(() => consoleMessages.filter((m) => m.type === "error"));
|
||||
},
|
||||
|
||||
getConsoleWarnings: async ({ consoleMessages }, use) => {
|
||||
await use(() => consoleMessages.filter((m) => m.type === "warning"));
|
||||
},
|
||||
|
||||
assertNoErrors: async ({ getConsoleErrors }, use) => {
|
||||
await use((allowedPatterns = []) => {
|
||||
const errors = getConsoleErrors();
|
||||
const unexpected = errors.filter(
|
||||
(e) => !allowedPatterns.some((p) => p.test(e.text)),
|
||||
);
|
||||
|
||||
if (unexpected.length > 0) {
|
||||
throw new Error(
|
||||
`Unexpected console errors:\n${unexpected.map((e) => e.text).join("\n")}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
test("page loads without errors", async ({ page, assertNoErrors }) => {
|
||||
await page.goto("/dashboard");
|
||||
await page.getByRole("button", { name: "Load" }).click();
|
||||
|
||||
assertNoErrors([/favicon/]); // Allow favicon errors
|
||||
});
|
||||
```
|
||||
|
||||
### Attach Console to Report
|
||||
|
||||
```typescript
|
||||
test("capture console for debugging", async ({ page }, testInfo) => {
|
||||
const logs: string[] = [];
|
||||
|
||||
page.on("console", (msg) => {
|
||||
logs.push(`[${msg.type()}] ${msg.text()}`);
|
||||
});
|
||||
|
||||
page.on("pageerror", (error) => {
|
||||
logs.push(`[EXCEPTION] ${error.message}`);
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
// ... test actions
|
||||
|
||||
// Attach console log to test report
|
||||
await testInfo.attach("console-log", {
|
||||
body: logs.join("\n"),
|
||||
contentType: "text/plain",
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| -------------------------- | -------------------------- | --------------------------- |
|
||||
| Ignoring console errors | Bugs go unnoticed | Check for errors in tests |
|
||||
| Too strict error checking | Tests fail on minor issues | Allow known/expected errors |
|
||||
| Not capturing stack traces | Hard to debug | Include location info |
|
||||
| Checking only at end | Miss errors during actions | Capture continuously |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Debugging**: See [debugging.md](debugging.md) for troubleshooting
|
||||
- **Error Testing**: See [error-testing.md](error-testing.md) for error scenarios
|
||||
@@ -0,0 +1,504 @@
|
||||
# Debugging & Troubleshooting
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Debug Tools](#debug-tools)
|
||||
2. [Trace Viewer](#trace-viewer)
|
||||
3. [Identifying Flaky Tests](#identifying-flaky-tests)
|
||||
4. [Debugging Network Issues](#debugging-network-issues)
|
||||
5. [Debugging in CI](#debugging-in-ci)
|
||||
6. [Debugging Authentication](#debugging-authentication)
|
||||
7. [Debugging Screenshots](#debugging-screenshots)
|
||||
8. [Common Issues](#common-issues)
|
||||
9. [Logging](#logging)
|
||||
|
||||
## Debug Tools
|
||||
|
||||
### Playwright Inspector
|
||||
|
||||
```bash
|
||||
# Run with inspector
|
||||
PWDEBUG=1 npx playwright test
|
||||
# Or specific test
|
||||
PWDEBUG=1 npx playwright test login.spec.ts
|
||||
```
|
||||
|
||||
Features:
|
||||
|
||||
- Step through test actions
|
||||
- Pick locators visually
|
||||
- Inspect DOM state
|
||||
- Edit and re-run
|
||||
|
||||
### Headed Mode
|
||||
|
||||
```bash
|
||||
# Run with visible browser
|
||||
npx playwright test --headed
|
||||
|
||||
# Interactive debugging (headed, paused, step-through)
|
||||
npx playwright test --debug
|
||||
```
|
||||
|
||||
You can also set `slowMo` to add an `N` ms delay per action, making test execution easier to follow while debugging.
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
use: {
|
||||
launchOptions: {
|
||||
slowMo: 500,
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### UI Mode
|
||||
|
||||
```bash
|
||||
# Interactive test runner
|
||||
npx playwright test --ui
|
||||
```
|
||||
|
||||
Features:
|
||||
|
||||
- Watch mode
|
||||
- Test timeline
|
||||
- DOM snapshots
|
||||
- Network logs
|
||||
- Console logs
|
||||
|
||||
### Debug in Code
|
||||
|
||||
```typescript
|
||||
test("debug example", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Pause and open inspector
|
||||
await page.pause();
|
||||
|
||||
// Continue test...
|
||||
await page.click("button");
|
||||
});
|
||||
```
|
||||
|
||||
## Trace Viewer
|
||||
|
||||
### Enable Traces
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
use: {
|
||||
trace: "on-first-retry", // Record on retry
|
||||
// trace: 'on', // Always record
|
||||
// trace: 'retain-on-failure', // Keep only failures
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### View Traces
|
||||
|
||||
```bash
|
||||
# Open trace file
|
||||
npx playwright show-trace trace.zip
|
||||
|
||||
# From test-results
|
||||
npx playwright show-trace test-results/test-name/trace.zip
|
||||
```
|
||||
|
||||
### Trace Contents
|
||||
|
||||
- Screenshots at each action
|
||||
- DOM snapshots
|
||||
- Network requests/responses
|
||||
- Console logs
|
||||
- Action timeline
|
||||
- Source code
|
||||
|
||||
### Programmatic Traces
|
||||
|
||||
```typescript
|
||||
test("manual trace", async ({ page, context }) => {
|
||||
await context.tracing.start({ screenshots: true, snapshots: true });
|
||||
|
||||
await page.goto("/");
|
||||
await page.click("button");
|
||||
|
||||
await context.tracing.stop({ path: "trace.zip" });
|
||||
});
|
||||
```
|
||||
|
||||
## Identifying Flaky Tests
|
||||
|
||||
If a test fails intermittently, it's likely flaky. Quick checks:
|
||||
|
||||
| Behavior | Likely Cause | Next Step |
|
||||
| -------------------------------------- | ----------------------------- | -------------------------------------- |
|
||||
| Fails sometimes, passes other times | Flaky - timing/race condition | [flaky-tests.md](flaky-tests.md) |
|
||||
| Fails only with multiple workers | Flaky - parallelism/isolation | [flaky-tests.md](flaky-tests.md) |
|
||||
| Fails only in CI | Environment difference | [CI Debugging](#debugging-in-ci) below |
|
||||
| Always fails | Bug in test or app | Debug with tools above |
|
||||
| Always passes locally, always fails CI | CI-specific issue | [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) |
|
||||
|
||||
> **For flaky test detection commands, root cause analysis, and fixing strategies**, see [flaky-tests.md](flaky-tests.md).
|
||||
|
||||
## Debugging Network Issues
|
||||
|
||||
### Monitor All Requests
|
||||
|
||||
```typescript
|
||||
test("debug network", async ({ page }) => {
|
||||
const requests: string[] = [];
|
||||
const failures: string[] = [];
|
||||
|
||||
page.on("request", (req) => requests.push(`>> ${req.method()} ${req.url()}`));
|
||||
page.on("requestfinished", (req) => {
|
||||
const resp = req.response();
|
||||
requests.push(`<< ${resp?.status()} ${req.url()}`);
|
||||
});
|
||||
page.on("requestfailed", (req) => {
|
||||
failures.push(`FAILED: ${req.url()} - ${req.failure()?.errorText}`);
|
||||
});
|
||||
|
||||
await page.goto("/dashboard");
|
||||
|
||||
// Log summary
|
||||
console.log("Requests:", requests.length);
|
||||
if (failures.length) console.log("Failures:", failures);
|
||||
});
|
||||
```
|
||||
|
||||
### Wait for Specific API Response
|
||||
|
||||
When debugging network-dependent issues, wait for specific API responses instead of arbitrary timeouts.
|
||||
|
||||
```typescript
|
||||
// Start waiting BEFORE triggering the request
|
||||
const responsePromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes("/api/data") && resp.status() === 200,
|
||||
);
|
||||
await page.getByRole("button", { name: "Load" }).click();
|
||||
const response = await responsePromise;
|
||||
console.log("Status:", response.status());
|
||||
```
|
||||
|
||||
> **For comprehensive waiting patterns** (navigation, element state, network, polling), see [assertions-waiting.md](../core/assertions-waiting.md#waiting-strategies).
|
||||
|
||||
### Debug Slow Requests
|
||||
|
||||
```typescript
|
||||
test("find slow requests", async ({ page }) => {
|
||||
page.on("requestfinished", (request) => {
|
||||
const timing = request.timing();
|
||||
const total = timing.responseEnd - timing.requestStart;
|
||||
if (total > 1000) {
|
||||
console.log(`SLOW (${total}ms): ${request.url()}`);
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
});
|
||||
```
|
||||
|
||||
## Debugging in CI
|
||||
|
||||
### Simulate CI Locally
|
||||
|
||||
```bash
|
||||
# Run in headless mode like CI
|
||||
CI=true npx playwright test
|
||||
|
||||
# Match CI browser versions
|
||||
npx playwright install --with-deps
|
||||
|
||||
# Run in Docker (same as CI)
|
||||
docker run --rm -v $(pwd):/work -w /work \
|
||||
mcr.microsoft.com/playwright:v1.40.0-jammy \
|
||||
npx playwright test
|
||||
```
|
||||
|
||||
### CI-Specific Configuration
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
// More artifacts in CI for debugging
|
||||
use: {
|
||||
trace: process.env.CI ? "on-first-retry" : "off",
|
||||
video: process.env.CI ? "retain-on-failure" : "off",
|
||||
screenshot: process.env.CI ? "only-on-failure" : "off",
|
||||
},
|
||||
|
||||
// More retries in CI (but investigate failures!)
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
});
|
||||
```
|
||||
|
||||
### Debug CI Environment
|
||||
|
||||
```typescript
|
||||
test("CI environment check", async ({ page }, testInfo) => {
|
||||
console.log("CI:", process.env.CI);
|
||||
console.log("Project:", testInfo.project.name);
|
||||
console.log("Worker:", testInfo.workerIndex);
|
||||
console.log("Retry:", testInfo.retry);
|
||||
console.log("Base URL:", testInfo.project.use.baseURL);
|
||||
|
||||
// Check viewport
|
||||
const viewport = page.viewportSize();
|
||||
console.log("Viewport:", viewport);
|
||||
});
|
||||
```
|
||||
|
||||
## Debugging Authentication
|
||||
|
||||
```typescript
|
||||
test("debug auth", async ({ page, context }) => {
|
||||
// Inspect current storage state
|
||||
const storage = await context.storageState();
|
||||
console.log(
|
||||
"Cookies:",
|
||||
storage.cookies.map((c) => c.name),
|
||||
);
|
||||
|
||||
// Check if auth cookies are present
|
||||
const cookies = await context.cookies();
|
||||
const authCookie = cookies.find((c) => c.name.includes("session"));
|
||||
console.log("Auth cookie:", authCookie ? "present" : "MISSING");
|
||||
|
||||
await page.goto("/protected");
|
||||
|
||||
// Check if redirected to login (auth failed)
|
||||
if (page.url().includes("/login")) {
|
||||
console.error("Auth failed - redirected to login");
|
||||
// Save state for inspection
|
||||
await context.storageState({ path: "debug-auth.json" });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Debugging Screenshots
|
||||
|
||||
### Compare Visual State
|
||||
|
||||
```typescript
|
||||
test("visual debug", async ({ page }, testInfo) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Screenshot before action
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("before.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Open Menu" }).click();
|
||||
|
||||
// Screenshot after action
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("after.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
// Attach to report
|
||||
await testInfo.attach("before", {
|
||||
path: testInfo.outputPath("before.png"),
|
||||
contentType: "image/png",
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Screenshot Specific Element
|
||||
|
||||
```typescript
|
||||
test("element screenshot", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const element = page.getByTestId("problem-area");
|
||||
|
||||
// Screenshot just the element
|
||||
await element.screenshot({ path: "element-debug.png" });
|
||||
|
||||
// Highlight element in full page screenshot
|
||||
await element.evaluate((el) => (el.style.border = "3px solid red"));
|
||||
await page.screenshot({ path: "highlighted.png" });
|
||||
});
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Element Not Found
|
||||
|
||||
```typescript
|
||||
// Debug: Check if element exists
|
||||
console.log(await page.getByRole("button").count());
|
||||
|
||||
// Debug: Log all buttons
|
||||
const buttons = await page.getByRole("button").all();
|
||||
for (const button of buttons) {
|
||||
console.log(await button.textContent());
|
||||
}
|
||||
|
||||
// Debug: Screenshot before action
|
||||
await page.screenshot({ path: "debug.png" });
|
||||
await page.getByRole("button").click();
|
||||
```
|
||||
|
||||
### Timeout Issues
|
||||
|
||||
```typescript
|
||||
// Increase timeout for slow operations
|
||||
await expect(page.getByText("Loaded")).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// Global timeout increase
|
||||
test.setTimeout(60000);
|
||||
|
||||
// Check what's blocking
|
||||
test("debug timeout", async ({ page }) => {
|
||||
await page.goto("/slow-page");
|
||||
|
||||
// Log network activity
|
||||
page.on("request", (request) => console.log(">>", request.url()));
|
||||
page.on("response", (response) =>
|
||||
console.log("<<", response.url(), response.status()),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Selector Issues
|
||||
|
||||
```typescript
|
||||
// Debug: Highlight element
|
||||
await page.getByRole("button").highlight();
|
||||
|
||||
// Debug: Evaluate selector in browser console
|
||||
// Run in Inspector console:
|
||||
// playwright.locator('button').first().highlight()
|
||||
|
||||
// Debug: Get element info
|
||||
const element = page.getByRole("button");
|
||||
console.log("Count:", await element.count());
|
||||
console.log("Visible:", await element.isVisible());
|
||||
console.log("Enabled:", await element.isEnabled());
|
||||
```
|
||||
|
||||
### Frame Issues
|
||||
|
||||
```typescript
|
||||
// Debug: List all frames
|
||||
for (const frame of page.frames()) {
|
||||
console.log("Frame:", frame.url());
|
||||
}
|
||||
|
||||
// Debug: Check if element is in iframe
|
||||
const frame = page.frameLocator("iframe").first();
|
||||
console.log(await frame.getByRole("button").count());
|
||||
```
|
||||
|
||||
## Logging
|
||||
|
||||
### Capture Browser Console
|
||||
|
||||
```typescript
|
||||
test("with logging", async ({ page }) => {
|
||||
page.on("console", (msg) => console.log("Browser:", msg.text()));
|
||||
page.on("pageerror", (error) => console.log("Page error:", error.message));
|
||||
await page.goto("/");
|
||||
});
|
||||
```
|
||||
|
||||
> **For comprehensive console error handling** (fail on errors, allowed patterns, fixtures), see [console-errors.md](console-errors.md).
|
||||
|
||||
### Custom Test Attachments
|
||||
|
||||
```typescript
|
||||
test("with attachments", async ({ page }, testInfo) => {
|
||||
// Attach screenshot to report
|
||||
const screenshot = await page.screenshot();
|
||||
await testInfo.attach("screenshot", {
|
||||
body: screenshot,
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
// Attach logs or data
|
||||
await testInfo.attach("logs", {
|
||||
body: "Custom log data",
|
||||
contentType: "text/plain",
|
||||
});
|
||||
|
||||
// Use testInfo for output paths
|
||||
const outputPath = testInfo.outputPath("debug-file.json");
|
||||
});
|
||||
```
|
||||
|
||||
## Troubleshooting Checklist
|
||||
|
||||
### By Symptom
|
||||
|
||||
| Symptom | Common Causes | Quick Fixes | Reference |
|
||||
| --------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| **Element not found** | Wrong selector, element not visible, in iframe, timing issue | Check locator with Inspector, wait for visibility, use frameLocator | [locators.md](../core/locators.md), [assertions-waiting.md](../core/assertions-waiting.md) |
|
||||
| **Timeout errors** | Slow network, heavy page load, waiting for wrong condition | Increase timeout, wait for specific response, check network tab | [assertions-waiting.md](../core/assertions-waiting.md) |
|
||||
| **Flaky tests** | Race conditions, shared state, timing dependencies | See comprehensive flaky test guide | [flaky-tests.md](flaky-tests.md) |
|
||||
| **Tests pass locally, fail in CI** | Environment differences, missing dependencies, timing | Simulate CI locally, check CI logs, verify environment vars | [ci-cd.md](../infrastructure-ci-cd/ci-cd.md), [flaky-tests.md](flaky-tests.md) |
|
||||
| **Slow test execution** | Not parallelized, heavy network calls, unnecessary waits | Enable parallelization, mock APIs, optimize waits | [performance.md](../infrastructure-ci-cd/performance.md) |
|
||||
| **Selector works in browser but not in test** | Element not attached, wrong context, dynamic content | Use auto-waiting, check iframe, verify element state | [locators.md](../core/locators.md) |
|
||||
| **Test fails on retry** | Non-deterministic data, external dependencies | Use test data fixtures, mock external services | [fixtures-hooks.md](../core/fixtures-hooks.md) |
|
||||
|
||||
### Step-by-Step Debugging Process
|
||||
|
||||
1. **Reproduce the issue**
|
||||
|
||||
```bash
|
||||
# Run with trace enabled
|
||||
npx playwright test tests/failing.spec.ts --trace on
|
||||
|
||||
# If intermittent, run multiple times
|
||||
npx playwright test --repeat-each=10
|
||||
```
|
||||
|
||||
2. **Inspect the failure**
|
||||
|
||||
```bash
|
||||
# View trace
|
||||
npx playwright show-trace test-results/path-to-trace.zip
|
||||
|
||||
# Run in headed mode to watch
|
||||
npx playwright test --headed
|
||||
|
||||
# Use inspector for step-by-step
|
||||
PWDEBUG=1 npx playwright test
|
||||
```
|
||||
|
||||
3. **Isolate the problem**
|
||||
|
||||
```typescript
|
||||
// Add debugging points
|
||||
await page.pause();
|
||||
|
||||
// Log element state
|
||||
console.log("Element count:", await page.getByRole("button").count());
|
||||
console.log("Element visible:", await page.getByRole("button").isVisible());
|
||||
|
||||
// Take screenshot at failure point
|
||||
await page.screenshot({ path: "debug.png" });
|
||||
```
|
||||
|
||||
4. **Check related areas**
|
||||
- Network requests: Are API calls completing? (see [Debugging Network Issues](#debugging-network-issues))
|
||||
- Timing: Is auto-waiting working correctly?
|
||||
- State: Is the test isolated? (see [flaky-tests.md](flaky-tests.md))
|
||||
- Environment: Does it work locally but fail in CI? (see [Debugging in CI](#debugging-in-ci))
|
||||
|
||||
5. **Apply fix and verify**
|
||||
- Fix the root cause (not just symptoms)
|
||||
- Run multiple times to confirm stability: `--repeat-each=10`
|
||||
- Check related tests aren't affected
|
||||
|
||||
## Related References
|
||||
|
||||
- **Flaky tests**: See [flaky-tests.md](flaky-tests.md) for comprehensive flaky test guide
|
||||
- **Locator issues**: See [locators.md](../core/locators.md) for selector strategies
|
||||
- **Waiting problems**: See [assertions-waiting.md](../core/assertions-waiting.md) for waiting patterns
|
||||
- **Test isolation**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for fixtures and isolation
|
||||
- **CI issues**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for CI configuration
|
||||
@@ -0,0 +1,360 @@
|
||||
# Error & Edge Case Testing
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Error Boundaries](#error-boundaries)
|
||||
2. [Network Failures](#network-failures)
|
||||
3. [Offline Testing](#offline-testing)
|
||||
4. [Loading States](#loading-states)
|
||||
5. [Form Validation](#form-validation)
|
||||
|
||||
## Error Boundaries
|
||||
|
||||
### Test Component Errors
|
||||
|
||||
```typescript
|
||||
test("error boundary catches component error", async ({ page }) => {
|
||||
// Trigger error via mock
|
||||
await page.route("**/api/user", (route) => {
|
||||
route.fulfill({
|
||||
json: null, // Will cause component to throw
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/profile");
|
||||
|
||||
// Error boundary should render fallback
|
||||
await expect(page.getByText("Something went wrong")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Try Again" })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Test Error Recovery
|
||||
|
||||
```typescript
|
||||
test("recover from error state", async ({ page }) => {
|
||||
let requestCount = 0;
|
||||
|
||||
await page.route("**/api/data", (route) => {
|
||||
requestCount++;
|
||||
if (requestCount === 1) {
|
||||
return route.fulfill({ status: 500 });
|
||||
}
|
||||
return route.fulfill({
|
||||
json: { data: "success" },
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/dashboard");
|
||||
|
||||
// Error state
|
||||
await expect(page.getByText("Failed to load")).toBeVisible();
|
||||
|
||||
// Retry
|
||||
await page.getByRole("button", { name: "Retry" }).click();
|
||||
|
||||
// Success state
|
||||
await expect(page.getByText("success")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Test JavaScript Errors
|
||||
|
||||
```typescript
|
||||
test("handles runtime error gracefully", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
|
||||
page.on("pageerror", (error) => {
|
||||
errors.push(error.message);
|
||||
});
|
||||
|
||||
await page.goto("/buggy-page");
|
||||
|
||||
// App should still be functional despite error
|
||||
await expect(page.getByRole("navigation")).toBeVisible();
|
||||
|
||||
// Error was logged
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
```
|
||||
|
||||
## Network Failures
|
||||
|
||||
### Test API Errors
|
||||
|
||||
```typescript
|
||||
test.describe("API error handling", () => {
|
||||
const errorCodes = [400, 401, 403, 404, 500, 502, 503];
|
||||
|
||||
for (const status of errorCodes) {
|
||||
test(`handles ${status} error`, async ({ page }) => {
|
||||
await page.route("**/api/data", (route) =>
|
||||
route.fulfill({
|
||||
status,
|
||||
json: { error: `Error ${status}` },
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/dashboard");
|
||||
|
||||
// Appropriate error message shown
|
||||
await expect(page.getByRole("alert")).toBeVisible();
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Test Timeout
|
||||
|
||||
```typescript
|
||||
test("handles request timeout", async ({ page }) => {
|
||||
await page.route("**/api/slow", async (route) => {
|
||||
// Never respond - simulates timeout
|
||||
await new Promise(() => {});
|
||||
});
|
||||
|
||||
await page.goto("/slow-page");
|
||||
|
||||
// Should show timeout message (app should have its own timeout)
|
||||
await expect(page.getByText("Request timed out")).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Test Connection Reset
|
||||
|
||||
```typescript
|
||||
test("handles connection failure", async ({ page }) => {
|
||||
await page.route("**/api/data", (route) => {
|
||||
route.abort("connectionfailed");
|
||||
});
|
||||
|
||||
await page.goto("/dashboard");
|
||||
|
||||
await expect(page.getByText("Connection failed")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Test Mid-Request Failure
|
||||
|
||||
```typescript
|
||||
test("handles failure during request", async ({ page }) => {
|
||||
let requestStarted = false;
|
||||
|
||||
await page.route("**/api/upload", async (route) => {
|
||||
requestStarted = true;
|
||||
// Abort after small delay (mid-request)
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
route.abort("failed");
|
||||
});
|
||||
|
||||
await page.goto("/upload");
|
||||
await page.getByLabel("File").setInputFiles("./fixtures/large-file.pdf");
|
||||
await page.getByRole("button", { name: "Upload" }).click();
|
||||
|
||||
// Should show failure, not hang
|
||||
await expect(page.getByText("Upload failed")).toBeVisible();
|
||||
expect(requestStarted).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
## Offline Testing
|
||||
|
||||
This section covers **unexpected network failures** and error recovery. For **offline-first apps (PWAs)** with service workers, caching, and background sync, see [service-workers.md](service-workers.md#offline-testing).
|
||||
|
||||
### Go Offline During Session
|
||||
|
||||
```typescript
|
||||
test("handles going offline", async ({ page, context }) => {
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.getByTestId("data")).toBeVisible();
|
||||
|
||||
// Go offline unexpectedly
|
||||
await context.setOffline(true);
|
||||
|
||||
// Try to refresh data
|
||||
await page.getByRole("button", { name: "Refresh" }).click();
|
||||
|
||||
// Should show offline indicator
|
||||
await expect(page.getByText("You're offline")).toBeVisible();
|
||||
|
||||
// Go back online
|
||||
await context.setOffline(false);
|
||||
|
||||
// Should recover
|
||||
await page.getByRole("button", { name: "Refresh" }).click();
|
||||
await expect(page.getByText("You're offline")).toBeHidden();
|
||||
});
|
||||
```
|
||||
|
||||
### Test Network Recovery
|
||||
|
||||
```typescript
|
||||
test("recovers gracefully when connection returns", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await page.goto("/dashboard");
|
||||
|
||||
// Simulate connection drop
|
||||
await context.setOffline(true);
|
||||
|
||||
// App should show degraded state
|
||||
await expect(page.getByRole("alert")).toContainText(/offline|connection/i);
|
||||
|
||||
// Connection restored
|
||||
await context.setOffline(false);
|
||||
|
||||
// Retry should work
|
||||
await page.getByRole("button", { name: "Retry" }).click();
|
||||
await expect(page.getByTestId("data")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Loading States
|
||||
|
||||
### Test Skeleton Loaders
|
||||
|
||||
```typescript
|
||||
test("shows skeleton during load", async ({ page }) => {
|
||||
// Add delay to API response
|
||||
await page.route("**/api/posts", async (route) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
route.fulfill({
|
||||
json: [{ id: 1, title: "Post 1" }],
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/posts");
|
||||
|
||||
// Skeleton should appear immediately
|
||||
await expect(page.getByTestId("skeleton")).toBeVisible();
|
||||
|
||||
// Then content replaces skeleton
|
||||
await expect(page.getByText("Post 1")).toBeVisible();
|
||||
await expect(page.getByTestId("skeleton")).toBeHidden();
|
||||
});
|
||||
```
|
||||
|
||||
### Test Loading Indicators
|
||||
|
||||
```typescript
|
||||
test("shows loading state for actions", async ({ page }) => {
|
||||
await page.route("**/api/save", async (route) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
route.fulfill({ json: { success: true } });
|
||||
});
|
||||
|
||||
await page.goto("/editor");
|
||||
await page.getByLabel("Content").fill("New content");
|
||||
|
||||
const saveButton = page.getByRole("button", { name: "Save" });
|
||||
await saveButton.click();
|
||||
|
||||
// Button should show loading state
|
||||
await expect(saveButton).toBeDisabled();
|
||||
await expect(page.getByTestId("spinner")).toBeVisible();
|
||||
|
||||
// Then success state
|
||||
await expect(saveButton).toBeEnabled();
|
||||
await expect(page.getByText("Saved")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Test Empty States
|
||||
|
||||
```typescript
|
||||
test("shows empty state when no data", async ({ page }) => {
|
||||
await page.route("**/api/items", (route) => route.fulfill({ json: [] }));
|
||||
|
||||
await page.goto("/items");
|
||||
|
||||
await expect(page.getByText("No items yet")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Create First Item" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Form Validation
|
||||
|
||||
### Test Client-Side Validation
|
||||
|
||||
```typescript
|
||||
test("validates required fields", async ({ page }) => {
|
||||
await page.goto("/signup");
|
||||
|
||||
// Submit empty form
|
||||
await page.getByRole("button", { name: "Sign Up" }).click();
|
||||
|
||||
// Should show validation errors
|
||||
await expect(page.getByText("Email is required")).toBeVisible();
|
||||
await expect(page.getByText("Password is required")).toBeVisible();
|
||||
|
||||
// Form should not submit
|
||||
await expect(page).toHaveURL("/signup");
|
||||
});
|
||||
```
|
||||
|
||||
### Test Format Validation
|
||||
|
||||
```typescript
|
||||
test("validates email format", async ({ page }) => {
|
||||
await page.goto("/signup");
|
||||
|
||||
await page.getByLabel("Email").fill("invalid-email");
|
||||
await page.getByLabel("Email").blur();
|
||||
|
||||
await expect(page.getByText("Invalid email address")).toBeVisible();
|
||||
|
||||
// Fix the error
|
||||
await page.getByLabel("Email").fill("valid@email.com");
|
||||
await page.getByLabel("Email").blur();
|
||||
|
||||
await expect(page.getByText("Invalid email address")).toBeHidden();
|
||||
});
|
||||
```
|
||||
|
||||
### Test Server-Side Validation
|
||||
|
||||
```typescript
|
||||
test("handles server validation errors", async ({ page }) => {
|
||||
await page.route("**/api/register", (route) =>
|
||||
route.fulfill({
|
||||
status: 422,
|
||||
json: {
|
||||
errors: {
|
||||
email: "Email already exists",
|
||||
username: "Username is taken",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/signup");
|
||||
await page.getByLabel("Email").fill("taken@email.com");
|
||||
await page.getByLabel("Username").fill("takenuser");
|
||||
await page.getByLabel("Password").fill("password123");
|
||||
await page.getByRole("button", { name: "Sign Up" }).click();
|
||||
|
||||
// Server errors should display
|
||||
await expect(page.getByText("Email already exists")).toBeVisible();
|
||||
await expect(page.getByText("Username is taken")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ------------------------ | ------------------------------ | -------------------------------------- |
|
||||
| Only testing happy path | Misses error handling bugs | Test all error scenarios |
|
||||
| No network failure tests | App crashes on poor connection | Test offline/slow/failed requests |
|
||||
| Skipping loading states | Janky UX not caught | Assert loading UI appears |
|
||||
| Ignoring validation | Form bugs slip through | Test both client and server validation |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Network Mocking**: See [network-advanced.md](../advanced/network-advanced.md) for mock patterns
|
||||
- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for error assertions
|
||||
@@ -0,0 +1,496 @@
|
||||
# Debugging and Managing Flaky Tests
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Understanding Flakiness Types](#understanding-flakiness-types)
|
||||
2. [Detection and Reproduction](#detection-and-reproduction)
|
||||
3. [Root Cause Analysis](#root-cause-analysis)
|
||||
4. [Fixing Strategies by Type](#fixing-strategies-by-type)
|
||||
5. [CI-Specific Flakiness](#ci-specific-flakiness)
|
||||
6. [Quarantine and Management](#quarantine-and-management)
|
||||
7. [Prevention Strategies](#prevention-strategies)
|
||||
|
||||
## Understanding Flakiness Types
|
||||
|
||||
### Categories of Flakiness
|
||||
|
||||
Most flaky tests fall into distinct categories requiring different remediation:
|
||||
|
||||
| Category | Symptoms | Common Causes |
|
||||
| --------------------------- | ------------------------------- | ------------------------------------------------------ |
|
||||
| **UI-driven** | Element not found, click missed | Missing waits, animations, dynamic rendering |
|
||||
| **Environment-driven** | CI-only failures | Slower CPU, memory limits, cold browser starts |
|
||||
| **Data/parallelism-driven** | Fails with multiple workers | Shared backend data, reused accounts, state collisions |
|
||||
| **Test-suite-driven** | Fails when run with other tests | Leaked state, shared fixtures, order dependencies |
|
||||
|
||||
### Flakiness Decision Tree
|
||||
|
||||
```
|
||||
Test fails intermittently
|
||||
├─ Fails locally too?
|
||||
│ ├─ YES → Timing/async issue → Check waits and assertions
|
||||
│ └─ NO → CI-specific → Check environment differences
|
||||
│
|
||||
├─ Fails only with multiple workers?
|
||||
│ └─ YES → Parallelism issue → Check data isolation
|
||||
│
|
||||
├─ Fails only when run after specific tests?
|
||||
│ └─ YES → State leak → Check fixtures and cleanup
|
||||
│
|
||||
└─ Fails randomly regardless of conditions?
|
||||
└─ External dependency → Check network/API stability
|
||||
```
|
||||
|
||||
## Detection and Reproduction
|
||||
|
||||
### Confirming Flakiness
|
||||
|
||||
```bash
|
||||
# Run test multiple times to confirm instability
|
||||
npx playwright test tests/checkout.spec.ts --repeat-each=20
|
||||
|
||||
# Run with single worker to isolate parallelism issues
|
||||
npx playwright test --workers=1
|
||||
|
||||
# Run in CI-like conditions locally
|
||||
CI=true npx playwright test --repeat-each=10
|
||||
```
|
||||
|
||||
### Reproduction Strategies
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts - Enable artifacts for flaky test investigation
|
||||
export default defineConfig({
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
use: {
|
||||
trace: "on-first-retry", // Capture trace on retry
|
||||
video: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Identify Flaky Tests Programmatically
|
||||
|
||||
```typescript
|
||||
// Track test results across runs
|
||||
test.afterEach(async ({}, testInfo) => {
|
||||
if (testInfo.retry > 0 && testInfo.status === "passed") {
|
||||
console.warn(`FLAKY: ${testInfo.title} passed on retry ${testInfo.retry}`);
|
||||
// Log to your tracking system
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### Event Logging for Race Conditions
|
||||
|
||||
Add comprehensive event logging to expose timing issues:
|
||||
|
||||
```typescript
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.on("console", (msg) =>
|
||||
console.log(`CONSOLE [${msg.type()}]:`, msg.text()),
|
||||
);
|
||||
page.on("pageerror", (err) => console.error("PAGE ERROR:", err.message));
|
||||
page.on("requestfailed", (req) =>
|
||||
console.error(`REQUEST FAILED: ${req.url()}`),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
> **For comprehensive console error handling** (fail on errors, allowed patterns, fixtures), see [console-errors.md](console-errors.md).
|
||||
|
||||
### Network Timing Analysis
|
||||
|
||||
```typescript
|
||||
// Capture slow or failed requests
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const slowRequests: string[] = [];
|
||||
|
||||
page.on("requestfinished", (request) => {
|
||||
const timing = request.timing();
|
||||
const duration = timing.responseEnd - timing.requestStart;
|
||||
if (duration > 2000) {
|
||||
slowRequests.push(`${request.url()} took ${duration}ms`);
|
||||
}
|
||||
});
|
||||
|
||||
page.on("requestfailed", (request) => {
|
||||
console.error(`Failed: ${request.url()} - ${request.failure()?.errorText}`);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Trace Analysis
|
||||
|
||||
```bash
|
||||
# View trace from failed CI run
|
||||
npx playwright show-trace path/to/trace.zip
|
||||
|
||||
# Generate trace for specific test
|
||||
npx playwright test tests/flaky.spec.ts --trace on
|
||||
```
|
||||
|
||||
## Fixing Strategies by Type
|
||||
|
||||
### UI-Driven Flakiness
|
||||
|
||||
**Problem: Element not ready when action executes**
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: No wait for element state
|
||||
await page.click("#submit");
|
||||
await page.fill("#username", "test"); // Element may not be ready
|
||||
|
||||
// ✅ GOOD: Actions + assertions pattern (auto-waiting built-in)
|
||||
await page.getByRole("button", { name: "Submit" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
|
||||
```
|
||||
|
||||
**Problem: Animations or transitions interfere**
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Click during animation
|
||||
await page.click(".menu-item");
|
||||
|
||||
// ✅ GOOD: Wait for animation to complete
|
||||
await page.getByRole("menuitem", { name: "Settings" }).click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
// Or disable animations entirely
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
```
|
||||
|
||||
**Problem: Brittle selectors**
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Fragile CSS chain
|
||||
await page.click("div.container > div:nth-child(2) > button.btn-primary");
|
||||
|
||||
// ✅ GOOD: Semantic selectors
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await page.getByTestId("checkout-button").click();
|
||||
await page.getByLabel("Email address").fill("test@example.com");
|
||||
```
|
||||
|
||||
### Async/Timing Flakiness
|
||||
|
||||
**Problem: Race between test and application**
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Arbitrary sleep
|
||||
await page.click("#load-data");
|
||||
await page.waitForTimeout(3000); // Hope data loads in 3s
|
||||
|
||||
// ✅ GOOD: Wait for specific condition
|
||||
await page.click("#load-data");
|
||||
await expect(page.locator(".data-row")).toHaveCount(10, { timeout: 10000 });
|
||||
|
||||
// ✅ BETTER: Wait for network response, then assert
|
||||
const responsePromise = page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().includes("/api/data") &&
|
||||
r.request().method() === "GET" &&
|
||||
r.ok(),
|
||||
);
|
||||
await page.click("#load-data");
|
||||
await responsePromise;
|
||||
await expect(page.locator(".data-row")).toHaveCount(10);
|
||||
```
|
||||
|
||||
> **For comprehensive waiting strategies** (navigation, element state, network, polling with `toPass()`), see [assertions-waiting.md](assertions-waiting.md#waiting-strategies).
|
||||
|
||||
**Problem: Complex async state**
|
||||
|
||||
```typescript
|
||||
// Custom wait for application-specific conditions
|
||||
await page.waitForFunction(() => {
|
||||
const app = (window as any).__APP_STATE__;
|
||||
return app?.isReady && !app?.isLoading;
|
||||
});
|
||||
|
||||
// Wait for multiple conditions
|
||||
await Promise.all([
|
||||
page.waitForResponse("**/api/user"),
|
||||
page.waitForResponse("**/api/settings"),
|
||||
page.getByRole("button", { name: "Load" }).click(),
|
||||
]);
|
||||
```
|
||||
|
||||
### Data/Parallelism-Driven Flakiness
|
||||
|
||||
**Problem: Tests share backend data**
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: All workers use same user
|
||||
const testUser = { email: "test@example.com", password: "pass123" };
|
||||
|
||||
// ✅ GOOD: Unique data per worker
|
||||
import { test as base } from "@playwright/test";
|
||||
|
||||
export const test = base.extend<
|
||||
{},
|
||||
{ testUser: { email: string; id: string } }
|
||||
>({
|
||||
testUser: [
|
||||
async ({}, use, workerInfo) => {
|
||||
const email = `test-${workerInfo.workerIndex}-${Date.now()}@example.com`;
|
||||
const user = await createTestUser(email);
|
||||
await use(user);
|
||||
await deleteTestUser(user.id);
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
**Problem: Shared storageState across workers**
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: All workers share same auth state
|
||||
use: {
|
||||
storageState: '.auth/user.json',
|
||||
}
|
||||
|
||||
// ✅ GOOD: Per-worker auth state
|
||||
export const test = base.extend<{}, { workerStorageState: string }>({
|
||||
workerStorageState: [
|
||||
async ({ browser }, use, workerInfo) => {
|
||||
const id = workerInfo.workerIndex;
|
||||
const fileName = `.auth/user-${id}.json`;
|
||||
|
||||
if (!fs.existsSync(fileName)) {
|
||||
const page = await browser.newPage({ storageState: undefined });
|
||||
await authenticateUser(page, `worker${id}@test.com`);
|
||||
await page.context().storageState({ path: fileName });
|
||||
await page.close();
|
||||
}
|
||||
|
||||
await use(fileName);
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Test-Suite-Driven Flakiness (State Leaks)
|
||||
|
||||
**Problem: Tests affect each other**
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Module-level state persists across tests
|
||||
let sharedPage: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
sharedPage = await browser.newPage(); // Shared across tests!
|
||||
});
|
||||
|
||||
// ✅ GOOD: Use Playwright's default isolation (fresh context per test)
|
||||
test("first test", async ({ page }) => {
|
||||
// Fresh page for this test
|
||||
});
|
||||
|
||||
test("second test", async ({ page }) => {
|
||||
// Fresh page for this test
|
||||
});
|
||||
```
|
||||
|
||||
**Problem: Fixture cleanup not happening**
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: Proper fixture with cleanup
|
||||
export const test = base.extend<{ tempFile: string }>({
|
||||
tempFile: async ({}, use) => {
|
||||
const file = `/tmp/test-${Date.now()}.json`;
|
||||
fs.writeFileSync(file, "{}");
|
||||
|
||||
await use(file);
|
||||
|
||||
// Cleanup always runs, even on failure
|
||||
if (fs.existsSync(file)) {
|
||||
fs.unlinkSync(file);
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## CI-Specific Flakiness
|
||||
|
||||
### Why Tests Fail Only in CI
|
||||
|
||||
| CI Condition | Impact | Solution |
|
||||
| ------------------ | ------------------------------------- | ---------------------------------------------------- |
|
||||
| Slower CPU | Actions complete later than expected | Use auto-waiting, not timeouts |
|
||||
| Cold browser start | No cached assets, slower initial load | Add explicit waits for first navigation |
|
||||
| Headless mode | Different rendering behavior | Test locally in headless mode |
|
||||
| Shared runners | Resource contention | Reduce parallelism or use dedicated runners |
|
||||
| Network latency | API calls slower | Mock external APIs, increase timeouts for real calls |
|
||||
|
||||
### Simulating CI Locally
|
||||
|
||||
```bash
|
||||
# Run headless with CI environment variable
|
||||
CI=true npx playwright test
|
||||
|
||||
# Limit CPU (Linux/Mac)
|
||||
cpulimit -l 50 -- npx playwright test
|
||||
|
||||
# Run in Docker matching CI environment
|
||||
docker run -it --rm \
|
||||
-v $(pwd):/work \
|
||||
-w /work \
|
||||
mcr.microsoft.com/playwright:v1.40.0-jammy \
|
||||
npx playwright test
|
||||
```
|
||||
|
||||
### Consistent Viewport and Scale
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts - Match CI rendering exactly
|
||||
export default defineConfig({
|
||||
use: {
|
||||
viewport: { width: 1280, height: 720 },
|
||||
deviceScaleFactor: 1,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Network Stubbing for External APIs
|
||||
|
||||
```typescript
|
||||
// Eliminate external API flakiness
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Stub unstable third-party APIs
|
||||
await page.route("**/api.analytics.com/**", (route) =>
|
||||
route.fulfill({ body: "" }),
|
||||
);
|
||||
await page.route("**/api.payment-provider.com/**", (route) =>
|
||||
route.fulfill({ json: { status: "ok" } }),
|
||||
);
|
||||
});
|
||||
|
||||
// Test-specific stub
|
||||
test("checkout with payment", async ({ page }) => {
|
||||
await page.route("**/api/payment", (route) =>
|
||||
route.fulfill({ json: { success: true, transactionId: "test-123" } }),
|
||||
);
|
||||
// Test proceeds with deterministic response
|
||||
});
|
||||
```
|
||||
|
||||
## Quarantine and Management
|
||||
|
||||
### Quarantine Pattern
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts - Separate flaky tests
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "stable",
|
||||
testIgnore: ["**/*.flaky.spec.ts"],
|
||||
},
|
||||
{
|
||||
name: "quarantine",
|
||||
testMatch: ["**/*.flaky.spec.ts"],
|
||||
retries: 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Annotation-Based Quarantine
|
||||
|
||||
```typescript
|
||||
// Mark flaky tests with annotations
|
||||
test("intermittent checkout issue", async ({ page }, testInfo) => {
|
||||
testInfo.annotations.push({
|
||||
type: "flaky",
|
||||
description: "Investigating payment API timing - JIRA-1234",
|
||||
});
|
||||
|
||||
// Test implementation
|
||||
});
|
||||
|
||||
// Skip flaky test conditionally
|
||||
test("known CI flaky", async ({ page }) => {
|
||||
test.skip(!!process.env.CI, "Flaky in CI - investigating JIRA-5678");
|
||||
// Test implementation
|
||||
});
|
||||
```
|
||||
|
||||
## Prevention Strategies
|
||||
|
||||
### Test Burn-In
|
||||
|
||||
```bash
|
||||
# Run new tests many times before merging
|
||||
npx playwright test tests/new-feature.spec.ts --repeat-each=50
|
||||
|
||||
# Run in parallel to expose race conditions
|
||||
npx playwright test tests/new-feature.spec.ts --repeat-each=20 --workers=4
|
||||
```
|
||||
|
||||
### Isolation Checklist
|
||||
|
||||
```typescript
|
||||
// ✅ Each test should be self-contained
|
||||
test.describe("User profile", () => {
|
||||
test("can update name", async ({ page, testUser }) => {
|
||||
// Uses unique testUser fixture
|
||||
// No dependency on other tests
|
||||
// Cleanup handled by fixture
|
||||
});
|
||||
|
||||
test("can update email", async ({ page, testUser }) => {
|
||||
// Independent of "can update name"
|
||||
// Own testUser, own state
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Defensive Assertions
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Single point of failure
|
||||
await expect(page.locator(".items")).toHaveCount(5);
|
||||
|
||||
// ✅ GOOD: Progressive assertions that help diagnose
|
||||
await expect(page.locator(".items-container")).toBeVisible();
|
||||
await expect(page.locator(".loading")).not.toBeVisible();
|
||||
await expect(page.locator(".items")).toHaveCount(5);
|
||||
```
|
||||
|
||||
### Retry Budget
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts - Limit retries to avoid masking issues
|
||||
export default defineConfig({
|
||||
retries: process.env.CI ? 2 : 0, // Only retry in CI
|
||||
expect: {
|
||||
timeout: 10000, // Reasonable assertion timeout
|
||||
},
|
||||
timeout: 60000, // Test timeout
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ----------------------------------------- | ----------------------------------- | ---------------------------------------------- |
|
||||
| `waitForTimeout()` as primary wait | Arbitrary, hides real timing issues | Use auto-waiting assertions |
|
||||
| Increasing global timeout to "fix" flakes | Masks root cause, slows all tests | Find and fix actual timing issue |
|
||||
| Retrying until pass | Hides systemic problems | Fix root cause, use retries for diagnosis only |
|
||||
| Shared test data across workers | Race conditions, collisions | Isolate data per worker |
|
||||
| Testing real external APIs | Network variability | Mock external dependencies |
|
||||
| Module-level mutable state | Leaks between tests | Use fixtures with proper cleanup |
|
||||
| Ignoring flaky tests | Problem compounds over time | Quarantine and track for fixing |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Debugging**: See [debugging.md](debugging.md) for trace viewer and inspector
|
||||
- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for worker-scoped isolation
|
||||
- **Performance**: See [performance.md](../infrastructure-ci-cd/performance.md) for parallel execution patterns
|
||||
- **Assertions**: See [assertions-waiting.md](../core/assertions-waiting.md) for auto-waiting patterns
|
||||
- **Global Setup**: See [global-setup.md](../core/global-setup.md) for setup vs fixtures decision
|
||||
@@ -0,0 +1,469 @@
|
||||
# Next.js Testing Patterns
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Setup](#setup)
|
||||
2. [App Router Patterns](#app-router-patterns)
|
||||
3. [Pages Router Patterns](#pages-router-patterns)
|
||||
4. [Dynamic Routes](#dynamic-routes)
|
||||
5. [API Routes](#api-routes)
|
||||
6. [Middleware Testing](#middleware-testing)
|
||||
7. [Hydration Testing](#hydration-testing)
|
||||
8. [next/image Testing](#nextimage-testing)
|
||||
9. [NextAuth.js Authentication](#nextauthjs-authentication)
|
||||
10. [Tips](#tips)
|
||||
11. [Anti-Patterns](#anti-patterns)
|
||||
12. [Related](#related)
|
||||
|
||||
> **When to use**: Testing Next.js applications with App Router, Pages Router, API routes, middleware, SSR, dynamic routes, and server components.
|
||||
> **Prerequisites**: [configuration.md](../core/configuration.md), [locators.md](../core/locators.md)
|
||||
|
||||
## Setup
|
||||
|
||||
### Configuration with webServer
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? '50%' : undefined,
|
||||
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'mobile', use: { ...devices['iPhone 14'] } },
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: process.env.CI
|
||||
? 'npm run build && npm run start'
|
||||
: 'npm run dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
NODE_ENV: process.env.CI ? 'production' : 'test',
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Next.js loads `.env.test` when `NODE_ENV=test`:
|
||||
|
||||
```bash
|
||||
# .env.test (commit this)
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3000/api
|
||||
DATABASE_URL=postgresql://localhost:5432/test_db
|
||||
|
||||
# .env.test.local (gitignored)
|
||||
NEXTAUTH_SECRET=test-secret-local
|
||||
```
|
||||
|
||||
## App Router Patterns
|
||||
|
||||
### Server Component Content
|
||||
|
||||
```typescript
|
||||
test('renders server component content', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Welcome', level: 1 })).toBeVisible();
|
||||
await expect(page.getByRole('navigation', { name: 'Main' })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Loading States with Streaming
|
||||
|
||||
```typescript
|
||||
test('loading state during data streaming', async ({ page }) => {
|
||||
await page.route('**/api/stats', async (route) => {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.goto('/dashboard');
|
||||
|
||||
await expect(page.getByRole('progressbar')).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||||
await expect(page.getByRole('progressbar')).toBeHidden();
|
||||
});
|
||||
```
|
||||
|
||||
### Nested Layouts
|
||||
|
||||
```typescript
|
||||
test('layouts persist across navigation', async ({ page }) => {
|
||||
await page.goto('/dashboard/analytics');
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Dashboard' });
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
await sidebar.getByRole('link', { name: 'Settings' }).click();
|
||||
await page.waitForURL('/dashboard/settings');
|
||||
|
||||
await expect(sidebar).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Pages Router Patterns
|
||||
|
||||
### SSR with getServerSideProps
|
||||
|
||||
```typescript
|
||||
test('page with getServerSideProps renders data', async ({ page }) => {
|
||||
await page.goto('/blog');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Blog', level: 1 })).toBeVisible();
|
||||
await expect(page.getByRole('article')).toHaveCount(10);
|
||||
await expect(page.getByRole('article').first()).toContainText(/\w+/);
|
||||
});
|
||||
```
|
||||
|
||||
### Static Generation with getStaticProps
|
||||
|
||||
```typescript
|
||||
test('static page shows pre-rendered content', async ({ page }) => {
|
||||
await page.goto('/about');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'About Us' })).toBeVisible();
|
||||
await expect(page.getByText('Founded in 2020')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Dynamic Routes
|
||||
|
||||
### Slug Parameters
|
||||
|
||||
```typescript
|
||||
test('dynamic [slug] renders correct content', async ({ page }) => {
|
||||
await page.goto('/blog/testing-guide');
|
||||
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText('Testing Guide');
|
||||
await expect(page.getByText('Page not found')).toBeHidden();
|
||||
});
|
||||
|
||||
test('non-existent slug shows 404', async ({ page }) => {
|
||||
const response = await page.goto('/blog/nonexistent-post');
|
||||
|
||||
expect(response?.status()).toBe(404);
|
||||
await expect(page.getByRole('heading', { name: '404' })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Catch-All Routes
|
||||
|
||||
```typescript
|
||||
test('catch-all handles nested paths', async ({ page }) => {
|
||||
await page.goto('/docs/getting-started/installation');
|
||||
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
|
||||
|
||||
await page.goto('/docs/api/configuration');
|
||||
await expect(page.getByRole('heading', { name: 'Configuration' })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Query Parameters
|
||||
|
||||
```typescript
|
||||
test('query parameters filter content', async ({ page }) => {
|
||||
await page.goto('/products?category=electronics&sort=price-asc');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Electronics' })).toBeVisible();
|
||||
|
||||
const prices = await page.getByTestId('product-price').allTextContents();
|
||||
const numericPrices = prices.map((p) => parseFloat(p.replace('$', '')));
|
||||
expect(numericPrices).toEqual([...numericPrices].sort((a, b) => a - b));
|
||||
});
|
||||
```
|
||||
|
||||
## API Routes
|
||||
|
||||
### Direct API Testing
|
||||
|
||||
```typescript
|
||||
test('GET /api/products returns list', async ({ request }) => {
|
||||
const response = await request.get('/api/products');
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const body = await response.json();
|
||||
expect(body.products).toBeInstanceOf(Array);
|
||||
expect(body.products[0]).toHaveProperty('id');
|
||||
expect(body.products[0]).toHaveProperty('name');
|
||||
});
|
||||
|
||||
test('POST /api/products creates item', async ({ request }) => {
|
||||
const response = await request.post('/api/products', {
|
||||
data: { name: 'Test Product', price: 29.99 },
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(201);
|
||||
const body = await response.json();
|
||||
expect(body.product.name).toBe('Test Product');
|
||||
});
|
||||
|
||||
test('POST /api/products validates fields', async ({ request }) => {
|
||||
const response = await request.post('/api/products', {
|
||||
data: { name: '' },
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(400);
|
||||
const body = await response.json();
|
||||
expect(body.error).toContainEqual(expect.objectContaining({ field: 'price' }));
|
||||
});
|
||||
```
|
||||
|
||||
### API Through UI
|
||||
|
||||
```typescript
|
||||
test('form submission calls API', async ({ page }) => {
|
||||
await page.goto('/products/new');
|
||||
|
||||
await page.getByLabel('Product name').fill('Widget');
|
||||
await page.getByLabel('Price').fill('19.99');
|
||||
await page.getByRole('button', { name: 'Create product' }).click();
|
||||
|
||||
await expect(page.getByText('Product created successfully')).toBeVisible();
|
||||
await page.waitForURL('/products/**');
|
||||
});
|
||||
```
|
||||
|
||||
## Middleware Testing
|
||||
|
||||
### Auth Redirects
|
||||
|
||||
```typescript
|
||||
test('unauthenticated user redirected to login', async ({ page }) => {
|
||||
await page.goto('/dashboard');
|
||||
|
||||
expect(page.url()).toContain('/login');
|
||||
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('redirect preserves return URL', async ({ page }) => {
|
||||
await page.goto('/dashboard/settings');
|
||||
|
||||
const url = new URL(page.url());
|
||||
expect(url.pathname).toBe('/login');
|
||||
expect(url.searchParams.get('callbackUrl') || url.searchParams.get('returnTo'))
|
||||
.toContain('/dashboard/settings');
|
||||
});
|
||||
```
|
||||
|
||||
### Security Headers
|
||||
|
||||
```typescript
|
||||
test('middleware sets security headers', async ({ page }) => {
|
||||
const response = await page.goto('/');
|
||||
|
||||
const headers = response!.headers();
|
||||
expect(headers['x-frame-options']).toBe('DENY');
|
||||
expect(headers['x-content-type-options']).toBe('nosniff');
|
||||
});
|
||||
```
|
||||
|
||||
### Locale Rewrites
|
||||
|
||||
```typescript
|
||||
test('middleware rewrites based on locale', async ({ page, context }) => {
|
||||
await context.setExtraHTTPHeaders({
|
||||
'Accept-Language': 'fr-FR,fr;q=0.9',
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByText('Bienvenue')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Hydration Testing
|
||||
|
||||
### Console Error Detection
|
||||
|
||||
```typescript
|
||||
test('no hydration errors in console', async ({ page }) => {
|
||||
const consoleErrors: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
consoleErrors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: 'Get started' }).click();
|
||||
|
||||
const hydrationErrors = consoleErrors.filter(
|
||||
(e) =>
|
||||
e.includes('Hydration') ||
|
||||
e.includes('hydration') ||
|
||||
e.includes('did not match')
|
||||
);
|
||||
expect(hydrationErrors).toEqual([]);
|
||||
});
|
||||
```
|
||||
|
||||
### Interactive Elements After Hydration
|
||||
|
||||
```typescript
|
||||
test('interactive elements work after hydration', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
const counter = page.getByTestId('counter-value');
|
||||
await expect(counter).toHaveText('0');
|
||||
|
||||
await page.getByRole('button', { name: 'Increment' }).click();
|
||||
await expect(counter).toHaveText('1');
|
||||
});
|
||||
```
|
||||
|
||||
## next/image Testing
|
||||
|
||||
```typescript
|
||||
test('hero image loads with srcset', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
const heroImage = page.getByRole('img', { name: 'Hero banner' });
|
||||
await expect(heroImage).toBeVisible();
|
||||
|
||||
const srcset = await heroImage.getAttribute('srcset');
|
||||
expect(srcset).toBeTruthy();
|
||||
expect(srcset).toContain('w=');
|
||||
|
||||
const loading = await heroImage.getAttribute('loading');
|
||||
expect(loading).not.toBe('lazy');
|
||||
});
|
||||
|
||||
test('offscreen images lazy load', async ({ page }) => {
|
||||
await page.goto('/gallery');
|
||||
|
||||
const offscreenImage = page.getByRole('img', { name: 'Gallery item 20' });
|
||||
|
||||
await offscreenImage.scrollIntoViewIfNeeded();
|
||||
await expect(offscreenImage).toBeVisible();
|
||||
|
||||
const naturalWidth = await offscreenImage.evaluate(
|
||||
(img: HTMLImageElement) => img.naturalWidth
|
||||
);
|
||||
expect(naturalWidth).toBeGreaterThan(0);
|
||||
});
|
||||
```
|
||||
|
||||
## NextAuth.js Authentication
|
||||
|
||||
### Setup Project
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
|
||||
{
|
||||
name: 'authenticated',
|
||||
use: { storageState: 'playwright/.auth/user.json' },
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
{ name: 'unauthenticated', testMatch: '**/*.unauth.spec.ts' },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Auth Setup
|
||||
|
||||
```typescript
|
||||
// tests/auth.setup.ts
|
||||
import { test as setup, expect } from '@playwright/test';
|
||||
|
||||
const authFile = 'playwright/.auth/user.json';
|
||||
|
||||
setup('authenticate via credentials', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Email').fill('test@example.com');
|
||||
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
|
||||
await page.waitForURL('/dashboard');
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||||
|
||||
await page.context().storageState({ path: authFile });
|
||||
});
|
||||
```
|
||||
|
||||
### Authenticated Tests
|
||||
|
||||
```typescript
|
||||
test('authenticated user sees dashboard', async ({ page }) => {
|
||||
await page.goto('/dashboard');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||||
await expect(page.getByText('test@example.com')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
### Dev Server vs Production Build
|
||||
|
||||
| Scenario | Command | Trade-off |
|
||||
|---|---|---|
|
||||
| Local development | `npm run dev` | Fast iteration, no production behavior |
|
||||
| CI pipeline | `npm run build && npm run start` | Tests real production bundle |
|
||||
|
||||
### Turbopack
|
||||
|
||||
```typescript
|
||||
webServer: {
|
||||
command: process.env.CI
|
||||
? 'npm run build && npm run start'
|
||||
: 'npx next dev --turbopack',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
```
|
||||
|
||||
### Multiple webServer Entries
|
||||
|
||||
```typescript
|
||||
webServer: [
|
||||
{
|
||||
command: 'npm run dev:api',
|
||||
url: 'http://localhost:4000/health',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
{
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
],
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Don't Do This | Problem | Do This Instead |
|
||||
|---|---|---|
|
||||
| `await page.waitForTimeout(3000)` | Arbitrary waits are fragile | `await page.waitForURL('/path')` or `await expect(locator).toBeVisible()` |
|
||||
| Test `getServerSideProps` directly | Depends on req/res context | Navigate to page and verify rendered output |
|
||||
| Mock your own API routes | Hides real API bugs | Let real API handle requests; mock only external services |
|
||||
| `page.goto('http://localhost:3000/path')` | Breaks when port changes | Use `page.goto('/path')` with `baseURL` |
|
||||
| Run `npm run build` locally for every test | Extremely slow | Use `npm run dev` locally with `reuseExistingServer: true` |
|
||||
| Test `next/image` by checking exact URLs | Paths change between dev/prod | Assert on `alt`, visibility, `naturalWidth > 0`, `srcset` |
|
||||
| Test server actions by calling as functions | Server actions need Next.js runtime | Trigger through UI (forms, buttons) |
|
||||
|
||||
## Related
|
||||
|
||||
- [configuration.md](../core/configuration.md) -- Playwright configuration including `webServer`
|
||||
- [authentication.md](../advanced/authentication.md) -- authentication setup and `storageState`
|
||||
- [api-testing.md](../testing-patterns/api-testing.md) -- testing API routes with `request` context
|
||||
- [react.md](react.md) -- React patterns for Next.js client components
|
||||
@@ -0,0 +1,531 @@
|
||||
# React Application Testing
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Patterns](#patterns)
|
||||
2. [Setup](#setup)
|
||||
3. [Framework Tips](#framework-tips)
|
||||
4. [Anti-Patterns](#anti-patterns)
|
||||
5. [Related](#related)
|
||||
|
||||
> **When to use**: Testing React apps built with Vite, Create React App, or custom bundlers. Covers E2E testing, component testing, React Router navigation, form libraries, portals, error boundaries, and context/state verification.
|
||||
> **Prerequisites**: [configuration.md](../core/configuration.md), [locators.md](../core/locators.md)
|
||||
|
||||
## Patterns
|
||||
|
||||
### Testing Context and Global State
|
||||
|
||||
**Use when**: Verifying React context (theme, auth, locale) and state management (Redux, Zustand) produce correct UI changes.
|
||||
**Avoid when**: You want to assert on raw state objects—test the UI, not internal state.
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('theme switching', () => {
|
||||
test('toggle applies dark mode across pages', async ({ page }) => {
|
||||
await page.goto('/preferences');
|
||||
|
||||
const root = page.locator('html');
|
||||
await expect(root).not.toHaveClass(/dark-mode/);
|
||||
|
||||
await page.getByRole('switch', { name: 'Enable dark theme' }).click();
|
||||
await expect(root).toHaveClass(/dark-mode/);
|
||||
|
||||
await page.getByRole('link', { name: 'Dashboard' }).click();
|
||||
await expect(page.locator('html')).toHaveClass(/dark-mode/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('cart state persistence', () => {
|
||||
test('item count updates globally', async ({ page }) => {
|
||||
await page.goto('/catalog');
|
||||
|
||||
const badge = page.getByTestId('cart-badge');
|
||||
|
||||
await page.getByRole('listitem')
|
||||
.filter({ hasText: 'Wireless Headphones' })
|
||||
.getByRole('button', { name: 'Add' })
|
||||
.click();
|
||||
await expect(badge).toHaveText('1');
|
||||
|
||||
await page.getByRole('link', { name: 'Contact' }).click();
|
||||
await expect(badge).toHaveText('1');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('auth state', () => {
|
||||
test('login updates header across components', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Login' })).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: 'Login' }).click();
|
||||
await page.getByLabel('Username').fill('testuser');
|
||||
await page.getByLabel('Password').fill('secret123');
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Login' })).toBeHidden();
|
||||
await expect(page.getByText('testuser')).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### React Router Navigation
|
||||
|
||||
**Use when**: Testing client-side routing with React Router v6+—route transitions, URL parameters, protected routes, browser history.
|
||||
**Avoid when**: Server-side routing (Next.js App Router—see [nextjs.md](nextjs.md)).
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('client routing', () => {
|
||||
test('navigation preserves SPA state', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await page.evaluate(() => {
|
||||
(window as any).__spaMarker = 'active';
|
||||
});
|
||||
|
||||
await page.getByRole('link', { name: 'Inventory' }).click();
|
||||
await page.waitForURL('/inventory');
|
||||
|
||||
const marker = await page.evaluate(() => (window as any).__spaMarker);
|
||||
expect(marker).toBe('active');
|
||||
});
|
||||
|
||||
test('query params filter content', async ({ page }) => {
|
||||
await page.goto('/items?type=books');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Books' })).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: 'Music' }).click();
|
||||
await page.waitForURL('/items?type=music');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Music' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('nested routes render layouts', async ({ page }) => {
|
||||
await page.goto('/account/security');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Security', level: 2 })).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: 'Privacy' }).click();
|
||||
await page.waitForURL('/account/privacy');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Privacy', level: 2 })).toBeVisible();
|
||||
});
|
||||
|
||||
test('history navigation works', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('link', { name: 'Inventory' }).click();
|
||||
await page.waitForURL('/inventory');
|
||||
await page.getByRole('link', { name: 'Help' }).click();
|
||||
await page.waitForURL('/help');
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/inventory/);
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
});
|
||||
|
||||
test('protected route redirects', async ({ page }) => {
|
||||
await page.goto('/admin/users');
|
||||
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
|
||||
test('unknown route shows 404', async ({ page }) => {
|
||||
await page.goto('/nonexistent-path');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Not Found' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Hooks Through UI
|
||||
|
||||
**Use when**: Verifying custom hooks produce correct UI behavior—Playwright cannot call hooks directly.
|
||||
**Avoid when**: Hook logic is pure computation—use unit tests instead.
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('useDebounce via SearchBox', () => {
|
||||
test('batches rapid input', async ({ page }) => {
|
||||
await page.goto('/search');
|
||||
|
||||
const apiCalls: string[] = [];
|
||||
await page.route('**/api/query*', async (route) => {
|
||||
apiCalls.push(route.request().url());
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.getByRole('textbox', { name: 'Search' }).pressSequentially('testing', {
|
||||
delay: 40,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('listitem')).toHaveCount(3);
|
||||
expect(apiCalls.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('usePagination via DataGrid', () => {
|
||||
test('page controls work', async ({ page }) => {
|
||||
await page.goto('/records');
|
||||
|
||||
await expect(page.getByText('Page 1 of 10')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Next' }).click();
|
||||
await expect(page.getByText('Page 2 of 10')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Previous' }).click();
|
||||
await expect(page.getByText('Page 1 of 10')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Previous' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Form Libraries (React Hook Form, Formik)
|
||||
|
||||
**Use when**: Testing forms built with react-hook-form or Formik—Playwright interacts with DOM, form library is transparent.
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('signup form', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/signup');
|
||||
});
|
||||
|
||||
test('validation on empty submit', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Register' }).click();
|
||||
|
||||
await expect(page.getByText('Email required')).toBeVisible();
|
||||
await expect(page.getByText('Password required')).toBeVisible();
|
||||
});
|
||||
|
||||
test('inline validation on blur', async ({ page }) => {
|
||||
const email = page.getByLabel('Email');
|
||||
await email.fill('invalid');
|
||||
await email.blur();
|
||||
|
||||
await expect(page.getByText('Invalid email format')).toBeVisible();
|
||||
});
|
||||
|
||||
test('password strength indicator', async ({ page }) => {
|
||||
const pwd = page.getByLabel('Password', { exact: true });
|
||||
|
||||
await pwd.fill('weak');
|
||||
await expect(page.getByText('Minimum 8 characters')).toHaveClass(/invalid/);
|
||||
|
||||
await pwd.fill('StrongPass1!');
|
||||
await expect(page.getByText('Minimum 8 characters')).toHaveClass(/valid/);
|
||||
});
|
||||
|
||||
test('successful submission redirects', async ({ page }) => {
|
||||
await page.getByLabel('Name').fill('Alice');
|
||||
await page.getByLabel('Email').fill('alice@test.com');
|
||||
await page.getByLabel('Password', { exact: true }).fill('Secure123!');
|
||||
await page.getByLabel('Confirm').fill('Secure123!');
|
||||
await page.getByLabel('Accept terms').check();
|
||||
|
||||
await page.getByRole('button', { name: 'Register' }).click();
|
||||
|
||||
await page.waitForURL('/welcome');
|
||||
await expect(page.getByText('Hello, Alice')).toBeVisible();
|
||||
});
|
||||
|
||||
test('submit button disabled during request', async ({ page }) => {
|
||||
await page.route('**/api/signup', async (route) => {
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
await route.fulfill({ status: 201, json: { id: 1 } });
|
||||
});
|
||||
|
||||
await page.getByLabel('Name').fill('Bob');
|
||||
await page.getByLabel('Email').fill('bob@test.com');
|
||||
await page.getByLabel('Password', { exact: true }).fill('Secure123!');
|
||||
await page.getByLabel('Confirm').fill('Secure123!');
|
||||
await page.getByLabel('Accept terms').check();
|
||||
|
||||
await page.getByRole('button', { name: 'Register' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: /Registering|Loading/ })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Portals (Modals, Tooltips, Dropdowns)
|
||||
|
||||
**Use when**: Testing components rendered via `ReactDOM.createPortal()`—modals, dialogs, tooltips, menus. These render outside parent DOM but Playwright sees the full document.
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('portal components', () => {
|
||||
test('modal interaction', async ({ page }) => {
|
||||
await page.goto('/items');
|
||||
|
||||
await page.getByRole('button', { name: 'Remove' }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'Confirm removal' });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Remove' }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
});
|
||||
|
||||
test('escape closes modal', async ({ page }) => {
|
||||
await page.goto('/items');
|
||||
await page.getByRole('button', { name: 'Remove' }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).toBeHidden();
|
||||
});
|
||||
|
||||
test('tooltip on hover', async ({ page }) => {
|
||||
await page.goto('/panel');
|
||||
|
||||
await page.getByRole('button', { name: 'Help' }).hover();
|
||||
await expect(page.getByRole('tooltip')).toBeVisible();
|
||||
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(page.getByRole('tooltip')).toBeHidden();
|
||||
});
|
||||
|
||||
test('dropdown menu', async ({ page }) => {
|
||||
await page.goto('/panel');
|
||||
|
||||
await page.getByRole('button', { name: 'Actions' }).click();
|
||||
|
||||
const menu = page.getByRole('menu');
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
await menu.getByRole('menuitem', { name: 'Rename' }).click();
|
||||
await expect(menu).toBeHidden();
|
||||
});
|
||||
|
||||
test('toast auto-dismisses', async ({ page }) => {
|
||||
await page.goto('/preferences');
|
||||
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await expect(page.getByText('Preferences saved')).toBeVisible();
|
||||
|
||||
await expect(page.getByText('Preferences saved')).toBeHidden({ timeout: 8000 });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Error Boundaries
|
||||
|
||||
**Use when**: Verifying error boundaries catch rendering errors and show fallback UI.
|
||||
**Avoid when**: Testing error handling in event handlers or async code—error boundaries only catch render errors.
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('error boundary', () => {
|
||||
test('shows fallback on crash', async ({ page }) => {
|
||||
await page.route('**/api/widgets', (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
json: { widgets: null },
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/panel');
|
||||
|
||||
await expect(page.getByText('Something went wrong')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
|
||||
await expect(page.getByRole('navigation')).toBeVisible();
|
||||
});
|
||||
|
||||
test('retry recovers component', async ({ page }) => {
|
||||
let calls = 0;
|
||||
await page.route('**/api/widgets', (route) => {
|
||||
calls++;
|
||||
if (calls === 1) {
|
||||
route.fulfill({ status: 200, json: { widgets: null } });
|
||||
} else {
|
||||
route.fulfill({ status: 200, json: { widgets: [{ id: 1, name: 'Chart' }] } });
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/panel');
|
||||
|
||||
await expect(page.getByText('Something went wrong')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Retry' }).click();
|
||||
|
||||
await expect(page.getByText('Something went wrong')).toBeHidden();
|
||||
await expect(page.getByText('Chart')).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Component Testing (Experimental)
|
||||
|
||||
**Use when**: Testing complex interactive components in isolation—data tables, form wizards, rich editors. Needs real browser but not full app.
|
||||
**Avoid when**: Component depends heavily on backend data or routing—use E2E instead.
|
||||
|
||||
```typescript
|
||||
// playwright-ct.config.ts
|
||||
import { defineConfig, devices } from '@playwright/experimental-ct-react';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/components',
|
||||
testMatch: '**/*.ct.ts',
|
||||
use: {
|
||||
trace: 'on-first-retry',
|
||||
ctPort: 3100,
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/components/Stepper.ct.ts
|
||||
import { test, expect } from '@playwright/experimental-ct-react';
|
||||
import Stepper from '../../src/components/Stepper';
|
||||
|
||||
test('increments on click', async ({ mount }) => {
|
||||
const component = await mount(<Stepper initial={0} />);
|
||||
|
||||
await expect(component.getByText('Value: 0')).toBeVisible();
|
||||
await component.getByRole('button', { name: '+' }).click();
|
||||
await expect(component.getByText('Value: 1')).toBeVisible();
|
||||
});
|
||||
|
||||
test('fires onChange callback', async ({ mount }) => {
|
||||
const values: number[] = [];
|
||||
const component = await mount(
|
||||
<Stepper initial={0} onChange={(v) => values.push(v)} />
|
||||
);
|
||||
|
||||
await component.getByRole('button', { name: '+' }).click();
|
||||
await component.getByRole('button', { name: '+' }).click();
|
||||
|
||||
expect(values).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
test('respects min boundary', async ({ mount }) => {
|
||||
const component = await mount(<Stepper initial={0} min={0} />);
|
||||
|
||||
await expect(component.getByRole('button', { name: '-' })).toBeDisabled();
|
||||
});
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
### E2E Config (Vite)
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? '50%' : undefined,
|
||||
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
||||
{ name: 'mobile', use: { ...devices['iPhone 14'] } },
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: process.env.CI ? 'npm run build && npx vite preview --port 5173' : 'npm run dev',
|
||||
url: 'http://localhost:5173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### CRA vs Vite Differences
|
||||
|
||||
| Aspect | Create React App | Vite |
|
||||
|---|---|---|
|
||||
| Default port | `3000` | `5173` |
|
||||
| Build output | `build/` | `dist/` |
|
||||
| Serve production | `npx serve -s build -l 3000` | `npx vite preview --port 5173` |
|
||||
| Env var prefix | `REACT_APP_*` | `VITE_*` |
|
||||
|
||||
## Framework Tips
|
||||
|
||||
### Strict Mode Double Effects
|
||||
|
||||
React Strict Mode runs effects twice in development. Tests should be resilient:
|
||||
|
||||
- Don't assert exact API call counts in dev mode
|
||||
- Run against production build for call count assertions, or account for double invocations
|
||||
|
||||
### Suspense and Lazy Components
|
||||
|
||||
```typescript
|
||||
test('lazy route loads content', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await page.getByRole('link', { name: 'Analytics' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Analytics' })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Detecting Memory Leaks
|
||||
|
||||
```typescript
|
||||
test('no unmounted state warnings', async ({ page }) => {
|
||||
const warnings: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'warning' && msg.text().includes('unmounted')) {
|
||||
warnings.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/panel');
|
||||
await page.getByRole('link', { name: 'Settings' }).click();
|
||||
await page.goBack();
|
||||
await page.getByRole('link', { name: 'Profile' }).click();
|
||||
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Don't | Problem | Do Instead |
|
||||
|---|---|---|
|
||||
| `page.evaluate(() => store.getState())` | Couples tests to implementation | Assert on UI: `expect(badge).toHaveText('3')` |
|
||||
| Import components in E2E tests | E2E runs in Node, not browser | Use `@playwright/experimental-ct-react` for components |
|
||||
| `page.waitForTimeout(500)` after state changes | Timing varies across machines | `expect(locator).toHaveText('value')` auto-retries |
|
||||
| `page.locator('.MuiButton-root')` | Class names change between versions | `page.getByRole('button', { name: 'Submit' })` |
|
||||
| Test every component with CT | Overhead for simple components | CT for complex widgets, unit tests for logic, E2E for flows |
|
||||
| Skip keyboard navigation tests | Accessibility regressions common | Test Tab, Enter, Escape, Arrow interactions |
|
||||
| Assert on `__REACT_FIBER__` internals | Not stable across versions | Only interact with rendered DOM |
|
||||
|
||||
## Related
|
||||
|
||||
- [locators.md](../core/locators.md) — locator strategies for any React component library
|
||||
- [assertions-waiting.md](../core/assertions-waiting.md) — auto-waiting for React state changes
|
||||
- [forms-validation.md](../testing-patterns/forms-validation.md) — form testing patterns
|
||||
- [component-testing.md](../testing-patterns/component-testing.md) — in-depth component testing
|
||||
- [test-architecture.md](../architecture/test-architecture.md) — E2E vs component vs unit decisions
|
||||
- [nextjs.md](nextjs.md) — Next.js-specific patterns for SSR
|
||||
@@ -0,0 +1,468 @@
|
||||
# CI/CD Integration
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [GitHub Actions](#github-actions)
|
||||
2. [Docker](#docker)
|
||||
3. [Reporting](#reporting)
|
||||
4. [Sharding](#sharding)
|
||||
5. [Environment Management](#environment-management)
|
||||
6. [Caching](#caching)
|
||||
|
||||
## GitHub Actions
|
||||
|
||||
### Basic Workflow
|
||||
|
||||
```yaml
|
||||
# .github/workflows/playwright.yml
|
||||
name: Playwright Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
|
||||
- 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
|
||||
```
|
||||
|
||||
### With Sharding
|
||||
|
||||
```yaml
|
||||
name: Playwright Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shardIndex: [1, 2, 3, 4]
|
||||
shardTotal: [4]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
|
||||
- name: Upload blob report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: blob-report-${{ matrix.shardIndex }}
|
||||
path: blob-report
|
||||
retention-days: 1
|
||||
|
||||
merge-reports:
|
||||
if: ${{ !cancelled() }}
|
||||
needs: [test]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Download blob reports
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: all-blob-reports
|
||||
pattern: blob-report-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Merge reports
|
||||
run: npx playwright merge-reports --reporter html ./all-blob-reports
|
||||
|
||||
- name: Upload HTML report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: html-report
|
||||
path: playwright-report
|
||||
retention-days: 14
|
||||
```
|
||||
|
||||
### With Container
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
# Use latest or more appropriate playwright version (match package.json)
|
||||
image: mcr.microsoft.com/playwright:v1.40.0-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npx playwright test
|
||||
env:
|
||||
HOME: /root
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
### Dockerfile
|
||||
|
||||
```dockerfile
|
||||
FROM mcr.microsoft.com/playwright:v1.40.0-jammy
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["npx", "playwright", "test"]
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
playwright:
|
||||
build: .
|
||||
volumes:
|
||||
- ./playwright-report:/app/playwright-report
|
||||
- ./test-results:/app/test-results
|
||||
environment:
|
||||
- CI=true
|
||||
- BASE_URL=http://app:3000
|
||||
depends_on:
|
||||
- app
|
||||
|
||||
app:
|
||||
build: ./app
|
||||
ports:
|
||||
- "3000:3000"
|
||||
```
|
||||
|
||||
### Run with Docker
|
||||
|
||||
```bash
|
||||
# Build and run
|
||||
docker build -t playwright-tests .
|
||||
docker run --rm -v $(pwd)/playwright-report:/app/playwright-report playwright-tests
|
||||
|
||||
# With docker-compose
|
||||
docker-compose run --rm playwright
|
||||
```
|
||||
|
||||
## Reporting
|
||||
|
||||
### Configuration
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
reporter: [
|
||||
// Always generate
|
||||
["html", { outputFolder: "playwright-report" }],
|
||||
|
||||
// Console output
|
||||
["list"],
|
||||
|
||||
// CI-friendly
|
||||
["github"], // GitHub Actions annotations
|
||||
|
||||
// JUnit for CI integration
|
||||
["junit", { outputFile: "results.xml" }],
|
||||
|
||||
// JSON for custom processing
|
||||
["json", { outputFile: "results.json" }],
|
||||
|
||||
// Blob for merging shards
|
||||
["blob", { outputDir: "blob-report" }],
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### CI-Specific Reporter
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
reporter: process.env.CI
|
||||
? [["github"], ["blob"], ["html"]]
|
||||
: [["list"], ["html"]],
|
||||
});
|
||||
```
|
||||
|
||||
## Sharding
|
||||
|
||||
### Command Line
|
||||
|
||||
```bash
|
||||
# Split into 4 shards, run shard 1
|
||||
npx playwright test --shard=1/4
|
||||
|
||||
# Run shard 2
|
||||
npx playwright test --shard=2/4
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
// Evenly distribute tests across shards
|
||||
fullyParallel: true,
|
||||
|
||||
// For blob reporter to merge later
|
||||
reporter: process.env.CI ? [["blob"]] : [["html"]],
|
||||
});
|
||||
```
|
||||
|
||||
### Merge Sharded Reports
|
||||
|
||||
```bash
|
||||
# After all shards complete, merge blob reports
|
||||
npx playwright merge-reports --reporter html ./all-blob-reports
|
||||
```
|
||||
|
||||
## Environment Management
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig } from "@playwright/test";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
// Load env file based on environment
|
||||
dotenv.config({ path: `.env.${process.env.NODE_ENV || "development"}` });
|
||||
|
||||
export default defineConfig({
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || "http://localhost:3000",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Multiple Environments
|
||||
|
||||
```yaml
|
||||
# .github/workflows/playwright.yml
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
matrix:
|
||||
environment: [staging, production]
|
||||
steps:
|
||||
- name: Run tests
|
||||
run: npx playwright test
|
||||
env:
|
||||
BASE_URL: ${{ matrix.environment == 'staging' && 'https://staging.example.com' || 'https://example.com' }}
|
||||
TEST_USER: ${{ secrets[format('TEST_USER_{0}', matrix.environment)] }}
|
||||
```
|
||||
|
||||
### Secrets Management
|
||||
|
||||
```yaml
|
||||
# GitHub Actions secrets
|
||||
- name: Run tests
|
||||
run: npx playwright test
|
||||
env:
|
||||
TEST_EMAIL: ${{ secrets.TEST_EMAIL }}
|
||||
TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests use environment variables
|
||||
test("login", async ({ page }) => {
|
||||
await page.getByLabel("Email").fill(process.env.TEST_EMAIL!);
|
||||
await page.getByLabel("Password").fill(process.env.TEST_PASSWORD!);
|
||||
});
|
||||
```
|
||||
|
||||
## Caching
|
||||
|
||||
### Cache Playwright Browsers
|
||||
|
||||
```yaml
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Install system deps only
|
||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
run: npx playwright install-deps
|
||||
```
|
||||
|
||||
### Cache Node Modules
|
||||
|
||||
```yaml
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
```
|
||||
|
||||
## Tag-Based Test Filtering
|
||||
|
||||
### Run Specific Tags in CI
|
||||
|
||||
```yaml
|
||||
# Run smoke tests on PR
|
||||
- name: Run smoke tests
|
||||
run: npx playwright test --grep @smoke
|
||||
|
||||
# Run full regression nightly
|
||||
- name: Run regression
|
||||
run: npx playwright test --grep @regression
|
||||
|
||||
# Exclude flaky tests
|
||||
- name: Run stable tests
|
||||
run: npx playwright test --grep-invert @flaky
|
||||
```
|
||||
|
||||
### PR vs Nightly Strategy
|
||||
|
||||
```yaml
|
||||
# .github/workflows/pr.yml - Fast feedback
|
||||
- name: Run critical tests
|
||||
run: npx playwright test --grep "@smoke|@critical"
|
||||
|
||||
# .github/workflows/nightly.yml - Full coverage
|
||||
- name: Run all tests
|
||||
run: npx playwright test --grep-invert @flaky
|
||||
```
|
||||
|
||||
### Tag Filtering in Config
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
grep: process.env.CI ? /@smoke|@critical/ : undefined,
|
||||
grepInvert: process.env.CI ? /@flaky/ : undefined,
|
||||
});
|
||||
```
|
||||
|
||||
### Project-Based Tag Filtering
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "smoke",
|
||||
grep: /@smoke/,
|
||||
},
|
||||
{
|
||||
name: "regression",
|
||||
grepInvert: /@smoke/,
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
| Practice | Benefit |
|
||||
| ----------------------------- | ------------------------- |
|
||||
| Use `npm ci` | Deterministic installs |
|
||||
| Run headless in CI | Faster, no display needed |
|
||||
| Set retries in CI only | Handle flakiness |
|
||||
| Upload artifacts on failure | Debug failures |
|
||||
| Use sharding for large suites | Faster execution |
|
||||
| Cache browsers | Faster setup |
|
||||
| Use blob reporter for shards | Merge reports correctly |
|
||||
| Use tags for PR vs nightly | Fast feedback + coverage |
|
||||
| Exclude @flaky in CI | Stable pipeline |
|
||||
|
||||
## CI Configuration Reference
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts - CI optimized
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: process.env.CI
|
||||
? [["github"], ["blob"], ["html"]]
|
||||
: [["list"], ["html"]],
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || "http://localhost:3000",
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
video: "on-first-retry",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Related References
|
||||
|
||||
- **Test tags**: See [test-tags.md](../core/test-tags.md) for tagging and filtering patterns
|
||||
- **Performance optimization**: See [performance.md](performance.md) for sharding and parallelization
|
||||
- **Debugging CI failures**: See [debugging.md](../debugging/debugging.md) for troubleshooting
|
||||
- **Test reporting**: See [debugging.md](../debugging/debugging.md) for trace viewer usage
|
||||
@@ -0,0 +1,283 @@
|
||||
# Container-Based Testing
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Patterns](#patterns)
|
||||
2. [Decision Guide](#decision-guide)
|
||||
3. [Anti-Patterns](#anti-patterns)
|
||||
4. [Troubleshooting](#troubleshooting)
|
||||
|
||||
> **When to use**: Running tests in containers for reproducible environments, CI pipelines, or consistent browser versions across team machines.
|
||||
|
||||
## Patterns
|
||||
|
||||
### Official Image Usage
|
||||
|
||||
Run tests without building a custom image:
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-v $(pwd):/app \
|
||||
-w /app \
|
||||
-e CI=true \
|
||||
-e BASE_URL=http://host.docker.internal:3000 \
|
||||
mcr.microsoft.com/playwright:v1.48.0-noble \
|
||||
bash -c "npm ci && npx playwright test"
|
||||
```
|
||||
|
||||
Extract reports with bind mounts:
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-v $(pwd):/app \
|
||||
-v $(pwd)/playwright-report:/app/playwright-report \
|
||||
-v $(pwd)/test-results:/app/test-results \
|
||||
-w /app \
|
||||
mcr.microsoft.com/playwright:v1.48.0-noble \
|
||||
bash -c "npm ci && npx playwright test"
|
||||
```
|
||||
|
||||
### Custom Dockerfile
|
||||
|
||||
Build a custom image when you need additional dependencies or pre-installed packages:
|
||||
|
||||
```dockerfile
|
||||
FROM mcr.microsoft.com/playwright:v1.48.0-noble
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["npx", "playwright", "test"]
|
||||
```
|
||||
|
||||
Chromium-only slim image:
|
||||
|
||||
```dockerfile
|
||||
FROM node:latest-slim
|
||||
|
||||
RUN npx playwright install --with-deps chromium
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
|
||||
CMD ["npx", "playwright", "test", "--project=chromium"]
|
||||
```
|
||||
|
||||
### Docker Compose Stack
|
||||
|
||||
Full application stack with database, cache, and test runner:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=test
|
||||
- DATABASE_URL=postgresql://postgres:postgres@db:5432/test
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
db:
|
||||
image: postgres:latest-alpine
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: test
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
tmpfs:
|
||||
- /var/lib/postgresql/data
|
||||
|
||||
e2e:
|
||||
image: mcr.microsoft.com/playwright:v1.48.0-noble
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
environment:
|
||||
- CI=true
|
||||
- BASE_URL=http://app:3000
|
||||
depends_on:
|
||||
- app
|
||||
command: bash -c "npm ci && npx playwright test"
|
||||
profiles:
|
||||
- test
|
||||
```
|
||||
|
||||
Run commands:
|
||||
|
||||
```bash
|
||||
docker compose --profile test up --abort-on-container-exit --exit-code-from e2e
|
||||
|
||||
docker compose --profile test down -v
|
||||
```
|
||||
|
||||
### CI Container Jobs
|
||||
|
||||
**GitHub Actions:**
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.48.0-noble
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: npm ci
|
||||
- run: npx playwright test
|
||||
env:
|
||||
HOME: /root
|
||||
```
|
||||
|
||||
**GitLab CI:**
|
||||
|
||||
```yaml
|
||||
test:
|
||||
image: mcr.microsoft.com/playwright:v1.48.0-noble
|
||||
script:
|
||||
- npm ci
|
||||
- npx playwright test
|
||||
```
|
||||
|
||||
**Jenkins:**
|
||||
|
||||
```groovy
|
||||
pipeline {
|
||||
agent {
|
||||
docker {
|
||||
image 'mcr.microsoft.com/playwright:v1.48.0-noble'
|
||||
args '-u root'
|
||||
}
|
||||
}
|
||||
stages {
|
||||
stage('Test') {
|
||||
steps {
|
||||
sh 'npm ci'
|
||||
sh 'npx playwright test'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dev Container Setup
|
||||
|
||||
VS Code Dev Container or GitHub Codespaces configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Playwright Dev",
|
||||
"image": "mcr.microsoft.com/playwright:v1.48.0-noble",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:latest": {
|
||||
"version": "20"
|
||||
}
|
||||
},
|
||||
"postCreateCommand": "npm ci",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": ["ms-playwright.playwright"]
|
||||
}
|
||||
},
|
||||
"forwardPorts": [3000, 9323],
|
||||
"remoteUser": "root"
|
||||
}
|
||||
```
|
||||
|
||||
## Decision Guide
|
||||
|
||||
| Scenario | Approach |
|
||||
|---|---|
|
||||
| Simple CI pipeline | Official image as CI container |
|
||||
| Tests need database + cache | Docker Compose with app, db, e2e services |
|
||||
| Team needs identical environments | Dev Container or custom Dockerfile |
|
||||
| Only testing Chromium | Slim image with `install --with-deps chromium` |
|
||||
| Cross-browser testing | Official image (all browsers pre-installed) |
|
||||
| Local development | Run directly on host for faster iteration |
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
|---|---|---|
|
||||
| Installing browsers at runtime | Wastes 60-90 seconds per run | Use official image or bake browsers into custom image |
|
||||
| Running as non-root without sandbox config | Chromium sandbox permission errors | Run as root or disable sandbox |
|
||||
| Bind-mounting `node_modules` from host | Platform-specific binary crashes | Use anonymous volume: `-v /app/node_modules` |
|
||||
| No health checks on dependent services | Tests start before database ready | Add `healthcheck` with `depends_on: condition: service_healthy` |
|
||||
| Building application inside Playwright container | Large image, slow builds | Separate app and e2e containers |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "browserType.launch: Executable doesn't exist"
|
||||
|
||||
Playwright version mismatch with Docker image. Ensure `@playwright/test` version matches image tag:
|
||||
|
||||
```bash
|
||||
npm ls @playwright/test
|
||||
docker pull mcr.microsoft.com/playwright:v<matching-version>-noble
|
||||
```
|
||||
|
||||
### "net::ERR_CONNECTION_REFUSED" in docker-compose
|
||||
|
||||
Tests trying to reach `localhost` instead of service name. Configure `baseURL`:
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:3000',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```yaml
|
||||
e2e:
|
||||
environment:
|
||||
- BASE_URL=http://app:3000
|
||||
```
|
||||
|
||||
### Permission denied on mounted volumes
|
||||
|
||||
Match user IDs or run as root:
|
||||
|
||||
```bash
|
||||
docker run --rm -u $(id -u):$(id -g) \
|
||||
-v $(pwd):/app -w /app \
|
||||
mcr.microsoft.com/playwright:v1.48.0-noble \
|
||||
npx playwright test
|
||||
```
|
||||
|
||||
### Slow container tests on macOS/Windows
|
||||
|
||||
Docker Desktop I/O overhead. Copy files instead of mounting:
|
||||
|
||||
```dockerfile
|
||||
FROM mcr.microsoft.com/playwright:v1.48.0-noble
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN npm ci
|
||||
CMD ["npx", "playwright", "test"]
|
||||
```
|
||||
|
||||
Or use delegated mount:
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-v $(pwd):/app:delegated \
|
||||
-w /app \
|
||||
mcr.microsoft.com/playwright:v1.48.0-noble \
|
||||
bash -c "npm ci && npx playwright test"
|
||||
```
|
||||
@@ -0,0 +1,546 @@
|
||||
# GitHub Actions for Playwright
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [CLI Commands](#cli-commands)
|
||||
2. [Workflow Patterns](#workflow-patterns)
|
||||
3. [Scenario Guide](#scenario-guide)
|
||||
4. [Common Mistakes](#common-mistakes)
|
||||
5. [Troubleshooting](#troubleshooting)
|
||||
6. [Related](#related)
|
||||
|
||||
> **When to use**: Automating Playwright tests on pull requests, main branch merges, or scheduled runs.
|
||||
|
||||
## CLI Commands
|
||||
|
||||
```bash
|
||||
npx playwright install --with-deps # browsers + OS dependencies
|
||||
npx playwright test --shard=1/4 # run shard 1 of 4
|
||||
npx playwright test --reporter=github # PR annotations
|
||||
npx playwright merge-reports ./blob-report # combine shard reports
|
||||
```
|
||||
|
||||
## Workflow Patterns
|
||||
|
||||
### Basic Workflow
|
||||
|
||||
**Use when**: Starting a new project or running a small test suite.
|
||||
|
||||
```yaml
|
||||
# .github/workflows/e2e.yml
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: e2e-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CI: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Cache browsers
|
||||
id: browser-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Install browsers
|
||||
if: steps.browser-cache.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Install OS dependencies
|
||||
if: steps.browser-cache.outputs.cache-hit == 'true'
|
||||
run: npx playwright install-deps
|
||||
|
||||
- run: npx playwright test
|
||||
|
||||
- name: Upload report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: test-report
|
||||
path: playwright-report/
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload traces
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: traces
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
```
|
||||
|
||||
### Sharded Execution
|
||||
|
||||
**Use when**: Test suite exceeds 10 minutes. Sharding cuts wall-clock time significantly.
|
||||
**Avoid when**: Suite runs under 5 minutes—sharding overhead negates benefits.
|
||||
|
||||
```yaml
|
||||
# .github/workflows/e2e-sharded.yml
|
||||
name: E2E Tests (Sharded)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: e2e-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CI: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 20
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1/4, 2/4, 3/4, 4/4]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Cache browsers
|
||||
id: browser-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Install browsers
|
||||
if: steps.browser-cache.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Install OS dependencies
|
||||
if: steps.browser-cache.outputs.cache-hit == 'true'
|
||||
run: npx playwright install-deps
|
||||
|
||||
- name: Run tests (shard ${{ matrix.shard }})
|
||||
run: npx playwright test --shard=${{ matrix.shard }}
|
||||
|
||||
- name: Upload blob report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: blob-${{ strategy.job-index }}
|
||||
path: blob-report/
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
if: ${{ !cancelled() }}
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Download blob reports
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: all-blobs
|
||||
pattern: blob-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Merge reports
|
||||
run: npx playwright merge-reports --reporter=html ./all-blobs
|
||||
|
||||
- name: Upload merged report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-report
|
||||
path: playwright-report/
|
||||
retention-days: 14
|
||||
```
|
||||
|
||||
**Config for sharding**—enable blob reporter:
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
reporter: process.env.CI
|
||||
? [['blob'], ['github']]
|
||||
: [['html', { open: 'on-failure' }]],
|
||||
});
|
||||
```
|
||||
|
||||
### Container-Based Execution
|
||||
|
||||
**Use when**: Reproducible environment matching local Docker setup, or runner OS dependencies cause issues.
|
||||
**Avoid when**: Standard `ubuntu-latest` with `--with-deps` works fine.
|
||||
|
||||
```yaml
|
||||
# .github/workflows/e2e-container.yml
|
||||
name: E2E Tests (Container)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.48.0-noble
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npx playwright test
|
||||
env:
|
||||
HOME: /root
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: test-report
|
||||
path: playwright-report/
|
||||
retention-days: 14
|
||||
```
|
||||
|
||||
### Environment Secrets
|
||||
|
||||
**Use when**: Tests target staging/production with credentials.
|
||||
**Avoid when**: Tests only run against local dev server.
|
||||
|
||||
```yaml
|
||||
# .github/workflows/e2e-staging.yml
|
||||
name: Staging Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
environment: staging
|
||||
|
||||
env:
|
||||
CI: true
|
||||
BASE_URL: ${{ vars.STAGING_URL }}
|
||||
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
|
||||
API_TOKEN: ${{ secrets.API_TOKEN }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Cache browsers
|
||||
id: browser-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Install browsers
|
||||
if: steps.browser-cache.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Install OS dependencies
|
||||
if: steps.browser-cache.outputs.cache-hit == 'true'
|
||||
run: npx playwright install-deps
|
||||
|
||||
- name: Run smoke tests
|
||||
run: npx playwright test --grep @smoke
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: staging-report
|
||||
path: playwright-report/
|
||||
retention-days: 14
|
||||
```
|
||||
|
||||
### Scheduled Runs
|
||||
|
||||
**Use when**: Full regression suite is too slow for every PR—run nightly instead.
|
||||
**Avoid when**: Suite runs under 15 minutes and can run on every PR.
|
||||
|
||||
```yaml
|
||||
# .github/workflows/nightly.yml
|
||||
name: Nightly Regression
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 3 * * 1-5'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
CI: true
|
||||
BASE_URL: ${{ vars.STAGING_URL }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Install browsers
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Run full regression
|
||||
run: npx playwright test --grep @regression
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: nightly-${{ github.run_number }}
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
uses: slackapi/slack-github-action@latest
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": "Nightly regression failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
```
|
||||
|
||||
### Reusable Workflow
|
||||
|
||||
**Use when**: Multiple repositories share the same Playwright setup.
|
||||
**Avoid when**: Single repo with one workflow.
|
||||
|
||||
```yaml
|
||||
# .github/workflows/pw-reusable.yml
|
||||
name: Playwright Reusable
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
node-version:
|
||||
type: string
|
||||
default: 'lts/*'
|
||||
test-command:
|
||||
type: string
|
||||
default: 'npx playwright test'
|
||||
secrets:
|
||||
BASE_URL:
|
||||
required: false
|
||||
TEST_PASSWORD:
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
CI: true
|
||||
BASE_URL: ${{ secrets.BASE_URL }}
|
||||
TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: npm
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Cache browsers
|
||||
id: browser-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Install browsers
|
||||
if: steps.browser-cache.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Install OS dependencies
|
||||
if: steps.browser-cache.outputs.cache-hit == 'true'
|
||||
run: npx playwright install-deps
|
||||
|
||||
- name: Run tests
|
||||
run: ${{ inputs.test-command }}
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: test-report
|
||||
path: playwright-report/
|
||||
retention-days: 14
|
||||
```
|
||||
|
||||
**Calling the reusable workflow:**
|
||||
|
||||
```yaml
|
||||
# .github/workflows/ci.yml
|
||||
name: CI
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
uses: ./.github/workflows/pw-reusable.yml
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
secrets:
|
||||
BASE_URL: ${{ secrets.STAGING_URL }}
|
||||
TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}
|
||||
```
|
||||
|
||||
## Scenario Guide
|
||||
|
||||
| Scenario | Approach |
|
||||
|---|---|
|
||||
| Small suite (< 5 min) | Single job, no sharding |
|
||||
| Medium suite (5-20 min) | 2-4 shards with matrix |
|
||||
| Large suite (20+ min) | 4-8 shards + blob merge |
|
||||
| Cross-browser on PRs | Chromium only on PRs; all browsers on main |
|
||||
| Staging/prod smoke tests | Separate workflow with `environment:` |
|
||||
| Nightly full regression | `schedule` trigger + `workflow_dispatch` |
|
||||
| Multiple repos, same setup | Reusable workflow with `workflow_call` |
|
||||
| Reproducible env needed | Container job with Playwright image |
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
| Mistake | Problem | Fix |
|
||||
|---|---|---|
|
||||
| No `concurrency` group | Duplicate runs waste minutes | Add `concurrency: { group: ..., cancel-in-progress: true }` |
|
||||
| `fail-fast: true` with sharding | One failure cancels others | Set `fail-fast: false` |
|
||||
| No browser caching | 60-90 seconds wasted per run | Cache `~/.cache/ms-playwright` |
|
||||
| No `timeout-minutes` | Stuck jobs run for 6 hours | Set explicit timeout: 20-30 minutes |
|
||||
| Artifacts only on failure | No report when tests pass | Use `if: ${{ !cancelled() }}` |
|
||||
| Hardcoded secrets | Security risk | Use GitHub Secrets and Environments |
|
||||
| All browsers on every PR | 3x CI cost | Chromium on PR; cross-browser on main |
|
||||
| No artifact retention | Default 90-day fills storage | Set `retention-days: 7-14` |
|
||||
| Missing `--with-deps` | Browser launch failures | Always use `npx playwright install --with-deps` |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Browser launch fails: "Missing dependencies"
|
||||
|
||||
**Cause**: Browsers restored from cache but OS dependencies weren't cached.
|
||||
|
||||
**Fix**: Run `npx playwright install-deps` on cache hit:
|
||||
|
||||
```yaml
|
||||
- name: Install OS dependencies
|
||||
if: steps.browser-cache.outputs.cache-hit == 'true'
|
||||
run: npx playwright install-deps
|
||||
```
|
||||
|
||||
### Tests pass locally but timeout in CI
|
||||
|
||||
**Cause**: CI runners have fewer resources than dev machines.
|
||||
|
||||
**Fix**: Reduce workers and increase timeouts:
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
workers: process.env.CI ? '50%' : undefined,
|
||||
use: {
|
||||
actionTimeout: process.env.CI ? 15_000 : 10_000,
|
||||
navigationTimeout: process.env.CI ? 30_000 : 15_000,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Sharded reports incomplete
|
||||
|
||||
**Cause**: Artifact names collide or `merge-multiple` not set.
|
||||
|
||||
**Fix**: Unique names per shard and enable merge:
|
||||
|
||||
```yaml
|
||||
# Upload in each shard
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: blob-${{ strategy.job-index }}
|
||||
path: blob-report/
|
||||
|
||||
# Download in merge job
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: all-blobs
|
||||
pattern: blob-*
|
||||
merge-multiple: true
|
||||
```
|
||||
|
||||
### `webServer` fails: "port already in use"
|
||||
|
||||
**Cause**: Zombie process from previous run.
|
||||
|
||||
**Fix**: Kill stale processes before starting:
|
||||
|
||||
```yaml
|
||||
- name: Kill stale processes
|
||||
run: lsof -ti:3000 | xargs kill -9 2>/dev/null || true
|
||||
```
|
||||
|
||||
### No PR annotations
|
||||
|
||||
**Cause**: `github` reporter not configured.
|
||||
|
||||
**Fix**: Add `github` reporter for CI:
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
reporter: process.env.CI
|
||||
? [['html', { open: 'never' }], ['github']]
|
||||
: [['html', { open: 'on-failure' }]],
|
||||
});
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [test-tags.md](../core/test-tags.md) — tagging and filtering tests
|
||||
- [parallel-sharding.md](parallel-sharding.md) — sharding strategies
|
||||
- [reporting.md](reporting.md) — reporter configuration
|
||||
- [docker.md](docker.md) — container images
|
||||
- [gitlab.md](gitlab.md) — GitLab CI equivalent
|
||||
- [other-providers.md](other-providers.md) — CircleCI, Azure DevOps, Jenkins
|
||||
@@ -0,0 +1,397 @@
|
||||
# GitLab CI/CD Configuration
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Key Commands](#key-commands)
|
||||
2. [Patterns](#patterns)
|
||||
3. [Decision Guide](#decision-guide)
|
||||
4. [Anti-Patterns](#anti-patterns)
|
||||
5. [Troubleshooting](#troubleshooting)
|
||||
|
||||
> **When to use**: Running Playwright tests in GitLab pipelines on merge requests, merges to main, or scheduled pipelines.
|
||||
|
||||
## Key Commands
|
||||
|
||||
```bash
|
||||
npx playwright install --with-deps # install browsers + OS deps
|
||||
npx playwright test --shard=1/4 # run 1 of 4 parallel shards
|
||||
npx playwright merge-reports ./blob-report # merge shard results
|
||||
npx playwright test --reporter=dot # minimal output for CI logs
|
||||
```
|
||||
|
||||
## Patterns
|
||||
|
||||
### Basic Pipeline Configuration
|
||||
|
||||
**Use when**: Any GitLab project with Playwright tests.
|
||||
|
||||
```yaml
|
||||
# .gitlab-ci.yml
|
||||
image: mcr.microsoft.com/playwright:v1.48.0-noble
|
||||
|
||||
stages:
|
||||
- install
|
||||
- test
|
||||
- report
|
||||
|
||||
variables:
|
||||
CI: "true"
|
||||
npm_config_cache: "$CI_PROJECT_DIR/.npm"
|
||||
|
||||
cache:
|
||||
key:
|
||||
files:
|
||||
- package-lock.json
|
||||
paths:
|
||||
- .npm/
|
||||
- node_modules/
|
||||
|
||||
setup:
|
||||
stage: install
|
||||
script:
|
||||
- npm ci
|
||||
artifacts:
|
||||
paths:
|
||||
- node_modules/
|
||||
expire_in: 1 hour
|
||||
|
||||
e2e:
|
||||
stage: test
|
||||
needs: [setup]
|
||||
script:
|
||||
- npx playwright test
|
||||
artifacts:
|
||||
when: always
|
||||
paths:
|
||||
- playwright-report/
|
||||
- test-results/
|
||||
expire_in: 14 days
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
```
|
||||
|
||||
### Sharded Parallel Execution
|
||||
|
||||
**Use when**: Test suite exceeds 10 minutes. GitLab's `parallel` keyword splits across jobs automatically.
|
||||
**Avoid when**: Suite runs under 5 minutes.
|
||||
|
||||
```yaml
|
||||
image: mcr.microsoft.com/playwright:v1.48.0-noble
|
||||
|
||||
stages:
|
||||
- install
|
||||
- test
|
||||
- report
|
||||
|
||||
variables:
|
||||
CI: "true"
|
||||
npm_config_cache: "$CI_PROJECT_DIR/.npm"
|
||||
|
||||
cache:
|
||||
key:
|
||||
files:
|
||||
- package-lock.json
|
||||
paths:
|
||||
- .npm/
|
||||
- node_modules/
|
||||
|
||||
setup:
|
||||
stage: install
|
||||
script:
|
||||
- npm ci
|
||||
artifacts:
|
||||
paths:
|
||||
- node_modules/
|
||||
expire_in: 1 hour
|
||||
|
||||
e2e:
|
||||
stage: test
|
||||
needs: [setup]
|
||||
parallel: 4
|
||||
script:
|
||||
- npx playwright test --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
|
||||
artifacts:
|
||||
when: always
|
||||
paths:
|
||||
- blob-report/
|
||||
expire_in: 1 hour
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
|
||||
combine-reports:
|
||||
stage: report
|
||||
needs: [e2e]
|
||||
when: always
|
||||
script:
|
||||
- npx playwright merge-reports --reporter=html ./blob-report
|
||||
artifacts:
|
||||
when: always
|
||||
paths:
|
||||
- playwright-report/
|
||||
expire_in: 14 days
|
||||
```
|
||||
|
||||
**Config for sharded pipelines:**
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
reporter: process.env.CI
|
||||
? [["blob"], ["dot"]]
|
||||
: [["html", { open: "on-failure" }]],
|
||||
});
|
||||
```
|
||||
|
||||
### Environment Variables and Secrets
|
||||
|
||||
**Use when**: Tests need secrets (API keys, passwords) and should only run on merge requests or the default branch.
|
||||
|
||||
```yaml
|
||||
image: mcr.microsoft.com/playwright:v1.48.0-noble
|
||||
|
||||
stages:
|
||||
- test
|
||||
|
||||
variables:
|
||||
CI: "true"
|
||||
|
||||
e2e:staging:
|
||||
stage: test
|
||||
variables:
|
||||
BASE_URL: $STAGING_URL
|
||||
TEST_PASSWORD: $TEST_PASSWORD
|
||||
API_KEY: $API_KEY
|
||||
before_script:
|
||||
- npm ci
|
||||
script:
|
||||
- npx playwright test
|
||||
artifacts:
|
||||
when: always
|
||||
paths:
|
||||
- playwright-report/
|
||||
- test-results/
|
||||
expire_in: 14 days
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
- when: manual
|
||||
allow_failure: true
|
||||
```
|
||||
|
||||
**Setting variables in GitLab:**
|
||||
Navigate to **Settings > CI/CD > Variables** and add:
|
||||
|
||||
- `STAGING_URL` -- not masked, not protected
|
||||
- `TEST_PASSWORD` -- masked, protected
|
||||
- `API_KEY` -- masked, protected
|
||||
|
||||
### Multi-Browser Matrix
|
||||
|
||||
**Use when**: Running Chromium on MRs and all browsers on the default branch.
|
||||
|
||||
```yaml
|
||||
image: mcr.microsoft.com/playwright:v1.48.0-noble
|
||||
|
||||
stages:
|
||||
- install
|
||||
- test
|
||||
|
||||
variables:
|
||||
CI: "true"
|
||||
|
||||
setup:
|
||||
stage: install
|
||||
script:
|
||||
- npm ci
|
||||
artifacts:
|
||||
paths:
|
||||
- node_modules/
|
||||
expire_in: 1 hour
|
||||
|
||||
e2e:chromium:
|
||||
stage: test
|
||||
needs: [setup]
|
||||
script:
|
||||
- npx playwright test --project=chromium
|
||||
artifacts:
|
||||
when: always
|
||||
paths:
|
||||
- playwright-report/
|
||||
- test-results/
|
||||
expire_in: 14 days
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
|
||||
e2e:all-browsers:
|
||||
stage: test
|
||||
needs: [setup]
|
||||
parallel:
|
||||
matrix:
|
||||
- PROJECT: [chromium, firefox, webkit]
|
||||
script:
|
||||
- npx playwright test --project=$PROJECT
|
||||
artifacts:
|
||||
when: always
|
||||
paths:
|
||||
- playwright-report/
|
||||
- test-results/
|
||||
expire_in: 14 days
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
```
|
||||
|
||||
### Services Integration (Database, Cache)
|
||||
|
||||
**Use when**: Tests need the application running alongside Playwright, or you need external services.
|
||||
|
||||
```yaml
|
||||
stages:
|
||||
- test
|
||||
|
||||
e2e:integration:
|
||||
stage: test
|
||||
image: mcr.microsoft.com/playwright:v1.48.0-noble
|
||||
services:
|
||||
- name: postgres:latest
|
||||
alias: db
|
||||
- name: redis:latest
|
||||
alias: cache
|
||||
variables:
|
||||
CI: "true"
|
||||
DATABASE_URL: "postgresql://postgres:postgres@db:5432/testdb"
|
||||
REDIS_URL: "redis://cache:6379"
|
||||
POSTGRES_PASSWORD: "postgres"
|
||||
POSTGRES_DB: "testdb"
|
||||
before_script:
|
||||
- npm ci
|
||||
- npx prisma db push
|
||||
- npx prisma db seed
|
||||
script:
|
||||
- npx playwright test
|
||||
artifacts:
|
||||
when: always
|
||||
paths:
|
||||
- playwright-report/
|
||||
- test-results/
|
||||
expire_in: 14 days
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
```
|
||||
|
||||
### Scheduled Nightly Regression
|
||||
|
||||
**Use when**: Full regression is too slow for every MR.
|
||||
|
||||
```yaml
|
||||
e2e:nightly:
|
||||
stage: test
|
||||
image: mcr.microsoft.com/playwright:v1.48.0-noble
|
||||
before_script:
|
||||
- npm ci
|
||||
script:
|
||||
- npx playwright test --grep @regression
|
||||
artifacts:
|
||||
when: always
|
||||
paths:
|
||||
- playwright-report/
|
||||
expire_in: 30 days
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "schedule"
|
||||
```
|
||||
|
||||
Set up the schedule in **CI/CD > Schedules**: `0 3 * * 1-5` (3 AM UTC, weekdays).
|
||||
|
||||
## Decision Guide
|
||||
|
||||
| Scenario | Approach | Why |
|
||||
| ------------------------------------ | ------------------------------------------------------ | --------------------------------------------------- |
|
||||
| Simple project, < 5 min suite | Single `test` job using Playwright Docker image | No sharding overhead; artifacts capture report |
|
||||
| Suite > 10 min | `parallel: N` with `--shard` | GitLab auto-assigns `CI_NODE_INDEX`/`CI_NODE_TOTAL` |
|
||||
| Merge request fast feedback | Chromium only on MRs; all browsers on main | 3x fewer pipeline minutes on MRs |
|
||||
| External services needed (DB, Redis) | `services:` keyword with Postgres/Redis images | GitLab manages service lifecycle |
|
||||
| Secrets for staging environment | GitLab CI/CD Variables (masked + protected) | Never hardcode secrets in `.gitlab-ci.yml` |
|
||||
| Full nightly regression | Pipeline schedule (`CI_PIPELINE_SOURCE == "schedule"`) | Avoids blocking MR pipelines |
|
||||
| Report browsing | `artifacts:` with `paths: [playwright-report/]` | Browse directly in GitLab job artifacts UI |
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Anti-Pattern | Problem | Do This Instead |
|
||||
| ---------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------- |
|
||||
| Not using the Playwright Docker image | Installing browsers every run adds 1-2 minutes | Use `mcr.microsoft.com/playwright:v1.48.0-noble` as base image |
|
||||
| `artifacts: when: on_failure` only | No report when tests pass; can't verify results | Use `when: always` to capture reports regardless |
|
||||
| No `expire_in` on artifacts | Artifacts accumulate and consume storage | Set `expire_in: 14 days` for reports, `1 hour` for intermediate artifacts |
|
||||
| Hardcoding `CI_NODE_TOTAL` in shard flag | Breaks when you change `parallel:` value | Use `--shard=$CI_NODE_INDEX/$CI_NODE_TOTAL` |
|
||||
| Skipping `needs:` between stages | Jobs wait for all previous stage jobs, not just their dependencies | Use `needs:` for precise dependency graphs |
|
||||
| Large `cache:` including `node_modules/` without key | Stale cache causes version conflicts | Key cache on `package-lock.json` hash |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Browser launch fails: "Failed to launch browser"
|
||||
|
||||
**Cause**: Not using the Playwright Docker image, or using a version that doesn't match your `@playwright/test` version.
|
||||
|
||||
**Fix**: Match the Docker image tag to your Playwright version:
|
||||
|
||||
```yaml
|
||||
# Check your version: npm ls @playwright/test
|
||||
image: mcr.microsoft.com/playwright:v1.48.0-noble
|
||||
```
|
||||
|
||||
### Tests hang in GitLab runner: "Navigation timeout exceeded"
|
||||
|
||||
**Cause**: GitLab shared runners may have limited resources.
|
||||
|
||||
**Fix**: Reduce workers and increase timeouts:
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
workers: process.env.CI ? 2 : undefined,
|
||||
use: {
|
||||
navigationTimeout: process.env.CI ? 30_000 : 15_000,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Pipeline runs on every push, not just merge requests
|
||||
|
||||
**Cause**: Missing `rules:` configuration.
|
||||
|
||||
**Fix**: Add explicit rules:
|
||||
|
||||
```yaml
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
```
|
||||
|
||||
### Services (Postgres/Redis) not reachable from tests
|
||||
|
||||
**Cause**: Using `localhost` instead of the service alias.
|
||||
|
||||
**Fix**: Use the service alias as hostname:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
- name: postgres:latest
|
||||
alias: db
|
||||
|
||||
variables:
|
||||
DATABASE_URL: "postgresql://postgres:postgres@db:5432/testdb"
|
||||
```
|
||||
|
||||
### Merged report is empty after sharded run
|
||||
|
||||
**Cause**: Each shard job needs the `blob` reporter, not `html`.
|
||||
|
||||
**Fix**: Configure blob reporter for CI:
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
reporter: process.env.CI
|
||||
? [["blob"], ["dot"]]
|
||||
: [["html", { open: "on-failure" }]],
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,521 @@
|
||||
# CI: CircleCI, Azure DevOps, and Jenkins
|
||||
|
||||
> **When to use**: Running Playwright tests in CI platforms other than GitHub Actions or GitLab.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Common Commands](#common-commands)
|
||||
2. [Jenkins](#jenkins)
|
||||
3. [CircleCI](#circleci)
|
||||
4. [Azure DevOps](#azure-devops)
|
||||
5. [JUnit Reporter Config](#junit-reporter-config)
|
||||
6. [Platform Comparison](#platform-comparison)
|
||||
7. [Troubleshooting](#troubleshooting)
|
||||
8. [Anti-Patterns](#anti-patterns)
|
||||
|
||||
---
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
npx playwright install --with-deps # browsers + OS dependencies
|
||||
npx playwright test --shard=1/4 # parallel sharding
|
||||
npx playwright merge-reports ./blob-report # combine shard results
|
||||
npx playwright test --reporter=dot,html # multiple reporters
|
||||
```
|
||||
|
||||
## Jenkins
|
||||
|
||||
### Declarative Pipeline
|
||||
|
||||
```groovy
|
||||
// Jenkinsfile
|
||||
pipeline {
|
||||
agent {
|
||||
docker {
|
||||
image 'mcr.microsoft.com/playwright:v1.48.0-noble'
|
||||
args '-u root'
|
||||
}
|
||||
}
|
||||
|
||||
environment {
|
||||
CI = 'true'
|
||||
HOME = '/root'
|
||||
npm_config_cache = "${WORKSPACE}/.npm"
|
||||
}
|
||||
|
||||
options {
|
||||
timeout(time: 30, unit: 'MINUTES')
|
||||
disableConcurrentBuilds()
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Install') {
|
||||
steps {
|
||||
sh 'npm ci'
|
||||
}
|
||||
}
|
||||
|
||||
stage('Test') {
|
||||
steps {
|
||||
sh 'npx playwright test'
|
||||
}
|
||||
post {
|
||||
always {
|
||||
junit allowEmptyResults: true,
|
||||
testResults: 'results/junit.xml'
|
||||
archiveArtifacts artifacts: 'pw-report/**',
|
||||
allowEmptyArchive: true
|
||||
archiveArtifacts artifacts: 'results/**',
|
||||
allowEmptyArchive: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
failure {
|
||||
echo 'Tests failed!'
|
||||
}
|
||||
cleanup {
|
||||
cleanWs()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Parallel Shards
|
||||
|
||||
```groovy
|
||||
// Jenkinsfile (sharded)
|
||||
pipeline {
|
||||
agent none
|
||||
|
||||
environment {
|
||||
CI = 'true'
|
||||
HOME = '/root'
|
||||
}
|
||||
|
||||
options {
|
||||
timeout(time: 30, unit: 'MINUTES')
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Test') {
|
||||
parallel {
|
||||
stage('Shard 1') {
|
||||
agent {
|
||||
docker {
|
||||
image 'mcr.microsoft.com/playwright:v1.48.0-noble'
|
||||
args '-u root'
|
||||
}
|
||||
}
|
||||
steps {
|
||||
sh 'npm ci'
|
||||
sh 'npx playwright test --shard=1/4'
|
||||
}
|
||||
post {
|
||||
always {
|
||||
archiveArtifacts artifacts: 'blob-report/**',
|
||||
allowEmptyArchive: true
|
||||
}
|
||||
}
|
||||
}
|
||||
stage('Shard 2') {
|
||||
agent {
|
||||
docker {
|
||||
image 'mcr.microsoft.com/playwright:v1.48.0-noble'
|
||||
args '-u root'
|
||||
}
|
||||
}
|
||||
steps {
|
||||
sh 'npm ci'
|
||||
sh 'npx playwright test --shard=2/4'
|
||||
}
|
||||
post {
|
||||
always {
|
||||
archiveArtifacts artifacts: 'blob-report/**',
|
||||
allowEmptyArchive: true
|
||||
}
|
||||
}
|
||||
}
|
||||
stage('Shard 3') {
|
||||
agent {
|
||||
docker {
|
||||
image 'mcr.microsoft.com/playwright:v1.48.0-noble'
|
||||
args '-u root'
|
||||
}
|
||||
}
|
||||
steps {
|
||||
sh 'npm ci'
|
||||
sh 'npx playwright test --shard=3/4'
|
||||
}
|
||||
post {
|
||||
always {
|
||||
archiveArtifacts artifacts: 'blob-report/**',
|
||||
allowEmptyArchive: true
|
||||
}
|
||||
}
|
||||
}
|
||||
stage('Shard 4') {
|
||||
agent {
|
||||
docker {
|
||||
image 'mcr.microsoft.com/playwright:v1.48.0-noble'
|
||||
args '-u root'
|
||||
}
|
||||
}
|
||||
steps {
|
||||
sh 'npm ci'
|
||||
sh 'npx playwright test --shard=4/4'
|
||||
}
|
||||
post {
|
||||
always {
|
||||
archiveArtifacts artifacts: 'blob-report/**',
|
||||
allowEmptyArchive: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## CircleCI
|
||||
|
||||
### Basic Pipeline
|
||||
|
||||
```yaml
|
||||
# .circleci/config.yml
|
||||
version: 2.1
|
||||
|
||||
executors:
|
||||
pw:
|
||||
docker:
|
||||
- image: mcr.microsoft.com/playwright:v1.48.0-noble
|
||||
working_directory: ~/app
|
||||
|
||||
jobs:
|
||||
install:
|
||||
executor: pw
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
keys:
|
||||
- deps-{{ checksum "package-lock.json" }}
|
||||
- run: npm ci
|
||||
- save_cache:
|
||||
key: deps-{{ checksum "package-lock.json" }}
|
||||
paths:
|
||||
- node_modules
|
||||
- persist_to_workspace:
|
||||
root: .
|
||||
paths:
|
||||
- node_modules
|
||||
|
||||
test:
|
||||
executor: pw
|
||||
parallelism: 4
|
||||
steps:
|
||||
- checkout
|
||||
- attach_workspace:
|
||||
at: .
|
||||
- run:
|
||||
name: Run tests
|
||||
command: |
|
||||
npx playwright test --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL
|
||||
- store_artifacts:
|
||||
path: pw-report
|
||||
destination: pw-report
|
||||
- store_artifacts:
|
||||
path: results
|
||||
destination: results
|
||||
- store_test_results:
|
||||
path: results/junit.xml
|
||||
|
||||
workflows:
|
||||
test:
|
||||
jobs:
|
||||
- install
|
||||
- test:
|
||||
requires:
|
||||
- install
|
||||
```
|
||||
|
||||
### Using Orbs
|
||||
|
||||
```yaml
|
||||
# .circleci/config.yml
|
||||
version: 2.1
|
||||
|
||||
orbs:
|
||||
node: circleci/node@latest
|
||||
|
||||
executors:
|
||||
pw:
|
||||
docker:
|
||||
- image: mcr.microsoft.com/playwright:v1.48.0-noble
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
executor: pw
|
||||
parallelism: 4
|
||||
steps:
|
||||
- checkout
|
||||
- node/install-packages
|
||||
- run:
|
||||
name: Run tests
|
||||
command: npx playwright test --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL
|
||||
- store_artifacts:
|
||||
path: pw-report
|
||||
- store_test_results:
|
||||
path: results/junit.xml
|
||||
|
||||
workflows:
|
||||
main:
|
||||
jobs:
|
||||
- e2e
|
||||
```
|
||||
|
||||
## Azure DevOps
|
||||
|
||||
### Basic Pipeline
|
||||
|
||||
```yaml
|
||||
# azure-pipelines.yml
|
||||
trigger:
|
||||
branches:
|
||||
include:
|
||||
- main
|
||||
|
||||
pr:
|
||||
branches:
|
||||
include:
|
||||
- main
|
||||
|
||||
pool:
|
||||
vmImage: "ubuntu-latest"
|
||||
|
||||
variables:
|
||||
CI: "true"
|
||||
npm_config_cache: $(Pipeline.Workspace)/.npm
|
||||
|
||||
steps:
|
||||
- task: NodeTool@0
|
||||
inputs:
|
||||
versionSpec: "20.x"
|
||||
displayName: "Install Node.js"
|
||||
|
||||
- task: Cache@2
|
||||
inputs:
|
||||
key: 'npm | "$(Agent.OS)" | package-lock.json'
|
||||
restoreKeys: |
|
||||
npm | "$(Agent.OS)"
|
||||
path: $(npm_config_cache)
|
||||
displayName: "Cache npm"
|
||||
|
||||
- script: npm ci
|
||||
displayName: "Install dependencies"
|
||||
|
||||
- script: npx playwright install --with-deps
|
||||
displayName: "Install browsers"
|
||||
|
||||
- script: npx playwright test
|
||||
displayName: "Run tests"
|
||||
|
||||
- task: PublishTestResults@2
|
||||
condition: always()
|
||||
inputs:
|
||||
testResultsFormat: "JUnit"
|
||||
testResultsFiles: "results/junit.xml"
|
||||
mergeTestResults: true
|
||||
testRunTitle: "E2E Tests"
|
||||
displayName: "Publish results"
|
||||
|
||||
- task: PublishPipelineArtifact@1
|
||||
condition: always()
|
||||
inputs:
|
||||
targetPath: pw-report
|
||||
artifact: pw-report
|
||||
publishLocation: "pipeline"
|
||||
displayName: "Upload report"
|
||||
```
|
||||
|
||||
### With Sharding
|
||||
|
||||
```yaml
|
||||
# azure-pipelines.yml
|
||||
trigger:
|
||||
branches:
|
||||
include:
|
||||
- main
|
||||
|
||||
pr:
|
||||
branches:
|
||||
include:
|
||||
- main
|
||||
|
||||
variables:
|
||||
CI: "true"
|
||||
|
||||
stages:
|
||||
- stage: Test
|
||||
jobs:
|
||||
- job: E2E
|
||||
pool:
|
||||
vmImage: "ubuntu-latest"
|
||||
strategy:
|
||||
matrix:
|
||||
shard1:
|
||||
SHARD: "1/4"
|
||||
shard2:
|
||||
SHARD: "2/4"
|
||||
shard3:
|
||||
SHARD: "3/4"
|
||||
shard4:
|
||||
SHARD: "4/4"
|
||||
steps:
|
||||
- task: NodeTool@0
|
||||
inputs:
|
||||
versionSpec: "20.x"
|
||||
|
||||
- script: npm ci
|
||||
displayName: "Install dependencies"
|
||||
|
||||
- script: npx playwright install --with-deps
|
||||
displayName: "Install browsers"
|
||||
|
||||
- script: npx playwright test --shard=$(SHARD)
|
||||
displayName: "Run tests (shard $(SHARD))"
|
||||
|
||||
- task: PublishPipelineArtifact@1
|
||||
condition: always()
|
||||
inputs:
|
||||
targetPath: blob-report
|
||||
artifact: blob-report-$(System.JobPositionInPhase)
|
||||
displayName: "Upload blob report"
|
||||
|
||||
- stage: Report
|
||||
dependsOn: Test
|
||||
condition: always()
|
||||
jobs:
|
||||
- job: MergeReports
|
||||
pool:
|
||||
vmImage: "ubuntu-latest"
|
||||
steps:
|
||||
- task: NodeTool@0
|
||||
inputs:
|
||||
versionSpec: "20.x"
|
||||
|
||||
- script: npm ci
|
||||
displayName: "Install dependencies"
|
||||
|
||||
- task: DownloadPipelineArtifact@2
|
||||
inputs:
|
||||
patterns: "blob-report-*/**"
|
||||
path: all-blob-reports
|
||||
displayName: "Download blob reports"
|
||||
|
||||
- script: npx playwright merge-reports --reporter=html ./all-blob-reports
|
||||
displayName: "Merge reports"
|
||||
|
||||
- task: PublishPipelineArtifact@1
|
||||
inputs:
|
||||
targetPath: pw-report
|
||||
artifact: pw-report
|
||||
displayName: "Upload merged report"
|
||||
```
|
||||
|
||||
## JUnit Reporter Config
|
||||
|
||||
All platforms benefit from JUnit output for native test result display:
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
reporter: process.env.CI
|
||||
? [
|
||||
["dot"],
|
||||
["html", { open: "never" }],
|
||||
["junit", { outputFile: "results/junit.xml" }],
|
||||
]
|
||||
: [["html", { open: "on-failure" }]],
|
||||
});
|
||||
```
|
||||
|
||||
## Platform Comparison
|
||||
|
||||
| Feature | CircleCI | Azure DevOps | Jenkins |
|
||||
| ----------------- | ----------------------------------------------- | -------------------------------- | ---------------------- |
|
||||
| Docker support | `docker:` executor | `vmImage` or container jobs | Docker Pipeline plugin |
|
||||
| Parallelism | `parallelism: N` + `CIRCLE_NODE_INDEX` | `strategy.matrix` | `parallel` stages |
|
||||
| Artifact upload | `store_artifacts` | `PublishPipelineArtifact@1` | `archiveArtifacts` |
|
||||
| JUnit integration | `store_test_results` | `PublishTestResults@2` | `junit` step |
|
||||
| Shard variable | `$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL` | Define in matrix: `SHARD: '1/4'` | Hardcode per stage |
|
||||
| Cache key | `checksum "package-lock.json"` | `Cache@2` with key template | `stash`/`unstash` |
|
||||
| Secrets | Context + env variables | Variable groups | Credentials plugin |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Jenkins: "Browser closed unexpectedly"
|
||||
|
||||
Running as non-root in container causes sandbox issues.
|
||||
|
||||
```groovy
|
||||
agent {
|
||||
docker {
|
||||
image 'mcr.microsoft.com/playwright:v1.48.0-noble'
|
||||
args '-u root'
|
||||
}
|
||||
}
|
||||
environment {
|
||||
HOME = '/root'
|
||||
}
|
||||
```
|
||||
|
||||
### CircleCI: "Executable doesn't exist"
|
||||
|
||||
Image version mismatch with `@playwright/test` version. Use `latest` tag or match versions:
|
||||
|
||||
```yaml
|
||||
docker:
|
||||
- image: mcr.microsoft.com/playwright:v1.48.0-noble
|
||||
```
|
||||
|
||||
### Azure DevOps: Test results not showing
|
||||
|
||||
Missing JUnit reporter or `PublishTestResults@2` task:
|
||||
|
||||
```typescript
|
||||
reporter: [['junit', { outputFile: 'results/junit.xml' }]],
|
||||
```
|
||||
|
||||
```yaml
|
||||
- task: PublishTestResults@2
|
||||
condition: always()
|
||||
inputs:
|
||||
testResultsFormat: "JUnit"
|
||||
testResultsFiles: "results/junit.xml"
|
||||
```
|
||||
|
||||
### Shard index off by one
|
||||
|
||||
CircleCI's `CIRCLE_NODE_INDEX` is 0-based, Playwright's `--shard` is 1-based:
|
||||
|
||||
```yaml
|
||||
# CircleCI - add 1
|
||||
command: npx playwright test --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ----------------------------------- | ----------------------------------------- | ---------------------------------------------------- |
|
||||
| Missing `--with-deps` on bare metal | OS libs missing, browser launch fails | Use Playwright Docker image or `--with-deps` |
|
||||
| No JUnit reporter | CI can't display test results | Add `['junit', { outputFile: 'results/junit.xml' }]` |
|
||||
| No job timeout | Hung tests consume resources indefinitely | Set explicit timeout (20-30 min) |
|
||||
| No artifact upload on success | Can't verify passing results | Always upload reports (`condition: always()`) |
|
||||
| Non-root in container without setup | Permission errors on browser binaries | Run as root or configure permissions |
|
||||
| Hardcoded shard count | Must update multiple places | Use CI-native variables |
|
||||
@@ -0,0 +1,371 @@
|
||||
# Sharding and Parallel Execution
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [CLI Commands](#cli-commands)
|
||||
2. [Patterns](#patterns)
|
||||
3. [Decision Guide](#decision-guide)
|
||||
4. [Anti-Patterns](#anti-patterns)
|
||||
5. [Troubleshooting](#troubleshooting)
|
||||
|
||||
> **When to use**: Speeding up test suites by running tests concurrently on one machine (workers) or splitting across multiple CI jobs (sharding).
|
||||
|
||||
## CLI Commands
|
||||
|
||||
```bash
|
||||
# Parallelism within one machine
|
||||
npx playwright test --workers=4
|
||||
npx playwright test --workers=50%
|
||||
|
||||
# Splitting across CI jobs
|
||||
npx playwright test --shard=1/4
|
||||
npx playwright test --shard=2/4
|
||||
|
||||
# Merging shard outputs
|
||||
npx playwright merge-reports ./blob-report
|
||||
npx playwright merge-reports --reporter=html,json ./blob-report
|
||||
|
||||
# Override config for single run
|
||||
npx playwright test --fully-parallel
|
||||
```
|
||||
|
||||
## Patterns
|
||||
|
||||
### Worker Configuration
|
||||
|
||||
**Use when**: Controlling concurrent test execution on a single machine.
|
||||
|
||||
```ts
|
||||
// playwright.config.ts
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
// Tests WITHIN a file also run in parallel
|
||||
fullyParallel: true,
|
||||
|
||||
// Worker count options:
|
||||
// - undefined: auto-detect (half CPU cores)
|
||||
// - number: fixed count
|
||||
// - string: percentage of cores
|
||||
workers: process.env.CI ? "50%" : undefined,
|
||||
});
|
||||
```
|
||||
|
||||
**`fullyParallel` behavior:**
|
||||
|
||||
| Setting | Files parallel | Tests in file parallel |
|
||||
| -------------------------------- | -------------- | ---------------------- |
|
||||
| `fullyParallel: false` (default) | Yes | No (serial) |
|
||||
| `fullyParallel: true` | Yes | Yes |
|
||||
|
||||
**Serial execution for specific files:**
|
||||
|
||||
```ts
|
||||
// tests/checkout-flow.spec.ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("add items to cart", async ({ page }) => {
|
||||
// ...
|
||||
});
|
||||
|
||||
test("complete payment", async ({ page }) => {
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
### Sharding Across CI Machines
|
||||
|
||||
**Use when**: Suite exceeds 5 minutes even with maximum workers.
|
||||
|
||||
```bash
|
||||
# Job 1 Job 2 Job 3 Job 4
|
||||
--shard=1/4 --shard=2/4 --shard=3/4 --shard=4/4
|
||||
```
|
||||
|
||||
**Config for sharded runs:**
|
||||
|
||||
```ts
|
||||
// playwright.config.ts
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
fullyParallel: true,
|
||||
workers: process.env.CI ? "50%" : undefined,
|
||||
|
||||
reporter: process.env.CI
|
||||
? [["blob"], ["github"]]
|
||||
: [["html", { open: "on-failure" }]],
|
||||
});
|
||||
```
|
||||
|
||||
### Merging Shard Reports
|
||||
|
||||
**Use when**: Combining blob reports from multiple shards into a unified report.
|
||||
|
||||
```bash
|
||||
# Merge all blobs into HTML
|
||||
npx playwright merge-reports --reporter=html ./all-blob-reports
|
||||
|
||||
# Multiple formats
|
||||
npx playwright merge-reports --reporter=html,json,junit ./all-blob-reports
|
||||
|
||||
# Custom output location
|
||||
PLAYWRIGHT_HTML_REPORT=merged-report npx playwright merge-reports --reporter=html ./all-blob-reports
|
||||
```
|
||||
|
||||
**GitHub Actions merge job:**
|
||||
|
||||
```yaml
|
||||
merge-reports:
|
||||
if: ${{ !cancelled() }}
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: npm ci
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: all-blob-reports
|
||||
pattern: blob-report-*
|
||||
merge-multiple: true
|
||||
|
||||
- run: npx playwright merge-reports --reporter=html ./all-blob-reports
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 14
|
||||
```
|
||||
|
||||
### Worker-Scoped Fixtures
|
||||
|
||||
**Use when**: Expensive resources (DB connections, auth tokens) should be created once per worker, not per test.
|
||||
|
||||
```ts
|
||||
// fixtures.ts
|
||||
import { test as base } from "@playwright/test";
|
||||
|
||||
type WorkerFixtures = {
|
||||
dbClient: DatabaseClient;
|
||||
apiToken: string;
|
||||
};
|
||||
|
||||
export const test = base.extend<{}, WorkerFixtures>({
|
||||
dbClient: [
|
||||
async ({}, use) => {
|
||||
const client = await DatabaseClient.connect(process.env.DB_URL!);
|
||||
await use(client);
|
||||
await client.disconnect();
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
|
||||
apiToken: [
|
||||
async ({}, use, workerInfo) => {
|
||||
const res = await fetch(`${process.env.API_URL}/auth`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
user: `test-user-${workerInfo.workerIndex}`,
|
||||
password: process.env.TEST_PASSWORD,
|
||||
}),
|
||||
});
|
||||
const { token } = await res.json();
|
||||
await use(token);
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
});
|
||||
|
||||
export { expect } from "@playwright/test";
|
||||
```
|
||||
|
||||
### Test Isolation for Parallelism
|
||||
|
||||
**Use when**: Preparing tests to run without interference.
|
||||
|
||||
Each test must create its own state. No test should depend on or modify shared state.
|
||||
|
||||
```ts
|
||||
// BAD: Shared user causes race conditions
|
||||
test("edit settings", async ({ page }) => {
|
||||
await page.goto("/users/test-user/settings");
|
||||
await page.getByLabel("Email").fill("new@example.com");
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
});
|
||||
|
||||
// GOOD: Unique user per test
|
||||
test("edit settings", async ({ page, request }) => {
|
||||
const res = await request.post("/api/users", {
|
||||
data: { name: `user-${Date.now()}`, email: `${Date.now()}@test.com` },
|
||||
});
|
||||
const user = await res.json();
|
||||
|
||||
await page.goto(`/users/${user.id}/settings`);
|
||||
await page.getByLabel("Email").fill("updated@example.com");
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
await expect(page.getByLabel("Email")).toHaveValue("updated@example.com");
|
||||
|
||||
await request.delete(`/api/users/${user.id}`);
|
||||
});
|
||||
```
|
||||
|
||||
**Using `testInfo` for unique identifiers:**
|
||||
|
||||
```ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("submit order", async ({ page }, testInfo) => {
|
||||
const orderId = `order-${testInfo.workerIndex}-${Date.now()}`;
|
||||
await page.goto(`/orders/new?ref=${orderId}`);
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
### Dynamic Shard Count
|
||||
|
||||
**Use when**: Automatically adjusting shards based on test count.
|
||||
|
||||
```yaml
|
||||
# .github/workflows/playwright.yml
|
||||
jobs:
|
||||
calculate-shards:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
shard-count: ${{ steps.calc.outputs.count }}
|
||||
shard-matrix: ${{ steps.calc.outputs.matrix }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: npm ci
|
||||
- id: calc
|
||||
run: |
|
||||
TEST_COUNT=$(npx playwright test --list --reporter=json 2>/dev/null | node -e "
|
||||
const data = require('fs').readFileSync('/dev/stdin', 'utf8');
|
||||
const parsed = JSON.parse(data);
|
||||
console.log(parsed.suites?.reduce((acc, s) => acc + (s.specs?.length || 0), 0) || 0);
|
||||
")
|
||||
# 1 shard per 20 tests, min 1, max 8
|
||||
SHARDS=$(( (TEST_COUNT + 19) / 20 ))
|
||||
SHARDS=$(( SHARDS > 8 ? 8 : SHARDS ))
|
||||
SHARDS=$(( SHARDS < 1 ? 1 : SHARDS ))
|
||||
MATRIX="["
|
||||
for i in $(seq 1 $SHARDS); do
|
||||
[ $i -gt 1 ] && MATRIX+=","
|
||||
MATRIX+="\"$i/$SHARDS\""
|
||||
done
|
||||
MATRIX+="]"
|
||||
echo "count=$SHARDS" >> $GITHUB_OUTPUT
|
||||
echo "matrix=$MATRIX" >> $GITHUB_OUTPUT
|
||||
|
||||
test:
|
||||
needs: calculate-shards
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: ${{ fromJson(needs.calculate-shards.outputs.shard-matrix) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: npm ci
|
||||
- run: npx playwright install --with-deps
|
||||
- run: npx playwright test --shard=${{ matrix.shard }}
|
||||
```
|
||||
|
||||
## Decision Guide
|
||||
|
||||
| Scenario | Workers | Shards | Reason |
|
||||
| -------------------------------- | -------------- | ------ | --------------------------------------- |
|
||||
| < 50 tests, < 5 min | Auto (default) | None | No optimization needed |
|
||||
| 50-200 tests, 5-15 min | `'50%'` in CI | 2-4 | Balance speed and cost |
|
||||
| 200+ tests, > 15 min | `'50%'` in CI | 4-8 | Keep feedback under 10 min |
|
||||
| Flaky due to resource contention | Reduce to 2 | Keep | Less CPU/memory pressure |
|
||||
| Tests modify shared database | 1 or isolate | Useful | Sharding splits files; workers run them |
|
||||
| CI has limited resources | 1 or `'25%'` | More | Compensate with more machines |
|
||||
|
||||
| Aspect | Workers (in-process) | Shards (across machines) |
|
||||
| -------------- | ------------------------- | -------------------------- |
|
||||
| What it splits | Tests across CPU cores | Test files across CI jobs |
|
||||
| Controlled by | Config or `--workers` CLI | `--shard=X/Y` CLI flag |
|
||||
| Shares memory | Yes | No |
|
||||
| Report merging | Not needed | Required (`merge-reports`) |
|
||||
| Cost | Free (same machine) | More CI minutes |
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| --------------------------------------- | ---------------------------------------- | ---------------------------------------------------- |
|
||||
| `fullyParallel: false` without reason | Tests in files run serially | Set `fullyParallel: true` unless tests need serial |
|
||||
| `workers: 1` in CI "for safety" | Negates parallelism | Fix isolation issues; use `workers: '50%'` |
|
||||
| Hardcoded shared user account | Race conditions in parallel runs | Each test creates unique data |
|
||||
| Sharding without blob reporter | Each shard produces separate HTML report | Configure `reporter: [['blob']]` for CI |
|
||||
| Sharding with 3 tests | Setup overhead exceeds time saved | Only shard when suite > 5 minutes |
|
||||
| `test.describe.serial()` everywhere | Kills parallelism, creates dependencies | Use only when tests genuinely need prior state |
|
||||
| Workers > CPU cores | Context switching overhead | Use `'50%'` or auto-detect |
|
||||
| Missing `fail-fast: false` in CI matrix | One shard failure cancels others | Always set `fail-fast: false` for sharded strategies |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tests pass solo but fail together
|
||||
|
||||
- **Shared state**. Make test data unique:
|
||||
```ts
|
||||
test("create item", async ({ request }, ti) => {
|
||||
await request.post("/api/items", {
|
||||
data: { name: `Item-${ti.workerIndex}-${Date.now()}` },
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### "No tests found" in some shards
|
||||
|
||||
- **Too many shards**. Never exceed file count:
|
||||
```bash
|
||||
npx playwright test --shard=1/10 # ok if 10 files
|
||||
npx playwright test --shard=1/20 # too many, some shards empty
|
||||
```
|
||||
|
||||
### Merged report missing results
|
||||
|
||||
- **Blob reports collide**. Use unique names:
|
||||
```yaml
|
||||
# Each shard
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: blob-report-${{ strategy.job-index }}
|
||||
path: blob-report/
|
||||
# Merge step
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: blob-report-*
|
||||
merge-multiple: true
|
||||
path: all-blob-reports
|
||||
```
|
||||
|
||||
### Worker-scoped fixture not working
|
||||
|
||||
- **Missing `{ scope: 'worker' }`**. Fix:
|
||||
```ts
|
||||
export const test = base.extend({
|
||||
resource: [
|
||||
async ({}, use) => {
|
||||
const r = await Resource.create();
|
||||
await use(r);
|
||||
await r.destroy();
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### More workers = Slower
|
||||
|
||||
- **Too many workers thrash**. Limit in CI:
|
||||
```ts
|
||||
export default defineConfig({
|
||||
workers: process.env.CI ? 2 : undefined,
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,453 @@
|
||||
# Performance & Parallelization
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Parallel Execution](#parallel-execution)
|
||||
2. [Sharding](#sharding)
|
||||
3. [Test Optimization](#test-optimization)
|
||||
4. [Network Optimization](#network-optimization)
|
||||
5. [Isolation and Parallel Execution](#isolation-and-parallel-execution)
|
||||
6. [Resource Management](#resource-management)
|
||||
7. [Benchmarking](#benchmarking)
|
||||
|
||||
## Parallel Execution
|
||||
|
||||
### Configuration
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
// Run test files in parallel
|
||||
fullyParallel: true,
|
||||
|
||||
// Number of worker processes
|
||||
workers: process.env.CI ? 1 : undefined, // undefined = half CPU cores
|
||||
|
||||
// Or explicit count
|
||||
// workers: 4,
|
||||
// workers: '50%', // Percentage of CPU cores
|
||||
});
|
||||
```
|
||||
|
||||
### Serial Execution When Needed
|
||||
|
||||
```typescript
|
||||
// Entire file serial
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test.describe("Sequential Tests", () => {
|
||||
test("first", async ({ page }) => {
|
||||
// Runs first
|
||||
});
|
||||
|
||||
test("second", async ({ page }) => {
|
||||
// Runs after first
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Single describe block serial
|
||||
test.describe("Parallel Tests", () => {
|
||||
test("a", async () => {}); // Parallel
|
||||
test("b", async () => {}); // Parallel
|
||||
});
|
||||
|
||||
test.describe.serial("Serial Tests", () => {
|
||||
test("c", async () => {}); // Serial
|
||||
test("d", async () => {}); // Serial
|
||||
});
|
||||
```
|
||||
|
||||
### Parallel Projects
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
|
||||
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```bash
|
||||
# Run all projects in parallel
|
||||
npx playwright test
|
||||
|
||||
# Run specific project
|
||||
npx playwright test --project=chromium
|
||||
```
|
||||
|
||||
## Sharding
|
||||
|
||||
### Basic Sharding
|
||||
|
||||
```bash
|
||||
# Split tests across 4 machines
|
||||
# Machine 1:
|
||||
npx playwright test --shard=1/4
|
||||
|
||||
# Machine 2:
|
||||
npx playwright test --shard=2/4
|
||||
|
||||
# Machine 3:
|
||||
npx playwright test --shard=3/4
|
||||
|
||||
# Machine 4:
|
||||
npx playwright test --shard=4/4
|
||||
```
|
||||
|
||||
### Sharding Strategy
|
||||
|
||||
Tests are distributed evenly by file. For optimal sharding:
|
||||
|
||||
- Keep test files similar in size
|
||||
- Use `fullyParallel: true` for even distribution
|
||||
- Balance slow tests across files
|
||||
|
||||
### CI Sharding Pattern
|
||||
|
||||
```yaml
|
||||
# GitHub Actions
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
steps:
|
||||
- run: npx playwright test --shard=${{ matrix.shard }}/4
|
||||
```
|
||||
|
||||
> **For comprehensive CI sharding** (blob reports, merging sharded results, full workflows), see [ci-cd.md](ci-cd.md#sharding).
|
||||
|
||||
## Test Optimization
|
||||
|
||||
### Reuse Authentication
|
||||
|
||||
Avoid logging in for every test. Use setup projects with storage state to authenticate once and reuse the session.
|
||||
|
||||
> **For authentication patterns** (storage state, multiple auth states, setup projects), see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns).
|
||||
|
||||
### Reuse Page State (serial only — trade-off with isolation)
|
||||
|
||||
Sharing a single page/context across tests with `beforeAll`/`afterAll` is **not recommended** for most suites: it breaks test isolation, causes state leak between tests, and makes failures harder to debug. Prefer a fresh `page` per test (Playwright default). Use shared page only when you explicitly need serial execution and accept no isolation.
|
||||
|
||||
```typescript
|
||||
// ⚠️ Serial only, no isolation: state from one test leaks into the next.
|
||||
// Prefer test.describe.configure({ mode: 'serial' }) + fresh page per test, or beforeEach + page.goto().
|
||||
test.describe.configure({ mode: "serial" });
|
||||
test.describe("Dashboard", () => {
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: ".auth/user.json",
|
||||
});
|
||||
page = await context.newPage();
|
||||
await page.goto("/dashboard");
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page?.close();
|
||||
});
|
||||
|
||||
test("shows stats", async () => {
|
||||
await expect(page.getByTestId("stats")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows chart", async () => {
|
||||
await expect(page.getByTestId("chart")).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Lazy Navigation
|
||||
|
||||
```typescript
|
||||
// Bad: Navigate in every test
|
||||
test("check header", async ({ page }) => {
|
||||
await page.goto("/products");
|
||||
await expect(page.getByRole("heading")).toBeVisible();
|
||||
});
|
||||
|
||||
test("check footer", async ({ page }) => {
|
||||
await page.goto("/products");
|
||||
await expect(page.getByRole("contentinfo")).toBeVisible();
|
||||
});
|
||||
|
||||
// Good: Share navigation
|
||||
test.describe("Products Page", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/products");
|
||||
});
|
||||
|
||||
test("check header", async ({ page }) => {
|
||||
await expect(page.getByRole("heading")).toBeVisible();
|
||||
});
|
||||
|
||||
test("check footer", async ({ page }) => {
|
||||
await expect(page.getByRole("contentinfo")).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Skip Unnecessary Setup
|
||||
|
||||
```typescript
|
||||
// Use test.skip for conditional execution
|
||||
test("admin feature", async ({ page }) => {
|
||||
test.skip(!process.env.ADMIN_ENABLED, "Admin features disabled");
|
||||
// ...
|
||||
});
|
||||
|
||||
// Use test.fixme for known broken tests
|
||||
test.fixme("broken feature", async ({ page }) => {
|
||||
// Skipped but tracked
|
||||
});
|
||||
```
|
||||
|
||||
## Network Optimization
|
||||
|
||||
### Mock APIs
|
||||
|
||||
```typescript
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Mock slow/heavy endpoints
|
||||
await page.route("**/api/analytics", (route) =>
|
||||
route.fulfill({ json: { views: 1000 } }),
|
||||
);
|
||||
|
||||
await page.route("**/api/recommendations", (route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Block Unnecessary Resources
|
||||
|
||||
```typescript
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Block analytics, ads, tracking
|
||||
await page.route("**/*", (route) => {
|
||||
const url = route.request().url();
|
||||
if (
|
||||
url.includes("google-analytics") ||
|
||||
url.includes("facebook") ||
|
||||
url.includes("hotjar")
|
||||
) {
|
||||
return route.abort();
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Block Resource Types
|
||||
|
||||
```typescript
|
||||
// Block images and fonts for faster tests
|
||||
await page.route("**/*", (route) => {
|
||||
const resourceType = route.request().resourceType();
|
||||
if (["image", "font", "stylesheet"].includes(resourceType)) {
|
||||
return route.abort();
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
```
|
||||
|
||||
### Cache API Responses
|
||||
|
||||
```typescript
|
||||
const apiCache = new Map<string, object>();
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const url = route.request().url();
|
||||
|
||||
if (apiCache.has(url)) {
|
||||
return route.fulfill({ json: apiCache.get(url) });
|
||||
}
|
||||
|
||||
const response = await route.fetch();
|
||||
const json = await response.json();
|
||||
apiCache.set(url, json);
|
||||
return route.fulfill({ json });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Isolation and Parallel Execution
|
||||
|
||||
### Default: one context per test
|
||||
|
||||
Playwright gives each test its own browser context (and page). That gives isolation: no shared cookies, storage, or DOM between tests, so failures don’t carry over and you can run tests in any order or in parallel. Keep this default unless you have a clear reason to share state.
|
||||
|
||||
### Avoiding state leak in parallel runs
|
||||
|
||||
- **Do not** rely on shared mutable state (e.g. a single `page` or `context` in `beforeAll`) when tests can run in parallel. State from one test can leak into another and cause flaky, order-dependent failures.
|
||||
- Use **fixtures** for setup/teardown and **`beforeEach`** for per-test navigation so each test gets a fresh page or a clean slate.
|
||||
- For **backend or DB state** shared across tests, isolate per worker so parallel workers don’t collide. Use a worker-scoped fixture and `testInfo.workerIndex` (or `process.env.TEST_WORKER_INDEX`) to create unique data per worker (e.g. unique user or DB prefix). See [fixtures-hooks.md](../core/fixtures-hooks.md) for worker-scoped fixtures and [debugging.md](../debugging/debugging.md) for debugging flaky parallel runs.
|
||||
|
||||
### Debugging flaky parallel runs
|
||||
|
||||
If a test is flaky only with multiple workers:
|
||||
|
||||
1. **Reproduce**: Run with default workers and `--repeat-each=10` (or `--repeat-each=100 --max-failures=1`).
|
||||
2. **Confirm parallel-specific**: Run with `--workers=1`. If the failure disappears, the cause is likely shared state or non-isolated backend/DB data.
|
||||
3. **Fix**: Remove shared page/context; use per-test fixtures and `beforeEach`; isolate test data per worker with `workerIndex` in a worker-scoped fixture.
|
||||
|
||||
Workers are restarted after a test failure so subsequent tests in that worker get a clean environment; fixing isolation still prevents the initial flakiness.
|
||||
|
||||
## Resource Management
|
||||
|
||||
### Browser Contexts
|
||||
|
||||
```typescript
|
||||
// Recommended: One context per test (default) — full isolation
|
||||
test("isolated test", async ({ page }) => {
|
||||
// Fresh context automatically
|
||||
});
|
||||
|
||||
// Manual context for specific needs
|
||||
test("multiple tabs", async ({ browser }) => {
|
||||
const context = await browser.newContext();
|
||||
const page1 = await context.newPage();
|
||||
const page2 = await context.newPage();
|
||||
|
||||
// Clean up
|
||||
await context.close();
|
||||
});
|
||||
```
|
||||
|
||||
### Memory Management
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
// Limit concurrent workers
|
||||
workers: 2,
|
||||
|
||||
// Limit parallel tests per worker
|
||||
use: {
|
||||
// Lower memory usage
|
||||
launchOptions: {
|
||||
args: ["--disable-dev-shm-usage"],
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Timeouts
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
// Global test timeout
|
||||
timeout: 30000,
|
||||
|
||||
// Assertion timeout
|
||||
expect: {
|
||||
timeout: 5000,
|
||||
},
|
||||
|
||||
// Navigation timeout
|
||||
use: {
|
||||
navigationTimeout: 15000,
|
||||
actionTimeout: 10000,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Benchmarking
|
||||
|
||||
### Measure Test Duration
|
||||
|
||||
```typescript
|
||||
test("performance test", async ({ page }, testInfo) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
const loadTime = Date.now() - startTime;
|
||||
console.log(`Page load: ${loadTime}ms`);
|
||||
|
||||
// Add to test report
|
||||
testInfo.annotations.push({
|
||||
type: "performance",
|
||||
description: `Load time: ${loadTime}ms`,
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Performance Metrics
|
||||
|
||||
```typescript
|
||||
test("collect metrics", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const metrics = await page.evaluate(() => ({
|
||||
// Navigation timing
|
||||
loadTime:
|
||||
performance.timing.loadEventEnd - performance.timing.navigationStart,
|
||||
domContentLoaded:
|
||||
performance.timing.domContentLoadedEventEnd -
|
||||
performance.timing.navigationStart,
|
||||
|
||||
// Performance entries
|
||||
resources: performance.getEntriesByType("resource").length,
|
||||
|
||||
// Memory (Chrome only)
|
||||
// @ts-ignore
|
||||
memory: performance.memory?.usedJSHeapSize,
|
||||
}));
|
||||
|
||||
console.log("Metrics:", metrics);
|
||||
expect(metrics.loadTime).toBeLessThan(3000);
|
||||
});
|
||||
```
|
||||
|
||||
### Lighthouse Integration
|
||||
|
||||
```typescript
|
||||
import { playAudit } from "playwright-lighthouse";
|
||||
|
||||
test("lighthouse audit", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const audit = await playAudit({
|
||||
page,
|
||||
thresholds: {
|
||||
performance: 80,
|
||||
accessibility: 90,
|
||||
"best-practices": 80,
|
||||
seo: 80,
|
||||
},
|
||||
port: 9222,
|
||||
});
|
||||
|
||||
expect(audit.lhr.categories.performance.score * 100).toBeGreaterThanOrEqual(
|
||||
80,
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## Performance Checklist
|
||||
|
||||
| Optimization | Impact |
|
||||
| ------------------------------ | ---------- |
|
||||
| Enable `fullyParallel` | High |
|
||||
| Reuse authentication | High |
|
||||
| Mock heavy APIs | High |
|
||||
| Block tracking scripts | Medium |
|
||||
| Use sharding in CI | High |
|
||||
| Reduce workers if memory-bound | Medium |
|
||||
| Cache API responses | Medium |
|
||||
| Skip unnecessary tests | Low-Medium |
|
||||
|
||||
## Related References
|
||||
|
||||
- **CI/CD sharding**: See [ci-cd.md](ci-cd.md) for CI configuration
|
||||
- **Test organization**: See [test-suite-structure.md](../core/test-suite-structure.md) for structuring tests
|
||||
- **Fixtures for reuse**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for authentication patterns
|
||||
@@ -0,0 +1,424 @@
|
||||
# Test Reports & Artifacts
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [CLI Commands](#cli-commands)
|
||||
2. [Reporter Configuration](#reporter-configuration)
|
||||
3. [Custom Reporter](#custom-reporter)
|
||||
4. [Trace Configuration](#trace-configuration)
|
||||
5. [Screenshot & Video Settings](#screenshot--video-settings)
|
||||
6. [Artifact Directory Structure](#artifact-directory-structure)
|
||||
7. [CI Artifact Upload](#ci-artifact-upload)
|
||||
8. [Decision Guide](#decision-guide)
|
||||
9. [Anti-Patterns](#anti-patterns)
|
||||
10. [Troubleshooting](#troubleshooting)
|
||||
|
||||
> **When to use**: Configuring test output for debugging, CI dashboards, and team visibility.
|
||||
|
||||
## CLI Commands
|
||||
|
||||
```bash
|
||||
# Display last HTML report
|
||||
npx playwright show-report
|
||||
|
||||
# Specify reporter
|
||||
npx playwright test --reporter=html
|
||||
npx playwright test --reporter=dot # minimal CI output
|
||||
npx playwright test --reporter=line # one line per test
|
||||
npx playwright test --reporter=json # machine-readable
|
||||
npx playwright test --reporter=junit # CI integration
|
||||
|
||||
# Combine reporters
|
||||
npx playwright test --reporter=dot,html
|
||||
|
||||
# Merge sharded reports
|
||||
npx playwright merge-reports --reporter=html ./blob-report
|
||||
```
|
||||
|
||||
## Reporter Configuration
|
||||
|
||||
### Environment-Based Setup
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
reporter: process.env.CI
|
||||
? [
|
||||
['dot'],
|
||||
['html', { open: 'never' }],
|
||||
['junit', { outputFile: 'results/junit.xml' }],
|
||||
['github'],
|
||||
]
|
||||
: [
|
||||
['list'],
|
||||
['html', { open: 'on-failure' }],
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Reporter Types
|
||||
|
||||
| Reporter | Output | Use Case |
|
||||
|---|---|---|
|
||||
| `list` | One line per test | Local development |
|
||||
| `line` | Single updating line | Local, less verbose |
|
||||
| `dot` | `.` pass, `F` fail | CI logs |
|
||||
| `html` | Interactive HTML page | Post-run analysis |
|
||||
| `json` | Machine-readable JSON | Custom tooling |
|
||||
| `junit` | JUnit XML | CI platforms |
|
||||
| `github` | PR annotations | GitHub Actions |
|
||||
| `blob` | Binary archive | Shard merging |
|
||||
|
||||
### JSON Output to File
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
reporter: [
|
||||
['json', { outputFile: 'results/output.json' }],
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### JUnit Customization
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
reporter: [
|
||||
['junit', {
|
||||
outputFile: 'results/junit.xml',
|
||||
stripANSIControlSequences: true,
|
||||
includeProjectInTestName: true,
|
||||
}],
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Custom Reporter
|
||||
|
||||
Build custom reporters for Slack notifications, database logging, or dashboards.
|
||||
|
||||
```typescript
|
||||
// reporters/notification-reporter.ts
|
||||
import type {
|
||||
FullResult,
|
||||
Reporter,
|
||||
TestCase,
|
||||
TestResult,
|
||||
} from '@playwright/test/reporter';
|
||||
|
||||
class NotificationReporter implements Reporter {
|
||||
private passed = 0;
|
||||
private failed = 0;
|
||||
private skipped = 0;
|
||||
private failures: string[] = [];
|
||||
|
||||
onTestEnd(test: TestCase, result: TestResult) {
|
||||
switch (result.status) {
|
||||
case 'passed':
|
||||
this.passed++;
|
||||
break;
|
||||
case 'failed':
|
||||
case 'timedOut':
|
||||
this.failed++;
|
||||
this.failures.push(`${test.title}: ${result.error?.message?.split('\n')[0]}`);
|
||||
break;
|
||||
case 'skipped':
|
||||
this.skipped++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async onEnd(result: FullResult) {
|
||||
const total = this.passed + this.failed + this.skipped;
|
||||
const status = this.failed > 0 ? 'FAILED' : 'PASSED';
|
||||
const message = [
|
||||
`Tests ${status}`,
|
||||
`Passed: ${this.passed} | Failed: ${this.failed} | Skipped: ${this.skipped}`,
|
||||
`Duration: ${(result.duration / 1000).toFixed(1)}s`,
|
||||
];
|
||||
|
||||
if (this.failures.length > 0) {
|
||||
message.push('', 'Failures:');
|
||||
this.failures.slice(0, 5).forEach((f) => message.push(` - ${f}`));
|
||||
if (this.failures.length > 5) {
|
||||
message.push(` ...and ${this.failures.length - 5} more`);
|
||||
}
|
||||
}
|
||||
|
||||
const webhookUrl = process.env.NOTIFICATION_WEBHOOK;
|
||||
if (webhookUrl) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
try {
|
||||
await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: message.join('\n') }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
// Intentionally swallow notifier failures to avoid blocking test completion
|
||||
console.warn('Webhook notification failed:', error.message);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default NotificationReporter;
|
||||
```
|
||||
|
||||
**Register custom reporter:**
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
reporter: [
|
||||
['dot'],
|
||||
['html', { open: 'never' }],
|
||||
['./reporters/notification-reporter.ts'],
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Trace Configuration
|
||||
|
||||
Traces capture actions, network requests, DOM snapshots, and console logs.
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
use: {
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Trace Options
|
||||
|
||||
| Value | Behavior | Overhead |
|
||||
|---|---|---|
|
||||
| `'off'` | Never records | None |
|
||||
| `'on'` | Every test | High |
|
||||
| `'on-first-retry'` | On first retry after failure | Minimal |
|
||||
| `'retain-on-failure'` | Records all, keeps failures | Medium |
|
||||
| `'retain-on-first-failure'` | Records all, keeps first failure | Medium |
|
||||
|
||||
### Viewing Traces
|
||||
|
||||
```bash
|
||||
# Local trace viewer
|
||||
npx playwright show-trace results/my-test/trace.zip
|
||||
|
||||
# From HTML report (click Traces tab)
|
||||
npx playwright show-report
|
||||
|
||||
# Online viewer: https://trace.playwright.dev
|
||||
```
|
||||
|
||||
## Screenshot & Video Settings
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
use: {
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Video with Custom Size
|
||||
|
||||
```typescript
|
||||
use: {
|
||||
video: {
|
||||
mode: 'retain-on-failure',
|
||||
size: { width: 1280, height: 720 },
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
### Screenshot Options
|
||||
|
||||
| Value | Captures | Disk Cost |
|
||||
|---|---|---|
|
||||
| `'off'` | Never | None |
|
||||
| `'on'` | Every test | High |
|
||||
| `'only-on-failure'` | Failed tests | Low |
|
||||
|
||||
### Video Options
|
||||
|
||||
| Value | Records | Keeps | Disk Cost |
|
||||
|---|---|---|---|
|
||||
| `'off'` | Never | — | None |
|
||||
| `'on'` | Every test | All | Very high |
|
||||
| `'on-first-retry'` | On retry | Retried | Low |
|
||||
| `'retain-on-failure'` | Every test | Failed | Medium |
|
||||
|
||||
## Artifact Directory Structure
|
||||
|
||||
```text
|
||||
test-results/
|
||||
├── checkout-test-chromium/
|
||||
│ ├── trace.zip
|
||||
│ ├── test-failed-1.png
|
||||
│ └── video.webm
|
||||
├── login-test-firefox/
|
||||
│ ├── trace.zip
|
||||
│ └── test-failed-1.png
|
||||
└── junit.xml
|
||||
|
||||
playwright-report/
|
||||
├── index.html
|
||||
└── data/
|
||||
|
||||
blob-report/
|
||||
└── report-1.zip
|
||||
```
|
||||
|
||||
## CI Artifact Upload
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
```yaml
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 14
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: test-traces
|
||||
path: |
|
||||
test-results/**/trace.zip
|
||||
test-results/**/*.png
|
||||
test-results/**/*.webm
|
||||
retention-days: 7
|
||||
```
|
||||
|
||||
## Decision Guide
|
||||
|
||||
| Scenario | Reporter Configuration |
|
||||
|---|---|
|
||||
| Local development | `[['list'], ['html', { open: 'on-failure' }]]` |
|
||||
| GitHub Actions | `[['dot'], ['html'], ['github']]` |
|
||||
| GitLab CI | `[['dot'], ['html'], ['junit']]` |
|
||||
| Azure DevOps / Jenkins | `[['dot'], ['html'], ['junit']]` |
|
||||
| Sharded CI | `[['blob'], ['github']]` |
|
||||
| Custom dashboard | `[['json', { outputFile: '...' }]]` + custom reporter |
|
||||
|
||||
| Artifact | When to Collect | Retention | Upload Condition |
|
||||
|---|---|---|---|
|
||||
| HTML report | Always | 14 days | `if: ${{ !cancelled() }}` |
|
||||
| Traces | On failure | 7 days | `if: failure()` |
|
||||
| Screenshots | On failure | 7 days | `if: failure()` |
|
||||
| Videos | On failure | 7 days | `if: failure()` |
|
||||
| JUnit XML | Always | 14 days | `if: ${{ !cancelled() }}` |
|
||||
| Blob report | Always (sharded) | 1 day | `if: ${{ !cancelled() }}` |
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
|---|---|---|
|
||||
| No reporter configured | Default `list` only; no persistent report | Configure `html` + CI reporter |
|
||||
| `trace: 'on'` in CI | Massive artifacts, slow uploads | Use `trace: 'on-first-retry'` |
|
||||
| `video: 'on'` in CI | Enormous storage, slower tests | Use `video: 'retain-on-failure'` |
|
||||
| Upload artifacts only on failure | No report when tests pass | Upload with `if: ${{ !cancelled() }}` |
|
||||
| No retention limits | CI storage fills quickly | Set `retention-days: 7-14` |
|
||||
| Only `dot` reporter | Cannot drill into failures | Pair `dot` with `html` |
|
||||
| JUnit to stdout | Interferes with console output | Write to file |
|
||||
| Blocking `onEnd` in custom reporter | Slow HTTP calls delay pipeline | Use `Promise.race` with timeout |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Empty HTML Report
|
||||
|
||||
Check reporter config. HTML report defaults to `playwright-report/`:
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
reporter: [['html', { outputFolder: 'playwright-report', open: 'never' }]],
|
||||
});
|
||||
```
|
||||
|
||||
### Traces Too Large
|
||||
|
||||
Switch from `trace: 'on'` to `'on-first-retry'` with retries enabled:
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
use: {
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### JUnit XML Not Recognized
|
||||
|
||||
Ensure path matches CI configuration:
|
||||
|
||||
```typescript
|
||||
reporter: [['junit', { outputFile: 'results/junit.xml' }]],
|
||||
```
|
||||
|
||||
```yaml
|
||||
# GitHub Actions
|
||||
- uses: dorny/test-reporter@latest
|
||||
with:
|
||||
path: results/junit.xml
|
||||
reporter: java-junit
|
||||
|
||||
# Azure DevOps
|
||||
- task: PublishTestResults@latest
|
||||
inputs:
|
||||
testResultsFiles: 'results/junit.xml'
|
||||
|
||||
# Jenkins
|
||||
junit 'results/junit.xml'
|
||||
```
|
||||
|
||||
### Empty Merged Report
|
||||
|
||||
Use `blob` reporter for sharded runs (not `html`):
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
reporter: process.env.CI
|
||||
? [['blob'], ['dot']]
|
||||
: [['html', { open: 'on-failure' }]],
|
||||
});
|
||||
```
|
||||
|
||||
### Missing Screenshots in Report
|
||||
|
||||
Enable screenshots and keep both directories:
|
||||
|
||||
```typescript
|
||||
use: {
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
```
|
||||
|
||||
The HTML report embeds screenshots from `test-results/`. Deleting that directory removes screenshots from the report.
|
||||
@@ -0,0 +1,497 @@
|
||||
# Test Coverage
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Coverage Setup](#coverage-setup)
|
||||
2. [Collecting Coverage](#collecting-coverage)
|
||||
3. [Coverage Reports](#coverage-reports)
|
||||
4. [Coverage Thresholds](#coverage-thresholds)
|
||||
5. [Advanced Patterns](#advanced-patterns)
|
||||
6. [CI Integration](#ci-integration)
|
||||
|
||||
## Coverage Setup
|
||||
|
||||
### Install Dependencies
|
||||
|
||||
```bash
|
||||
# For V8 coverage (built into Playwright)
|
||||
# No additional dependencies needed
|
||||
|
||||
# For Istanbul-based coverage (more features)
|
||||
npm install -D nyc @istanbuljs/nyc-config-typescript
|
||||
```
|
||||
|
||||
### Basic Configuration
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
use: {
|
||||
// Enable coverage collection
|
||||
contextOptions: {
|
||||
// V8 coverage is automatic with the API below
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### V8 Coverage Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/coverage.ts
|
||||
import { test as base, expect } from "@playwright/test";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export const test = base.extend<{}, { collectCoverage: void }>({
|
||||
collectCoverage: [
|
||||
async ({ browser }, use) => {
|
||||
// Start coverage for all pages
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.coverage.startJSCoverage();
|
||||
await page.coverage.startCSSCoverage();
|
||||
|
||||
await use();
|
||||
|
||||
// Collect coverage
|
||||
const [jsCoverage, cssCoverage] = await Promise.all([
|
||||
page.coverage.stopJSCoverage(),
|
||||
page.coverage.stopCSSCoverage(),
|
||||
]);
|
||||
|
||||
// Save coverage data
|
||||
const coverageDir = "./coverage";
|
||||
if (!fs.existsSync(coverageDir)) {
|
||||
fs.mkdirSync(coverageDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(coverageDir, `coverage-${randomUUID()}.json`),
|
||||
JSON.stringify([...jsCoverage, ...cssCoverage])
|
||||
);
|
||||
|
||||
await context.close();
|
||||
},
|
||||
{ scope: "worker", auto: true },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Collecting Coverage
|
||||
|
||||
### Per-Test Coverage
|
||||
|
||||
```typescript
|
||||
test("collect coverage for single test", async ({ page }) => {
|
||||
// Start coverage collection
|
||||
await page.coverage.startJSCoverage({
|
||||
resetOnNavigation: false,
|
||||
});
|
||||
|
||||
// Run test
|
||||
await page.goto("/app");
|
||||
await page.getByRole("button", { name: "Submit" }).click();
|
||||
await expect(page.getByText("Success")).toBeVisible();
|
||||
|
||||
// Stop and get coverage
|
||||
const coverage = await page.coverage.stopJSCoverage();
|
||||
|
||||
// Filter to only your source files
|
||||
const appCoverage = coverage.filter((entry) => entry.url.includes("/src/"));
|
||||
|
||||
console.log(`Covered ${appCoverage.length} source files`);
|
||||
});
|
||||
```
|
||||
|
||||
### Coverage for Specific Files
|
||||
|
||||
```typescript
|
||||
test("track specific module coverage", async ({ page }) => {
|
||||
await page.coverage.startJSCoverage();
|
||||
|
||||
await page.goto("/checkout");
|
||||
await page.getByRole("button", { name: "Pay" }).click();
|
||||
|
||||
const coverage = await page.coverage.stopJSCoverage();
|
||||
|
||||
// Find coverage for checkout module
|
||||
const checkoutCoverage = coverage.find((c) => c.url.includes("checkout.js"));
|
||||
|
||||
if (checkoutCoverage) {
|
||||
const totalBytes = checkoutCoverage.text?.length || 0;
|
||||
const coveredBytes = checkoutCoverage.ranges.reduce(
|
||||
(sum, range) => sum + (range.end - range.start),
|
||||
0
|
||||
);
|
||||
const percentage = (coveredBytes / totalBytes) * 100;
|
||||
|
||||
console.log(`Checkout module: ${percentage.toFixed(1)}% covered`);
|
||||
expect(percentage).toBeGreaterThan(80);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### CSS Coverage
|
||||
|
||||
```typescript
|
||||
test("collect CSS coverage", async ({ page }) => {
|
||||
await page.coverage.startCSSCoverage();
|
||||
|
||||
await page.goto("/app");
|
||||
|
||||
// Interact to trigger different CSS states
|
||||
await page.getByRole("button").hover();
|
||||
await page.getByRole("dialog").waitFor();
|
||||
|
||||
const cssCoverage = await page.coverage.stopCSSCoverage();
|
||||
|
||||
// Find unused CSS
|
||||
for (const entry of cssCoverage) {
|
||||
const totalBytes = entry.text?.length || 0;
|
||||
const usedBytes = entry.ranges.reduce(
|
||||
(sum, range) => sum + (range.end - range.start),
|
||||
0
|
||||
);
|
||||
const unusedPercentage = ((totalBytes - usedBytes) / totalBytes) * 100;
|
||||
|
||||
if (unusedPercentage > 50) {
|
||||
console.warn(`${entry.url}: ${unusedPercentage.toFixed(1)}% unused CSS`);
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Coverage Reports
|
||||
|
||||
### Converting to Istanbul Format
|
||||
|
||||
```typescript
|
||||
// scripts/convert-coverage.ts
|
||||
import { execSync } from "child_process";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import v8ToIstanbul from "v8-to-istanbul";
|
||||
|
||||
async function convertCoverage() {
|
||||
const coverageDir = "./coverage";
|
||||
const files = fs.readdirSync(coverageDir).filter((f) => f.endsWith(".json"));
|
||||
|
||||
const istanbulCoverage: any = {};
|
||||
|
||||
for (const file of files) {
|
||||
const coverageData = JSON.parse(
|
||||
fs.readFileSync(path.join(coverageDir, file), "utf-8")
|
||||
);
|
||||
|
||||
for (const entry of coverageData) {
|
||||
if (!entry.url.startsWith("file://")) continue;
|
||||
|
||||
const filePath = entry.url.replace("file://", "");
|
||||
const converter = v8ToIstanbul(filePath);
|
||||
|
||||
await converter.load();
|
||||
converter.applyCoverage(entry.functions || []);
|
||||
|
||||
const istanbul = converter.toIstanbul();
|
||||
Object.assign(istanbulCoverage, istanbul);
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(coverageDir, "coverage-final.json"),
|
||||
JSON.stringify(istanbulCoverage)
|
||||
);
|
||||
}
|
||||
|
||||
convertCoverage();
|
||||
```
|
||||
|
||||
### Generating HTML Report
|
||||
|
||||
```bash
|
||||
# Using nyc to generate report
|
||||
npx nyc report --reporter=html --reporter=text --temp-dir=./coverage
|
||||
```
|
||||
|
||||
```typescript
|
||||
// package.json scripts
|
||||
{
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:coverage": "playwright test && npm run coverage:report",
|
||||
"coverage:report": "npx nyc report --reporter=html --reporter=lcov --temp-dir=./coverage"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Coverage Reporter
|
||||
|
||||
```typescript
|
||||
// reporters/coverage-reporter.ts
|
||||
import type { Reporter, FullResult } from "@playwright/test/reporter";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
class CoverageReporter implements Reporter {
|
||||
private coverageData: any[] = [];
|
||||
|
||||
onEnd(result: FullResult) {
|
||||
// Aggregate all coverage files
|
||||
const coverageDir = "./coverage";
|
||||
const files = fs
|
||||
.readdirSync(coverageDir)
|
||||
.filter((f) => f.endsWith(".json"));
|
||||
|
||||
for (const file of files) {
|
||||
const data = JSON.parse(
|
||||
fs.readFileSync(path.join(coverageDir, file), "utf-8")
|
||||
);
|
||||
this.coverageData.push(...data);
|
||||
}
|
||||
|
||||
// Generate summary
|
||||
const summary = this.generateSummary();
|
||||
console.log("\n📊 Coverage Summary:");
|
||||
console.log(` Files: ${summary.totalFiles}`);
|
||||
console.log(` Lines: ${summary.lineCoverage.toFixed(1)}%`);
|
||||
console.log(` Bytes: ${summary.byteCoverage.toFixed(1)}%`);
|
||||
|
||||
if (summary.lineCoverage < 80) {
|
||||
console.warn("⚠️ Coverage below 80% threshold!");
|
||||
}
|
||||
}
|
||||
|
||||
private generateSummary() {
|
||||
let totalBytes = 0;
|
||||
let coveredBytes = 0;
|
||||
const files = new Set<string>();
|
||||
|
||||
for (const entry of this.coverageData) {
|
||||
if (entry.url.includes("/src/")) {
|
||||
files.add(entry.url);
|
||||
totalBytes += entry.text?.length || 0;
|
||||
coveredBytes += entry.ranges.reduce(
|
||||
(sum: number, r: any) => sum + (r.end - r.start),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalFiles: files.size,
|
||||
byteCoverage: (coveredBytes / totalBytes) * 100,
|
||||
lineCoverage: (coveredBytes / totalBytes) * 100, // Simplified
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default CoverageReporter;
|
||||
```
|
||||
|
||||
## Coverage Thresholds
|
||||
|
||||
### Enforcing Minimum Coverage
|
||||
|
||||
```typescript
|
||||
// tests/coverage.spec.ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
test.afterAll(async () => {
|
||||
const coverageDir = "./coverage";
|
||||
const files = fs.readdirSync(coverageDir).filter((f) => f.endsWith(".json"));
|
||||
|
||||
let totalBytes = 0;
|
||||
let coveredBytes = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const coverage = JSON.parse(
|
||||
fs.readFileSync(path.join(coverageDir, file), "utf-8")
|
||||
);
|
||||
|
||||
for (const entry of coverage) {
|
||||
if (!entry.url.includes("/src/")) continue;
|
||||
totalBytes += entry.text?.length || 0;
|
||||
coveredBytes += entry.ranges.reduce(
|
||||
(sum: number, r: any) => sum + (r.end - r.start),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const coveragePercent = (coveredBytes / totalBytes) * 100;
|
||||
|
||||
// Enforce threshold
|
||||
expect(coveragePercent).toBeGreaterThan(80);
|
||||
});
|
||||
```
|
||||
|
||||
### Per-Directory Thresholds
|
||||
|
||||
```typescript
|
||||
// coverage-check.ts
|
||||
interface CoverageThreshold {
|
||||
pattern: RegExp;
|
||||
minCoverage: number;
|
||||
}
|
||||
|
||||
const thresholds: CoverageThreshold[] = [
|
||||
{ pattern: /\/src\/core\//, minCoverage: 90 },
|
||||
{ pattern: /\/src\/utils\//, minCoverage: 85 },
|
||||
{ pattern: /\/src\/components\//, minCoverage: 70 },
|
||||
{ pattern: /\/src\/pages\//, minCoverage: 60 },
|
||||
];
|
||||
|
||||
function checkThresholds(coverage: any[]): string[] {
|
||||
const violations: string[] = [];
|
||||
|
||||
for (const threshold of thresholds) {
|
||||
const matchingFiles = coverage.filter((c) => threshold.pattern.test(c.url));
|
||||
|
||||
let total = 0;
|
||||
let covered = 0;
|
||||
|
||||
for (const file of matchingFiles) {
|
||||
total += file.text?.length || 0;
|
||||
covered += file.ranges.reduce(
|
||||
(sum: number, r: any) => sum + (r.end - r.start),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
const percent = total > 0 ? (covered / total) * 100 : 0;
|
||||
|
||||
if (percent < threshold.minCoverage) {
|
||||
violations.push(
|
||||
`${threshold.pattern}: ${percent.toFixed(1)}% < ${
|
||||
threshold.minCoverage
|
||||
}%`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### Merging Coverage Across Shards
|
||||
|
||||
```typescript
|
||||
// scripts/merge-coverage.ts
|
||||
import fs from "fs";
|
||||
import { glob } from "glob";
|
||||
|
||||
async function mergeCoverage() {
|
||||
const files = await glob("shard-*/coverage/*.json");
|
||||
const merged = new Map<string, any>();
|
||||
|
||||
for (const file of files) {
|
||||
const data = JSON.parse(fs.readFileSync(file, "utf-8"));
|
||||
for (const entry of data) {
|
||||
if (merged.has(entry.url)) {
|
||||
const existing = merged.get(entry.url);
|
||||
existing.ranges.push(...entry.ranges);
|
||||
} else {
|
||||
merged.set(entry.url, { ...entry });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
"./coverage/merged.json",
|
||||
JSON.stringify([...merged.values()])
|
||||
);
|
||||
}
|
||||
|
||||
mergeCoverage();
|
||||
```
|
||||
|
||||
### Incremental Coverage
|
||||
|
||||
```typescript
|
||||
// Check coverage only for changed files in CI
|
||||
import { execSync } from "child_process";
|
||||
import fs from "fs";
|
||||
|
||||
const changedFiles = execSync("git diff --name-only HEAD~1")
|
||||
.toString()
|
||||
.split("\n")
|
||||
.filter((f) => f.endsWith(".ts"));
|
||||
|
||||
const coverage = JSON.parse(fs.readFileSync("./coverage/merged.json", "utf-8"));
|
||||
|
||||
for (const file of changedFiles) {
|
||||
const entry = coverage.find((c: any) => c.url.includes(file));
|
||||
if (entry) {
|
||||
const percent =
|
||||
(entry.ranges.reduce((s: number, r: any) => s + r.end - r.start, 0) /
|
||||
(entry.text?.length || 1)) *
|
||||
100;
|
||||
console.log(`${file}: ${percent.toFixed(1)}%`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## CI Integration
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml
|
||||
name: Tests with Coverage
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- run: npm ci
|
||||
- run: npx playwright install --with-deps
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: npm run test:coverage
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
files: ./coverage/lcov.info
|
||||
fail_ci_if_error: true
|
||||
|
||||
- name: Check coverage threshold
|
||||
run: |
|
||||
COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')
|
||||
if (( $(echo "$COVERAGE < 80" | bc -l) )); then
|
||||
echo "Coverage $COVERAGE% is below 80% threshold"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ---------------------------- | -------------------------------------- | --------------------------- |
|
||||
| Coverage for coverage's sake | Gaming metrics | Focus on critical paths |
|
||||
| 100% coverage target | Diminishing returns, tests for getters | Set realistic thresholds |
|
||||
| Ignoring coverage drops | Technical debt | Enforce thresholds in CI |
|
||||
| No source map support | Wrong line numbers | Enable source maps in build |
|
||||
| Coverage only in CI | Late feedback | Run locally too |
|
||||
|
||||
## Related References
|
||||
|
||||
- **CI/CD**: See [ci-cd.md](ci-cd.md) for pipeline configuration
|
||||
- **Performance**: See [performance.md](performance.md) for optimizing coverage collection
|
||||
@@ -0,0 +1,359 @@
|
||||
# Accessibility Testing
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Axe-Core Integration](#axe-core-integration)
|
||||
2. [Keyboard Navigation](#keyboard-navigation)
|
||||
3. [ARIA Validation](#aria-validation)
|
||||
4. [Focus Management](#focus-management)
|
||||
5. [Color & Contrast](#color--contrast)
|
||||
|
||||
## Axe-Core Integration
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
npm install -D @axe-core/playwright
|
||||
```
|
||||
|
||||
### Basic A11y Test
|
||||
|
||||
```typescript
|
||||
import { test, expect } from "@playwright/test";
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
|
||||
test("homepage should have no a11y violations", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const results = await new AxeBuilder({ page }).analyze();
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
```
|
||||
|
||||
### Scoped Analysis
|
||||
|
||||
```typescript
|
||||
test("form accessibility", async ({ page }) => {
|
||||
await page.goto("/contact");
|
||||
|
||||
// Analyze only the form
|
||||
const results = await new AxeBuilder({ page })
|
||||
.include("#contact-form")
|
||||
.analyze();
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
|
||||
test("ignore known issues", async ({ page }) => {
|
||||
await page.goto("/legacy-page");
|
||||
|
||||
const results = await new AxeBuilder({ page })
|
||||
.exclude(".legacy-widget") // Skip legacy component
|
||||
.disableRules(["color-contrast"]) // Disable specific rule
|
||||
.analyze();
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
```
|
||||
|
||||
### A11y Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/a11y.fixture.ts
|
||||
import { test as base } from "@playwright/test";
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
|
||||
type A11yFixtures = {
|
||||
makeAxeBuilder: () => AxeBuilder;
|
||||
};
|
||||
|
||||
export const test = base.extend<A11yFixtures>({
|
||||
makeAxeBuilder: async ({ page }, use) => {
|
||||
await use(() =>
|
||||
new AxeBuilder({ page }).withTags([
|
||||
"wcag2a",
|
||||
"wcag2aa",
|
||||
"wcag21a",
|
||||
"wcag21aa",
|
||||
]),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
test("dashboard a11y", async ({ page, makeAxeBuilder }) => {
|
||||
await page.goto("/dashboard");
|
||||
const results = await makeAxeBuilder().analyze();
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
```
|
||||
|
||||
### Detailed Violation Reporting
|
||||
|
||||
```typescript
|
||||
test("report a11y issues", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const results = await new AxeBuilder({ page }).analyze();
|
||||
|
||||
// Custom failure message with details
|
||||
const violations = results.violations.map((v) => ({
|
||||
id: v.id,
|
||||
impact: v.impact,
|
||||
description: v.description,
|
||||
nodes: v.nodes.map((n) => n.html),
|
||||
}));
|
||||
|
||||
expect(violations, JSON.stringify(violations, null, 2)).toHaveLength(0);
|
||||
});
|
||||
```
|
||||
|
||||
## Keyboard Navigation
|
||||
|
||||
### Tab Order Testing
|
||||
|
||||
```typescript
|
||||
test("correct tab order in form", async ({ page }) => {
|
||||
await page.goto("/signup");
|
||||
|
||||
// Start from the beginning
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.getByLabel("Email")).toBeFocused();
|
||||
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.getByLabel("Password")).toBeFocused();
|
||||
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.getByRole("button", { name: "Sign up" })).toBeFocused();
|
||||
});
|
||||
```
|
||||
|
||||
### Keyboard-Only Interaction
|
||||
|
||||
```typescript
|
||||
test("complete flow with keyboard only", async ({ page }) => {
|
||||
await page.goto("/products");
|
||||
|
||||
// Navigate to product with keyboard
|
||||
await page.keyboard.press("Tab"); // Skip to main content
|
||||
await page.keyboard.press("Tab"); // First product
|
||||
await page.keyboard.press("Enter"); // Open product
|
||||
|
||||
await expect(page).toHaveURL(/\/products\/\d+/);
|
||||
|
||||
// Add to cart with keyboard
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.press("Tab"); // Navigate to "Add to Cart"
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText("Added to cart");
|
||||
});
|
||||
```
|
||||
|
||||
### Skip Links
|
||||
|
||||
```typescript
|
||||
test("skip link works", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.keyboard.press("Tab");
|
||||
const skipLink = page.getByRole("link", { name: /skip to main/i });
|
||||
await expect(skipLink).toBeFocused();
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
// Focus should move to main content
|
||||
await expect(page.getByRole("main")).toBeFocused();
|
||||
});
|
||||
```
|
||||
|
||||
### Escape Key Handling
|
||||
|
||||
```typescript
|
||||
test("escape closes modal", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
await page.getByRole("button", { name: "Settings" }).click();
|
||||
|
||||
const modal = page.getByRole("dialog");
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(modal).toBeHidden();
|
||||
// Focus should return to trigger
|
||||
await expect(page.getByRole("button", { name: "Settings" })).toBeFocused();
|
||||
});
|
||||
```
|
||||
|
||||
## ARIA Validation
|
||||
|
||||
### Role Verification
|
||||
|
||||
```typescript
|
||||
test("correct ARIA roles", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
|
||||
// Verify landmark roles
|
||||
await expect(page.getByRole("navigation")).toBeVisible();
|
||||
await expect(page.getByRole("main")).toBeVisible();
|
||||
await expect(page.getByRole("contentinfo")).toBeVisible(); // footer
|
||||
|
||||
// Verify interactive roles
|
||||
await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
|
||||
await expect(page.getByRole("search")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### ARIA States
|
||||
|
||||
```typescript
|
||||
test("aria-expanded updates correctly", async ({ page }) => {
|
||||
await page.goto("/faq");
|
||||
|
||||
const accordion = page.getByRole("button", { name: "Shipping" });
|
||||
|
||||
// Initially collapsed
|
||||
await expect(accordion).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
await accordion.click();
|
||||
|
||||
// Now expanded
|
||||
await expect(accordion).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
// Content is visible
|
||||
const panel = page.getByRole("region", { name: "Shipping" });
|
||||
await expect(panel).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Live Regions
|
||||
|
||||
```typescript
|
||||
test("live region announces updates", async ({ page }) => {
|
||||
await page.goto("/checkout");
|
||||
|
||||
// Find live region
|
||||
const liveRegion = page.locator('[aria-live="polite"]');
|
||||
|
||||
await page.getByLabel("Quantity").fill("3");
|
||||
|
||||
// Live region should update with new total
|
||||
await expect(liveRegion).toContainText("Total: $29.97");
|
||||
});
|
||||
```
|
||||
|
||||
## Focus Management
|
||||
|
||||
### Focus Trap in Modal
|
||||
|
||||
```typescript
|
||||
test("focus trapped in modal", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Open Modal" }).click();
|
||||
|
||||
const modal = page.getByRole("dialog");
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// Get all focusable elements in modal
|
||||
const focusableElements = modal.locator(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
const count = await focusableElements.count();
|
||||
|
||||
// Tab through all elements, should stay in modal
|
||||
for (let i = 0; i < count + 1; i++) {
|
||||
await page.keyboard.press("Tab");
|
||||
const focused = page.locator(":focus");
|
||||
await expect(modal).toContainText((await focused.textContent()) || "");
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Focus Restoration
|
||||
|
||||
```typescript
|
||||
test("focus returns after modal close", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const trigger = page.getByRole("button", { name: "Delete Item" });
|
||||
await trigger.click();
|
||||
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
|
||||
// Focus should return to the trigger
|
||||
await expect(trigger).toBeFocused();
|
||||
});
|
||||
```
|
||||
|
||||
## Color & Contrast
|
||||
|
||||
### High Contrast Mode
|
||||
|
||||
```typescript
|
||||
test("works in high contrast mode", async ({ page }) => {
|
||||
await page.emulateMedia({ forcedColors: "active" });
|
||||
await page.goto("/");
|
||||
|
||||
// Verify key elements are visible
|
||||
await expect(page.getByRole("navigation")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Sign In" })).toBeVisible();
|
||||
|
||||
// Take screenshot for visual verification
|
||||
await expect(page).toHaveScreenshot("high-contrast.png");
|
||||
});
|
||||
```
|
||||
|
||||
### Reduced Motion
|
||||
|
||||
```typescript
|
||||
test("respects reduced motion preference", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.goto("/");
|
||||
|
||||
// Animations should be disabled
|
||||
const hero = page.getByTestId("hero-animation");
|
||||
const animation = await hero.evaluate(
|
||||
(el) => getComputedStyle(el).animationDuration,
|
||||
);
|
||||
|
||||
expect(animation).toBe("0s");
|
||||
});
|
||||
```
|
||||
|
||||
## CI Integration
|
||||
|
||||
### A11y as CI Gate
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "a11y",
|
||||
testMatch: /.*\.a11y\.spec\.ts/,
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```yaml
|
||||
# .github/workflows/a11y.yml
|
||||
- name: Run accessibility tests
|
||||
run: npx playwright test --project=a11y
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ----------------------------- | ---------------------------- | ------------------------------------------ |
|
||||
| Testing a11y only on homepage | Misses issues on other pages | Test all critical user flows |
|
||||
| Ignoring all violations | No value from tests | Address or explicitly exclude known issues |
|
||||
| Only automated testing | Misses many a11y issues | Combine with manual testing |
|
||||
| Testing without screen reader | Misses interaction issues | Test with VoiceOver/NVDA periodically |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Locators**: See [locators.md](../core/locators.md) for role-based selectors
|
||||
- **Visual testing**: See [test-suite-structure.md](../core/test-suite-structure.md) for screenshot comparison
|
||||
@@ -0,0 +1,719 @@
|
||||
# API Testing
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Patterns](#patterns)
|
||||
2. [Decision Guide](#decision-guide)
|
||||
3. [Anti-Patterns](#anti-patterns)
|
||||
4. [Troubleshooting](#troubleshooting)
|
||||
|
||||
> **When to use**: Testing REST APIs directly — validating endpoints, seeding test data, or verifying backend behavior without browser overhead.
|
||||
> **See also**: [graphql-testing.md](graphql-testing.md) for GraphQL-specific patterns.
|
||||
|
||||
## Patterns
|
||||
|
||||
### Request Fixtures for Authenticated Clients
|
||||
|
||||
**Use when**: Multiple tests need an authenticated API client with shared configuration.
|
||||
**Avoid when**: A single test makes one-off API calls — use the built-in `request` fixture directly.
|
||||
|
||||
```typescript
|
||||
// fixtures/api-fixtures.ts
|
||||
import { test as base, expect, APIRequestContext } from "@playwright/test";
|
||||
|
||||
type ApiFixtures = {
|
||||
authApi: APIRequestContext;
|
||||
adminApi: APIRequestContext;
|
||||
};
|
||||
|
||||
export const test = base.extend<ApiFixtures>({
|
||||
authApi: async ({ playwright }, use) => {
|
||||
const ctx = await playwright.request.newContext({
|
||||
baseURL: "https://api.myapp.io",
|
||||
extraHTTPHeaders: {
|
||||
Authorization: `Bearer ${process.env.API_TOKEN}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
await use(ctx);
|
||||
await ctx.dispose();
|
||||
},
|
||||
|
||||
adminApi: async ({ playwright }, use) => {
|
||||
const loginCtx = await playwright.request.newContext({
|
||||
baseURL: "https://api.myapp.io",
|
||||
});
|
||||
const loginResp = await loginCtx.post("/auth/login", {
|
||||
data: {
|
||||
email: process.env.ADMIN_EMAIL,
|
||||
password: process.env.ADMIN_PASSWORD,
|
||||
},
|
||||
});
|
||||
expect(loginResp.ok()).toBeTruthy();
|
||||
const { token } = await loginResp.json();
|
||||
await loginCtx.dispose();
|
||||
|
||||
const ctx = await playwright.request.newContext({
|
||||
baseURL: "https://api.myapp.io",
|
||||
extraHTTPHeaders: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
await use(ctx);
|
||||
await ctx.dispose();
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/api/admin.spec.ts
|
||||
import { test, expect } from "../../fixtures/api-fixtures";
|
||||
|
||||
test("admin retrieves all accounts", async ({ adminApi }) => {
|
||||
const resp = await adminApi.get("/admin/accounts");
|
||||
expect(resp.status()).toBe(200);
|
||||
const body = await resp.json();
|
||||
expect(body.accounts.length).toBeGreaterThan(0);
|
||||
});
|
||||
```
|
||||
|
||||
### CRUD Operations
|
||||
|
||||
**Use when**: Making HTTP requests — GET, POST, PUT, PATCH, DELETE with headers, query params, and bodies.
|
||||
**Avoid when**: You need to test browser-rendered responses (redirects, cookies with `HttpOnly`).
|
||||
|
||||
```typescript
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("full CRUD cycle", async ({ request }) => {
|
||||
// GET with query params
|
||||
const listResp = await request.get("/api/items", {
|
||||
params: { page: 1, limit: 10, category: "tools" },
|
||||
});
|
||||
expect(listResp.ok()).toBeTruthy();
|
||||
|
||||
// POST with JSON body
|
||||
const createResp = await request.post("/api/items", {
|
||||
data: {
|
||||
title: "Hammer",
|
||||
price: 19.99,
|
||||
category: "tools",
|
||||
},
|
||||
});
|
||||
expect(createResp.status()).toBe(201);
|
||||
const created = await createResp.json();
|
||||
|
||||
// PUT — full replacement
|
||||
const putResp = await request.put(`/api/items/${created.id}`, {
|
||||
data: {
|
||||
title: "Claw Hammer",
|
||||
price: 24.99,
|
||||
category: "tools",
|
||||
},
|
||||
});
|
||||
expect(putResp.ok()).toBeTruthy();
|
||||
|
||||
// PATCH — partial update
|
||||
const patchResp = await request.patch(`/api/items/${created.id}`, {
|
||||
data: { price: 22.5 },
|
||||
});
|
||||
expect(patchResp.ok()).toBeTruthy();
|
||||
const patched = await patchResp.json();
|
||||
expect(patched.price).toBe(22.5);
|
||||
|
||||
// DELETE
|
||||
const delResp = await request.delete(`/api/items/${created.id}`);
|
||||
expect(delResp.status()).toBe(204);
|
||||
|
||||
// Verify deletion
|
||||
const getDeleted = await request.get(`/api/items/${created.id}`);
|
||||
expect(getDeleted.status()).toBe(404);
|
||||
});
|
||||
|
||||
test("form-urlencoded body", async ({ request }) => {
|
||||
const resp = await request.post("/oauth/token", {
|
||||
form: {
|
||||
grant_type: "client_credentials",
|
||||
client_id: "my-client",
|
||||
client_secret: "secret-value",
|
||||
},
|
||||
});
|
||||
expect(resp.ok()).toBeTruthy();
|
||||
const token = await resp.json();
|
||||
expect(token).toHaveProperty("access_token");
|
||||
});
|
||||
```
|
||||
|
||||
### Dedicated API Project Configuration
|
||||
|
||||
**Use when**: Writing dedicated API test suites that do not need a browser.
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "api",
|
||||
testDir: "./tests/api",
|
||||
use: {
|
||||
baseURL: "https://api.myapp.io",
|
||||
extraHTTPHeaders: { Accept: "application/json" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "e2e",
|
||||
testDir: "./tests/e2e",
|
||||
use: {
|
||||
baseURL: "https://myapp.io",
|
||||
browserName: "chromium",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Response Assertions
|
||||
|
||||
**Use when**: Validating response status, headers, and body structure.
|
||||
**Avoid when**: Never skip these — every API test should assert on status and body.
|
||||
|
||||
```typescript
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("comprehensive response validation", async ({ request }) => {
|
||||
const resp = await request.get("/api/items/101");
|
||||
|
||||
// Status code — always check first
|
||||
expect(resp.status()).toBe(200);
|
||||
expect(resp.ok()).toBeTruthy();
|
||||
|
||||
// Headers
|
||||
expect(resp.headers()["content-type"]).toContain("application/json");
|
||||
expect(resp.headers()["cache-control"]).toMatch(/max-age=\d+/);
|
||||
|
||||
const item = await resp.json();
|
||||
|
||||
// Exact match on known fields
|
||||
expect(item.id).toBe(101);
|
||||
expect(item.title).toBe("Widget");
|
||||
|
||||
// Partial match — ignore fields you don't care about
|
||||
expect(item).toMatchObject({
|
||||
id: 101,
|
||||
title: "Widget",
|
||||
status: expect.stringMatching(/^(active|inactive|archived)$/),
|
||||
});
|
||||
|
||||
// Type checks
|
||||
expect(item).toMatchObject({
|
||||
id: expect.any(Number),
|
||||
title: expect.any(String),
|
||||
createdAt: expect.any(String),
|
||||
tags: expect.any(Array),
|
||||
});
|
||||
|
||||
// Array content
|
||||
expect(item.tags).toEqual(expect.arrayContaining(["featured"]));
|
||||
expect(item.tags).not.toContain("deprecated");
|
||||
|
||||
// Nested object
|
||||
expect(item.metadata).toMatchObject({
|
||||
views: expect.any(Number),
|
||||
rating: expect.any(Number),
|
||||
});
|
||||
|
||||
// Date format
|
||||
expect(new Date(item.createdAt).toISOString()).toBe(item.createdAt);
|
||||
});
|
||||
|
||||
test("list response structure", async ({ request }) => {
|
||||
const resp = await request.get("/api/items");
|
||||
const body = await resp.json();
|
||||
|
||||
expect(body.items).toHaveLength(10);
|
||||
|
||||
for (const item of body.items) {
|
||||
expect(item).toMatchObject({
|
||||
id: expect.any(Number),
|
||||
title: expect.any(String),
|
||||
price: expect.any(Number),
|
||||
});
|
||||
}
|
||||
|
||||
expect(body.pagination).toEqual({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
total: expect.any(Number),
|
||||
totalPages: expect.any(Number),
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### API Data Seeding
|
||||
|
||||
**Use when**: E2E tests need specific data to exist before running. API seeding is 10-100x faster than UI-based setup.
|
||||
**Avoid when**: The test specifically validates the creation flow through the UI.
|
||||
|
||||
```typescript
|
||||
import { test as base, expect } from "@playwright/test";
|
||||
|
||||
type SeedFixtures = {
|
||||
seedAccount: { id: number; email: string; password: string };
|
||||
seedWorkspace: { id: number; name: string };
|
||||
};
|
||||
|
||||
export const test = base.extend<SeedFixtures>({
|
||||
seedAccount: async ({ request }, use) => {
|
||||
const email = `account-${Date.now()}@test.io`;
|
||||
const password = "SecurePass123!";
|
||||
|
||||
const resp = await request.post("/api/accounts", {
|
||||
data: { name: "Test Account", email, password },
|
||||
});
|
||||
expect(resp.ok()).toBeTruthy();
|
||||
const account = await resp.json();
|
||||
|
||||
await use({ id: account.id, email, password });
|
||||
|
||||
// Cleanup
|
||||
await request.delete(`/api/accounts/${account.id}`);
|
||||
},
|
||||
|
||||
seedWorkspace: async ({ request, seedAccount }, use) => {
|
||||
const resp = await request.post("/api/workspaces", {
|
||||
data: { name: `Workspace ${Date.now()}`, ownerId: seedAccount.id },
|
||||
});
|
||||
expect(resp.ok()).toBeTruthy();
|
||||
const workspace = await resp.json();
|
||||
|
||||
await use({ id: workspace.id, name: workspace.name });
|
||||
|
||||
await request.delete(`/api/workspaces/${workspace.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/e2e/workspace-dashboard.spec.ts
|
||||
import { test, expect } from "../../fixtures/seed-fixtures";
|
||||
|
||||
test("user sees workspace on dashboard", async ({
|
||||
page,
|
||||
seedAccount,
|
||||
seedWorkspace,
|
||||
}) => {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Email").fill(seedAccount.email);
|
||||
await page.getByLabel("Password").fill(seedAccount.password);
|
||||
await page.getByRole("button", { name: "Sign in" }).click();
|
||||
|
||||
await page.waitForURL("/dashboard");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: seedWorkspace.name })
|
||||
).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Error Response Testing
|
||||
|
||||
**Use when**: Every API has error paths — test them. A missing 401 test today is a security hole tomorrow.
|
||||
|
||||
```typescript
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Error responses", () => {
|
||||
test("400 — validation error with details", async ({ request }) => {
|
||||
const resp = await request.post("/api/items", {
|
||||
data: { title: "", price: -5 },
|
||||
});
|
||||
expect(resp.status()).toBe(400);
|
||||
|
||||
const body = await resp.json();
|
||||
expect(body).toMatchObject({
|
||||
error: "Validation Error",
|
||||
details: expect.any(Array),
|
||||
});
|
||||
expect(body.details).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
field: "title",
|
||||
message: expect.any(String),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
field: "price",
|
||||
message: expect.any(String),
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
test("401 — missing authentication", async ({ request }) => {
|
||||
const resp = await request.get("/api/protected/resource", {
|
||||
headers: { Authorization: "" },
|
||||
});
|
||||
expect(resp.status()).toBe(401);
|
||||
const body = await resp.json();
|
||||
expect(body.error).toMatch(/unauthorized|unauthenticated/i);
|
||||
});
|
||||
|
||||
test("403 — insufficient permissions", async ({ request }) => {
|
||||
const resp = await request.delete("/api/admin/items/1");
|
||||
expect(resp.status()).toBe(403);
|
||||
const body = await resp.json();
|
||||
expect(body.error).toMatch(/forbidden|insufficient permissions/i);
|
||||
});
|
||||
|
||||
test("404 — resource not found", async ({ request }) => {
|
||||
const resp = await request.get("/api/items/999999");
|
||||
expect(resp.status()).toBe(404);
|
||||
const body = await resp.json();
|
||||
expect(body).toMatchObject({ error: expect.stringMatching(/not found/i) });
|
||||
});
|
||||
|
||||
test("409 — conflict on duplicate", async ({ request }) => {
|
||||
const sku = `SKU-${Date.now()}`;
|
||||
await request.post("/api/items", { data: { title: "First", sku } });
|
||||
|
||||
const resp = await request.post("/api/items", {
|
||||
data: { title: "Duplicate", sku },
|
||||
});
|
||||
expect(resp.status()).toBe(409);
|
||||
});
|
||||
|
||||
test("422 — unprocessable entity", async ({ request }) => {
|
||||
const resp = await request.post("/api/orders", {
|
||||
data: { items: [] },
|
||||
});
|
||||
expect(resp.status()).toBe(422);
|
||||
const body = await resp.json();
|
||||
expect(body.error).toContain("at least one item");
|
||||
});
|
||||
|
||||
test("429 — rate limiting", async ({ request }) => {
|
||||
const responses = await Promise.all(
|
||||
Array.from({ length: 50 }, () =>
|
||||
request.get("/api/search", { params: { q: "test" } })
|
||||
)
|
||||
);
|
||||
const rateLimited = responses.filter((r) => r.status() === 429);
|
||||
expect(rateLimited.length).toBeGreaterThan(0);
|
||||
expect(rateLimited[0].headers()["retry-after"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### File Upload via API
|
||||
|
||||
**Use when**: Testing file upload endpoints with multipart form data.
|
||||
**Avoid when**: You need to test the browser file picker dialog — use `page.setInputFiles()` instead.
|
||||
|
||||
```typescript
|
||||
import { test, expect } from "@playwright/test";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
test("upload file via multipart", async ({ request }) => {
|
||||
const filePath = path.resolve("tests/fixtures/report.pdf");
|
||||
|
||||
const resp = await request.post("/api/documents/upload", {
|
||||
multipart: {
|
||||
file: {
|
||||
name: "report.pdf",
|
||||
mimeType: "application/pdf",
|
||||
buffer: fs.readFileSync(filePath),
|
||||
},
|
||||
description: "Monthly report",
|
||||
category: "reports",
|
||||
},
|
||||
});
|
||||
|
||||
expect(resp.status()).toBe(201);
|
||||
const body = await resp.json();
|
||||
expect(body).toMatchObject({
|
||||
id: expect.any(String),
|
||||
filename: "report.pdf",
|
||||
mimeType: "application/pdf",
|
||||
size: expect.any(Number),
|
||||
url: expect.stringMatching(/^https:\/\//),
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects oversized files", async ({ request }) => {
|
||||
const largeBuffer = Buffer.alloc(11 * 1024 * 1024); // 11MB
|
||||
|
||||
const resp = await request.post("/api/documents/upload", {
|
||||
multipart: {
|
||||
file: {
|
||||
name: "large-file.bin",
|
||||
mimeType: "application/octet-stream",
|
||||
buffer: largeBuffer,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resp.status()).toBe(413);
|
||||
});
|
||||
```
|
||||
|
||||
### Chained API Calls
|
||||
|
||||
**Use when**: Testing multi-step workflows — create, read, update, delete sequences; order flows; state machine transitions.
|
||||
**Avoid when**: You can test each endpoint in isolation and the interactions are trivial.
|
||||
|
||||
```typescript
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("complete order workflow", async ({ request }) => {
|
||||
// Step 1: Create a product
|
||||
const productResp = await request.post("/api/products", {
|
||||
data: { name: "Gadget", price: 49.99, stock: 50 },
|
||||
});
|
||||
expect(productResp.status()).toBe(201);
|
||||
const product = await productResp.json();
|
||||
|
||||
// Step 2: Create a cart
|
||||
const cartResp = await request.post("/api/carts", {
|
||||
data: { items: [{ productId: product.id, quantity: 3 }] },
|
||||
});
|
||||
expect(cartResp.status()).toBe(201);
|
||||
const cart = await cartResp.json();
|
||||
expect(cart.total).toBe(149.97);
|
||||
|
||||
// Step 3: Checkout
|
||||
const orderResp = await request.post("/api/orders", {
|
||||
data: {
|
||||
cartId: cart.id,
|
||||
shippingAddress: {
|
||||
street: "456 Main Ave",
|
||||
city: "Metropolis",
|
||||
zip: "54321",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(orderResp.status()).toBe(201);
|
||||
const order = await orderResp.json();
|
||||
expect(order.status).toBe("pending");
|
||||
expect(order.items).toHaveLength(1);
|
||||
|
||||
// Step 4: Verify order in list
|
||||
const ordersResp = await request.get("/api/orders");
|
||||
const orders = await ordersResp.json();
|
||||
expect(orders.items.map((o: any) => o.id)).toContain(order.id);
|
||||
|
||||
// Step 5: Verify stock decreased
|
||||
const updatedProduct = await (
|
||||
await request.get(`/api/products/${product.id}`)
|
||||
).json();
|
||||
expect(updatedProduct.stock).toBe(47);
|
||||
|
||||
// Cleanup
|
||||
await request.delete(`/api/orders/${order.id}`);
|
||||
await request.delete(`/api/products/${product.id}`);
|
||||
});
|
||||
|
||||
test("state machine transitions — publish workflow", async ({ request }) => {
|
||||
const createResp = await request.post("/api/articles", {
|
||||
data: { title: "Draft Article", body: "Content here." },
|
||||
});
|
||||
const article = await createResp.json();
|
||||
expect(article.status).toBe("draft");
|
||||
|
||||
// Submit for review
|
||||
const reviewResp = await request.patch(`/api/articles/${article.id}/status`, {
|
||||
data: { status: "in_review" },
|
||||
});
|
||||
expect(reviewResp.ok()).toBeTruthy();
|
||||
expect((await reviewResp.json()).status).toBe("in_review");
|
||||
|
||||
// Approve
|
||||
const approveResp = await request.patch(
|
||||
`/api/articles/${article.id}/status`,
|
||||
{
|
||||
data: { status: "published" },
|
||||
}
|
||||
);
|
||||
expect(approveResp.ok()).toBeTruthy();
|
||||
expect((await approveResp.json()).status).toBe("published");
|
||||
|
||||
// Cannot revert to draft from published
|
||||
const revertResp = await request.patch(`/api/articles/${article.id}/status`, {
|
||||
data: { status: "draft" },
|
||||
});
|
||||
expect(revertResp.status()).toBe(422);
|
||||
|
||||
await request.delete(`/api/articles/${article.id}`);
|
||||
});
|
||||
|
||||
test("API + E2E hybrid — seed via API, verify in browser", async ({
|
||||
request,
|
||||
page,
|
||||
}) => {
|
||||
const resp = await request.post("/api/products", {
|
||||
data: {
|
||||
name: `Hybrid Product ${Date.now()}`,
|
||||
price: 35.0,
|
||||
published: true,
|
||||
},
|
||||
});
|
||||
const product = await resp.json();
|
||||
|
||||
await page.goto("/products");
|
||||
await expect(page.getByRole("heading", { name: product.name })).toBeVisible();
|
||||
await expect(page.getByText("$35.00")).toBeVisible();
|
||||
|
||||
await request.delete(`/api/products/${product.id}`);
|
||||
});
|
||||
```
|
||||
|
||||
### Schema Validation with Zod
|
||||
|
||||
**Use when**: Verifying API responses match a contract — field types, required fields, value constraints.
|
||||
**Avoid when**: You only need to check one or two specific fields — use `toMatchObject` instead.
|
||||
|
||||
```typescript
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { z } from "zod";
|
||||
|
||||
const ItemSchema = z.object({
|
||||
id: z.number().positive(),
|
||||
title: z.string().min(1),
|
||||
price: z.number().nonnegative(),
|
||||
status: z.enum(["active", "inactive", "archived"]),
|
||||
createdAt: z.string().datetime(),
|
||||
metadata: z.object({
|
||||
views: z.number().int().nonnegative(),
|
||||
rating: z.number().min(0).max(5).nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
const PaginatedItemsSchema = z.object({
|
||||
items: z.array(ItemSchema),
|
||||
pagination: z.object({
|
||||
page: z.number().int().positive(),
|
||||
limit: z.number().int().positive(),
|
||||
total: z.number().int().nonnegative(),
|
||||
}),
|
||||
});
|
||||
|
||||
test("GET /api/items matches schema", async ({ request }) => {
|
||||
const resp = await request.get("/api/items");
|
||||
expect(resp.ok()).toBeTruthy();
|
||||
|
||||
const body = await resp.json();
|
||||
const result = PaginatedItemsSchema.safeParse(body);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`Schema validation failed:\n${result.error.issues
|
||||
.map((i) => ` ${i.path.join(".")}: ${i.message}`)
|
||||
.join("\n")}`
|
||||
);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Decision Guide
|
||||
|
||||
| Scenario | Use API Tests | Use E2E Tests | Why |
|
||||
| ------------------------------------------------ | --------------------------- | ------------------------------ | ------------------------------------------------------------------ |
|
||||
| Validate response status/body/headers | Yes | No | No browser needed; 10-100x faster |
|
||||
| Test business logic (calculations, rules) | Yes | No | API tests isolate backend logic from UI |
|
||||
| Verify form submission creates correct data | Seed via API, submit via UI | Yes | UI test validates the form; API check confirms persistence |
|
||||
| Test error messages shown to user | No | Yes | Error rendering is a UI concern |
|
||||
| Validate pagination, filtering, sorting | Yes | Maybe both | API test for correctness; E2E test only if the UI logic is complex |
|
||||
| Seed test data for E2E tests | Yes (fixture) | No | API seeding is fast and reliable |
|
||||
| Test auth flows (login/logout/RBAC) | Yes for token/session logic | Yes for UI flow | Both matter: API protects resources, UI guides users |
|
||||
| Verify file upload processing | Yes | Only if testing file picker UI | API test validates backend processing |
|
||||
| Contract/schema regression testing | Yes | No | Schema tests run in milliseconds |
|
||||
| Test third-party webhook handling | Yes | No | Webhooks are API-to-API; no UI involved |
|
||||
| Verify redirect behavior after action | No | Yes | Redirects are browser/navigation concerns |
|
||||
| Test real-time updates (WebSocket + API trigger) | API triggers | E2E verifies | Seed via API, observe in browser |
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Don't Do This | Problem | Do This Instead |
|
||||
| ---------------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| Use E2E tests to validate pure API responses | Slow, flaky, launches a browser for no reason | Use `request` fixture — no browser, direct HTTP |
|
||||
| Ignore `response.status()` | A 500 with a fallback body can pass all body assertions | Always assert status first: `expect(response.status()).toBe(200)` |
|
||||
| Skip response header checks | Missing `Content-Type`, `Cache-Control`, CORS headers cause production bugs | Assert critical headers |
|
||||
| Only test the happy path | Real users trigger 400, 401, 403, 404, 409, 422 — every one needs a test | Dedicate a `describe` block to error responses |
|
||||
| Hardcode IDs in API tests | Tests break when database is reset or IDs are reassigned | Create resources in the test, use returned IDs |
|
||||
| Share mutable state between tests | Tests that depend on execution order are flaky and cannot run in parallel | Each test creates and cleans up its own data |
|
||||
| Parse `response.text()` then `JSON.parse()` manually | Playwright's `response.json()` handles this and throws clear errors on non-JSON | Use `await response.json()` |
|
||||
| Forget cleanup after creating resources | Test pollution: subsequent tests may see stale data or hit unique constraints | Use fixtures with teardown or explicit `delete` calls |
|
||||
| Use `page.request` when you don't need a page | `page.request` shares cookies with the browser context, which may cause auth confusion | Use the standalone `request` fixture for pure API tests |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Request failed: connect ECONNREFUSED 127.0.0.1:3000"
|
||||
|
||||
**Cause**: The API server is not running, or `baseURL` points to the wrong host/port.
|
||||
|
||||
**Fix**: Verify the server is running before tests. Use `webServer` in config to start it automatically.
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
webServer: {
|
||||
command: "npm run start:api",
|
||||
url: "http://localhost:3000/api/health",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
use: { baseURL: "http://localhost:3000" },
|
||||
});
|
||||
```
|
||||
|
||||
### "response.json() failed — body is not valid JSON"
|
||||
|
||||
**Cause**: The endpoint returned HTML (error page), plain text, or an empty body instead of JSON.
|
||||
|
||||
**Fix**: Check `response.status()` first — a 500 or 302 often returns HTML. Log `await response.text()` to see the actual body. Verify the `Accept: application/json` header is set.
|
||||
|
||||
```typescript
|
||||
const resp = await request.get("/api/endpoint");
|
||||
if (!resp.ok()) {
|
||||
console.error(`Status: ${resp.status()}, Body: ${await resp.text()}`);
|
||||
}
|
||||
const body = await resp.json();
|
||||
```
|
||||
|
||||
### "401 Unauthorized" when using `request` fixture
|
||||
|
||||
**Cause**: The built-in `request` fixture does not carry browser cookies or auth tokens automatically.
|
||||
|
||||
**Fix**: Set `extraHTTPHeaders` in config or create a custom authenticated fixture. If you need cookies from a browser login, use `page.request` instead.
|
||||
|
||||
```typescript
|
||||
// Option A: config-level headers
|
||||
export default defineConfig({
|
||||
use: {
|
||||
extraHTTPHeaders: { Authorization: `Bearer ${process.env.API_TOKEN}` },
|
||||
},
|
||||
});
|
||||
|
||||
// Option B: per-request headers
|
||||
const resp = await request.get("/api/resource", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
// Option C: use page.request to inherit browser cookies
|
||||
test("API call with browser auth", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
// ... login via UI ...
|
||||
const resp = await page.request.get("/api/profile");
|
||||
expect(resp.ok()).toBeTruthy();
|
||||
});
|
||||
```
|
||||
|
||||
### Tests pass locally but fail in CI
|
||||
|
||||
**Cause**: Different environments, database state, or missing environment variables.
|
||||
|
||||
**Fix**: Use `process.env` for secrets and base URLs. Run database seeds or migrations in `globalSetup`. Use unique identifiers (timestamps, UUIDs) for test data. Check that the CI `baseURL` matches the deployed service.
|
||||
@@ -0,0 +1,506 @@
|
||||
# Browser Extension Testing
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Setup & Configuration](#setup--configuration)
|
||||
2. [Loading Extensions](#loading-extensions)
|
||||
3. [Popup Testing](#popup-testing)
|
||||
4. [Background Script Testing](#background-script-testing)
|
||||
5. [Content Script Testing](#content-script-testing)
|
||||
6. [Extension APIs](#extension-apis)
|
||||
7. [Cross-Browser Testing](#cross-browser-testing)
|
||||
|
||||
## Setup & Configuration
|
||||
|
||||
### Prerequisites
|
||||
|
||||
```bash
|
||||
npm install -D @playwright/test
|
||||
npx playwright install chromium # Extensions only work in Chromium
|
||||
```
|
||||
|
||||
### Basic Configuration
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig } from "@playwright/test";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
use: {
|
||||
// Extensions require non-headless Chromium
|
||||
headless: false,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium-extension",
|
||||
use: {
|
||||
browserName: "chromium",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Extension Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/extension.ts
|
||||
import { test as base, chromium, BrowserContext, Page } from "@playwright/test";
|
||||
import path from "path";
|
||||
|
||||
type ExtensionFixtures = {
|
||||
context: BrowserContext;
|
||||
extensionId: string;
|
||||
backgroundPage: Page;
|
||||
};
|
||||
|
||||
export const test = base.extend<ExtensionFixtures>({
|
||||
context: async ({}, use) => {
|
||||
const pathToExtension = path.join(__dirname, "../extension");
|
||||
|
||||
const context = await chromium.launchPersistentContext("", {
|
||||
headless: false,
|
||||
args: [
|
||||
`--disable-extensions-except=${pathToExtension}`,
|
||||
`--load-extension=${pathToExtension}`,
|
||||
],
|
||||
});
|
||||
|
||||
await use(context);
|
||||
await context.close();
|
||||
},
|
||||
|
||||
extensionId: async ({ context }, use) => {
|
||||
// Get extension ID from service worker URL
|
||||
let extensionId = "";
|
||||
|
||||
// Wait for service worker to be registered
|
||||
const serviceWorker =
|
||||
context.serviceWorkers()[0] ||
|
||||
(await context.waitForEvent("serviceworker"));
|
||||
|
||||
extensionId = serviceWorker.url().split("/")[2];
|
||||
|
||||
await use(extensionId);
|
||||
},
|
||||
|
||||
backgroundPage: async ({ context }, use) => {
|
||||
// For Manifest V2 extensions
|
||||
const backgroundPage =
|
||||
context.backgroundPages()[0] ||
|
||||
(await context.waitForEvent("backgroundpage"));
|
||||
|
||||
await use(backgroundPage);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from "@playwright/test";
|
||||
```
|
||||
|
||||
## Loading Extensions
|
||||
|
||||
### Manifest V3 (Service Worker)
|
||||
|
||||
```typescript
|
||||
test("load MV3 extension", async () => {
|
||||
const pathToExtension = path.join(__dirname, "../my-extension");
|
||||
|
||||
const context = await chromium.launchPersistentContext("", {
|
||||
headless: false,
|
||||
args: [
|
||||
`--disable-extensions-except=${pathToExtension}`,
|
||||
`--load-extension=${pathToExtension}`,
|
||||
],
|
||||
});
|
||||
|
||||
// Wait for service worker
|
||||
const serviceWorker = await context.waitForEvent("serviceworker");
|
||||
expect(serviceWorker.url()).toContain("chrome-extension://");
|
||||
|
||||
await context.close();
|
||||
});
|
||||
```
|
||||
|
||||
### Manifest V2 (Background Page)
|
||||
|
||||
```typescript
|
||||
test("load MV2 extension", async () => {
|
||||
const pathToExtension = path.join(__dirname, "../my-extension-v2");
|
||||
|
||||
const context = await chromium.launchPersistentContext("", {
|
||||
headless: false,
|
||||
args: [
|
||||
`--disable-extensions-except=${pathToExtension}`,
|
||||
`--load-extension=${pathToExtension}`,
|
||||
],
|
||||
});
|
||||
|
||||
// Wait for background page
|
||||
const backgroundPage = await context.waitForEvent("backgroundpage");
|
||||
expect(backgroundPage.url()).toContain("chrome-extension://");
|
||||
|
||||
await context.close();
|
||||
});
|
||||
```
|
||||
|
||||
### Multiple Extensions
|
||||
|
||||
```typescript
|
||||
test("load multiple extensions", async () => {
|
||||
const extension1 = path.join(__dirname, "../extension1");
|
||||
const extension2 = path.join(__dirname, "../extension2");
|
||||
|
||||
const context = await chromium.launchPersistentContext("", {
|
||||
headless: false,
|
||||
args: [
|
||||
`--disable-extensions-except=${extension1},${extension2}`,
|
||||
`--load-extension=${extension1},${extension2}`,
|
||||
],
|
||||
});
|
||||
|
||||
// Both service workers should be available
|
||||
await context.waitForEvent("serviceworker");
|
||||
await context.waitForEvent("serviceworker");
|
||||
|
||||
expect(context.serviceWorkers().length).toBe(2);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
```
|
||||
|
||||
## Popup Testing
|
||||
|
||||
### Opening Extension Popup
|
||||
|
||||
```typescript
|
||||
test("test popup UI", async ({ context, extensionId }) => {
|
||||
// Open popup directly by URL
|
||||
const popupPage = await context.newPage();
|
||||
await popupPage.goto(`chrome-extension://${extensionId}/popup.html`);
|
||||
|
||||
// Test popup interactions
|
||||
await expect(popupPage.getByRole("heading")).toHaveText("My Extension");
|
||||
await popupPage.getByRole("button", { name: "Enable" }).click();
|
||||
await expect(popupPage.getByText("Enabled")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Popup State Persistence
|
||||
|
||||
```typescript
|
||||
test("popup remembers state", async ({ context, extensionId }) => {
|
||||
// First interaction
|
||||
const popup1 = await context.newPage();
|
||||
await popup1.goto(`chrome-extension://${extensionId}/popup.html`);
|
||||
await popup1.getByRole("checkbox", { name: "Dark Mode" }).check();
|
||||
await popup1.close();
|
||||
|
||||
// Reopen popup
|
||||
const popup2 = await context.newPage();
|
||||
await popup2.goto(`chrome-extension://${extensionId}/popup.html`);
|
||||
|
||||
// State should persist
|
||||
await expect(
|
||||
popup2.getByRole("checkbox", { name: "Dark Mode" }),
|
||||
).toBeChecked();
|
||||
});
|
||||
```
|
||||
|
||||
### Popup Communication with Background
|
||||
|
||||
```typescript
|
||||
test("popup sends message to background", async ({ context, extensionId }) => {
|
||||
const popup = await context.newPage();
|
||||
await popup.goto(`chrome-extension://${extensionId}/popup.html`);
|
||||
|
||||
// Set up listener for response
|
||||
const responsePromise = popup.evaluate(() => {
|
||||
return new Promise((resolve) => {
|
||||
chrome.runtime.onMessage.addListener((message) => {
|
||||
if (message.type === "RESPONSE") resolve(message.data);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Click button that sends message
|
||||
await popup.getByRole("button", { name: "Fetch Data" }).click();
|
||||
|
||||
// Verify response
|
||||
const response = await responsePromise;
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
```
|
||||
|
||||
## Background Script Testing
|
||||
|
||||
### Manifest V3 Service Worker
|
||||
|
||||
```typescript
|
||||
test("service worker handles messages", async ({ context, extensionId }) => {
|
||||
const page = await context.newPage();
|
||||
await page.goto("https://example.com");
|
||||
|
||||
// Send message to service worker from page
|
||||
const response = await page.evaluate(async (extId) => {
|
||||
return new Promise((resolve) => {
|
||||
chrome.runtime.sendMessage(extId, { type: "GET_STATUS" }, resolve);
|
||||
});
|
||||
}, extensionId);
|
||||
|
||||
expect(response).toEqual({ status: "active" });
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Background Logic
|
||||
|
||||
```typescript
|
||||
test("background script logic", async ({ context }) => {
|
||||
const serviceWorker =
|
||||
context.serviceWorkers()[0] ||
|
||||
(await context.waitForEvent("serviceworker"));
|
||||
|
||||
// Evaluate in service worker context
|
||||
const result = await serviceWorker.evaluate(async () => {
|
||||
// Access extension APIs
|
||||
const storage = await chrome.storage.local.get("settings");
|
||||
return storage;
|
||||
});
|
||||
|
||||
expect(result.settings).toBeDefined();
|
||||
});
|
||||
```
|
||||
|
||||
### Alarms and Timers
|
||||
|
||||
```typescript
|
||||
test("alarm triggers correctly", async ({ context }) => {
|
||||
const serviceWorker = await context.waitForEvent("serviceworker");
|
||||
|
||||
// Create alarm
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await chrome.alarms.create("test-alarm", { delayInMinutes: 0.01 });
|
||||
});
|
||||
|
||||
// Wait for alarm handler
|
||||
await serviceWorker.evaluate(() => {
|
||||
return new Promise<void>((resolve) => {
|
||||
chrome.alarms.onAlarm.addListener((alarm) => {
|
||||
if (alarm.name === "test-alarm") resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Verify alarm was handled (check side effects)
|
||||
const wasHandled = await serviceWorker.evaluate(async () => {
|
||||
const { alarmTriggered } = await chrome.storage.local.get("alarmTriggered");
|
||||
return alarmTriggered;
|
||||
});
|
||||
|
||||
expect(wasHandled).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
## Content Script Testing
|
||||
|
||||
### Injected Content Script
|
||||
|
||||
```typescript
|
||||
test("content script injects UI", async ({ context }) => {
|
||||
const page = await context.newPage();
|
||||
await page.goto("https://example.com");
|
||||
|
||||
// Wait for content script to inject elements
|
||||
await expect(page.locator("#my-extension-widget")).toBeVisible();
|
||||
|
||||
// Interact with injected UI
|
||||
await page.locator("#my-extension-widget button").click();
|
||||
await expect(page.locator("#my-extension-widget .result")).toHaveText(
|
||||
"Success",
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Content Script Communication
|
||||
|
||||
```typescript
|
||||
test("content script communicates with background", async ({
|
||||
context,
|
||||
extensionId,
|
||||
}) => {
|
||||
const page = await context.newPage();
|
||||
await page.goto("https://example.com");
|
||||
|
||||
// Trigger content script action
|
||||
await page.locator("#my-extension-button").click();
|
||||
|
||||
// Wait for background response reflected in UI
|
||||
await expect(page.locator("#my-extension-status")).toHaveText("Connected");
|
||||
});
|
||||
```
|
||||
|
||||
### Page Modification Testing
|
||||
|
||||
```typescript
|
||||
test("content script modifies page", async ({ context }) => {
|
||||
const page = await context.newPage();
|
||||
await page.goto("https://example.com");
|
||||
|
||||
// Verify content script modifications
|
||||
const hasModification = await page.evaluate(() => {
|
||||
// Check for injected styles
|
||||
const styles = document.querySelectorAll('style[data-extension="my-ext"]');
|
||||
return styles.length > 0;
|
||||
});
|
||||
|
||||
expect(hasModification).toBe(true);
|
||||
|
||||
// Check DOM modifications
|
||||
const modifiedElements = await page
|
||||
.locator("[data-modified-by-extension]")
|
||||
.count();
|
||||
expect(modifiedElements).toBeGreaterThan(0);
|
||||
});
|
||||
```
|
||||
|
||||
## Extension APIs
|
||||
|
||||
### Storage API
|
||||
|
||||
```typescript
|
||||
test("chrome.storage operations", async ({ context }) => {
|
||||
const serviceWorker = await context.waitForEvent("serviceworker");
|
||||
|
||||
// Set storage
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await chrome.storage.local.set({ key: "value", count: 42 });
|
||||
});
|
||||
|
||||
// Get storage
|
||||
const data = await serviceWorker.evaluate(async () => {
|
||||
return await chrome.storage.local.get(["key", "count"]);
|
||||
});
|
||||
|
||||
expect(data).toEqual({ key: "value", count: 42 });
|
||||
|
||||
// Test storage.sync
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await chrome.storage.sync.set({ synced: true });
|
||||
});
|
||||
|
||||
const syncData = await serviceWorker.evaluate(async () => {
|
||||
return await chrome.storage.sync.get("synced");
|
||||
});
|
||||
|
||||
expect(syncData.synced).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
### Tabs API
|
||||
|
||||
```typescript
|
||||
test("chrome.tabs operations", async ({ context }) => {
|
||||
const serviceWorker = await context.waitForEvent("serviceworker");
|
||||
|
||||
// Create a tab
|
||||
const page = await context.newPage();
|
||||
await page.goto("https://example.com");
|
||||
|
||||
// Query tabs from service worker
|
||||
const tabs = await serviceWorker.evaluate(async () => {
|
||||
return await chrome.tabs.query({ url: "*://example.com/*" });
|
||||
});
|
||||
|
||||
expect(tabs.length).toBeGreaterThan(0);
|
||||
expect(tabs[0].url).toContain("example.com");
|
||||
|
||||
// Send message to tab
|
||||
await serviceWorker.evaluate(async (tabId) => {
|
||||
await chrome.tabs.sendMessage(tabId, { type: "PING" });
|
||||
}, tabs[0].id);
|
||||
});
|
||||
```
|
||||
|
||||
### Context Menus
|
||||
|
||||
```typescript
|
||||
test("context menu actions", async ({ context, extensionId }) => {
|
||||
const serviceWorker = await context.waitForEvent("serviceworker");
|
||||
|
||||
// Create context menu
|
||||
await serviceWorker.evaluate(async () => {
|
||||
await chrome.contextMenus.create({
|
||||
id: "test-menu",
|
||||
title: "Test Action",
|
||||
contexts: ["selection"],
|
||||
});
|
||||
});
|
||||
|
||||
// Simulate context menu click
|
||||
const page = await context.newPage();
|
||||
await page.goto("https://example.com");
|
||||
|
||||
// Select text
|
||||
await page.evaluate(() => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(document.body.firstChild!);
|
||||
window.getSelection()?.addRange(range);
|
||||
});
|
||||
|
||||
// Trigger context menu action programmatically
|
||||
await serviceWorker.evaluate(async () => {
|
||||
// Simulate the click handler
|
||||
chrome.contextMenus.onClicked.dispatch(
|
||||
{ menuItemId: "test-menu", selectionText: "selected text" },
|
||||
{ id: 1, url: "https://example.com" },
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Permissions API
|
||||
|
||||
```typescript
|
||||
test("request permissions", async ({ context, extensionId }) => {
|
||||
const popup = await context.newPage();
|
||||
await popup.goto(`chrome-extension://${extensionId}/popup.html`);
|
||||
|
||||
// Check current permissions
|
||||
const hasPermission = await popup.evaluate(async () => {
|
||||
return await chrome.permissions.contains({
|
||||
origins: ["https://*.github.com/*"],
|
||||
});
|
||||
});
|
||||
|
||||
// Request new permission (will show prompt in real scenario)
|
||||
// For testing, we check the request is made correctly
|
||||
const permissionRequest = popup.evaluate(async () => {
|
||||
try {
|
||||
return await chrome.permissions.request({
|
||||
origins: ["https://*.github.com/*"],
|
||||
});
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// In automated tests, permission prompts are typically auto-granted or mocked
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ------------------------------ | --------------------- | ---------------------------------------- |
|
||||
| Testing in headless mode | Extensions don't load | Use `headless: false` |
|
||||
| Not waiting for service worker | Race conditions | Wait for `serviceworker` event |
|
||||
| Hardcoding extension ID | ID changes on reload | Extract ID from service worker URL |
|
||||
| Testing packed extensions only | Slow iteration | Test unpacked during development |
|
||||
| Ignoring MV3 differences | Breaking changes | Test both MV2 and MV3 if supporting both |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Service Workers**: See [service-workers.md](../browser-apis/service-workers.md) for SW testing patterns
|
||||
- **Multi-Context**: See [multi-context.md](../advanced/multi-context.md) for popup handling
|
||||
- **Browser APIs**: See [browser-apis.md](../browser-apis/browser-apis.md) for permissions testing
|
||||
@@ -0,0 +1,493 @@
|
||||
# Canvas & WebGL Testing
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Canvas Basics](#canvas-basics)
|
||||
2. [Visual Comparison](#visual-comparison)
|
||||
3. [Interaction Testing](#interaction-testing)
|
||||
4. [WebGL Testing](#webgl-testing)
|
||||
5. [Chart Libraries](#chart-libraries)
|
||||
6. [Game & Animation Testing](#game--animation-testing)
|
||||
|
||||
## Canvas Basics
|
||||
|
||||
### Locating Canvas Elements
|
||||
|
||||
```typescript
|
||||
test("find canvas", async ({ page }) => {
|
||||
await page.goto("/canvas-app");
|
||||
|
||||
// By tag
|
||||
const canvas = page.locator("canvas");
|
||||
|
||||
// By ID or class
|
||||
const gameCanvas = page.locator("canvas#game");
|
||||
const chartCanvas = page.locator("canvas.chart-canvas");
|
||||
|
||||
// Verify canvas is present and visible
|
||||
await expect(canvas).toBeVisible();
|
||||
|
||||
// Get canvas dimensions
|
||||
const box = await canvas.boundingBox();
|
||||
console.log(`Canvas size: ${box?.width}x${box?.height}`);
|
||||
});
|
||||
```
|
||||
|
||||
### Canvas Screenshot Testing
|
||||
|
||||
```typescript
|
||||
test("canvas renders correctly", async ({ page }) => {
|
||||
await page.goto("/chart");
|
||||
|
||||
// Wait for canvas to be ready (check for specific content)
|
||||
await page.waitForFunction(() => {
|
||||
const canvas = document.querySelector("canvas");
|
||||
const ctx = canvas?.getContext("2d");
|
||||
// Check if canvas has been drawn to
|
||||
return ctx && !isCanvasBlank(canvas);
|
||||
|
||||
function isCanvasBlank(canvas) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
return !data.some((channel) => channel !== 0);
|
||||
}
|
||||
});
|
||||
|
||||
// Screenshot just the canvas
|
||||
const canvas = page.locator("canvas");
|
||||
await expect(canvas).toHaveScreenshot("chart.png");
|
||||
});
|
||||
```
|
||||
|
||||
### Extracting Canvas Data
|
||||
|
||||
```typescript
|
||||
test("verify canvas content", async ({ page }) => {
|
||||
await page.goto("/drawing-app");
|
||||
|
||||
// Get canvas image data
|
||||
const imageData = await page.evaluate(() => {
|
||||
const canvas = document.querySelector("canvas") as HTMLCanvasElement;
|
||||
return canvas.toDataURL("image/png");
|
||||
});
|
||||
|
||||
// Verify it's not empty
|
||||
expect(imageData).toMatch(/^data:image\/png;base64,.+/);
|
||||
|
||||
// Get pixel data at specific location
|
||||
const pixelColor = await page.evaluate(() => {
|
||||
const canvas = document.querySelector("canvas") as HTMLCanvasElement;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
const pixel = ctx.getImageData(100, 100, 1, 1).data;
|
||||
return { r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3] };
|
||||
});
|
||||
|
||||
// Verify specific pixel color
|
||||
expect(pixelColor.r).toBeGreaterThan(200); // Expecting red-ish
|
||||
});
|
||||
```
|
||||
|
||||
## Visual Comparison
|
||||
|
||||
### Screenshot Assertions
|
||||
|
||||
```typescript
|
||||
test("chart matches baseline", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
|
||||
// Wait for chart animation to complete
|
||||
await page.waitForTimeout(1000); // Or better: wait for specific state
|
||||
|
||||
// Full page screenshot
|
||||
await expect(page).toHaveScreenshot("dashboard.png", {
|
||||
maxDiffPixels: 100, // Allow small differences
|
||||
});
|
||||
|
||||
// Just the canvas
|
||||
const chart = page.locator("canvas#sales-chart");
|
||||
await expect(chart).toHaveScreenshot("sales-chart.png", {
|
||||
maxDiffPixelRatio: 0.01, // 1% difference allowed
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Handling Animation
|
||||
|
||||
```typescript
|
||||
test("animated canvas", async ({ page }) => {
|
||||
await page.goto("/animated-chart");
|
||||
|
||||
// Pause animation before screenshot
|
||||
await page.evaluate(() => {
|
||||
// Common pattern: chart libraries expose pause method
|
||||
window.chartInstance?.stop?.();
|
||||
|
||||
// Or override requestAnimationFrame
|
||||
window.requestAnimationFrame = () => 0;
|
||||
});
|
||||
|
||||
await expect(page.locator("canvas")).toHaveScreenshot();
|
||||
});
|
||||
|
||||
test("wait for animation complete", async ({ page }) => {
|
||||
await page.goto("/chart-with-animation");
|
||||
|
||||
// Wait for animation complete event
|
||||
await page.evaluate(() => {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (window.chart?.isAnimating === false) {
|
||||
resolve();
|
||||
} else {
|
||||
window.chart?.on("animationComplete", resolve);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await expect(page.locator("canvas")).toHaveScreenshot();
|
||||
});
|
||||
```
|
||||
|
||||
### Threshold Configuration
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
expect: {
|
||||
toHaveScreenshot: {
|
||||
// Increased threshold for canvas (anti-aliasing differences)
|
||||
maxDiffPixelRatio: 0.02,
|
||||
threshold: 0.3, // Per-pixel color threshold
|
||||
animations: "disabled",
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Interaction Testing
|
||||
|
||||
### Click on Canvas
|
||||
|
||||
```typescript
|
||||
test("click on canvas element", async ({ page }) => {
|
||||
await page.goto("/interactive-map");
|
||||
|
||||
const canvas = page.locator("canvas");
|
||||
|
||||
// Click at specific coordinates
|
||||
await canvas.click({ position: { x: 150, y: 200 } });
|
||||
|
||||
// Verify click was registered
|
||||
await expect(page.locator("#info-panel")).toContainText("Location: Paris");
|
||||
});
|
||||
```
|
||||
|
||||
### Drawing on Canvas
|
||||
|
||||
```typescript
|
||||
test("draw on canvas", async ({ page }) => {
|
||||
await page.goto("/whiteboard");
|
||||
|
||||
const canvas = page.locator("canvas");
|
||||
const box = await canvas.boundingBox();
|
||||
|
||||
// Draw a line using mouse
|
||||
await page.mouse.move(box!.x + 50, box!.y + 50);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box!.x + 200, box!.y + 200, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
|
||||
// Verify something was drawn
|
||||
const hasDrawing = await page.evaluate(() => {
|
||||
const canvas = document.querySelector("canvas") as HTMLCanvasElement;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
return data.some((v, i) => i % 4 !== 3 && v !== 255); // Non-white pixels
|
||||
});
|
||||
|
||||
expect(hasDrawing).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
### Drag and Drop
|
||||
|
||||
```typescript
|
||||
test("drag canvas element", async ({ page }) => {
|
||||
await page.goto("/diagram-editor");
|
||||
|
||||
const canvas = page.locator("canvas");
|
||||
const box = await canvas.boundingBox();
|
||||
|
||||
// Drag shape from position A to B
|
||||
await page.mouse.move(box!.x + 100, box!.y + 100);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box!.x + 300, box!.y + 200, { steps: 20 });
|
||||
await page.mouse.up();
|
||||
|
||||
// Verify via screenshot or state check
|
||||
await expect(canvas).toHaveScreenshot("shape-moved.png");
|
||||
});
|
||||
```
|
||||
|
||||
### Touch Gestures on Canvas
|
||||
|
||||
```typescript
|
||||
test("pinch zoom on canvas", async ({ page }) => {
|
||||
await page.goto("/map");
|
||||
|
||||
const canvas = page.locator("canvas");
|
||||
const box = await canvas.boundingBox();
|
||||
const centerX = box!.x + box!.width / 2;
|
||||
const centerY = box!.y + box!.height / 2;
|
||||
|
||||
// Simulate pinch zoom using two touch points
|
||||
await page.touchscreen.tap(centerX, centerY);
|
||||
|
||||
// Use evaluate for complex gestures
|
||||
await page.evaluate(
|
||||
async ({ x, y }) => {
|
||||
const target = document.querySelector("canvas")!;
|
||||
|
||||
// Simulate pinch start
|
||||
const touch1 = new Touch({
|
||||
identifier: 1,
|
||||
target,
|
||||
clientX: x - 50,
|
||||
clientY: y,
|
||||
});
|
||||
const touch2 = new Touch({
|
||||
identifier: 2,
|
||||
target,
|
||||
clientX: x + 50,
|
||||
clientY: y,
|
||||
});
|
||||
|
||||
target.dispatchEvent(
|
||||
new TouchEvent("touchstart", {
|
||||
touches: [touch1, touch2],
|
||||
targetTouches: [touch1, touch2],
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Simulate pinch out
|
||||
const touch1End = new Touch({
|
||||
identifier: 1,
|
||||
target,
|
||||
clientX: x - 100,
|
||||
clientY: y,
|
||||
});
|
||||
const touch2End = new Touch({
|
||||
identifier: 2,
|
||||
target,
|
||||
clientX: x + 100,
|
||||
clientY: y,
|
||||
});
|
||||
|
||||
target.dispatchEvent(
|
||||
new TouchEvent("touchmove", {
|
||||
touches: [touch1End, touch2End],
|
||||
targetTouches: [touch1End, touch2End],
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
|
||||
target.dispatchEvent(new TouchEvent("touchend", { bubbles: true }));
|
||||
},
|
||||
{ x: centerX, y: centerY },
|
||||
);
|
||||
|
||||
// Verify zoom level changed
|
||||
const zoomLevel = await page.locator("#zoom-indicator").textContent();
|
||||
expect(parseFloat(zoomLevel!)).toBeGreaterThan(1);
|
||||
});
|
||||
```
|
||||
|
||||
## WebGL Testing
|
||||
|
||||
### Checking WebGL Support
|
||||
|
||||
```typescript
|
||||
test("WebGL is supported", async ({ page }) => {
|
||||
await page.goto("/3d-viewer");
|
||||
|
||||
const hasWebGL = await page.evaluate(() => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const gl =
|
||||
canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
|
||||
return !!gl;
|
||||
});
|
||||
|
||||
expect(hasWebGL).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
### WebGL Screenshot Testing
|
||||
|
||||
```typescript
|
||||
test("3D scene renders", async ({ page }) => {
|
||||
await page.goto("/3d-model-viewer");
|
||||
|
||||
// Wait for WebGL scene to render
|
||||
await page.waitForFunction(() => {
|
||||
const canvas = document.querySelector("canvas");
|
||||
if (!canvas) return false;
|
||||
|
||||
const gl = canvas.getContext("webgl") || canvas.getContext("webgl2");
|
||||
if (!gl) return false;
|
||||
|
||||
// Check if something has been drawn
|
||||
const pixels = new Uint8Array(4);
|
||||
gl.readPixels(
|
||||
canvas.width / 2,
|
||||
canvas.height / 2,
|
||||
1,
|
||||
1,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
pixels,
|
||||
);
|
||||
return pixels.some((p) => p > 0);
|
||||
});
|
||||
|
||||
// Screenshot comparison (higher threshold for WebGL)
|
||||
await expect(page.locator("canvas")).toHaveScreenshot("3d-scene.png", {
|
||||
maxDiffPixelRatio: 0.05, // WebGL can have more variation
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Three.js Applications
|
||||
|
||||
```typescript
|
||||
test("Three.js scene interaction", async ({ page }) => {
|
||||
await page.goto("/three-demo");
|
||||
|
||||
// Wait for scene to be ready
|
||||
await page.waitForFunction(() => window.scene?.children?.length > 0);
|
||||
|
||||
// Interact with scene (orbit controls)
|
||||
const canvas = page.locator("canvas");
|
||||
const box = await canvas.boundingBox();
|
||||
|
||||
// Rotate camera by dragging
|
||||
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
box!.x + box!.width / 2 + 100,
|
||||
box!.y + box!.height / 2,
|
||||
{
|
||||
steps: 10,
|
||||
},
|
||||
);
|
||||
await page.mouse.up();
|
||||
|
||||
// Verify camera position changed
|
||||
const cameraRotation = await page.evaluate(() => {
|
||||
return window.camera?.rotation?.y;
|
||||
});
|
||||
|
||||
expect(cameraRotation).not.toBe(0);
|
||||
});
|
||||
```
|
||||
|
||||
## Chart Libraries
|
||||
|
||||
### Chart.js Testing
|
||||
|
||||
```typescript
|
||||
test("Chart.js renders data", async ({ page }) => {
|
||||
await page.goto("/chartjs-demo");
|
||||
|
||||
// Wait for Chart.js to initialize
|
||||
await page.waitForFunction(() => {
|
||||
return window.Chart && document.querySelector("canvas")?.__chart__;
|
||||
});
|
||||
|
||||
// Get chart data via Chart.js API
|
||||
const chartData = await page.evaluate(() => {
|
||||
const canvas = document.querySelector("canvas") as any;
|
||||
const chart = canvas.__chart__;
|
||||
return chart.data.datasets[0].data;
|
||||
});
|
||||
|
||||
expect(chartData).toEqual([12, 19, 3, 5, 2, 3]);
|
||||
|
||||
// Screenshot test
|
||||
await expect(page.locator("canvas")).toHaveScreenshot();
|
||||
});
|
||||
```
|
||||
|
||||
### D3.js / ECharts Testing
|
||||
|
||||
```typescript
|
||||
test("chart library interaction", async ({ page }) => {
|
||||
await page.goto("/chart-demo");
|
||||
|
||||
// Wait for chart to render
|
||||
await page.waitForFunction(() => document.querySelector("canvas, svg.chart"));
|
||||
|
||||
// For SVG charts (D3)
|
||||
const bars = page.locator("svg.chart rect.bar");
|
||||
if ((await bars.count()) > 0) {
|
||||
await bars.first().hover();
|
||||
await expect(page.locator(".tooltip")).toBeVisible();
|
||||
}
|
||||
|
||||
// For canvas charts (ECharts, Chart.js)
|
||||
const canvas = page.locator("canvas");
|
||||
await canvas.click({ position: { x: 200, y: 150 } });
|
||||
});
|
||||
```
|
||||
|
||||
## Game & Animation Testing
|
||||
|
||||
### Frame-by-Frame Testing
|
||||
|
||||
```typescript
|
||||
test("game frame control", async ({ page }) => {
|
||||
await page.goto("/game");
|
||||
|
||||
// Pause and step through frames
|
||||
await page.evaluate(() => window.gameLoop?.pause());
|
||||
await page.evaluate(() => window.gameLoop?.tick());
|
||||
await expect(page.locator("canvas")).toHaveScreenshot("frame-1.png");
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await page.evaluate(() => window.gameLoop?.tick());
|
||||
}
|
||||
await expect(page.locator("canvas")).toHaveScreenshot("frame-11.png");
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Game State
|
||||
|
||||
```typescript
|
||||
test("game state changes", async ({ page }) => {
|
||||
await page.goto("/game");
|
||||
|
||||
const initialScore = await page.evaluate(() => window.game?.score);
|
||||
expect(initialScore).toBe(0);
|
||||
|
||||
await page.keyboard.press("Space"); // Action
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const newScore = await page.evaluate(() => window.game?.score);
|
||||
expect(newScore).toBeGreaterThan(0);
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ------------------------ | ------------------------ | ----------------------------------- |
|
||||
| Pixel-perfect assertions | Fails across browsers/OS | Use maxDiffPixelRatio threshold |
|
||||
| Not waiting for render | Blank canvas screenshots | Wait for draw completion |
|
||||
| Testing raw pixel data | Brittle and slow | Use visual comparison |
|
||||
| Ignoring animation | Flaky screenshots | Pause/disable animations |
|
||||
| Hardcoded coordinates | Breaks on resize | Calculate relative to canvas bounds |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Visual Testing**: See [test-suite-structure.md](../core/test-suite-structure.md) for visual regression setup
|
||||
- **Mobile Gestures**: See [mobile-testing.md](../advanced/mobile-testing.md) for touch interactions
|
||||
- **Performance**: See [performance-testing.md](performance-testing.md) for FPS monitoring
|
||||
@@ -0,0 +1,438 @@
|
||||
# Component Testing
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Setup & Configuration](#setup--configuration)
|
||||
2. [Mounting Components](#mounting-components)
|
||||
3. [Props & State Testing](#props--state-testing)
|
||||
4. [Events & Interactions](#events--interactions)
|
||||
5. [Slots & Children](#slots--children)
|
||||
6. [Mocking Dependencies](#mocking-dependencies)
|
||||
7. [Framework-Specific Patterns](#framework-specific-patterns)
|
||||
|
||||
## Setup & Configuration
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# React
|
||||
npm init playwright@latest -- --ct
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
```typescript
|
||||
// playwright-ct.config.ts
|
||||
import { defineConfig, devices } from "@playwright/experimental-ct-react";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/components",
|
||||
snapshotDir: "./tests/components/__snapshots__",
|
||||
|
||||
use: {
|
||||
ctPort: 3100,
|
||||
ctViteConfig: {
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": "/src",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
projects: [
|
||||
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
|
||||
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
components/
|
||||
Button.tsx
|
||||
Modal.tsx
|
||||
tests/
|
||||
components/
|
||||
Button.spec.tsx
|
||||
Modal.spec.tsx
|
||||
playwright/
|
||||
index.html # CT entry point
|
||||
index.tsx # CT setup (providers, styles)
|
||||
```
|
||||
|
||||
## Mounting Components
|
||||
|
||||
### Basic Mount
|
||||
|
||||
```tsx
|
||||
// Button.spec.tsx
|
||||
import { test, expect } from "@playwright/experimental-ct-react";
|
||||
import { Button } from "@/components/Button";
|
||||
|
||||
test("renders button with text", async ({ mount }) => {
|
||||
const component = await mount(<Button>Click me</Button>);
|
||||
|
||||
await expect(component).toContainText("Click me");
|
||||
await expect(component).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Mount with Props
|
||||
|
||||
```tsx
|
||||
test("renders with all props", async ({ mount }) => {
|
||||
const component = await mount(
|
||||
<Button variant="primary" size="large" disabled={false} icon="check">
|
||||
Submit
|
||||
</Button>,
|
||||
);
|
||||
|
||||
await expect(component).toHaveClass(/primary/);
|
||||
await expect(component).toHaveClass(/large/);
|
||||
await expect(component.locator("svg")).toBeVisible(); // icon
|
||||
});
|
||||
```
|
||||
|
||||
### Mount with Wrapper/Provider
|
||||
|
||||
```tsx
|
||||
// playwright/index.tsx - Global providers
|
||||
import { ThemeProvider } from "@/providers/theme";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import "@/styles/globals.css";
|
||||
|
||||
export default function PlaywrightWrapper({ children }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Or per-test wrapper
|
||||
test("with custom provider", async ({ mount }) => {
|
||||
const component = await mount(
|
||||
<AuthProvider initialUser={{ name: "Test" }}>
|
||||
<UserProfile />
|
||||
</AuthProvider>,
|
||||
);
|
||||
|
||||
await expect(component.getByText("Test")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Props & State Testing
|
||||
|
||||
### Testing Prop Variations
|
||||
|
||||
```tsx
|
||||
test.describe("Button variants", () => {
|
||||
const variants = ["primary", "secondary", "danger", "ghost"] as const;
|
||||
|
||||
for (const variant of variants) {
|
||||
test(`renders ${variant} variant`, async ({ mount }) => {
|
||||
const component = await mount(<Button variant={variant}>Button</Button>);
|
||||
await expect(component).toHaveClass(new RegExp(variant));
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Updating Props
|
||||
|
||||
```tsx
|
||||
test("responds to prop changes", async ({ mount }) => {
|
||||
const component = await mount(<Counter initialCount={0} />);
|
||||
|
||||
await expect(component.getByTestId("count")).toHaveText("0");
|
||||
|
||||
// Update props
|
||||
await component.update(<Counter initialCount={10} />);
|
||||
await expect(component.getByTestId("count")).toHaveText("10");
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Controlled Components
|
||||
|
||||
```tsx
|
||||
test("controlled input", async ({ mount }) => {
|
||||
let externalValue = "";
|
||||
|
||||
const component = await mount(
|
||||
<Input
|
||||
value={externalValue}
|
||||
onChange={(e) => {
|
||||
externalValue = e.target.value;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await component.locator("input").fill("hello");
|
||||
|
||||
// For controlled components, update with new value
|
||||
await component.update(
|
||||
<Input value="hello" onChange={(e) => (externalValue = e.target.value)} />,
|
||||
);
|
||||
|
||||
await expect(component.locator("input")).toHaveValue("hello");
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Internal State
|
||||
|
||||
```tsx
|
||||
test("internal state updates", async ({ mount }) => {
|
||||
const component = await mount(<Toggle defaultChecked={false} />);
|
||||
|
||||
// Initial state
|
||||
await expect(component.locator('[role="switch"]')).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
|
||||
// Trigger state change
|
||||
await component.click();
|
||||
|
||||
// Verify state updated
|
||||
await expect(component.locator('[role="switch"]')).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## Events & Interactions
|
||||
|
||||
### Testing Click Events
|
||||
|
||||
```tsx
|
||||
test("click event fires", async ({ mount }) => {
|
||||
let clicked = false;
|
||||
|
||||
const component = await mount(
|
||||
<Button onClick={() => (clicked = true)}>Click</Button>,
|
||||
);
|
||||
|
||||
await component.click();
|
||||
|
||||
expect(clicked).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Event Payloads
|
||||
|
||||
```tsx
|
||||
test("onChange provides correct value", async ({ mount }) => {
|
||||
const values: string[] = [];
|
||||
|
||||
const component = await mount(
|
||||
<Select
|
||||
options={["a", "b", "c"]}
|
||||
onChange={(value) => values.push(value)}
|
||||
/>,
|
||||
);
|
||||
|
||||
await component.getByRole("combobox").click();
|
||||
await component.getByRole("option", { name: "b" }).click();
|
||||
|
||||
expect(values).toEqual(["b"]);
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Form Submission
|
||||
|
||||
```tsx
|
||||
test("form submission", async ({ mount }) => {
|
||||
let submittedData: FormData | null = null;
|
||||
|
||||
const component = await mount(
|
||||
<LoginForm
|
||||
onSubmit={(data) => {
|
||||
submittedData = data;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await component.getByLabel("Email").fill("test@example.com");
|
||||
await component.getByLabel("Password").fill("secret123");
|
||||
await component.getByRole("button", { name: "Sign in" }).click();
|
||||
|
||||
expect(submittedData).toEqual({
|
||||
email: "test@example.com",
|
||||
password: "secret123",
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Keyboard Interactions
|
||||
|
||||
```tsx
|
||||
test("keyboard navigation", async ({ mount }) => {
|
||||
const component = await mount(
|
||||
<Dropdown options={["Apple", "Banana", "Cherry"]} />,
|
||||
);
|
||||
|
||||
// Open dropdown
|
||||
await component.getByRole("button").click();
|
||||
|
||||
// Navigate with keyboard
|
||||
await component.press("ArrowDown");
|
||||
await component.press("ArrowDown");
|
||||
await component.press("Enter");
|
||||
|
||||
await expect(component.getByRole("button")).toHaveText("Banana");
|
||||
});
|
||||
```
|
||||
|
||||
## Slots & Children
|
||||
|
||||
### Testing Children Content
|
||||
|
||||
```tsx
|
||||
test("renders children", async ({ mount }) => {
|
||||
const component = await mount(
|
||||
<Card>
|
||||
<h2>Title</h2>
|
||||
<p>Description</p>
|
||||
</Card>,
|
||||
);
|
||||
|
||||
await expect(component.getByRole("heading")).toHaveText("Title");
|
||||
await expect(component.getByText("Description")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Render Props
|
||||
|
||||
```tsx
|
||||
test("render prop pattern", async ({ mount }) => {
|
||||
const component = await mount(
|
||||
<DataFetcher url="/api/users">
|
||||
{({ data, loading }) =>
|
||||
loading ? <span>Loading...</span> : <span>{data.name}</span>
|
||||
}
|
||||
</DataFetcher>,
|
||||
);
|
||||
|
||||
// Initially loading
|
||||
await expect(component.getByText("Loading...")).toBeVisible();
|
||||
|
||||
// After data loads
|
||||
await expect(component.getByText(/User/)).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Mocking Dependencies
|
||||
|
||||
### Mocking Imports
|
||||
|
||||
```tsx
|
||||
// playwright/index.tsx - Mock at setup level
|
||||
import { beforeMount } from "@playwright/experimental-ct-react/hooks";
|
||||
|
||||
beforeMount(async ({ hooksConfig }) => {
|
||||
// Mock analytics
|
||||
window.analytics = {
|
||||
track: () => {},
|
||||
identify: () => {},
|
||||
};
|
||||
|
||||
// Mock feature flags
|
||||
if (hooksConfig?.featureFlags) {
|
||||
window.__FEATURE_FLAGS__ = hooksConfig.featureFlags;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Test with mocked config
|
||||
test("with feature flag", async ({ mount }) => {
|
||||
const component = await mount(<FeatureComponent />, {
|
||||
hooksConfig: {
|
||||
featureFlags: { newFeature: true },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(component.getByText("New Feature")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Mocking API Calls
|
||||
|
||||
```tsx
|
||||
test("component with API", async ({ mount, page }) => {
|
||||
// Mock API before mounting
|
||||
await page.route("**/api/user", (route) => {
|
||||
route.fulfill({
|
||||
json: { id: 1, name: "Test User" },
|
||||
});
|
||||
});
|
||||
|
||||
const component = await mount(<UserProfile userId={1} />);
|
||||
|
||||
await expect(component.getByText("Test User")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Mocking Hooks
|
||||
|
||||
```tsx
|
||||
// Mock custom hook via module mock
|
||||
test("with mocked hook", async ({ mount }) => {
|
||||
const component = await mount(<Dashboard />, {
|
||||
hooksConfig: {
|
||||
mockAuth: { user: { name: "Admin" }, isAdmin: true },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(component.getByText("Admin Panel")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Framework-Specific Patterns
|
||||
|
||||
### React Testing
|
||||
|
||||
```tsx
|
||||
// React with refs
|
||||
test("exposes ref methods", async ({ mount }) => {
|
||||
let inputRef: HTMLInputElement | null = null;
|
||||
|
||||
const component = await mount(<Input ref={(el) => (inputRef = el)} />);
|
||||
|
||||
await component.locator("input").fill("test");
|
||||
expect(inputRef?.value).toBe("test");
|
||||
});
|
||||
|
||||
// React with context
|
||||
test("uses context", async ({ mount }) => {
|
||||
const component = await mount(
|
||||
<UserContext.Provider value={{ name: "Test" }}>
|
||||
<UserGreeting />
|
||||
</UserContext.Provider>,
|
||||
);
|
||||
|
||||
await expect(component).toContainText("Hello, Test");
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ------------------------------ | ------------------- | --------------------------------- |
|
||||
| Testing implementation details | Brittle tests | Test behavior, not internal state |
|
||||
| Snapshot testing everything | Maintenance burden | Use for visual regression only |
|
||||
| Not isolating components | Hidden dependencies | Mock all external dependencies |
|
||||
| Testing framework behavior | Redundant | Focus on your component logic |
|
||||
| Skipping accessibility | Misses real issues | Include a11y checks in CT |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Accessibility**: See [accessibility.md](accessibility.md) for a11y testing in components
|
||||
- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for shared test setup
|
||||
@@ -0,0 +1,576 @@
|
||||
# Drag and Drop Testing
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Kanban Board (Cross-Column Movement)](#kanban-board-cross-column-movement)
|
||||
2. [Sortable Lists (Reordering)](#sortable-lists-reordering)
|
||||
3. [Native HTML5 Drag and Drop](#native-html5-drag-and-drop)
|
||||
4. [File Drop Zone](#file-drop-zone)
|
||||
5. [Canvas Coordinate-Based Dragging](#canvas-coordinate-based-dragging)
|
||||
6. [Custom Drag Preview](#custom-drag-preview)
|
||||
7. [Variations](#variations)
|
||||
8. [Tips](#tips)
|
||||
|
||||
> **When to use**: Testing drag-and-drop interactions — sortable lists, kanban boards, file drop zones, or repositionable elements.
|
||||
|
||||
---
|
||||
|
||||
## Kanban Board (Cross-Column Movement)
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('moves card between columns', async ({ page }) => {
|
||||
await page.goto('/board');
|
||||
|
||||
const backlog = page.locator('[data-column="backlog"]');
|
||||
const active = page.locator('[data-column="active"]');
|
||||
|
||||
const ticket = backlog.getByText('Update API docs');
|
||||
await expect(ticket).toBeVisible();
|
||||
|
||||
const backlogCountBefore = await backlog.getByRole('article').count();
|
||||
const activeCountBefore = await active.getByRole('article').count();
|
||||
|
||||
await ticket.dragTo(active);
|
||||
|
||||
await expect(active.getByText('Update API docs')).toBeVisible();
|
||||
await expect(backlog.getByText('Update API docs')).not.toBeVisible();
|
||||
|
||||
await expect(backlog.getByRole('article')).toHaveCount(backlogCountBefore - 1);
|
||||
await expect(active.getByRole('article')).toHaveCount(activeCountBefore + 1);
|
||||
});
|
||||
|
||||
test('progresses card through workflow stages', async ({ page }) => {
|
||||
await page.goto('/board');
|
||||
|
||||
const cols = {
|
||||
backlog: page.locator('[data-column="backlog"]'),
|
||||
active: page.locator('[data-column="active"]'),
|
||||
review: page.locator('[data-column="review"]'),
|
||||
complete: page.locator('[data-column="complete"]'),
|
||||
};
|
||||
|
||||
await cols.backlog.getByText('Update API docs').dragTo(cols.active);
|
||||
await expect(cols.active.getByText('Update API docs')).toBeVisible();
|
||||
|
||||
await cols.active.getByText('Update API docs').dragTo(cols.review);
|
||||
await expect(cols.review.getByText('Update API docs')).toBeVisible();
|
||||
|
||||
await cols.review.getByText('Update API docs').dragTo(cols.complete);
|
||||
await expect(cols.complete.getByText('Update API docs')).toBeVisible();
|
||||
|
||||
await expect(cols.backlog.getByText('Update API docs')).not.toBeVisible();
|
||||
await expect(cols.active.getByText('Update API docs')).not.toBeVisible();
|
||||
await expect(cols.review.getByText('Update API docs')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('reorders cards within same column', async ({ page }) => {
|
||||
await page.goto('/board');
|
||||
|
||||
const backlog = page.locator('[data-column="backlog"]');
|
||||
|
||||
const itemX = backlog.getByRole('article').filter({ hasText: 'Item X' });
|
||||
const itemZ = backlog.getByRole('article').filter({ hasText: 'Item Z' });
|
||||
|
||||
await itemZ.dragTo(itemX);
|
||||
|
||||
const cards = await backlog.getByRole('article').allTextContents();
|
||||
expect(cards.indexOf('Item Z')).toBeLessThan(cards.indexOf('Item X'));
|
||||
});
|
||||
|
||||
test('verifies drag persists via API', async ({ page }) => {
|
||||
await page.goto('/board');
|
||||
|
||||
const backlog = page.locator('[data-column="backlog"]');
|
||||
const active = page.locator('[data-column="active"]');
|
||||
|
||||
const responsePromise = page.waitForResponse(
|
||||
(r) => r.url().includes('/api/tickets') && r.request().method() === 'PATCH'
|
||||
);
|
||||
|
||||
await backlog.getByText('Update API docs').dragTo(active);
|
||||
|
||||
const response = await responsePromise;
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.column).toBe('active');
|
||||
|
||||
await page.reload();
|
||||
await expect(active.getByText('Update API docs')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sortable Lists (Reordering)
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('reorders list items', async ({ page }) => {
|
||||
await page.goto('/priorities');
|
||||
|
||||
const list = page.getByRole('list', { name: 'Priority list' });
|
||||
|
||||
const initial = await list.getByRole('listitem').allTextContents();
|
||||
expect(initial[0]).toContain('Priority A');
|
||||
expect(initial[1]).toContain('Priority B');
|
||||
expect(initial[2]).toContain('Priority C');
|
||||
|
||||
const priorityC = list.getByRole('listitem').filter({ hasText: 'Priority C' });
|
||||
const priorityA = list.getByRole('listitem').filter({ hasText: 'Priority A' });
|
||||
|
||||
await priorityC.dragTo(priorityA);
|
||||
|
||||
const reordered = await list.getByRole('listitem').allTextContents();
|
||||
expect(reordered[0]).toContain('Priority C');
|
||||
expect(reordered[1]).toContain('Priority A');
|
||||
expect(reordered[2]).toContain('Priority B');
|
||||
});
|
||||
|
||||
test('reorders via drag handle', async ({ page }) => {
|
||||
await page.goto('/priorities');
|
||||
|
||||
const list = page.getByRole('list', { name: 'Priority list' });
|
||||
|
||||
const handle = list
|
||||
.getByRole('listitem')
|
||||
.filter({ hasText: 'Priority C' })
|
||||
.getByRole('button', { name: /drag|reorder|grip/i });
|
||||
|
||||
const target = list.getByRole('listitem').filter({ hasText: 'Priority A' });
|
||||
|
||||
await handle.dragTo(target);
|
||||
|
||||
const items = await list.getByRole('listitem').allTextContents();
|
||||
expect(items[0]).toContain('Priority C');
|
||||
});
|
||||
|
||||
test('reorder persists after reload', async ({ page }) => {
|
||||
await page.goto('/priorities');
|
||||
|
||||
const list = page.getByRole('list', { name: 'Priority list' });
|
||||
|
||||
const priorityC = list.getByRole('listitem').filter({ hasText: 'Priority C' });
|
||||
const priorityA = list.getByRole('listitem').filter({ hasText: 'Priority A' });
|
||||
|
||||
await priorityC.dragTo(priorityA);
|
||||
|
||||
await page.waitForResponse((response) =>
|
||||
response.url().includes('/api/priorities/reorder') && response.status() === 200
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
|
||||
const items = await list.getByRole('listitem').allTextContents();
|
||||
expect(items[0]).toContain('Priority C');
|
||||
expect(items[1]).toContain('Priority A');
|
||||
expect(items[2]).toContain('Priority B');
|
||||
});
|
||||
```
|
||||
|
||||
### Incremental Mouse Movement for Custom Libraries
|
||||
|
||||
Some drag libraries (react-beautiful-dnd, dnd-kit) require incremental mouse movements:
|
||||
|
||||
```typescript
|
||||
test('reorders with incremental mouse movements', async ({ page }) => {
|
||||
await page.goto('/priorities');
|
||||
|
||||
const list = page.getByRole('list', { name: 'Priority list' });
|
||||
const source = list.getByRole('listitem').filter({ hasText: 'Priority C' });
|
||||
const target = list.getByRole('listitem').filter({ hasText: 'Priority A' });
|
||||
|
||||
const sourceBox = await source.boundingBox();
|
||||
const targetBox = await target.boundingBox();
|
||||
|
||||
await source.hover();
|
||||
await page.mouse.down();
|
||||
|
||||
const steps = 10;
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
await page.mouse.move(
|
||||
sourceBox!.x + sourceBox!.width / 2,
|
||||
sourceBox!.y + (targetBox!.y - sourceBox!.y) * (i / steps),
|
||||
{ steps: 1 }
|
||||
);
|
||||
}
|
||||
|
||||
await page.mouse.up();
|
||||
|
||||
const items = await list.getByRole('listitem').allTextContents();
|
||||
expect(items[0]).toContain('Priority C');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Native HTML5 Drag and Drop
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('drags item to drop zone', async ({ page }) => {
|
||||
await page.goto('/drag-example');
|
||||
|
||||
const source = page.getByText('Movable Element');
|
||||
const dropArea = page.locator('#target-zone');
|
||||
|
||||
await expect(source).toBeVisible();
|
||||
await expect(dropArea).not.toContainText('Movable Element');
|
||||
|
||||
await source.dragTo(dropArea);
|
||||
|
||||
await expect(dropArea).toContainText('Movable Element');
|
||||
});
|
||||
|
||||
test('drags between zones', async ({ page }) => {
|
||||
await page.goto('/drag-example');
|
||||
|
||||
const item = page.locator('[data-testid="element-1"]');
|
||||
const areaA = page.locator('[data-testid="area-a"]');
|
||||
const areaB = page.locator('[data-testid="area-b"]');
|
||||
|
||||
await expect(areaA).toContainText('Element 1');
|
||||
|
||||
await item.dragTo(areaB);
|
||||
|
||||
await expect(areaB).toContainText('Element 1');
|
||||
await expect(areaA).not.toContainText('Element 1');
|
||||
|
||||
await areaB.getByText('Element 1').dragTo(areaA);
|
||||
|
||||
await expect(areaA).toContainText('Element 1');
|
||||
await expect(areaB).not.toContainText('Element 1');
|
||||
});
|
||||
|
||||
test('verifies drag visual feedback', async ({ page }) => {
|
||||
await page.goto('/drag-example');
|
||||
|
||||
const source = page.getByText('Movable Element');
|
||||
const dropArea = page.locator('#target-zone');
|
||||
|
||||
await source.hover();
|
||||
await page.mouse.down();
|
||||
|
||||
const dropBox = await dropArea.boundingBox();
|
||||
await page.mouse.move(dropBox!.x + dropBox!.width / 2, dropBox!.y + dropBox!.height / 2);
|
||||
|
||||
await expect(dropArea).toHaveClass(/drag-over|highlight/);
|
||||
|
||||
await page.mouse.up();
|
||||
|
||||
await expect(dropArea).not.toHaveClass(/drag-over|highlight/);
|
||||
await expect(dropArea).toContainText('Movable Element');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Drop Zone
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
import path from 'path';
|
||||
|
||||
test('uploads file via drop zone', async ({ page }) => {
|
||||
await page.goto('/upload');
|
||||
|
||||
const dropZone = page.locator('[data-testid="file-drop-zone"]');
|
||||
|
||||
await expect(dropZone).toContainText('Drag files here');
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
|
||||
await fileInput.setInputFiles(path.resolve(__dirname, '../fixtures/report.pdf'));
|
||||
|
||||
await expect(page.getByText('report.pdf')).toBeVisible();
|
||||
await expect(page.getByText(/\d+ KB/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('simulates drag-over visual feedback', async ({ page }) => {
|
||||
await page.goto('/upload');
|
||||
|
||||
const dropZone = page.locator('[data-testid="file-drop-zone"]');
|
||||
|
||||
await dropZone.dispatchEvent('dragenter', {
|
||||
dataTransfer: { types: ['Files'] },
|
||||
});
|
||||
|
||||
await expect(dropZone).toHaveClass(/drag-active|drop-highlight/);
|
||||
await expect(dropZone).toContainText(/drop.*here|release.*upload/i);
|
||||
|
||||
await dropZone.dispatchEvent('dragleave');
|
||||
|
||||
await expect(dropZone).not.toHaveClass(/drag-active|drop-highlight/);
|
||||
});
|
||||
|
||||
test('rejects invalid file types', async ({ page }) => {
|
||||
await page.goto('/upload');
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
|
||||
await fileInput.setInputFiles({
|
||||
name: 'script.exe',
|
||||
mimeType: 'application/x-msdownload',
|
||||
buffer: Buffer.from('fake-content'),
|
||||
});
|
||||
|
||||
await expect(page.getByRole('alert')).toContainText(/not allowed|invalid file type/i);
|
||||
await expect(page.getByText('script.exe')).not.toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Canvas Coordinate-Based Dragging
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('drags element to specific coordinates', async ({ page }) => {
|
||||
await page.goto('/design-tool');
|
||||
|
||||
const canvas = page.locator('#editor-canvas');
|
||||
const shape = page.locator('[data-testid="shape-1"]');
|
||||
|
||||
const canvasBox = await canvas.boundingBox();
|
||||
const targetX = canvasBox!.x + 300;
|
||||
const targetY = canvasBox!.y + 200;
|
||||
|
||||
await shape.hover();
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(targetX, targetY, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
|
||||
const newBox = await shape.boundingBox();
|
||||
expect(newBox!.x).toBeCloseTo(targetX - newBox!.width / 2, -1);
|
||||
expect(newBox!.y).toBeCloseTo(targetY - newBox!.height / 2, -1);
|
||||
});
|
||||
|
||||
test('snaps element to grid', async ({ page }) => {
|
||||
await page.goto('/design-tool');
|
||||
|
||||
const shape = page.locator('[data-testid="shape-1"]');
|
||||
const canvas = page.locator('#editor-canvas');
|
||||
|
||||
const canvasBox = await canvas.boundingBox();
|
||||
|
||||
await shape.hover();
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(canvasBox!.x + 147, canvasBox!.y + 83, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
|
||||
const snappedBox = await shape.boundingBox();
|
||||
expect(snappedBox!.x % 20).toBeCloseTo(0, 0);
|
||||
expect(snappedBox!.y % 20).toBeCloseTo(0, 0);
|
||||
});
|
||||
|
||||
test('constrains drag within boundaries', async ({ page }) => {
|
||||
await page.goto('/design-tool');
|
||||
|
||||
const shape = page.locator('[data-testid="bounded-shape"]');
|
||||
const container = page.locator('#bounds-container');
|
||||
|
||||
const containerBox = await container.boundingBox();
|
||||
|
||||
await shape.hover();
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(containerBox!.x + containerBox!.width + 500, containerBox!.y - 200, {
|
||||
steps: 10,
|
||||
});
|
||||
await page.mouse.up();
|
||||
|
||||
const shapeBox = await shape.boundingBox();
|
||||
|
||||
expect(shapeBox!.x).toBeGreaterThanOrEqual(containerBox!.x);
|
||||
expect(shapeBox!.y).toBeGreaterThanOrEqual(containerBox!.y);
|
||||
expect(shapeBox!.x + shapeBox!.width).toBeLessThanOrEqual(
|
||||
containerBox!.x + containerBox!.width
|
||||
);
|
||||
expect(shapeBox!.y + shapeBox!.height).toBeLessThanOrEqual(
|
||||
containerBox!.y + containerBox!.height
|
||||
);
|
||||
});
|
||||
|
||||
test('resizes element via handle', async ({ page }) => {
|
||||
await page.goto('/design-tool');
|
||||
|
||||
const shape = page.locator('[data-testid="shape-1"]');
|
||||
await shape.click();
|
||||
|
||||
const resizeHandle = shape.locator('.resize-handle-se');
|
||||
const handleBox = await resizeHandle.boundingBox();
|
||||
|
||||
const initialBox = await shape.boundingBox();
|
||||
|
||||
await resizeHandle.hover();
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(handleBox!.x + 100, handleBox!.y + 80, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
|
||||
const newBox = await shape.boundingBox();
|
||||
expect(newBox!.width).toBeCloseTo(initialBox!.width + 100, -1);
|
||||
expect(newBox!.height).toBeCloseTo(initialBox!.height + 80, -1);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Custom Drag Preview
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('shows custom drag preview', async ({ page }) => {
|
||||
await page.goto('/board');
|
||||
|
||||
const card = page.locator('[data-testid="ticket-1"]');
|
||||
const targetCol = page.locator('[data-column="active"]');
|
||||
|
||||
const cardBox = await card.boundingBox();
|
||||
const targetBox = await targetCol.boundingBox();
|
||||
|
||||
await card.hover();
|
||||
await page.mouse.down();
|
||||
|
||||
const midX = (cardBox!.x + targetBox!.x) / 2;
|
||||
const midY = (cardBox!.y + targetBox!.y) / 2;
|
||||
await page.mouse.move(midX, midY, { steps: 5 });
|
||||
|
||||
await expect(page.locator('.drag-preview')).toBeVisible();
|
||||
await expect(card).toHaveClass(/dragging|placeholder/);
|
||||
|
||||
await page.mouse.move(
|
||||
targetBox!.x + targetBox!.width / 2,
|
||||
targetBox!.y + targetBox!.height / 2,
|
||||
{ steps: 5 }
|
||||
);
|
||||
await page.mouse.up();
|
||||
|
||||
await expect(page.locator('.drag-preview')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('multi-select drag shows item count', async ({ page }) => {
|
||||
await page.goto('/board');
|
||||
|
||||
await page.locator('[data-testid="ticket-1"]').click();
|
||||
await page.locator('[data-testid="ticket-2"]').click({ modifiers: ['Shift'] });
|
||||
await page.locator('[data-testid="ticket-3"]').click({ modifiers: ['Shift'] });
|
||||
|
||||
const card = page.locator('[data-testid="ticket-1"]');
|
||||
const targetCol = page.locator('[data-column="complete"]');
|
||||
|
||||
await card.hover();
|
||||
await page.mouse.down();
|
||||
|
||||
const targetBox = await targetCol.boundingBox();
|
||||
await page.mouse.move(targetBox!.x + 50, targetBox!.y + 50, { steps: 5 });
|
||||
|
||||
await expect(page.locator('.drag-preview')).toContainText('3 items');
|
||||
|
||||
await page.mouse.up();
|
||||
|
||||
await expect(targetCol.locator('[data-testid="ticket-1"]')).toBeVisible();
|
||||
await expect(targetCol.locator('[data-testid="ticket-2"]')).toBeVisible();
|
||||
await expect(targetCol.locator('[data-testid="ticket-3"]')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Variations
|
||||
|
||||
### Keyboard-Based Reordering
|
||||
|
||||
```typescript
|
||||
test('reorders using keyboard', async ({ page }) => {
|
||||
await page.goto('/priorities');
|
||||
|
||||
const list = page.getByRole('list', { name: 'Priority list' });
|
||||
const priorityC = list.getByRole('listitem').filter({ hasText: 'Priority C' });
|
||||
|
||||
await priorityC.focus();
|
||||
await page.keyboard.press('Space');
|
||||
|
||||
await page.keyboard.press('ArrowUp');
|
||||
await page.keyboard.press('ArrowUp');
|
||||
|
||||
await page.keyboard.press('Space');
|
||||
|
||||
const items = await list.getByRole('listitem').allTextContents();
|
||||
expect(items[0]).toContain('Priority C');
|
||||
});
|
||||
```
|
||||
|
||||
### Cross-Frame Dragging
|
||||
|
||||
```typescript
|
||||
test('drags between main page and iframe', async ({ page }) => {
|
||||
await page.goto('/composer');
|
||||
|
||||
const sourceWidget = page.getByText('Component A');
|
||||
const iframe = page.frameLocator('#preview-frame');
|
||||
const iframeElement = page.locator('#preview-frame');
|
||||
|
||||
const sourceBox = await sourceWidget.boundingBox();
|
||||
const iframeBox = await iframeElement.boundingBox();
|
||||
|
||||
const targetX = iframeBox!.x + 100;
|
||||
const targetY = iframeBox!.y + 100;
|
||||
|
||||
await sourceWidget.hover();
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(targetX, targetY, { steps: 20 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect(iframe.getByText('Component A')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Touch-Based Drag on Mobile
|
||||
|
||||
```typescript
|
||||
test('drags via touch events', async ({ page }) => {
|
||||
await page.goto('/priorities');
|
||||
|
||||
const list = page.getByRole('list', { name: 'Priority list' });
|
||||
const source = list.getByRole('listitem').filter({ hasText: 'Priority C' });
|
||||
const target = list.getByRole('listitem').filter({ hasText: 'Priority A' });
|
||||
|
||||
const sourceBox = await source.boundingBox();
|
||||
const targetBox = await target.boundingBox();
|
||||
|
||||
await source.dispatchEvent('touchstart', {
|
||||
touches: [{ clientX: sourceBox!.x + 10, clientY: sourceBox!.y + 10 }],
|
||||
});
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const y = sourceBox!.y + (targetBox!.y - sourceBox!.y) * (i / 5);
|
||||
await source.dispatchEvent('touchmove', {
|
||||
touches: [{ clientX: sourceBox!.x + 10, clientY: y }],
|
||||
});
|
||||
}
|
||||
|
||||
await source.dispatchEvent('touchend');
|
||||
|
||||
const items = await list.getByRole('listitem').allTextContents();
|
||||
expect(items[0]).toContain('Priority C');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
1. **Start with `dragTo()`, fall back to manual mouse events**. Playwright's `dragTo()` handles most HTML5 drag-and-drop. Use `page.mouse.down()` / `move()` / `up()` only for custom libraries (react-beautiful-dnd, dnd-kit, SortableJS) that need specific event sequences.
|
||||
|
||||
2. **Add intermediate mouse steps for drag libraries**. Libraries like `react-beautiful-dnd` require multiple `mousemove` events. Use `{ steps: 10 }` or a manual loop — a single jump often fails silently.
|
||||
|
||||
3. **Assert final state, not just the drop event**. Verify DOM reflects the change — item order, column contents, position coordinates. Visual feedback during drag is secondary to the persisted state.
|
||||
|
||||
4. **Use `boundingBox()` for coordinate assertions**. For canvas editors or position-sensitive drops, capture bounding box after the operation and compare with `toBeCloseTo()` for tolerance.
|
||||
|
||||
5. **Test undo after drag operations**. If your app supports Ctrl+Z, verify the drag is reversible — this catches state management bugs.
|
||||
@@ -0,0 +1,509 @@
|
||||
# Electron Testing
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Setup & Configuration](#setup--configuration)
|
||||
2. [Launching Electron Apps](#launching-electron-apps)
|
||||
3. [Main Process Testing](#main-process-testing)
|
||||
4. [Renderer Process Testing](#renderer-process-testing)
|
||||
5. [IPC Communication](#ipc-communication)
|
||||
6. [Native Features](#native-features)
|
||||
7. [Packaging & Distribution](#packaging--distribution)
|
||||
|
||||
## Setup & Configuration
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
npm install -D @playwright/test electron
|
||||
```
|
||||
|
||||
### Basic Configuration
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
timeout: 30000,
|
||||
use: {
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Electron Test Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/electron.ts
|
||||
import {
|
||||
test as base,
|
||||
_electron as electron,
|
||||
ElectronApplication,
|
||||
Page,
|
||||
} from "@playwright/test";
|
||||
|
||||
type ElectronFixtures = {
|
||||
electronApp: ElectronApplication;
|
||||
window: Page;
|
||||
};
|
||||
|
||||
export const test = base.extend<ElectronFixtures>({
|
||||
electronApp: async ({}, use) => {
|
||||
// Launch Electron app
|
||||
const electronApp = await electron.launch({
|
||||
args: [".", "--no-sandbox"],
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "test",
|
||||
},
|
||||
});
|
||||
|
||||
await use(electronApp);
|
||||
|
||||
// Cleanup
|
||||
await electronApp.close();
|
||||
},
|
||||
|
||||
window: async ({ electronApp }, use) => {
|
||||
// Wait for first window
|
||||
const window = await electronApp.firstWindow();
|
||||
|
||||
// Wait for app to be ready
|
||||
await window.waitForLoadState("domcontentloaded");
|
||||
|
||||
await use(window);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from "@playwright/test";
|
||||
```
|
||||
|
||||
### Launch Options
|
||||
|
||||
```typescript
|
||||
// Advanced launch configuration
|
||||
const electronApp = await electron.launch({
|
||||
args: ["main.js", "--custom-flag"],
|
||||
cwd: "/path/to/app",
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_ENABLE_LOGGING: "1",
|
||||
NODE_ENV: "test",
|
||||
},
|
||||
timeout: 30000,
|
||||
// For packaged apps
|
||||
executablePath: "/path/to/MyApp.app/Contents/MacOS/MyApp",
|
||||
});
|
||||
```
|
||||
|
||||
## Launching Electron Apps
|
||||
|
||||
### Development Mode
|
||||
|
||||
```typescript
|
||||
test("launch in dev mode", async () => {
|
||||
const electronApp = await electron.launch({
|
||||
args: ["."], // Points to package.json main
|
||||
});
|
||||
|
||||
const window = await electronApp.firstWindow();
|
||||
await expect(window.locator("h1")).toContainText("My App");
|
||||
|
||||
await electronApp.close();
|
||||
});
|
||||
```
|
||||
|
||||
### Packaged Application
|
||||
|
||||
```typescript
|
||||
test("launch packaged app", async () => {
|
||||
const appPath =
|
||||
process.platform === "darwin"
|
||||
? "/Applications/MyApp.app/Contents/MacOS/MyApp"
|
||||
: process.platform === "win32"
|
||||
? "C:\\Program Files\\MyApp\\MyApp.exe"
|
||||
: "/usr/bin/myapp";
|
||||
|
||||
const electronApp = await electron.launch({
|
||||
executablePath: appPath,
|
||||
});
|
||||
|
||||
const window = await electronApp.firstWindow();
|
||||
await expect(window).toHaveTitle(/MyApp/);
|
||||
|
||||
await electronApp.close();
|
||||
});
|
||||
```
|
||||
|
||||
### Multiple Windows
|
||||
|
||||
```typescript
|
||||
test("handle multiple windows", async ({ electronApp }) => {
|
||||
const mainWindow = await electronApp.firstWindow();
|
||||
|
||||
// Trigger new window
|
||||
await mainWindow.getByRole("button", { name: "Open Settings" }).click();
|
||||
|
||||
// Wait for new window
|
||||
const settingsWindow = await electronApp.waitForEvent("window");
|
||||
|
||||
// Both windows are now accessible
|
||||
await expect(settingsWindow.locator("h1")).toHaveText("Settings");
|
||||
await expect(mainWindow.locator("h1")).toHaveText("Main");
|
||||
|
||||
// Get all windows
|
||||
const windows = electronApp.windows();
|
||||
expect(windows.length).toBe(2);
|
||||
});
|
||||
```
|
||||
|
||||
## Main Process Testing
|
||||
|
||||
### Evaluate in Main Process
|
||||
|
||||
```typescript
|
||||
test("access main process", async ({ electronApp }) => {
|
||||
// Evaluate in main process context
|
||||
const appPath = await electronApp.evaluate(async ({ app }) => {
|
||||
return app.getAppPath();
|
||||
});
|
||||
|
||||
expect(appPath).toContain("my-electron-app");
|
||||
});
|
||||
```
|
||||
|
||||
### Access Electron APIs
|
||||
|
||||
```typescript
|
||||
test("electron API access", async ({ electronApp }) => {
|
||||
// Get app version
|
||||
const version = await electronApp.evaluate(async ({ app }) => {
|
||||
return app.getVersion();
|
||||
});
|
||||
expect(version).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
|
||||
// Get platform info
|
||||
const platform = await electronApp.evaluate(async ({ app }) => {
|
||||
return process.platform;
|
||||
});
|
||||
expect(["darwin", "win32", "linux"]).toContain(platform);
|
||||
|
||||
// Check if app is ready
|
||||
const isReady = await electronApp.evaluate(async ({ app }) => {
|
||||
return app.isReady();
|
||||
});
|
||||
expect(isReady).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
### BrowserWindow Properties
|
||||
|
||||
```typescript
|
||||
test("check window properties", async ({ electronApp, window }) => {
|
||||
// Get BrowserWindow from main process
|
||||
const windowBounds = await electronApp.evaluate(async ({ BrowserWindow }) => {
|
||||
const win = BrowserWindow.getAllWindows()[0];
|
||||
return win.getBounds();
|
||||
});
|
||||
|
||||
expect(windowBounds.width).toBeGreaterThan(0);
|
||||
expect(windowBounds.height).toBeGreaterThan(0);
|
||||
|
||||
// Check window state
|
||||
const isMaximized = await electronApp.evaluate(async ({ BrowserWindow }) => {
|
||||
const win = BrowserWindow.getAllWindows()[0];
|
||||
return win.isMaximized();
|
||||
});
|
||||
|
||||
// Check window title
|
||||
const title = await electronApp.evaluate(async ({ BrowserWindow }) => {
|
||||
const win = BrowserWindow.getAllWindows()[0];
|
||||
return win.getTitle();
|
||||
});
|
||||
expect(title).toBeTruthy();
|
||||
});
|
||||
```
|
||||
|
||||
## Renderer Process Testing
|
||||
|
||||
### Standard Page Testing
|
||||
|
||||
```typescript
|
||||
test("renderer interactions", async ({ window }) => {
|
||||
// Standard Playwright page interactions
|
||||
await window.getByRole("button", { name: "Click Me" }).click();
|
||||
await expect(window.getByText("Clicked!")).toBeVisible();
|
||||
|
||||
// Fill forms
|
||||
await window.getByLabel("Username").fill("testuser");
|
||||
await window.getByLabel("Password").fill("password123");
|
||||
await window.getByRole("button", { name: "Login" }).click();
|
||||
|
||||
// Verify navigation
|
||||
await expect(window).toHaveURL(/dashboard/);
|
||||
});
|
||||
```
|
||||
|
||||
### Access Node.js in Renderer
|
||||
|
||||
```typescript
|
||||
test("node integration", async ({ window }) => {
|
||||
// If nodeIntegration is enabled
|
||||
const nodeVersion = await window.evaluate(() => {
|
||||
return (window as any).process?.version;
|
||||
});
|
||||
|
||||
// Check if Node APIs are available
|
||||
const hasFs = await window.evaluate(() => {
|
||||
return typeof (window as any).require === "function";
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Context Isolation Testing
|
||||
|
||||
```typescript
|
||||
test("context isolation", async ({ window }) => {
|
||||
// Test preload script exposed APIs
|
||||
const apiAvailable = await window.evaluate(() => {
|
||||
return typeof (window as any).electronAPI !== "undefined";
|
||||
});
|
||||
expect(apiAvailable).toBe(true);
|
||||
|
||||
// Call exposed API
|
||||
const result = await window.evaluate(async () => {
|
||||
return await (window as any).electronAPI.getAppVersion();
|
||||
});
|
||||
expect(result).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
});
|
||||
```
|
||||
|
||||
## IPC Communication
|
||||
|
||||
### Testing IPC from Renderer
|
||||
|
||||
```typescript
|
||||
test("IPC invoke", async ({ window }) => {
|
||||
// Test preload-exposed IPC call
|
||||
const result = await window.evaluate(async () => {
|
||||
return await (window as any).electronAPI.getData("user-settings");
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty("theme");
|
||||
});
|
||||
```
|
||||
|
||||
### Testing IPC from Main Process
|
||||
|
||||
```typescript
|
||||
test("main to renderer IPC", async ({ electronApp, window }) => {
|
||||
// Set up listener in renderer
|
||||
await window.evaluate(() => {
|
||||
(window as any).receivedMessage = null;
|
||||
(window as any).electronAPI.onMessage((msg: string) => {
|
||||
(window as any).receivedMessage = msg;
|
||||
});
|
||||
});
|
||||
|
||||
// Send from main process
|
||||
await electronApp.evaluate(async ({ BrowserWindow }) => {
|
||||
const win = BrowserWindow.getAllWindows()[0];
|
||||
win.webContents.send("message", "Hello from main!");
|
||||
});
|
||||
|
||||
// Verify receipt
|
||||
await window.waitForFunction(() => (window as any).receivedMessage !== null);
|
||||
const message = await window.evaluate(() => (window as any).receivedMessage);
|
||||
expect(message).toBe("Hello from main!");
|
||||
});
|
||||
```
|
||||
|
||||
### Mock IPC Handlers
|
||||
|
||||
```typescript
|
||||
// In test setup or fixture
|
||||
test("mock IPC handler", async ({ electronApp, window }) => {
|
||||
// Override IPC handler in main process
|
||||
await electronApp.evaluate(async ({ ipcMain }) => {
|
||||
// Remove existing handler
|
||||
ipcMain.removeHandler("fetch-data");
|
||||
|
||||
// Add mock handler
|
||||
ipcMain.handle("fetch-data", async () => {
|
||||
return { mocked: true, data: "test-data" };
|
||||
});
|
||||
});
|
||||
|
||||
// Test with mocked handler
|
||||
const result = await window.evaluate(async () => {
|
||||
return await (window as any).electronAPI.fetchData();
|
||||
});
|
||||
|
||||
expect(result.mocked).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
## Native Features
|
||||
|
||||
### File System Dialogs
|
||||
|
||||
```typescript
|
||||
test("file dialog", async ({ electronApp, window }) => {
|
||||
// Mock dialog response
|
||||
await electronApp.evaluate(async ({ dialog }) => {
|
||||
dialog.showOpenDialog = async () => ({
|
||||
canceled: false,
|
||||
filePaths: ["/mock/path/file.txt"],
|
||||
});
|
||||
});
|
||||
|
||||
// Trigger file open
|
||||
await window.getByRole("button", { name: "Open File" }).click();
|
||||
|
||||
// Verify file was "opened"
|
||||
await expect(window.getByText("file.txt")).toBeVisible();
|
||||
});
|
||||
|
||||
test("save dialog", async ({ electronApp, window }) => {
|
||||
await electronApp.evaluate(async ({ dialog }) => {
|
||||
dialog.showSaveDialog = async () => ({
|
||||
canceled: false,
|
||||
filePath: "/mock/path/saved-file.txt",
|
||||
});
|
||||
});
|
||||
|
||||
await window.getByRole("button", { name: "Save" }).click();
|
||||
await expect(window.getByText("Saved successfully")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Menu Testing
|
||||
|
||||
```typescript
|
||||
test("application menu", async ({ electronApp }) => {
|
||||
// Get menu structure
|
||||
const menuLabels = await electronApp.evaluate(async ({ Menu }) => {
|
||||
const menu = Menu.getApplicationMenu();
|
||||
return menu?.items.map((item) => item.label) || [];
|
||||
});
|
||||
|
||||
expect(menuLabels).toContain("File");
|
||||
expect(menuLabels).toContain("Edit");
|
||||
|
||||
// Trigger menu action
|
||||
await electronApp.evaluate(async ({ Menu }) => {
|
||||
const menu = Menu.getApplicationMenu();
|
||||
const fileMenu = menu?.items.find((item) => item.label === "File");
|
||||
const newItem = fileMenu?.submenu?.items.find(
|
||||
(item) => item.label === "New",
|
||||
);
|
||||
newItem?.click();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Native Notifications
|
||||
|
||||
```typescript
|
||||
test("notifications", async ({ electronApp, window }) => {
|
||||
// Mock Notification
|
||||
let notificationShown = false;
|
||||
await electronApp.evaluate(async ({ Notification }) => {
|
||||
const OriginalNotification = Notification;
|
||||
(global as any).Notification = class extends OriginalNotification {
|
||||
constructor(options: any) {
|
||||
super(options);
|
||||
(global as any).lastNotification = options;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Trigger notification
|
||||
await window.getByRole("button", { name: "Notify" }).click();
|
||||
|
||||
// Verify notification was created
|
||||
const notification = await electronApp.evaluate(async () => {
|
||||
return (global as any).lastNotification;
|
||||
});
|
||||
|
||||
expect(notification.title).toBe("New Message");
|
||||
});
|
||||
```
|
||||
|
||||
### Clipboard
|
||||
|
||||
```typescript
|
||||
test("clipboard operations", async ({ electronApp, window }) => {
|
||||
// Write to clipboard
|
||||
await electronApp.evaluate(async ({ clipboard }) => {
|
||||
clipboard.writeText("Test clipboard content");
|
||||
});
|
||||
|
||||
// Paste in app
|
||||
await window.getByRole("textbox").focus();
|
||||
await window.keyboard.press("ControlOrMeta+v");
|
||||
|
||||
// Read clipboard
|
||||
const clipboardContent = await electronApp.evaluate(async ({ clipboard }) => {
|
||||
return clipboard.readText();
|
||||
});
|
||||
|
||||
expect(clipboardContent).toBe("Test clipboard content");
|
||||
});
|
||||
```
|
||||
|
||||
## Packaging & Distribution
|
||||
|
||||
### Testing Packaged Apps
|
||||
|
||||
```typescript
|
||||
// fixtures/packaged-electron.ts
|
||||
import { test as base, _electron as electron } from "@playwright/test";
|
||||
import path from "path";
|
||||
import { execSync } from "child_process";
|
||||
|
||||
export const test = base.extend({
|
||||
electronApp: async ({}, use) => {
|
||||
// Build the app first (or use pre-built)
|
||||
const distPath = path.join(__dirname, "../dist");
|
||||
|
||||
let executablePath: string;
|
||||
if (process.platform === "darwin") {
|
||||
executablePath = path.join(
|
||||
distPath,
|
||||
"mac",
|
||||
"MyApp.app",
|
||||
"Contents",
|
||||
"MacOS",
|
||||
"MyApp",
|
||||
);
|
||||
} else if (process.platform === "win32") {
|
||||
executablePath = path.join(distPath, "win-unpacked", "MyApp.exe");
|
||||
} else {
|
||||
executablePath = path.join(distPath, "linux-unpacked", "myapp");
|
||||
}
|
||||
|
||||
const electronApp = await electron.launch({ executablePath });
|
||||
await use(electronApp);
|
||||
await electronApp.close();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ------------------------------------- | ---------------------------- | -------------------------------------------- |
|
||||
| Not closing ElectronApplication | Resource leaks | Always call `electronApp.close()` in cleanup |
|
||||
| Hardcoded executable paths | Breaks cross-platform | Use platform detection |
|
||||
| Testing packaged app without building | Outdated code | Build before testing or test dev mode |
|
||||
| Ignoring IPC in tests | Missing coverage | Test IPC communication explicitly |
|
||||
| Not mocking native dialogs | Tests hang waiting for input | Mock dialog responses |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for custom fixture patterns
|
||||
- **Component Testing**: See [component-testing.md](component-testing.md) for renderer testing patterns
|
||||
- **Debugging**: See [debugging.md](../debugging/debugging.md) for troubleshooting
|
||||
@@ -0,0 +1,377 @@
|
||||
# File Upload & Download Testing
|
||||
|
||||
> For advanced patterns (progress tracking, cancellation, retry logic), see [file-upload-download.md](./file-upload-download.md)
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [File Downloads](#file-downloads)
|
||||
2. [File Uploads](#file-uploads)
|
||||
3. [Drag and Drop](#drag-and-drop)
|
||||
4. [File Content Verification](#file-content-verification)
|
||||
|
||||
## File Downloads
|
||||
|
||||
### Basic Download
|
||||
|
||||
```typescript
|
||||
test("download PDF report", async ({ page }) => {
|
||||
await page.goto("/reports");
|
||||
|
||||
// Start waiting for download before clicking
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "Download PDF" }).click();
|
||||
const download = await downloadPromise;
|
||||
|
||||
// Verify filename
|
||||
expect(download.suggestedFilename()).toBe("report.pdf");
|
||||
|
||||
// Save to specific path
|
||||
await download.saveAs("./downloads/report.pdf");
|
||||
});
|
||||
```
|
||||
|
||||
### Download with Custom Path
|
||||
|
||||
```typescript
|
||||
test("download to temp directory", async ({ page }, testInfo) => {
|
||||
await page.goto("/exports");
|
||||
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page.getByRole("link", { name: "Export CSV" }).click();
|
||||
const download = await downloadPromise;
|
||||
|
||||
// Save to test output directory
|
||||
const path = testInfo.outputPath(download.suggestedFilename());
|
||||
await download.saveAs(path);
|
||||
|
||||
// Attach to test report
|
||||
await testInfo.attach("downloaded-file", { path });
|
||||
});
|
||||
```
|
||||
|
||||
### Verify Download Content
|
||||
|
||||
```typescript
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
test("verify CSV content", async ({ page }, testInfo) => {
|
||||
await page.goto("/data");
|
||||
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "Export" }).click();
|
||||
const download = await downloadPromise;
|
||||
|
||||
const filePath = testInfo.outputPath("export.csv");
|
||||
await download.saveAs(filePath);
|
||||
|
||||
// Read and verify content
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
expect(content).toContain("Name,Email,Status");
|
||||
expect(content).toContain("John Doe");
|
||||
|
||||
// Verify row count
|
||||
const rows = content.trim().split("\n");
|
||||
expect(rows.length).toBeGreaterThan(1);
|
||||
});
|
||||
```
|
||||
|
||||
### Multiple Downloads
|
||||
|
||||
```typescript
|
||||
test("download multiple files", async ({ page }) => {
|
||||
await page.goto("/batch-export");
|
||||
|
||||
await page.getByRole("checkbox", { name: "Select All" }).check();
|
||||
|
||||
// Collect all downloads
|
||||
const downloads: Download[] = [];
|
||||
page.on("download", (download) => downloads.push(download));
|
||||
|
||||
await page.getByRole("button", { name: "Download Selected" }).click();
|
||||
|
||||
// Wait for all downloads
|
||||
await expect.poll(() => downloads.length, { timeout: 30000 }).toBe(5);
|
||||
|
||||
// Verify each download
|
||||
for (const download of downloads) {
|
||||
expect(download.suggestedFilename()).toMatch(/\.pdf$/);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Download Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/download.fixture.ts
|
||||
import { test as base, Download } from "@playwright/test";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
type DownloadFixtures = {
|
||||
downloadDir: string;
|
||||
downloadAndVerify: (
|
||||
trigger: () => Promise<void>,
|
||||
expectedFilename: string,
|
||||
) => Promise<string>;
|
||||
};
|
||||
|
||||
export const test = base.extend<DownloadFixtures>({
|
||||
downloadDir: async ({}, use, testInfo) => {
|
||||
const dir = testInfo.outputPath("downloads");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
await use(dir);
|
||||
},
|
||||
|
||||
downloadAndVerify: async ({ page, downloadDir }, use) => {
|
||||
await use(async (trigger, expectedFilename) => {
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await trigger();
|
||||
const download = await downloadPromise;
|
||||
|
||||
expect(download.suggestedFilename()).toBe(expectedFilename);
|
||||
|
||||
const filePath = path.join(downloadDir, expectedFilename);
|
||||
await download.saveAs(filePath);
|
||||
return filePath;
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## File Uploads
|
||||
|
||||
### Basic Upload
|
||||
|
||||
```typescript
|
||||
test("upload profile picture", async ({ page }) => {
|
||||
await page.goto("/settings/profile");
|
||||
|
||||
// Upload file
|
||||
await page
|
||||
.getByLabel("Profile Picture")
|
||||
.setInputFiles("./fixtures/avatar.png");
|
||||
|
||||
// Verify preview
|
||||
await expect(page.getByAltText("Profile preview")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
await expect(page.getByText("Profile updated")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Multiple File Upload
|
||||
|
||||
```typescript
|
||||
test("upload multiple documents", async ({ page }) => {
|
||||
await page.goto("/documents/upload");
|
||||
|
||||
await page
|
||||
.getByLabel("Documents")
|
||||
.setInputFiles([
|
||||
"./fixtures/doc1.pdf",
|
||||
"./fixtures/doc2.pdf",
|
||||
"./fixtures/doc3.pdf",
|
||||
]);
|
||||
|
||||
// Verify all files listed
|
||||
await expect(page.getByText("doc1.pdf")).toBeVisible();
|
||||
await expect(page.getByText("doc2.pdf")).toBeVisible();
|
||||
await expect(page.getByText("doc3.pdf")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Upload All" }).click();
|
||||
await expect(page.getByText("3 files uploaded")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Upload with File Chooser
|
||||
|
||||
```typescript
|
||||
test("upload via file chooser dialog", async ({ page }) => {
|
||||
await page.goto("/upload");
|
||||
|
||||
// Handle file chooser
|
||||
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||
await page.getByRole("button", { name: "Choose File" }).click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
|
||||
await fileChooser.setFiles("./fixtures/document.pdf");
|
||||
|
||||
await expect(page.getByText("document.pdf")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Clear and Re-upload
|
||||
|
||||
```typescript
|
||||
test("replace uploaded file", async ({ page }) => {
|
||||
await page.goto("/upload");
|
||||
|
||||
const input = page.getByLabel("Document");
|
||||
|
||||
// Upload first file
|
||||
await input.setInputFiles("./fixtures/old.pdf");
|
||||
await expect(page.getByText("old.pdf")).toBeVisible();
|
||||
|
||||
// Clear selection
|
||||
await input.setInputFiles([]);
|
||||
|
||||
// Upload new file
|
||||
await input.setInputFiles("./fixtures/new.pdf");
|
||||
await expect(page.getByText("new.pdf")).toBeVisible();
|
||||
await expect(page.getByText("old.pdf")).toBeHidden();
|
||||
});
|
||||
```
|
||||
|
||||
### Upload from Buffer
|
||||
|
||||
```typescript
|
||||
test("upload generated file", async ({ page }) => {
|
||||
await page.goto("/upload");
|
||||
|
||||
// Create file content dynamically
|
||||
const content = "Name,Email\nJohn,john@example.com";
|
||||
|
||||
await page.getByLabel("CSV File").setInputFiles({
|
||||
name: "users.csv",
|
||||
mimeType: "text/csv",
|
||||
buffer: Buffer.from(content),
|
||||
});
|
||||
|
||||
await expect(page.getByText("users.csv")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Drag and Drop
|
||||
|
||||
### Drag and Drop Upload
|
||||
|
||||
```typescript
|
||||
test("drag and drop file upload", async ({ page }) => {
|
||||
await page.goto("/upload");
|
||||
|
||||
const dropzone = page.getByTestId("dropzone");
|
||||
|
||||
// Create a DataTransfer with the file
|
||||
const dataTransfer = await page.evaluateHandle(() => new DataTransfer());
|
||||
|
||||
// Read file and add to DataTransfer
|
||||
const buffer = fs.readFileSync("./fixtures/image.png");
|
||||
await page.evaluate(
|
||||
async ([dataTransfer, data]) => {
|
||||
const file = new File([new Uint8Array(data)], "image.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
dataTransfer.items.add(file);
|
||||
},
|
||||
[dataTransfer, [...buffer]] as const,
|
||||
);
|
||||
|
||||
// Dispatch drop event
|
||||
await dropzone.dispatchEvent("drop", { dataTransfer });
|
||||
|
||||
await expect(page.getByText("image.png uploaded")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Simpler Drag and Drop
|
||||
|
||||
```typescript
|
||||
test("drag and drop with setInputFiles", async ({ page }) => {
|
||||
await page.goto("/upload");
|
||||
|
||||
// Most dropzones have a hidden file input
|
||||
const input = page.locator('input[type="file"]');
|
||||
|
||||
// This works even if the input is hidden
|
||||
await input.setInputFiles("./fixtures/document.pdf");
|
||||
|
||||
await expect(page.getByText("document.pdf")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## File Content Verification
|
||||
|
||||
### Verify PDF Content
|
||||
|
||||
```typescript
|
||||
import pdf from "pdf-parse";
|
||||
|
||||
test("verify PDF content", async ({ page }, testInfo) => {
|
||||
await page.goto("/invoice/123");
|
||||
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "Download Invoice" }).click();
|
||||
const download = await downloadPromise;
|
||||
|
||||
const path = testInfo.outputPath("invoice.pdf");
|
||||
await download.saveAs(path);
|
||||
|
||||
// Parse PDF
|
||||
const dataBuffer = fs.readFileSync(path);
|
||||
const data = await pdf(dataBuffer);
|
||||
|
||||
expect(data.text).toContain("Invoice #123");
|
||||
expect(data.text).toContain("Total: $99.99");
|
||||
});
|
||||
```
|
||||
|
||||
### Verify Excel Content
|
||||
|
||||
```typescript
|
||||
import XLSX from "xlsx";
|
||||
|
||||
test("verify Excel export", async ({ page }, testInfo) => {
|
||||
await page.goto("/reports");
|
||||
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "Export Excel" }).click();
|
||||
const download = await downloadPromise;
|
||||
|
||||
const path = testInfo.outputPath("report.xlsx");
|
||||
await download.saveAs(path);
|
||||
|
||||
// Parse Excel
|
||||
const workbook = XLSX.readFile(path);
|
||||
const sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
const data = XLSX.utils.sheet_to_json(sheet);
|
||||
|
||||
expect(data).toHaveLength(10);
|
||||
expect(data[0]).toHaveProperty("Name");
|
||||
expect(data[0]).toHaveProperty("Email");
|
||||
});
|
||||
```
|
||||
|
||||
### Verify JSON Download
|
||||
|
||||
```typescript
|
||||
test("verify JSON export", async ({ page }, testInfo) => {
|
||||
await page.goto("/api-data");
|
||||
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "Export JSON" }).click();
|
||||
const download = await downloadPromise;
|
||||
|
||||
const path = testInfo.outputPath("data.json");
|
||||
await download.saveAs(path);
|
||||
|
||||
const content = JSON.parse(fs.readFileSync(path, "utf-8"));
|
||||
|
||||
expect(content.users).toHaveLength(5);
|
||||
expect(content.exportDate).toBeDefined();
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ------------------------------------- | ------------------------------- | --------------------------------------------- |
|
||||
| Not waiting for download | Race condition, test fails | Always use `waitForEvent("download")` |
|
||||
| Hardcoded download paths | Conflicts in parallel runs | Use `testInfo.outputPath()` |
|
||||
| Skipping content verification | Download might be empty/corrupt | Verify file content when possible |
|
||||
| Using `force: true` for hidden inputs | May not trigger proper events | Use `setInputFiles` on hidden inputs directly |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Fixtures**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for download fixture patterns
|
||||
- **Debugging**: See [debugging.md](../debugging/debugging.md) for troubleshooting download issues
|
||||
@@ -0,0 +1,562 @@
|
||||
# File Upload and Download Testing
|
||||
|
||||
> **When to use**: Testing file uploads (single, multiple, drag-and-drop), downloads (content verification, filename, type), upload progress indicators, or file type/size restrictions.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Downloading Files](#downloading-files)
|
||||
2. [Single File Upload](#single-file-upload)
|
||||
3. [Multiple File Upload](#multiple-file-upload)
|
||||
4. [Drag-and-Drop Zones](#drag-and-drop-zones)
|
||||
5. [File Chooser Dialog](#file-chooser-dialog)
|
||||
6. [Upload Progress and Cancellation](#upload-progress-and-cancellation)
|
||||
7. [Retry After Failure](#retry-after-failure)
|
||||
8. [File Type and Size Restrictions](#file-type-and-size-restrictions)
|
||||
9. [Image Preview](#image-preview)
|
||||
10. [Authenticated Downloads](#authenticated-downloads)
|
||||
11. [Tips](#tips)
|
||||
|
||||
---
|
||||
|
||||
## Downloading Files
|
||||
|
||||
### Capturing Downloads and Verifying Content
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
test('verifies downloaded CSV content', async ({ page }) => {
|
||||
await page.goto('/exports');
|
||||
|
||||
// Set up download listener BEFORE triggering the download
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByRole('link', { name: 'transactions.csv' }).click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
const savePath = path.join(__dirname, '../tmp', download.suggestedFilename());
|
||||
await download.saveAs(savePath);
|
||||
|
||||
const content = fs.readFileSync(savePath, 'utf-8');
|
||||
expect(content).toContain('id,amount,date');
|
||||
expect(content).toContain('1001,250.00,2025-01-15');
|
||||
|
||||
const rows = content.trim().split('\n');
|
||||
expect(rows.length).toBeGreaterThan(1);
|
||||
|
||||
fs.unlinkSync(savePath);
|
||||
});
|
||||
|
||||
test('reads download via stream without disk I/O', async ({ page }) => {
|
||||
await page.goto('/exports');
|
||||
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByRole('link', { name: 'transactions.csv' }).click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
const readable = await download.createReadStream();
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
for await (const chunk of readable!) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
|
||||
const content = Buffer.concat(chunks).toString('utf-8');
|
||||
expect(content).toContain('id,amount,date');
|
||||
});
|
||||
```
|
||||
|
||||
### Verifying Filename and Format
|
||||
|
||||
```typescript
|
||||
test('export filename matches selected format', async ({ page }) => {
|
||||
await page.goto('/analytics');
|
||||
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: 'Export PDF' }).click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toMatch(/^analytics-\d{4}-\d{2}-\d{2}\.pdf$/);
|
||||
});
|
||||
|
||||
test('format selector changes output extension', async ({ page }) => {
|
||||
await page.goto('/analytics');
|
||||
|
||||
await page.getByLabel('Format').selectOption('csv');
|
||||
const csvDownload = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: 'Download' }).click();
|
||||
expect((await csvDownload).suggestedFilename()).toMatch(/\.csv$/);
|
||||
|
||||
await page.getByLabel('Format').selectOption('xlsx');
|
||||
const xlsxDownload = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: 'Download' }).click();
|
||||
expect((await xlsxDownload).suggestedFilename()).toMatch(/\.xlsx$/);
|
||||
});
|
||||
```
|
||||
|
||||
### Checking Response Headers
|
||||
|
||||
```typescript
|
||||
test('download response has correct MIME type', async ({ page }) => {
|
||||
await page.goto('/analytics');
|
||||
|
||||
const responsePromise = page.waitForResponse('**/api/analytics/export**');
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
|
||||
await page.getByRole('button', { name: 'Export PDF' }).click();
|
||||
|
||||
const response = await responsePromise;
|
||||
expect(response.headers()['content-type']).toContain('application/pdf');
|
||||
expect(response.headers()['content-disposition']).toContain('attachment');
|
||||
|
||||
await downloadPromise;
|
||||
});
|
||||
```
|
||||
|
||||
### Handling Download Failures
|
||||
|
||||
```typescript
|
||||
test('shows error when download fails', async ({ page }) => {
|
||||
await page.route('**/api/analytics/export**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Generation failed' }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/analytics');
|
||||
await page.getByRole('button', { name: 'Export PDF' }).click();
|
||||
|
||||
await expect(page.getByRole('alert')).toContainText(/failed|error/i);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Single File Upload
|
||||
|
||||
### From Fixture File
|
||||
|
||||
```typescript
|
||||
import path from 'path';
|
||||
|
||||
test('uploads document from fixture', async ({ page }) => {
|
||||
await page.goto('/attachments');
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(path.resolve(__dirname, '../fixtures/invoice.pdf'));
|
||||
|
||||
await expect(page.getByText('invoice.pdf')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Upload' }).click();
|
||||
await expect(page.getByRole('alert')).toContainText('uploaded successfully');
|
||||
await expect(page.getByRole('link', { name: 'invoice.pdf' })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### From In-Memory Buffer
|
||||
|
||||
```typescript
|
||||
test('uploads in-memory CSV', async ({ page }) => {
|
||||
await page.goto('/attachments');
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles({
|
||||
name: 'contacts.csv',
|
||||
mimeType: 'text/csv',
|
||||
buffer: Buffer.from('name,email\nAlice,alice@acme.com\nBob,bob@acme.com'),
|
||||
});
|
||||
|
||||
await expect(page.getByText('contacts.csv')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Upload' }).click();
|
||||
await expect(page.getByRole('alert')).toContainText('uploaded successfully');
|
||||
});
|
||||
```
|
||||
|
||||
### Clearing Selection
|
||||
|
||||
```typescript
|
||||
test('clears selected file', async ({ page }) => {
|
||||
await page.goto('/attachments');
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles({
|
||||
name: 'draft.txt',
|
||||
mimeType: 'text/plain',
|
||||
buffer: Buffer.from('draft content'),
|
||||
});
|
||||
|
||||
await expect(page.getByText('draft.txt')).toBeVisible();
|
||||
|
||||
// Clear via API
|
||||
await fileInput.setInputFiles([]);
|
||||
await expect(page.getByText('draft.txt')).not.toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Multiple File Upload
|
||||
|
||||
```typescript
|
||||
test('uploads multiple files at once', async ({ page }) => {
|
||||
await page.goto('/attachments');
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles([
|
||||
{ name: 'doc1.pdf', mimeType: 'application/pdf', buffer: Buffer.from('pdf1') },
|
||||
{ name: 'doc2.pdf', mimeType: 'application/pdf', buffer: Buffer.from('pdf2') },
|
||||
{ name: 'doc3.pdf', mimeType: 'application/pdf', buffer: Buffer.from('pdf3') },
|
||||
]);
|
||||
|
||||
await expect(page.getByText('doc1.pdf')).toBeVisible();
|
||||
await expect(page.getByText('doc2.pdf')).toBeVisible();
|
||||
await expect(page.getByText('doc3.pdf')).toBeVisible();
|
||||
await expect(page.getByText('3 files selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Upload all' }).click();
|
||||
await expect(page.getByRole('alert')).toContainText('3 files uploaded');
|
||||
});
|
||||
|
||||
test('removes one file from selection', async ({ page }) => {
|
||||
await page.goto('/attachments');
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles([
|
||||
{ name: 'keep.txt', mimeType: 'text/plain', buffer: Buffer.from('keep') },
|
||||
{ name: 'discard.txt', mimeType: 'text/plain', buffer: Buffer.from('discard') },
|
||||
]);
|
||||
|
||||
const discardRow = page.getByText('discard.txt').locator('..');
|
||||
await discardRow.getByRole('button', { name: /remove|delete|×/i }).click();
|
||||
|
||||
await expect(page.getByText('discard.txt')).not.toBeVisible();
|
||||
await expect(page.getByText('keep.txt')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Drag-and-Drop Zones
|
||||
|
||||
Drop zones always have an underlying `input[type="file"]`—target it directly instead of simulating OS-level drag events.
|
||||
|
||||
```typescript
|
||||
test('uploads via drop zone', async ({ page }) => {
|
||||
await page.goto('/attachments');
|
||||
|
||||
const dropZone = page.locator('[data-testid="drop-zone"]');
|
||||
await expect(dropZone).toContainText(/drag.*here|drop.*files/i);
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles({
|
||||
name: 'dropped.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: Buffer.from('pdf-content'),
|
||||
});
|
||||
|
||||
await expect(dropZone.getByText('dropped.pdf')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Upload' }).click();
|
||||
await expect(page.getByRole('alert')).toContainText('uploaded successfully');
|
||||
});
|
||||
|
||||
test('shows visual feedback on drag-over', async ({ page }) => {
|
||||
await page.goto('/attachments');
|
||||
|
||||
const dropZone = page.locator('[data-testid="drop-zone"]');
|
||||
|
||||
await dropZone.dispatchEvent('dragenter', {
|
||||
dataTransfer: { types: ['Files'], files: [] },
|
||||
});
|
||||
|
||||
await expect(dropZone).toHaveClass(/active|highlight|drag-over/);
|
||||
await expect(dropZone).toContainText(/release|drop now/i);
|
||||
|
||||
await dropZone.dispatchEvent('dragleave');
|
||||
await expect(dropZone).not.toHaveClass(/active|highlight|drag-over/);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Chooser Dialog
|
||||
|
||||
```typescript
|
||||
test('uploads via native file chooser', async ({ page }) => {
|
||||
await page.goto('/attachments');
|
||||
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
await page.getByRole('button', { name: 'Choose file' }).click();
|
||||
|
||||
const fileChooser = await fileChooserPromise;
|
||||
expect(fileChooser.isMultiple()).toBe(false);
|
||||
|
||||
await fileChooser.setFiles({
|
||||
name: 'selected.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: Buffer.from('pdf-content'),
|
||||
});
|
||||
|
||||
await expect(page.getByText('selected.pdf')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Upload Progress and Cancellation
|
||||
|
||||
```typescript
|
||||
test('displays upload progress for large file', async ({ page }) => {
|
||||
await page.goto('/attachments');
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
const largeBuffer = Buffer.alloc(5 * 1024 * 1024, 'x');
|
||||
|
||||
await fileInput.setInputFiles({
|
||||
name: 'dataset.bin',
|
||||
mimeType: 'application/octet-stream',
|
||||
buffer: largeBuffer,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Upload' }).click();
|
||||
|
||||
const progressBar = page.getByRole('progressbar');
|
||||
await expect(progressBar).toBeVisible();
|
||||
|
||||
await expect(async () => {
|
||||
const value = await progressBar.getAttribute('aria-valuenow');
|
||||
expect(Number(value)).toBeGreaterThan(0);
|
||||
}).toPass({ timeout: 10000 });
|
||||
|
||||
await expect(progressBar).not.toBeVisible({ timeout: 60000 });
|
||||
await expect(page.getByRole('alert')).toContainText('uploaded successfully');
|
||||
});
|
||||
|
||||
test('cancels in-progress upload', async ({ page }) => {
|
||||
await page.route('**/api/attachments/upload', async (route) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10000));
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.goto('/attachments');
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles({
|
||||
name: 'large.bin',
|
||||
mimeType: 'application/octet-stream',
|
||||
buffer: Buffer.alloc(5 * 1024 * 1024, 'x'),
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Upload' }).click();
|
||||
await expect(page.getByRole('progressbar')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel upload' }).click();
|
||||
|
||||
await expect(page.getByRole('progressbar')).not.toBeVisible();
|
||||
await expect(page.getByText(/cancelled|aborted/i)).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'large.bin' })).not.toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Retry After Failure
|
||||
|
||||
```typescript
|
||||
test('retries failed upload', async ({ page }) => {
|
||||
let attempt = 0;
|
||||
|
||||
await page.route('**/api/attachments/upload', async (route) => {
|
||||
attempt++;
|
||||
if (attempt === 1) {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Server error' }),
|
||||
});
|
||||
} else {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ id: 'abc', name: 'data.csv' }),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/attachments');
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles({
|
||||
name: 'data.csv',
|
||||
mimeType: 'text/csv',
|
||||
buffer: Buffer.from('col1,col2\nval1,val2'),
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Upload' }).click();
|
||||
await expect(page.getByText(/upload failed|error/i)).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /retry/i }).click();
|
||||
await expect(page.getByRole('alert')).toContainText('uploaded successfully');
|
||||
expect(attempt).toBe(2);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Type and Size Restrictions
|
||||
|
||||
### Validating Allowed Types
|
||||
|
||||
```typescript
|
||||
test('accepts allowed file types', async ({ page }) => {
|
||||
await page.goto('/attachments');
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await expect(fileInput).toHaveAttribute('accept', /\.pdf|\.doc|\.docx|\.txt/);
|
||||
|
||||
await fileInput.setInputFiles({
|
||||
name: 'report.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: Buffer.from('pdf-content'),
|
||||
});
|
||||
|
||||
await expect(page.getByText('report.pdf')).toBeVisible();
|
||||
await expect(page.getByText(/not allowed|invalid/i)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('rejects disallowed file types', async ({ page }) => {
|
||||
await page.goto('/attachments');
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
// setInputFiles bypasses the accept attribute—tests JavaScript validation
|
||||
await fileInput.setInputFiles({
|
||||
name: 'malware.exe',
|
||||
mimeType: 'application/x-msdownload',
|
||||
buffer: Buffer.from('exe-content'),
|
||||
});
|
||||
|
||||
await expect(page.getByRole('alert')).toContainText(
|
||||
/not allowed|unsupported file type|only .pdf, .doc/i
|
||||
);
|
||||
await expect(page.getByText('malware.exe')).not.toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Enforcing Size Limits
|
||||
|
||||
```typescript
|
||||
test('rejects oversized file', async ({ page }) => {
|
||||
await page.goto('/attachments');
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
const oversizedBuffer = Buffer.alloc(11 * 1024 * 1024, 'x');
|
||||
|
||||
await fileInput.setInputFiles({
|
||||
name: 'huge.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: oversizedBuffer,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('alert')).toContainText(/file.*too large|exceeds.*10 ?MB/i);
|
||||
await expect(page.getByText('huge.pdf')).not.toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Enforcing File Count Limits
|
||||
|
||||
```typescript
|
||||
test('rejects too many files', async ({ page }) => {
|
||||
await page.goto('/attachments');
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
const files = Array.from({ length: 6 }, (_, i) => ({
|
||||
name: `file-${i + 1}.txt`,
|
||||
mimeType: 'text/plain' as const,
|
||||
buffer: Buffer.from(`content ${i + 1}`),
|
||||
}));
|
||||
|
||||
await fileInput.setInputFiles(files);
|
||||
|
||||
await expect(page.getByRole('alert')).toContainText(/maximum.*5 files|too many files/i);
|
||||
});
|
||||
```
|
||||
|
||||
### Validating Image Dimensions
|
||||
|
||||
```typescript
|
||||
test('rejects image below minimum dimensions', async ({ page }) => {
|
||||
await page.goto('/profile/avatar');
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
// Minimal 1x1 PNG
|
||||
const tinyPng = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||
'base64'
|
||||
);
|
||||
|
||||
await fileInput.setInputFiles({
|
||||
name: 'tiny.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: tinyPng,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('alert')).toContainText(/minimum.*dimensions|too small/i);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Image Preview
|
||||
|
||||
```typescript
|
||||
test('shows image preview after selection', async ({ page }) => {
|
||||
await page.goto('/profile/avatar');
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(path.resolve(__dirname, '../fixtures/photo.jpg'));
|
||||
|
||||
const preview = page.getByRole('img', { name: /preview|avatar/i });
|
||||
await expect(preview).toBeVisible();
|
||||
|
||||
const src = await preview.getAttribute('src');
|
||||
expect(src).toMatch(/^(blob:|data:image)/);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authenticated Downloads
|
||||
|
||||
```typescript
|
||||
test('downloads file requiring authentication', async ({ page, request }) => {
|
||||
await page.goto('/attachments');
|
||||
|
||||
// Browser download works because cookies are sent
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByRole('link', { name: 'confidential.pdf' }).click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe('confidential.pdf');
|
||||
|
||||
// Verify via API request (carries auth context)
|
||||
const response = await request.get('/api/attachments/456/download');
|
||||
expect(response.ok()).toBeTruthy();
|
||||
expect(response.headers()['content-type']).toContain('application/pdf');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
1. **Use `setInputFiles` for uploads**. Even drag-and-drop zones have an underlying `input[type="file"]`. Target it directly instead of simulating OS-level drag events.
|
||||
|
||||
2. **Prefer in-memory buffers**. Creating files with `Buffer.from()` keeps tests self-contained. Use fixture files only when you need real content (e.g., a valid PDF your app parses).
|
||||
|
||||
3. **Set up download listener before clicking**. Call `page.waitForEvent('download')` before the click that triggers the download—otherwise you may miss the event.
|
||||
|
||||
4. **Use `createReadStream()` for content verification**. Reading directly from the stream avoids disk I/O and cleanup of temporary files.
|
||||
|
||||
5. **Test both `accept` attribute and JavaScript validation**. The HTML `accept` attribute only filters the OS file dialog. `setInputFiles()` bypasses it, which is exactly what you need to test your app's JavaScript validation.
|
||||
@@ -0,0 +1,561 @@
|
||||
# Form Testing Patterns
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Quick Reference](#quick-reference)
|
||||
2. [Patterns](#patterns)
|
||||
3. [Decision Guide](#decision-guide)
|
||||
4. [Anti-Patterns](#anti-patterns)
|
||||
5. [Troubleshooting](#troubleshooting)
|
||||
|
||||
> **When to use**: Testing form filling, submission, validation messages, multi-step wizards, dynamic fields, and auto-complete interactions.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```typescript
|
||||
// Text input
|
||||
await page.getByLabel("Username").fill("john_doe");
|
||||
|
||||
// Select dropdown
|
||||
await page.getByLabel("Region").selectOption("EU");
|
||||
await page.getByLabel("Region").selectOption({ label: "Europe" });
|
||||
|
||||
// Checkbox and radio
|
||||
await page.getByLabel("Subscribe").check();
|
||||
await page.getByLabel("Priority shipping").click();
|
||||
|
||||
// Date input
|
||||
await page.getByLabel("Departure").fill("2025-08-20");
|
||||
|
||||
// Clear a field
|
||||
await page.getByLabel("Username").clear();
|
||||
|
||||
// Submit
|
||||
await page.getByRole("button", { name: "Register" }).click();
|
||||
|
||||
// Verify validation error
|
||||
await expect(page.getByText("Username is required")).toBeVisible();
|
||||
```
|
||||
|
||||
## Patterns
|
||||
|
||||
### Auto-Complete and Typeahead Fields
|
||||
|
||||
**Use when**: Testing search fields, address lookups, mention pickers, or any input that shows suggestions as the user types.
|
||||
|
||||
```typescript
|
||||
test("select from typeahead suggestions", async ({ page }) => {
|
||||
await page.goto("/products");
|
||||
|
||||
const searchBox = page.getByRole("combobox", { name: "Find product" });
|
||||
await searchBox.pressSequentially("lapt", { delay: 100 });
|
||||
|
||||
const suggestionList = page.getByRole("listbox");
|
||||
await expect(suggestionList).toBeVisible();
|
||||
|
||||
await suggestionList.getByRole("option", { name: "Laptop Pro" }).click();
|
||||
await expect(searchBox).toHaveValue("Laptop Pro");
|
||||
});
|
||||
|
||||
test("typeahead with API-driven suggestions", async ({ page }) => {
|
||||
await page.goto("/shipping");
|
||||
|
||||
const streetField = page.getByLabel("Street");
|
||||
const responsePromise = page.waitForResponse("**/api/address-lookup*");
|
||||
await streetField.pressSequentially("456 Elm", { delay: 50 });
|
||||
|
||||
await responsePromise;
|
||||
|
||||
await page.getByRole("option", { name: /456 Elm St/ }).click();
|
||||
|
||||
await expect(page.getByLabel("Town")).toHaveValue("Austin");
|
||||
await expect(page.getByLabel("State")).toHaveValue("TX");
|
||||
await expect(page.getByLabel("Postal code")).toHaveValue("78701");
|
||||
});
|
||||
|
||||
test("dismiss suggestions and enter custom value", async ({ page }) => {
|
||||
await page.goto("/labels");
|
||||
|
||||
const labelInput = page.getByLabel("New label");
|
||||
await labelInput.pressSequentially("my-label");
|
||||
|
||||
await labelInput.press("Escape");
|
||||
await expect(page.getByRole("listbox")).not.toBeVisible();
|
||||
|
||||
await labelInput.press("Enter");
|
||||
await expect(page.getByText("my-label")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Dynamic Forms — Conditional Fields
|
||||
|
||||
**Use when**: Form fields appear, disappear, or change based on the value of other fields.
|
||||
|
||||
```typescript
|
||||
test("conditional fields appear based on selection", async ({ page }) => {
|
||||
await page.goto("/loan/apply");
|
||||
|
||||
await page.getByLabel("Applicant type").selectOption("corporate");
|
||||
|
||||
await expect(page.getByLabel("Business name")).toBeVisible();
|
||||
await expect(page.getByLabel("EIN")).toBeVisible();
|
||||
|
||||
await page.getByLabel("Business name").fill("TechCorp Inc");
|
||||
await page.getByLabel("EIN").fill("98-7654321");
|
||||
|
||||
await page.getByLabel("Applicant type").selectOption("individual");
|
||||
await expect(page.getByLabel("Business name")).not.toBeVisible();
|
||||
await expect(page.getByLabel("EIN")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("checkbox toggles additional section", async ({ page }) => {
|
||||
await page.goto("/delivery");
|
||||
|
||||
await page.getByLabel("Separate invoice address").check();
|
||||
|
||||
const invoiceSection = page.getByRole("group", { name: "Invoice address" });
|
||||
await expect(invoiceSection).toBeVisible();
|
||||
|
||||
await invoiceSection.getByLabel("Address").fill("789 Pine Rd");
|
||||
await invoiceSection.getByLabel("City").fill("Denver");
|
||||
|
||||
await page.getByLabel("Separate invoice address").uncheck();
|
||||
await expect(invoiceSection).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("dependent dropdown chains", async ({ page }) => {
|
||||
await page.goto("/region-selector");
|
||||
|
||||
await page.getByLabel("Country").selectOption("CA");
|
||||
|
||||
const provinceDropdown = page.getByLabel("Province");
|
||||
await expect(provinceDropdown.getByRole("option")).not.toHaveCount(0);
|
||||
|
||||
await provinceDropdown.selectOption("ON");
|
||||
|
||||
const cityDropdown = page.getByLabel("City");
|
||||
await expect(cityDropdown.getByRole("option")).not.toHaveCount(0);
|
||||
|
||||
await cityDropdown.selectOption({ label: "Toronto" });
|
||||
});
|
||||
```
|
||||
|
||||
### Multi-Step Forms and Wizards
|
||||
|
||||
**Use when**: The form spans multiple pages or steps, with next/previous navigation and per-step validation.
|
||||
|
||||
```typescript
|
||||
test("complete a multi-step booking wizard", async ({ page }) => {
|
||||
await page.goto("/booking");
|
||||
|
||||
await test.step("enter guest information", async () => {
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Guest Info" }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByLabel("Full name").fill("Alice Smith");
|
||||
await page.getByLabel("Email").fill("alice@test.com");
|
||||
await page.getByLabel("Phone").fill("555-1234");
|
||||
|
||||
await page.getByRole("button", { name: "Next" }).click();
|
||||
});
|
||||
|
||||
await test.step("select room options", async () => {
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Room Selection" }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByLabel("Room type").selectOption("suite");
|
||||
await page.getByLabel("Check-in").fill("2025-09-01");
|
||||
await page.getByLabel("Check-out").fill("2025-09-05");
|
||||
|
||||
await page.getByRole("button", { name: "Next" }).click();
|
||||
});
|
||||
|
||||
await test.step("confirm booking", async () => {
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Confirmation" }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByText("Alice Smith")).toBeVisible();
|
||||
await expect(page.getByText("suite")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Confirm booking" }).click();
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Booking complete" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("wizard validates each step before proceeding", async ({ page }) => {
|
||||
await page.goto("/booking");
|
||||
|
||||
await page.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Guest Info" })).toBeVisible();
|
||||
await expect(page.getByText("Full name is required")).toBeVisible();
|
||||
});
|
||||
|
||||
test("wizard supports going back without losing data", async ({ page }) => {
|
||||
await page.goto("/booking");
|
||||
|
||||
await page.getByLabel("Full name").fill("Alice Smith");
|
||||
await page.getByLabel("Email").fill("alice@test.com");
|
||||
await page.getByLabel("Phone").fill("555-1234");
|
||||
await page.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
await page.getByRole("button", { name: "Previous" }).click();
|
||||
|
||||
await expect(page.getByLabel("Full name")).toHaveValue("Alice Smith");
|
||||
await expect(page.getByLabel("Email")).toHaveValue("alice@test.com");
|
||||
});
|
||||
```
|
||||
|
||||
### Form Submission and Response Handling
|
||||
|
||||
**Use when**: Testing what happens after a form is submitted — success messages, redirects, error responses from the server, and loading states during submission.
|
||||
|
||||
```typescript
|
||||
test("successful form submission shows confirmation", async ({ page }) => {
|
||||
await page.goto("/feedback");
|
||||
|
||||
await page.getByLabel("Subject").fill("Feature request");
|
||||
await page.getByLabel("Email").fill("user@test.com");
|
||||
await page.getByLabel("Details").fill("Please add dark mode");
|
||||
|
||||
const responsePromise = page.waitForResponse("**/api/feedback");
|
||||
await page.getByRole("button", { name: "Submit feedback" }).click();
|
||||
const response = await responsePromise;
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
await expect(page.getByText("Feedback received")).toBeVisible();
|
||||
});
|
||||
|
||||
test("form submission shows server-side validation errors", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/signup");
|
||||
|
||||
await page.getByLabel("Email").fill("existing@test.com");
|
||||
await page.getByLabel("Password", { exact: true }).fill("Secure1@pass");
|
||||
await page.getByRole("button", { name: "Sign up" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText("Email address already registered"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("form shows loading state during submission", async ({ page }) => {
|
||||
await page.goto("/feedback");
|
||||
|
||||
await page.getByLabel("Subject").fill("Bug report");
|
||||
await page.getByLabel("Email").fill("user@test.com");
|
||||
await page.getByLabel("Details").fill("Found an issue");
|
||||
|
||||
const submit = page.getByRole("button", {
|
||||
name: /Submit feedback|Submitting/,
|
||||
});
|
||||
await submit.click();
|
||||
|
||||
await expect(submit).toHaveText(/Submitting/);
|
||||
await expect(submit).toBeDisabled();
|
||||
|
||||
await expect(submit).toHaveText("Submit feedback");
|
||||
await expect(submit).toBeEnabled();
|
||||
});
|
||||
|
||||
test("form redirects after successful submission", async ({ page }) => {
|
||||
await page.goto("/auth/login");
|
||||
|
||||
await page.getByLabel("Email").fill("admin@test.com");
|
||||
await page.getByLabel("Password").fill("admin123");
|
||||
await page.getByRole("button", { name: "Log in" }).click();
|
||||
|
||||
await page.waitForURL("/home");
|
||||
await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Filling Basic Form Fields
|
||||
|
||||
**Use when**: Testing any form with standard HTML inputs — text, email, password, number, textarea, select, checkbox, radio.
|
||||
|
||||
```typescript
|
||||
test("fill and submit a signup form", async ({ page }) => {
|
||||
await page.goto("/signup");
|
||||
|
||||
await page.getByLabel("First name").fill("Bob");
|
||||
await page.getByLabel("Last name").fill("Wilson");
|
||||
await page.getByLabel("Email").fill("bob@test.com");
|
||||
await page.getByLabel("Password", { exact: true }).fill("P@ssw0rd!");
|
||||
await page.getByLabel("Confirm password").fill("P@ssw0rd!");
|
||||
|
||||
await page.getByLabel("About you").fill("Developer with 5 years experience.");
|
||||
await page.getByLabel("Years of experience").fill("5");
|
||||
|
||||
await page.getByLabel("Country").selectOption("UK");
|
||||
await page.getByLabel("City").selectOption({ label: "London" });
|
||||
await page
|
||||
.getByLabel("Skills")
|
||||
.selectOption(["typescript", "playwright", "nodejs"]);
|
||||
|
||||
await page.getByLabel("Accept terms").check();
|
||||
await expect(page.getByLabel("Accept terms")).toBeChecked();
|
||||
|
||||
await page.getByLabel("Annual billing").check();
|
||||
await expect(page.getByLabel("Annual billing")).toBeChecked();
|
||||
|
||||
await page.getByRole("button", { name: "Create account" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Date and Time Inputs
|
||||
|
||||
**Use when**: Testing native `<input type="date">`, `<input type="time">`, `<input type="datetime-local">`, or third-party date pickers.
|
||||
|
||||
```typescript
|
||||
test("fill native date and time inputs", async ({ page }) => {
|
||||
await page.goto("/reservation");
|
||||
|
||||
await page.getByLabel("Reservation date").fill("2025-07-10");
|
||||
await expect(page.getByLabel("Reservation date")).toHaveValue("2025-07-10");
|
||||
|
||||
await page.getByLabel("Time slot").fill("18:00");
|
||||
await page.getByLabel("Reminder").fill("2025-07-10T17:30");
|
||||
});
|
||||
|
||||
test("interact with a third-party date picker", async ({ page }) => {
|
||||
await page.goto("/reservation");
|
||||
|
||||
await page.getByLabel("Event date").click();
|
||||
await page.getByRole("button", { name: "Next month" }).click();
|
||||
await page.getByRole("gridcell", { name: "25" }).click();
|
||||
|
||||
await expect(page.getByLabel("Event date")).toHaveValue(/2025/);
|
||||
});
|
||||
```
|
||||
|
||||
### Required Field Validation
|
||||
|
||||
**Use when**: Testing that the form shows appropriate error messages when required fields are empty.
|
||||
|
||||
```typescript
|
||||
test("shows validation errors for empty required fields", async ({ page }) => {
|
||||
await page.goto("/inquiry");
|
||||
|
||||
await page.getByRole("button", { name: "Send inquiry" }).click();
|
||||
|
||||
await expect(page.getByText("Name is required")).toBeVisible();
|
||||
await expect(page.getByText("Email is required")).toBeVisible();
|
||||
await expect(page.getByText("Question is required")).toBeVisible();
|
||||
|
||||
await expect(page).toHaveURL(/\/inquiry/);
|
||||
});
|
||||
|
||||
test("clears validation errors when fields are filled", async ({ page }) => {
|
||||
await page.goto("/inquiry");
|
||||
|
||||
await page.getByRole("button", { name: "Send inquiry" }).click();
|
||||
await expect(page.getByText("Name is required")).toBeVisible();
|
||||
|
||||
await page.getByLabel("Name").fill("Carol Brown");
|
||||
await page.getByLabel("Email").focus();
|
||||
|
||||
await expect(page.getByText("Name is required")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("native HTML5 validation with required attribute", async ({ page }) => {
|
||||
await page.goto("/basic-form");
|
||||
|
||||
await page.getByRole("button", { name: "Submit" }).click();
|
||||
|
||||
const emailInput = page.getByLabel("Email");
|
||||
const validationMessage = await emailInput.evaluate(
|
||||
(el: HTMLInputElement) => el.validationMessage,
|
||||
);
|
||||
expect(validationMessage).toBeTruthy();
|
||||
});
|
||||
```
|
||||
|
||||
### Format Validation and Custom Rules
|
||||
|
||||
**Use when**: Testing email format, phone number format, password strength, and business-specific validation rules.
|
||||
|
||||
```typescript
|
||||
test("validates email format", async ({ page }) => {
|
||||
await page.goto("/signup");
|
||||
|
||||
const emailField = page.getByLabel("Email");
|
||||
|
||||
const invalidEmails = [
|
||||
"invalid",
|
||||
"missing@",
|
||||
"@nodomain.com",
|
||||
"has spaces@mail.com",
|
||||
];
|
||||
|
||||
for (const email of invalidEmails) {
|
||||
await emailField.fill(email);
|
||||
await emailField.blur();
|
||||
await expect(page.getByText("Enter a valid email address")).toBeVisible();
|
||||
}
|
||||
|
||||
await emailField.fill("correct@domain.com");
|
||||
await emailField.blur();
|
||||
await expect(page.getByText("Enter a valid email address")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("validates password strength rules", async ({ page }) => {
|
||||
await page.goto("/signup");
|
||||
|
||||
const passwordField = page.getByLabel("Password", { exact: true });
|
||||
|
||||
await passwordField.fill("Xy1!");
|
||||
await passwordField.blur();
|
||||
await expect(page.getByText("Minimum 8 characters")).toBeVisible();
|
||||
|
||||
await passwordField.fill("lowercase1!");
|
||||
await passwordField.blur();
|
||||
await expect(page.getByText("Include an uppercase letter")).toBeVisible();
|
||||
|
||||
await passwordField.fill("SecureP@ss1");
|
||||
await passwordField.blur();
|
||||
await expect(page.getByText(/Minimum|Include/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("validates custom business rule — minimum amount", async ({ page }) => {
|
||||
await page.goto("/transfer");
|
||||
|
||||
await page.getByLabel("Amount").fill("5");
|
||||
await page.getByLabel("Amount").blur();
|
||||
await expect(page.getByText("Minimum transfer is $10")).toBeVisible();
|
||||
|
||||
await page.getByLabel("Amount").fill("1000000");
|
||||
await page.getByLabel("Amount").blur();
|
||||
await expect(page.getByText("Maximum transfer is $100,000")).toBeVisible();
|
||||
|
||||
await page.getByLabel("Amount").fill("500");
|
||||
await page.getByLabel("Amount").blur();
|
||||
await expect(page.getByText(/Minimum|Maximum/)).not.toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Form Reset Testing
|
||||
|
||||
**Use when**: Testing "clear form" or "reset" functionality, verifying that fields return to their default values.
|
||||
|
||||
```typescript
|
||||
test("reset button clears all fields to defaults", async ({ page }) => {
|
||||
await page.goto("/preferences");
|
||||
|
||||
await page.getByLabel("Nickname").fill("CustomNick");
|
||||
await page.getByLabel("Language").selectOption("es");
|
||||
await page.getByLabel("Email alerts").uncheck();
|
||||
|
||||
await page.getByRole("button", { name: "Reset" }).click();
|
||||
|
||||
await expect(page.getByLabel("Nickname")).toHaveValue("");
|
||||
await expect(page.getByLabel("Language")).toHaveValue("en");
|
||||
await expect(page.getByLabel("Email alerts")).toBeChecked();
|
||||
});
|
||||
|
||||
test("confirmation dialog before resetting a dirty form", async ({ page }) => {
|
||||
await page.goto("/document");
|
||||
|
||||
await page.getByLabel("Document title").fill("Draft document");
|
||||
|
||||
page.on("dialog", (dialog) => dialog.accept());
|
||||
await page.getByRole("button", { name: "Clear changes" }).click();
|
||||
|
||||
await expect(page.getByLabel("Document title")).toHaveValue("");
|
||||
});
|
||||
```
|
||||
|
||||
## Decision Guide
|
||||
|
||||
| Scenario | Approach | Key API |
|
||||
| ------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------------ |
|
||||
| Standard text input | `fill()` (clears, then types) | `page.getByLabel('Field').fill('value')` |
|
||||
| Need keystroke events (autocomplete) | `pressSequentially()` with delay | `locator.pressSequentially('text', { delay: 100 })` |
|
||||
| Native `<select>` dropdown | `selectOption()` by value or label | `locator.selectOption('US')` or `{ label: 'United States' }` |
|
||||
| Custom dropdown (ARIA listbox) | Click trigger, then select option role | `getByRole('option', { name: '...' }).click()` |
|
||||
| Checkbox | `check()` / `uncheck()` (idempotent) | `locator.check()` — safe to call even if already checked |
|
||||
| Radio button | `check()` on the target radio | `page.getByLabel('Option').check()` |
|
||||
| Date input (native) | `fill()` with ISO format | `locator.fill('2025-03-15')` |
|
||||
| Date picker (third-party) | Click to open, navigate, select day | `getByRole('gridcell', { name: '15' }).click()` |
|
||||
| Validation errors | Submit, then assert error text | `expect(page.getByText('Required')).toBeVisible()` |
|
||||
| Multi-step wizard | `test.step()` per step, assert heading | `await test.step('Step 1', async () => { ... })` |
|
||||
| Conditional/dynamic fields | Change trigger field, assert new field visibility | `expect(locator).toBeVisible()` / `.not.toBeVisible()` |
|
||||
| Form submission | `waitForResponse` + click submit | Register response listener before click |
|
||||
| Auto-complete | `pressSequentially()`, wait for listbox, select option | `getByRole('option', { name }).click()` |
|
||||
| Form reset | Click reset, assert default values | `expect(locator).toHaveValue('')` |
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Don't Do This | Problem | Do This Instead |
|
||||
| ------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------ |
|
||||
| `await page.getByLabel('Field').type('value')` | `type()` appends to existing content; does not clear first | `await page.getByLabel('Field').fill('value')` |
|
||||
| `await page.getByLabel('Option').click()` | `click()` toggles — if already checked, it unchecks | `await page.getByLabel('Option').check()` |
|
||||
| `await page.fill('#email', 'test@test.com')` | CSS selector is fragile | `await page.getByLabel('Email').fill('test@test.com')` |
|
||||
| `await page.selectOption('select', 'US')` without label | Targets first `<select>` on page; ambiguous | `await page.getByLabel('Country').selectOption('US')` |
|
||||
| Testing every invalid input in one test | Test becomes huge, slow, and hard to debug | One test per validation rule or group related rules |
|
||||
| `expect(await input.inputValue()).toBe('value')` | Resolves once — no retry. Race condition. | `await expect(input).toHaveValue('value')` |
|
||||
| Filling fields with `page.evaluate()` | Bypasses event handlers (no `input`, `change` events fire) | Use `fill()` or `pressSequentially()` |
|
||||
| Not waiting for conditional fields before filling | `fill()` fails on hidden/detached elements | `await expect(field).toBeVisible()` first |
|
||||
| Hardcoding wait after selecting a dropdown | `waitForTimeout(500)` is flaky and slow | Wait for the dependent element to appear |
|
||||
| Skipping server-side validation tests | Client-side validation can be bypassed | Test both client-side UX and server response |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `fill()` does nothing or clears but doesn't type
|
||||
|
||||
**Cause**: The input field uses a contenteditable div (rich text editors), not a real `<input>` or `<textarea>`.
|
||||
|
||||
```typescript
|
||||
const isContentEditable = await page
|
||||
.getByTestId("editor")
|
||||
.evaluate((el) => el.getAttribute("contenteditable"));
|
||||
|
||||
if (isContentEditable) {
|
||||
await page.getByTestId("editor").click();
|
||||
await page.getByTestId("editor").pressSequentially("Hello world");
|
||||
}
|
||||
```
|
||||
|
||||
### Date picker does not accept `fill()` value
|
||||
|
||||
**Cause**: Third-party date pickers often render custom UI over a hidden input. `fill()` sets the hidden input but the UI does not update.
|
||||
|
||||
```typescript
|
||||
await page.getByLabel("Date").click();
|
||||
await page.getByRole("button", { name: "Next month" }).click();
|
||||
await page.getByRole("gridcell", { name: "15" }).click();
|
||||
|
||||
// Alternatively, if the library reads from the input on change:
|
||||
await page.getByLabel("Date").fill("2025-06-15");
|
||||
await page.getByLabel("Date").dispatchEvent("change");
|
||||
```
|
||||
|
||||
### `selectOption()` throws "not a select element"
|
||||
|
||||
**Cause**: The dropdown is a custom component (ARIA listbox), not a native `<select>`.
|
||||
|
||||
```typescript
|
||||
await page.getByRole("combobox", { name: "Country" }).click();
|
||||
await page.getByRole("option", { name: "United States" }).click();
|
||||
```
|
||||
|
||||
### Validation errors do not appear after `fill()` and submit
|
||||
|
||||
**Cause**: The validation triggers on `blur` (focus leaving the field), but `fill()` does not trigger blur automatically.
|
||||
|
||||
```typescript
|
||||
await page.getByLabel("Email").fill("invalid");
|
||||
await page.getByLabel("Email").blur();
|
||||
await expect(page.getByText("Enter a valid email")).toBeVisible();
|
||||
|
||||
// Or move focus to the next field
|
||||
await page.getByLabel("Password").focus();
|
||||
```
|
||||
@@ -0,0 +1,331 @@
|
||||
# GraphQL Testing
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Patterns](#patterns)
|
||||
2. [Anti-Patterns](#anti-patterns)
|
||||
3. [Troubleshooting](#troubleshooting)
|
||||
|
||||
> **When to use**: Testing GraphQL APIs — queries, mutations, variables, and error handling.
|
||||
|
||||
## Patterns
|
||||
|
||||
### Basic Query with Variables
|
||||
|
||||
All GraphQL requests go through `POST` to a single endpoint. Send `query`, `variables`, and optionally `operationName` in the JSON body.
|
||||
|
||||
```typescript
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
const GQL_ENDPOINT = "/graphql";
|
||||
|
||||
test("query with variables", async ({ request }) => {
|
||||
const resp = await request.post(GQL_ENDPOINT, {
|
||||
data: {
|
||||
query: `
|
||||
query FetchItem($id: ID!) {
|
||||
item(id: $id) {
|
||||
id
|
||||
title
|
||||
price
|
||||
reviews { id rating }
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { id: "101" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(resp.ok()).toBeTruthy();
|
||||
const { data, errors } = await resp.json();
|
||||
|
||||
// GraphQL returns 200 even on errors — always check both
|
||||
expect(errors).toBeUndefined();
|
||||
expect(data.item).toMatchObject({
|
||||
id: "101",
|
||||
title: expect.any(String),
|
||||
price: expect.any(Number),
|
||||
});
|
||||
expect(data.item.reviews).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
rating: expect.any(Number),
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Mutations
|
||||
|
||||
```typescript
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
const GQL_ENDPOINT = "/graphql";
|
||||
|
||||
test("mutation creates resource", async ({ request }) => {
|
||||
const resp = await request.post(GQL_ENDPOINT, {
|
||||
data: {
|
||||
query: `
|
||||
mutation AddItem($input: ItemInput!) {
|
||||
addItem(input: $input) {
|
||||
id
|
||||
title
|
||||
status
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
title: "New Widget",
|
||||
price: 15.0,
|
||||
status: "DRAFT",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { data, errors } = await resp.json();
|
||||
expect(errors).toBeUndefined();
|
||||
expect(data.addItem).toMatchObject({
|
||||
id: expect.any(String),
|
||||
title: "New Widget",
|
||||
status: "DRAFT",
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Validation Errors
|
||||
|
||||
```typescript
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
const GQL_ENDPOINT = "/graphql";
|
||||
|
||||
test("handles validation errors", async ({ request }) => {
|
||||
const resp = await request.post(GQL_ENDPOINT, {
|
||||
data: {
|
||||
query: `
|
||||
mutation AddItem($input: ItemInput!) {
|
||||
addItem(input: $input) { id }
|
||||
}
|
||||
`,
|
||||
variables: { input: { title: "" } },
|
||||
},
|
||||
});
|
||||
|
||||
const { data, errors } = await resp.json();
|
||||
expect(errors).toBeDefined();
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].message).toContain("title");
|
||||
expect(errors[0].extensions?.code).toBe("BAD_USER_INPUT");
|
||||
});
|
||||
```
|
||||
|
||||
### Authorization Errors
|
||||
|
||||
```typescript
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
const GQL_ENDPOINT = "/graphql";
|
||||
|
||||
test("handles authorization errors", async ({ request }) => {
|
||||
const resp = await request.post(GQL_ENDPOINT, {
|
||||
data: {
|
||||
query: `
|
||||
query AdminDashboard {
|
||||
adminMetrics { revenue activeUsers }
|
||||
}
|
||||
`,
|
||||
},
|
||||
});
|
||||
|
||||
const { data, errors } = await resp.json();
|
||||
expect(errors).toBeDefined();
|
||||
expect(errors[0].extensions?.code).toBe("UNAUTHORIZED");
|
||||
expect(data?.adminMetrics).toBeNull();
|
||||
});
|
||||
```
|
||||
|
||||
### Authenticated GraphQL Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/graphql-fixtures.ts
|
||||
import { test as base, expect, APIRequestContext } from "@playwright/test";
|
||||
|
||||
type GraphQLFixtures = {
|
||||
gqlClient: APIRequestContext;
|
||||
adminGqlClient: APIRequestContext;
|
||||
};
|
||||
|
||||
export const test = base.extend<GraphQLFixtures>({
|
||||
gqlClient: async ({ playwright }, use) => {
|
||||
const ctx = await playwright.request.newContext({
|
||||
baseURL: "https://api.myapp.io",
|
||||
extraHTTPHeaders: {
|
||||
Authorization: `Bearer ${process.env.API_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
await use(ctx);
|
||||
await ctx.dispose();
|
||||
},
|
||||
|
||||
adminGqlClient: async ({ playwright }, use) => {
|
||||
const loginCtx = await playwright.request.newContext({
|
||||
baseURL: "https://api.myapp.io",
|
||||
});
|
||||
const loginResp = await loginCtx.post("/graphql", {
|
||||
data: {
|
||||
query: `
|
||||
mutation Login($email: String!, $password: String!) {
|
||||
login(email: $email, password: $password) { token }
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
email: process.env.ADMIN_EMAIL,
|
||||
password: process.env.ADMIN_PASSWORD,
|
||||
},
|
||||
},
|
||||
});
|
||||
const { data } = await loginResp.json();
|
||||
|
||||
if (!data?.login?.token) {
|
||||
throw new Error(`Admin login failed: status ${loginResp.status()}, response: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
await loginCtx.dispose();
|
||||
|
||||
const ctx = await playwright.request.newContext({
|
||||
baseURL: "https://api.myapp.io",
|
||||
extraHTTPHeaders: {
|
||||
Authorization: `Bearer ${data.login.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
await use(ctx);
|
||||
await ctx.dispose();
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
```
|
||||
|
||||
### GraphQL Helper Function
|
||||
|
||||
```typescript
|
||||
// utils/graphql.ts
|
||||
import { APIRequestContext, expect } from "@playwright/test";
|
||||
|
||||
export async function gqlQuery<T = any>(
|
||||
request: APIRequestContext,
|
||||
query: string,
|
||||
variables?: Record<string, any>
|
||||
): Promise<{ data: T; errors?: any[] }> {
|
||||
const resp = await request.post("/graphql", {
|
||||
data: { query, variables },
|
||||
});
|
||||
expect(resp.ok()).toBeTruthy();
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function gqlMutation<T = any>(
|
||||
request: APIRequestContext,
|
||||
mutation: string,
|
||||
variables?: Record<string, any>
|
||||
): Promise<{ data: T; errors?: any[] }> {
|
||||
return gqlQuery<T>(request, mutation, variables);
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// tests/api/items.spec.ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { gqlQuery, gqlMutation } from "../../utils/graphql";
|
||||
|
||||
test("fetch and update item", async ({ request }) => {
|
||||
const { data: fetchData } = await gqlQuery(
|
||||
request,
|
||||
`query GetItem($id: ID!) { item(id: $id) { id title } }`,
|
||||
{ id: "101" }
|
||||
);
|
||||
expect(fetchData.item.title).toBeDefined();
|
||||
|
||||
const { data: updateData, errors } = await gqlMutation(
|
||||
request,
|
||||
`mutation UpdateItem($id: ID!, $title: String!) {
|
||||
updateItem(id: $id, title: $title) { id title }
|
||||
}`,
|
||||
{ id: "101", title: "Updated Title" }
|
||||
);
|
||||
expect(errors).toBeUndefined();
|
||||
expect(updateData.updateItem.title).toBe("Updated Title");
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Don't Do This | Problem | Do This Instead |
|
||||
| --- | --- | --- |
|
||||
| Check only `response.ok()` | GraphQL returns 200 even on errors — `errors` array is the real signal | Always check both `data` and `errors` in the response body |
|
||||
| Ignore `errors` array | Validation and auth errors appear in `errors`, not HTTP status | Destructure and assert: `expect(errors).toBeUndefined()` |
|
||||
| Hardcode query strings inline everywhere | Duplicated queries are hard to maintain | Extract queries to constants or use a helper function |
|
||||
| Skip variable validation | Invalid variables cause cryptic server errors | Validate input shape before sending |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### GraphQL returns 200 but data is null
|
||||
|
||||
**Cause**: GraphQL servers return HTTP 200 even when the query has errors. The actual error is in the `errors` array.
|
||||
|
||||
**Fix**: Always destructure and check both `data` and `errors`.
|
||||
|
||||
```typescript
|
||||
const { data, errors } = await resp.json();
|
||||
if (errors) {
|
||||
console.error("GraphQL errors:", JSON.stringify(errors, null, 2));
|
||||
}
|
||||
expect(errors).toBeUndefined();
|
||||
expect(data.item).toBeDefined();
|
||||
```
|
||||
|
||||
### "Cannot query field X on type Y"
|
||||
|
||||
**Cause**: The field doesn't exist in the schema, or you're querying the wrong type.
|
||||
|
||||
**Fix**: Verify the schema. Use introspection or check your GraphQL IDE for available fields.
|
||||
|
||||
```typescript
|
||||
// Introspection query to debug schema
|
||||
const { data } = await request.post("/graphql", {
|
||||
data: {
|
||||
query: `{ __type(name: "Item") { fields { name type { name } } } }`,
|
||||
},
|
||||
});
|
||||
console.log(data.__type.fields);
|
||||
```
|
||||
|
||||
### Variables not being applied
|
||||
|
||||
**Cause**: Variable names in the query don't match the `variables` object keys, or types don't match.
|
||||
|
||||
**Fix**: Ensure variable names match exactly (case-sensitive) and types align with the schema.
|
||||
|
||||
```typescript
|
||||
// Wrong: variable name mismatch
|
||||
const resp = await request.post("/graphql", {
|
||||
data: {
|
||||
query: `query GetItem($itemId: ID!) { item(id: $itemId) { id } }`,
|
||||
variables: { id: "101" }, // Should be { itemId: "101" }
|
||||
},
|
||||
});
|
||||
|
||||
// Correct
|
||||
const resp = await request.post("/graphql", {
|
||||
data: {
|
||||
query: `query GetItem($itemId: ID!) { item(id: $itemId) { id } }`,
|
||||
variables: { itemId: "101" },
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,508 @@
|
||||
# Internationalization (i18n) Testing
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Locale Configuration](#locale-configuration)
|
||||
2. [Testing Multiple Locales](#testing-multiple-locales)
|
||||
3. [RTL Layout Testing](#rtl-layout-testing)
|
||||
4. [Date, Time & Number Formats](#date-time--number-formats)
|
||||
5. [Translation Verification](#translation-verification)
|
||||
6. [Visual Regression for i18n](#visual-regression-for-i18n)
|
||||
|
||||
## Locale Configuration
|
||||
|
||||
### Setting Browser Locale
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "english",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
locale: "en-US",
|
||||
timezoneId: "America/New_York",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "german",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
locale: "de-DE",
|
||||
timezoneId: "Europe/Berlin",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "japanese",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
locale: "ja-JP",
|
||||
timezoneId: "Asia/Tokyo",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "arabic",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
locale: "ar-SA",
|
||||
timezoneId: "Asia/Riyadh",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Per-Test Locale Override
|
||||
|
||||
```typescript
|
||||
test("test in French locale", async ({ browser }) => {
|
||||
const context = await browser.newContext({
|
||||
locale: "fr-FR",
|
||||
timezoneId: "Europe/Paris",
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
await page.goto("/");
|
||||
|
||||
// Verify French content
|
||||
await expect(page.getByRole("button", { name: "Connexion" })).toBeVisible();
|
||||
|
||||
await context.close();
|
||||
});
|
||||
```
|
||||
|
||||
### Accept-Language Header
|
||||
|
||||
```typescript
|
||||
test("server-side locale detection", async ({ browser }) => {
|
||||
const context = await browser.newContext({
|
||||
locale: "es-ES",
|
||||
extraHTTPHeaders: {
|
||||
"Accept-Language": "es-ES,es;q=0.9,en;q=0.8",
|
||||
},
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
await page.goto("/");
|
||||
|
||||
// Server should respond with Spanish content
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "es");
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Multiple Locales
|
||||
|
||||
### Parameterized Locale Tests
|
||||
|
||||
```typescript
|
||||
const locales = [
|
||||
{ locale: "en-US", greeting: "Hello", button: "Sign In" },
|
||||
{ locale: "de-DE", greeting: "Hallo", button: "Anmelden" },
|
||||
{ locale: "fr-FR", greeting: "Bonjour", button: "Se connecter" },
|
||||
{ locale: "ja-JP", greeting: "こんにちは", button: "ログイン" },
|
||||
];
|
||||
|
||||
for (const { locale, greeting, button } of locales) {
|
||||
test(`login page in ${locale}`, async ({ browser }) => {
|
||||
const context = await browser.newContext({ locale });
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto("/login");
|
||||
|
||||
await expect(page.getByText(greeting)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: button })).toBeVisible();
|
||||
|
||||
await context.close();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Locale Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/i18n.ts
|
||||
import { test as base } from "@playwright/test";
|
||||
|
||||
type LocaleFixtures = {
|
||||
localePage: (locale: string) => Promise<Page>;
|
||||
};
|
||||
|
||||
export const test = base.extend<LocaleFixtures>({
|
||||
localePage: async ({ browser }, use) => {
|
||||
const pages: Page[] = [];
|
||||
|
||||
const createLocalePage = async (locale: string) => {
|
||||
const context = await browser.newContext({ locale });
|
||||
const page = await context.newPage();
|
||||
pages.push(page);
|
||||
return page;
|
||||
};
|
||||
|
||||
await use(createLocalePage);
|
||||
|
||||
// Cleanup
|
||||
for (const page of pages) {
|
||||
await page.context().close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Usage
|
||||
test("compare locales", async ({ localePage }) => {
|
||||
const enPage = await localePage("en-US");
|
||||
const dePage = await localePage("de-DE");
|
||||
|
||||
await enPage.goto("/pricing");
|
||||
await dePage.goto("/pricing");
|
||||
|
||||
const enPrice = await enPage.getByTestId("price").textContent();
|
||||
const dePrice = await dePage.getByTestId("price").textContent();
|
||||
|
||||
expect(enPrice).toContain("$");
|
||||
expect(dePrice).toContain("€");
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Locale Switching
|
||||
|
||||
```typescript
|
||||
test("user can switch locale", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Initial locale (from browser)
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en");
|
||||
|
||||
// Switch to German
|
||||
await page.getByRole("button", { name: "Language" }).click();
|
||||
await page.getByRole("menuitem", { name: "Deutsch" }).click();
|
||||
|
||||
// Verify switch
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "de");
|
||||
await expect(page.getByRole("heading", { level: 1 })).toContainText(
|
||||
/Willkommen/,
|
||||
);
|
||||
|
||||
// Verify persistence (reload)
|
||||
await page.reload();
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "de");
|
||||
});
|
||||
```
|
||||
|
||||
## RTL Layout Testing
|
||||
|
||||
### Setting Up RTL Tests
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "rtl-arabic",
|
||||
use: {
|
||||
locale: "ar-SA",
|
||||
// RTL is usually set by the app based on locale
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rtl-hebrew",
|
||||
use: {
|
||||
locale: "he-IL",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Verifying RTL Direction
|
||||
|
||||
```typescript
|
||||
test("RTL layout is applied", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Check document direction
|
||||
await expect(page.locator("html")).toHaveAttribute("dir", "rtl");
|
||||
|
||||
// Or check computed style
|
||||
const direction = await page.evaluate(() => {
|
||||
return window.getComputedStyle(document.body).direction;
|
||||
});
|
||||
expect(direction).toBe("rtl");
|
||||
});
|
||||
```
|
||||
|
||||
### RTL-Specific Element Positioning
|
||||
|
||||
```typescript
|
||||
test("sidebar is on the right in RTL", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
|
||||
const sidebar = page.getByTestId("sidebar");
|
||||
const main = page.getByTestId("main-content");
|
||||
|
||||
const sidebarBox = await sidebar.boundingBox();
|
||||
const mainBox = await main.boundingBox();
|
||||
|
||||
// In RTL, sidebar should be to the right of main content
|
||||
expect(sidebarBox!.x).toBeGreaterThan(mainBox!.x);
|
||||
});
|
||||
```
|
||||
|
||||
### RTL Visual Regression
|
||||
|
||||
```typescript
|
||||
test("RTL layout matches snapshot", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Screenshot for RTL comparison
|
||||
await expect(page).toHaveScreenshot("homepage-rtl.png", {
|
||||
// Separate snapshots per locale/direction
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
// LTR comparison
|
||||
test("LTR layout matches snapshot", async ({ browser }) => {
|
||||
const context = await browser.newContext({ locale: "en-US" });
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveScreenshot("homepage-ltr.png", { fullPage: true });
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Bidirectional Text
|
||||
|
||||
```typescript
|
||||
test("bidirectional text renders correctly", async ({ page }) => {
|
||||
await page.goto("/profile");
|
||||
|
||||
// Mixed LTR/RTL content
|
||||
const nameField = page.getByTestId("full-name");
|
||||
|
||||
// Arabic name with English email
|
||||
await expect(nameField).toContainText("محمد (mohammed@example.com)");
|
||||
|
||||
// Verify text doesn't overlap or break
|
||||
const box = await nameField.boundingBox();
|
||||
expect(box!.width).toBeGreaterThan(100); // Content not collapsed
|
||||
});
|
||||
```
|
||||
|
||||
## Date, Time & Number Formats
|
||||
|
||||
### Testing Date Formats
|
||||
|
||||
```typescript
|
||||
test("dates are formatted per locale", async ({ browser }) => {
|
||||
const testDate = new Date("2024-03-15");
|
||||
|
||||
const formats = [
|
||||
{ locale: "en-US", expected: "March 15, 2024" },
|
||||
{ locale: "en-GB", expected: "15 March 2024" },
|
||||
{ locale: "de-DE", expected: "15. März 2024" },
|
||||
{ locale: "ja-JP", expected: "2024年3月15日" },
|
||||
];
|
||||
|
||||
for (const { locale, expected } of formats) {
|
||||
const context = await browser.newContext({ locale });
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`/event?date=${testDate.toISOString()}`);
|
||||
|
||||
const dateDisplay = page.getByTestId("event-date");
|
||||
await expect(dateDisplay).toContainText(expected);
|
||||
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Number Formats
|
||||
|
||||
```typescript
|
||||
test("numbers are formatted per locale", async ({ browser }) => {
|
||||
const testNumber = 1234567.89;
|
||||
|
||||
const formats = [
|
||||
{ locale: "en-US", expected: "1,234,567.89" },
|
||||
{ locale: "de-DE", expected: "1.234.567,89" },
|
||||
{ locale: "fr-FR", expected: "1 234 567,89" },
|
||||
];
|
||||
|
||||
for (const { locale, expected } of formats) {
|
||||
const context = await browser.newContext({ locale });
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`/stats?value=${testNumber}`);
|
||||
|
||||
await expect(page.getByTestId("formatted-number")).toHaveText(expected);
|
||||
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Currency Formats
|
||||
|
||||
```typescript
|
||||
test("currency displays correctly", async ({ browser }) => {
|
||||
const price = 99.99;
|
||||
|
||||
const currencies = [
|
||||
{ locale: "en-US", currency: "USD", expected: "$99.99" },
|
||||
{ locale: "de-DE", currency: "EUR", expected: "99,99 €" },
|
||||
{ locale: "ja-JP", currency: "JPY", expected: "¥100" }, // JPY has no decimals
|
||||
{ locale: "en-GB", currency: "GBP", expected: "£99.99" },
|
||||
];
|
||||
|
||||
for (const { locale, currency, expected } of currencies) {
|
||||
const context = await browser.newContext({ locale });
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`/product?price=${price}¤cy=${currency}`);
|
||||
|
||||
await expect(page.getByTestId("price")).toContainText(expected);
|
||||
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Translation Verification
|
||||
|
||||
### Checking for Missing Translations
|
||||
|
||||
```typescript
|
||||
test("no missing translations", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Common patterns for missing translations
|
||||
const missingPatterns = [
|
||||
/\{\{.*\}\}/, // Handlebars-style
|
||||
/\$\{.*\}/, // Template literal style
|
||||
/t\(["'][\w.]+["']\)/, // i18n key exposed
|
||||
/MISSING_TRANSLATION/, // Common placeholder
|
||||
/\[UNTRANSLATED\]/, // Another placeholder
|
||||
];
|
||||
|
||||
const bodyText = await page.locator("body").textContent();
|
||||
|
||||
for (const pattern of missingPatterns) {
|
||||
expect(bodyText).not.toMatch(pattern);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Detecting Text Overflow
|
||||
|
||||
```typescript
|
||||
test("translations fit UI containers", async ({ browser }) => {
|
||||
const locales = ["en-US", "de-DE", "fr-FR", "es-ES"];
|
||||
const issues: string[] = [];
|
||||
|
||||
for (const locale of locales) {
|
||||
const context = await browser.newContext({ locale });
|
||||
const page = await context.newPage();
|
||||
await page.goto("/");
|
||||
|
||||
const overflowing = await page.evaluate(() => {
|
||||
const elements = document.querySelectorAll("button, .label, h1, h2, h3");
|
||||
return Array.from(elements)
|
||||
.filter(
|
||||
(el) =>
|
||||
(el as HTMLElement).scrollWidth > (el as HTMLElement).clientWidth,
|
||||
)
|
||||
.map((el) => `${el.tagName}: "${el.textContent?.substring(0, 20)}..."`);
|
||||
});
|
||||
|
||||
if (overflowing.length > 0)
|
||||
issues.push(`${locale}: ${overflowing.join(", ")}`);
|
||||
await context.close();
|
||||
}
|
||||
|
||||
expect(issues).toEqual([]);
|
||||
});
|
||||
```
|
||||
|
||||
## Visual Regression for i18n
|
||||
|
||||
### Locale-Specific Snapshots
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
snapshotPathTemplate:
|
||||
"{testDir}/__snapshots__/{projectName}/{testFilePath}/{arg}{ext}",
|
||||
|
||||
projects: [
|
||||
{ name: "en-US", use: { locale: "en-US" } },
|
||||
{ name: "de-DE", use: { locale: "de-DE" } },
|
||||
{ name: "ja-JP", use: { locale: "ja-JP" } },
|
||||
{ name: "ar-SA", use: { locale: "ar-SA" } },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// test file
|
||||
test("homepage visual", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Snapshot auto-saved to {projectName}/homepage.png
|
||||
await expect(page).toHaveScreenshot("homepage.png");
|
||||
});
|
||||
```
|
||||
|
||||
### Critical Element Screenshots
|
||||
|
||||
```typescript
|
||||
test("navigation in all locales", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Just the nav - catches overflow, truncation
|
||||
const nav = page.getByRole("navigation");
|
||||
await expect(nav).toHaveScreenshot("navigation.png");
|
||||
});
|
||||
|
||||
test("buttons dont truncate", async ({ page }) => {
|
||||
await page.goto("/checkout");
|
||||
|
||||
const ctaButton = page.getByRole("button", {
|
||||
name: /checkout|kaufen|acheter/i,
|
||||
});
|
||||
await expect(ctaButton).toHaveScreenshot("checkout-button.png");
|
||||
});
|
||||
```
|
||||
|
||||
### Font Loading for i18n
|
||||
|
||||
```typescript
|
||||
test("wait for fonts before screenshot", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Wait for fonts (important for CJK, Arabic)
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.waitForFunction(() =>
|
||||
document.fonts.check("16px 'Noto Sans Arabic'"),
|
||||
);
|
||||
|
||||
await expect(page).toHaveScreenshot("with-fonts.png");
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| ------------------------- | ------------------------------- | ------------------------------- |
|
||||
| Hardcoded text assertions | Breaks in other locales | Use test IDs or parameterize |
|
||||
| Single locale testing | Misses i18n bugs | Test multiple locales |
|
||||
| Ignoring RTL | Layout broken for RTL users | Dedicated RTL project |
|
||||
| No font wait | Screenshots with fallback fonts | Wait for `document.fonts.ready` |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Clock Mocking**: See [clock-mocking.md](../advanced/clock-mocking.md) for timezone testing
|
||||
- **Mobile Testing**: See [mobile-testing.md](../advanced/mobile-testing.md) for device-specific locales
|
||||
@@ -0,0 +1,476 @@
|
||||
# Performance Testing & Web Vitals
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Core Web Vitals](#core-web-vitals)
|
||||
2. [Performance Metrics](#performance-metrics)
|
||||
3. [Performance Budgets](#performance-budgets)
|
||||
4. [Lighthouse Integration](#lighthouse-integration)
|
||||
5. [Performance Fixtures](#performance-fixtures)
|
||||
6. [CI Performance Monitoring](#ci-performance-monitoring)
|
||||
|
||||
## Core Web Vitals
|
||||
|
||||
### Measure LCP, FID, CLS
|
||||
|
||||
```typescript
|
||||
test("core web vitals within thresholds", async ({ page }) => {
|
||||
// Inject web-vitals library
|
||||
await page.addInitScript(() => {
|
||||
(window as any).__webVitals = {};
|
||||
|
||||
// Simplified web vitals collection
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.entryType === "largest-contentful-paint") {
|
||||
(window as any).__webVitals.lcp = entry.startTime;
|
||||
}
|
||||
}
|
||||
}).observe({ type: "largest-contentful-paint", buffered: true });
|
||||
|
||||
new PerformanceObserver((list) => {
|
||||
let cls = 0;
|
||||
for (const entry of list.getEntries() as any[]) {
|
||||
if (!entry.hadRecentInput) {
|
||||
cls += entry.value;
|
||||
}
|
||||
}
|
||||
(window as any).__webVitals.cls = cls;
|
||||
}).observe({ type: "layout-shift", buffered: true });
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
// Wait for page to stabilize
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Get metrics
|
||||
const vitals = await page.evaluate(() => (window as any).__webVitals);
|
||||
|
||||
// Assert thresholds (Google's "good" thresholds)
|
||||
expect(vitals.lcp).toBeLessThan(2500); // LCP < 2.5s
|
||||
expect(vitals.cls).toBeLessThan(0.1); // CLS < 0.1
|
||||
});
|
||||
```
|
||||
|
||||
### Using web-vitals Library
|
||||
|
||||
```typescript
|
||||
test("web vitals with library", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
(window as any).__vitals = {};
|
||||
});
|
||||
|
||||
// Inject web-vitals after navigation
|
||||
await page.goto("/");
|
||||
|
||||
await page.addScriptTag({
|
||||
url: "https://unpkg.com/web-vitals@3/dist/web-vitals.iife.js",
|
||||
});
|
||||
|
||||
await page.evaluate(() => {
|
||||
const { onLCP, onFID, onCLS, onFCP, onTTFB } = (window as any).webVitals;
|
||||
|
||||
onLCP((metric: any) => ((window as any).__vitals.lcp = metric.value));
|
||||
onFID((metric: any) => ((window as any).__vitals.fid = metric.value));
|
||||
onCLS((metric: any) => ((window as any).__vitals.cls = metric.value));
|
||||
onFCP((metric: any) => ((window as any).__vitals.fcp = metric.value));
|
||||
onTTFB((metric: any) => ((window as any).__vitals.ttfb = metric.value));
|
||||
});
|
||||
|
||||
// Trigger FID by clicking
|
||||
await page.getByRole("button").first().click();
|
||||
|
||||
// Wait and collect
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const vitals = await page.evaluate(() => (window as any).__vitals);
|
||||
|
||||
console.log("Web Vitals:", vitals);
|
||||
|
||||
// Assertions
|
||||
if (vitals.lcp) expect(vitals.lcp).toBeLessThan(2500);
|
||||
if (vitals.fid) expect(vitals.fid).toBeLessThan(100);
|
||||
if (vitals.cls) expect(vitals.cls).toBeLessThan(0.1);
|
||||
});
|
||||
```
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
### Navigation Timing
|
||||
|
||||
```typescript
|
||||
test("page load performance", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const timing = await page.evaluate(() => {
|
||||
const nav = performance.getEntriesByType(
|
||||
"navigation",
|
||||
)[0] as PerformanceNavigationTiming;
|
||||
|
||||
return {
|
||||
// Time to First Byte
|
||||
ttfb: nav.responseStart - nav.requestStart,
|
||||
// DOM Content Loaded
|
||||
domContentLoaded: nav.domContentLoadedEventEnd - nav.startTime,
|
||||
// Full page load
|
||||
loadComplete: nav.loadEventEnd - nav.startTime,
|
||||
// DNS lookup
|
||||
dns: nav.domainLookupEnd - nav.domainLookupStart,
|
||||
// Connection time
|
||||
connection: nav.connectEnd - nav.connectStart,
|
||||
// Download time
|
||||
download: nav.responseEnd - nav.responseStart,
|
||||
// DOM processing
|
||||
domProcessing: nav.domComplete - nav.domInteractive,
|
||||
};
|
||||
});
|
||||
|
||||
console.log("Performance timing:", timing);
|
||||
|
||||
// Assertions
|
||||
expect(timing.ttfb).toBeLessThan(600); // TTFB < 600ms
|
||||
expect(timing.domContentLoaded).toBeLessThan(2000); // DCL < 2s
|
||||
expect(timing.loadComplete).toBeLessThan(4000); // Load < 4s
|
||||
});
|
||||
```
|
||||
|
||||
### Resource Timing
|
||||
|
||||
```typescript
|
||||
test("resource loading performance", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const resources = await page.evaluate(() => {
|
||||
return performance.getEntriesByType("resource").map((entry) => ({
|
||||
name: entry.name.split("/").pop(),
|
||||
type: (entry as PerformanceResourceTiming).initiatorType,
|
||||
duration: entry.duration,
|
||||
size: (entry as PerformanceResourceTiming).transferSize,
|
||||
}));
|
||||
});
|
||||
|
||||
// Find slow resources
|
||||
const slowResources = resources.filter((r) => r.duration > 1000);
|
||||
|
||||
if (slowResources.length > 0) {
|
||||
console.warn("Slow resources:", slowResources);
|
||||
}
|
||||
|
||||
// Find large resources
|
||||
const largeResources = resources.filter((r) => r.size > 500000); // > 500KB
|
||||
|
||||
expect(largeResources.length).toBe(0);
|
||||
});
|
||||
```
|
||||
|
||||
### Memory Usage
|
||||
|
||||
```typescript
|
||||
test("memory usage is reasonable", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
|
||||
// Check memory (Chrome only)
|
||||
const memory = await page.evaluate(() => {
|
||||
if ((performance as any).memory) {
|
||||
return {
|
||||
usedJSHeapSize: (performance as any).memory.usedJSHeapSize,
|
||||
totalJSHeapSize: (performance as any).memory.totalJSHeapSize,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
if (memory) {
|
||||
const usedMB = memory.usedJSHeapSize / 1024 / 1024;
|
||||
console.log(`Memory usage: ${usedMB.toFixed(2)} MB`);
|
||||
|
||||
// Assert reasonable memory usage
|
||||
expect(usedMB).toBeLessThan(100); // < 100MB
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Performance Budgets
|
||||
|
||||
### Define Budgets
|
||||
|
||||
```typescript
|
||||
// performance-budgets.ts
|
||||
export const budgets = {
|
||||
homepage: {
|
||||
lcp: 2500,
|
||||
cls: 0.1,
|
||||
fcp: 1800,
|
||||
ttfb: 600,
|
||||
totalSize: 1500000, // 1.5MB
|
||||
jsSize: 500000, // 500KB
|
||||
imageCount: 20,
|
||||
},
|
||||
dashboard: {
|
||||
lcp: 3000,
|
||||
cls: 0.1,
|
||||
fcp: 2000,
|
||||
ttfb: 800,
|
||||
totalSize: 2000000,
|
||||
jsSize: 800000,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Test Against Budgets
|
||||
|
||||
```typescript
|
||||
import { budgets } from "./performance-budgets";
|
||||
|
||||
test("homepage meets performance budget", async ({ page }) => {
|
||||
const budget = budgets.homepage;
|
||||
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Measure LCP
|
||||
const lcp = await page.evaluate(() => {
|
||||
return new Promise<number>((resolve) => {
|
||||
new PerformanceObserver((list) => {
|
||||
const entries = list.getEntries();
|
||||
resolve(entries[entries.length - 1].startTime);
|
||||
}).observe({ type: "largest-contentful-paint", buffered: true });
|
||||
});
|
||||
});
|
||||
|
||||
// Measure resources
|
||||
const resources = await page.evaluate(() => {
|
||||
const entries = performance.getEntriesByType(
|
||||
"resource",
|
||||
) as PerformanceResourceTiming[];
|
||||
return {
|
||||
totalSize: entries.reduce((sum, e) => sum + (e.transferSize || 0), 0),
|
||||
jsSize: entries
|
||||
.filter((e) => e.initiatorType === "script")
|
||||
.reduce((sum, e) => sum + (e.transferSize || 0), 0),
|
||||
imageCount: entries.filter((e) => e.initiatorType === "img").length,
|
||||
};
|
||||
});
|
||||
|
||||
// Assert budgets
|
||||
expect(lcp, "LCP exceeds budget").toBeLessThan(budget.lcp);
|
||||
expect(resources.totalSize, "Total size exceeds budget").toBeLessThan(
|
||||
budget.totalSize,
|
||||
);
|
||||
expect(resources.jsSize, "JS size exceeds budget").toBeLessThan(
|
||||
budget.jsSize,
|
||||
);
|
||||
expect(resources.imageCount, "Too many images").toBeLessThanOrEqual(
|
||||
budget.imageCount,
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Budget Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/performance.fixture.ts
|
||||
type PerformanceBudget = {
|
||||
lcp?: number;
|
||||
cls?: number;
|
||||
ttfb?: number;
|
||||
totalSize?: number;
|
||||
};
|
||||
|
||||
type PerformanceFixtures = {
|
||||
assertBudget: (budget: PerformanceBudget) => Promise<void>;
|
||||
};
|
||||
|
||||
export const test = base.extend<PerformanceFixtures>({
|
||||
assertBudget: async ({ page }, use) => {
|
||||
await use(async (budget) => {
|
||||
const metrics = await page.evaluate(() => {
|
||||
const nav = performance.getEntriesByType(
|
||||
"navigation",
|
||||
)[0] as PerformanceNavigationTiming;
|
||||
const resources = performance.getEntriesByType(
|
||||
"resource",
|
||||
) as PerformanceResourceTiming[];
|
||||
|
||||
return {
|
||||
ttfb: nav.responseStart - nav.requestStart,
|
||||
totalSize: resources.reduce(
|
||||
(sum, r) => sum + (r.transferSize || 0),
|
||||
0,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
if (budget.ttfb) {
|
||||
expect(
|
||||
metrics.ttfb,
|
||||
`TTFB ${metrics.ttfb}ms exceeds budget ${budget.ttfb}ms`,
|
||||
).toBeLessThan(budget.ttfb);
|
||||
}
|
||||
|
||||
if (budget.totalSize) {
|
||||
expect(metrics.totalSize, `Total size exceeds budget`).toBeLessThan(
|
||||
budget.totalSize,
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Lighthouse Integration
|
||||
|
||||
### Using playwright-lighthouse
|
||||
|
||||
```bash
|
||||
npm install -D playwright-lighthouse lighthouse
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { playAudit } from "playwright-lighthouse";
|
||||
|
||||
test("lighthouse audit", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Run Lighthouse
|
||||
const audit = await playAudit({
|
||||
page,
|
||||
port: 9222, // Chrome debugging port
|
||||
thresholds: {
|
||||
performance: 80,
|
||||
accessibility: 90,
|
||||
"best-practices": 80,
|
||||
seo: 80,
|
||||
},
|
||||
});
|
||||
|
||||
// Assertions
|
||||
expect(audit.lhr.categories.performance.score * 100).toBeGreaterThanOrEqual(
|
||||
80,
|
||||
);
|
||||
expect(audit.lhr.categories.accessibility.score * 100).toBeGreaterThanOrEqual(
|
||||
90,
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Lighthouse with Config
|
||||
|
||||
```typescript
|
||||
test("lighthouse with custom config", async ({ page }, testInfo) => {
|
||||
await page.goto("/");
|
||||
|
||||
const audit = await playAudit({
|
||||
page,
|
||||
port: 9222,
|
||||
thresholds: {
|
||||
performance: 70,
|
||||
},
|
||||
config: {
|
||||
extends: "lighthouse:default",
|
||||
settings: {
|
||||
onlyCategories: ["performance"],
|
||||
throttling: {
|
||||
rttMs: 40,
|
||||
throughputKbps: 10240,
|
||||
cpuSlowdownMultiplier: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Save report
|
||||
const reportPath = testInfo.outputPath("lighthouse-report.html");
|
||||
// Save audit.report to file
|
||||
|
||||
// Attach to test report
|
||||
await testInfo.attach("lighthouse", {
|
||||
body: JSON.stringify(audit.lhr),
|
||||
contentType: "application/json",
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## CI Performance Monitoring
|
||||
|
||||
### Track Performance Over Time
|
||||
|
||||
```typescript
|
||||
// reporters/perf-reporter.ts
|
||||
import { Reporter, TestResult } from "@playwright/test/reporter";
|
||||
|
||||
class PerfReporter implements Reporter {
|
||||
private metrics: any[] = [];
|
||||
|
||||
onTestEnd(test: any, result: TestResult) {
|
||||
const perfAnnotation = test.annotations.find(
|
||||
(a: any) => a.type === "performance",
|
||||
);
|
||||
|
||||
if (perfAnnotation) {
|
||||
this.metrics.push({
|
||||
test: test.title,
|
||||
...JSON.parse(perfAnnotation.description),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async onEnd() {
|
||||
// Send to metrics service
|
||||
if (process.env.METRICS_ENDPOINT) {
|
||||
await fetch(process.env.METRICS_ENDPOINT, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
commit: process.env.GITHUB_SHA,
|
||||
branch: process.env.GITHUB_REF,
|
||||
metrics: this.metrics,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default PerfReporter;
|
||||
```
|
||||
|
||||
### Performance Regression Detection
|
||||
|
||||
```typescript
|
||||
test("no performance regression", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const metrics = await page.evaluate(() => {
|
||||
const nav = performance.getEntriesByType(
|
||||
"navigation",
|
||||
)[0] as PerformanceNavigationTiming;
|
||||
return {
|
||||
loadTime: nav.loadEventEnd - nav.startTime,
|
||||
};
|
||||
});
|
||||
|
||||
// Compare against baseline (could be from file or API)
|
||||
const baseline = 2000; // ms
|
||||
const threshold = 1.1; // 10% regression allowed
|
||||
|
||||
expect(
|
||||
metrics.loadTime,
|
||||
`Load time ${metrics.loadTime}ms is ${((metrics.loadTime / baseline - 1) * 100).toFixed(1)}% slower than baseline`,
|
||||
).toBeLessThan(baseline * threshold);
|
||||
});
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| --------------------------- | ------------------------- | -------------------------------- |
|
||||
| Testing only once | Results vary | Run multiple times, use averages |
|
||||
| Ignoring network conditions | Unrealistic results | Test with throttling |
|
||||
| No baseline comparison | Can't detect regressions | Track metrics over time |
|
||||
| Testing in dev mode | Slow, not production-like | Test production builds |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Performance Optimization**: See [performance.md](../infrastructure-ci-cd/performance.md) for test execution performance
|
||||
- **CI/CD**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for CI integration
|
||||
@@ -0,0 +1,430 @@
|
||||
# Security Testing Basics
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [XSS Prevention](#xss-prevention)
|
||||
2. [CSRF Protection](#csrf-protection)
|
||||
3. [Authentication Security](#authentication-security)
|
||||
4. [Authorization Testing](#authorization-testing)
|
||||
5. [Input Validation](#input-validation)
|
||||
6. [Security Headers](#security-headers)
|
||||
|
||||
## XSS Prevention
|
||||
|
||||
### Test Reflected XSS
|
||||
|
||||
```typescript
|
||||
test("input is properly escaped", async ({ page }) => {
|
||||
const xssPayloads = [
|
||||
'<script>alert("xss")</script>',
|
||||
'<img src="x" onerror="alert(1)">',
|
||||
'"><script>alert(1)</script>',
|
||||
"javascript:alert(1)",
|
||||
'<svg onload="alert(1)">',
|
||||
];
|
||||
|
||||
for (const payload of xssPayloads) {
|
||||
await page.goto(`/search?q=${encodeURIComponent(payload)}`);
|
||||
|
||||
// Verify script didn't execute
|
||||
const alertTriggered = await page.evaluate(() => {
|
||||
return (window as any).__xssTriggered === true;
|
||||
});
|
||||
expect(alertTriggered).toBe(false);
|
||||
|
||||
// Verify payload is escaped in HTML
|
||||
const content = await page.content();
|
||||
expect(content).not.toContain("<script>alert");
|
||||
expect(content).not.toContain("onerror=");
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Test Stored XSS
|
||||
|
||||
```typescript
|
||||
test("user content is sanitized", async ({ page }) => {
|
||||
await page.goto("/create-post");
|
||||
|
||||
// Try to inject script via form
|
||||
await page.getByLabel("Content").fill('<script>alert("xss")</script>Hello');
|
||||
await page.getByRole("button", { name: "Submit" }).click();
|
||||
|
||||
// View the post
|
||||
await page.goto("/posts/latest");
|
||||
|
||||
// Script should not be in page
|
||||
const scripts = await page.locator("script").count();
|
||||
const pageContent = await page.content();
|
||||
|
||||
// The script tag should be escaped or removed
|
||||
expect(pageContent).not.toContain("<script>alert");
|
||||
|
||||
// Text should still be visible (just sanitized)
|
||||
await expect(page.getByText("Hello")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Monitor for XSS Execution
|
||||
|
||||
```typescript
|
||||
test("no XSS execution", async ({ page }) => {
|
||||
// Set up XSS detection
|
||||
await page.addInitScript(() => {
|
||||
(window as any).__xssDetected = false;
|
||||
|
||||
// Override alert/confirm/prompt
|
||||
window.alert = () => {
|
||||
(window as any).__xssDetected = true;
|
||||
};
|
||||
window.confirm = () => {
|
||||
(window as any).__xssDetected = true;
|
||||
return false;
|
||||
};
|
||||
window.prompt = () => {
|
||||
(window as any).__xssDetected = true;
|
||||
return null;
|
||||
};
|
||||
});
|
||||
|
||||
// Perform test actions
|
||||
await page.goto("/vulnerable-page");
|
||||
await page.getByLabel("Search").fill('"><img src=x onerror=alert(1)>');
|
||||
await page.getByLabel("Search").press("Enter");
|
||||
|
||||
// Check if XSS triggered
|
||||
const xssDetected = await page.evaluate(() => (window as any).__xssDetected);
|
||||
expect(xssDetected).toBe(false);
|
||||
});
|
||||
```
|
||||
|
||||
## CSRF Protection
|
||||
|
||||
### Verify CSRF Token Present
|
||||
|
||||
```typescript
|
||||
test("forms include CSRF token", async ({ page }) => {
|
||||
await page.goto("/settings");
|
||||
|
||||
// Check form has CSRF token
|
||||
const csrfInput = page.locator(
|
||||
'input[name="_csrf"], input[name="csrf_token"]',
|
||||
);
|
||||
await expect(csrfInput).toBeAttached();
|
||||
|
||||
const csrfValue = await csrfInput.getAttribute("value");
|
||||
expect(csrfValue).toBeTruthy();
|
||||
expect(csrfValue!.length).toBeGreaterThan(20);
|
||||
});
|
||||
```
|
||||
|
||||
### Test CSRF Token Validation
|
||||
|
||||
```typescript
|
||||
test("rejects requests without CSRF token", async ({ page, request }) => {
|
||||
await page.goto("/settings");
|
||||
|
||||
// Try to submit without CSRF token
|
||||
const response = await request.post("/api/settings", {
|
||||
data: { theme: "dark" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
// Should be rejected
|
||||
expect(response.status()).toBe(403);
|
||||
});
|
||||
|
||||
test("rejects requests with invalid CSRF token", async ({ page, request }) => {
|
||||
await page.goto("/settings");
|
||||
|
||||
const response = await request.post("/api/settings", {
|
||||
data: { theme: "dark" },
|
||||
headers: {
|
||||
"X-CSRF-Token": "invalid-token",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(403);
|
||||
});
|
||||
```
|
||||
|
||||
### Test CSRF with Valid Token
|
||||
|
||||
```typescript
|
||||
test("accepts requests with valid CSRF token", async ({ page }) => {
|
||||
await page.goto("/settings");
|
||||
|
||||
// Get CSRF token from page
|
||||
const csrfToken = await page
|
||||
.locator('meta[name="csrf-token"]')
|
||||
.getAttribute("content");
|
||||
|
||||
// Submit form normally
|
||||
await page.getByLabel("Theme").selectOption("dark");
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
// Should succeed
|
||||
await expect(page.getByText("Settings saved")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Authentication Security
|
||||
|
||||
### Test Session Expiry
|
||||
|
||||
```typescript
|
||||
test("session expires after timeout", async ({ page, context }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Email").fill("user@example.com");
|
||||
await page.getByLabel("Password").fill("password");
|
||||
await page.getByRole("button", { name: "Sign in" }).click();
|
||||
|
||||
await expect(page).toHaveURL("/dashboard");
|
||||
|
||||
// Simulate time passing (if using clock mocking)
|
||||
await page.clock.fastForward("02:00:00"); // 2 hours
|
||||
|
||||
// Try to access protected page
|
||||
await page.goto("/profile");
|
||||
|
||||
// Should redirect to login
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.getByText("Session expired")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Test Concurrent Sessions
|
||||
|
||||
```typescript
|
||||
test("handles concurrent session limit", async ({ browser }) => {
|
||||
// Login from first browser
|
||||
const context1 = await browser.newContext();
|
||||
const page1 = await context1.newPage();
|
||||
|
||||
await page1.goto("/login");
|
||||
await page1.getByLabel("Email").fill("user@example.com");
|
||||
await page1.getByLabel("Password").fill("password");
|
||||
await page1.getByRole("button", { name: "Sign in" }).click();
|
||||
await expect(page1).toHaveURL("/dashboard");
|
||||
|
||||
// Login from second browser (same user)
|
||||
const context2 = await browser.newContext();
|
||||
const page2 = await context2.newPage();
|
||||
|
||||
await page2.goto("/login");
|
||||
await page2.getByLabel("Email").fill("user@example.com");
|
||||
await page2.getByLabel("Password").fill("password");
|
||||
await page2.getByRole("button", { name: "Sign in" }).click();
|
||||
|
||||
// First session should be invalidated (or warning shown)
|
||||
await page1.reload();
|
||||
await expect(
|
||||
page1.getByText(/session.*another device|logged out/i),
|
||||
).toBeVisible();
|
||||
|
||||
await context1.close();
|
||||
await context2.close();
|
||||
});
|
||||
```
|
||||
|
||||
### Test Password Reset Security
|
||||
|
||||
```typescript
|
||||
test("password reset token is single-use", async ({ page, request }) => {
|
||||
// Request password reset
|
||||
await page.goto("/forgot-password");
|
||||
await page.getByLabel("Email").fill("user@example.com");
|
||||
await page.getByRole("button", { name: "Reset" }).click();
|
||||
|
||||
// Get token (in test env, might be exposed or use email mock)
|
||||
const resetToken = "mock-reset-token";
|
||||
|
||||
// Use token first time
|
||||
await page.goto(`/reset-password?token=${resetToken}`);
|
||||
await page.getByLabel("New Password").fill("NewPassword123");
|
||||
await page.getByRole("button", { name: "Reset" }).click();
|
||||
|
||||
await expect(page.getByText("Password updated")).toBeVisible();
|
||||
|
||||
// Try to use same token again
|
||||
await page.goto(`/reset-password?token=${resetToken}`);
|
||||
|
||||
await expect(page.getByText("Invalid or expired token")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Authorization Testing
|
||||
|
||||
### Test Unauthorized Access
|
||||
|
||||
```typescript
|
||||
test.describe("authorization", () => {
|
||||
test("cannot access admin routes as user", async ({ browser }) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: ".auth/user.json", // Regular user
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
// Try to access admin page
|
||||
await page.goto("/admin/users");
|
||||
|
||||
// Should be denied
|
||||
await expect(page).not.toHaveURL("/admin/users");
|
||||
expect(
|
||||
(await page.getByText("Access denied").isVisible()) ||
|
||||
(await page.url()).includes("/login") ||
|
||||
(await page.url()).includes("/403"),
|
||||
).toBe(true);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test("cannot access other user's data", async ({ page }) => {
|
||||
// Logged in as user 1, try to access user 2's profile
|
||||
await page.goto("/users/other-user-id/settings");
|
||||
|
||||
await expect(page.getByText("Access denied")).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Test IDOR (Insecure Direct Object Reference)
|
||||
|
||||
```typescript
|
||||
test("cannot access other user resources by changing ID", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// Get current user's order
|
||||
await page.goto("/orders/my-order-123");
|
||||
await expect(page.getByText("Order #my-order-123")).toBeVisible();
|
||||
|
||||
// Try to access another user's order
|
||||
const response = await request.get("/api/orders/other-user-order-456");
|
||||
|
||||
// Should be forbidden
|
||||
expect(response.status()).toBe(403);
|
||||
});
|
||||
```
|
||||
|
||||
## Input Validation
|
||||
|
||||
### Test SQL Injection Prevention
|
||||
|
||||
```typescript
|
||||
test("SQL injection is prevented", async ({ page }) => {
|
||||
const sqlPayloads = [
|
||||
"'; DROP TABLE users; --",
|
||||
"1' OR '1'='1",
|
||||
"1; DELETE FROM orders",
|
||||
"' UNION SELECT * FROM users --",
|
||||
];
|
||||
|
||||
for (const payload of sqlPayloads) {
|
||||
await page.goto("/search");
|
||||
await page.getByLabel("Search").fill(payload);
|
||||
await page.getByRole("button", { name: "Search" }).click();
|
||||
|
||||
// Should not error (injection blocked/escaped)
|
||||
await expect(page.getByText("Error")).not.toBeVisible();
|
||||
|
||||
// Should show no results or escaped text
|
||||
const hasError = await page
|
||||
.getByText(/database error|sql|syntax/i)
|
||||
.isVisible();
|
||||
expect(hasError).toBe(false);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Test Input Length Limits
|
||||
|
||||
```typescript
|
||||
test("enforces input length limits", async ({ page }) => {
|
||||
await page.goto("/profile");
|
||||
|
||||
// Try to submit very long input
|
||||
const longString = "a".repeat(10000);
|
||||
|
||||
await page.getByLabel("Bio").fill(longString);
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
// Should show validation error or truncate
|
||||
const bioValue = await page.getByLabel("Bio").inputValue();
|
||||
expect(bioValue.length).toBeLessThanOrEqual(500); // Expected max
|
||||
});
|
||||
```
|
||||
|
||||
## Security Headers
|
||||
|
||||
### Verify Security Headers
|
||||
|
||||
```typescript
|
||||
test("response includes security headers", async ({ page }) => {
|
||||
const response = await page.goto("/");
|
||||
|
||||
const headers = response!.headers();
|
||||
|
||||
// Content Security Policy
|
||||
expect(headers["content-security-policy"]).toBeTruthy();
|
||||
|
||||
// Prevent clickjacking
|
||||
expect(headers["x-frame-options"]).toMatch(/DENY|SAMEORIGIN/);
|
||||
|
||||
// Prevent MIME type sniffing
|
||||
expect(headers["x-content-type-options"]).toBe("nosniff");
|
||||
|
||||
// XSS Protection (legacy but good to have)
|
||||
expect(headers["x-xss-protection"]).toBeTruthy();
|
||||
|
||||
// HTTPS enforcement
|
||||
if (!page.url().includes("localhost")) {
|
||||
expect(headers["strict-transport-security"]).toBeTruthy();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Test CSP Violations
|
||||
|
||||
```typescript
|
||||
test("CSP blocks inline scripts", async ({ page }) => {
|
||||
const cspViolations: string[] = [];
|
||||
|
||||
// Listen for CSP violations via console
|
||||
page.on("console", (msg) => {
|
||||
if (msg.text().includes("Content Security Policy")) {
|
||||
cspViolations.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
// Try to inject inline script - CSP should block it
|
||||
await page.evaluate(() => {
|
||||
const script = document.createElement("script");
|
||||
script.textContent = 'console.log("injected")';
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
|
||||
expect(cspViolations.length).toBeGreaterThan(0);
|
||||
});
|
||||
```
|
||||
|
||||
> **For comprehensive console monitoring** (fixtures, allowed patterns, fail on errors), see [console-errors.md](../debugging/console-errors.md).
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
| -------------------------- | --------------------- | ----------------------------- |
|
||||
| Testing only happy path | Misses security holes | Test malicious inputs |
|
||||
| Hardcoded test credentials | Security risk | Use environment variables |
|
||||
| Skipping auth tests in dev | Bugs reach production | Test auth in all environments |
|
||||
| Not testing authorization | Access control bugs | Test all role combinations |
|
||||
|
||||
## Related References
|
||||
|
||||
- **Authentication**: See [fixtures-hooks.md](../core/fixtures-hooks.md) for auth fixtures
|
||||
- **Multi-User**: See [multi-user.md](../advanced/multi-user.md) for role-based testing
|
||||
- **Error Testing**: See [error-testing.md](../debugging/error-testing.md) for validation testing
|
||||
@@ -0,0 +1,634 @@
|
||||
# Visual Regression Testing
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Quick Reference](#quick-reference)
|
||||
2. [Patterns](#patterns)
|
||||
3. [Decision Guide](#decision-guide)
|
||||
4. [Anti-Patterns](#anti-patterns)
|
||||
5. [Troubleshooting](#troubleshooting)
|
||||
|
||||
> **When to use**: Detecting unintended visual changes—layout shifts, style regressions, broken responsive designs—that functional assertions miss.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```typescript
|
||||
// Element screenshot
|
||||
await expect(page.getByTestId('product-card')).toHaveScreenshot();
|
||||
|
||||
// Full page screenshot
|
||||
await expect(page).toHaveScreenshot('landing-hero.png');
|
||||
|
||||
// Threshold for minor pixel variance
|
||||
await expect(page).toHaveScreenshot({ maxDiffPixelRatio: 0.01 });
|
||||
|
||||
// Mask volatile content
|
||||
await expect(page).toHaveScreenshot({
|
||||
mask: [page.getByTestId('clock'), page.getByRole('img', { name: 'User photo' })],
|
||||
});
|
||||
|
||||
// Disable CSS animations
|
||||
await expect(page).toHaveScreenshot({ animations: 'disabled' });
|
||||
|
||||
// Update baselines
|
||||
npx playwright test --update-snapshots
|
||||
```
|
||||
|
||||
## Patterns
|
||||
|
||||
### Masking Volatile Content
|
||||
|
||||
**Use when**: Page contains timestamps, avatars, ad slots, relative dates, random images, or A/B variants.
|
||||
|
||||
The `mask` option overlays a solid box over specified locators before capturing.
|
||||
|
||||
```typescript
|
||||
test('analytics panel with masked dynamic elements', async ({ page }) => {
|
||||
await page.goto('/analytics');
|
||||
|
||||
await expect(page).toHaveScreenshot('analytics.png', {
|
||||
mask: [
|
||||
page.getByTestId('last-updated'),
|
||||
page.getByTestId('profile-avatar'),
|
||||
page.getByTestId('active-users'),
|
||||
page.locator('.promo-banner'),
|
||||
],
|
||||
maskColor: '#FF00FF',
|
||||
});
|
||||
});
|
||||
|
||||
test('activity stream with relative times', async ({ page }) => {
|
||||
await page.goto('/activity');
|
||||
|
||||
await expect(page).toHaveScreenshot('activity.png', {
|
||||
mask: [page.locator('time[datetime]')],
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Alternative: freeze content with JavaScript** when masking affects layout:
|
||||
|
||||
```typescript
|
||||
test('freeze timestamps before capture', async ({ page }) => {
|
||||
await page.goto('/analytics');
|
||||
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll('[data-testid="time-display"]').forEach((el) => {
|
||||
el.textContent = 'Jan 1, 2025 12:00 PM';
|
||||
});
|
||||
});
|
||||
|
||||
await expect(page).toHaveScreenshot('analytics-frozen.png');
|
||||
});
|
||||
```
|
||||
|
||||
### Disabling Animations
|
||||
|
||||
**Use when**: Always. CSS animations and transitions are the primary cause of flaky visual diffs.
|
||||
|
||||
```typescript
|
||||
test('renders without animation interference', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page).toHaveScreenshot('home.png', {
|
||||
animations: 'disabled',
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Set globally** in config:
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
expect: {
|
||||
toHaveScreenshot: {
|
||||
animations: 'disabled',
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
When `animations: 'disabled'` is set, Playwright injects CSS forcing animation/transition duration to 0s, waits for running animations to finish, then captures.
|
||||
|
||||
For JavaScript-driven animations (GSAP, Framer Motion), wait for stability:
|
||||
|
||||
```typescript
|
||||
test('page with JS animations', async ({ page }) => {
|
||||
await page.goto('/animated-hero');
|
||||
|
||||
const heroBanner = page.getByTestId('hero-banner');
|
||||
await heroBanner.waitFor({ state: 'visible' });
|
||||
|
||||
// Wait for animation to complete by checking for stable state
|
||||
await expect(heroBanner).not.toHaveClass(/animating/);
|
||||
|
||||
await expect(page).toHaveScreenshot('hero.png', {
|
||||
animations: 'disabled',
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Configuring Thresholds
|
||||
|
||||
**Use when**: Minor rendering differences from anti-aliasing, font hinting, or sub-pixel rendering cause false failures.
|
||||
|
||||
| Option | Controls | Typical Value |
|
||||
|---|---|---|
|
||||
| `maxDiffPixels` | Absolute pixel count that can differ | `100` for pages, `10` for components |
|
||||
| `maxDiffPixelRatio` | Fraction of total pixels (0-1) | `0.01` (1%) for pages |
|
||||
| `threshold` | Per-pixel color tolerance (0-1) | `0.2` for most UIs, `0.1` for design systems |
|
||||
|
||||
```typescript
|
||||
test('control panel allows minor variance', async ({ page }) => {
|
||||
await page.goto('/control-panel');
|
||||
|
||||
await expect(page).toHaveScreenshot('control-panel.png', {
|
||||
maxDiffPixelRatio: 0.01,
|
||||
});
|
||||
});
|
||||
|
||||
test('brand logo renders pixel-perfect', async ({ page }) => {
|
||||
await page.goto('/brand');
|
||||
|
||||
await expect(page.getByTestId('brand-logo')).toHaveScreenshot('brand-logo.png', {
|
||||
maxDiffPixels: 0,
|
||||
threshold: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('graph allows anti-aliasing differences', async ({ page }) => {
|
||||
await page.goto('/reports');
|
||||
|
||||
await expect(page.getByTestId('sales-graph')).toHaveScreenshot('sales-graph.png', {
|
||||
threshold: 0.3,
|
||||
maxDiffPixels: 200,
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Global thresholds** in config:
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
expect: {
|
||||
toHaveScreenshot: {
|
||||
maxDiffPixelRatio: 0.01,
|
||||
threshold: 0.2,
|
||||
animations: 'disabled',
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### CI Configuration
|
||||
|
||||
**Use when**: Running visual tests in CI. Consistent rendering is critical—the same test must produce identical screenshots every time.
|
||||
|
||||
**The problem**: Font rendering and anti-aliasing differ across operating systems. macOS snapshots won't match Linux.
|
||||
|
||||
**The solution**: Run visual tests in Docker using the official Playwright container. Generate and update snapshots from the same container.
|
||||
|
||||
**GitHub Actions with Docker**
|
||||
|
||||
```yaml
|
||||
# .github/workflows/visual-tests.yml
|
||||
name: Visual Regression Tests
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
visual-tests:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.48.0-noble
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: npm
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Run visual tests
|
||||
run: npx playwright test --project=visual
|
||||
env:
|
||||
HOME: /root
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: visual-test-report
|
||||
path: playwright-report/
|
||||
retention-days: 14
|
||||
```
|
||||
|
||||
**Updating snapshots locally using Docker**:
|
||||
|
||||
```bash
|
||||
docker run --rm -v $(pwd):/work -w /work \
|
||||
mcr.microsoft.com/playwright:v1.48.0-noble \
|
||||
npx playwright test --update-snapshots --project=visual
|
||||
```
|
||||
|
||||
**Add script to `package.json`**:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"test:visual": "npx playwright test --project=visual",
|
||||
"test:visual:update": "docker run --rm -v $(pwd):/work -w /work mcr.microsoft.com/playwright:v1.48.0-noble npx playwright test --update-snapshots --project=visual"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Platform-agnostic snapshots** (requires Docker for generation):
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{ext}',
|
||||
projects: [
|
||||
{
|
||||
name: 'visual',
|
||||
testMatch: '**/*.visual.spec.ts',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Full Page vs Element Screenshots
|
||||
|
||||
**Use when**: Deciding scope. Full page catches layout shifts. Element screenshots isolate components and are more stable.
|
||||
|
||||
```typescript
|
||||
test('full page captures layout shifts', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Visible viewport
|
||||
await expect(page).toHaveScreenshot('home-viewport.png');
|
||||
|
||||
// Entire scrollable page
|
||||
await expect(page).toHaveScreenshot('home-full.png', {
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('element screenshot isolates component', async ({ page }) => {
|
||||
await page.goto('/catalog');
|
||||
|
||||
await expect(page.getByRole('table')).toHaveScreenshot('catalog-table.png');
|
||||
await expect(page.getByTestId('featured-item')).toHaveScreenshot('featured-item.png');
|
||||
});
|
||||
```
|
||||
|
||||
**Rule of thumb**: Element screenshots for independently changing components. Full page screenshots for key layouts where spacing matters.
|
||||
|
||||
### Responsive Visual Testing
|
||||
|
||||
**Use when**: Application has responsive breakpoints requiring verification at different viewport sizes.
|
||||
|
||||
```typescript
|
||||
const breakpoints = [
|
||||
{ name: 'phone', width: 375, height: 812 },
|
||||
{ name: 'tablet', width: 768, height: 1024 },
|
||||
{ name: 'desktop', width: 1440, height: 900 },
|
||||
];
|
||||
|
||||
for (const bp of breakpoints) {
|
||||
test(`landing at ${bp.name} (${bp.width}x${bp.height})`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: bp.width, height: bp.height });
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page).toHaveScreenshot(`landing-${bp.name}.png`, {
|
||||
animations: 'disabled',
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Alternative: use projects for responsive testing**:
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: 'desktop',
|
||||
testMatch: '**/*.visual.spec.ts',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'tablet',
|
||||
testMatch: '**/*.visual.spec.ts',
|
||||
use: { ...devices['iPad (gen 7)'] },
|
||||
},
|
||||
{
|
||||
name: 'mobile',
|
||||
testMatch: '**/*.visual.spec.ts',
|
||||
use: { ...devices['iPhone 14'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Component Visual Testing
|
||||
|
||||
**Use when**: Testing individual UI components in isolation—buttons, cards, forms, modals. Faster and more stable than full-page screenshots.
|
||||
|
||||
```typescript
|
||||
test.describe('Button visual states', () => {
|
||||
test('primary button', async ({ page }) => {
|
||||
await page.goto('/storybook/iframe.html?id=button--primary');
|
||||
const btn = page.getByRole('button');
|
||||
await expect(btn).toHaveScreenshot('btn-primary.png', {
|
||||
animations: 'disabled',
|
||||
});
|
||||
});
|
||||
|
||||
test('primary button hover', async ({ page }) => {
|
||||
await page.goto('/storybook/iframe.html?id=button--primary');
|
||||
const btn = page.getByRole('button');
|
||||
await btn.hover();
|
||||
await expect(btn).toHaveScreenshot('btn-primary-hover.png', {
|
||||
animations: 'disabled',
|
||||
});
|
||||
});
|
||||
|
||||
test('button sizes', async ({ page }) => {
|
||||
for (const size of ['small', 'medium', 'large']) {
|
||||
await page.goto(`/storybook/iframe.html?id=button--${size}`);
|
||||
const btn = page.getByRole('button');
|
||||
await expect(btn).toHaveScreenshot(`btn-${size}.png`, {
|
||||
animations: 'disabled',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Using a dedicated test harness** instead of Storybook:
|
||||
|
||||
```typescript
|
||||
test.describe('Card component', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/test-harness/card');
|
||||
});
|
||||
|
||||
test('default state', async ({ page }) => {
|
||||
await expect(page.getByTestId('card')).toHaveScreenshot('card-default.png', {
|
||||
animations: 'disabled',
|
||||
});
|
||||
});
|
||||
|
||||
test('truncates long content', async ({ page }) => {
|
||||
await page.goto('/test-harness/card?content=long');
|
||||
await expect(page.getByTestId('card')).toHaveScreenshot('card-long.png', {
|
||||
animations: 'disabled',
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Updating Snapshots
|
||||
|
||||
**Use when**: Intentionally changed UI—design refresh, rebrand, new feature. Never update when diff is unexpected.
|
||||
|
||||
```bash
|
||||
# Update all snapshots
|
||||
npx playwright test --update-snapshots
|
||||
|
||||
# Update for specific file
|
||||
npx playwright test tests/landing.spec.ts --update-snapshots
|
||||
|
||||
# Update for specific project
|
||||
npx playwright test --project=chromium --update-snapshots
|
||||
```
|
||||
|
||||
**Workflow for reviewing changes:**
|
||||
|
||||
1. Run tests and view failures in HTML report:
|
||||
```bash
|
||||
npx playwright test
|
||||
npx playwright show-report
|
||||
```
|
||||
The report shows expected, actual, and diff images side-by-side.
|
||||
|
||||
2. If changes are intentional, update:
|
||||
```bash
|
||||
npx playwright test --update-snapshots
|
||||
```
|
||||
|
||||
3. Review updated snapshots before committing:
|
||||
```bash
|
||||
git diff --name-only
|
||||
```
|
||||
|
||||
**Tag visual tests for selective updates:**
|
||||
|
||||
```typescript
|
||||
test('landing visual @visual', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveScreenshot('landing.png', {
|
||||
animations: 'disabled',
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
```bash
|
||||
npx playwright test --grep @visual --update-snapshots
|
||||
```
|
||||
|
||||
### Cross-Browser Visual Testing
|
||||
|
||||
**Use when**: Users span Chrome, Firefox, Safari and you need per-browser rendering verification.
|
||||
|
||||
Playwright separates snapshots by project name automatically. Each browser gets its own baseline—browsers render fonts and shadows differently.
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
expect: {
|
||||
toHaveScreenshot: {
|
||||
animations: 'disabled',
|
||||
maxDiffPixelRatio: 0.01,
|
||||
},
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
**Strategy**: Run visual tests in a single browser (Chromium on Linux in CI) to minimize snapshot count. Add other browsers only when you have actual cross-browser rendering bugs:
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: 'visual',
|
||||
testMatch: '**/*.visual.spec.ts',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'chromium',
|
||||
testIgnore: '**/*.visual.spec.ts',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
testIgnore: '**/*.visual.spec.ts',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Decision Guide
|
||||
|
||||
| Scenario | Approach | Rationale |
|
||||
|---|---|---|
|
||||
| Key landing/marketing pages | Full page, `fullPage: true` | Catches layout shifts, spacing, overall harmony |
|
||||
| Individual components | Element screenshot | Isolated, fast, immune to unrelated changes |
|
||||
| Page with dynamic content | Full page + `mask` | Covers layout while ignoring volatile content |
|
||||
| Design system library | Element per variant, zero threshold | Pixel-perfect enforcement |
|
||||
| Responsive verification | Screenshot per viewport | Catches breakpoint bugs |
|
||||
| Cross-browser consistency | Separate snapshots per browser | Browsers render differently |
|
||||
| CI pipeline | Docker container, Linux-only snapshots | Consistent rendering |
|
||||
| Threshold: design system | `threshold: 0`, `maxDiffPixels: 0` | Zero tolerance |
|
||||
| Threshold: content pages | `maxDiffPixelRatio: 0.01`, `threshold: 0.2` | Minor anti-aliasing variance |
|
||||
| Threshold: charts/graphs | `maxDiffPixels: 200`, `threshold: 0.3` | Anti-aliasing on curves varies |
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Don't | Problem | Do Instead |
|
||||
|---|---|---|
|
||||
| Visual test every page | Massive maintenance, constant false failures | Pick 5-10 key pages and critical components |
|
||||
| Skip masking dynamic content | Screenshots differ every run, permanently flaky | Use `mask` for all volatile elements |
|
||||
| Run across macOS, Linux, Windows | Font rendering differs, snapshots never match | Standardize on Linux via Docker |
|
||||
| Skip Docker in CI | OS updates shift rendering silently | Pin specific Playwright Docker image |
|
||||
| Blindly run `--update-snapshots` | Accepts unintentional regressions | Always review diff in HTML report first |
|
||||
| Skip `animations: 'disabled'` | CSS transitions create random diffs | Set globally in config |
|
||||
| Replace functional assertions with visual tests | Diffs don't tell you *what* broke | Visual tests complement, never replace |
|
||||
| Commit snapshots from different platforms | Tests fail for everyone | All team members use same Docker container |
|
||||
| Set threshold too high (`0.1`) | 10% pixel change passes, defeats purpose | Start with `0.01`, adjust per-test |
|
||||
| Full page on infinite scroll pages | Page height nondeterministic | Element screenshots on above-the-fold content |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Screenshot comparison failed" on first CI run after local development
|
||||
|
||||
**Cause**: Snapshots generated on macOS locally. CI runs on Linux. Font rendering differs.
|
||||
|
||||
**Fix**: Generate snapshots using Docker:
|
||||
|
||||
```bash
|
||||
docker run --rm -v $(pwd):/work -w /work \
|
||||
mcr.microsoft.com/playwright:v1.48.0-noble \
|
||||
npx playwright test --update-snapshots --project=visual
|
||||
```
|
||||
|
||||
Commit Linux-generated snapshots.
|
||||
|
||||
### "Expected screenshot to match but X pixels differ"
|
||||
|
||||
**Cause**: Anti-aliasing, font hinting, sub-pixel rendering differences.
|
||||
|
||||
**Fix**: Add tolerance:
|
||||
|
||||
```typescript
|
||||
await expect(page).toHaveScreenshot('page.png', {
|
||||
maxDiffPixelRatio: 0.01,
|
||||
threshold: 0.2,
|
||||
});
|
||||
```
|
||||
|
||||
Check HTML report diff image to determine if it's regression or noise.
|
||||
|
||||
### Visual tests pass locally but fail in CI (even with Docker)
|
||||
|
||||
**Cause**: Different Playwright versions locally vs CI.
|
||||
|
||||
**Fix**: Ensure `package.json` version matches Docker image tag:
|
||||
|
||||
```json
|
||||
{
|
||||
"devDependencies": {
|
||||
"@playwright/test": "latest"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```yaml
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.48.0-noble
|
||||
```
|
||||
|
||||
### Animations cause random diff failures
|
||||
|
||||
**Cause**: CSS animations captured mid-frame.
|
||||
|
||||
**Fix**: Set `animations: 'disabled'` globally:
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
expect: {
|
||||
toHaveScreenshot: {
|
||||
animations: 'disabled',
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
For JS animations, wait for stable state before capture.
|
||||
|
||||
### Snapshot file names conflict between tests
|
||||
|
||||
**Cause**: Two tests use same screenshot name without unique paths.
|
||||
|
||||
**Fix**: Use explicit unique names:
|
||||
|
||||
```typescript
|
||||
await expect(page).toHaveScreenshot('auth-home.png');
|
||||
await expect(page).toHaveScreenshot('public-home.png');
|
||||
```
|
||||
|
||||
Or customize snapshot path template:
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{ext}',
|
||||
});
|
||||
```
|
||||
|
||||
### Too many snapshot files to maintain
|
||||
|
||||
**Cause**: Visual tests for every page, browser, viewport.
|
||||
|
||||
**Fix**: Be selective. Visual test only high-risk pages:
|
||||
- Landing and marketing pages
|
||||
- Design system components
|
||||
- Complex layouts (dashboards, data tables)
|
||||
- Pages after major refactor
|
||||
|
||||
Skip pages where functional assertions cover key elements.
|
||||
@@ -0,0 +1,441 @@
|
||||
---
|
||||
name: python-patterns
|
||||
description: Python development principles and decision-making. Framework selection, async patterns, type hints, project structure. Teaches thinking, not copying.
|
||||
allowed-tools: Read, Write, Edit, Glob, Grep
|
||||
---
|
||||
|
||||
# Python Patterns
|
||||
|
||||
> Python development principles and decision-making for 2025.
|
||||
> **Learn to THINK, not memorize patterns.**
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ How to Use This Skill
|
||||
|
||||
This skill teaches **decision-making principles**, not fixed code to copy.
|
||||
|
||||
- ASK user for framework preference when unclear
|
||||
- Choose async vs sync based on CONTEXT
|
||||
- Don't default to same framework every time
|
||||
|
||||
---
|
||||
|
||||
## 1. Framework Selection (2025)
|
||||
|
||||
### Decision Tree
|
||||
|
||||
```
|
||||
What are you building?
|
||||
│
|
||||
├── API-first / Microservices
|
||||
│ └── FastAPI (async, modern, fast)
|
||||
│
|
||||
├── Full-stack web / CMS / Admin
|
||||
│ └── Django (batteries-included)
|
||||
│
|
||||
├── Simple / Script / Learning
|
||||
│ └── Flask (minimal, flexible)
|
||||
│
|
||||
├── AI/ML API serving
|
||||
│ └── FastAPI (Pydantic, async, uvicorn)
|
||||
│
|
||||
└── Background workers
|
||||
└── Celery + any framework
|
||||
```
|
||||
|
||||
### Comparison Principles
|
||||
|
||||
| Factor | FastAPI | Django | Flask |
|
||||
|--------|---------|--------|-------|
|
||||
| **Best for** | APIs, microservices | Full-stack, CMS | Simple, learning |
|
||||
| **Async** | Native | Django 5.0+ | Via extensions |
|
||||
| **Admin** | Manual | Built-in | Via extensions |
|
||||
| **ORM** | Choose your own | Django ORM | Choose your own |
|
||||
| **Learning curve** | Low | Medium | Low |
|
||||
|
||||
### Selection Questions to Ask:
|
||||
1. Is this API-only or full-stack?
|
||||
2. Need admin interface?
|
||||
3. Team familiar with async?
|
||||
4. Existing infrastructure?
|
||||
|
||||
---
|
||||
|
||||
## 2. Async vs Sync Decision
|
||||
|
||||
### When to Use Async
|
||||
|
||||
```
|
||||
async def is better when:
|
||||
├── I/O-bound operations (database, HTTP, file)
|
||||
├── Many concurrent connections
|
||||
├── Real-time features
|
||||
├── Microservices communication
|
||||
└── FastAPI/Starlette/Django ASGI
|
||||
|
||||
def (sync) is better when:
|
||||
├── CPU-bound operations
|
||||
├── Simple scripts
|
||||
├── Legacy codebase
|
||||
├── Team unfamiliar with async
|
||||
└── Blocking libraries (no async version)
|
||||
```
|
||||
|
||||
### The Golden Rule
|
||||
|
||||
```
|
||||
I/O-bound → async (waiting for external)
|
||||
CPU-bound → sync + multiprocessing (computing)
|
||||
|
||||
Don't:
|
||||
├── Mix sync and async carelessly
|
||||
├── Use sync libraries in async code
|
||||
└── Force async for CPU work
|
||||
```
|
||||
|
||||
### Async Library Selection
|
||||
|
||||
| Need | Async Library |
|
||||
|------|---------------|
|
||||
| HTTP client | httpx |
|
||||
| PostgreSQL | asyncpg |
|
||||
| Redis | aioredis / redis-py async |
|
||||
| File I/O | aiofiles |
|
||||
| Database ORM | SQLAlchemy 2.0 async, Tortoise |
|
||||
|
||||
---
|
||||
|
||||
## 3. Type Hints Strategy
|
||||
|
||||
### When to Type
|
||||
|
||||
```
|
||||
Always type:
|
||||
├── Function parameters
|
||||
├── Return types
|
||||
├── Class attributes
|
||||
├── Public APIs
|
||||
|
||||
Can skip:
|
||||
├── Local variables (let inference work)
|
||||
├── One-off scripts
|
||||
├── Tests (usually)
|
||||
```
|
||||
|
||||
### Common Type Patterns
|
||||
|
||||
```python
|
||||
# These are patterns, understand them:
|
||||
|
||||
# Optional → might be None
|
||||
from typing import Optional
|
||||
def find_user(id: int) -> Optional[User]: ...
|
||||
|
||||
# Union → one of multiple types
|
||||
def process(data: str | dict) -> None: ...
|
||||
|
||||
# Generic collections
|
||||
def get_items() -> list[Item]: ...
|
||||
def get_mapping() -> dict[str, int]: ...
|
||||
|
||||
# Callable
|
||||
from typing import Callable
|
||||
def apply(fn: Callable[[int], str]) -> str: ...
|
||||
```
|
||||
|
||||
### Pydantic for Validation
|
||||
|
||||
```
|
||||
When to use Pydantic:
|
||||
├── API request/response models
|
||||
├── Configuration/settings
|
||||
├── Data validation
|
||||
├── Serialization
|
||||
|
||||
Benefits:
|
||||
├── Runtime validation
|
||||
├── Auto-generated JSON schema
|
||||
├── Works with FastAPI natively
|
||||
└── Clear error messages
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Project Structure Principles
|
||||
|
||||
### Structure Selection
|
||||
|
||||
```
|
||||
Small project / Script:
|
||||
├── main.py
|
||||
├── utils.py
|
||||
└── requirements.txt
|
||||
|
||||
Medium API:
|
||||
├── app/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py
|
||||
│ ├── models/
|
||||
│ ├── routes/
|
||||
│ ├── services/
|
||||
│ └── schemas/
|
||||
├── tests/
|
||||
└── pyproject.toml
|
||||
|
||||
Large application:
|
||||
├── src/
|
||||
│ └── myapp/
|
||||
│ ├── core/
|
||||
│ ├── api/
|
||||
│ ├── services/
|
||||
│ ├── models/
|
||||
│ └── ...
|
||||
├── tests/
|
||||
└── pyproject.toml
|
||||
```
|
||||
|
||||
### FastAPI Structure Principles
|
||||
|
||||
```
|
||||
Organize by feature or layer:
|
||||
|
||||
By layer:
|
||||
├── routes/ (API endpoints)
|
||||
├── services/ (business logic)
|
||||
├── models/ (database models)
|
||||
├── schemas/ (Pydantic models)
|
||||
└── dependencies/ (shared deps)
|
||||
|
||||
By feature:
|
||||
├── users/
|
||||
│ ├── routes.py
|
||||
│ ├── service.py
|
||||
│ └── schemas.py
|
||||
└── products/
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Django Principles (2025)
|
||||
|
||||
### Django Async (Django 5.0+)
|
||||
|
||||
```
|
||||
Django supports async:
|
||||
├── Async views
|
||||
├── Async middleware
|
||||
├── Async ORM (limited)
|
||||
└── ASGI deployment
|
||||
|
||||
When to use async in Django:
|
||||
├── External API calls
|
||||
├── WebSocket (Channels)
|
||||
├── High-concurrency views
|
||||
└── Background task triggering
|
||||
```
|
||||
|
||||
### Django Best Practices
|
||||
|
||||
```
|
||||
Model design:
|
||||
├── Fat models, thin views
|
||||
├── Use managers for common queries
|
||||
├── Abstract base classes for shared fields
|
||||
|
||||
Views:
|
||||
├── Class-based for complex CRUD
|
||||
├── Function-based for simple endpoints
|
||||
├── Use viewsets with DRF
|
||||
|
||||
Queries:
|
||||
├── select_related() for FKs
|
||||
├── prefetch_related() for M2M
|
||||
├── Avoid N+1 queries
|
||||
└── Use .only() for specific fields
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. FastAPI Principles
|
||||
|
||||
### async def vs def in FastAPI
|
||||
|
||||
```
|
||||
Use async def when:
|
||||
├── Using async database drivers
|
||||
├── Making async HTTP calls
|
||||
├── I/O-bound operations
|
||||
└── Want to handle concurrency
|
||||
|
||||
Use def when:
|
||||
├── Blocking operations
|
||||
├── Sync database drivers
|
||||
├── CPU-bound work
|
||||
└── FastAPI runs in threadpool automatically
|
||||
```
|
||||
|
||||
### Dependency Injection
|
||||
|
||||
```
|
||||
Use dependencies for:
|
||||
├── Database sessions
|
||||
├── Current user / Auth
|
||||
├── Configuration
|
||||
├── Shared resources
|
||||
|
||||
Benefits:
|
||||
├── Testability (mock dependencies)
|
||||
├── Clean separation
|
||||
├── Automatic cleanup (yield)
|
||||
```
|
||||
|
||||
### Pydantic v2 Integration
|
||||
|
||||
```python
|
||||
# FastAPI + Pydantic are tightly integrated:
|
||||
|
||||
# Request validation
|
||||
@app.post("/users")
|
||||
async def create(user: UserCreate) -> UserResponse:
|
||||
# user is already validated
|
||||
...
|
||||
|
||||
# Response serialization
|
||||
# Return type becomes response schema
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Background Tasks
|
||||
|
||||
### Selection Guide
|
||||
|
||||
| Solution | Best For |
|
||||
|----------|----------|
|
||||
| **BackgroundTasks** | Simple, in-process tasks |
|
||||
| **Celery** | Distributed, complex workflows |
|
||||
| **ARQ** | Async, Redis-based |
|
||||
| **RQ** | Simple Redis queue |
|
||||
| **Dramatiq** | Actor-based, simpler than Celery |
|
||||
|
||||
### When to Use Each
|
||||
|
||||
```
|
||||
FastAPI BackgroundTasks:
|
||||
├── Quick operations
|
||||
├── No persistence needed
|
||||
├── Fire-and-forget
|
||||
└── Same process
|
||||
|
||||
Celery/ARQ:
|
||||
├── Long-running tasks
|
||||
├── Need retry logic
|
||||
├── Distributed workers
|
||||
├── Persistent queue
|
||||
└── Complex workflows
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Error Handling Principles
|
||||
|
||||
### Exception Strategy
|
||||
|
||||
```
|
||||
In FastAPI:
|
||||
├── Create custom exception classes
|
||||
├── Register exception handlers
|
||||
├── Return consistent error format
|
||||
└── Log without exposing internals
|
||||
|
||||
Pattern:
|
||||
├── Raise domain exceptions in services
|
||||
├── Catch and transform in handlers
|
||||
└── Client gets clean error response
|
||||
```
|
||||
|
||||
### Error Response Philosophy
|
||||
|
||||
```
|
||||
Include:
|
||||
├── Error code (programmatic)
|
||||
├── Message (human readable)
|
||||
├── Details (field-level when applicable)
|
||||
└── NOT stack traces (security)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Testing Principles
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
| Type | Purpose | Tools |
|
||||
|------|---------|-------|
|
||||
| **Unit** | Business logic | pytest |
|
||||
| **Integration** | API endpoints | pytest + httpx/TestClient |
|
||||
| **E2E** | Full workflows | pytest + DB |
|
||||
|
||||
### Async Testing
|
||||
|
||||
```python
|
||||
# Use pytest-asyncio for async tests
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_endpoint():
|
||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
||||
response = await client.get("/users")
|
||||
assert response.status_code == 200
|
||||
```
|
||||
|
||||
### Fixtures Strategy
|
||||
|
||||
```
|
||||
Common fixtures:
|
||||
├── db_session → Database connection
|
||||
├── client → Test client
|
||||
├── authenticated_user → User with token
|
||||
└── sample_data → Test data setup
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Decision Checklist
|
||||
|
||||
Before implementing:
|
||||
|
||||
- [ ] **Asked user about framework preference?**
|
||||
- [ ] **Chosen framework for THIS context?** (not just default)
|
||||
- [ ] **Decided async vs sync?**
|
||||
- [ ] **Planned type hint strategy?**
|
||||
- [ ] **Defined project structure?**
|
||||
- [ ] **Planned error handling?**
|
||||
- [ ] **Considered background tasks?**
|
||||
|
||||
---
|
||||
|
||||
## 11. Anti-Patterns to Avoid
|
||||
|
||||
### ❌ DON'T:
|
||||
- Default to Django for simple APIs (FastAPI may be better)
|
||||
- Use sync libraries in async code
|
||||
- Skip type hints for public APIs
|
||||
- Put business logic in routes/views
|
||||
- Ignore N+1 queries
|
||||
- Mix async and sync carelessly
|
||||
|
||||
### ✅ DO:
|
||||
- Choose framework based on context
|
||||
- Ask about async requirements
|
||||
- Use Pydantic for validation
|
||||
- Separate concerns (routes → services → repos)
|
||||
- Test critical paths
|
||||
|
||||
---
|
||||
|
||||
> **Remember**: Python patterns are about decision-making for YOUR specific context. Don't copy code—think about what serves your application best.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "python-patterns",
|
||||
"description": "Python development principles and decision-making. Framework selection, async patterns, type hints, project structure. Teaches thinking, not copying.",
|
||||
"category": "development",
|
||||
"canonical_category": "development",
|
||||
"repository": "majiayu000/claude-skill-registry-data",
|
||||
"repository_url": "https://github.com/majiayu000/claude-skill-registry-data",
|
||||
"author": "majiayu000",
|
||||
"author_avatar": "https://github.com/majiayu000.png",
|
||||
"file_path": "data/antigravity-python-patterns/SKILL.md",
|
||||
"source": "github_curated_repos",
|
||||
"stars": 2,
|
||||
"quality_score": 64,
|
||||
"best_practices_score": 60,
|
||||
"skill_level": 3,
|
||||
"skill_level_label": "resources",
|
||||
"has_scripts": true,
|
||||
"has_extra_files": false,
|
||||
"downloads": 0,
|
||||
"content_hash": "4dd862e5d25189c938320851ba3a0fcc6155f88b67f3822aaa4d0fac7e2d4659",
|
||||
"indexed_at": "2026-03-01T03:09:48.327Z",
|
||||
"synced_at": "2026-03-01T06:21:10.361Z",
|
||||
"omni_registry_url": "https://omni-skill-registry.omniroute.online/#/skill/1d2fb797167c0af9a8694048782c1c3aab4b64b5b33cd055e1e7b702ede3eeb5",
|
||||
"install_command": "mkdir -p .claude/skills/python-patterns && curl -sL \"https://raw.githubusercontent.com/majiayu000/claude-skill-registry-data/main/data/antigravity-python-patterns/SKILL.md\" > .claude/skills/python-patterns/SKILL.md",
|
||||
"raw_url": "https://raw.githubusercontent.com/majiayu000/claude-skill-registry-data/main/data/antigravity-python-patterns/SKILL.md"
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
---
|
||||
name: rank-tracker
|
||||
description: 'Track keyword rankings and SERP feature changes in traditional search and AI responses over time. 排名追踪/SERP监控'
|
||||
version: "6.0.0"
|
||||
license: Apache-2.0
|
||||
compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
|
||||
homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
|
||||
when_to_use: "Use when tracking keyword rankings, monitoring position changes, comparing ranking snapshots, or detecting ranking drops."
|
||||
argument-hint: "<domain> [keyword list]"
|
||||
metadata:
|
||||
author: aaron-he-zhu
|
||||
version: "6.0.0"
|
||||
geo-relevance: "medium"
|
||||
tags:
|
||||
- seo
|
||||
- geo
|
||||
- rank-tracking
|
||||
- keyword-rankings
|
||||
- serp-positions
|
||||
- ranking-changes
|
||||
- position-tracking
|
||||
- 排名追踪
|
||||
- ランキング追跡
|
||||
- 순위추적
|
||||
- seguimiento-rankings
|
||||
triggers:
|
||||
# EN-formal
|
||||
- "track rankings"
|
||||
- "check keyword positions"
|
||||
- "ranking changes"
|
||||
- "monitor SERP positions"
|
||||
- "keyword tracking"
|
||||
- "position monitoring"
|
||||
# EN-casual
|
||||
- "how am I ranking"
|
||||
- "where do I rank for this keyword"
|
||||
- "did my rankings change"
|
||||
- "where do I rank now"
|
||||
- "check my positions"
|
||||
# EN-question
|
||||
- "what position am I ranking at"
|
||||
- "how are my rankings doing"
|
||||
# ZH-pro
|
||||
- "排名追踪"
|
||||
- "关键词排名"
|
||||
- "SERP位置监控"
|
||||
- "排名变化"
|
||||
# ZH-casual
|
||||
- "查排名"
|
||||
- "排名变了吗"
|
||||
- "我排第几"
|
||||
# JA
|
||||
- "ランキング追跡"
|
||||
- "検索順位チェック"
|
||||
- "順位変動"
|
||||
- "キーワード順位確認"
|
||||
# KO
|
||||
- "순위 추적"
|
||||
- "키워드 순위"
|
||||
- "순위 확인"
|
||||
- "내 순위 어떻게 됐어?"
|
||||
# ES
|
||||
- "seguimiento de rankings"
|
||||
- "posición en buscadores"
|
||||
- "posicionamiento SEO"
|
||||
- "en qué posición estoy"
|
||||
# PT
|
||||
- "rastreamento de rankings"
|
||||
- "monitoramento de posições"
|
||||
- "posição no Google"
|
||||
# Misspellings
|
||||
- "rank trackng"
|
||||
---
|
||||
|
||||
# Rank Tracker
|
||||
|
||||
|
||||
> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
|
||||
> **System Mode**: This monitoring skill follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
|
||||
|
||||
|
||||
Tracks, analyzes, and reports on keyword ranking positions over time. Monitors both traditional SERP rankings and AI/GEO visibility to provide comprehensive search performance insights.
|
||||
|
||||
**System role**: Monitoring layer skill. It turns performance changes into deltas, alerts, and next actions.
|
||||
|
||||
## When This Must Trigger
|
||||
|
||||
Use this when the conversation involves any of these situations — even if the user does not use SEO terminology:
|
||||
|
||||
Use this whenever the task needs time-aware change detection, escalation, or stakeholder-ready visibility.
|
||||
|
||||
- Setting up ranking tracking for new campaigns
|
||||
- Monitoring keyword position changes
|
||||
- Analyzing ranking trends over time
|
||||
- Comparing rankings against competitors
|
||||
- Tracking SERP feature appearances
|
||||
- Monitoring AI Overview inclusions
|
||||
- Creating ranking reports for stakeholders
|
||||
|
||||
## What This Skill Does
|
||||
|
||||
1. **Position Tracking**: Records and tracks keyword rankings
|
||||
2. **Trend Analysis**: Identifies ranking patterns over time
|
||||
3. **Movement Detection**: Flags significant position changes
|
||||
4. **Competitor Comparison**: Benchmarks against competitors
|
||||
5. **SERP Feature Tracking**: Monitors featured snippets, PAA
|
||||
6. **GEO Visibility Tracking**: Tracks AI citation appearances
|
||||
7. **Report Generation**: Creates ranking performance reports
|
||||
|
||||
## Quick Start
|
||||
|
||||
Start with one of these prompts. Finish with a short handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
|
||||
|
||||
### Set Up Tracking
|
||||
|
||||
```
|
||||
Set up rank tracking for [domain] targeting these keywords: [keyword list]
|
||||
```
|
||||
|
||||
### Analyze Rankings
|
||||
|
||||
```
|
||||
Analyze ranking changes for [domain] over the past [time period]
|
||||
```
|
||||
|
||||
### Compare to Competitors
|
||||
|
||||
```
|
||||
Compare my rankings to [competitor] for [keywords]
|
||||
```
|
||||
|
||||
### Generate Reports
|
||||
|
||||
```
|
||||
Create a ranking report for [domain/campaign]
|
||||
```
|
||||
|
||||
## Skill Contract
|
||||
|
||||
**Expected output**: a delta summary, alert/report output, and a short handoff summary ready for `memory/monitoring/`.
|
||||
|
||||
- **Reads**: current metrics, previous baselines, alert thresholds, and reporting context from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
|
||||
- **Writes**: a user-facing monitoring deliverable plus a reusable summary that can be stored under `memory/monitoring/`.
|
||||
- **Promotes**: significant changes, confirmed anomalies, and follow-up actions to `memory/open-loops.md` and `memory/decisions.md`.
|
||||
- **Next handoff**: use the `Next Best Skill` below when a change needs action.
|
||||
|
||||
## Data Sources
|
||||
|
||||
> **Note:** All integrations are optional. This skill works without any API keys — users provide data manually when no tools are connected.
|
||||
|
||||
> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
|
||||
|
||||
**With ~~SEO tool + ~~search console + ~~analytics + ~~AI monitor connected:**
|
||||
Automatically pull ranking positions from ~~SEO tool, search impressions/clicks from ~~search console, traffic data from ~~analytics, and AI Overview citation tracking from ~~AI monitor. Daily automated rank checks with historical trend data.
|
||||
|
||||
**With manual data only:**
|
||||
Ask the user to provide:
|
||||
1. Keyword ranking positions (current and historical if available)
|
||||
2. Target keyword list with search volumes
|
||||
3. Competitor domains and their ranking positions for key terms
|
||||
4. SERP feature status (featured snippets, PAA appearances)
|
||||
5. AI Overview citation data (if tracking GEO metrics)
|
||||
|
||||
Proceed with the full analysis using provided data. Note in the output which metrics are from automated collection vs. user-provided data.
|
||||
|
||||
## Instructions
|
||||
|
||||
When a user requests rank tracking or analysis:
|
||||
|
||||
1. **Set Up Keyword Tracking** -- Configure domain, location, device, language, update frequency. Add keywords with volume, current rank, type, and priority. Set up competitor tracking and keyword categories (brand/product/informational/commercial).
|
||||
|
||||
2. **Record Current Rankings** -- Ranking overview by position range (#1, #2-3, #4-10, #11-20, etc.), position distribution visualization, detailed rankings with URL, SERP features, and change.
|
||||
|
||||
3. **Analyze Ranking Changes** -- Overall movement metrics, biggest improvements and declines with hypothesized causes, recommended recovery actions, stable keywords, new rankings, lost rankings.
|
||||
|
||||
4. **Track SERP Features** -- Feature ownership comparison vs competitors (snippets, PAA, image/video pack, local pack), featured snippet status, PAA appearances.
|
||||
|
||||
5. **Track GEO/AI Visibility** -- AI Overview presence per keyword, citation rate and position, GEO performance trend over time, improvement opportunities.
|
||||
|
||||
6. **Compare Against Competitors** -- Share of voice table, head-to-head comparison per keyword, competitor movement alerts with threat level.
|
||||
|
||||
7. **Generate Ranking Report** -- Executive summary with overall trend, position distribution, key highlights (wins/concerns/opportunities), detailed analysis, SERP feature report, GEO visibility, competitive position, recommendations.
|
||||
|
||||
> **Reference**: See [references/ranking-analysis-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/rank-tracker/references/ranking-analysis-templates.md) for complete output templates for all 7 steps.
|
||||
|
||||
## Validation Checkpoints
|
||||
|
||||
### Input Validation
|
||||
- [ ] Keywords list is complete with search volumes
|
||||
- [ ] Target domain and tracking location are specified
|
||||
- [ ] Competitor domains identified for comparison
|
||||
- [ ] Historical baseline data available or initial tracking period set
|
||||
|
||||
### Output Validation
|
||||
- [ ] Every metric cites its data source and collection date
|
||||
- [ ] Ranking changes include context (vs. previous period)
|
||||
- [ ] Significant movements have explanations or investigation notes
|
||||
- [ ] Source of each data point clearly stated (~~SEO tool data, ~~search console data, user-provided, or estimated)
|
||||
|
||||
## Example
|
||||
|
||||
**User**: "Analyze my ranking changes for the past month"
|
||||
|
||||
**Output**:
|
||||
|
||||
```markdown
|
||||
# Ranking Analysis: [current month, year]
|
||||
|
||||
## Summary
|
||||
|
||||
Your average position improved from 15.3 to 12.8 (-2.5 positions = better)
|
||||
Keywords in top 10 increased from 12 to 17 (+5)
|
||||
|
||||
## Biggest Wins
|
||||
|
||||
| Keyword | Old | New | Change | Possible Cause |
|
||||
|---------|-----|-----|--------|----------------|
|
||||
| email marketing tips | 18 | 5 | +13 | Likely driven by content refresh |
|
||||
| best crm software | 24 | 11 | +13 | Correlates with new backlinks acquired |
|
||||
| sales automation | 15 | 7 | +8 | Correlates with schema markup addition |
|
||||
|
||||
## Needs Attention
|
||||
|
||||
| Keyword | Old | New | Change | Action |
|
||||
|---------|-----|-----|--------|--------|
|
||||
| marketing automation | 4 | 12 | -8 | Likely displaced by new HubSpot guide |
|
||||
|
||||
**Recommended**: Update your marketing automation guide with [current year] statistics and examples.
|
||||
```
|
||||
|
||||
## Tips for Success
|
||||
|
||||
1. **Track consistently** - Same time, same device, same location
|
||||
2. **Include enough keywords** - 50-200 for meaningful data
|
||||
3. **Segment by intent** - Track brand, commercial, informational separately
|
||||
4. **Monitor competitors** - Context makes your data meaningful
|
||||
5. **Track SERP features** - Position 1 without snippet may lose to position 4 with snippet
|
||||
6. **Include GEO metrics** - AI visibility increasingly important
|
||||
|
||||
## Rank Change Quick Reference
|
||||
|
||||
### Response Protocol
|
||||
|
||||
| Change | Timeframe | Action |
|
||||
|--------|-----------|--------|
|
||||
| Drop 1-3 positions | Wait 1-2 weeks | Monitor -- may be normal fluctuation |
|
||||
| Drop 3-5 positions | Investigate within 1 week | Check for technical issues, competitor changes |
|
||||
| Drop 5-10 positions | Investigate immediately | Full diagnostic: technical, content, links |
|
||||
| Drop off page 1 | Emergency response | Comprehensive audit + recovery plan |
|
||||
| Position gained | Document and learn | What worked? Can you replicate? |
|
||||
|
||||
> **Reference**: See [references/tracking-setup-guide.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/rank-tracker/references/tracking-setup-guide.md) for root cause taxonomy, CTR benchmarks by position, SERP feature CTR impact, algorithm update assessment, tracking configuration best practices, keyword selection and grouping strategies, and data interpretation guidelines.
|
||||
|
||||
|
||||
### Save Results
|
||||
|
||||
After delivering monitoring data or reports to the user, ask:
|
||||
|
||||
> "Save these results for future sessions?"
|
||||
|
||||
If yes, write a dated summary to `memory/monitoring/YYYY-MM-DD-<topic>.md` containing:
|
||||
- One-line headline finding or status change
|
||||
- Top 3-5 actionable items
|
||||
- Open loops or anomalies requiring follow-up
|
||||
- Source data references
|
||||
|
||||
If any findings should influence ongoing strategy, recommend promoting key conclusions to `memory/hot-cache.md`.
|
||||
|
||||
## Reference Materials
|
||||
|
||||
- [Tracking Setup Guide](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/rank-tracker/references/tracking-setup-guide.md) — Configuration best practices, device/location settings, and SERP feature tracking setup
|
||||
|
||||
## Next Best Skill
|
||||
|
||||
- **Primary**: [alert-manager](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/monitor/alert-manager/SKILL.md) — operationalize rank changes into thresholds and follow-ups.
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
name: review-animations
|
||||
description: Reviews animation and motion code against a high craft bar derived from Emil Kowalski's design engineering philosophy. Default to flagging; approval is earned.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Reviewing Animations
|
||||
|
||||
A specialized review skill. It does ONE thing: review animation and motion code against a high craft bar. It does not write features, fix unrelated bugs, or review non-motion code. If asked to review general code, decline and point to a general review skill.
|
||||
|
||||
## Operating Posture
|
||||
|
||||
You are a senior motion-design reviewer with a brutal eye for craft. Your bias is toward **motion that feels right**, not motion that merely runs. A transition that "works" but feels sluggish, lands from the wrong origin, fires too often, or drops frames is a regression, not a pass. Default to flagging. Approval is earned, not assumed.
|
||||
|
||||
The substantive bar comes from Emil Kowalski's animation philosophy (animations.dev). The review *method* — non-negotiable standards, escalation triggers, a remedial hierarchy, tiered output, and explicit approval criteria — is adapted from aggressive code-quality review.
|
||||
|
||||
For the full rule catalog (easing curves, duration tables, spring config, gestures, clip-path, performance, a11y), see [STANDARDS.md](STANDARDS.md). Load it whenever a finding needs a precise value or citation.
|
||||
|
||||
## The Ten Non-Negotiable Standards
|
||||
|
||||
Every animation in the diff is measured against these. A violation is a finding.
|
||||
|
||||
1. **Justified motion.** Every animation must answer "why does this animate?" — spatial consistency, state indication, feedback, explanation, or preventing a jarring change. "It looks cool" on a frequently-seen element is a block.
|
||||
|
||||
2. **Frequency-appropriate.** Match motion to how often it's seen. Keyboard-initiated and 100+/day actions get **no** animation. Tens/day gets reduced motion. Occasional gets standard. Rare/first-time can have delight.
|
||||
|
||||
3. **Responsive easing.** Entering/exiting elements use `ease-out` or a strong custom curve. `ease-in` on UI is a block — it delays the moment the user watches most. Built-in CSS easings are too weak; expect custom cubic-beziers.
|
||||
|
||||
4. **Sub-300ms UI.** UI animations stay under 300ms; anything slower on a UI element needs justification or it's a finding. Per-element budgets live in [STANDARDS.md](STANDARDS.md).
|
||||
|
||||
5. **Origin & physical correctness.** Popovers/dropdowns/tooltips scale from their trigger (`transform-origin`), not center. Never animate from `scale(0)` — start from `scale(0.9–0.97)` + opacity (Modals are exempt — they stay centered.)
|
||||
|
||||
6. **Interruptibility.** Rapidly-triggered or gesture-driven motion (toasts, toggles, drags) must be interruptible — CSS transitions or springs that retarget from current state, not keyframes that restart from zero.
|
||||
|
||||
7. **GPU-only properties.** Animate `transform` and `opacity` only. Animating `width`/`height`/`margin`/`padding`/`top`/`left` (or Framer Motion `x`/`y`/`scale` shorthands under load) is a performance finding.
|
||||
|
||||
8. **Accessibility.** `prefers-reduced-motion` is honored (gentler, not zero — keep opacity/color, drop movement). Hover animations are gated behind `@media (hover: hover) and (pointer: fine)`.
|
||||
|
||||
9. **Asymmetric enter/exit.** Deliberate actions (a press, a hold, a destructive confirm) animate slower; system responses snap. Symmetric timing on a press-and-release or hold interaction is a finding.
|
||||
|
||||
10. **Cohesion.** Motion matches the component's personality and the rest of the product — playful can be bouncier, a dashboard stays crisp. Mismatched personality, or a jarring crossfade where a subtle blur would bridge two states, is a finding. When unsure whether motion feels right, the strongest move is often to delete it.
|
||||
|
||||
## Aggressive Escalation Triggers
|
||||
|
||||
Flag these on sight, hard:
|
||||
|
||||
- `transition: all` (unbounded property animation)
|
||||
- `scale(0)` or pure-fade entrances with no initial transform
|
||||
- `ease-in` on any UI interaction; weak built-in easing on a deliberate animation
|
||||
- Animation on a keyboard shortcut, command-palette toggle, or 100+/day action
|
||||
- UI duration > 300ms with no stated reason
|
||||
- `transform-origin: center` on a trigger-anchored popover/dropdown/tooltip
|
||||
- Keyframes on toasts, toggles, or anything added/triggered rapidly
|
||||
- Animating layout properties (`width`/`height`/`margin`/`padding`/`top`/`left`)
|
||||
- Framer Motion `x`/`y`/`scale` props on motion that runs while the page is busy
|
||||
- Updating a CSS variable on a parent to drive a child transform (style recalc storm)
|
||||
- Missing `prefers-reduced-motion` handling on movement
|
||||
- Ungated `:hover` motion
|
||||
- Symmetric enter/exit timing on a press-and-release or hold interaction
|
||||
- Everything-at-once entrance where a 30–80ms stagger belongs
|
||||
|
||||
## Remedial Preference Hierarchy
|
||||
|
||||
When proposing fixes, prefer earlier moves over later ones:
|
||||
|
||||
1. **Delete the animation** (high-frequency / no purpose / keyboard-triggered).
|
||||
2. **Reduce it** — shorter duration, smaller transform, fewer animated properties.
|
||||
3. **Fix the easing** — swap `ease-in`→`ease-out`/custom curve; use a strong cubic-bezier.
|
||||
4. **Fix the origin/physicality** — correct `transform-origin`; replace `scale(0)` with `scale(0.95)`+opacity.
|
||||
5. **Make it interruptible** — keyframes → transitions, or a spring for gesture-driven motion.
|
||||
6. **Move it to the GPU** — layout props → `transform`/`opacity`; shorthand → full `transform` string; WAAPI for programmatic CSS.
|
||||
7. **Asymmetric timing** — slow the deliberate phase, snap the response.
|
||||
8. **Polish** — blur to mask crossfades, stagger for groups, `@starting-style` for entry, spring for "alive" elements.
|
||||
9. **Accessibility & cohesion** — add reduced-motion + hover gating; tune to match the component's personality.
|
||||
|
||||
## Required Output Format
|
||||
|
||||
Two parts, in this order.
|
||||
|
||||
### Part 1 — Findings table (REQUIRED)
|
||||
|
||||
A single markdown table. One row per issue. Never a "Before:/After:" list.
|
||||
|
||||
| Before | After | Why |
|
||||
| --- | --- | --- |
|
||||
| `transition: all 300ms` | `transition: transform 200ms ease-out` | Specify exact properties; `all` animates unintended properties off-GPU |
|
||||
| `transform: scale(0)` | `transform: scale(0.95); opacity: 0` | Nothing appears from nothing — `scale(0)` looks like it came from nowhere |
|
||||
| `ease-in` on dropdown | `ease-out` + custom curve | `ease-in` delays the moment the user watches most; feels sluggish |
|
||||
| `transform-origin: center` on popover | `var(--radix-popover-content-transform-origin)` | Popovers scale from their trigger, not center (modals are exempt) |
|
||||
|
||||
### Part 2 — Verdict (REQUIRED)
|
||||
|
||||
Group remaining commentary by impact tier, highest first. Omit empty tiers.
|
||||
|
||||
1. **Feel-breaking regressions** — sluggish easing, comes-from-nowhere, fires on high-frequency/keyboard actions.
|
||||
2. **Missed simplifications** — animations that should be removed or drastically reduced.
|
||||
3. **Performance** — non-GPU properties, dropped-frame risks, recalc storms.
|
||||
4. **Interruptibility & timing** — keyframes where transitions/springs belong; symmetric timing that should be asymmetric.
|
||||
5. **Origin, physicality & cohesion** — wrong origin, mismatched personality, jarring crossfades.
|
||||
6. **Accessibility** — reduced-motion and pointer/hover gating.
|
||||
|
||||
Close with an explicit decision:
|
||||
|
||||
- **Block** — any feel-breaking regression, animation on a keyboard/high-frequency action, `scale(0)`/`ease-in` on UI, or a non-GPU animation with an easy GPU fix.
|
||||
- **Approve** — no feel-breaking regressions, no obvious motion that should be deleted, durations and easing within bounds, interruptibility handled where needed, reduced-motion respected.
|
||||
|
||||
Be specific and cite `file:line`. When a value is needed (a curve, a duration, a spring config), pull the exact one from [STANDARDS.md](STANDARDS.md) rather than approximating.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Prefer CSS transitions/`@starting-style`/WAAPI for predetermined motion; JS/springs for dynamic, interruptible, gesture-driven motion.
|
||||
- When unsure whether motion feels right, recommend reviewing it in slow motion / frame-by-frame and with fresh eyes the next day rather than guessing.
|
||||
@@ -0,0 +1,188 @@
|
||||
# Animation Standards Reference
|
||||
|
||||
The precise values, curves, and rules behind the review. Cite these in findings instead of approximating. Distilled from Emil Kowalski's design engineering philosophy ([animations.dev](https://animations.dev/)).
|
||||
|
||||
## Should it animate? (frequency table)
|
||||
|
||||
| Frequency | Decision |
|
||||
| --- | --- |
|
||||
| 100+ times/day (keyboard shortcuts, command palette toggle) | No animation. Ever. |
|
||||
| Tens of times/day (hover effects, list navigation) | Remove or drastically reduce |
|
||||
| Occasional (modals, drawers, toasts) | Standard animation |
|
||||
| Rare / first-time (onboarding, feedback, celebrations) | Can add delight |
|
||||
|
||||
**Never animate keyboard-initiated actions** — they repeat hundreds of times daily; animation makes them feel slow and disconnected. (Raycast has no open/close animation — correct for something used hundreds of times a day.)
|
||||
|
||||
Valid purposes for motion: spatial consistency, state indication, explanation, feedback, preventing jarring change. "It looks cool" on a frequently-seen element is not valid.
|
||||
|
||||
## Easing
|
||||
|
||||
Decision order:
|
||||
- Entering or exiting → **`ease-out`** (starts fast, feels responsive)
|
||||
- Moving / morphing on screen → **`ease-in-out`**
|
||||
- Hover / color change → **`ease`**
|
||||
- Constant motion (marquee, progress) → **`linear`**
|
||||
- Default → **`ease-out`**
|
||||
|
||||
**Never `ease-in` on UI.** It starts slow, delaying the exact moment the user is watching. `ease-out` at 200ms *feels* faster than `ease-in` at 200ms.
|
||||
|
||||
Built-in CSS easings are too weak. Use strong custom curves:
|
||||
|
||||
```css
|
||||
--ease-out: cubic-bezier(0.23, 1, 0.32, 1); /* strong ease-out for UI */
|
||||
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); /* strong ease-in-out for on-screen movement */
|
||||
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); /* iOS-like drawer curve (Ionic) */
|
||||
```
|
||||
|
||||
Find curves at [easing.dev](https://easing.dev/) or [easings.co](https://easings.co/) — don't hand-roll from scratch.
|
||||
|
||||
## Duration
|
||||
|
||||
| Element | Duration |
|
||||
| --- | --- |
|
||||
| Button press feedback | 100–160ms |
|
||||
| Tooltips, small popovers | 125–200ms |
|
||||
| Dropdowns, selects | 150–250ms |
|
||||
| Modals, drawers | 200–500ms |
|
||||
| Marketing / explanatory | Can be longer |
|
||||
|
||||
**Rule: UI animations stay under 300ms.** A 180ms dropdown feels more responsive than a 400ms one. Faster spinners make load feel faster (same actual time). Instant tooltips after the first (skip delay + animation) make a toolbar feel faster.
|
||||
|
||||
## Physicality
|
||||
|
||||
- **Never `scale(0)`.** Start from `scale(0.9–0.97)` + `opacity: 0`. Nothing in the real world appears from nothing.
|
||||
- **Origin-aware popovers.** Scale from the trigger, not center:
|
||||
```css
|
||||
.popover { transform-origin: var(--radix-popover-content-transform-origin); } /* Radix */
|
||||
.popover { transform-origin: var(--transform-origin); } /* Base UI */
|
||||
```
|
||||
**Modals are exempt** — they appear centered in the viewport, keep `transform-origin: center`.
|
||||
- **Button press feedback.** `transform: scale(0.97)` on `:active`, `transition: transform 160ms ease-out`. Subtle (0.95–0.98). Applies to any pressable element.
|
||||
|
||||
## Springs
|
||||
|
||||
Feel natural because they simulate physics; no fixed duration — they settle on parameters. Use for: drag with momentum, "alive" elements (Dynamic Island), interruptible gestures, decorative mouse-tracking.
|
||||
|
||||
```js
|
||||
// Apple-style (easier to reason about) — recommended
|
||||
{ type: "spring", duration: 0.5, bounce: 0.2 }
|
||||
|
||||
// Traditional physics (more control)
|
||||
{ type: "spring", mass: 1, stiffness: 100, damping: 10 }
|
||||
```
|
||||
|
||||
Keep bounce subtle (0.1–0.3); avoid bounce in most UI — reserve for drag-to-dismiss and playful interactions. Springs maintain velocity when interrupted (keyframes restart from zero), so they're ideal for gestures users may reverse mid-motion.
|
||||
|
||||
Mouse interactions: interpolate with `useSpring` rather than tying value directly to mouse position (direct = artificial, no momentum). Only do this when the motion is decorative.
|
||||
|
||||
## Interruptibility
|
||||
|
||||
CSS **transitions** can be interrupted and retargeted mid-animation; **keyframes** restart from zero. For anything triggered rapidly (toasts being added, toggles), transitions are smoother.
|
||||
|
||||
```css
|
||||
/* Interruptible — good for dynamic UI */
|
||||
.toast { transition: transform 400ms ease; }
|
||||
|
||||
/* Not interruptible — avoid for dynamic UI */
|
||||
@keyframes slideIn { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
||||
```
|
||||
|
||||
Use `@starting-style` for entry without JS:
|
||||
|
||||
```css
|
||||
.toast {
|
||||
opacity: 1; transform: translateY(0);
|
||||
transition: opacity 400ms ease, transform 400ms ease;
|
||||
@starting-style { opacity: 0; transform: translateY(100%); }
|
||||
}
|
||||
```
|
||||
|
||||
Legacy fallback: `useEffect(() => setMounted(true), [])` + `data-mounted` attribute.
|
||||
|
||||
## Asymmetric timing
|
||||
|
||||
Slow where the user is deciding, fast where the system responds.
|
||||
|
||||
```css
|
||||
.overlay { transition: clip-path 200ms ease-out; } /* release: fast */
|
||||
.button:active .overlay { transition: clip-path 2s linear; } /* press: slow, deliberate */
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
- **Only animate `transform` and `opacity`** — they skip layout/paint and run on the GPU. `padding`/`margin`/`height`/`width`/`top`/`left` trigger all three rendering steps.
|
||||
- **Don't drive child transforms via a CSS variable on the parent** — it recalcs styles for all children. Set `transform` directly on the element.
|
||||
```js
|
||||
element.style.setProperty('--swipe-amount', `${d}px`); // bad: recalc on all children
|
||||
element.style.transform = `translateY(${d}px)`; // good: only this element
|
||||
```
|
||||
- **Framer Motion shorthands are NOT hardware-accelerated.** `x`/`y`/`scale` run on the main thread via rAF and drop frames under load. Use the full transform string:
|
||||
```jsx
|
||||
<motion.div animate={{ x: 100 }} /> // drops frames under load
|
||||
<motion.div animate={{ transform: "translateX(100px)" }} /> // hardware accelerated
|
||||
```
|
||||
- **CSS animations beat JS under load** — they run off the main thread; rAF-based animations stutter while the browser loads/scripts/paints. Use CSS for predetermined motion, JS for dynamic/interruptible.
|
||||
- **WAAPI** gives JS control with CSS performance (hardware-accelerated, interruptible, no library):
|
||||
```js
|
||||
element.animate([{ clipPath: 'inset(0 0 100% 0)' }, { clipPath: 'inset(0 0 0 0)' }],
|
||||
{ duration: 1000, fill: 'forwards', easing: 'cubic-bezier(0.77, 0, 0.175, 1)' });
|
||||
```
|
||||
|
||||
## Transforms & clip-path
|
||||
|
||||
- **`translate` percentages** are relative to the element's own size — `translateY(100%)` moves by the element's height regardless of dimensions (how Sonner/Vaul position toasts/drawers). Prefer over hardcoded px.
|
||||
- **`scale()` scales children too** (font, icons, content) — a feature for press feedback.
|
||||
- **3D**: `rotateX/Y` + `transform-style: preserve-3d` for depth/orbit/flip without JS.
|
||||
- **`clip-path: inset(t r b l)`** is a powerful animation tool: each value eats in from that side. Uses: reveal-on-scroll (`inset(0 0 100% 0)` → `inset(0 0 0 0)`), hold-to-delete overlay, seamless tab color transitions (duplicate + clip the active copy), comparison sliders.
|
||||
|
||||
## Gestures & drag
|
||||
|
||||
- **Momentum dismissal**: don't require crossing a distance threshold — compute velocity (`Math.abs(distance)/elapsedMs`); dismiss if `> ~0.11`. A flick should be enough.
|
||||
- **Damping at boundaries**: dragging past a natural edge moves less the further you go (real things slow before stopping).
|
||||
- **Pointer capture** once dragging starts, so it continues when the pointer leaves bounds.
|
||||
- **Multi-touch protection**: ignore extra touch points after the drag begins (`if (isDragging) return`) — prevents jumps.
|
||||
- **Friction over hard stops** — allow over-drag with rising resistance rather than an invisible wall.
|
||||
|
||||
## Masking imperfect crossfades
|
||||
|
||||
When a crossfade shows two overlapping states despite tuning easing/duration, add subtle `filter: blur(2px)` during the transition to blend them into one perceived transformation. Keep blur < 20px (heavy blur is expensive, especially Safari).
|
||||
|
||||
## Stagger
|
||||
|
||||
Stagger group entrances; 30–80ms between items. Longer delays feel slow. Stagger is decorative — never block interaction while it plays.
|
||||
|
||||
```css
|
||||
.item { opacity: 0; transform: translateY(8px); animation: fadeIn 300ms ease-out forwards; }
|
||||
.item:nth-child(2) { animation-delay: 50ms; }
|
||||
.item:nth-child(3) { animation-delay: 100ms; }
|
||||
@keyframes fadeIn { to { opacity: 1; transform: translateY(0); } }
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.element { animation: fade 0.2s ease; } /* keep opacity/color, drop transform-based motion */
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.element:hover { transform: scale(1.05); } /* gate hover motion — touch fires false hovers on tap */
|
||||
}
|
||||
```
|
||||
|
||||
```jsx
|
||||
const reduce = useReducedMotion();
|
||||
const closedX = reduce ? 0 : '-100%';
|
||||
```
|
||||
|
||||
Reduced motion means fewer and gentler animations, not zero — keep transitions that aid comprehension, remove movement/position changes.
|
||||
|
||||
## Debugging (recommend in reviews when feel is uncertain)
|
||||
|
||||
- **Slow motion**: bump duration 2–5× or use DevTools animation inspector. Check colors crossfade cleanly, easing doesn't stop abruptly, `transform-origin` is right, coordinated properties stay in sync.
|
||||
- **Frame-by-frame**: Chrome DevTools Animations panel reveals timing drift between coordinated properties.
|
||||
- **Real devices** for gestures (drawers, swipe) — connect a phone, hit the dev server by IP, use Safari remote devtools.
|
||||
- **Fresh eyes next day** — imperfections invisible during development surface later.
|
||||
|
||||
## Cohesion
|
||||
|
||||
Match motion to the component's personality: playful can be bouncier; a professional dashboard should be crisp and fast. Sonner feels right partly because easing, duration, design, and even the name are in harmony — slightly slower, `ease` rather than `ease-out`, to feel elegant. Opacity + height in entering/exiting lists is trial and error; there's no formula — adjust until it feels right.
|
||||
@@ -0,0 +1,402 @@
|
||||
---
|
||||
name: schema-markup-generator
|
||||
description: 'Generate JSON-LD structured data for FAQ, HowTo, Article, Product, LocalBusiness rich results. Schema标记/结构化数据'
|
||||
version: "6.0.0"
|
||||
license: Apache-2.0
|
||||
compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
|
||||
homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
|
||||
when_to_use: "Use when generating JSON-LD structured data, Schema.org markup, or rich snippet markup for a page."
|
||||
argument-hint: "<page URL or content type>"
|
||||
allowed-tools: WebFetch
|
||||
metadata:
|
||||
author: aaron-he-zhu
|
||||
version: "6.0.0"
|
||||
geo-relevance: "medium"
|
||||
tags:
|
||||
- seo
|
||||
- structured-data
|
||||
- json-ld
|
||||
- rich-results
|
||||
- faq-schema
|
||||
- howto-schema
|
||||
- product-schema
|
||||
- article-schema
|
||||
- schema-org
|
||||
- 结构化数据
|
||||
- 構造化データ
|
||||
- 스키마마크업
|
||||
- datos-estructurados
|
||||
triggers:
|
||||
# EN-formal
|
||||
- "add schema markup"
|
||||
- "generate structured data"
|
||||
- "JSON-LD"
|
||||
- "rich snippets"
|
||||
- "FAQ schema"
|
||||
- "schema.org"
|
||||
- "structured data markup"
|
||||
# EN-casual
|
||||
- "add FAQ rich results"
|
||||
- "I want star ratings in Google"
|
||||
- "product markup"
|
||||
- "recipe schema"
|
||||
- "add structured data to my page"
|
||||
# EN-question
|
||||
- "how to add schema markup"
|
||||
- "how to get rich snippets"
|
||||
# ZH-pro
|
||||
- "结构化数据"
|
||||
- "Schema标记"
|
||||
- "JSON-LD生成"
|
||||
- "富摘要"
|
||||
# ZH-casual
|
||||
- "添加结构化数据"
|
||||
- "要星级评分"
|
||||
- "搜索结果要好看"
|
||||
# JA
|
||||
- "構造化データ"
|
||||
- "スキーママークアップ"
|
||||
- "リッチリザルト"
|
||||
# KO
|
||||
- "스키마 마크업"
|
||||
- "구조화 데이터"
|
||||
# ES
|
||||
- "datos estructurados"
|
||||
- "marcado schema"
|
||||
# PT
|
||||
- "dados estruturados"
|
||||
# Misspellings
|
||||
- "shema markup"
|
||||
- "structred data"
|
||||
---
|
||||
|
||||
# Schema Markup Generator
|
||||
|
||||
> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
|
||||
> **System Mode**: This build skill follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
|
||||
|
||||
|
||||
This skill creates Schema.org structured data markup in JSON-LD format to help search engines understand your content and enable rich results in SERPs.
|
||||
|
||||
**System role**: Build layer skill. It turns briefs and signals into assets that other skills can review, publish, and monitor.
|
||||
|
||||
## When This Must Trigger
|
||||
|
||||
Use this when the conversation involves any of these situations — even if the user does not use SEO terminology:
|
||||
|
||||
Use this whenever the task needs a shippable asset or transformation that should feed directly into quality review, deployment, or monitoring.
|
||||
|
||||
- Adding FAQ schema for expanded SERP presence
|
||||
- Creating How-To schema for step-by-step content
|
||||
- Adding Product schema for e-commerce pages
|
||||
- Implementing Article schema for blog posts
|
||||
- Adding Local Business schema for location pages
|
||||
- Creating Review/Rating schema
|
||||
- Implementing Organization schema for brand presence
|
||||
- Any page where rich results would improve visibility
|
||||
|
||||
## What This Skill Does
|
||||
|
||||
1. **Schema Type Selection**: Recommends appropriate schema types
|
||||
2. **JSON-LD Generation**: Creates valid structured data markup
|
||||
3. **Property Mapping**: Maps your content to schema properties
|
||||
4. **Validation Guidance**: Ensures schema meets requirements
|
||||
5. **Nested Schema**: Handles complex, multi-type schemas
|
||||
6. **Rich Result Eligibility**: Identifies which rich results you can target
|
||||
|
||||
## Quick Start
|
||||
|
||||
Start with one of these prompts. Finish with a short handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
|
||||
|
||||
### Generate Schema for Content
|
||||
|
||||
```
|
||||
Generate schema markup for this [content type]: [content/URL]
|
||||
```
|
||||
|
||||
```
|
||||
Create FAQ schema for these questions and answers: [Q&A list]
|
||||
```
|
||||
|
||||
### Specific Schema Types
|
||||
|
||||
```
|
||||
Create Product schema for [product name] with [details]
|
||||
```
|
||||
|
||||
```
|
||||
Generate LocalBusiness schema for [business name and details]
|
||||
```
|
||||
|
||||
### Audit Existing Schema
|
||||
|
||||
```
|
||||
Review and improve this schema markup: [existing schema]
|
||||
```
|
||||
|
||||
## Skill Contract
|
||||
|
||||
**Expected output**: a ready-to-use asset or implementation-ready transformation plus a short handoff summary ready for `memory/content/`.
|
||||
|
||||
- **Reads**: the brief, target keywords, entity inputs, quality constraints, and prior decisions from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
|
||||
- **Writes**: a user-facing content, metadata, or schema deliverable plus a reusable summary that can be stored under `memory/content/`.
|
||||
- **Promotes**: approved angles, messaging choices, missing evidence, and publish blockers to `CLAUDE.md`, `memory/decisions.md`, and `memory/open-loops.md`.
|
||||
- **Next handoff**: use the `Next Best Skill` below when the asset is ready for review or deployment.
|
||||
|
||||
## Data Sources
|
||||
|
||||
> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
|
||||
|
||||
**With ~~web crawler connected:**
|
||||
Automatically crawl and extract page content (visible text, headings, lists, tables), existing schema markup, page metadata, and structured content elements that map to schema properties.
|
||||
|
||||
**With manual data only:**
|
||||
Ask the user to provide:
|
||||
1. Page URL or full HTML content
|
||||
2. Page type (article, product, FAQ, how-to, local business, etc.)
|
||||
3. Specific data needed for schema (prices, dates, author info, Q&A pairs, etc.)
|
||||
4. Current schema markup (if optimizing existing)
|
||||
|
||||
Proceed with the full workflow using provided data. Note in the output which data is from automated extraction vs. user-provided data.
|
||||
|
||||
## Instructions
|
||||
|
||||
When a user requests schema markup:
|
||||
|
||||
1. **Identify Content Type and Rich Result Opportunity**
|
||||
|
||||
Reference the [CORE-EEAT Benchmark](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/core-eeat-benchmark.md) item **O05 (Schema Markup)** for content-type to schema mapping:
|
||||
|
||||
```markdown
|
||||
### CORE-EEAT Schema Mapping (O05)
|
||||
|
||||
| Content Type | Required Schema | Conditional Schema |
|
||||
|-------------|----------------|--------------------|
|
||||
| Blog (guides) | Article, Breadcrumb | FAQ, HowTo |
|
||||
| Blog (tools) | Article, Breadcrumb | FAQ, Review |
|
||||
| Blog (insights) | Article, Breadcrumb | FAQ |
|
||||
| Alternative | Comparison*, Breadcrumb, FAQ | AggregateRating |
|
||||
| Best-of | ItemList, Breadcrumb, FAQ | AggregateRating per tool |
|
||||
| Use-case | WebPage, Breadcrumb, FAQ | — |
|
||||
| FAQ | FAQPage, Breadcrumb | — |
|
||||
| Landing | SoftwareApplication, Breadcrumb, FAQ | WebPage |
|
||||
| Testimonial | Review, Breadcrumb | FAQ, Person |
|
||||
|
||||
*Use the mapping above to ensure schema type matches content type (CORE-EEAT O05: Pass criteria).*
|
||||
```
|
||||
|
||||
```markdown
|
||||
### Schema Analysis
|
||||
|
||||
**Content Type**: [blog/product/FAQ/how-to/local business/etc.]
|
||||
**Page URL**: [URL]
|
||||
|
||||
**Eligible Rich Results**:
|
||||
|
||||
| Rich Result Type | Eligibility | Impact |
|
||||
|------------------|-------------|--------|
|
||||
| FAQ | ✅/❌ | High - Expands SERP presence |
|
||||
| How-To | ✅/❌ | Medium - Shows steps in SERP |
|
||||
| Product | ✅/❌ | High - Shows price, availability |
|
||||
| Review | ✅/❌ | High - Shows star ratings |
|
||||
| Article | ✅/❌ | Medium - Shows publish date, author |
|
||||
| Breadcrumb | ✅/❌ | Medium - Shows navigation path |
|
||||
| Video | ✅/❌ | High - Shows video thumbnail |
|
||||
|
||||
**Recommended Schema Types**:
|
||||
1. [Primary schema type] - [reason]
|
||||
2. [Secondary schema type] - [reason]
|
||||
```
|
||||
|
||||
2. **Generate Schema Markup**
|
||||
|
||||
Based on the identified content type, generate the appropriate JSON-LD schema. Supported types: FAQPage, HowTo, Article/BlogPosting/NewsArticle, Product, LocalBusiness, Organization, BreadcrumbList, Event, Recipe, and combined multi-type schemas.
|
||||
|
||||
> **Reference**: See [references/schema-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/schema-markup-generator/references/schema-templates.md) for complete, copy-ready JSON-LD templates for all schema types with required and optional properties.
|
||||
|
||||
For each schema generated, include:
|
||||
- All required properties for the chosen type
|
||||
- Rich result preview showing expected SERP appearance
|
||||
- Notes on which properties are required vs. optional
|
||||
|
||||
When combining multiple schema types on one page, wrap them in a JSON array inside a single `<script type="application/ld+json">` tag.
|
||||
|
||||
3. **Provide Implementation and Validation**
|
||||
|
||||
```markdown
|
||||
## Implementation Guide
|
||||
|
||||
### Adding Schema to Your Page
|
||||
|
||||
**Option 1: In HTML <head>**
|
||||
```html
|
||||
<head>
|
||||
<script type="application/ld+json">
|
||||
[Your JSON-LD schema here]
|
||||
</script>
|
||||
</head>
|
||||
```
|
||||
|
||||
**Option 2: Before closing </body>**
|
||||
```html
|
||||
<script type="application/ld+json">
|
||||
[Your JSON-LD schema here]
|
||||
</script>
|
||||
</body>
|
||||
```
|
||||
|
||||
### Validation Steps
|
||||
|
||||
1. **~~schema validator**
|
||||
- Test your live URL or paste code
|
||||
- Check for errors and warnings
|
||||
|
||||
2. **Schema.org Validator**
|
||||
- URL: https://validator.schema.org/
|
||||
- Validates against Schema.org spec
|
||||
|
||||
3. **~~search console**
|
||||
- Monitor rich results in ~~search console
|
||||
- Check Enhancements reports for issues
|
||||
|
||||
### Validation Checklist
|
||||
|
||||
- [ ] JSON syntax is valid (no trailing commas)
|
||||
- [ ] All required properties present
|
||||
- [ ] URLs are absolute, not relative
|
||||
- [ ] Dates are in ISO 8601 format
|
||||
- [ ] Content matches visible page content
|
||||
- [ ] No policy violations
|
||||
```
|
||||
|
||||
## Validation Checkpoints
|
||||
|
||||
### Input Validation
|
||||
- [ ] Page URL or content provided
|
||||
- [ ] Schema type appropriate for content (Article for blog, Product for e-commerce, etc.)
|
||||
- [ ] All required data available (author, dates, prices, etc. depending on schema type)
|
||||
- [ ] Content eligibility for rich results confirmed
|
||||
|
||||
### Output Validation
|
||||
- [ ] JSON syntax validates (no trailing commas, proper quotes)
|
||||
- [ ] All required properties present for chosen schema type
|
||||
- [ ] URLs are absolute, not relative
|
||||
- [ ] Dates in ISO 8601 format (YYYY-MM-DDTHH:MM:SS+00:00)
|
||||
- [ ] Schema content matches visible page content exactly
|
||||
- [ ] Passes ~~schema validator with no errors
|
||||
- [ ] Source of each data point clearly stated (~~web crawler extraction, user-provided, or manual entry)
|
||||
|
||||
## Example
|
||||
|
||||
**User**: "Generate FAQ schema for a page about SEO with 3 questions"
|
||||
|
||||
**Output**:
|
||||
|
||||
```markdown
|
||||
## FAQ Schema for SEO Page
|
||||
|
||||
### Schema Markup
|
||||
|
||||
```json
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": [
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "What is SEO?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "SEO (Search Engine Optimization) is the practice of optimizing websites and content to rank higher in search engine results pages (SERPs). It involves technical optimizations, content creation, and link building to increase organic visibility and drive qualified traffic to your website."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "How long does SEO take to work?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "SEO typically takes 3-6 months to show significant results, though this varies based on competition, domain authority, and effort invested. New websites may take 6-12 months, while established sites with existing authority can see improvements in 1-3 months for less competitive keywords."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Is SEO better than paid advertising?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "SEO and paid advertising serve different purposes. SEO provides sustainable, long-term traffic without per-click costs but takes time to build. Paid advertising delivers immediate results but stops when you stop paying. Most successful businesses use both: paid ads for immediate leads and SEO for long-term growth."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
_Implementation: Wrap the above JSON-LD in `<script type="application/ld+json">...</script>` and place in `<head>` or before `</body>`. Test with ~~schema validator._
|
||||
|
||||
### SERP Preview
|
||||
|
||||
```
|
||||
SEO Guide: Complete Beginner's Tutorial
|
||||
yoursite.com/seo-guide/
|
||||
Learn SEO from scratch with our comprehensive guide...
|
||||
|
||||
▼ What is SEO?
|
||||
SEO (Search Engine Optimization) is the practice of optimizing...
|
||||
▼ How long does SEO take to work?
|
||||
SEO typically takes 3-6 months to show significant results...
|
||||
▼ Is SEO better than paid advertising?
|
||||
SEO and paid advertising serve different purposes...
|
||||
```
|
||||
```
|
||||
|
||||
## Schema Type Quick Reference
|
||||
|
||||
| Content Type | Schema Type | Key Properties |
|
||||
|--------------|-------------|----------------|
|
||||
| Blog Post | BlogPosting/Article | headline, datePublished, author |
|
||||
| Product | Product | name, price, availability |
|
||||
| FAQ | FAQPage | Question, Answer |
|
||||
| How-To | HowTo | step, totalTime |
|
||||
| Local Business | LocalBusiness | address, geo, openingHours |
|
||||
| Recipe | Recipe | ingredients, cookTime |
|
||||
| Event | Event | startDate, location |
|
||||
| Video | VideoObject | uploadDate, duration |
|
||||
| Course | Course | provider, name |
|
||||
| Review | Review | itemReviewed, ratingValue |
|
||||
|
||||
## Tips for Success
|
||||
|
||||
1. **Match visible content** - Schema must reflect what users see
|
||||
2. **Don't spam** - Only add schema for relevant content
|
||||
3. **Keep updated** - Update dates and prices when they change
|
||||
4. **Test thoroughly** - Validate before deploying
|
||||
5. **Monitor Search Console** - Watch for errors and warnings
|
||||
|
||||
## Schema Type Decision Tree
|
||||
|
||||
> **Reference**: See [references/schema-decision-tree.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/schema-markup-generator/references/schema-decision-tree.md) for the full decision tree (content-to-schema mapping), industry-specific recommendations, implementation priority tiers (P0-P4), and validation quick reference.
|
||||
|
||||
|
||||
### Save Results
|
||||
|
||||
After delivering content or optimization output to the user, ask:
|
||||
|
||||
> "Save these results for future sessions?"
|
||||
|
||||
If yes, write a dated summary to `memory/content/YYYY-MM-DD-<topic>.md` containing:
|
||||
- One-line description of what was created
|
||||
- Target keyword and content type
|
||||
- Open loops or items needing review
|
||||
- Source data references
|
||||
|
||||
**Gate check recommended**: Run content-quality-auditor before publishing (PostToolUse hook will remind automatically).
|
||||
|
||||
If any findings should influence ongoing strategy, recommend promoting key conclusions to `memory/hot-cache.md`.
|
||||
|
||||
## Reference Materials
|
||||
|
||||
- [Schema Templates](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/schema-markup-generator/references/schema-templates.md) - Copy-ready JSON-LD templates for all schema types
|
||||
- [Validation Guide](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/schema-markup-generator/references/validation-guide.md) - Common errors, required properties, testing workflow
|
||||
|
||||
## Next Best Skill
|
||||
|
||||
- **Primary**: [technical-seo-checker](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/optimize/technical-seo-checker/SKILL.md) — verify implementation quality and deployment readiness.
|
||||
@@ -0,0 +1,456 @@
|
||||
---
|
||||
name: seo-content-writer
|
||||
description: 'Write SEO blog posts, articles, landing pages with keyword integration, header optimization, and snippet targeting. SEO文章写作/内容优化'
|
||||
version: "6.0.0"
|
||||
license: Apache-2.0
|
||||
compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
|
||||
homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
|
||||
when_to_use: "Use when writing SEO-optimized articles, blog posts, landing pages, or product descriptions. Also when the user asks to create content targeting a specific keyword."
|
||||
argument-hint: "<topic> <target keyword>"
|
||||
metadata:
|
||||
author: aaron-he-zhu
|
||||
version: "6.0.0"
|
||||
geo-relevance: "medium"
|
||||
tags:
|
||||
- seo
|
||||
- content-writing
|
||||
- blog-writing
|
||||
- seo-copywriting
|
||||
- content-creation
|
||||
- featured-snippet-optimization
|
||||
- article-writing
|
||||
- landing-page
|
||||
- surferSEO-alternative
|
||||
- clearscope-alternative
|
||||
- SEO文章
|
||||
- 博客写作
|
||||
- SEOライティング
|
||||
- SEO글쓰기
|
||||
- redaccion-seo
|
||||
triggers:
|
||||
# EN-formal
|
||||
- "write SEO content"
|
||||
- "create blog post"
|
||||
- "write an article"
|
||||
- "content writing"
|
||||
- "draft optimized content"
|
||||
- "write for SEO"
|
||||
- "SEO copywriting"
|
||||
- "blog writing"
|
||||
# EN-casual
|
||||
- "write me a blog post"
|
||||
- "write me an article"
|
||||
- "help me write about"
|
||||
- "draft an article about"
|
||||
- "I need a blog post"
|
||||
- "create content for my site"
|
||||
# EN-question
|
||||
- "how do I write content that ranks"
|
||||
- "how to write SEO friendly content"
|
||||
- "what makes content rank well"
|
||||
# EN-competitor
|
||||
- "SurferSEO alternative"
|
||||
- "Clearscope alternative"
|
||||
- "Jasper AI alternative for SEO"
|
||||
# ZH-pro
|
||||
- "SEO文章写作"
|
||||
- "SEO内容创作"
|
||||
- "博客写作"
|
||||
- "内容优化"
|
||||
- "内容创作"
|
||||
# ZH-casual
|
||||
- "帮我写文章"
|
||||
- "写一篇博客"
|
||||
- "排名上不去"
|
||||
- "帮我写SEO文章"
|
||||
- "写一篇SEO文章"
|
||||
# JA
|
||||
- "SEOライティング"
|
||||
- "SEO記事作成"
|
||||
- "ブログ記事作成"
|
||||
- "SEOコンテンツ"
|
||||
# KO
|
||||
- "SEO 글쓰기"
|
||||
- "블로그 작성"
|
||||
- "SEO 콘텐츠 작성"
|
||||
- "블로그 글 작성해줘"
|
||||
- "이 주제로 글 써봐"
|
||||
# ES
|
||||
- "redacción SEO"
|
||||
- "escribir artículo SEO"
|
||||
- "contenido optimizado"
|
||||
# PT
|
||||
- "redação SEO"
|
||||
- "escrever artigo SEO"
|
||||
# Misspellings
|
||||
- "SEO copywritting"
|
||||
- "writting SEO content"
|
||||
---
|
||||
|
||||
# SEO Content Writer
|
||||
|
||||
> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
|
||||
> **System Mode**: This build skill follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
|
||||
|
||||
|
||||
This skill creates search-engine-optimized content that ranks well while providing genuine value to readers. It applies proven SEO copywriting techniques, proper keyword integration, and optimal content structure.
|
||||
|
||||
**System role**: Build layer skill. It turns briefs and signals into assets that other skills can review, publish, and monitor.
|
||||
|
||||
## When This Must Trigger
|
||||
|
||||
Use this when the conversation involves any of these situations — even if the user does not use SEO terminology:
|
||||
|
||||
Use this whenever the task needs a shippable asset or transformation that should feed directly into quality review, deployment, or monitoring.
|
||||
|
||||
- Writing blog posts targeting specific keywords
|
||||
- Creating landing pages optimized for search
|
||||
- Developing pillar content for topic clusters
|
||||
- Writing product descriptions for e-commerce
|
||||
- Creating service pages for local SEO
|
||||
- Producing how-to guides and tutorials
|
||||
- Writing comparison and review articles
|
||||
|
||||
## What This Skill Does
|
||||
|
||||
1. **Keyword Integration**: Naturally incorporates target and related keywords
|
||||
2. **Structure Optimization**: Creates scannable, well-organized content
|
||||
3. **Title & Meta Creation**: Writes compelling, click-worthy titles
|
||||
4. **Header Optimization**: Uses strategic H1-H6 hierarchy
|
||||
5. **Internal Linking**: Suggests relevant internal link opportunities
|
||||
6. **Readability Enhancement**: Ensures content is accessible and engaging
|
||||
7. **Featured Snippet Optimization**: Formats for SERP feature opportunities
|
||||
|
||||
## Quick Start
|
||||
|
||||
Start with one of these prompts. Finish with a short handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
|
||||
|
||||
### Basic Content Creation
|
||||
|
||||
```
|
||||
Write an SEO-optimized article about [topic] targeting the keyword [keyword]
|
||||
```
|
||||
|
||||
```
|
||||
Create a blog post for [topic] with these keywords: [keyword list]
|
||||
```
|
||||
|
||||
### With Specific Requirements
|
||||
|
||||
```
|
||||
Write a 2,000-word guide about [topic] targeting [keyword],
|
||||
include FAQ section for featured snippets
|
||||
```
|
||||
|
||||
### Content Briefs
|
||||
|
||||
```
|
||||
Here's my content brief: [brief]. Write SEO-optimized content following this outline.
|
||||
```
|
||||
|
||||
## Skill Contract
|
||||
|
||||
**Expected output**: a ready-to-use asset or implementation-ready transformation plus a short handoff summary ready for `memory/content/`.
|
||||
|
||||
- **Reads**: the brief, target keywords, entity inputs, quality constraints, and prior decisions from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
|
||||
- **Writes**: a user-facing content, metadata, or schema deliverable plus a reusable summary that can be stored under `memory/content/`.
|
||||
- **Promotes**: approved angles, messaging choices, missing evidence, and publish blockers to `CLAUDE.md`, `memory/decisions.md`, and `memory/open-loops.md`.
|
||||
- **Next handoff**: use the `Next Best Skill` below when the asset is ready for review or deployment.
|
||||
|
||||
## Data Sources
|
||||
|
||||
> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
|
||||
|
||||
**With ~~SEO tool + ~~search console connected:**
|
||||
Automatically pull keyword metrics (search volume, difficulty, CPC), competitor content analysis (top-ranking pages, content length, common topics), SERP features (featured snippets, PAA questions), and keyword opportunities (related keywords, question-based queries).
|
||||
|
||||
**With manual data only:**
|
||||
Ask the user to provide:
|
||||
1. Target primary keyword and 3-5 secondary keywords
|
||||
2. Target audience and search intent (informational/commercial/transactional)
|
||||
3. Target word count and desired tone
|
||||
4. Any competitor URLs or content examples to reference
|
||||
|
||||
Proceed with the full workflow using provided data. Note in the output which metrics are from automated collection vs. user-provided data.
|
||||
|
||||
## Instructions
|
||||
|
||||
When a user requests SEO content:
|
||||
|
||||
1. **Gather Requirements**
|
||||
|
||||
Confirm or ask for:
|
||||
|
||||
```markdown
|
||||
### Content Requirements
|
||||
|
||||
**Primary Keyword**: [main keyword]
|
||||
**Secondary Keywords**: [2-5 related keywords]
|
||||
**Target Word Count**: [length]
|
||||
**Content Type**: [blog/guide/landing page/etc.]
|
||||
**Target Audience**: [who is this for]
|
||||
**Search Intent**: [informational/commercial/transactional]
|
||||
**Tone**: [professional/casual/technical/friendly]
|
||||
**CTA Goal**: [what action should readers take]
|
||||
**Competitor URLs**: [top ranking content to beat]
|
||||
```
|
||||
|
||||
2. **Load CORE-EEAT Quality Constraints**
|
||||
|
||||
Before writing, load content quality standards from the [CORE-EEAT Benchmark](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/core-eeat-benchmark.md):
|
||||
|
||||
```markdown
|
||||
### CORE-EEAT Pre-Write Checklist
|
||||
|
||||
**Content Type**: [identified from requirements above]
|
||||
**Loaded Constraints** (high-weight items for this content type):
|
||||
|
||||
Apply these standards while writing:
|
||||
|
||||
| ID | Standard | How to Apply |
|
||||
|----|----------|-------------|
|
||||
| C01 | Intent Alignment | Title promise must match content delivery |
|
||||
| C02 | Direct Answer | Core answer in first 150 words |
|
||||
| C06 | Audience Targeting | State "this article is for..." |
|
||||
| C10 | Semantic Closure | Conclusion answers opening question + next steps |
|
||||
| O01 | Heading Hierarchy | H1→H2→H3, no level skipping |
|
||||
| O02 | Summary Box | Include TL;DR or Key Takeaways |
|
||||
| O06 | Section Chunking | Each section single topic; paragraphs 3–5 sentences |
|
||||
| O09 | Information Density | No filler; consistent terminology |
|
||||
| R01 | Data Precision | ≥5 precise numbers with units |
|
||||
| R02 | Citation Density | ≥1 external citation per 500 words |
|
||||
| R04 | Evidence-Claim Mapping | Every claim backed by evidence |
|
||||
| R07 | Entity Precision | Full names for people/orgs/products |
|
||||
| C03 | Query Coverage | Cover ≥3 query variants (synonyms, long-tail) |
|
||||
| O08 | Anchor Navigation | Table of contents with jump links |
|
||||
| O10 | Multimedia Structure | Images/videos have captions and carry information |
|
||||
| E07 | Practical Tools | Include downloadable templates, checklists, or calculators |
|
||||
|
||||
_These 16 items apply across all content types. For content-type-specific dimension weights, see the Content-Type Weight Table in [core-eeat-benchmark.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/core-eeat-benchmark.md)._
|
||||
_Full 80-item benchmark: [references/core-eeat-benchmark.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/core-eeat-benchmark.md)_
|
||||
_For complete content quality audit: use [content-quality-auditor](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/content-quality-auditor/SKILL.md)_
|
||||
```
|
||||
|
||||
3. **Research and Plan**
|
||||
|
||||
Before writing:
|
||||
|
||||
```markdown
|
||||
### Content Research
|
||||
|
||||
**SERP Analysis**:
|
||||
- Top results format: [what's ranking]
|
||||
- Average word count: [X] words
|
||||
- Common sections: [list]
|
||||
- SERP features: [snippets, PAA, etc.]
|
||||
|
||||
**Keyword Map**:
|
||||
- Primary: [keyword] - use in title, H1, intro, conclusion
|
||||
- Secondary: [keywords] - use in H2s, body paragraphs
|
||||
- LSI/Related: [terms] - sprinkle naturally throughout
|
||||
- Questions: [PAA questions] - use as H2/H3s or FAQ
|
||||
|
||||
**Content Angle**:
|
||||
[What unique perspective or value will this content provide?]
|
||||
```
|
||||
|
||||
4. **Create Optimized Title**
|
||||
|
||||
```markdown
|
||||
### Title Optimization
|
||||
|
||||
**Requirements**:
|
||||
- Include primary keyword (preferably at start)
|
||||
- Under 60 characters for full SERP display
|
||||
- Compelling and click-worthy
|
||||
- Match search intent
|
||||
|
||||
**Title Options**:
|
||||
|
||||
1. [Title option 1] ([X] chars)
|
||||
- Keyword position: [front/middle]
|
||||
- Power words: [list]
|
||||
|
||||
2. [Title option 2] ([X] chars)
|
||||
- Keyword position: [front/middle]
|
||||
- Power words: [list]
|
||||
|
||||
**Recommended**: [Best option with reasoning]
|
||||
```
|
||||
|
||||
5. **Write Meta Description**
|
||||
|
||||
```markdown
|
||||
### Meta Description
|
||||
|
||||
**Requirements**:
|
||||
- 150-160 characters
|
||||
- Include primary keyword naturally
|
||||
- Include call-to-action
|
||||
- Compelling and specific
|
||||
|
||||
**Meta Description**:
|
||||
"[Description text]" ([X] characters)
|
||||
|
||||
**Elements included**:
|
||||
- ✅ Primary keyword
|
||||
- ✅ Value proposition
|
||||
- ✅ CTA or curiosity hook
|
||||
```
|
||||
|
||||
6. **Structure Content and Write**
|
||||
|
||||
Structure: H1 (primary keyword, one per page) > Introduction (100-150 words, hook + promise + keyword in first 100 words) > H2 sections (secondary keywords/questions) > H3 sub-topics > FAQ section > Conclusion (summary + keyword + CTA).
|
||||
|
||||
7. **Apply On-Page SEO Best Practices**
|
||||
|
||||
Follow the on-page SEO checklist (keyword placement, content quality, readability, technical elements) and the content writing template (H1 with keyword, hook, sections with H2/H3, FAQ, conclusion with CTA).
|
||||
|
||||
> **Reference**: See [references/seo-writing-checklist.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/seo-content-writer/references/seo-writing-checklist.md) for the full on-page SEO checklist, content writing template, and featured snippet optimization patterns.
|
||||
|
||||
Key requirements while writing:
|
||||
- Primary keyword in title, H1, first 100 words, at least one H2, and conclusion
|
||||
- Paragraphs of 3-5 sentences; varied sentence length; bullet points and bold key phrases
|
||||
- Internal links (2-5) and external authoritative links (2-3)
|
||||
- FAQ section with 40-60 word answers for featured snippet opportunity
|
||||
- Optimize for definition, list, table, and how-to snippets where applicable
|
||||
|
||||
8. **Add Internal/External Links**
|
||||
|
||||
```markdown
|
||||
### Link Recommendations
|
||||
|
||||
**Internal Links** (include 2-5):
|
||||
1. "[anchor text]" → [/your-page-url] (relevant because: [reason])
|
||||
2. "[anchor text]" → [/your-page-url] (relevant because: [reason])
|
||||
|
||||
**External Links** (include 2-3 authoritative sources):
|
||||
1. "[anchor text]" → [authoritative-source.com] (supports: [claim])
|
||||
2. "[anchor text]" → [authoritative-source.com] (supports: [claim])
|
||||
```
|
||||
|
||||
9. **Final SEO Review and CORE-EEAT Self-Check**
|
||||
|
||||
Score content across 10 SEO factors (title, meta description, H1, keyword placement, H2s, internal links, external links, FAQ, readability, word count) and produce an Overall SEO Score out of 10.
|
||||
|
||||
Then verify the 16 CORE-EEAT pre-write constraints (C01, C02, C06, C10, O01, O02, O06, O09, R01, R02, R04, R07, C03, O08, O10, E07) with pass/warning/fail status. List items needing attention.
|
||||
|
||||
_For full 80-item audit, use [content-quality-auditor](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/content-quality-auditor/SKILL.md)_
|
||||
|
||||
#### Issue Classification
|
||||
|
||||
When the self-check reveals issues, classify and handle them:
|
||||
|
||||
**Auto-correct (fix silently, then document in a `### Changes Made` block after the final content):**
|
||||
- Meta description exceeds 160 characters → rewrite to fit while preserving primary keyword and CTA
|
||||
- Title tag exceeds 60 characters → shorten while preserving primary keyword
|
||||
- Missing alt text on images → generate descriptive alt text
|
||||
- Duplicate H2 headings → differentiate with modifiers
|
||||
- Keyword density above 2% → replace some instances with semantic variants
|
||||
- Missing table of contents → generate TOC with anchor links for articles with 3+ H2 sections
|
||||
- Paragraphs exceeding 5 sentences → split at the most natural break point
|
||||
|
||||
Use this format for the Changes Made block:
|
||||
|
||||
```markdown
|
||||
### Changes Made During Self-Check
|
||||
|
||||
| Item | Original | Fixed |
|
||||
|------|----------|-------|
|
||||
| Meta description | 185 chars | 158 chars — removed non-essential qualifier |
|
||||
| Keyword density | 2.4% | 1.8% — replaced 3 instances with semantic variants |
|
||||
```
|
||||
|
||||
**Needs your decision (ask before changing):**
|
||||
- H1 wording changes (may affect brand voice)
|
||||
- Keyword density below 0.5% (may need structural rewrite)
|
||||
- Tone adjustments (formal ↔ casual)
|
||||
- Claim strength (e.g., "best" → "top-rated" for compliance)
|
||||
- Content length significantly above/below target (±30%)
|
||||
- Removing/replacing external links
|
||||
- Statistics or data claims that cannot be verified against the cited source
|
||||
|
||||
## Validation Checkpoints
|
||||
|
||||
### Input Validation
|
||||
- [ ] Primary keyword confirmed and matches search intent
|
||||
- [ ] Target word count specified (minimum 800 words for substantive content)
|
||||
- [ ] Content type and audience clearly defined
|
||||
- [ ] Competitor URLs reviewed or target SERP features identified
|
||||
|
||||
### Output Validation
|
||||
- [ ] Keyword density within 1-2% for primary keyword (Note: Keyword density is a guideline, not a hard rule. Modern search engines prioritize semantic relevance and natural language over exact density targets. Focus on covering the topic comprehensively with semantic variants rather than hitting a specific percentage.)
|
||||
- [ ] All sections from outline covered completely
|
||||
- [ ] Internal links included (2-5 relevant links)
|
||||
- [ ] FAQ section present with at least 3 questions
|
||||
- [ ] Readability score appropriate for target audience
|
||||
- [ ] Source of each data point clearly stated (~~SEO tool data, user-provided, or estimated)
|
||||
|
||||
## Example
|
||||
|
||||
**User**: "Write an SEO-optimized article about 'email marketing best practices' targeting small businesses"
|
||||
|
||||
> **Reference**: See [references/seo-writing-checklist.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/seo-content-writer/references/seo-writing-checklist.md) for the full example output demonstrating a complete SEO article with meta description, H1/H2/H3 hierarchy, statistics with citations, comparison tables, FAQ section, and conclusion with CTA.
|
||||
|
||||
The example output demonstrates: keyword in H1 and first 100 words, statistics with sources (DMA, Emarsys), comparison tables, bullet-point lists, pro tips, FAQ section with 40-60 word answers, and a clear CTA in the conclusion.
|
||||
|
||||
## Content Type Templates
|
||||
|
||||
### How-To Guide
|
||||
|
||||
```
|
||||
Write a how-to guide for [task] targeting [keyword]
|
||||
```
|
||||
|
||||
### Comparison Article
|
||||
|
||||
```
|
||||
Write a comparison article: [Option A] vs [Option B] for [keyword]
|
||||
```
|
||||
|
||||
### Listicle
|
||||
|
||||
```
|
||||
Write a list post: "X Best [Items] for [Audience/Purpose]" targeting [keyword]
|
||||
```
|
||||
|
||||
### Ultimate Guide
|
||||
|
||||
```
|
||||
Write an ultimate guide about [topic] (3,000+ words) targeting [keyword]
|
||||
```
|
||||
|
||||
## Tips for Success
|
||||
|
||||
1. **Match search intent** - Informational queries need guides, not sales pages
|
||||
2. **Front-load value** - Put key information early for readers and snippets
|
||||
3. **Use data and examples** - Specific beats generic every time
|
||||
4. **Write for humans first** - SEO optimization should feel natural
|
||||
5. **Include visual elements** - Break up text with images, tables, lists
|
||||
6. **Update regularly** - Fresh content signals to search engines
|
||||
|
||||
|
||||
### Save Results
|
||||
|
||||
After delivering content or optimization output to the user, ask:
|
||||
|
||||
> "Save these results for future sessions?"
|
||||
|
||||
If yes, write a dated summary to `memory/content/YYYY-MM-DD-<topic>.md` containing:
|
||||
- One-line description of what was created
|
||||
- Target keyword and content type
|
||||
- Open loops or items needing review
|
||||
- Source data references
|
||||
|
||||
**Gate check recommended**: Run content-quality-auditor before publishing (PostToolUse hook will remind automatically).
|
||||
|
||||
If any findings should influence ongoing strategy, recommend promoting key conclusions to `memory/hot-cache.md`.
|
||||
|
||||
## Reference Materials
|
||||
|
||||
- [Title Formulas](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/seo-content-writer/references/title-formulas.md) - Proven headline formulas, power words, CTR patterns
|
||||
- [Content Structure Templates](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/seo-content-writer/references/content-structure-templates.md) - Templates for blog posts, comparisons, listicles, how-tos, pillar pages
|
||||
|
||||
## Next Best Skill
|
||||
|
||||
- **Primary**: [content-quality-auditor](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/cross-cutting/content-quality-auditor/SKILL.md) — gate the draft before publishing or handing it off.
|
||||
@@ -0,0 +1,284 @@
|
||||
---
|
||||
name: serp-analysis
|
||||
description: 'Analyze SERPs: ranking factors, features, intent patterns, AI overviews, featured snippets. SERP分析/搜索结果'
|
||||
version: "6.0.0"
|
||||
license: Apache-2.0
|
||||
compatibility: "Claude Code ≥1.0, skills.sh marketplace, ClawHub marketplace, Vercel Labs skills ecosystem. No system packages required. Optional: MCP network access for SEO tool integrations."
|
||||
allowed-tools: WebFetch
|
||||
homepage: "https://github.com/aaron-he-zhu/seo-geo-claude-skills"
|
||||
when_to_use: "Use when analyzing search engine results pages, SERP features, featured snippets, People Also Ask, or understanding ranking patterns for a query."
|
||||
argument-hint: "<keyword or query>"
|
||||
metadata:
|
||||
author: aaron-he-zhu
|
||||
version: "6.0.0"
|
||||
geo-relevance: "high"
|
||||
tags:
|
||||
- seo
|
||||
- geo
|
||||
- serp-analysis
|
||||
- serp-features
|
||||
- featured-snippet
|
||||
- ai-overview
|
||||
- people-also-ask
|
||||
- search-intent
|
||||
- SERP分析
|
||||
- 検索結果分析
|
||||
- 검색결과
|
||||
- analisis-serp
|
||||
triggers:
|
||||
# EN-formal
|
||||
- "analyze search results"
|
||||
- "SERP analysis"
|
||||
- "what ranks for"
|
||||
- "SERP features"
|
||||
- "why does this page rank"
|
||||
- "featured snippets"
|
||||
- "AI overviews"
|
||||
# EN-casual
|
||||
- "what's on page one for this query"
|
||||
- "who ranks for this keyword"
|
||||
- "what does Google show for"
|
||||
- "what shows up for this search"
|
||||
- "who is on page one"
|
||||
# EN-question
|
||||
- "why does this page rank first"
|
||||
- "what SERP features appear for"
|
||||
# ZH-pro
|
||||
- "SERP分析"
|
||||
- "搜索结果分析"
|
||||
- "精选摘要"
|
||||
- "AI概览"
|
||||
# ZH-casual
|
||||
- "谁排第一"
|
||||
- "搜索结果长什么样"
|
||||
- "谁排在前面"
|
||||
# JA
|
||||
- "検索結果ページ分析"
|
||||
- "検索結果分析"
|
||||
- "強調スニペット"
|
||||
# KO
|
||||
- "검색 결과 분석"
|
||||
- "SERP 분석"
|
||||
# ES
|
||||
- "análisis SERP"
|
||||
- "análisis de resultados de búsqueda"
|
||||
# PT
|
||||
- "análise de SERP"
|
||||
# Misspellings
|
||||
- "serp anaylsis"
|
||||
---
|
||||
|
||||
# SERP Analysis
|
||||
|
||||
|
||||
> **[SEO & GEO Skills Library](https://github.com/aaron-he-zhu/seo-geo-claude-skills)** · 20 skills for SEO + GEO · [ClawHub](https://clawhub.ai/u/aaron-he-zhu) · [skills.sh](https://skills.sh/aaron-he-zhu/seo-geo-claude-skills)
|
||||
> **System Mode**: This research skill follows the shared [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md) and [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md).
|
||||
|
||||
|
||||
This skill analyzes Search Engine Results Pages to reveal what's working for ranking content, which SERP features appear, and what triggers AI-generated answers. Understand the battlefield before creating content.
|
||||
|
||||
**System role**: Research layer skill. It turns market signals into reusable strategic inputs for the rest of the library.
|
||||
|
||||
## When This Must Trigger
|
||||
|
||||
Use this when the conversation involves any of these situations — even if the user does not use SEO terminology:
|
||||
|
||||
Use this whenever the task needs reusable market intelligence that should influence strategy, not just an ad hoc answer.
|
||||
|
||||
- Before creating content for a target keyword
|
||||
- Understanding why certain pages rank #1
|
||||
- Identifying SERP feature opportunities (featured snippets, PAA)
|
||||
- Analyzing AI Overview/SGE patterns
|
||||
- Evaluating keyword difficulty more accurately
|
||||
- Planning content format based on what ranks
|
||||
- Identifying ranking factors for specific queries
|
||||
|
||||
## What This Skill Does
|
||||
|
||||
1. **SERP Composition Analysis**: Maps what appears on the results page
|
||||
2. **Ranking Factor Identification**: Reveals why top results rank
|
||||
3. **SERP Feature Mapping**: Identifies featured snippets, PAA, knowledge panels
|
||||
4. **AI Overview Analysis**: Examines when and how AI answers appear
|
||||
5. **Intent Signal Detection**: Confirms user intent from SERP composition
|
||||
6. **Content Format Recommendations**: Suggests optimal format based on SERP
|
||||
7. **Difficulty Assessment**: Evaluates realistic ranking potential
|
||||
|
||||
## Quick Start
|
||||
|
||||
Start with one of these prompts. Finish with a short handoff summary using the repository format in [Skill Contract](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/skill-contract.md).
|
||||
|
||||
### Basic SERP Analysis
|
||||
|
||||
```
|
||||
Analyze the SERP for [keyword]
|
||||
```
|
||||
|
||||
```
|
||||
What does it take to rank for [keyword]?
|
||||
```
|
||||
|
||||
### Feature-Specific Analysis
|
||||
|
||||
```
|
||||
Analyze featured snippet opportunities for [keyword list]
|
||||
```
|
||||
|
||||
```
|
||||
Which of these keywords trigger AI Overviews? [keyword list]
|
||||
```
|
||||
|
||||
### Competitive SERP Analysis
|
||||
|
||||
```
|
||||
Why does [URL] rank #1 for [keyword]?
|
||||
```
|
||||
|
||||
## Skill Contract
|
||||
|
||||
**Expected output**: a prioritized research brief, evidence-backed findings, and a short handoff summary ready for `memory/research/`.
|
||||
|
||||
- **Reads**: user goals, target market inputs, available tool data, and prior strategy from [CLAUDE.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CLAUDE.md) and the shared [State Model](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/references/state-model.md) when available.
|
||||
- **Writes**: a user-facing research deliverable plus a reusable summary that can be stored under `memory/research/`.
|
||||
- **Promotes**: durable keyword priorities, competitor facts, entity candidates, and strategic decisions to `CLAUDE.md`, `memory/decisions.md`, and `memory/research/`; hand canonical entity work to `entity-optimizer`.
|
||||
- **Next handoff**: use the `Next Best Skill` below when the findings are ready to drive action.
|
||||
|
||||
## Data Sources
|
||||
|
||||
> **Note:** All integrations are optional. This skill works without any API keys — users provide data manually when no tools are connected.
|
||||
|
||||
> See [CONNECTORS.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/CONNECTORS.md) for tool category placeholders.
|
||||
|
||||
**With ~~SEO tool + ~~search console + ~~AI monitor connected:**
|
||||
Automatically fetch SERP snapshots for target keywords, extract ranking page metrics (domain authority, backlinks, content length), pull SERP feature data, and check AI Overview presence using ~~AI monitor. Historical SERP change data and mobile vs. desktop variations can be retrieved automatically.
|
||||
|
||||
**With manual data only:**
|
||||
Ask the user to provide:
|
||||
1. Target keyword(s) to analyze
|
||||
2. SERP screenshots or detailed descriptions of search results
|
||||
3. URLs of top 10 ranking pages
|
||||
4. Search location and device type (mobile/desktop)
|
||||
5. Any observations about SERP features (featured snippets, PAA, AI Overviews)
|
||||
|
||||
Proceed with the full analysis using provided data. Note in the output which metrics are from automated collection vs. user-provided data.
|
||||
|
||||
## Instructions
|
||||
|
||||
When a user requests SERP analysis:
|
||||
|
||||
1. **Understand the Query**
|
||||
|
||||
Clarify if needed:
|
||||
- Target keyword(s) to analyze
|
||||
- Search location/language
|
||||
- Device type (mobile/desktop)
|
||||
- Specific questions about the SERP
|
||||
|
||||
2. **Map SERP Composition**
|
||||
|
||||
Document all elements appearing on the results page: AI Overview, ads, featured snippet, organic results, PAA, knowledge panel, image pack, video results, local pack, shopping results, news results, sitelinks, and related searches.
|
||||
|
||||
3. **Analyze Top Ranking Pages**
|
||||
|
||||
For each of the top 10 results, document: URL, domain, domain authority, content type, word count, publish/update dates, on-page factors (title, meta description, H1, URL structure), content structure (headings, media, tables, FAQ), estimated metrics (backlinks, referring domains), and why it ranks.
|
||||
|
||||
4. **Identify Ranking Patterns**
|
||||
|
||||
Analyze common characteristics across top 5 results: word count, domain authority, backlinks, content freshness, HTTPS, mobile optimization. Document content format distribution, domain type distribution, and key success factors.
|
||||
|
||||
5. **Analyze SERP Features**
|
||||
|
||||
For each present SERP feature: analyze the current holder, content format, and strategy to win. Cover Featured Snippet (type, content, winning strategy), PAA (questions, current answers, optimization approach), and AI Overview (sources cited, content patterns, citation strategy).
|
||||
|
||||
6. **Determine Search Intent**
|
||||
|
||||
Confirm primary intent from SERP composition. Document evidence, intent breakdown percentages, and content format implications (format, tone, CTA).
|
||||
|
||||
7. **Calculate True Difficulty**
|
||||
|
||||
Score overall difficulty (1-100) based on: top 10 domain authority, page authority, backlinks required, content quality bar, and SERP stability. Provide realistic assessments for new, growing, and established sites, plus easier alternatives.
|
||||
|
||||
8. **Generate Recommendations**
|
||||
|
||||
Produce a summary with: Key Findings, Content Requirements to Rank (minimum requirements + differentiators), SERP Feature Strategy, Recommended Content Outline, and Next Steps.
|
||||
|
||||
> **Reference**: See [references/analysis-templates.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/serp-analysis/references/analysis-templates.md) for detailed templates for each step.
|
||||
|
||||
## Validation Checkpoints
|
||||
|
||||
### Input Validation
|
||||
- [ ] Target keyword(s) clearly specified
|
||||
- [ ] Search location and device type confirmed
|
||||
- [ ] SERP data is current (date confirmed)
|
||||
- [ ] Top 10 ranking URLs identified or provided
|
||||
|
||||
### Output Validation
|
||||
- [ ] Every recommendation cites specific data points (not generic advice)
|
||||
- [ ] SERP composition mapped with all features documented
|
||||
- [ ] Ranking factors identified from actual top 10 analysis (not assumptions)
|
||||
- [ ] Content requirements based on observed patterns in current SERP
|
||||
- [ ] Source of each data point clearly stated (~~SEO tool data, ~~AI monitor data, user-provided, or manual observation)
|
||||
|
||||
## Example
|
||||
|
||||
> **Reference**: See [references/example-report.md](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/serp-analysis/references/example-report.md) for a complete example analyzing the SERP for "how to start a podcast".
|
||||
|
||||
## Advanced Analysis
|
||||
|
||||
### Multi-Keyword SERP Comparison
|
||||
|
||||
```
|
||||
Compare SERPs for [keyword 1], [keyword 2], [keyword 3]
|
||||
```
|
||||
|
||||
### Historical SERP Changes
|
||||
|
||||
```
|
||||
How has the SERP for [keyword] changed over time?
|
||||
```
|
||||
|
||||
### Local SERP Variations
|
||||
|
||||
```
|
||||
Compare SERP for [keyword] in [location 1] vs [location 2]
|
||||
```
|
||||
|
||||
### Mobile vs Desktop SERP
|
||||
|
||||
```
|
||||
Analyze mobile vs desktop SERP differences for [keyword]
|
||||
```
|
||||
|
||||
## Tips for Success
|
||||
|
||||
1. **Always check SERP before writing** - Don't assume, verify
|
||||
2. **Match content format to SERP** - If lists rank, write lists
|
||||
3. **Identify SERP feature opportunities** - Lower competition than #1
|
||||
4. **Note SERP volatility** - Stable SERPs are harder to break into
|
||||
5. **Study the outliers** - Why does a weaker site rank? Opportunity!
|
||||
6. **Consider AI Overview optimization** - Growing importance
|
||||
|
||||
|
||||
|
||||
### Save Results
|
||||
|
||||
After delivering findings to the user, ask:
|
||||
|
||||
> "Save these results for future sessions?"
|
||||
|
||||
If yes, write a dated summary to `memory/research/serp-analysis/YYYY-MM-DD-<topic>.md` containing:
|
||||
- One-line headline finding
|
||||
- Top 3-5 actionable items
|
||||
- Open loops or blockers
|
||||
- Source data references
|
||||
|
||||
If any findings should influence ongoing strategy, recommend promoting key conclusions to `memory/hot-cache.md`.
|
||||
|
||||
## Reference Materials
|
||||
|
||||
- [Analysis Templates](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/serp-analysis/references/analysis-templates.md) — Detailed templates for each analysis step (SERP composition, top results, ranking patterns, features, intent, difficulty, recommendations)
|
||||
- [SERP Feature Taxonomy](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/serp-analysis/references/serp-feature-taxonomy.md) — Complete taxonomy of SERP features with trigger conditions, AI overview framework, intent signals, and volatility assessment
|
||||
- [Example Report](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/research/serp-analysis/references/example-report.md) — Complete example analyzing the SERP for "how to start a podcast"
|
||||
|
||||
## Next Best Skill
|
||||
|
||||
- **Primary**: [seo-content-writer](https://github.com/aaron-he-zhu/seo-geo-claude-skills/blob/main/build/seo-content-writer/SKILL.md) — turn SERP patterns into a content brief or page structure.
|
||||
@@ -0,0 +1,240 @@
|
||||
---
|
||||
name: shadcn
|
||||
description: Manages shadcn components and projects — adding, searching, fixing, debugging, styling, and composing UI. Provides project context, component docs, and usage examples. Applies when working with shadcn/ui, component registries, presets, --preset codes, or any project with a components.json file. Also triggers for "shadcn init", "create an app with --preset", or "switch to --preset".
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# shadcn/ui
|
||||
|
||||
A framework for building ui, components and design systems. Components are added as source code to the user's project via the CLI.
|
||||
|
||||
> **IMPORTANT:** Run all CLI commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest` — based on the project's `packageManager`. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
|
||||
|
||||
## Current Project Context
|
||||
|
||||
```json
|
||||
!`npx shadcn@latest info --json 2>/dev/null || echo '{"error": "No shadcn project found. Run shadcn init first."}'`
|
||||
```
|
||||
|
||||
The JSON above contains the project config and installed components. Use `npx shadcn@latest docs <component>` to get documentation and example URLs for any component.
|
||||
|
||||
## Principles
|
||||
|
||||
1. **Use existing components first.** Use `npx shadcn@latest search` to check registries before writing custom UI. Check community registries too.
|
||||
2. **Compose, don't reinvent.** Settings page = Tabs + Card + form controls. Dashboard = Sidebar + Card + Chart + Table.
|
||||
3. **Use built-in variants before custom styles.** `variant="outline"`, `size="sm"`, etc.
|
||||
4. **Use semantic colors.** `bg-primary`, `text-muted-foreground` — never raw values like `bg-blue-500`.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
These rules are **always enforced**. Each links to a file with Incorrect/Correct code pairs.
|
||||
|
||||
### Styling & Tailwind → [styling.md](./rules/styling.md)
|
||||
|
||||
- **`className` for layout, not styling.** Never override component colors or typography.
|
||||
- **No `space-x-*` or `space-y-*`.** Use `flex` with `gap-*`. For vertical stacks, `flex flex-col gap-*`.
|
||||
- **Use `size-*` when width and height are equal.** `size-10` not `w-10 h-10`.
|
||||
- **Use `truncate` shorthand.** Not `overflow-hidden text-ellipsis whitespace-nowrap`.
|
||||
- **No manual `dark:` color overrides.** Use semantic tokens (`bg-background`, `text-muted-foreground`).
|
||||
- **Use `cn()` for conditional classes.** Don't write manual template literal ternaries.
|
||||
- **No manual `z-index` on overlay components.** Dialog, Sheet, Popover, etc. handle their own stacking.
|
||||
|
||||
### Forms & Inputs → [forms.md](./rules/forms.md)
|
||||
|
||||
- **Forms use `FieldGroup` + `Field`.** Never use raw `div` with `space-y-*` or `grid gap-*` for form layout.
|
||||
- **`InputGroup` uses `InputGroupInput`/`InputGroupTextarea`.** Never raw `Input`/`Textarea` inside `InputGroup`.
|
||||
- **Buttons inside inputs use `InputGroup` + `InputGroupAddon`.**
|
||||
- **Option sets (2–7 choices) use `ToggleGroup`.** Don't loop `Button` with manual active state.
|
||||
- **`FieldSet` + `FieldLegend` for grouping related checkboxes/radios.** Don't use a `div` with a heading.
|
||||
- **Field validation uses `data-invalid` + `aria-invalid`.** `data-invalid` on `Field`, `aria-invalid` on the control. For disabled: `data-disabled` on `Field`, `disabled` on the control.
|
||||
|
||||
### Component Structure → [composition.md](./rules/composition.md)
|
||||
|
||||
- **Items always inside their Group.** `SelectItem` → `SelectGroup`. `DropdownMenuItem` → `DropdownMenuGroup`. `CommandItem` → `CommandGroup`.
|
||||
- **Use `asChild` (radix) or `render` (base) for custom triggers.** Check `base` field from `npx shadcn@latest info`. → [base-vs-radix.md](./rules/base-vs-radix.md)
|
||||
- **Dialog, Sheet, and Drawer always need a Title.** `DialogTitle`, `SheetTitle`, `DrawerTitle` required for accessibility. Use `className="sr-only"` if visually hidden.
|
||||
- **Use full Card composition.** `CardHeader`/`CardTitle`/`CardDescription`/`CardContent`/`CardFooter`. Don't dump everything in `CardContent`.
|
||||
- **Button has no `isPending`/`isLoading`.** Compose with `Spinner` + `data-icon` + `disabled`.
|
||||
- **`TabsTrigger` must be inside `TabsList`.** Never render triggers directly in `Tabs`.
|
||||
- **`Avatar` always needs `AvatarFallback`.** For when the image fails to load.
|
||||
|
||||
### Use Components, Not Custom Markup → [composition.md](./rules/composition.md)
|
||||
|
||||
- **Use existing components before custom markup.** Check if a component exists before writing a styled `div`.
|
||||
- **Callouts use `Alert`.** Don't build custom styled divs.
|
||||
- **Empty states use `Empty`.** Don't build custom empty state markup.
|
||||
- **Toast via `sonner`.** Use `toast()` from `sonner`.
|
||||
- **Use `Separator`** instead of `<hr>` or `<div className="border-t">`.
|
||||
- **Use `Skeleton`** for loading placeholders. No custom `animate-pulse` divs.
|
||||
- **Use `Badge`** instead of custom styled spans.
|
||||
|
||||
### Icons → [icons.md](./rules/icons.md)
|
||||
|
||||
- **Icons in `Button` use `data-icon`.** `data-icon="inline-start"` or `data-icon="inline-end"` on the icon.
|
||||
- **No sizing classes on icons inside components.** Components handle icon sizing via CSS. No `size-4` or `w-4 h-4`.
|
||||
- **Pass icons as objects, not string keys.** `icon={CheckIcon}`, not a string lookup.
|
||||
|
||||
### CLI
|
||||
|
||||
- **Never decode or fetch preset codes manually.** Pass them directly to `npx shadcn@latest init --preset <code>`.
|
||||
|
||||
## Key Patterns
|
||||
|
||||
These are the most common patterns that differentiate correct shadcn/ui code. For edge cases, see the linked rule files above.
|
||||
|
||||
```tsx
|
||||
// Form layout: FieldGroup + Field, not div + Label.
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input id="email" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
// Validation: data-invalid on Field, aria-invalid on the control.
|
||||
<Field data-invalid>
|
||||
<FieldLabel>Email</FieldLabel>
|
||||
<Input aria-invalid />
|
||||
<FieldDescription>Invalid email.</FieldDescription>
|
||||
</Field>
|
||||
|
||||
// Icons in buttons: data-icon, no sizing classes.
|
||||
<Button>
|
||||
<SearchIcon data-icon="inline-start" />
|
||||
Search
|
||||
</Button>
|
||||
|
||||
// Spacing: gap-*, not space-y-*.
|
||||
<div className="flex flex-col gap-4"> // correct
|
||||
<div className="space-y-4"> // wrong
|
||||
|
||||
// Equal dimensions: size-*, not w-* h-*.
|
||||
<Avatar className="size-10"> // correct
|
||||
<Avatar className="w-10 h-10"> // wrong
|
||||
|
||||
// Status colors: Badge variants or semantic tokens, not raw colors.
|
||||
<Badge variant="secondary">+20.1%</Badge> // correct
|
||||
<span className="text-emerald-600">+20.1%</span> // wrong
|
||||
```
|
||||
|
||||
## Component Selection
|
||||
|
||||
| Need | Use |
|
||||
| -------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| Button/action | `Button` with appropriate variant |
|
||||
| Form inputs | `Input`, `Select`, `Combobox`, `Switch`, `Checkbox`, `RadioGroup`, `Textarea`, `InputOTP`, `Slider` |
|
||||
| Toggle between 2–5 options | `ToggleGroup` + `ToggleGroupItem` |
|
||||
| Data display | `Table`, `Card`, `Badge`, `Avatar` |
|
||||
| Navigation | `Sidebar`, `NavigationMenu`, `Breadcrumb`, `Tabs`, `Pagination` |
|
||||
| Overlays | `Dialog` (modal), `Sheet` (side panel), `Drawer` (bottom sheet), `AlertDialog` (confirmation) |
|
||||
| Feedback | `sonner` (toast), `Alert`, `Progress`, `Skeleton`, `Spinner` |
|
||||
| Command palette | `Command` inside `Dialog` |
|
||||
| Charts | `Chart` (wraps Recharts) |
|
||||
| Layout | `Card`, `Separator`, `Resizable`, `ScrollArea`, `Accordion`, `Collapsible` |
|
||||
| Empty states | `Empty` |
|
||||
| Menus | `DropdownMenu`, `ContextMenu`, `Menubar` |
|
||||
| Tooltips/info | `Tooltip`, `HoverCard`, `Popover` |
|
||||
|
||||
## Key Fields
|
||||
|
||||
The injected project context contains these key fields:
|
||||
|
||||
- **`aliases`** → use the actual alias prefix for imports (e.g. `@/`, `~/`), never hardcode.
|
||||
- **`isRSC`** → when `true`, components using `useState`, `useEffect`, event handlers, or browser APIs need `"use client"` at the top of the file. Always reference this field when advising on the directive.
|
||||
- **`tailwindVersion`** → `"v4"` uses `@theme inline` blocks; `"v3"` uses `tailwind.config.js`.
|
||||
- **`tailwindCssFile`** → the global CSS file where custom CSS variables are defined. Always edit this file, never create a new one.
|
||||
- **`style`** → component visual treatment (e.g. `nova`, `vega`).
|
||||
- **`base`** → primitive library (`radix` or `base`). Affects component APIs and available props.
|
||||
- **`iconLibrary`** → determines icon imports. Use `lucide-react` for `lucide`, `@tabler/icons-react` for `tabler`, etc. Never assume `lucide-react`.
|
||||
- **`resolvedPaths`** → exact file-system destinations for components, utils, hooks, etc.
|
||||
- **`framework`** → routing and file conventions (e.g. Next.js App Router vs Vite SPA).
|
||||
- **`packageManager`** → use this for any non-shadcn dependency installs (e.g. `pnpm add date-fns` vs `npm install date-fns`).
|
||||
|
||||
See [cli.md — `info` command](./cli.md) for the full field reference.
|
||||
|
||||
## Component Docs, Examples, and Usage
|
||||
|
||||
Run `npx shadcn@latest docs <component>` to get the URLs for a component's documentation, examples, and API reference. Fetch these URLs to get the actual content.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest docs button dialog select
|
||||
```
|
||||
|
||||
**When creating, fixing, debugging, or using a component, always run `npx shadcn@latest docs` and fetch the URLs first.** This ensures you're working with the correct API and usage patterns rather than guessing.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Get project context** — already injected above. Run `npx shadcn@latest info` again if you need to refresh.
|
||||
2. **Check installed components first** — before running `add`, always check the `components` list from project context or list the `resolvedPaths.ui` directory. Don't import components that haven't been added, and don't re-add ones already installed.
|
||||
3. **Find components** — `npx shadcn@latest search`.
|
||||
4. **Get docs and examples** — run `npx shadcn@latest docs <component>` to get URLs, then fetch them. Use `npx shadcn@latest view` to browse registry items you haven't installed. To preview changes to installed components, use `npx shadcn@latest add --diff`.
|
||||
5. **Install or update** — `npx shadcn@latest add`. When updating existing components, use `--dry-run` and `--diff` to preview changes first (see [Updating Components](#updating-components) below).
|
||||
6. **Fix imports in third-party components** — After adding components from community registries (e.g. `@bundui`, `@magicui`), check the added non-UI files for hardcoded import paths like `@/components/ui/...`. These won't match the project's actual aliases. Use `npx shadcn@latest info` to get the correct `ui` alias (e.g. `@workspace/ui/components`) and rewrite the imports accordingly. The CLI rewrites imports for its own UI files, but third-party registry components may use default paths that don't match the project.
|
||||
7. **Review added components** — After adding a component or block from any registry, **always read the added files and verify they are correct**. Check for missing sub-components (e.g. `SelectItem` without `SelectGroup`), missing imports, incorrect composition, or violations of the [Critical Rules](#critical-rules). Also replace any icon imports with the project's `iconLibrary` from the project context (e.g. if the registry item uses `lucide-react` but the project uses `hugeicons`, swap the imports and icon names accordingly). Fix all issues before moving on.
|
||||
8. **Registry must be explicit** — When the user asks to add a block or component, **do not guess the registry**. If no registry is specified (e.g. user says "add a login block" without specifying `@shadcn`, `@tailark`, etc.), ask which registry to use. Never default to a registry on behalf of the user.
|
||||
9. **Switching presets** — Ask the user first: **reinstall**, **merge**, or **skip**?
|
||||
- **Reinstall**: `npx shadcn@latest init --preset <code> --force --reinstall`. Overwrites all components.
|
||||
- **Merge**: `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to list installed components, then for each installed component use `--dry-run` and `--diff` to [smart merge](#updating-components) it individually.
|
||||
- **Skip**: `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS, leaves components as-is.
|
||||
|
||||
## Updating Components
|
||||
|
||||
When the user asks to update a component from upstream while keeping their local changes, use `--dry-run` and `--diff` to intelligently merge. **NEVER fetch raw files from GitHub manually — always use the CLI.**
|
||||
|
||||
1. Run `npx shadcn@latest add <component> --dry-run` to see all files that would be affected.
|
||||
2. For each file, run `npx shadcn@latest add <component> --diff <file>` to see what changed upstream vs local.
|
||||
3. Decide per file based on the diff:
|
||||
- No local changes → safe to overwrite.
|
||||
- Has local changes → read the local file, analyze the diff, and apply upstream updates while preserving local modifications.
|
||||
- User says "just update everything" → use `--overwrite`, but confirm first.
|
||||
4. **Never use `--overwrite` without the user's explicit approval.**
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Create a new project.
|
||||
npx shadcn@latest init --name my-app --preset base-nova
|
||||
npx shadcn@latest init --name my-app --preset a2r6bw --template vite
|
||||
|
||||
# Create a monorepo project.
|
||||
npx shadcn@latest init --name my-app --preset base-nova --monorepo
|
||||
npx shadcn@latest init --name my-app --preset base-nova --template next --monorepo
|
||||
|
||||
# Initialize existing project.
|
||||
npx shadcn@latest init --preset base-nova
|
||||
npx shadcn@latest init --defaults # shortcut: --template=next --preset=base-nova
|
||||
|
||||
# Add components.
|
||||
npx shadcn@latest add button card dialog
|
||||
npx shadcn@latest add @magicui/shimmer-button
|
||||
npx shadcn@latest add --all
|
||||
|
||||
# Preview changes before adding/updating.
|
||||
npx shadcn@latest add button --dry-run
|
||||
npx shadcn@latest add button --diff button.tsx
|
||||
npx shadcn@latest add @acme/form --view button.tsx
|
||||
|
||||
# Search registries.
|
||||
npx shadcn@latest search @shadcn -q "sidebar"
|
||||
npx shadcn@latest search @tailark -q "stats"
|
||||
|
||||
# Get component docs and example URLs.
|
||||
npx shadcn@latest docs button dialog select
|
||||
|
||||
# View registry item details (for items not yet installed).
|
||||
npx shadcn@latest view @shadcn/button
|
||||
```
|
||||
|
||||
**Named presets:** `base-nova`, `radix-nova`
|
||||
**Templates:** `next`, `vite`, `start`, `react-router`, `astro` (all support `--monorepo`) and `laravel` (not supported for monorepo)
|
||||
**Preset codes:** Base62 strings starting with `a` (e.g. `a2r6bw`), from [ui.shadcn.com](https://ui.shadcn.com).
|
||||
|
||||
## Detailed References
|
||||
|
||||
- [rules/forms.md](./rules/forms.md) — FieldGroup, Field, InputGroup, ToggleGroup, FieldSet, validation states
|
||||
- [rules/composition.md](./rules/composition.md) — Groups, overlays, Card, Tabs, Avatar, Alert, Empty, Toast, Separator, Skeleton, Badge, Button loading
|
||||
- [rules/icons.md](./rules/icons.md) — data-icon, icon sizing, passing icons as objects
|
||||
- [rules/styling.md](./rules/styling.md) — Semantic colors, variants, className, spacing, size, truncate, dark mode, cn(), z-index
|
||||
- [rules/base-vs-radix.md](./rules/base-vs-radix.md) — asChild vs render, Select, ToggleGroup, Slider, Accordion
|
||||
- [cli.md](./cli.md) — Commands, flags, presets, templates
|
||||
- [customization.md](./customization.md) — Theming, CSS variables, extending components
|
||||
@@ -0,0 +1,5 @@
|
||||
interface:
|
||||
display_name: "shadcn/ui"
|
||||
short_description: "Manages shadcn/ui components — adding, searching, fixing, debugging, styling, and composing UI."
|
||||
icon_small: "./assets/shadcn-small.png"
|
||||
icon_large: "./assets/shadcn.png"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,255 @@
|
||||
# shadcn CLI Reference
|
||||
|
||||
Configuration is read from `components.json`.
|
||||
|
||||
> **IMPORTANT:** Always run commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest`. Check `packageManager` from project context to choose the right one. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
|
||||
|
||||
> **IMPORTANT:** Only use the flags documented below. Do not invent or guess flags — if a flag isn't listed here, it doesn't exist. The CLI auto-detects the package manager from the project's lockfile; there is no `--package-manager` flag.
|
||||
|
||||
## Contents
|
||||
|
||||
- Commands: init, add (dry-run, smart merge), search, view, docs, info, build
|
||||
- Templates: next, vite, start, react-router, astro
|
||||
- Presets: named, code, URL formats and fields
|
||||
- Switching presets
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
### `init` — Initialize or create a project
|
||||
|
||||
```bash
|
||||
npx shadcn@latest init [components...] [options]
|
||||
```
|
||||
|
||||
Initializes shadcn/ui in an existing project or creates a new project (when `--name` is provided). Optionally installs components in the same step.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| ----------------------- | ----- | --------------------------------------------------------- | ------- |
|
||||
| `--template <template>` | `-t` | Template (next, start, vite, next-monorepo, react-router) | — |
|
||||
| `--preset [name]` | `-p` | Preset configuration (named, code, or URL) | — |
|
||||
| `--yes` | `-y` | Skip confirmation prompt | `true` |
|
||||
| `--defaults` | `-d` | Use defaults (`--template=next --preset=base-nova`) | `false` |
|
||||
| `--force` | `-f` | Force overwrite existing configuration | `false` |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
| `--name <name>` | `-n` | Name for new project | — |
|
||||
| `--silent` | `-s` | Mute output | `false` |
|
||||
| `--rtl` | | Enable RTL support | — |
|
||||
| `--reinstall` | | Re-install existing UI components | `false` |
|
||||
| `--monorepo` | | Scaffold a monorepo project | — |
|
||||
| `--no-monorepo` | | Skip the monorepo prompt | — |
|
||||
|
||||
`npx shadcn@latest create` is an alias for `npx shadcn@latest init`.
|
||||
|
||||
### `add` — Add components
|
||||
|
||||
> **IMPORTANT:** To compare local components against upstream or to preview changes, ALWAYS use `npx shadcn@latest add <component> --dry-run`, `--diff`, or `--view`. NEVER fetch raw files from GitHub or other sources manually. The CLI handles registry resolution, file paths, and CSS diffing automatically.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add [components...] [options]
|
||||
```
|
||||
|
||||
Accepts component names, registry-prefixed names (`@magicui/shimmer-button`), URLs, or local paths.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| --------------- | ----- | -------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| `--yes` | `-y` | Skip confirmation prompt | `false` |
|
||||
| `--overwrite` | `-o` | Overwrite existing files | `false` |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
| `--all` | `-a` | Add all available components | `false` |
|
||||
| `--path <path>` | `-p` | Target path for the component | — |
|
||||
| `--silent` | `-s` | Mute output | `false` |
|
||||
| `--dry-run` | | Preview all changes without writing files | `false` |
|
||||
| `--diff [path]` | | Show diffs. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
|
||||
| `--view [path]` | | Show file contents. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
|
||||
|
||||
#### Dry-Run Mode
|
||||
|
||||
Use `--dry-run` to preview what `add` would do without writing any files. `--diff` and `--view` both imply `--dry-run`.
|
||||
|
||||
```bash
|
||||
# Preview all changes.
|
||||
npx shadcn@latest add button --dry-run
|
||||
|
||||
# Show diffs for all files (top 5).
|
||||
npx shadcn@latest add button --diff
|
||||
|
||||
# Show the diff for a specific file.
|
||||
npx shadcn@latest add button --diff button.tsx
|
||||
|
||||
# Show contents for all files (top 5).
|
||||
npx shadcn@latest add button --view
|
||||
|
||||
# Show the full content of a specific file.
|
||||
npx shadcn@latest add button --view button.tsx
|
||||
|
||||
# Works with URLs too.
|
||||
npx shadcn@latest add https://api.npoint.io/abc123 --dry-run
|
||||
|
||||
# CSS diffs.
|
||||
npx shadcn@latest add button --diff globals.css
|
||||
```
|
||||
|
||||
**When to use dry-run:**
|
||||
|
||||
- When the user asks "what files will this add?" or "what will this change?" — use `--dry-run`.
|
||||
- Before overwriting existing components — use `--diff` to preview the changes first.
|
||||
- When the user wants to inspect component source code without installing — use `--view`.
|
||||
- When checking what CSS changes would be made to `globals.css` — use `--diff globals.css`.
|
||||
- When the user asks to review or audit third-party registry code before installing — use `--view` to inspect the source.
|
||||
|
||||
> **`npx shadcn@latest add --dry-run` vs `npx shadcn@latest view`:** Prefer `npx shadcn@latest add --dry-run/--diff/--view` over `npx shadcn@latest view` when the user wants to preview changes to their project. `npx shadcn@latest view` only shows raw registry metadata. `npx shadcn@latest add --dry-run` shows exactly what would happen in the user's project: resolved file paths, diffs against existing files, and CSS updates. Use `npx shadcn@latest view` only when the user wants to browse registry info without a project context.
|
||||
|
||||
#### Smart Merge from Upstream
|
||||
|
||||
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full workflow.
|
||||
|
||||
### `search` — Search registries
|
||||
|
||||
```bash
|
||||
npx shadcn@latest search <registries...> [options]
|
||||
```
|
||||
|
||||
Fuzzy search across registries. Also aliased as `npx shadcn@latest list`. Without `-q`, lists all items.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| ------------------- | ----- | ---------------------- | ------- |
|
||||
| `--query <query>` | `-q` | Search query | — |
|
||||
| `--limit <number>` | `-l` | Max items per registry | `100` |
|
||||
| `--offset <number>` | `-o` | Items to skip | `0` |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
|
||||
### `view` — View item details
|
||||
|
||||
```bash
|
||||
npx shadcn@latest view <items...> [options]
|
||||
```
|
||||
|
||||
Displays item info including file contents. Example: `npx shadcn@latest view @shadcn/button`.
|
||||
|
||||
### `docs` — Get component documentation URLs
|
||||
|
||||
```bash
|
||||
npx shadcn@latest docs <components...> [options]
|
||||
```
|
||||
|
||||
Outputs resolved URLs for component documentation, examples, and API references. Accepts one or more component names. Fetch the URLs to get the actual content.
|
||||
|
||||
Example output for `npx shadcn@latest docs input button`:
|
||||
|
||||
```
|
||||
base radix
|
||||
|
||||
input
|
||||
docs https://ui.shadcn.com/docs/components/radix/input
|
||||
examples https://raw.githubusercontent.com/.../examples/input-example.tsx
|
||||
|
||||
button
|
||||
docs https://ui.shadcn.com/docs/components/radix/button
|
||||
examples https://raw.githubusercontent.com/.../examples/button-example.tsx
|
||||
```
|
||||
|
||||
Some components include an `api` link to the underlying library (e.g. `cmdk` for the command component).
|
||||
|
||||
### `diff` — Check for updates
|
||||
|
||||
Do not use this command. Use `npx shadcn@latest add --diff` instead.
|
||||
|
||||
### `info` — Project information
|
||||
|
||||
```bash
|
||||
npx shadcn@latest info [options]
|
||||
```
|
||||
|
||||
Displays project info and `components.json` configuration. Run this first to discover the project's framework, aliases, Tailwind version, and resolved paths.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| ------------- | ----- | ----------------- | ------- |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
|
||||
**Project Info fields:**
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| -------------------- | --------- | ------------------------------------------------------------------ |
|
||||
| `framework` | `string` | Detected framework (`next`, `vite`, `react-router`, `start`, etc.) |
|
||||
| `frameworkVersion` | `string` | Framework version (e.g. `15.2.4`) |
|
||||
| `isSrcDir` | `boolean` | Whether the project uses a `src/` directory |
|
||||
| `isRSC` | `boolean` | Whether React Server Components are enabled |
|
||||
| `isTsx` | `boolean` | Whether the project uses TypeScript |
|
||||
| `tailwindVersion` | `string` | `"v3"` or `"v4"` |
|
||||
| `tailwindConfigFile` | `string` | Path to the Tailwind config file |
|
||||
| `tailwindCssFile` | `string` | Path to the global CSS file |
|
||||
| `aliasPrefix` | `string` | Import alias prefix (e.g. `@`, `~`, `@/`) |
|
||||
| `packageManager` | `string` | Detected package manager (`npm`, `pnpm`, `yarn`, `bun`) |
|
||||
|
||||
**Components.json fields:**
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| -------------------- | --------- | ------------------------------------------------------------------------------------------ |
|
||||
| `base` | `string` | Primitive library (`radix` or `base`) — determines component APIs and available props |
|
||||
| `style` | `string` | Visual style (e.g. `nova`, `vega`) |
|
||||
| `rsc` | `boolean` | RSC flag from config |
|
||||
| `tsx` | `boolean` | TypeScript flag |
|
||||
| `tailwind.config` | `string` | Tailwind config path |
|
||||
| `tailwind.css` | `string` | Global CSS path — this is where custom CSS variables go |
|
||||
| `iconLibrary` | `string` | Icon library — determines icon import package (e.g. `lucide-react`, `@tabler/icons-react`) |
|
||||
| `aliases.components` | `string` | Component import alias (e.g. `@/components`) |
|
||||
| `aliases.utils` | `string` | Utils import alias (e.g. `@/lib/utils`) |
|
||||
| `aliases.ui` | `string` | UI component alias (e.g. `@/components/ui`) |
|
||||
| `aliases.lib` | `string` | Lib alias (e.g. `@/lib`) |
|
||||
| `aliases.hooks` | `string` | Hooks alias (e.g. `@/hooks`) |
|
||||
| `resolvedPaths` | `object` | Absolute file-system paths for each alias |
|
||||
| `registries` | `object` | Configured custom registries |
|
||||
|
||||
**Links fields:**
|
||||
|
||||
The `info` output includes a **Links** section with templated URLs for component docs, source, and examples. For resolved URLs, use `npx shadcn@latest docs <component>` instead.
|
||||
|
||||
### `build` — Build a custom registry
|
||||
|
||||
```bash
|
||||
npx shadcn@latest build [registry] [options]
|
||||
```
|
||||
|
||||
Builds `registry.json` into individual JSON files for distribution. Default input: `./registry.json`, default output: `./public/r`.
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
| ----------------- | ----- | ----------------- | ------------ |
|
||||
| `--output <path>` | `-o` | Output directory | `./public/r` |
|
||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
||||
|
||||
---
|
||||
|
||||
## Templates
|
||||
|
||||
| Value | Framework | Monorepo support |
|
||||
| -------------- | -------------- | ---------------- |
|
||||
| `next` | Next.js | Yes |
|
||||
| `vite` | Vite | Yes |
|
||||
| `start` | TanStack Start | Yes |
|
||||
| `react-router` | React Router | Yes |
|
||||
| `astro` | Astro | Yes |
|
||||
| `laravel` | Laravel | No |
|
||||
|
||||
All templates support monorepo scaffolding via the `--monorepo` flag. When passed, the CLI uses a monorepo-specific template directory (e.g. `next-monorepo`, `vite-monorepo`). When neither `--monorepo` nor `--no-monorepo` is passed, the CLI prompts interactively. Laravel does not support monorepo scaffolding.
|
||||
|
||||
---
|
||||
|
||||
## Presets
|
||||
|
||||
Three ways to specify a preset via `--preset`:
|
||||
|
||||
1. **Named:** `--preset base-nova` or `--preset radix-nova`
|
||||
2. **Code:** `--preset a2r6bw` (base62 string, starts with lowercase `a`)
|
||||
3. **URL:** `--preset "https://ui.shadcn.com/init?base=radix&style=nova&..."`
|
||||
|
||||
> **IMPORTANT:** Never try to decode, fetch, or resolve preset codes manually. Preset codes are opaque — pass them directly to `npx shadcn@latest init --preset <code>` and let the CLI handle resolution.
|
||||
|
||||
## Switching Presets
|
||||
|
||||
Ask the user first: **reinstall**, **merge**, or **skip** existing components?
|
||||
|
||||
- **Re-install** → `npx shadcn@latest init --preset <code> --force --reinstall`. Overwrites all component files with the new preset styles. Use when the user hasn't customized components.
|
||||
- **Merge** → `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to get the list of installed components and use the [smart merge workflow](./SKILL.md#updating-components) to update them one by one, preserving local changes. Use when the user has customized components.
|
||||
- **Skip** → `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS variables, leaves existing components as-is.
|
||||
@@ -0,0 +1,202 @@
|
||||
# Customization & Theming
|
||||
|
||||
Components reference semantic CSS variable tokens. Change the variables to change every component.
|
||||
|
||||
## Contents
|
||||
|
||||
- How it works (CSS variables → Tailwind utilities → components)
|
||||
- Color variables and OKLCH format
|
||||
- Dark mode setup
|
||||
- Changing the theme (presets, CSS variables)
|
||||
- Adding custom colors (Tailwind v3 and v4)
|
||||
- Border radius
|
||||
- Customizing components (variants, className, wrappers)
|
||||
- Checking for updates
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
1. CSS variables defined in `:root` (light) and `.dark` (dark mode).
|
||||
2. Tailwind maps them to utilities: `bg-primary`, `text-muted-foreground`, etc.
|
||||
3. Components use these utilities — changing a variable changes all components that reference it.
|
||||
|
||||
---
|
||||
|
||||
## Color Variables
|
||||
|
||||
Every color follows the `name` / `name-foreground` convention. The base variable is for backgrounds, `-foreground` is for text/icons on that background.
|
||||
|
||||
| Variable | Purpose |
|
||||
| -------------------------------------------- | -------------------------------- |
|
||||
| `--background` / `--foreground` | Page background and default text |
|
||||
| `--card` / `--card-foreground` | Card surfaces |
|
||||
| `--primary` / `--primary-foreground` | Primary buttons and actions |
|
||||
| `--secondary` / `--secondary-foreground` | Secondary actions |
|
||||
| `--muted` / `--muted-foreground` | Muted/disabled states |
|
||||
| `--accent` / `--accent-foreground` | Hover and accent states |
|
||||
| `--destructive` / `--destructive-foreground` | Error and destructive actions |
|
||||
| `--border` | Default border color |
|
||||
| `--input` | Form input borders |
|
||||
| `--ring` | Focus ring color |
|
||||
| `--chart-1` through `--chart-5` | Chart/data visualization |
|
||||
| `--sidebar-*` | Sidebar-specific colors |
|
||||
| `--surface` / `--surface-foreground` | Secondary surface |
|
||||
|
||||
Colors use OKLCH: `--primary: oklch(0.205 0 0)` where values are lightness (0–1), chroma (0 = gray), and hue (0–360).
|
||||
|
||||
---
|
||||
|
||||
## Dark Mode
|
||||
|
||||
Class-based toggle via `.dark` on the root element. In Next.js, use `next-themes`:
|
||||
|
||||
```tsx
|
||||
import { ThemeProvider } from "next-themes"
|
||||
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Changing the Theme
|
||||
|
||||
```bash
|
||||
# Apply a preset code from ui.shadcn.com.
|
||||
npx shadcn@latest init --preset a2r6bw --force
|
||||
|
||||
# Switch to a named preset.
|
||||
npx shadcn@latest init --preset radix-nova --force
|
||||
npx shadcn@latest init --reinstall # update existing components to match
|
||||
|
||||
# Use a custom theme URL.
|
||||
npx shadcn@latest init --preset "https://ui.shadcn.com/init?base=radix&style=nova&theme=blue&..." --force
|
||||
```
|
||||
|
||||
Or edit CSS variables directly in `globals.css`.
|
||||
|
||||
---
|
||||
|
||||
## Adding Custom Colors
|
||||
|
||||
Add variables to the file at `tailwindCssFile` from `npx shadcn@latest info` (typically `globals.css`). Never create a new CSS file for this.
|
||||
|
||||
```css
|
||||
/* 1. Define in the global CSS file. */
|
||||
:root {
|
||||
--warning: oklch(0.84 0.16 84);
|
||||
--warning-foreground: oklch(0.28 0.07 46);
|
||||
}
|
||||
.dark {
|
||||
--warning: oklch(0.41 0.11 46);
|
||||
--warning-foreground: oklch(0.99 0.02 95);
|
||||
}
|
||||
```
|
||||
|
||||
```css
|
||||
/* 2a. Register with Tailwind v4 (@theme inline). */
|
||||
@theme inline {
|
||||
--color-warning: var(--warning);
|
||||
--color-warning-foreground: var(--warning-foreground);
|
||||
}
|
||||
```
|
||||
|
||||
When `tailwindVersion` is `"v3"` (check via `npx shadcn@latest info`), register in `tailwind.config.js` instead:
|
||||
|
||||
```js
|
||||
// 2b. Register with Tailwind v3 (tailwind.config.js).
|
||||
module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
warning: "oklch(var(--warning) / <alpha-value>)",
|
||||
"warning-foreground":
|
||||
"oklch(var(--warning-foreground) / <alpha-value>)",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// 3. Use in components.
|
||||
<div className="bg-warning text-warning-foreground">Warning</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Border Radius
|
||||
|
||||
`--radius` controls border radius globally. Components derive values from it (`rounded-lg` = `var(--radius)`, `rounded-md` = `calc(var(--radius) - 2px)`).
|
||||
|
||||
---
|
||||
|
||||
## Customizing Components
|
||||
|
||||
See also: [rules/styling.md](./rules/styling.md) for Incorrect/Correct examples.
|
||||
|
||||
Prefer these approaches in order:
|
||||
|
||||
### 1. Built-in variants
|
||||
|
||||
```tsx
|
||||
<Button variant="outline" size="sm">Click</Button>
|
||||
```
|
||||
|
||||
### 2. Tailwind classes via `className`
|
||||
|
||||
```tsx
|
||||
<Card className="max-w-md mx-auto">...</Card>
|
||||
```
|
||||
|
||||
### 3. Add a new variant
|
||||
|
||||
Edit the component source to add a variant via `cva`:
|
||||
|
||||
```tsx
|
||||
// components/ui/button.tsx
|
||||
warning: "bg-warning text-warning-foreground hover:bg-warning/90",
|
||||
```
|
||||
|
||||
### 4. Wrapper components
|
||||
|
||||
Compose shadcn/ui primitives into higher-level components:
|
||||
|
||||
```tsx
|
||||
export function ConfirmDialog({ title, description, onConfirm, children }) {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm}>Confirm</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checking for Updates
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add button --diff
|
||||
```
|
||||
|
||||
To preview exactly what would change before updating, use `--dry-run` and `--diff`:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add button --dry-run # see all affected files
|
||||
npx shadcn@latest add button --diff button.tsx # see the diff for a specific file
|
||||
```
|
||||
|
||||
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full smart merge workflow.
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"skill_name": "shadcn",
|
||||
"evals": [
|
||||
{
|
||||
"id": 1,
|
||||
"prompt": "I'm building a Next.js app with shadcn/ui (base-nova preset, lucide icons). Create a settings form component with fields for: full name, email address, and notification preferences (email, SMS, push notifications as toggle options). Add validation states for required fields.",
|
||||
"expected_output": "A React component using FieldGroup, Field, ToggleGroup, data-invalid/aria-invalid validation, gap-* spacing, and semantic colors.",
|
||||
"files": [],
|
||||
"expectations": [
|
||||
"Uses FieldGroup and Field components for form layout instead of raw div with space-y",
|
||||
"Uses Switch for independent on/off notification toggles (not looping Button with manual active state)",
|
||||
"Uses data-invalid on Field and aria-invalid on the input control for validation states",
|
||||
"Uses gap-* (e.g. gap-4, gap-6) instead of space-y-* or space-x-* for spacing",
|
||||
"Uses semantic color tokens (e.g. bg-background, text-muted-foreground, text-destructive) instead of raw colors like bg-red-500",
|
||||
"No manual dark: color overrides"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"prompt": "Create a dialog component for editing a user profile. It should have the user's avatar at the top, input fields for name and bio, and Save/Cancel buttons with appropriate icons. Using shadcn/ui with radix-nova preset and tabler icons.",
|
||||
"expected_output": "A React component with DialogTitle, Avatar+AvatarFallback, data-icon on icon buttons, no icon sizing classes, tabler icon imports.",
|
||||
"files": [],
|
||||
"expectations": [
|
||||
"Includes DialogTitle for accessibility (visible or with sr-only class)",
|
||||
"Avatar component includes AvatarFallback",
|
||||
"Icons on buttons use the data-icon attribute (data-icon=\"inline-start\" or data-icon=\"inline-end\")",
|
||||
"No sizing classes on icons inside components (no size-4, w-4, h-4, etc.)",
|
||||
"Uses tabler icons (@tabler/icons-react) instead of lucide-react",
|
||||
"Uses asChild for custom triggers (radix preset)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"prompt": "Create a dashboard component that shows 4 stat cards in a grid. Each card has a title, large number, percentage change badge, and a loading skeleton state. Using shadcn/ui with base-nova preset and lucide icons.",
|
||||
"expected_output": "A React component with full Card composition, Skeleton for loading, Badge for changes, semantic colors, gap-* spacing.",
|
||||
"files": [],
|
||||
"expectations": [
|
||||
"Uses full Card composition with CardHeader, CardTitle, CardContent (not dumping everything into CardContent)",
|
||||
"Uses Skeleton component for loading placeholders instead of custom animate-pulse divs",
|
||||
"Uses Badge component for percentage change instead of custom styled spans",
|
||||
"Uses semantic color tokens instead of raw color values like bg-green-500 or text-red-600",
|
||||
"Uses gap-* instead of space-y-* or space-x-* for spacing",
|
||||
"Uses size-* when width and height are equal instead of separate w-* h-*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
# shadcn MCP Server
|
||||
|
||||
The CLI includes an MCP server that lets AI assistants search, browse, view, and install components from registries.
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
shadcn mcp # start the MCP server (stdio)
|
||||
shadcn mcp init # write config for your editor
|
||||
```
|
||||
|
||||
Editor config files:
|
||||
|
||||
| Editor | Config file |
|
||||
|--------|------------|
|
||||
| Claude Code | `.mcp.json` |
|
||||
| Cursor | `.cursor/mcp.json` |
|
||||
| VS Code | `.vscode/mcp.json` |
|
||||
| OpenCode | `opencode.json` |
|
||||
| Codex | `~/.codex/config.toml` (manual) |
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
> **Tip:** MCP tools handle registry operations (search, view, install). For project configuration (aliases, framework, Tailwind version), use `npx shadcn@latest info` — there is no MCP equivalent.
|
||||
|
||||
### `shadcn:get_project_registries`
|
||||
|
||||
Returns registry names from `components.json`. Errors if no `components.json` exists.
|
||||
|
||||
**Input:** none
|
||||
|
||||
### `shadcn:list_items_in_registries`
|
||||
|
||||
Lists all items from one or more registries.
|
||||
|
||||
**Input:** `registries` (string[]), `limit` (number, optional), `offset` (number, optional)
|
||||
|
||||
### `shadcn:search_items_in_registries`
|
||||
|
||||
Fuzzy search across registries.
|
||||
|
||||
**Input:** `registries` (string[]), `query` (string), `limit` (number, optional), `offset` (number, optional)
|
||||
|
||||
### `shadcn:view_items_in_registries`
|
||||
|
||||
View item details including full file contents.
|
||||
|
||||
**Input:** `items` (string[]) — e.g. `["@shadcn/button", "@shadcn/card"]`
|
||||
|
||||
### `shadcn:get_item_examples_from_registries`
|
||||
|
||||
Find usage examples and demos with source code.
|
||||
|
||||
**Input:** `registries` (string[]), `query` (string) — e.g. `"accordion-demo"`, `"button example"`
|
||||
|
||||
### `shadcn:get_add_command_for_items`
|
||||
|
||||
Returns the CLI install command.
|
||||
|
||||
**Input:** `items` (string[]) — e.g. `["@shadcn/button"]`
|
||||
|
||||
### `shadcn:get_audit_checklist`
|
||||
|
||||
Returns a checklist for verifying components (imports, deps, lint, TypeScript).
|
||||
|
||||
**Input:** none
|
||||
|
||||
---
|
||||
|
||||
## Configuring Registries
|
||||
|
||||
Registries are set in `components.json`. The `@shadcn` registry is always built-in.
|
||||
|
||||
```json
|
||||
{
|
||||
"registries": {
|
||||
"@acme": "https://acme.com/r/{name}.json",
|
||||
"@private": {
|
||||
"url": "https://private.com/r/{name}.json",
|
||||
"headers": { "Authorization": "Bearer ${MY_TOKEN}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Names must start with `@`.
|
||||
- URLs must contain `{name}`.
|
||||
- `${VAR}` references are resolved from environment variables.
|
||||
|
||||
Community registry index: `https://ui.shadcn.com/r/registries.json`
|
||||
@@ -0,0 +1,306 @@
|
||||
# Base vs Radix
|
||||
|
||||
API differences between `base` and `radix`. Check the `base` field from `npx shadcn@latest info`.
|
||||
|
||||
## Contents
|
||||
|
||||
- Composition: asChild vs render
|
||||
- Button / trigger as non-button element
|
||||
- Select (items prop, placeholder, positioning, multiple, object values)
|
||||
- ToggleGroup (type vs multiple)
|
||||
- Slider (scalar vs array)
|
||||
- Accordion (type and defaultValue)
|
||||
|
||||
---
|
||||
|
||||
## Composition: asChild (radix) vs render (base)
|
||||
|
||||
Radix uses `asChild` to replace the default element. Base uses `render`. Don't wrap triggers in extra elements.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<DialogTrigger>
|
||||
<div>
|
||||
<Button>Open</Button>
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
```
|
||||
|
||||
**Correct (radix):**
|
||||
|
||||
```tsx
|
||||
<DialogTrigger asChild>
|
||||
<Button>Open</Button>
|
||||
</DialogTrigger>
|
||||
```
|
||||
|
||||
**Correct (base):**
|
||||
|
||||
```tsx
|
||||
<DialogTrigger render={<Button />}>Open</DialogTrigger>
|
||||
```
|
||||
|
||||
This applies to all trigger and close components: `DialogTrigger`, `SheetTrigger`, `AlertDialogTrigger`, `DropdownMenuTrigger`, `PopoverTrigger`, `TooltipTrigger`, `CollapsibleTrigger`, `DialogClose`, `SheetClose`, `NavigationMenuLink`, `BreadcrumbLink`, `SidebarMenuButton`, `Badge`, `Item`.
|
||||
|
||||
---
|
||||
|
||||
## Button / trigger as non-button element (base only)
|
||||
|
||||
When `render` changes an element to a non-button (`<a>`, `<span>`), add `nativeButton={false}`.
|
||||
|
||||
**Incorrect (base):** missing `nativeButton={false}`.
|
||||
|
||||
```tsx
|
||||
<Button render={<a href="/docs" />}>Read the docs</Button>
|
||||
```
|
||||
|
||||
**Correct (base):**
|
||||
|
||||
```tsx
|
||||
<Button render={<a href="/docs" />} nativeButton={false}>
|
||||
Read the docs
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Correct (radix):**
|
||||
|
||||
```tsx
|
||||
<Button asChild>
|
||||
<a href="/docs">Read the docs</a>
|
||||
</Button>
|
||||
```
|
||||
|
||||
Same for triggers whose `render` is not a `Button`:
|
||||
|
||||
```tsx
|
||||
// base.
|
||||
<PopoverTrigger render={<InputGroupAddon />} nativeButton={false}>
|
||||
Pick date
|
||||
</PopoverTrigger>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Select
|
||||
|
||||
**items prop (base only).** Base requires an `items` prop on the root. Radix uses inline JSX only.
|
||||
|
||||
**Incorrect (base):**
|
||||
|
||||
```tsx
|
||||
<Select>
|
||||
<SelectTrigger><SelectValue placeholder="Select a fruit" /></SelectTrigger>
|
||||
</Select>
|
||||
```
|
||||
|
||||
**Correct (base):**
|
||||
|
||||
```tsx
|
||||
const items = [
|
||||
{ label: "Select a fruit", value: null },
|
||||
{ label: "Apple", value: "apple" },
|
||||
{ label: "Banana", value: "banana" },
|
||||
]
|
||||
|
||||
<Select items={items}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
```
|
||||
|
||||
**Correct (radix):**
|
||||
|
||||
```tsx
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a fruit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="apple">Apple</SelectItem>
|
||||
<SelectItem value="banana">Banana</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
```
|
||||
|
||||
**Placeholder.** Base uses a `{ value: null }` item in the items array. Radix uses `<SelectValue placeholder="...">`.
|
||||
|
||||
**Content positioning.** Base uses `alignItemWithTrigger`. Radix uses `position`.
|
||||
|
||||
```tsx
|
||||
// base.
|
||||
<SelectContent alignItemWithTrigger={false} side="bottom">
|
||||
|
||||
// radix.
|
||||
<SelectContent position="popper">
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Select — multiple selection and object values (base only)
|
||||
|
||||
Base supports `multiple`, render-function children on `SelectValue`, and object values with `itemToStringValue`. Radix is single-select with string values only.
|
||||
|
||||
**Correct (base — multiple selection):**
|
||||
|
||||
```tsx
|
||||
<Select items={items} multiple defaultValue={[]}>
|
||||
<SelectTrigger>
|
||||
<SelectValue>
|
||||
{(value: string[]) => value.length === 0 ? "Select fruits" : `${value.length} selected`}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
...
|
||||
</Select>
|
||||
```
|
||||
|
||||
**Correct (base — object values):**
|
||||
|
||||
```tsx
|
||||
<Select defaultValue={plans[0]} itemToStringValue={(plan) => plan.name}>
|
||||
<SelectTrigger>
|
||||
<SelectValue>{(value) => value.name}</SelectValue>
|
||||
</SelectTrigger>
|
||||
...
|
||||
</Select>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ToggleGroup
|
||||
|
||||
Base uses a `multiple` boolean prop. Radix uses `type="single"` or `type="multiple"`.
|
||||
|
||||
**Incorrect (base):**
|
||||
|
||||
```tsx
|
||||
<ToggleGroup type="single" defaultValue="daily">
|
||||
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
```
|
||||
|
||||
**Correct (base):**
|
||||
|
||||
```tsx
|
||||
// Single (no prop needed), defaultValue is always an array.
|
||||
<ToggleGroup defaultValue={["daily"]} spacing={2}>
|
||||
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
|
||||
// Multi-selection.
|
||||
<ToggleGroup multiple>
|
||||
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
|
||||
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
```
|
||||
|
||||
**Correct (radix):**
|
||||
|
||||
```tsx
|
||||
// Single, defaultValue is a string.
|
||||
<ToggleGroup type="single" defaultValue="daily" spacing={2}>
|
||||
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
|
||||
// Multi-selection.
|
||||
<ToggleGroup type="multiple">
|
||||
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
|
||||
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
```
|
||||
|
||||
**Controlled single value:**
|
||||
|
||||
```tsx
|
||||
// base — wrap/unwrap arrays.
|
||||
const [value, setValue] = React.useState("normal")
|
||||
<ToggleGroup value={[value]} onValueChange={(v) => setValue(v[0])}>
|
||||
|
||||
// radix — plain string.
|
||||
const [value, setValue] = React.useState("normal")
|
||||
<ToggleGroup type="single" value={value} onValueChange={setValue}>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Slider
|
||||
|
||||
Base accepts a plain number for a single thumb. Radix always requires an array.
|
||||
|
||||
**Incorrect (base):**
|
||||
|
||||
```tsx
|
||||
<Slider defaultValue={[50]} max={100} step={1} />
|
||||
```
|
||||
|
||||
**Correct (base):**
|
||||
|
||||
```tsx
|
||||
<Slider defaultValue={50} max={100} step={1} />
|
||||
```
|
||||
|
||||
**Correct (radix):**
|
||||
|
||||
```tsx
|
||||
<Slider defaultValue={[50]} max={100} step={1} />
|
||||
```
|
||||
|
||||
Both use arrays for range sliders. Controlled `onValueChange` in base may need a cast:
|
||||
|
||||
```tsx
|
||||
// base.
|
||||
const [value, setValue] = React.useState([0.3, 0.7])
|
||||
<Slider value={value} onValueChange={(v) => setValue(v as number[])} />
|
||||
|
||||
// radix.
|
||||
const [value, setValue] = React.useState([0.3, 0.7])
|
||||
<Slider value={value} onValueChange={setValue} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Accordion
|
||||
|
||||
Radix requires `type="single"` or `type="multiple"` and supports `collapsible`. `defaultValue` is a string. Base uses no `type` prop, uses `multiple` boolean, and `defaultValue` is always an array.
|
||||
|
||||
**Incorrect (base):**
|
||||
|
||||
```tsx
|
||||
<Accordion type="single" collapsible defaultValue="item-1">
|
||||
<AccordionItem value="item-1">...</AccordionItem>
|
||||
</Accordion>
|
||||
```
|
||||
|
||||
**Correct (base):**
|
||||
|
||||
```tsx
|
||||
<Accordion defaultValue={["item-1"]}>
|
||||
<AccordionItem value="item-1">...</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
// Multi-select.
|
||||
<Accordion multiple defaultValue={["item-1", "item-2"]}>
|
||||
<AccordionItem value="item-1">...</AccordionItem>
|
||||
<AccordionItem value="item-2">...</AccordionItem>
|
||||
</Accordion>
|
||||
```
|
||||
|
||||
**Correct (radix):**
|
||||
|
||||
```tsx
|
||||
<Accordion type="single" collapsible defaultValue="item-1">
|
||||
<AccordionItem value="item-1">...</AccordionItem>
|
||||
</Accordion>
|
||||
```
|
||||
@@ -0,0 +1,195 @@
|
||||
# Component Composition
|
||||
|
||||
## Contents
|
||||
|
||||
- Items always inside their Group component
|
||||
- Callouts use Alert
|
||||
- Empty states use Empty component
|
||||
- Toast notifications use sonner
|
||||
- Choosing between overlay components
|
||||
- Dialog, Sheet, and Drawer always need a Title
|
||||
- Card structure
|
||||
- Button has no isPending or isLoading prop
|
||||
- TabsTrigger must be inside TabsList
|
||||
- Avatar always needs AvatarFallback
|
||||
- Use Separator instead of raw hr or border divs
|
||||
- Use Skeleton for loading placeholders
|
||||
- Use Badge instead of custom styled spans
|
||||
|
||||
---
|
||||
|
||||
## Items always inside their Group component
|
||||
|
||||
Never render items directly inside the content container.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<SelectContent>
|
||||
<SelectItem value="apple">Apple</SelectItem>
|
||||
<SelectItem value="banana">Banana</SelectItem>
|
||||
</SelectContent>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="apple">Apple</SelectItem>
|
||||
<SelectItem value="banana">Banana</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
```
|
||||
|
||||
This applies to all group-based components:
|
||||
|
||||
| Item | Group |
|
||||
|------|-------|
|
||||
| `SelectItem`, `SelectLabel` | `SelectGroup` |
|
||||
| `DropdownMenuItem`, `DropdownMenuLabel`, `DropdownMenuSub` | `DropdownMenuGroup` |
|
||||
| `MenubarItem` | `MenubarGroup` |
|
||||
| `ContextMenuItem` | `ContextMenuGroup` |
|
||||
| `CommandItem` | `CommandGroup` |
|
||||
|
||||
---
|
||||
|
||||
## Callouts use Alert
|
||||
|
||||
```tsx
|
||||
<Alert>
|
||||
<AlertTitle>Warning</AlertTitle>
|
||||
<AlertDescription>Something needs attention.</AlertDescription>
|
||||
</Alert>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Empty states use Empty component
|
||||
|
||||
```tsx
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon"><FolderIcon /></EmptyMedia>
|
||||
<EmptyTitle>No projects yet</EmptyTitle>
|
||||
<EmptyDescription>Get started by creating a new project.</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button>Create Project</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Toast notifications use sonner
|
||||
|
||||
```tsx
|
||||
import { toast } from "sonner"
|
||||
|
||||
toast.success("Changes saved.")
|
||||
toast.error("Something went wrong.")
|
||||
toast("File deleted.", {
|
||||
action: { label: "Undo", onClick: () => undoDelete() },
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Choosing between overlay components
|
||||
|
||||
| Use case | Component |
|
||||
|----------|-----------|
|
||||
| Focused task that requires input | `Dialog` |
|
||||
| Destructive action confirmation | `AlertDialog` |
|
||||
| Side panel with details or filters | `Sheet` |
|
||||
| Mobile-first bottom panel | `Drawer` |
|
||||
| Quick info on hover | `HoverCard` |
|
||||
| Small contextual content on click | `Popover` |
|
||||
|
||||
---
|
||||
|
||||
## Dialog, Sheet, and Drawer always need a Title
|
||||
|
||||
`DialogTitle`, `SheetTitle`, `DrawerTitle` are required for accessibility. Use `className="sr-only"` if visually hidden.
|
||||
|
||||
```tsx
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Profile</DialogTitle>
|
||||
<DialogDescription>Update your profile.</DialogDescription>
|
||||
</DialogHeader>
|
||||
...
|
||||
</DialogContent>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Card structure
|
||||
|
||||
Use full composition — don't dump everything into `CardContent`:
|
||||
|
||||
```tsx
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Team Members</CardTitle>
|
||||
<CardDescription>Manage your team.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>...</CardContent>
|
||||
<CardFooter>
|
||||
<Button>Invite</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Button has no isPending or isLoading prop
|
||||
|
||||
Compose with `Spinner` + `data-icon` + `disabled`:
|
||||
|
||||
```tsx
|
||||
<Button disabled>
|
||||
<Spinner data-icon="inline-start" />
|
||||
Saving...
|
||||
</Button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TabsTrigger must be inside TabsList
|
||||
|
||||
Never render `TabsTrigger` directly inside `Tabs` — always wrap in `TabsList`:
|
||||
|
||||
```tsx
|
||||
<Tabs defaultValue="account">
|
||||
<TabsList>
|
||||
<TabsTrigger value="account">Account</TabsTrigger>
|
||||
<TabsTrigger value="password">Password</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="account">...</TabsContent>
|
||||
</Tabs>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Avatar always needs AvatarFallback
|
||||
|
||||
Always include `AvatarFallback` for when the image fails to load:
|
||||
|
||||
```tsx
|
||||
<Avatar>
|
||||
<AvatarImage src="/avatar.png" alt="User" />
|
||||
<AvatarFallback>JD</AvatarFallback>
|
||||
</Avatar>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Use existing components instead of custom markup
|
||||
|
||||
| Instead of | Use |
|
||||
|---|---|
|
||||
| `<hr>` or `<div className="border-t">` | `<Separator />` |
|
||||
| `<div className="animate-pulse">` with styled divs | `<Skeleton className="h-4 w-3/4" />` |
|
||||
| `<span className="rounded-full bg-green-100 ...">` | `<Badge variant="secondary">` |
|
||||
@@ -0,0 +1,192 @@
|
||||
# Forms & Inputs
|
||||
|
||||
## Contents
|
||||
|
||||
- Forms use FieldGroup + Field
|
||||
- InputGroup requires InputGroupInput/InputGroupTextarea
|
||||
- Buttons inside inputs use InputGroup + InputGroupAddon
|
||||
- Option sets (2–7 choices) use ToggleGroup
|
||||
- FieldSet + FieldLegend for grouping related fields
|
||||
- Field validation and disabled states
|
||||
|
||||
---
|
||||
|
||||
## Forms use FieldGroup + Field
|
||||
|
||||
Always use `FieldGroup` + `Field` — never raw `div` with `space-y-*`:
|
||||
|
||||
```tsx
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input id="email" type="email" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="password">Password</FieldLabel>
|
||||
<Input id="password" type="password" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
```
|
||||
|
||||
Use `Field orientation="horizontal"` for settings pages. Use `FieldLabel className="sr-only"` for visually hidden labels.
|
||||
|
||||
**Choosing form controls:**
|
||||
|
||||
- Simple text input → `Input`
|
||||
- Dropdown with predefined options → `Select`
|
||||
- Searchable dropdown → `Combobox`
|
||||
- Native HTML select (no JS) → `native-select`
|
||||
- Boolean toggle → `Switch` (for settings) or `Checkbox` (for forms)
|
||||
- Single choice from few options → `RadioGroup`
|
||||
- Toggle between 2–5 options → `ToggleGroup` + `ToggleGroupItem`
|
||||
- OTP/verification code → `InputOTP`
|
||||
- Multi-line text → `Textarea`
|
||||
|
||||
---
|
||||
|
||||
## InputGroup requires InputGroupInput/InputGroupTextarea
|
||||
|
||||
Never use raw `Input` or `Textarea` inside an `InputGroup`.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<InputGroup>
|
||||
<Input placeholder="Search..." />
|
||||
</InputGroup>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
import { InputGroup, InputGroupInput } from "@/components/ui/input-group"
|
||||
|
||||
<InputGroup>
|
||||
<InputGroupInput placeholder="Search..." />
|
||||
</InputGroup>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Buttons inside inputs use InputGroup + InputGroupAddon
|
||||
|
||||
Never place a `Button` directly inside or adjacent to an `Input` with custom positioning.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<div className="relative">
|
||||
<Input placeholder="Search..." className="pr-10" />
|
||||
<Button className="absolute right-0 top-0" size="icon">
|
||||
<SearchIcon />
|
||||
</Button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
import { InputGroup, InputGroupInput, InputGroupAddon } from "@/components/ui/input-group"
|
||||
|
||||
<InputGroup>
|
||||
<InputGroupInput placeholder="Search..." />
|
||||
<InputGroupAddon>
|
||||
<Button size="icon">
|
||||
<SearchIcon data-icon="inline-start" />
|
||||
</Button>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Option sets (2–7 choices) use ToggleGroup
|
||||
|
||||
Don't manually loop `Button` components with active state.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
const [selected, setSelected] = useState("daily")
|
||||
|
||||
<div className="flex gap-2">
|
||||
{["daily", "weekly", "monthly"].map((option) => (
|
||||
<Button
|
||||
key={option}
|
||||
variant={selected === option ? "default" : "outline"}
|
||||
onClick={() => setSelected(option)}
|
||||
>
|
||||
{option}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||
|
||||
<ToggleGroup spacing={2}>
|
||||
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
|
||||
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
|
||||
<ToggleGroupItem value="monthly">Monthly</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
```
|
||||
|
||||
Combine with `Field` for labelled toggle groups:
|
||||
|
||||
```tsx
|
||||
<Field orientation="horizontal">
|
||||
<FieldTitle id="theme-label">Theme</FieldTitle>
|
||||
<ToggleGroup aria-labelledby="theme-label" spacing={2}>
|
||||
<ToggleGroupItem value="light">Light</ToggleGroupItem>
|
||||
<ToggleGroupItem value="dark">Dark</ToggleGroupItem>
|
||||
<ToggleGroupItem value="system">System</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</Field>
|
||||
```
|
||||
|
||||
> **Note:** `defaultValue` and `type`/`multiple` props differ between base and radix. See [base-vs-radix.md](./base-vs-radix.md#togglegroup).
|
||||
|
||||
---
|
||||
|
||||
## FieldSet + FieldLegend for grouping related fields
|
||||
|
||||
Use `FieldSet` + `FieldLegend` for related checkboxes, radios, or switches — not `div` with a heading:
|
||||
|
||||
```tsx
|
||||
<FieldSet>
|
||||
<FieldLegend variant="label">Preferences</FieldLegend>
|
||||
<FieldDescription>Select all that apply.</FieldDescription>
|
||||
<FieldGroup className="gap-3">
|
||||
<Field orientation="horizontal">
|
||||
<Checkbox id="dark" />
|
||||
<FieldLabel htmlFor="dark" className="font-normal">Dark mode</FieldLabel>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Field validation and disabled states
|
||||
|
||||
Both attributes are needed — `data-invalid`/`data-disabled` styles the field (label, description), while `aria-invalid`/`disabled` styles the control.
|
||||
|
||||
```tsx
|
||||
// Invalid.
|
||||
<Field data-invalid>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input id="email" aria-invalid />
|
||||
<FieldDescription>Invalid email address.</FieldDescription>
|
||||
</Field>
|
||||
|
||||
// Disabled.
|
||||
<Field data-disabled>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input id="email" disabled />
|
||||
</Field>
|
||||
```
|
||||
|
||||
Works for all controls: `Input`, `Textarea`, `Select`, `Checkbox`, `RadioGroupItem`, `Switch`, `Slider`, `NativeSelect`, `InputOTP`.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Icons
|
||||
|
||||
**Always use the project's configured `iconLibrary` for imports.** Check the `iconLibrary` field from project context: `lucide` → `lucide-react`, `tabler` → `@tabler/icons-react`, etc. Never assume `lucide-react`.
|
||||
|
||||
---
|
||||
|
||||
## Icons in Button use data-icon attribute
|
||||
|
||||
Add `data-icon="inline-start"` (prefix) or `data-icon="inline-end"` (suffix) to the icon. No sizing classes on the icon.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<Button>
|
||||
<SearchIcon className="mr-2 size-4" />
|
||||
Search
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
<Button>
|
||||
<SearchIcon data-icon="inline-start"/>
|
||||
Search
|
||||
</Button>
|
||||
|
||||
<Button>
|
||||
Next
|
||||
<ArrowRightIcon data-icon="inline-end"/>
|
||||
</Button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## No sizing classes on icons inside components
|
||||
|
||||
Components handle icon sizing via CSS. Don't add `size-4`, `w-4 h-4`, or other sizing classes to icons inside `Button`, `DropdownMenuItem`, `Alert`, `Sidebar*`, or other shadcn components. Unless the user explicitly asks for custom icon sizes.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<Button>
|
||||
<SearchIcon className="size-4" data-icon="inline-start" />
|
||||
Search
|
||||
</Button>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<SettingsIcon className="mr-2 size-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
<Button>
|
||||
<SearchIcon data-icon="inline-start" />
|
||||
Search
|
||||
</Button>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<SettingsIcon />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pass icons as component objects, not string keys
|
||||
|
||||
Use `icon={CheckIcon}`, not a string key to a lookup map.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
const iconMap = {
|
||||
check: CheckIcon,
|
||||
alert: AlertIcon,
|
||||
}
|
||||
|
||||
function StatusBadge({ icon }: { icon: string }) {
|
||||
const Icon = iconMap[icon]
|
||||
return <Icon />
|
||||
}
|
||||
|
||||
<StatusBadge icon="check" />
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
// Import from the project's configured iconLibrary (e.g. lucide-react, @tabler/icons-react).
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
function StatusBadge({ icon: Icon }: { icon: React.ComponentType }) {
|
||||
return <Icon />
|
||||
}
|
||||
|
||||
<StatusBadge icon={CheckIcon} />
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user