chore: import upstream snapshot with attribution
Release / Tag + GitHub Release (push) Waiting to run
Deploy Documentation to Pages / build (push) Waiting to run
Deploy Documentation to Pages / deploy (push) Blocked by required conditions
Sync Codex Skills Symlinks / sync (push) Waiting to run

This commit is contained in:
wehub-resource-sync
2026-07-13 12:41:47 +08:00
commit a1fa97429b
4594 changed files with 736231 additions and 0 deletions
@@ -0,0 +1,13 @@
{
"name": "engineering-skills",
"description": "32 production-ready engineering skills: architecture, frontend, backend, fullstack, QA, DevOps, security, AI/ML, data engineering, Playwright, self-improving agent, security suite (adversarial-reviewer, ai-security, cloud-security, incident-response, red-team, threat-detection), Stripe integration, TDD guide, Google Workspace CLI, a11y audit, Snowflake development, and more. v2.8.1 augments senior-fullstack / senior-frontend / senior-backend with karpathy-coder + Matt Pocock discipline: each ships a 7-question forcing-question library, 4 customization profiles (JSON, swappable), a deterministic decision engine, a composition map into POWERFUL specialists, plus cs-fullstack-engineer / cs-frontend-engineer / cs-backend-engineer orchestrator agents (context: fork) invokable by other agents via /cs:fullstack-review, /cs:frontend-review, /cs:backend-review, /cs:engineer-grill. Agent skill and plugin for Claude Code, Codex, Gemini CLI, Cursor, OpenClaw.",
"version": "2.9.0",
"author": {
"name": "Alireza Rezvani",
"url": "https://alirezarezvani.com"
},
"homepage": "https://github.com/alirezarezvani/claude-skills/tree/main/engineering-team",
"repository": "https://github.com/alirezarezvani/claude-skills",
"license": "MIT",
"skills": ["./skills"]
}
+40
View File
@@ -0,0 +1,40 @@
# Engineering Skills — Codex CLI Instructions
When working on engineering tasks, use the engineering skill system:
## Routing
1. **Identify the domain:** Architecture, frontend, backend, DevOps, security, AI/ML, data, or QA
2. **Read the specialist SKILL.md** for detailed instructions
3. **Use Python tools** for scaffolding and analysis
## Python Tools
All scripts in `engineering-team/*/scripts/` are stdlib-only and CLI-first. Run them directly:
```bash
python3 engineering-team/senior-fullstack/scripts/project_scaffolder.py --help
python3 engineering-team/senior-security/scripts/threat_modeler.py --help
python3 engineering-team/senior-frontend/scripts/bundle_analyzer.py --help
```
## Key Skills by Task
| Task | Skill |
|------|-------|
| System design | senior-architect |
| React/Next.js | senior-frontend |
| API design | senior-backend |
| Full project scaffold | senior-fullstack |
| Test generation | senior-qa |
| CI/CD pipelines | senior-devops |
| Threat modeling | senior-security |
| AWS architecture | aws-solution-architect |
| Code review | code-reviewer |
| E2E testing | playwright-pro |
| Stripe payments | stripe-integration-expert |
## Rules
- Load only 1-2 skills per request — don't bulk-load
- Use Python tools for analysis and scaffolding
+322
View File
@@ -0,0 +1,322 @@
# Engineering Team Skills - Claude Code Guidance
This guide covers the 32 production-ready engineering skills and their Python automation tools.
## Engineering Skills Overview
**Core Engineering (16 skills):**
- senior-architect, senior-frontend, senior-backend, senior-fullstack
- senior-qa, senior-devops, senior-secops
- code-reviewer, senior-security
- aws-solution-architect, ms365-tenant-manager, google-workspace-cli, tdd-guide, tech-stack-evaluator, epic-design
- **a11y-audit** — WCAG 2.2 accessibility audit and fix (a11y_scanner.py, contrast_checker.py)
- **azure-cloud-architect** — Azure infrastructure design, ARM/Bicep templates, landing zones
- **gcp-cloud-architect** — GCP infrastructure design, Terraform modules, cloud-native patterns
- **security-pen-testing** — Penetration testing methodology, vulnerability assessment, exploit analysis
- **snowflake-development** — Snowflake data warehouse development, SQL optimization, data pipeline patterns
**Security (5 skills):**
- **adversarial-reviewer** — Adversarial code review with 3 hostile personas (Saboteur, New Hire, Security Auditor)
- **threat-detection** — Hypothesis-driven threat hunting, IOC sweep generation, z-score anomaly detection
- **incident-response** — SEV1-SEV4 triage, 14-type incident taxonomy, NIST SP 800-61 forensics
- **cloud-security** — IAM privilege escalation paths, S3 public access checks, security group detection
- **red-team** — MITRE ATT&CK kill-chain planning, effort scoring, choke point identification
- **ai-security** — ATLAS-mapped prompt injection detection, model inversion & data poisoning risk scoring
**AI/ML/Data (5 skills):**
- senior-data-scientist, senior-data-engineer, senior-ml-engineer
- senior-prompt-engineer, senior-computer-vision
**Total Tools:** 39+ Python automation tools
## Core Engineering Tools
### 1. Project Scaffolder (`senior-fullstack/scripts/project_scaffolder.py`)
**Purpose:** Production-ready project scaffolding for modern stacks
**Supported Stacks:**
- Next.js + GraphQL + PostgreSQL
- React + REST + MongoDB
- Vue + GraphQL + MySQL
- Express + TypeScript + PostgreSQL
**Features:**
- Docker Compose configuration
- CI/CD pipeline (GitHub Actions)
- Testing infrastructure (Jest, Cypress)
- TypeScript + ESLint + Prettier
- Database migrations
**Usage:**
```bash
# Create new project
python senior-fullstack/scripts/project_scaffolder.py my-project --type nextjs-graphql
# Start services
cd my-project && docker-compose up -d
```
### 2. Code Quality Analyzer (`senior-fullstack/scripts/code_quality_analyzer.py`)
**Purpose:** Comprehensive code quality analysis and metrics
**Features:**
- Security vulnerability scanning
- Performance issue detection
- Test coverage assessment
- Documentation quality
- Dependency analysis
- Actionable recommendations
**Usage:**
```bash
# Analyze project
python senior-fullstack/scripts/code_quality_analyzer.py /path/to/project
# JSON output
python senior-fullstack/scripts/code_quality_analyzer.py /path/to/project --json
```
**Output:**
```
Code Quality Report:
- Overall Score: 85/100
- Security: 90/100 (2 medium issues)
- Performance: 80/100 (3 optimization opportunities)
- Test Coverage: 75% (target: 80%)
- Documentation: 88/100
Recommendations:
1. Update lodash to 4.17.21 (CVE-2020-8203)
2. Optimize database queries in UserService
3. Add integration tests for payment flow
```
### 3. Fullstack Scaffolder (`senior-fullstack/scripts/fullstack_scaffolder.py`)
**Purpose:** Rapid fullstack application generation
**Usage:**
```bash
python senior-fullstack/scripts/fullstack_scaffolder.py my-app --stack nextjs-graphql
```
## AI/ML/Data Tools
### Data Science Tools
**Experiment Designer** (`senior-data-scientist/scripts/experiment_designer.py`)
- A/B test design
- Statistical power analysis
- Sample size calculation
**Feature Engineering Pipeline** (`senior-data-scientist/scripts/feature_engineering_pipeline.py`)
- Automated feature generation
- Correlation analysis
- Feature selection
**Statistical Analyzer** (`senior-data-scientist/scripts/statistical_analyzer.py`)
- Hypothesis testing
- Causal inference
- Regression analysis
### Data Engineering Tools
**Pipeline Orchestrator** (`senior-data-engineer/scripts/pipeline_orchestrator.py`)
- Airflow DAG generation
- Spark job templates
- Data quality checks
**Data Quality Validator** (`senior-data-engineer/scripts/data_quality_validator.py`)
- Schema validation
- Null check enforcement
- Anomaly detection
**ETL Generator** (`senior-data-engineer/scripts/etl_generator.py`)
- Extract-Transform-Load workflows
- CDC (Change Data Capture) patterns
- Incremental loading
### ML Engineering Tools
**Model Deployment Pipeline** (`senior-ml-engineer/scripts/model_deployment_pipeline.py`)
- Containerized model serving
- REST API generation
- Load balancing config
**MLOps Setup Tool** (`senior-ml-engineer/scripts/mlops_setup_tool.py`)
- MLflow configuration
- Model versioning
- Drift monitoring
**LLM Integration Builder** (`senior-ml-engineer/scripts/llm_integration_builder.py`)
- OpenAI API integration
- Prompt templates
- Response parsing
### Prompt Engineering Tools
**Prompt Optimizer** (`senior-prompt-engineer/scripts/prompt_optimizer.py`)
- Prompt A/B testing
- Token optimization
- Few-shot example generation
**RAG System Builder** (`senior-prompt-engineer/scripts/rag_system_builder.py`)
- Vector database setup
- Embedding generation
- Retrieval strategies
**Agent Orchestrator** (`senior-prompt-engineer/scripts/agent_orchestrator.py`)
- Multi-agent workflows
- Tool calling patterns
- State management
### Computer Vision Tools
**Vision Model Trainer** (`senior-computer-vision/scripts/vision_model_trainer.py`)
- Object detection (YOLO, Faster R-CNN)
- Semantic segmentation
- Transfer learning
**Inference Optimizer** (`senior-computer-vision/scripts/inference_optimizer.py`)
- Model quantization
- TensorRT optimization
- ONNX export
**Video Processor** (`senior-computer-vision/scripts/video_processor.py`)
- Frame extraction
- Object tracking
- Scene detection
## Tech Stack Patterns
### Frontend (React/Next.js)
- TypeScript strict mode
- Component-driven architecture
- Atomic design patterns
- State management (Zustand/Jotai)
- Testing (Jest + React Testing Library)
### Backend (Node.js/Express)
- Clean architecture
- Dependency injection
- Repository pattern
- Domain-driven design
- Testing (Jest + Supertest)
### Fullstack Integration
- GraphQL for API layer
- REST for external services
- WebSocket for real-time
- Redis for caching
- PostgreSQL for persistence
## Development Workflows
### Workflow 1: New Project Setup
```bash
# 1. Scaffold project
python senior-fullstack/scripts/project_scaffolder.py my-app --type nextjs-graphql
# 2. Start services
cd my-app && docker-compose up -d
# 3. Run migrations
npm run migrate
# 4. Start development
npm run dev
```
### Workflow 2: Code Quality Check
```bash
# 1. Analyze codebase
python senior-fullstack/scripts/code_quality_analyzer.py ./
# 2. Fix security issues
npm audit fix
# 3. Run tests
npm test
# 4. Build production
npm run build
```
### Workflow 3: ML Model Deployment
```bash
# 1. Setup MLOps infrastructure
python senior-ml-engineer/scripts/mlops_setup_tool.py
# 2. Deploy model
python senior-ml-engineer/scripts/model_deployment_pipeline.py model.pkl
# 3. Monitor performance
# Check MLflow dashboard
```
## Quality Standards
**All engineering tools must:**
- Support modern tech stacks (Next.js, React, Vue, Express)
- Generate production-ready code
- Include testing infrastructure
- Provide Docker configurations
- Support CI/CD integration
## Integration Patterns
### GitHub Actions CI/CD
All scaffolders generate GitHub Actions workflows:
```yaml
.github/workflows/
├── test.yml # Run tests on PR
├── build.yml # Build and lint
└── deploy.yml # Deploy to production
```
### Docker Compose
Multi-service development environment:
```yaml
services:
- app (Next.js)
- api (GraphQL)
- db (PostgreSQL)
- redis (Cache)
```
## Additional Resources
- **Quick Start:** `START_HERE.md`
- **Team Structure:** `TEAM_STRUCTURE_GUIDE.md`
- **Engineering Roadmap:** `engineering_skills_roadmap.md` (if exists)
- **Main Documentation:** `../CLAUDE.md`
---
**Last Updated:** May 10, 2026
**Skills Deployed:** 32 engineering skills production-ready
**Total Tools:** 39+ Python automation tools across core + AI/ML/Data + epic-design + a11y
---
## epic-design
Build cinematic 2.5D interactive websites with scroll storytelling, parallax depth, and premium animations. Includes asset inspection pipeline, 45+ techniques across 8 categories, and accessibility built-in.
**Key features:**
- 6-layer depth system with automatic parallax
- 13 text animation techniques, 9 scroll patterns
- Asset inspection with background judgment rules
- Python tool for automated image analysis
- WCAG 2.1 AA compliant (reduced-motion)
**Use for:** Product launches, portfolio sites, SaaS marketing pages, event sites, Apple-style animations
**Live demo:** [epic-design-showcase.vercel.app](https://epic-design-showcase.vercel.app/)
+608
View File
@@ -0,0 +1,608 @@
# Engineering Skills Collection
Complete set of 32 engineering skills (role skills, security suite, AI/ML/Data, and specialized tools) tailored to your tech stack (ReactJS, NextJS, NodeJS, Express, React Native, Swift, Kotlin, Flutter, Postgres, GraphQL, Go, Python).
## ⚡ Installation
### Quick Install (Recommended)
Install all engineering skills with one command:
```bash
# Install all engineering skills to all supported agents
npx ai-agent-skills install alirezarezvani/claude-skills/engineering-team
# Install to Claude Code only
npx ai-agent-skills install alirezarezvani/claude-skills/engineering-team --agent claude
# Install to Cursor only
npx ai-agent-skills install alirezarezvani/claude-skills/engineering-team --agent cursor
```
### Install Individual Skills
```bash
# Core Engineering
npx ai-agent-skills install alirezarezvani/claude-skills/engineering-team/skills/senior-architect
npx ai-agent-skills install alirezarezvani/claude-skills/engineering-team/skills/senior-frontend
npx ai-agent-skills install alirezarezvani/claude-skills/engineering-team/skills/senior-backend
npx ai-agent-skills install alirezarezvani/claude-skills/engineering-team/skills/senior-fullstack
npx ai-agent-skills install alirezarezvani/claude-skills/engineering-team/skills/senior-qa
npx ai-agent-skills install alirezarezvani/claude-skills/engineering-team/skills/senior-devops
npx ai-agent-skills install alirezarezvani/claude-skills/engineering-team/skills/senior-secops
npx ai-agent-skills install alirezarezvani/claude-skills/engineering-team/skills/code-reviewer
npx ai-agent-skills install alirezarezvani/claude-skills/engineering-team/skills/senior-security
# Cloud & Enterprise
npx ai-agent-skills install alirezarezvani/claude-skills/engineering-team/skills/aws-solution-architect
npx ai-agent-skills install alirezarezvani/claude-skills/engineering-team/skills/ms365-tenant-manager
# Development Tools
npx ai-agent-skills install alirezarezvani/claude-skills/engineering-team/skills/tdd-guide
npx ai-agent-skills install alirezarezvani/claude-skills/engineering-team/skills/tech-stack-evaluator
# AI/ML/Data
npx ai-agent-skills install alirezarezvani/claude-skills/engineering-team/skills/senior-data-scientist
npx ai-agent-skills install alirezarezvani/claude-skills/engineering-team/skills/senior-data-engineer
npx ai-agent-skills install alirezarezvani/claude-skills/engineering-team/skills/senior-ml-engineer
npx ai-agent-skills install alirezarezvani/claude-skills/engineering-team/skills/senior-prompt-engineer
npx ai-agent-skills install alirezarezvani/claude-skills/engineering-team/skills/senior-computer-vision
```
**Supported Agents:** Claude Code, Cursor, VS Code, Copilot, Goose, Amp, Codex
**Complete Installation Guide:** See [../INSTALLATION.md](../INSTALLATION.md) for detailed instructions, troubleshooting, and manual installation.
---
## 📦 Skills Package
All skills follow the exact structure from your fullstack-engineer example:
```
skill-name/
├── SKILL.md # Main skill documentation
├── references/ # 3 detailed reference guides
│ ├── [topic]_patterns.md
│ ├── [topic]_guide.md
│ └── [topic]_practices.md
└── scripts/ # 3 automation scripts
├── [tool]_generator.py
├── [tool]_analyzer.py
└── [tool]_scaffolder.py
```
## 🎯 Skills Overview
### 1. Senior Software Architect (`skills/senior-architect/`)
**Purpose:** System architecture design, tech stack decisions, architecture diagrams
**Key Capabilities:**
- Architecture diagram generation (C4, sequence, component)
- Dependency analysis and visualization
- Architecture Decision Records (ADR) creation
- System design patterns (Monolithic, Microservices, Serverless)
- Integration pattern templates
- Tech stack decision framework
**Scripts:**
- `architecture_diagram_generator.py` - Generate professional architecture diagrams
- `project_architect.py` - Scaffold architecture documentation
- `dependency_analyzer.py` - Analyze dependencies and detect issues
**References:**
- `architecture_patterns.md` - Comprehensive architecture patterns
- `system_design_workflows.md` - Step-by-step design process
- `tech_decision_guide.md` - Tech stack selection guide
**Use When:**
- Designing new system architecture
- Making technology stack decisions
- Creating technical documentation
- Evaluating architectural trade-offs
---
### 2. Senior Frontend Engineer (`skills/senior-frontend/`)
**Purpose:** Frontend development with React, Next.js, TypeScript
**Key Capabilities:**
- React component scaffolding
- Bundle size analysis and optimization
- Performance optimization
- Next.js App Router patterns
- State management (Zustand, Context)
- UI/UX best practices
**Scripts:**
- `component_generator.py` - Generate React components
- `bundle_analyzer.py` - Analyze and optimize bundles
- `frontend_scaffolder.py` - Scaffold frontend projects
**References:**
- `react_patterns.md` - React best practices and patterns
- `nextjs_optimization_guide.md` - Next.js performance guide
- `frontend_best_practices.md` - Modern frontend practices
**Use When:**
- Building React/Next.js applications
- Optimizing frontend performance
- Implementing UI components
- Managing application state
---
### 3. Senior Backend Engineer (`skills/senior-backend/`)
**Purpose:** Backend development with Node.js, Express, GraphQL, Go, Python
**Key Capabilities:**
- REST & GraphQL API design
- Database optimization (PostgreSQL)
- Authentication/Authorization
- API load testing
- Microservice patterns
- Error handling strategies
**Scripts:**
- `api_scaffolder.py` - Generate API endpoints
- `database_migration_tool.py` - Database migration management
- `api_load_tester.py` - API performance testing
**References:**
- `api_design_patterns.md` - API design best practices
- `database_optimization_guide.md` - Database performance guide
- `backend_security_practices.md` - Security implementation
**Use When:**
- Designing APIs (REST/GraphQL)
- Optimizing database queries
- Implementing authentication
- Building microservices
---
### 4. Senior Fullstack Engineer (`skills/senior-fullstack/`)
**Purpose:** End-to-end application development
**Key Capabilities:**
- Full project scaffolding
- Code quality analysis
- Full-stack architecture
- Frontend-backend integration
- Testing strategies
- Deployment workflows
**Scripts:**
- `fullstack_scaffolder.py` - Generate complete projects
- `project_scaffolder.py` - Project structure creation
- `code_quality_analyzer.py` - Comprehensive code analysis
**References:**
- `tech_stack_guide.md` - Complete tech stack reference
- `architecture_patterns.md` - Full-stack architecture
- `development_workflows.md` - Development best practices
**Use When:**
- Starting new full-stack projects
- Analyzing code quality
- Implementing complete features
- Setting up development environments
---
### 5. Senior QA Testing Engineer (`skills/senior-qa/`)
**Purpose:** Quality assurance and test automation for React/Next.js applications
**Tech Stack Focus:**
- Jest + React Testing Library (unit/integration)
- Playwright (E2E testing)
- Istanbul/NYC (coverage analysis)
- MSW (API mocking)
**Key Capabilities:**
- Component test generation with accessibility checks
- Coverage gap analysis with critical path detection
- E2E test scaffolding with Page Object Model
- Test pyramid implementation (70/20/10 ratio)
- CI/CD integration patterns
**Scripts:**
- `test_suite_generator.py` - Scans React components, generates Jest + RTL tests with accessibility assertions
- `coverage_analyzer.py` - Parses Istanbul/LCOV reports, identifies untested critical paths, generates HTML reports
- `e2e_test_scaffolder.py` - Scans Next.js routes, generates Playwright tests with Page Object Model classes
**References:**
- `testing_strategies.md` - Test pyramid, coverage targets, CI/CD integration patterns
- `test_automation_patterns.md` - Page Object Model, fixtures, mocking strategies, async testing
- `qa_best_practices.md` - Test naming, isolation, flaky test handling, debugging strategies
**Use When:**
- Setting up React/Next.js testing infrastructure
- Generating component test suites with RTL
- Analyzing coverage gaps in critical paths
- Scaffolding Playwright E2E tests for Next.js routes
---
### 6. Senior DevOps Engineer (`skills/senior-devops/`)
**Purpose:** CI/CD, infrastructure automation, deployment
**Key Capabilities:**
- CI/CD pipeline setup (GitHub Actions, CircleCI)
- Infrastructure as Code (Terraform)
- Docker containerization
- Kubernetes orchestration
- Deployment automation
- Monitoring setup
**Scripts:**
- `pipeline_generator.py` - Generate CI/CD pipelines
- `terraform_scaffolder.py` - Create IaC templates
- `deployment_manager.py` - Manage deployments
**References:**
- `cicd_pipeline_guide.md` - Pipeline setup and best practices
- `infrastructure_as_code.md` - IaC patterns and examples
- `deployment_strategies.md` - Blue-green, canary deployments
**Use When:**
- Setting up CI/CD pipelines
- Automating deployments
- Managing infrastructure
- Containerizing applications
---
### 7. Senior SecOps Engineer (`skills/senior-secops/`)
**Purpose:** Security operations and compliance
**Key Capabilities:**
- Security scanning automation
- Vulnerability assessment
- Compliance checking (GDPR, SOC2)
- Security audit automation
- Incident response
- Security metrics
**Scripts:**
- `security_scanner.py` - Scan for vulnerabilities
- `vulnerability_assessor.py` - Assess security risks
- `compliance_checker.py` - Check compliance status
**References:**
- `security_standards.md` - OWASP Top 10, security standards
- `vulnerability_management_guide.md` - Vulnerability handling
- `compliance_requirements.md` - Compliance frameworks
**Use When:**
- Implementing security controls
- Conducting security audits
- Managing vulnerabilities
- Ensuring compliance
---
### 8. Code Reviewer (`skills/code-reviewer/`)
**Purpose:** Code review automation and quality checking
**Key Capabilities:**
- Automated PR analysis
- Code quality metrics
- Security scanning
- Best practice checking
- Review checklist generation
- Anti-pattern detection
**Scripts:**
- `pr_analyzer.py` - Analyze pull requests
- `code_quality_checker.py` - Check code quality
- `review_report_generator.py` - Generate review reports
**References:**
- `code_review_checklist.md` - Comprehensive checklist
- `coding_standards.md` - Language-specific standards
- `common_antipatterns.md` - What to avoid
**Use When:**
- Reviewing pull requests
- Ensuring code quality
- Identifying issues
- Providing feedback
---
### 9. Senior Security Engineer (`skills/senior-security/`)
**Purpose:** Security architecture and penetration testing
**Key Capabilities:**
- Threat modeling
- Security architecture design
- Penetration testing automation
- Cryptography implementation
- Security auditing
- Zero Trust architecture
**Scripts:**
- `threat_modeler.py` - Create threat models
- `security_auditor.py` - Perform security audits
- `pentest_automator.py` - Automate penetration tests
**References:**
- `security_architecture_patterns.md` - Security design patterns
- `penetration_testing_guide.md` - Pen testing methodologies
- `cryptography_implementation.md` - Crypto best practices
**Use When:**
- Designing security architecture
- Conducting penetration tests
- Implementing cryptography
- Performing security audits
---
## 🚀 Quick Start Guide
### Installation
1. **Pick the skills** you need under `skills/`
2. **Open** the skill folder (e.g., `cd skills/senior-architect`)
3. **Install dependencies** (if needed):
```bash
# For Python scripts
pip install -r requirements.txt
# For Node.js tools
npm install
```
### Using a Skill
Each skill follows the same pattern:
```bash
# 1. Read the SKILL.md file
cat SKILL.md
# 2. Check the reference documentation
ls references/
# 3. Run the scripts
python scripts/[script-name].py --help
# Example: Generate architecture diagrams
cd skills/senior-architect
python scripts/architecture_diagram_generator.py --type c4 --output ./docs
```
### Skill Selection Guide
**Starting a new project?**
→ Use `senior-fullstack` or `senior-architect`
**Building frontend features?**
→ Use `senior-frontend`
**Designing APIs?**
→ Use `senior-backend`
**Setting up CI/CD?**
→ Use `senior-devops`
**Security concerns?**
→ Use `senior-secops` or `senior-security`
**Code review?**
→ Use `code-reviewer`
**Testing strategy?**
→ Use `senior-qa`
---
## 📚 Common Workflows
### Workflow 1: Starting a New Project
```bash
# Step 1: Design architecture
cd skills/senior-architect
python scripts/project_architect.py my-app --pattern microservices
# Step 2: Scaffold project
cd ../senior-fullstack
python scripts/project_scaffolder.py my-app --type nextjs-graphql
# Step 3: Setup CI/CD
cd ../senior-devops
python scripts/pipeline_generator.py my-app --platform github
```
### Workflow 2: Code Review Process
```bash
# Step 1: Analyze PR
cd skills/code-reviewer
python scripts/pr_analyzer.py ../my-app
# Step 2: Check quality
python scripts/code_quality_checker.py ../my-app
# Step 3: Generate report
python scripts/review_report_generator.py ../my-app --output review.md
```
### Workflow 3: Security Audit
```bash
# Step 1: Scan for vulnerabilities
cd skills/senior-secops
python scripts/security_scanner.py ../my-app
# Step 2: Assess risks
python scripts/vulnerability_assessor.py ../my-app
# Step 3: Check compliance
python scripts/compliance_checker.py ../my-app --standard soc2
```
---
## 🛠 Tech Stack Support
All skills are optimized for your tech stack:
**Frontend:**
- React 18+
- Next.js 14+ (App Router)
- TypeScript
- Tailwind CSS
- React Native
- Flutter
**Backend:**
- Node.js 20+
- Express 4+
- GraphQL (Apollo Server)
- Go (Gin/Echo)
- Python (FastAPI)
**Database:**
- PostgreSQL 16+
- Prisma ORM
- NeonDB
- Supabase
**Mobile:**
- Swift (iOS)
- Kotlin (Android)
- React Native
- Flutter
**DevOps:**
- Docker
- Kubernetes
- Terraform
- GitHub Actions
- CircleCI
- AWS/GCP/Azure
**Tools:**
- Git (GitHub/GitLab/Bitbucket)
- Jira
- Confluence
- Figma
- Miro
---
## 📖 Best Practices
### Using Scripts
1. **Always read help first**: `python script.py --help`
2. **Test in development**: Run on sample projects first
3. **Review outputs**: Check generated files before using
4. **Customize as needed**: Scripts are starting points
### Using References
1. **Start with patterns**: Read the patterns guide first
2. **Follow workflows**: Use step-by-step workflows
3. **Adapt to context**: Adjust recommendations for your needs
4. **Document decisions**: Keep track of what works
### Combining Skills
Skills work best together:
- **Architect** + **Fullstack**: Design then build
- **DevOps** + **SecOps**: Deploy securely
- **Backend** + **QA**: Build and test APIs
- **Frontend** + **Code Reviewer**: Build quality UIs
---
## 🔄 Iteration and Updates
These skills are designed to evolve:
1. **Use the skill** on real projects
2. **Note improvements** needed
3. **Update scripts** and references
4. **Share learnings** with team
---
## 📝 Customization
Each skill can be customized:
### Updating Scripts
Edit Python scripts to add:
- Company-specific conventions
- Custom templates
- Additional checks
- Integration with your tools
### Updating References
Edit markdown files to add:
- Your patterns and practices
- Team standards
- Project examples
- Lessons learned
---
## 🎯 Summary
You now have **9 comprehensive engineering skills** that match your tech stack:
1. ✅ **Senior Architect** - System design and architecture
2. ✅ **Senior Frontend** - React/Next.js development
3. ✅ **Senior Backend** - API and backend development
4. ✅ **Senior Fullstack** - End-to-end development
5. ✅ **Senior QA** - Testing and quality assurance
6. ✅ **Senior DevOps** - CI/CD and infrastructure
7. ✅ **Senior SecOps** - Security operations
8. ✅ **Code Reviewer** - Code review automation
9. ✅ **Senior Security** - Security architecture
Each skill includes:
- **Comprehensive SKILL.md** with quick start guide
- **3 reference guides** with patterns and best practices
- **3 automation scripts** for common tasks
---
## 🚀 Next Steps
1. **Pick** the skills you need most under `skills/`
2. **Explore** the folder structure
3. **Read** SKILL.md for each skill
4. **Run** example scripts to understand capabilities
5. **Customize** for your specific needs
6. **Integrate** into your development workflow
---
## 💡 Tips
- **Start small**: Begin with 2-3 core skills
- **Test scripts**: Run on sample projects first
- **Read references**: They contain valuable patterns
- **Iterate**: Update skills based on usage
- **Share**: Use as team knowledge base
---
**Happy Engineering! 🎉**
+353
View File
@@ -0,0 +1,353 @@
# 🎯 **START HERE: World-Class Team Skills**
## 📦 **What You're Getting**
**32 production-ready skills** for building exceptional engineering and AI/ML/Data teams (this quick-start tour covers the original 14 role skills; see README.md for the full current list).
All skills follow your exact template structure with:
-**SKILL.md** - Complete documentation with quick start
-**3 Reference Guides** - Advanced patterns and best practices
-**3 Automation Scripts** - Production-grade Python tools
-**7 files per skill** - Comprehensive and ready to use
---
## 📚 **Your Documents**
### **1. [TEAM_STRUCTURE_GUIDE.md](TEAM_STRUCTURE_GUIDE.md)** ⭐ **START HERE**
**THE MASTER GUIDE** - Complete team structure recommendations:
- Team compositions for startups, scale-ups, and enterprises
- When to use each skill
- Workflow examples
- Hiring and team building
- Performance benchmarks
- Tech stack coverage
### **2. [README.md](README.md)**
Original engineering skills guide covering the 9 engineering roles in detail.
---
## 🎯 **Quick Role Finder**
### **Need to...**
**Design a system?** → [skills/senior-architect/](skills/senior-architect/)
**Build frontend?** → [skills/senior-frontend/](skills/senior-frontend/)
**Build backend?** → [skills/senior-backend/](skills/senior-backend/)
**Build full-stack?** → [skills/senior-fullstack/](skills/senior-fullstack/)
**Setup testing?** → [skills/senior-qa/](skills/senior-qa/)
**Setup DevOps?** → [skills/senior-devops/](skills/senior-devops/)
**Setup security?** → [skills/senior-secops/](skills/senior-secops/) or [skills/senior-security/](skills/senior-security/)
**Review code?** → [skills/code-reviewer/](skills/code-reviewer/)
**Analyze data?** → [skills/senior-data-scientist/](skills/senior-data-scientist/)
**Build data pipelines?** → [skills/senior-data-engineer/](skills/senior-data-engineer/)
**Deploy ML models?** → [skills/senior-ml-engineer/](skills/senior-ml-engineer/)
**Optimize LLMs?** → [skills/senior-prompt-engineer/](skills/senior-prompt-engineer/)
**Build vision AI?** → [skills/senior-computer-vision/](skills/senior-computer-vision/)
---
## 🏗️ **Team Size Guide**
### **Startup (5-10 people)**
Use these 5 skills:
1. [skills/senior-fullstack/](skills/senior-fullstack/) (×2)
2. [skills/senior-data-scientist/](skills/senior-data-scientist/) (×1)
3. [skills/senior-devops/](skills/senior-devops/) (×1)
4. [skills/senior-ml-engineer/](skills/senior-ml-engineer/) (×1)
### **Scale-Up (10-25 people)**
Use these 9 skills:
1. [skills/senior-architect/](skills/senior-architect/) (×1)
2. [skills/senior-frontend/](skills/senior-frontend/) (×2)
3. [skills/senior-backend/](skills/senior-backend/) (×3)
4. [skills/senior-data-engineer/](skills/senior-data-engineer/) (×2)
5. [skills/senior-data-scientist/](skills/senior-data-scientist/) (×2)
6. [skills/senior-ml-engineer/](skills/senior-ml-engineer/) (×2)
7. [skills/senior-qa/](skills/senior-qa/) (×1)
8. [skills/senior-devops/](skills/senior-devops/) (×1)
9. [skills/senior-secops/](skills/senior-secops/) (×1)
### **Enterprise (25-50+ people)**
Use all 14 role skills - you'll need the full suite!
---
## 📥 **All Skills at a Glance**
### **Engineering Team (9 Skills)**
| # | Skill | Download | What It Does |
|---|-------|----------|--------------|
| 1 | **Senior Architect** | [skills/senior-architect/](skills/senior-architect/) | System design, architecture decisions, diagrams |
| 2 | **Senior Frontend** | [skills/senior-frontend/](skills/senior-frontend/) | React, Next.js, UI/UX, performance |
| 3 | **Senior Backend** | [skills/senior-backend/](skills/senior-backend/) | APIs, databases, business logic |
| 4 | **Senior Fullstack** | [skills/senior-fullstack/](skills/senior-fullstack/) | End-to-end development |
| 5 | **Senior QA** | [skills/senior-qa/](skills/senior-qa/) | Testing, automation, quality |
| 6 | **Senior DevOps** | [skills/senior-devops/](skills/senior-devops/) | CI/CD, infrastructure, deployment |
| 7 | **Senior SecOps** | [skills/senior-secops/](skills/senior-secops/) | Security operations, compliance |
| 8 | **Code Reviewer** | [skills/code-reviewer/](skills/code-reviewer/) | Code quality, standards, reviews |
| 9 | **Senior Security** | [skills/senior-security/](skills/senior-security/) | Security architecture, pentesting |
### **AI/ML/Data Team (5 Skills)**
| # | Skill | Download | What It Does |
|---|-------|----------|--------------|
| 10 | **Senior Data Scientist** | [skills/senior-data-scientist/](skills/senior-data-scientist/) | Statistical modeling, experimentation, analytics |
| 11 | **Senior Data Engineer** | [skills/senior-data-engineer/](skills/senior-data-engineer/) | Data pipelines, ETL, infrastructure |
| 12 | **Senior ML Engineer** | [skills/senior-ml-engineer/](skills/senior-ml-engineer/) | MLOps, model deployment, LLMs |
| 13 | **Senior Prompt Engineer** | [skills/senior-prompt-engineer/](skills/senior-prompt-engineer/) | LLM optimization, RAG, agents |
| 14 | **Senior Computer Vision** | [skills/senior-computer-vision/](skills/senior-computer-vision/) | Image/video AI, object detection |
---
## 🚀 **Quick Start (3 Steps)**
### **Step 1: Choose Your Path**
Pick one based on your immediate need:
- **Building a team?** → Read [TEAM_STRUCTURE_GUIDE.md](TEAM_STRUCTURE_GUIDE.md)
- **Starting a project?** → Download [skills/senior-architect/](skills/senior-architect/) + [skills/senior-fullstack/](skills/senior-fullstack/)
- **Building AI features?** → Download [skills/senior-ml-engineer/](skills/senior-ml-engineer/) + [skills/senior-prompt-engineer/](skills/senior-prompt-engineer/)
- **Data infrastructure?** → Download [skills/senior-data-engineer/](skills/senior-data-engineer/)
### **Step 2: Extract & Explore**
```bash
# Open the skill folder
cd skills/senior-ml-engineer
# Read the main guide
cat SKILL.md
# Check what's included
tree .
```
### **Step 3: Use the Tools**
```bash
# Try a script
python scripts/model_deployment_pipeline.py --help
# Read a reference
cat references/mlops_production_patterns.md
# Customize for your needs
vim SKILL.md
```
---
## 💡 **Pro Tips**
### **For CTO/Engineering Leaders**
1. **Start with TEAM_STRUCTURE_GUIDE.md** - Understand team compositions
2. **Download skills matching your team size**
3. **Use for hiring** - Job descriptions, interview questions
4. **Use for onboarding** - Training material for new hires
5. **Customize** - Add your company's patterns and practices
### **For Individual Engineers**
1. **Download your role's skill**
2. **Study the reference guides** - Learn advanced patterns
3. **Use the scripts** - Automate your workflows
4. **Contribute back** - Add your learnings
5. **Share with team** - Knowledge sharing
### **For Data/ML Teams**
1. **Download all 5 AI/ML/Data skills**
2. **Focus on MLOps patterns** - Production-grade ML
3. **Implement DataOps** - Quality data pipelines
4. **Optimize LLMs** - Cost-effective AI
5. **Monitor everything** - Model drift, data quality
---
## 🎯 **What Makes These Skills World-Class?**
### **✅ Production-Grade**
- Scalable architectures
- Performance optimized
- Security built-in
- Monitoring integrated
### **✅ Senior-Level**
- Advanced patterns
- Strategic thinking
- Leadership aspects
- Mentorship guidance
### **✅ Comprehensive**
- 7 files per skill
- Code + documentation
- Examples + templates
- Best practices
### **✅ Practical**
- Automation scripts
- Real workflows
- Production patterns
- Battle-tested
### **✅ Modern Stack**
- Your tech stack (React, Next.js, Node.js, Python, Go)
- Latest frameworks (PyTorch, LangChain, Spark)
- Cloud platforms (AWS, GCP, Azure)
- Modern tools (Docker, Kubernetes, Terraform)
---
## 📖 **Additional Resources**
### **Tech Stack Covered**
**Frontend:** React, Next.js, TypeScript, Tailwind, React Native, Flutter, Swift, Kotlin
**Backend:** Node.js, Express, GraphQL, Go, Python, FastAPI
**Data:** PostgreSQL, Spark, Airflow, dbt, Kafka, Databricks, Snowflake
**ML/AI:** PyTorch, TensorFlow, LangChain, LlamaIndex, OpenCV, Transformers
**Infrastructure:** Docker, Kubernetes, Terraform, AWS, GCP, Azure
**Tools:** Git, Jira, Confluence, Figma, MLflow, W&B
---
## 🎓 **Learning Path**
### **Level 1: Foundation (Weeks 1-2)**
- Read TEAM_STRUCTURE_GUIDE.md
- Download 3-5 core skills
- Explore SKILL.md files
- Try example scripts
### **Level 2: Implementation (Weeks 3-6)**
- Deep dive into reference guides
- Customize scripts for your needs
- Implement one pattern per week
- Share learnings with team
### **Level 3: Mastery (Months 2-6)**
- Master all patterns
- Contribute improvements
- Mentor others
- Establish team standards
### **Level 4: Innovation (Ongoing)**
- Research new approaches
- Experiment with cutting edge
- Publish findings
- Drive industry forward
---
## 🔥 **Common Use Cases**
### **Use Case 1: Starting a Startup**
**Downloads:** skills/senior-fullstack/, skills/senior-ml-engineer/, skills/senior-devops/
**Focus:** MVP development, rapid iteration, lean team
### **Use Case 2: Building AI Product**
**Downloads:** skills/senior-prompt-engineer/, skills/senior-ml-engineer/, skills/senior-data-engineer/
**Focus:** LLM integration, RAG systems, data pipelines
### **Use Case 3: Scaling Engineering Team**
**Downloads:** skills/senior-architect/, skills/code-reviewer/, all engineering skills
**Focus:** Architecture, standards, processes, quality
### **Use Case 4: Data Science Team**
**Downloads:** All 5 AI/ML/Data skills
**Focus:** Analytics, ML, data infrastructure
### **Use Case 5: Computer Vision Product**
**Downloads:** skills/senior-computer-vision/, skills/senior-ml-engineer/, skills/senior-devops/
**Focus:** Vision models, real-time inference, deployment
---
## ✨ **Key Differentiators**
What makes these skills special:
1. **Your Exact Template** - Follows your fullstack-engineer example perfectly
2. **World-Class Quality** - Production-grade, senior-level content
3. **Complete Coverage** - 14 roles, all bases covered
4. **Actionable Tools** - 42 production scripts (3 per skill)
5. **Deep References** - 42 comprehensive guides (3 per skill)
6. **Modern Stack** - Your tech stack throughout
7. **Team-Focused** - Built for collaboration
8. **Battle-Tested** - Industry best practices
9. **Customizable** - Starting point, not endpoint
10. **Growth-Oriented** - Scales from startup to enterprise
---
## 🎯 **Next Actions**
### **Right Now (5 minutes)**
1. ✅ Read [TEAM_STRUCTURE_GUIDE.md](TEAM_STRUCTURE_GUIDE.md)
2. ✅ Identify your team size
3. ✅ Note which skills you need
### **Today (30 minutes)**
1. ✅ Download 2-3 core skills
2. ✅ Extract and explore SKILL.md
3. ✅ Try one script with `--help`
### **This Week (2-3 hours)**
1. ✅ Read all reference guides for your role
2. ✅ Run scripts on sample projects
3. ✅ Customize one script for your workflow
### **This Month (10+ hours)**
1. ✅ Implement 3-5 patterns from references
2. ✅ Share skills with team
3. ✅ Establish team standards based on skills
4. ✅ Track improvements in velocity and quality
---
## 🙌 **You're All Set!**
You now have everything needed to build and scale world-class engineering and AI/ML/Data teams:
**14 comprehensive skills**
**42 production scripts**
**42 reference guides**
**Team structure recommendations**
**Workflow examples**
**Best practices**
**Performance benchmarks**
**Time to build something amazing! 🚀**
---
**Questions?**
- Check TEAM_STRUCTURE_GUIDE.md for team compositions
- Check individual SKILL.md files for tool details
- Check reference/*.md files for deep dives
- Customize and iterate based on your needs
**Remember:** These skills are starting points. Make them your own, add your learnings, and build the future! 🎯
+629
View File
@@ -0,0 +1,629 @@
# 🚀 World-Class Engineering & AI/ML/Data Team Skills
Complete set of **14 senior-level skills** for building exceptional engineering and AI/data teams.
---
## 🎯 **Complete Team Structure**
### **Engineering Team (9 Roles)**
| Role | Skill Package | Primary Focus |
|------|---------------|---------------|
| **Senior Software Architect** | `skills/senior-architect/` | System design, architecture decisions, tech stack |
| **Senior Frontend Engineer** | `skills/senior-frontend/` | React, Next.js, UI/UX, performance |
| **Senior Backend Engineer** | `skills/senior-backend/` | APIs, databases, business logic |
| **Senior Fullstack Engineer** | `skills/senior-fullstack/` | End-to-end development |
| **Senior QA/Test Engineer** | `skills/senior-qa/` | Quality assurance, test automation |
| **Senior DevOps Engineer** | `skills/senior-devops/` | CI/CD, infrastructure, deployment |
| **Senior SecOps Engineer** | `skills/senior-secops/` | Security operations, compliance |
| **Code Reviewer** | `skills/code-reviewer/` | Code quality, standards, reviews |
| **Senior Security Engineer** | `skills/senior-security/` | Security architecture, pentesting |
### **AI/ML/Data Team (5 Roles)**
| Role | Skill Package | Primary Focus |
|------|---------------|---------------|
| **Senior Data Scientist** | `skills/senior-data-scientist/` | Statistical modeling, experimentation, analytics |
| **Senior Data Engineer** | `skills/senior-data-engineer/` | Data pipelines, ETL, data infrastructure |
| **Senior ML/AI Engineer** | `skills/senior-ml-engineer/` | MLOps, model deployment, LLM integration |
| **Senior Prompt Engineer** | `skills/senior-prompt-engineer/` | LLM optimization, RAG, agentic AI |
| **Senior Computer Vision Engineer** | `skills/senior-computer-vision/` | Image/video AI, object detection, vision systems |
---
## 🏗️ **Recommended Team Compositions**
### **Startup Team (5-10 people)**
**Minimum Viable Team:**
1. **Senior Fullstack Engineer** (×2) - Build everything
2. **Senior Data Scientist** - Analytics & insights
3. **Senior DevOps Engineer** - Deploy & scale
4. **Senior ML Engineer** - AI/ML features
**Why this works:**
- Fullstack engineers handle frontend & backend
- Data scientist provides insights
- DevOps ensures reliability
- ML engineer adds AI capabilities
---
### **Scale-Up Team (10-25 people)**
**Growing Team:**
1. **Senior Architect** (×1) - System design & tech strategy
2. **Senior Frontend Engineer** (×2) - User experience
3. **Senior Backend Engineer** (×3) - APIs & business logic
4. **Senior Data Engineer** (×2) - Data infrastructure
5. **Senior Data Scientist** (×2) - Analytics & modeling
6. **Senior ML Engineer** (×2) - ML in production
7. **Senior QA Engineer** (×1) - Quality assurance
8. **Senior DevOps Engineer** (×1) - Infrastructure
9. **Senior SecOps Engineer** (×1) - Security
**Why this works:**
- Clear separation of concerns
- Specialized expertise
- Dedicated quality & security
- Scalable data infrastructure
---
### **Enterprise Team (25-50+ people)**
**Complete Team:**
**Engineering:**
1. **Senior Architect** (×2) - System & solution architecture
2. **Senior Frontend Engineer** (×4-6) - Web & mobile UI
3. **Senior Backend Engineer** (×6-8) - Microservices
4. **Senior Fullstack Engineer** (×2-3) - Rapid prototyping
5. **Senior QA Engineer** (×3-4) - Test automation
6. **Senior DevOps Engineer** (×3-4) - Platform engineering
7. **Senior SecOps Engineer** (×2) - Security operations
8. **Senior Security Engineer** (×2) - Security architecture
9. **Code Reviewer** (×2) - Quality gatekeeping
**AI/ML/Data:**
1. **Senior Data Scientist** (×4-6) - Experimentation & modeling
2. **Senior Data Engineer** (×4-6) - Data platform
3. **Senior ML Engineer** (×4-6) - ML platform & deployment
4. **Senior Prompt Engineer** (×2-3) - LLM optimization
5. **Senior Computer Vision Engineer** (×2-3) - Vision AI
**Why this works:**
- Multiple teams per domain
- Deep specialization
- Redundancy for reliability
- Research & innovation capacity
---
## 💡 **Skill Selection Guide**
### **When to Use Each Skill**
#### **System Design & Architecture**
→ Use `skills/senior-architect/`
- Designing new systems
- Making tech stack decisions
- Creating architecture diagrams
- Evaluating trade-offs
#### **Frontend Development**
→ Use `skills/senior-frontend/`
- Building React/Next.js apps
- UI/UX implementation
- Performance optimization
- State management
#### **Backend Development**
→ Use `skills/senior-backend/`
- Designing APIs (REST/GraphQL)
- Database optimization
- Authentication/authorization
- Microservices
#### **Full-Stack Development**
→ Use `skills/senior-fullstack/`
- Building complete features
- Rapid prototyping
- Startup MVP development
- Code quality analysis
#### **Testing & QA**
→ Use `skills/senior-qa/`
- Test strategy design
- Test automation
- Coverage analysis
- Quality metrics
#### **DevOps & Infrastructure**
→ Use `skills/senior-devops/`
- CI/CD pipelines
- Infrastructure as code
- Deployment automation
- Container orchestration
#### **Security Operations**
→ Use `skills/senior-secops/`
- Security scanning
- Vulnerability management
- Compliance checking
- Incident response
#### **Code Reviews**
→ Use `skills/code-reviewer/`
- PR reviews
- Code quality checks
- Standards enforcement
- Mentoring feedback
#### **Security Architecture**
→ Use `skills/senior-security/`
- Security design
- Penetration testing
- Threat modeling
- Cryptography
#### **Data Science**
→ Use `skills/senior-data-scientist/`
- Statistical modeling
- A/B testing
- Causal inference
- Feature engineering
- Business analytics
#### **Data Engineering**
→ Use `skills/senior-data-engineer/`
- Data pipelines
- ETL/ELT design
- Data modeling
- Data quality
- Stream processing
#### **ML/AI Engineering**
→ Use `skills/senior-ml-engineer/`
- Model deployment
- MLOps
- LLM integration
- RAG systems
- Model monitoring
#### **Prompt Engineering**
→ Use `skills/senior-prompt-engineer/`
- LLM optimization
- Prompt patterns
- Agent design
- RAG optimization
- AI evaluation
#### **Computer Vision**
→ Use `skills/senior-computer-vision/`
- Object detection
- Image segmentation
- Video analysis
- Vision models
- Real-time inference
---
## 🎓 **Tech Stack Coverage**
### **Engineering Stack**
**Frontend:**
- React 18+
- Next.js 14+ (App Router)
- TypeScript
- Tailwind CSS
- React Native
- Flutter
- Swift (iOS)
- Kotlin (Android)
**Backend:**
- Node.js + Express
- GraphQL (Apollo)
- Go (Gin/Echo)
- Python (FastAPI)
- PostgreSQL
- Prisma ORM
**Infrastructure:**
- Docker
- Kubernetes
- Terraform
- AWS/GCP/Azure
- GitHub Actions
- CircleCI
### **AI/ML/Data Stack**
**Data Processing:**
- Python
- SQL
- Spark
- Airflow
- dbt
- Kafka
- Databricks
**ML Frameworks:**
- PyTorch
- TensorFlow
- Scikit-learn
- XGBoost
- Transformers
- LangChain
- LlamaIndex
**MLOps:**
- MLflow
- Weights & Biases
- Kubeflow
- SageMaker
- Vertex AI
**Data Storage:**
- PostgreSQL
- Snowflake
- BigQuery
- Redshift
- Pinecone (vector DB)
- Redis
**Computer Vision:**
- OpenCV
- YOLO
- Segment Anything (SAM)
- CLIP
- Stable Diffusion
---
## 🚀 **Quick Start Guide**
### **1. Choose Your Team Size**
- **Startup (< 10)**: Fullstack + Data + ML + DevOps
- **Scale-up (10-25)**: Add specialists (Frontend, Backend, Data Eng)
- **Enterprise (25+)**: Complete teams with redundancy
### **2. Pick Relevant Skills**
Each skill lives in this repo under `skills/<name>/` — use it in place or copy the folder.
### **3. Extract and Explore**
```bash
# Open a skill folder
cd skills/senior-ml-engineer
# Read the documentation
cat SKILL.md
# Check reference guides
ls references/
# Try the scripts
python scripts/model_deployment_pipeline.py --help
```
### **4. Customize for Your Needs**
Each skill is a starting point:
- Update scripts for your workflows
- Add your patterns to references
- Customize for your tech stack
- Share learnings with team
---
## 🔄 **Workflow Examples**
### **Workflow 1: New AI Product Feature**
```bash
# 1. Design system architecture
cd senior-architect
python scripts/architecture_diagram_generator.py --type system --output docs/
# 2. Build data pipeline
cd ../senior-data-engineer
python scripts/pipeline_orchestrator.py --input raw/ --output processed/
# 3. Train ML model
cd ../senior-ml-engineer
python scripts/model_deployment_pipeline.py --train --config model_config.yaml
# 4. Optimize prompts
cd ../senior-prompt-engineer
python scripts/prompt_optimizer.py --model gpt-4 --task classification
# 5. Deploy with DevOps
cd ../senior-devops
python scripts/deployment_manager.py --service ml-api --environment production
```
### **Workflow 2: Complete Application Development**
```bash
# 1. Architecture design
cd senior-architect
python scripts/project_architect.py my-app --pattern microservices
# 2. Backend API
cd ../senior-backend
python scripts/api_scaffolder.py my-app-api --type graphql
# 3. Frontend
cd ../senior-frontend
python scripts/frontend_scaffolder.py my-app-web --framework nextjs
# 4. Testing
cd ../senior-qa
python scripts/test_suite_generator.py ../my-app --coverage
# 5. CI/CD
cd ../senior-devops
python scripts/pipeline_generator.py my-app --platform github
```
### **Workflow 3: Data Science Project**
```bash
# 1. Design experiment
cd senior-data-scientist
python scripts/experiment_designer.py --hypothesis "feature X improves conversion" --power 0.8
# 2. Feature engineering
python scripts/feature_engineering_pipeline.py --input data/raw --output data/features
# 3. Build data pipeline
cd ../senior-data-engineer
python scripts/pipeline_orchestrator.py --schedule daily --destination warehouse
# 4. Deploy model
cd ../senior-ml-engineer
python scripts/model_deployment_pipeline.py --model ./models/best.pkl --endpoint /api/predict
```
---
## 📊 **Senior-Level Expectations**
Each skill embodies world-class senior-level practices:
### **Technical Excellence**
- Production-grade code quality
- Scalable architecture design
- Performance optimization
- Security best practices
- Comprehensive testing
### **Leadership**
- Mentor junior engineers
- Drive technical decisions
- Establish coding standards
- Code review excellence
- Knowledge sharing
### **Strategic Thinking**
- Align with business goals
- Evaluate trade-offs
- Plan for scale
- Manage technical debt
- Innovation mindset
### **Collaboration**
- Cross-functional teamwork
- Stakeholder communication
- Consensus building
- Documentation
- Remote-friendly practices
### **Production Operations**
- High availability (99.9%+)
- Monitoring & alerting
- Incident response
- Performance optimization
- Cost optimization
---
## 🎯 **Performance Benchmarks**
### **System Performance**
**Latency Targets:**
- P50: < 50ms
- P95: < 100ms
- P99: < 200ms
- P99.9: < 500ms
**Throughput Targets:**
- Requests/second: > 1,000
- Concurrent users: > 10,000
- Data processed: > 1TB/day
**Availability:**
- Uptime: 99.9%
- Error rate: < 0.1%
- MTTR: < 15 minutes
### **ML/AI Performance**
**Model Metrics:**
- Training time: Optimized
- Inference latency: < 100ms P95
- Accuracy: Domain-specific targets
- Drift detection: < 24 hours
**Data Quality:**
- Completeness: > 99%
- Accuracy: > 99.5%
- Timeliness: Real-time to daily
- Consistency: Validated
---
## 🛡️ **Security & Compliance**
All skills include:
- **Authentication & Authorization**: OAuth2, OIDC, RBAC
- **Data Protection**: Encryption at rest & in transit
- **Privacy**: PII handling, GDPR/CCPA compliance
- **Vulnerability Management**: Regular scanning & patching
- **Audit Logging**: Comprehensive activity tracking
- **Security Testing**: Penetration testing, SAST, DAST
---
## 📚 **Continuous Learning**
### **Staying Current**
Each skill encourages:
- Reading research papers
- Following industry blogs
- Attending conferences
- Contributing to open source
- Experimenting with new tech
- Sharing knowledge
### **Knowledge Sharing**
- Tech talks & demos
- Documentation
- Code reviews as learning
- Pair programming
- Mentoring sessions
---
## 🔧 **Customization Guide**
### **Adapting Skills**
1. **Update Scripts**
- Add company-specific logic
- Integrate with your tools
- Customize templates
- Add validation rules
2. **Enhance References**
- Add your patterns
- Document decisions
- Include examples
- Share lessons learned
3. **Team Standards**
- Coding conventions
- Git workflow
- Review process
- Deployment procedures
---
## 💼 **Hiring & Team Building**
### **Using Skills for Hiring**
1. **Job Descriptions**: Use skill requirements
2. **Technical Interviews**: Assess skill areas
3. **Code Challenges**: Based on skill patterns
4. **Onboarding**: Skills as training material
### **Team Development**
1. **Skill Gaps**: Identify and address
2. **Training Plans**: Based on skill content
3. **Mentorship**: Use patterns and practices
4. **Career Paths**: Senior → Lead → Principal
---
## 📈 **Success Metrics**
### **Engineering Metrics**
- **Velocity**: Story points/sprint
- **Quality**: Defect rate, test coverage
- **Reliability**: Uptime, MTTR
- **Performance**: Latency, throughput
- **Security**: Vulnerabilities, incidents
### **AI/ML Metrics**
- **Model Performance**: Accuracy, precision, recall
- **Data Quality**: Completeness, accuracy
- **Pipeline Reliability**: Success rate, latency
- **Business Impact**: Revenue, engagement, conversion
- **Cost Efficiency**: $/prediction, resource usage
---
## 🎉 **Summary**
You now have **14 world-class skills** covering:
### **Engineering (9 Skills)**
✅ Architecture & Design
✅ Frontend & Backend Development
✅ Full-Stack Development
✅ Quality Assurance & Testing
✅ DevOps & Infrastructure
✅ Security Operations & Engineering
✅ Code Review & Standards
### **AI/ML/Data (5 Skills)**
✅ Data Science & Analytics
✅ Data Engineering & Pipelines
✅ ML/AI Engineering & MLOps
✅ Prompt Engineering & LLMs
✅ Computer Vision & Visual AI
Each skill includes:
- **Comprehensive SKILL.md** with quick start
- **3 reference guides** with advanced patterns
- **3 production-grade scripts** for automation
- **World-class practices** from industry leaders
- **Senior-level expectations** and responsibilities
---
## 🚀 **Next Steps**
1. **Review team structure** recommendations
2. **Download skills** matching your team size
3. **Extract and explore** SKILL.md files
4. **Customize scripts** for your workflows
5. **Integrate into** development process
6. **Share with team** and iterate
---
## 💡 **Key Principles**
Remember these core principles:
1. **Production First**: Always design for production
2. **Quality Always**: Never compromise on quality
3. **Security Built-In**: Security is not optional
4. **Performance Matters**: Optimize intelligently
5. **Collaborate**: Work across teams
6. **Mentor**: Share knowledge generously
7. **Innovate**: Stay current and experiment
8. **Document**: Write it down
9. **Automate**: Eliminate toil
10. **Measure**: You can't improve what you don't measure
---
**Build World-Class Teams! 🎯**
These skills are your foundation for engineering and AI/ML excellence. Use them to build, grow, and scale exceptional teams that deliver outstanding products.
@@ -0,0 +1,15 @@
{
"name": "a11y-audit",
"description": "WCAG 2.2 accessibility audit and fix skill for React, Next.js, Vue, Angular, Svelte, and HTML. Static scanner detecting 20+ violation types, contrast checker with suggest mode, framework-specific fix patterns, CI-friendly exit codes.",
"version": "2.9.0",
"author": {
"name": "Alireza Rezvani",
"url": "https://alirezarezvani.com"
},
"homepage": "https://github.com/alirezarezvani/claude-skills/tree/main/engineering-team/a11y-audit",
"repository": "https://github.com/alirezarezvani/claude-skills",
"license": "MIT",
"skills": [
"./skills"
]
}
+46
View File
@@ -0,0 +1,46 @@
# A11y Audit — WCAG 2.2 Accessibility Audit & Fix
Audit and fix WCAG 2.2 accessibility issues in any frontend project. Covers React, Next.js, Vue, Angular, Svelte, and plain HTML.
## Quick Start
```bash
# Scan a project
/a11y-audit ./src
# Or use the scripts directly
python3 scripts/a11y_scanner.py ./src
python3 scripts/contrast_checker.py "#1a1a2e" "#ffffff"
```
## Scripts
| Script | Purpose |
|--------|---------|
| `a11y_scanner.py` | Scan HTML/JSX/TSX/Vue/Svelte/CSS for 20+ a11y violations |
| `contrast_checker.py` | WCAG contrast ratio calculator with AA/AAA checks and `--suggest` mode |
Both are stdlib-only — no pip install needed. CI-friendly exit codes (0 = pass, 1 = blocking issues).
## What It Covers
- **Images**: missing alt, empty alt on informative images
- **Forms**: missing labels, orphan labels, missing fieldset/legend
- **Headings**: skipped levels, missing h1, multiple h1s
- **Landmarks**: missing main/nav/skip link
- **Keyboard**: tabindex > 0, click without keyboard handler
- **ARIA**: invalid attributes, aria-hidden on focusable, missing aria-live
- **Color**: contrast ratios below AA thresholds
- **Links**: empty links, "click here" text
- **Tables**: missing headers, missing caption
- **Media**: missing captions, autoplay without controls
## References
- `references/wcag-quick-ref.md` — WCAG 2.2 Level A/AA criteria table
- `references/aria-patterns.md` — ARIA roles, live regions, keyboard patterns
- `references/framework-a11y-patterns.md` — React, Vue, Angular, Svelte fix patterns
## License
MIT
+15
View File
@@ -0,0 +1,15 @@
{
"name": "a11y-audit",
"displayName": "A11y Audit",
"version": "2.1.2",
"description": "WCAG 2.2 accessibility audit and fix. Scans React, Next.js, Vue, Angular, Svelte, and HTML for 20+ violation types. Contrast checker with suggest mode. CI-friendly.",
"author": "Alireza Rezvani",
"license": "MIT",
"platforms": ["claude-code", "openclaw", "codex"],
"category": "engineering",
"tags": ["accessibility", "a11y", "wcag", "aria", "screen-reader", "keyboard-navigation", "contrast", "react", "vue", "angular", "nextjs", "svelte"],
"repository": "https://github.com/alirezarezvani/claude-skills",
"commands": {
"a11y-audit": "/a11y-audit"
}
}
@@ -0,0 +1,211 @@
---
name: "a11y-audit"
description: "Accessibility audit skill for scanning, fixing, and verifying WCAG 2.2 Level A and AA compliance across React, Next.js, Vue, Angular, Svelte, and plain HTML codebases. Use when auditing accessibility, fixing a11y violations, checking color contrast, generating compliance reports, or integrating accessibility checks into CI/CD pipelines."
---
# Accessibility Audit
WCAG 2.2 Accessibility Audit and Remediation Skill
## Description
The a11y-audit skill provides a complete accessibility audit pipeline for modern web applications. It implements a three-phase workflow -- Scan, Fix, Verify -- that identifies WCAG 2.2 Level A and AA violations, generates exact fix code per framework, and produces stakeholder-ready compliance reports.
For every violation it finds, it provides the precise before/after code fix tailored to your framework (React, Next.js, Vue, Angular, Svelte, or plain HTML).
**What this skill does:**
1. **Scans** your codebase for every WCAG 2.2 Level A and AA violation, categorized by severity (Critical, Major, Minor)
2. **Fixes** each violation with framework-specific before/after code patterns
3. **Verifies** that fixes resolve the original violations and introduces no regressions
4. **Reports** findings in a structured format suitable for developers, PMs, and compliance stakeholders
5. **Integrates** into CI/CD pipelines to prevent accessibility regressions
## Features
| Feature | Description |
|---------|-------------|
| **Full WCAG 2.2 Scan** | Checks all Level A and AA success criteria across your codebase |
| **Framework Detection** | Auto-detects React, Next.js, Vue, Angular, Svelte, or plain HTML |
| **Severity Classification** | Categorizes each violation as Critical, Major, or Minor |
| **Fix Code Generation** | Produces before/after code diffs for every issue |
| **Color Contrast Checker** | Validates foreground/background pairs against AA and AAA ratios |
| **Compliance Reporting** | Generates stakeholder reports with pass/fail summaries |
| **CI/CD Integration** | GitHub Actions, GitLab CI, Azure DevOps pipeline configs |
| **Keyboard Navigation Audit** | Detects missing focus management and tab order issues |
| **ARIA Validation** | Checks for incorrect, redundant, or missing ARIA attributes |
### Severity Definitions
| Severity | Definition | Example | SLA |
|----------|-----------|---------|-----|
| **Critical** | Blocks access for entire user groups | Missing alt text, no keyboard access to navigation | Fix before release |
| **Major** | Significant barrier that degrades experience | Insufficient color contrast, missing form labels | Fix within current sprint |
| **Minor** | Usability issue that causes friction | Redundant ARIA roles, suboptimal heading hierarchy | Fix within next 2 sprints |
## Usage
### Quick Start
```bash
# Scan entire project
python scripts/a11y_scanner.py /path/to/project
# Scan with JSON output for tooling
python scripts/a11y_scanner.py /path/to/project --json
# Check color contrast for specific values
python scripts/contrast_checker.py --fg "#777777" --bg "#ffffff"
# Check contrast across a CSS/Tailwind file
python scripts/contrast_checker.py --file /path/to/styles.css
```
### Slash Command
```
/a11y-audit # Audit current project
/a11y-audit --scope src/ # Audit specific directory
/a11y-audit --fix # Audit and auto-apply fixes
/a11y-audit --report # Generate stakeholder report
/a11y-audit --ci # Output CI-compatible results
```
### Three-Phase Workflow
**Phase 1: Scan** -- Walk the source tree, detect framework, apply rule set.
```bash
python scripts/a11y_scanner.py /path/to/project --format table
```
**Phase 2: Fix** -- Apply framework-specific fixes for each violation.
> See [references/framework-a11y-patterns.md](references/framework-a11y-patterns.md) for the complete fix patterns catalog.
**Phase 3: Verify** -- Re-run the scanner to confirm fixes and check for regressions.
```bash
python scripts/a11y_scanner.py /path/to/project --baseline audit-baseline.json
```
## Example: React Component Audit
```tsx
// BEFORE: src/components/ProductCard.tsx
function ProductCard({ product }) {
return (
<div onClick={() => navigate(`/product/${product.id}`)}>
<img src={product.image} />
<div style={{ color: '#aaa', fontSize: '12px' }}>{product.name}</div>
<span style={{ color: '#999' }}>${product.price}</span>
</div>
);
}
```
| # | WCAG | Severity | Issue |
|---|------|----------|-------|
| 1 | 1.1.1 | Critical | `<img>` missing `alt` attribute |
| 2 | 2.1.1 | Critical | `<div onClick>` not keyboard accessible |
| 3 | 1.4.3 | Major | Color `#aaa` on white fails contrast (2.32:1, needs 4.5:1) |
| 4 | 1.4.3 | Major | Color `#999` on white fails contrast (2.85:1, needs 4.5:1) |
| 5 | 4.1.2 | Major | Interactive element missing role and accessible name |
```tsx
// AFTER: src/components/ProductCard.tsx
function ProductCard({ product }) {
return (
<a href={`/product/${product.id}`} className="product-card"
aria-label={`View ${product.name} - $${product.price}`}>
<img src={product.image} alt={product.imageAlt || product.name} />
<div style={{ color: '#595959', fontSize: '12px' }}>{product.name}</div>
<span style={{ color: '#767676' }}>${product.price}</span>
</a>
);
}
```
> See [references/examples-by-framework.md](references/examples-by-framework.md) for Vue, Angular, Next.js, and Svelte examples.
## Tools Reference
### a11y_scanner.py
```
Usage: python scripts/a11y_scanner.py <path> [options]
Options:
--json Output results as JSON
--format {table,csv} Output format (default: table)
--severity {critical,major,minor} Filter by minimum severity
--framework {react,vue,angular,svelte,html,auto} Force framework (default: auto)
--baseline FILE Compare against previous scan results
--report Generate stakeholder report
--output FILE Write results to file
--quiet Suppress output, exit code only
--ci CI mode: non-zero exit on critical issues
```
### contrast_checker.py
```
Usage: python scripts/contrast_checker.py [options]
Options:
--fg COLOR Foreground color (hex)
--bg COLOR Background color (hex)
--file FILE Scan CSS file for color pairs
--tailwind DIR Scan directory for Tailwind color classes
--json Output results as JSON
--suggest Suggest accessible alternatives for failures
--level {aa,aaa} Target conformance level (default: aa)
```
## Common Pitfalls
| Pitfall | Correct Approach |
|---------|------------------|
| `role="button"` on a `<div>` | Use native `<button>` -- includes keyboard handling for free |
| `tabindex="0"` on everything | Only interactive elements need focus; use native elements |
| `aria-label` on non-interactive elements | Use `aria-labelledby` pointing to visible text |
| `display: none` for screen reader hiding | Use `.sr-only` class instead |
| Color alone to convey meaning | Add icons, text labels, or patterns alongside color |
| Placeholder as only label | Always provide a visible `<label>` |
| `outline: none` without replacement | Always provide a visible focus indicator via `focus-visible` |
| Empty `alt=""` on informational images | Informational images need descriptive alt text |
| Skipping heading levels (h1 -> h3) | Heading levels must be sequential |
| `onClick` without `onKeyDown` | Add keyboard support or prefer native elements |
| Ignoring `prefers-reduced-motion` | Wrap animations in `@media (prefers-reduced-motion: no-preference)` |
## Related Skills
| Skill | Relationship |
|-------|-------------|
| **senior-frontend** | Frontend patterns used in a11y fixes |
| **code-reviewer** | Include a11y checks in code review workflows |
| **senior-qa** | Integration of a11y testing into QA processes |
| **playwright-pro** | Automated browser testing with accessibility assertions |
| **epic-design** | WCAG 2.1 AA compliant animations and scroll storytelling |
| **tdd-guide** | Test-driven development patterns for a11y test cases |
## Reference Documentation
| Reference | Description |
|-----------|-------------|
| [wcag-quick-ref.md](references/wcag-quick-ref.md) | WCAG 2.2 Level A & AA criteria quick reference |
| [wcag-22-new-criteria.md](references/wcag-22-new-criteria.md) | New WCAG 2.2 success criteria (Focus Appearance, Target Size, etc.) |
| [aria-patterns.md](references/aria-patterns.md) | ARIA patterns, keyboard interaction, and live regions |
| [framework-a11y-patterns.md](references/framework-a11y-patterns.md) | Framework-specific fix patterns (React, Vue, Angular, Svelte, HTML) |
| [color-contrast-guide.md](references/color-contrast-guide.md) | Color contrast checker details, Tailwind palette mapping, sr-only class |
| [ci-cd-integration.md](references/ci-cd-integration.md) | GitHub Actions, GitLab CI, Azure DevOps, pre-commit hook configs |
| [audit-report-template.md](references/audit-report-template.md) | Stakeholder-ready audit report template |
| [testing-checklist.md](references/testing-checklist.md) | Manual testing checklist (keyboard, screen reader, visual, forms) |
| [examples-by-framework.md](references/examples-by-framework.md) | Full audit examples for Vue, Angular, Next.js, and Svelte |
## Resources
- [WCAG 2.2 Specification](https://www.w3.org/TR/WCAG22/)
- [WAI-ARIA Authoring Practices 1.2](https://www.w3.org/WAI/ARIA/apg/)
- [Deque axe-core Rules](https://github.com/dequelabs/axe-core/blob/develop/doc/rule-descriptions.md)
- [eslint-plugin-jsx-a11y](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y)
@@ -0,0 +1,51 @@
// Sample React component with intentional a11y issues for testing
import React from 'react';
export function UserCard({ user, onEdit, onDelete }) {
return (
<div className="card" onClick={() => onEdit(user.id)}>
<img src={user.avatar} />
<div className="name">{user.name}</div>
<div className="email">{user.email}</div>
<div className="actions">
<div onClick={() => onDelete(user.id)} style={{ color: '#aaa', cursor: 'pointer' }}>
Delete
</div>
<a href="#">Edit</a>
</div>
<input placeholder="Add note" />
</div>
);
}
export function SearchBar() {
return (
<div>
<input type="text" placeholder="Search..." />
<div onClick={() => alert('searching')} tabIndex={5}>
🔍
</div>
</div>
);
}
export function DataTable({ rows }) {
return (
<table>
<tr>
<td><b>Name</b></td>
<td><b>Email</b></td>
<td><b>Status</b></td>
</tr>
{rows.map((row) => (
<tr key={row.id}>
<td>{row.name}</td>
<td>{row.email}</td>
<td style={{ color: row.active ? 'green' : 'red' }}>
{row.active ? '●' : '●'}
</td>
</tr>
))}
</table>
);
}
@@ -0,0 +1,39 @@
Contrast Check: #777777 on #ffffff
Foreground: #777777 (r=119, g=119, b=119)
Background: #ffffff (r=255, g=255, b=255)
Contrast Ratio: 4.48:1
Normal text (4.5:1 required):
AA: FAIL (4.48 < 4.5)
AAA: FAIL (4.48 < 7.0)
Large text (3.0:1 required):
AA: PASS (4.48 >= 3.0)
AAA: FAIL (4.48 < 4.5)
UI components (3.0:1 required):
AA: PASS (4.48 >= 3.0)
Verdict: FAIL — does not meet AA for normal text
---
Contrast Check: #1a1a2e on #ffffff
Foreground: #1a1a2e (r=26, g=26, b=46)
Background: #ffffff (r=255, g=255, b=255)
Contrast Ratio: 17.06:1
Normal text (4.5:1 required):
AA: PASS
AAA: PASS
Large text (3.0:1 required):
AA: PASS
AAA: PASS
UI components (3.0:1 required):
AA: PASS
Verdict: PASS — meets AAA for all categories
@@ -0,0 +1,34 @@
{
"summary": {
"files_scanned": 1,
"files_with_issues": 1,
"total_issues": 9,
"critical": 3,
"serious": 4,
"moderate": 2,
"minor": 0,
"verdict": "FAIL"
},
"findings": [
{
"severity": "critical",
"category": "IMG-ALT",
"file": "sample-component.tsx",
"line": 7,
"code": "<img src={user.avatar} />",
"wcag": "1.1.1",
"message": "Image missing alt attribute",
"fix": "Add alt text: alt=\"description of image\""
},
{
"severity": "critical",
"category": "KB-CLICK",
"file": "sample-component.tsx",
"line": 5,
"code": "<div className=\"card\" onClick={() => onEdit(user.id)}>",
"wcag": "2.1.1",
"message": "Click handler on non-interactive element without keyboard support",
"fix": "Use <button> or add role=\"button\", tabIndex={0}, onKeyDown"
}
]
}
@@ -0,0 +1,63 @@
# A11y Audit Report — sample-component.tsx
**Scanned:** 1 file | **Issues:** 9 | **Status:** FAIL
## Critical (3)
### 1. Missing alt text on image
- **File:** sample-component.tsx:7
- **Code:** `<img src={user.avatar} />`
- **WCAG:** 1.1.1 Non-text Content (Level A)
- **Fix:** Add descriptive alt text: `<img src={user.avatar} alt={`${user.name}'s avatar`} />`
### 2. Click handler without keyboard support
- **File:** sample-component.tsx:5
- **Code:** `<div className="card" onClick={() => onEdit(user.id)}>`
- **WCAG:** 2.1.1 Keyboard (Level A)
- **Fix:** Use `<button>` or add `role="button"`, `tabIndex={0}`, and `onKeyDown`
### 3. Click handler without keyboard support
- **File:** sample-component.tsx:11
- **Code:** `<div onClick={() => onDelete(user.id)} ...>`
- **WCAG:** 2.1.1 Keyboard (Level A)
- **Fix:** Replace `<div>` with `<button>`
## Serious (4)
### 4. Missing form label
- **File:** sample-component.tsx:15
- **Code:** `<input placeholder="Add note" />`
- **WCAG:** 3.3.2 Labels or Instructions (Level A)
- **Fix:** Add `<label>` or `aria-label="Add note"`
### 5. Empty link
- **File:** sample-component.tsx:14
- **Code:** `<a href="#">Edit</a>`
- **WCAG:** 2.4.4 Link Purpose (Level A)
- **Fix:** Use a real href or replace with `<button>`
### 6. tabindex greater than 0
- **File:** sample-component.tsx:24
- **Code:** `tabIndex={5}`
- **WCAG:** 2.4.3 Focus Order (Level A)
- **Fix:** Use `tabIndex={0}` — positive values disrupt natural tab order
### 7. Missing table headers
- **File:** sample-component.tsx:30
- **Code:** `<td><b>Name</b></td>` (using td+b instead of th)
- **WCAG:** 1.3.1 Info and Relationships (Level A)
- **Fix:** Use `<th scope="col">Name</th>`
## Moderate (2)
### 8. Missing form label
- **File:** sample-component.tsx:22
- **Code:** `<input type="text" placeholder="Search..." />`
- **WCAG:** 3.3.2 Labels or Instructions (Level A)
- **Fix:** Add `aria-label="Search"` or visible label
### 9. Color as sole indicator
- **File:** sample-component.tsx:38
- **Code:** `style={{ color: row.active ? 'green' : 'red' }}`
- **WCAG:** 1.4.1 Use of Color (Level A)
- **Fix:** Add text or icon alongside color: `{row.active ? '✓ Active' : '✗ Inactive'}`
@@ -0,0 +1,223 @@
# ARIA Patterns & Keyboard Interaction Reference
## Landmark Roles
Every page should have these landmarks:
```html
<header role="banner"> <!-- Site header — once per page -->
<nav role="navigation"> <!-- Navigation — can have multiple with aria-label -->
<main role="main"> <!-- Main content — once per page -->
<aside role="complementary"> <!-- Sidebar — related but not essential -->
<footer role="contentinfo"> <!-- Site footer — once per page -->
<form role="search"> <!-- Search form -->
```
**Semantic HTML equivalents:** `<header>`, `<nav>`, `<main>`, `<aside>`, `<footer>` provide implicit roles — no need to double up with explicit `role` attributes.
## Live Regions
### When to Use
| Pattern | Attribute | Use Case |
|---------|-----------|----------|
| Polite | `aria-live="polite"` | Toast notifications, status updates, search result counts |
| Assertive | `aria-live="assertive"` | Error messages, urgent alerts, form validation errors |
| Status | `role="status"` | Loading indicators, progress updates |
| Alert | `role="alert"` | Error dialogs, time-sensitive warnings |
| Log | `role="log"` | Chat messages, activity feeds |
| Timer | `role="timer"` | Countdown timers |
### Implementation
```html
<!-- Toast notifications -->
<div aria-live="polite" aria-atomic="true">
<!-- Inject toast content here dynamically -->
</div>
<!-- Form validation errors -->
<div aria-live="assertive" role="alert">
<p>Please enter a valid email address.</p>
</div>
<!-- Loading state -->
<div role="status" aria-live="polite">
Loading results...
</div>
```
**Key rule:** The live region container must exist in the DOM *before* content is injected. Adding `aria-live` to a newly created element won't announce it.
## Focus Management
### Focus Trap (Modals)
```javascript
// Trap focus inside modal
const modal = document.querySelector('[role="dialog"]');
const focusable = modal.querySelectorAll(
'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
modal.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
if (e.key === 'Escape') closeModal();
});
```
### Focus Restoration
```javascript
// Save focus before opening modal
const trigger = document.activeElement;
openModal();
// Restore focus on close
function closeModal() {
modal.hidden = true;
trigger.focus();
}
```
### Skip Link
```html
<a href="#main-content" class="skip-link">Skip to main content</a>
<!-- ... navigation ... -->
<main id="main-content" tabindex="-1">
```
```css
.skip-link {
position: absolute;
left: -9999px;
z-index: 999;
}
.skip-link:focus {
left: 10px;
top: 10px;
background: #000;
color: #fff;
padding: 8px 16px;
}
```
## Keyboard Interaction Patterns
### Tabs
```
Tab → Move to tab list, then to tab panel
Arrow Left/Right → Switch between tabs
Home → First tab
End → Last tab
```
```html
<div role="tablist" aria-label="Settings">
<button role="tab" aria-selected="true" aria-controls="panel-1" id="tab-1">General</button>
<button role="tab" aria-selected="false" aria-controls="panel-2" id="tab-2" tabindex="-1">Security</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">...</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>...</div>
```
### Combobox / Autocomplete
```
Arrow Down → Open list / next option
Arrow Up → Previous option
Enter → Select option
Escape → Close list
Type → Filter options
```
### Menu
```
Enter/Space → Activate item
Arrow Down → Next item
Arrow Up → Previous item
Arrow Right → Open submenu
Arrow Left → Close submenu
Escape → Close menu
```
### Accordion
```
Enter/Space → Toggle section
Arrow Down → Next header
Arrow Up → Previous header
Home → First header
End → Last header
```
## Framework-Specific ARIA
### React
```jsx
// Announce route changes (SPA)
<div aria-live="polite" className="sr-only">
{`Navigated to ${pageTitle}`}
</div>
// Error boundary with accessible error
<div role="alert">
<h2>Something went wrong</h2>
<p>{error.message}</p>
</div>
```
### Vue
```vue
<!-- Announce dynamic content -->
<div aria-live="polite">
<p v-if="results.length">{{ results.length }} results found</p>
</div>
<!-- Accessible toggle -->
<button
:aria-expanded="isOpen"
:aria-controls="panelId"
@click="toggle"
>
{{ isOpen ? 'Collapse' : 'Expand' }}
</button>
```
### Angular
```html
<!-- cdkTrapFocus for modals -->
<div cdkTrapFocus cdkTrapFocusAutoCapture role="dialog" aria-labelledby="dialog-title">
<h2 id="dialog-title">Confirm Action</h2>
</div>
<!-- LiveAnnouncer service -->
<!-- In component: this.liveAnnouncer.announce('Item added to cart'); -->
```
## Common ARIA Mistakes
| Mistake | Why It's Wrong | Fix |
|---------|---------------|-----|
| `<div role="button">` without keyboard | Div doesn't get keyboard events | Use `<button>` or add `tabindex="0"` + `onkeydown` |
| `aria-hidden="true"` on focusable element | Screen reader skips it but keyboard reaches it | Remove from tab order too: `tabindex="-1"` |
| `aria-label` overriding visible text | Confusing for sighted screen reader users | Use `aria-labelledby` pointing to visible text |
| Redundant ARIA on semantic HTML | `<nav role="navigation">` is redundant | Drop the `role``<nav>` implies it |
| `aria-live` on container that already has content | Initial content gets announced on load | Add `aria-live` to empty container, inject content after |
| Missing `aria-expanded` on toggles | Screen reader can't tell if section is open | Add `aria-expanded="true/false"` |
@@ -0,0 +1,46 @@
# Audit Report Template
The scanner generates a stakeholder-ready report when run with the `--report` flag:
```bash
python scripts/a11y_scanner.py /path/to/project --report --output audit-report.md
```
## Generated Report Structure
```markdown
# Accessibility Audit Report
**Project:** Acme Dashboard
**Date:** 2026-03-18
**Standard:** WCAG 2.2 Level AA
**Tool:** a11y-audit v2.1.2
## Executive Summary
- Files Scanned: 127
- Total Violations: 14
- Critical: 3 | Major: 7 | Minor: 4
- Estimated Remediation: 8-12 hours
- Compliance Score: 72% (Target: 100%)
## Violations by Category
| Category | Count | Severity Breakdown |
|----------|-------|--------------------|
| Missing Alt Text | 3 | 2 Critical, 1 Minor |
| Keyboard Access | 4 | 2 Critical, 2 Major |
| Color Contrast | 3 | 3 Major |
| Form Labels | 2 | 2 Major |
| ARIA Usage | 2 | 2 Minor |
## Detailed Findings
[Per-violation details with file, line, WCAG criterion, and fix]
## Remediation Priority
1. Fix all Critical issues (blocks release)
2. Fix Major issues in current sprint
3. Schedule Minor issues for next sprint
## Recommendations
- Add a11y linting to CI pipeline (eslint-plugin-jsx-a11y)
- Include keyboard testing in QA checklist
- Schedule quarterly manual audit with assistive technology
```
@@ -0,0 +1,127 @@
# CI/CD Integration for Accessibility Auditing
## GitHub Actions
```yaml
# .github/workflows/a11y-audit.yml
name: Accessibility Audit
on:
pull_request:
paths:
- 'src/**/*.tsx'
- 'src/**/*.vue'
- 'src/**/*.html'
- 'src/**/*.svelte'
jobs:
a11y-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Run A11y Scanner
run: |
python scripts/a11y_scanner.py ./src --json > a11y-results.json
- name: Check for Critical Issues
run: |
python -c "
import json, sys
with open('a11y-results.json') as f:
data = json.load(f)
critical = [v for v in data.get('violations', []) if v['severity'] == 'critical']
if critical:
print(f'FAILED: {len(critical)} critical a11y violations found')
for v in critical:
print(f\" [{v['wcag']}] {v['file']}:{v['line']} - {v['message']}\")
sys.exit(1)
print('PASSED: No critical a11y violations')
"
- name: Upload Audit Report
if: always()
uses: actions/upload-artifact@v4
with:
name: a11y-audit-report
path: a11y-results.json
- name: Comment on PR
if: failure()
uses: marocchino/sticky-pull-request-comment@v2
with:
header: a11y-audit
message: |
## Accessibility Audit Failed
Critical WCAG 2.2 violations were found. See the uploaded artifact for details.
Run `python scripts/a11y_scanner.py ./src` locally to view and fix issues.
```
## GitLab CI
```yaml
# .gitlab-ci.yml
a11y-audit:
stage: test
image: python:3.11-slim
script:
- python scripts/a11y_scanner.py ./src --json > a11y-results.json
- python -c "
import json, sys;
data = json.load(open('a11y-results.json'));
critical = [v for v in data.get('violations', []) if v['severity'] == 'critical'];
sys.exit(1) if critical else print('A11y audit passed')
"
artifacts:
paths:
- a11y-results.json
when: always
rules:
- changes:
- "src/**/*.{tsx,vue,html,svelte}"
```
## Azure DevOps
```yaml
# azure-pipelines.yml
- task: PythonScript@0
displayName: 'Run A11y Audit'
inputs:
scriptSource: 'filePath'
scriptPath: 'scripts/a11y_scanner.py'
arguments: './src --json --output $(Build.ArtifactStagingDirectory)/a11y-results.json'
- task: PublishBuildArtifacts@1
condition: always()
inputs:
PathtoPublish: '$(Build.ArtifactStagingDirectory)/a11y-results.json'
ArtifactName: 'a11y-audit-report'
```
## Pre-Commit Hook
```bash
#!/bin/bash
# .git/hooks/pre-commit
# Run a11y scan on staged files only
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(tsx|vue|html|svelte|jsx)$')
if [ -n "$STAGED_FILES" ]; then
echo "Running accessibility audit on staged files..."
for file in $STAGED_FILES; do
python scripts/a11y_scanner.py "$file" --severity critical --quiet
if [ $? -ne 0 ]; then
echo "A11y audit FAILED for $file. Fix critical issues before committing."
exit 1
fi
done
echo "A11y audit passed."
fi
```
@@ -0,0 +1,83 @@
# Color Contrast Guide
## Contrast Checker Usage
The `contrast_checker.py` script validates color pairs against WCAG 2.2 contrast requirements.
```bash
# Check a single color pair
python scripts/contrast_checker.py --fg "#777777" --bg "#ffffff"
# Output:
# Foreground: #777777 | Background: #ffffff
# Contrast Ratio: 4.48:1
# AA Normal Text (4.5:1): FAIL
# AA Large Text (3.0:1): PASS
# AAA Normal Text (7.0:1): FAIL
# Suggested alternative: #767676 (4.54:1 - passes AA)
# Scan a CSS file for all color pairs
python scripts/contrast_checker.py --file src/styles/globals.css
# Scan Tailwind classes in components
python scripts/contrast_checker.py --tailwind src/components/
```
## Common Contrast Fixes
| Original Color | Contrast on White | Fix | New Contrast |
|----------------|------------------|-----|--------------|
| `#aaaaaa` | 2.32:1 | `#767676` | 4.54:1 (AA) |
| `#999999` | 2.85:1 | `#767676` | 4.54:1 (AA) |
| `#888888` | 3.54:1 | `#767676` | 4.54:1 (AA) |
| `#777777` | 4.48:1 | `#757575` | 4.60:1 (AA) |
| `#66bb6a` | 3.06:1 | `#2e7d32` | 5.87:1 (AA) |
| `#42a5f5` | 2.81:1 | `#1565c0` | 6.08:1 (AA) |
| `#ef5350` | 3.13:1 | `#c62828` | 5.57:1 (AA) |
## Tailwind CSS Accessible Palette Mapping
| Inaccessible Class | Contrast on White | Accessible Alternative | Contrast |
|---------------------|------------------|----------------------|----------|
| `text-gray-400` | 2.68:1 | `text-gray-600` | 5.74:1 |
| `text-blue-400` | 2.81:1 | `text-blue-700` | 5.96:1 |
| `text-green-400` | 2.12:1 | `text-green-700` | 5.18:1 |
| `text-red-400` | 3.04:1 | `text-red-700` | 6.05:1 |
| `text-yellow-500` | 1.47:1 | `text-yellow-800` | 7.34:1 |
## Screen Reader Utility Class
Every project should include this utility class for visually hiding content while keeping it accessible to screen readers:
```css
/* Visually hidden but accessible to screen readers */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* Allow the element to be focusable when navigated to via keyboard */
.sr-only-focusable:focus,
.sr-only-focusable:active {
position: static;
width: auto;
height: auto;
padding: inherit;
margin: inherit;
overflow: visible;
clip: auto;
white-space: inherit;
}
```
Tailwind CSS includes this as `sr-only` by default. For other frameworks:
- **Angular**: Add to `styles.scss`
- **Vue**: Add to `assets/global.css`
- **Svelte**: Add to `app.css`
@@ -0,0 +1,312 @@
# Accessibility Audit Examples by Framework
## Example 1: Vue SFC Form Audit
```vue
<!-- BEFORE: src/components/LoginForm.vue -->
<template>
<form @submit="handleLogin">
<input type="text" placeholder="Email" v-model="email" />
<input type="password" placeholder="Password" v-model="password" />
<div v-if="error" style="color: red">{{ error }}</div>
<div @click="handleLogin">Sign In</div>
</form>
</template>
```
**Violations detected:**
| # | WCAG | Severity | Issue |
|---|------|----------|-------|
| 1 | 1.3.1 | Critical | Inputs missing associated `<label>` elements |
| 2 | 3.3.2 | Major | Placeholder text used as only label (disappears on input) |
| 3 | 2.1.1 | Critical | `<div @click>` not keyboard accessible |
| 4 | 4.1.3 | Major | Error message not announced to screen readers |
| 5 | 3.3.1 | Major | Error not programmatically associated with input |
```vue
<!-- AFTER: src/components/LoginForm.vue -->
<template>
<form @submit.prevent="handleLogin" aria-label="Sign in to your account">
<div class="field">
<label for="login-email">Email</label>
<input
id="login-email"
type="email"
v-model="email"
autocomplete="email"
required
:aria-describedby="emailError ? 'email-error' : undefined"
:aria-invalid="!!emailError"
/>
<span v-if="emailError" id="email-error" role="alert">
{{ emailError }}
</span>
</div>
<div class="field">
<label for="login-password">Password</label>
<input
id="login-password"
type="password"
v-model="password"
autocomplete="current-password"
required
:aria-describedby="passwordError ? 'password-error' : undefined"
:aria-invalid="!!passwordError"
/>
<span v-if="passwordError" id="password-error" role="alert">
{{ passwordError }}
</span>
</div>
<div v-if="error" role="alert" aria-live="assertive" class="form-error">
{{ error }}
</div>
<button type="submit">Sign In</button>
</form>
</template>
```
## Example 2: Angular Template Audit
```html
<!-- BEFORE: src/app/dashboard/dashboard.component.html -->
<div class="tabs">
<div *ngFor="let tab of tabs"
(click)="selectTab(tab)"
[class.active]="tab.active">
{{ tab.label }}
</div>
</div>
<div class="tab-content">
<div *ngIf="selectedTab">{{ selectedTab.content }}</div>
</div>
```
**Violations detected:**
| # | WCAG | Severity | Issue |
|---|------|----------|-------|
| 1 | 4.1.2 | Critical | Tab widget missing ARIA roles (`tablist`, `tab`, `tabpanel`) |
| 2 | 2.1.1 | Critical | Tabs not keyboard navigable (arrow keys, Home, End) |
| 3 | 2.4.11 | Major | No visible focus indicator on active tab |
```html
<!-- AFTER: src/app/dashboard/dashboard.component.html -->
<div class="tabs" role="tablist" aria-label="Dashboard sections">
<button
*ngFor="let tab of tabs; let i = index"
role="tab"
[id]="'tab-' + tab.id"
[attr.aria-selected]="tab.active"
[attr.aria-controls]="'panel-' + tab.id"
[attr.tabindex]="tab.active ? 0 : -1"
(click)="selectTab(tab)"
(keydown)="handleTabKeydown($event, i)"
class="tab-button"
[class.active]="tab.active">
{{ tab.label }}
</button>
</div>
<div
*ngIf="selectedTab"
role="tabpanel"
[id]="'panel-' + selectedTab.id"
[attr.aria-labelledby]="'tab-' + selectedTab.id"
tabindex="0"
class="tab-content">
{{ selectedTab.content }}
</div>
```
**Supporting TypeScript for keyboard navigation:**
```typescript
// dashboard.component.ts
handleTabKeydown(event: KeyboardEvent, index: number): void {
const tabCount = this.tabs.length;
let newIndex = index;
switch (event.key) {
case 'ArrowRight':
newIndex = (index + 1) % tabCount;
break;
case 'ArrowLeft':
newIndex = (index - 1 + tabCount) % tabCount;
break;
case 'Home':
newIndex = 0;
break;
case 'End':
newIndex = tabCount - 1;
break;
default:
return;
}
event.preventDefault();
this.selectTab(this.tabs[newIndex]);
// Move focus to the new tab button
const tabElement = document.getElementById(`tab-${this.tabs[newIndex].id}`);
tabElement?.focus();
}
```
## Example 3: Next.js Page-Level Audit
```tsx
// BEFORE: src/app/page.tsx
export default function Home() {
return (
<main>
<div className="text-4xl font-bold">Welcome to Acme</div>
<div className="mt-4">
Build better products with our platform.
</div>
<div className="mt-8 bg-blue-600 text-white px-6 py-3 rounded cursor-pointer"
onClick={() => router.push('/signup')}>
Get Started
</div>
</main>
);
}
```
**Violations detected:**
| # | WCAG | Severity | Issue |
|---|------|----------|-------|
| 1 | 1.3.1 | Major | Heading uses `<div>` instead of `<h1>` -- no semantic structure |
| 2 | 2.4.2 | Major | Page missing `<title>` (Next.js metadata) |
| 3 | 2.1.1 | Critical | CTA uses `<div onClick>` -- not keyboard accessible |
| 4 | 3.1.1 | Minor | `<html>` missing `lang` attribute (check `layout.tsx`) |
```tsx
// AFTER: src/app/page.tsx
import type { Metadata } from 'next';
import Link from 'next/link';
export const metadata: Metadata = {
title: 'Acme - Build Better Products',
description: 'Build better products with the Acme platform.',
};
export default function Home() {
return (
<main>
<h1 className="text-4xl font-bold">Welcome to Acme</h1>
<p className="mt-4">
Build better products with our platform.
</p>
<Link
href="/signup"
className="mt-8 inline-block bg-blue-600 text-white px-6 py-3 rounded
hover:bg-blue-700 focus-visible:outline focus-visible:outline-2
focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Get Started
</Link>
</main>
);
}
```
```tsx
// Also fix: src/app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
```
## Example 4: Svelte Component Audit
```svelte
<!-- BEFORE: src/lib/components/Accordion.svelte -->
<script>
let openIndex = -1;
function toggle(i) { openIndex = openIndex === i ? -1 : i; }
</script>
{#each items as item, i}
<div class="header" on:click={() => toggle(i)}>
{item.title}
</div>
{#if openIndex === i}
<div class="content">{item.body}</div>
{/if}
{/each}
```
**Violations detected:**
| # | WCAG | Severity | Issue |
|---|------|----------|-------|
| 1 | 4.1.2 | Critical | Accordion missing ARIA roles and properties |
| 2 | 2.1.1 | Critical | Headers not keyboard accessible |
| 3 | 2.5.8 | Minor | Click targets may be smaller than 24x24px (NEW in WCAG 2.2) |
```svelte
<!-- AFTER: src/lib/components/Accordion.svelte -->
<script>
export let items = [];
let openIndex = -1;
function toggle(i) {
openIndex = openIndex === i ? -1 : i;
}
function handleKeydown(event, i) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
toggle(i);
}
}
</script>
<div class="accordion">
{#each items as item, i}
<h3>
<button
class="accordion-header"
aria-expanded={openIndex === i}
aria-controls="panel-{i}"
id="header-{i}"
on:click={() => toggle(i)}
on:keydown={(e) => handleKeydown(e, i)}
>
{item.title}
<span class="icon" aria-hidden="true">
{openIndex === i ? '' : '+'}
</span>
</button>
</h3>
<div
id="panel-{i}"
role="region"
aria-labelledby="header-{i}"
class="accordion-content"
class:open={openIndex === i}
hidden={openIndex !== i}
>
{item.body}
</div>
{/each}
</div>
<style>
.accordion-header {
min-height: 44px; /* WCAG 2.5.8 Target Size */
width: 100%;
padding: 12px 16px;
cursor: pointer;
text-align: left;
}
.accordion-header:focus-visible {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
</style>
```
@@ -0,0 +1,661 @@
# Framework-Specific Accessibility Patterns
## React / Next.js
### Common Issues and Fixes
**Image alt text:**
```jsx
// ❌ Bad
<img src="/hero.jpg" />
<Image src="/hero.jpg" width={800} height={400} />
// ✅ Good
<img src="/hero.jpg" alt="Team collaborating in office" />
<Image src="/hero.jpg" width={800} height={400} alt="Team collaborating in office" />
// ✅ Decorative image
<img src="/divider.svg" alt="" role="presentation" />
```
**Form labels:**
```jsx
// ❌ Bad — placeholder as label
<input placeholder="Email" type="email" />
// ✅ Good — explicit label
<label htmlFor="email">Email</label>
<input id="email" type="email" placeholder="user@example.com" />
// ✅ Good — aria-label for icon-only inputs
<input type="search" aria-label="Search products" />
```
**Click handlers on divs:**
```jsx
// ❌ Bad — not keyboard accessible
<div onClick={handleClick}>Click me</div>
// ✅ Good — use button
<button onClick={handleClick}>Click me</button>
// ✅ If div is required — add keyboard support
<div
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleClick(); }}
>
Click me
</div>
```
**SPA route announcements (Next.js App Router):**
```jsx
// Layout component — announce page changes
'use client';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
export function RouteAnnouncer() {
const pathname = usePathname();
const [announcement, setAnnouncement] = useState('');
useEffect(() => {
const title = document.title;
setAnnouncement(`Navigated to ${title}`);
}, [pathname]);
return (
<div aria-live="assertive" role="status" className="sr-only">
{announcement}
</div>
);
}
```
**Focus management after dynamic content:**
```jsx
// After adding item to list, announce it
const [items, setItems] = useState([]);
const statusRef = useRef(null);
const addItem = (item) => {
setItems([...items, item]);
// Announce to screen readers
statusRef.current.textContent = `${item.name} added to list`;
};
return (
<>
<div ref={statusRef} aria-live="polite" className="sr-only" />
{/* list content */}
</>
);
```
### React-Specific Libraries
- `@radix-ui/*` — accessible primitives (Dialog, Tabs, Select, etc.)
- `@headlessui/react` — unstyled accessible components
- `react-aria` — Adobe's accessibility hooks
- `eslint-plugin-jsx-a11y` — lint rules for JSX accessibility
## Vue 3
### Common Issues and Fixes
**Dynamic content announcements:**
```vue
<template>
<div aria-live="polite" class="sr-only">
{{ announcement }}
</div>
<button @click="search">Search</button>
<ul v-if="results.length">
<li v-for="r in results" :key="r.id">{{ r.name }}</li>
</ul>
</template>
<script setup>
import { ref } from 'vue';
const results = ref([]);
const announcement = ref('');
async function search() {
results.value = await fetchResults();
announcement.value = `${results.value.length} results found`;
}
</script>
```
**Conditional rendering with focus:**
```vue
<template>
<button @click="showForm = true">Add Item</button>
<form v-if="showForm" ref="formRef">
<label for="name">Name</label>
<input id="name" ref="nameInput" />
</form>
</template>
<script setup>
import { ref, nextTick } from 'vue';
const showForm = ref(false);
const nameInput = ref(null);
watch(showForm, async (val) => {
if (val) {
await nextTick();
nameInput.value?.focus();
}
});
</script>
```
### Vue-Specific Libraries
- `vue-announcer` — route change announcements
- `@headlessui/vue` — accessible components
- `eslint-plugin-vuejs-accessibility` — lint rules
## Angular
### Common Issues and Fixes
**CDK accessibility utilities:**
```typescript
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { FocusTrapFactory } from '@angular/cdk/a11y';
@Component({...})
export class MyComponent {
constructor(
private liveAnnouncer: LiveAnnouncer,
private focusTrapFactory: FocusTrapFactory
) {}
addItem(item: Item) {
this.items.push(item);
this.liveAnnouncer.announce(`${item.name} added`);
}
openDialog(element: HTMLElement) {
const focusTrap = this.focusTrapFactory.create(element);
focusTrap.focusInitialElement();
}
}
```
**Template-driven forms:**
```html
<!-- ❌ Bad -->
<input [formControl]="email" placeholder="Email" />
<!-- ✅ Good -->
<label for="email">Email address</label>
<input id="email" [formControl]="email"
[attr.aria-invalid]="email.invalid && email.touched"
[attr.aria-describedby]="email.invalid ? 'email-error' : null" />
<div id="email-error" *ngIf="email.invalid && email.touched" role="alert">
Please enter a valid email address.
</div>
```
### Angular-Specific Tools
- `@angular/cdk/a11y``FocusTrap`, `LiveAnnouncer`, `FocusMonitor`
- `codelyzer` — a11y lint rules for Angular templates
## Svelte / SvelteKit
### Common Issues and Fixes
```svelte
<!-- ❌ Bad — on:click without keyboard -->
<div on:click={handleClick}>Action</div>
<!-- ✅ Good — Svelte a11y warning built-in -->
<button on:click={handleClick}>Action</button>
<!-- ✅ Accessible toggle -->
<button
on:click={() => isOpen = !isOpen}
aria-expanded={isOpen}
aria-controls="panel"
>
{isOpen ? 'Close' : 'Open'} Details
</button>
{#if isOpen}
<div id="panel" role="region" aria-labelledby="toggle-btn">
Panel content
</div>
{/if}
```
**Note:** Svelte has built-in a11y warnings in the compiler — it flags missing alt text, click-without-keyboard, and other common issues at build time.
## Plain HTML
### Checklist for Static Sites
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Descriptive Page Title</title>
</head>
<body>
<!-- Skip link -->
<a href="#main" class="skip-link">Skip to main content</a>
<header>
<nav aria-label="Main navigation">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about" aria-current="page">About</a></li>
</ul>
</nav>
</header>
<main id="main" tabindex="-1">
<h1>Page Heading</h1>
<!-- Only one h1 per page -->
<!-- Heading levels don't skip (h1 → h2 → h3, never h1 → h3) -->
</main>
<footer>
<p>&copy; 2026 Company Name</p>
</footer>
</body>
</html>
```
## CSS Accessibility Patterns
### Focus Indicators
```css
/* ❌ Bad — removes focus indicator entirely */
:focus { outline: none; }
/* ✅ Good — custom focus indicator */
:focus-visible {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
/* ✅ Good — enhanced for high contrast mode */
@media (forced-colors: active) {
:focus-visible {
outline: 2px solid ButtonText;
}
}
```
### Reduced Motion
```css
/* ✅ Respect prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
```
### Screen Reader Only
```css
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
```
## Fix Patterns Catalog
### React / Next.js Fix Patterns
#### Missing Alt Text (1.1.1)
```tsx
// BEFORE
<img src={hero} />
// AFTER - Informational image
<img src={hero} alt="Team collaborating around a whiteboard" />
// AFTER - Decorative image
<img src={divider} alt="" role="presentation" />
```
#### Non-Interactive Element with Click Handler (2.1.1)
```tsx
// BEFORE
<div onClick={handleClick}>Click me</div>
// AFTER - If it navigates
<Link href="/destination">Click me</Link>
// AFTER - If it performs an action
<button type="button" onClick={handleClick}>Click me</button>
```
#### Missing Focus Management in Modals (2.4.3)
```tsx
// BEFORE
function Modal({ isOpen, onClose, children }) {
if (!isOpen) return null;
return <div className="modal-overlay">{children}</div>;
}
// AFTER
import { useEffect, useRef } from 'react';
function Modal({ isOpen, onClose, children, title }) {
const modalRef = useRef(null);
const previousFocus = useRef(null);
useEffect(() => {
if (isOpen) {
previousFocus.current = document.activeElement;
modalRef.current?.focus();
} else {
previousFocus.current?.focus();
}
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const handleKeydown = (e) => {
if (e.key === 'Escape') onClose();
if (e.key === 'Tab') {
const focusable = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusable?.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener('keydown', handleKeydown);
return () => document.removeEventListener('keydown', handleKeydown);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose} aria-hidden="true">
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={title}
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={onClose}
aria-label="Close dialog"
className="modal-close"
>
&times;
</button>
{children}
</div>
</div>
);
}
```
#### Focus Appearance (2.4.11 -- NEW in WCAG 2.2)
```css
/* BEFORE */
button:focus {
outline: none; /* Removes default focus indicator */
}
/* AFTER - Meets WCAG 2.2 Focus Appearance */
button:focus-visible {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
```
```tsx
// Tailwind CSS pattern
<button className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600">
Submit
</button>
```
### Vue Fix Patterns
#### Missing Form Labels (1.3.1)
```vue
<!-- BEFORE -->
<input type="text" v-model="name" placeholder="Name" />
<!-- AFTER -->
<label for="user-name">Name</label>
<input id="user-name" type="text" v-model="name" autocomplete="name" />
```
#### Dynamic Content Without Live Region (4.1.3)
```vue
<!-- BEFORE -->
<div v-if="status">{{ statusMessage }}</div>
<!-- AFTER -->
<div aria-live="polite" aria-atomic="true">
<p v-if="status">{{ statusMessage }}</p>
</div>
```
#### Vue Router Navigation Announcements (2.4.2)
```typescript
// router/index.ts
router.afterEach((to) => {
const title = to.meta.title || 'Page';
document.title = `${title} | My App`;
// Announce route change to screen readers
const announcer = document.getElementById('route-announcer');
if (announcer) {
announcer.textContent = `Navigated to ${title}`;
}
});
```
```vue
<!-- App.vue - Add announcer element -->
<div
id="route-announcer"
role="status"
aria-live="assertive"
aria-atomic="true"
class="sr-only"
></div>
```
### Angular Fix Patterns
#### Missing ARIA on Custom Components (4.1.2)
```typescript
// BEFORE
@Component({
selector: 'app-dropdown',
template: `
<div (click)="toggle()">{{ selected }}</div>
<div *ngIf="isOpen">
<div *ngFor="let opt of options" (click)="select(opt)">{{ opt }}</div>
</div>
`
})
// AFTER
@Component({
selector: 'app-dropdown',
template: `
<button
role="combobox"
[attr.aria-expanded]="isOpen"
aria-haspopup="listbox"
[attr.aria-label]="label"
(click)="toggle()"
(keydown)="handleKeydown($event)"
>
{{ selected }}
</button>
<ul *ngIf="isOpen" role="listbox" [attr.aria-label]="label + ' options'">
<li
*ngFor="let opt of options; let i = index"
role="option"
[attr.aria-selected]="opt === selected"
[attr.id]="'option-' + i"
(click)="select(opt)"
(keydown)="handleOptionKeydown($event, opt, i)"
tabindex="-1"
>
{{ opt }}
</li>
</ul>
`
})
```
#### Angular CDK A11y Module Integration
```typescript
// Use Angular CDK for focus trap in dialogs
import { A11yModule } from '@angular/cdk/a11y';
@Component({
template: `
<div cdkTrapFocus cdkTrapFocusAutoCapture>
<h2 id="dialog-title">Edit Profile</h2>
<!-- dialog content -->
</div>
`
})
```
### Svelte Fix Patterns
#### Accessible Announcements (4.1.3)
```svelte
<!-- BEFORE -->
{#if message}
<p class="toast">{message}</p>
{/if}
<!-- AFTER -->
<div aria-live="polite" class="sr-only">
{#if message}
<p>{message}</p>
{/if}
</div>
<div class="toast" aria-hidden="true">
{#if message}
<p>{message}</p>
{/if}
</div>
```
#### SvelteKit Page Titles (2.4.2)
```svelte
<!-- +page.svelte -->
<svelte:head>
<title>Dashboard | My App</title>
</svelte:head>
```
### Plain HTML Fix Patterns
#### Skip Navigation Link (2.4.1)
```html
<!-- BEFORE -->
<body>
<nav><!-- long navigation --></nav>
<main><!-- content --></main>
</body>
<!-- AFTER -->
<body>
<a href="#main-content" class="skip-link">Skip to main content</a>
<nav aria-label="Main navigation"><!-- long navigation --></nav>
<main id="main-content" tabindex="-1"><!-- content --></main>
</body>
```
```css
.skip-link {
position: absolute;
top: -40px;
left: 0;
padding: 8px 16px;
background: #005fcc;
color: #fff;
z-index: 1000;
transition: top 0.2s;
}
.skip-link:focus {
top: 0;
}
```
#### Accessible Data Table (1.3.1)
```html
<!-- BEFORE -->
<table>
<tr><td>Name</td><td>Email</td><td>Role</td></tr>
<tr><td>Alice</td><td>alice@co.com</td><td>Admin</td></tr>
</table>
<!-- AFTER -->
<table aria-label="Team members">
<caption class="sr-only">List of team members and their roles</caption>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Email</th>
<th scope="col">Role</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Alice</th>
<td>alice@co.com</td>
<td>Admin</td>
</tr>
</tbody>
</table>
```
@@ -0,0 +1,41 @@
# Accessibility Testing Checklist
Use this checklist after applying fixes to verify accessibility manually.
## Keyboard Navigation
- [ ] All interactive elements reachable via Tab key
- [ ] Tab order follows visual/logical reading order
- [ ] Focus indicator visible on every focusable element (2px+ outline)
- [ ] Modals trap focus and return focus on close
- [ ] Escape key closes modals, dropdowns, and popups
- [ ] Arrow keys navigate within composite widgets (tabs, menus, listboxes)
- [ ] No keyboard traps (user can always Tab away)
## Screen Reader
- [ ] All images have appropriate alt text (or `alt=""` for decorative)
- [ ] Headings create logical document outline (h1 -> h2 -> h3)
- [ ] Form inputs have associated labels
- [ ] Error messages announced via `aria-live` or `role="alert"`
- [ ] Page title updates on navigation (SPA)
- [ ] Dynamic content changes announced appropriately
## Visual
- [ ] Text contrast meets 4.5:1 for normal text, 3:1 for large text
- [ ] UI component contrast meets 3:1 against background
- [ ] Content reflows without horizontal scrolling at 320px width
- [ ] Text resizable to 200% without loss of content
- [ ] No information conveyed by color alone
- [ ] Focus indicators meet 2.4.11 Focus Appearance criteria
## Motion and Media
- [ ] Animations respect `prefers-reduced-motion`
- [ ] No auto-playing media with audio
- [ ] No content flashing more than 3 times per second
- [ ] Video has captions; audio has transcripts
## Forms
- [ ] All inputs have visible labels
- [ ] Required fields indicated (not by color alone)
- [ ] Error messages specific and associated with input via `aria-describedby`
- [ ] Autocomplete attributes present on common fields (name, email, etc.)
- [ ] No CAPTCHA without alternative method (WCAG 2.2 3.3.8)
@@ -0,0 +1,79 @@
# WCAG 2.2 New Success Criteria Reference
These criteria were added in WCAG 2.2 and are commonly missed.
## 2.4.11 Focus Appearance (Level AA)
The focus indicator must have a minimum area of a 2px perimeter around the component and a contrast ratio of at least 3:1 against adjacent colors.
**Pattern:**
```css
:focus-visible {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
```
## 2.5.7 Dragging Movements (Level AA)
Any functionality that uses dragging must have a single-pointer alternative (click, tap).
**Pattern:**
```tsx
// Sortable list: support both drag and button-based reorder
<li draggable onDragStart={handleDrag}>
{item.name}
<button onClick={() => moveUp(index)} aria-label={`Move ${item.name} up`}>
Move Up
</button>
<button onClick={() => moveDown(index)} aria-label={`Move ${item.name} down`}>
Move Down
</button>
</li>
```
## 2.5.8 Target Size (Level AA)
Interactive targets must be at least 24x24 CSS pixels, with exceptions for inline text links and elements where the spacing provides equivalent clearance.
**Pattern:**
```css
button, a, input, select, textarea {
min-height: 24px;
min-width: 24px;
}
/* Recommended: 44x44px for touch targets */
@media (pointer: coarse) {
button, a, input[type="checkbox"], input[type="radio"] {
min-height: 44px;
min-width: 44px;
}
}
```
## 3.3.7 Redundant Entry (Level A)
Information previously entered by the user must be auto-populated or available for selection when needed again in the same process.
**Pattern:**
```tsx
// Multi-step form: persist data across steps
const [formData, setFormData] = useState({});
// Step 2 pre-fills shipping address from billing
<input
defaultValue={formData.billingAddress || ''}
autoComplete="shipping street-address"
/>
```
## 3.3.8 Accessible Authentication (Level AA)
Authentication must not require cognitive function tests (e.g., remembering a password, solving a puzzle) unless an alternative is provided.
**Pattern:**
- Support password managers (`autocomplete="current-password"`)
- Offer passkey / biometric authentication
- Allow copy-paste in password fields (never block paste)
- Provide email/SMS OTP as alternative to CAPTCHA
@@ -0,0 +1,113 @@
# WCAG 2.2 Quick Reference — Level A & AA
## Perceivable
### 1.1 Text Alternatives
| Criterion | Level | Requirement | Common Violation |
|-----------|-------|-------------|------------------|
| 1.1.1 Non-text Content | A | All images have `alt` text; decorative images use `alt=""` or `role="presentation"` | `<img src="logo.png">` without alt |
### 1.2 Time-Based Media
| Criterion | Level | Requirement | Common Violation |
|-----------|-------|-------------|------------------|
| 1.2.1 Audio-only / Video-only | A | Provide transcript or audio description | Video without captions |
| 1.2.2 Captions | A | Captions for all prerecorded audio in video | Missing `<track kind="captions">` |
| 1.2.3 Audio Description | A | Audio description for prerecorded video | No descriptive track |
| 1.2.5 Audio Description (Prerecorded) | AA | Audio description for all prerecorded video | Same as 1.2.3 but stricter |
### 1.3 Adaptable
| Criterion | Level | Requirement | Common Violation |
|-----------|-------|-------------|------------------|
| 1.3.1 Info and Relationships | A | Semantic markup conveys structure | Using `<div>` instead of `<nav>`, `<main>`, `<header>` |
| 1.3.2 Meaningful Sequence | A | Reading order matches visual order | CSS flex/grid reordering without DOM reorder |
| 1.3.3 Sensory Characteristics | A | Don't rely solely on color, shape, position | "Click the red button" |
| 1.3.4 Orientation | AA | Content not restricted to portrait/landscape | CSS `orientation: portrait` lock |
| 1.3.5 Identify Input Purpose | AA | Input purpose identifiable via `autocomplete` | Missing `autocomplete="email"` on email inputs |
### 1.4 Distinguishable
| Criterion | Level | Requirement | Ratio |
|-----------|-------|-------------|-------|
| 1.4.1 Use of Color | A | Color not sole means of conveying info | Red-only error indicators |
| 1.4.2 Audio Control | A | Auto-playing audio has pause/stop | `autoplay` without `controls` |
| 1.4.3 Contrast (Minimum) | AA | Text: 4.5:1, Large text: 3:1 | Light gray text on white |
| 1.4.4 Resize Text | AA | Text resizable to 200% without loss | Fixed `px` font sizes |
| 1.4.5 Images of Text | AA | Use real text, not text in images | Logo text as PNG |
| 1.4.10 Reflow | AA | Content reflows at 320px width | Horizontal scrolling at mobile widths |
| 1.4.11 Non-text Contrast | AA | UI components and graphics: 3:1 | Low-contrast borders, icons |
| 1.4.12 Text Spacing | AA | No loss of content when spacing adjusted | Fixed-height containers clipping |
| 1.4.13 Content on Hover/Focus | AA | Dismissible, hoverable, persistent | Tooltips that disappear on mouse move |
## Operable
### 2.1 Keyboard Accessible
| Criterion | Level | Requirement | Common Violation |
|-----------|-------|-------------|------------------|
| 2.1.1 Keyboard | A | All functionality via keyboard | `onClick` without `onKeyDown` |
| 2.1.2 No Keyboard Trap | A | Focus can move away from any component | Modal without focus trap escape |
| 2.1.4 Character Key Shortcuts | A | Single-key shortcuts can be turned off | `accesskey` conflicts |
### 2.4 Navigable
| Criterion | Level | Requirement | Common Violation |
|-----------|-------|-------------|------------------|
| 2.4.1 Bypass Blocks | A | Skip navigation link | No "Skip to content" link |
| 2.4.2 Page Titled | A | Descriptive `<title>` | `<title>Untitled</title>` |
| 2.4.3 Focus Order | A | Logical tab order | `tabindex` > 0 |
| 2.4.4 Link Purpose | A | Link text describes destination | "Click here", "Read more" |
| 2.4.6 Headings and Labels | AA | Descriptive headings | Generic headings |
| 2.4.7 Focus Visible | AA | Visible focus indicator | `outline: none` without replacement |
| 2.4.11 Focus Not Obscured | AA | Focused element not hidden by sticky header | Fixed header covering focused element |
### 2.5 Input Modalities
| Criterion | Level | Requirement | Common Violation |
|-----------|-------|-------------|------------------|
| 2.5.1 Pointer Gestures | A | Multi-point gestures have single-point alternative | Pinch-to-zoom only |
| 2.5.2 Pointer Cancellation | A | Down-event doesn't trigger action | `mousedown` instead of `click` |
| 2.5.3 Label in Name | A | Visible label is in accessible name | Button shows "Submit" but `aria-label="btn1"` |
| 2.5.4 Motion Actuation | A | Motion-triggered actions have alternative | Shake-to-undo only |
| 2.5.7 Dragging Movements | AA | Drag has single-pointer alternative | Drag-and-drop only reordering |
| 2.5.8 Target Size | AA | Touch targets minimum 24x24 CSS pixels | Tiny mobile buttons |
## Understandable
### 3.1 Readable
| Criterion | Level | Requirement | Common Violation |
|-----------|-------|-------------|------------------|
| 3.1.1 Language of Page | A | `<html lang="en">` | Missing `lang` attribute |
| 3.1.2 Language of Parts | AA | `lang` on foreign-language spans | Mixed-language content without `lang` |
### 3.2 Predictable
| Criterion | Level | Requirement | Common Violation |
|-----------|-------|-------------|------------------|
| 3.2.1 On Focus | A | Focus doesn't trigger unexpected change | Auto-submitting on focus |
| 3.2.2 On Input | A | Input doesn't trigger unexpected change | Auto-navigating on select change |
| 3.2.3 Consistent Navigation | AA | Navigation consistent across pages | Menu order changes per page |
| 3.2.4 Consistent Identification | AA | Same function = same label | "Search" vs "Find" for same action |
### 3.3 Input Assistance
| Criterion | Level | Requirement | Common Violation |
|-----------|-------|-------------|------------------|
| 3.3.1 Error Identification | A | Errors described in text | Red border only, no message |
| 3.3.2 Labels or Instructions | A | Labels for required input | Placeholder as only label |
| 3.3.3 Error Suggestion | AA | Suggest corrections | "Invalid input" without guidance |
| 3.3.4 Error Prevention | AA | Reversible submissions for legal/financial | No confirmation for payment |
| 3.3.7 Redundant Entry | A | Don't ask for same info twice | Re-entering address in checkout |
| 3.3.8 Accessible Authentication | AA | No cognitive function test for login | CAPTCHA without audio alternative |
## Robust
### 4.1 Compatible
| Criterion | Level | Requirement | Common Violation |
|-----------|-------|-------------|------------------|
| 4.1.2 Name, Role, Value | A | Custom controls have accessible name and role | Custom dropdown without ARIA |
| 4.1.3 Status Messages | AA | Status updates announced without focus change | Toast without `aria-live` |
@@ -0,0 +1,684 @@
#!/usr/bin/env python3
"""WCAG 2.2 Accessibility Scanner for Frontend Codebases.
Scans HTML, JSX, TSX, Vue, Svelte, and CSS files for accessibility
violations across 10 categories: images, forms, headings, landmarks,
keyboard, ARIA, color/contrast, links, tables, and media.
Usage:
python a11y_scanner.py /path/to/project
python a11y_scanner.py /path/to/project --json
python a11y_scanner.py /path/to/project --severity critical,serious
python a11y_scanner.py /path/to/project --format json
"""
import argparse
import json
import os
import re
import sys
from dataclasses import dataclass, asdict
from typing import List, Optional
@dataclass
class Finding:
"""A single accessibility finding."""
rule_id: str
category: str
severity: str
message: str
file: str
line: int
snippet: str
wcag_criterion: str
fix: str
# ---------------------------------------------------------------------------
# Rule definitions: each returns a list of Finding from a single file
# ---------------------------------------------------------------------------
VALID_ARIA_ATTRS = {
"aria-activedescendant", "aria-atomic", "aria-autocomplete", "aria-busy",
"aria-checked", "aria-colcount", "aria-colindex", "aria-colspan",
"aria-controls", "aria-current", "aria-describedby", "aria-details",
"aria-disabled", "aria-dropeffect", "aria-errormessage", "aria-expanded",
"aria-flowto", "aria-grabbed", "aria-haspopup", "aria-hidden",
"aria-invalid", "aria-keyshortcuts", "aria-label", "aria-labelledby",
"aria-level", "aria-live", "aria-modal", "aria-multiline",
"aria-multiselectable", "aria-orientation", "aria-owns", "aria-placeholder",
"aria-posinset", "aria-pressed", "aria-readonly", "aria-relevant",
"aria-required", "aria-roledescription", "aria-rowcount", "aria-rowindex",
"aria-rowspan", "aria-selected", "aria-setsize", "aria-sort",
"aria-valuemax", "aria-valuemin", "aria-valuenow", "aria-valuetext",
"aria-braillelabel", "aria-brailleroledescription", "aria-description",
}
BAD_LINK_TEXT = re.compile(
r">\s*(click here|here|read more|more|link|this)\s*<", re.IGNORECASE
)
TAG_RE = re.compile(r"<(\w[\w-]*)\b([^>]*)(/?)>", re.DOTALL)
ATTR_RE = re.compile(r"""([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+))""")
ATTR_BOOL_RE = re.compile(r"\b([\w:.-]+)(?=\s|/?>|$)")
INLINE_COLOR_RE = re.compile(
r'style\s*=\s*["\'][^"\']*\bcolor\s*:', re.IGNORECASE
)
ARIA_ATTR_RE = re.compile(r"\baria-[\w-]+")
def _attrs(attr_str: str) -> dict:
"""Parse HTML/JSX attribute string into a dict."""
result = {}
for m in ATTR_RE.finditer(attr_str):
result[m.group(1)] = m.group(2) or m.group(3) or m.group(4) or ""
# boolean attrs
cleaned = ATTR_RE.sub("", attr_str)
for m in ATTR_BOOL_RE.finditer(cleaned):
name = m.group(1)
if name not in result and not name.startswith("/"):
result[name] = True
return result
def _snippet(line_text: str) -> str:
"""Trim a line for display as a code snippet."""
s = line_text.rstrip("\n\r")
return s[:120] + "..." if len(s) > 120 else s
def _find(rule_id, cat, sev, msg, fp, ln, snip, wcag, fix):
return Finding(rule_id, cat, sev, msg, fp, ln, snip, wcag, fix)
# ---------- Images ----------------------------------------------------------
def check_img_missing_alt(tag, attrs, fp, ln, snip):
if tag == "img" and "alt" not in attrs:
return _find("img-alt-missing", "images", "critical",
"<img> missing alt attribute",
fp, ln, snip, "1.1.1 Non-text Content",
"Add alt=\"description\" or alt=\"\" for decorative images.")
def check_img_empty_alt_informative(tag, attrs, fp, ln, snip):
if tag == "img" and attrs.get("alt") == "" and attrs.get("src", ""):
src = attrs.get("src", "")
if not any(kw in src.lower() for kw in ("spacer", "border", "decorat", "bg")):
return _find("img-alt-empty-informative", "images", "serious",
"<img> has empty alt but may be informative",
fp, ln, snip, "1.1.1 Non-text Content",
"If image conveys information, add descriptive alt text.")
def check_img_decorative_has_alt(tag, attrs, fp, ln, snip):
if tag == "img" and attrs.get("role") == "presentation" and attrs.get("alt", "") != "":
return _find("img-decorative-alt", "images", "moderate",
"Decorative image (role=presentation) should have alt=\"\"",
fp, ln, snip, "1.1.1 Non-text Content",
"Set alt=\"\" on decorative images with role=presentation.")
# ---------- Forms -----------------------------------------------------------
def check_input_missing_label(tag, attrs, fp, ln, snip):
input_types = {"text", "email", "password", "search", "tel", "url", "number", "date"}
if tag == "input" and attrs.get("type", "text") in input_types:
if "aria-label" not in attrs and "aria-labelledby" not in attrs and "id" not in attrs:
return _find("form-input-no-label", "forms", "critical",
"<input> has no id, aria-label, or aria-labelledby",
fp, ln, snip, "1.3.1 Info and Relationships",
"Add id + <label for>, or aria-label attribute.")
def check_input_no_aria_label(tag, attrs, fp, ln, snip):
if tag in ("select", "textarea"):
if "aria-label" not in attrs and "aria-labelledby" not in attrs and "id" not in attrs:
return _find("form-select-no-label", "forms", "critical",
f"<{tag}> has no accessible name",
fp, ln, snip, "4.1.2 Name, Role, Value",
f"Add aria-label or id + <label for> to <{tag}>.")
def check_orphan_label(lines, fp):
"""Labels whose 'for' points to a non-existent id."""
findings = []
ids = set()
label_fors = []
for ln, line in enumerate(lines, 1):
for m in re.finditer(r'\bid\s*=\s*["\']([^"\']+)["\']', line):
ids.add(m.group(1))
for m in re.finditer(r'<label[^>]*\bfor\s*=\s*["\']([^"\']+)["\']', line):
label_fors.append((ln, m.group(1), line))
for ln, for_val, line in label_fors:
if for_val not in ids:
findings.append(_find("form-orphan-label", "forms", "serious",
f"<label for=\"{for_val}\"> references non-existent id",
fp, ln, _snippet(line), "1.3.1 Info and Relationships",
f"Ensure an element with id=\"{for_val}\" exists."))
return findings
def check_fieldset_legend(lines, fp):
"""Radio/checkbox groups without fieldset."""
findings = []
radio_lines = []
has_fieldset = any("fieldset" in l.lower() for l in lines)
for ln, line in enumerate(lines, 1):
if re.search(r'type\s*=\s*["\'](?:radio|checkbox)["\']', line, re.I):
radio_lines.append((ln, line))
if radio_lines and not has_fieldset:
ln, line = radio_lines[0]
findings.append(_find("form-missing-fieldset", "forms", "serious",
"Radio/checkbox group without <fieldset>/<legend>",
fp, ln, _snippet(line), "1.3.1 Info and Relationships",
"Wrap related radio/checkbox inputs in <fieldset> with <legend>."))
return findings
# ---------- Headings --------------------------------------------------------
def check_headings(lines, fp):
findings = []
heading_levels = []
for ln, line in enumerate(lines, 1):
for m in re.finditer(r"<[hH]([1-6])\b", line):
heading_levels.append((int(m.group(1)), ln, line))
if not heading_levels:
return findings
# Missing h1
levels_seen = {h[0] for h in heading_levels}
if 1 not in levels_seen and any(l <= 3 for l in levels_seen):
findings.append(_find("heading-missing-h1", "headings", "serious",
"Page has headings but no <h1>",
fp, heading_levels[0][1], _snippet(heading_levels[0][2]),
"1.3.1 Info and Relationships",
"Add a single <h1> as the main page heading."))
# Multiple h1s
h1_lines = [(ln, line) for lvl, ln, line in heading_levels if lvl == 1]
if len(h1_lines) > 1:
findings.append(_find("heading-multiple-h1", "headings", "moderate",
f"Page has {len(h1_lines)} <h1> elements",
fp, h1_lines[1][0], _snippet(h1_lines[1][1]),
"1.3.1 Info and Relationships",
"Use a single <h1> per page. Demote others to <h2>+."))
# Skipped levels
prev_level = 0
for lvl, ln, line in heading_levels:
if prev_level > 0 and lvl > prev_level + 1:
findings.append(_find("heading-skipped", "headings", "moderate",
f"Heading level skips from h{prev_level} to h{lvl}",
fp, ln, _snippet(line),
"1.3.1 Info and Relationships",
f"Use <h{prev_level + 1}> instead of <h{lvl}>."))
prev_level = lvl
return findings
# ---------- Landmarks -------------------------------------------------------
def check_landmarks(lines, fp):
findings = []
content = "\n".join(lines)
# Missing main landmark
if not re.search(r'<main\b|role\s*=\s*["\']main["\']', content, re.I):
findings.append(_find("landmark-no-main", "landmarks", "serious",
"Page missing <main> landmark",
fp, 1, "", "1.3.1 Info and Relationships",
"Add a <main> element to wrap primary content."))
# Missing nav
if not re.search(r'<nav\b|role\s*=\s*["\']navigation["\']', content, re.I):
findings.append(_find("landmark-no-nav", "landmarks", "moderate",
"Page missing <nav> landmark",
fp, 1, "", "1.3.1 Info and Relationships",
"Add <nav> for primary navigation blocks."))
# Missing skip link
if not re.search(r'skip.{0,10}(nav|main|content)', content, re.I):
findings.append(_find("landmark-no-skip-link", "landmarks", "serious",
"Page missing skip navigation link",
fp, 1, "", "2.4.1 Bypass Blocks",
"Add <a href=\"#main\">Skip to main content</a> as first focusable element."))
return findings
# ---------- Keyboard --------------------------------------------------------
def check_tabindex_positive(tag, attrs, fp, ln, snip):
ti = attrs.get("tabindex", "")
if isinstance(ti, str) and ti.lstrip("-").isdigit() and int(ti) > 0:
return _find("keyboard-tabindex-positive", "keyboard", "serious",
f"tabindex={ti} creates unexpected tab order",
fp, ln, snip, "2.4.3 Focus Order",
"Use tabindex=\"0\" or tabindex=\"-1\" instead of positive values.")
def check_click_no_keyboard(tag, attrs, fp, ln, snip):
has_click = "onClick" in attrs or "onclick" in attrs or "@click" in attrs or "on:click" in attrs
has_key = any(k for k in attrs if "keydown" in k.lower() or "keyup" in k.lower() or "keypress" in k.lower())
if tag in ("div", "span", "td", "li", "p", "section") and has_click and not has_key:
if attrs.get("role") not in ("button", "link", "tab", "menuitem"):
return _find("keyboard-click-no-key", "keyboard", "critical",
f"<{tag}> has click handler but no keyboard handler",
fp, ln, snip, "2.1.1 Keyboard",
f"Add onKeyDown handler or use <button> instead of <{tag}>.")
def check_autofocus_misuse(tag, attrs, fp, ln, snip):
if "autofocus" in attrs or "autoFocus" in attrs:
if tag not in ("input", "textarea", "select"):
return _find("keyboard-autofocus", "keyboard", "moderate",
f"autofocus on <{tag}> can disorient screen reader users",
fp, ln, snip, "3.2.1 On Focus",
"Avoid autofocus on non-input elements. Use focus management instead.")
# ---------- ARIA ------------------------------------------------------------
def check_invalid_aria(tag, attrs, fp, ln, snip):
findings = []
for key in attrs:
if key.startswith("aria-") and key.lower() not in VALID_ARIA_ATTRS:
findings.append(_find("aria-invalid-attr", "aria", "serious",
f"Invalid ARIA attribute: {key}",
fp, ln, snip, "4.1.2 Name, Role, Value",
f"Remove or replace \"{key}\" with a valid ARIA attribute."))
return findings
def check_aria_hidden_focusable(tag, attrs, fp, ln, snip):
if attrs.get("aria-hidden") in ("true", True):
focusable_tags = {"a", "button", "input", "select", "textarea"}
if tag in focusable_tags or (isinstance(attrs.get("tabindex", ""), str) and
attrs.get("tabindex", "-1") != "-1"):
return _find("aria-hidden-focusable", "aria", "critical",
f"aria-hidden=\"true\" on focusable <{tag}>",
fp, ln, snip, "4.1.2 Name, Role, Value",
"Remove aria-hidden or make element non-focusable (tabindex=\"-1\").")
def check_aria_live_missing(lines, fp):
"""Alert/status roles or live regions without aria-live."""
findings = []
for ln, line in enumerate(lines, 1):
if re.search(r'role\s*=\s*["\'](?:alert|status)["\']', line, re.I):
if "aria-live" not in line:
findings.append(_find("aria-live-missing", "aria", "serious",
"role=alert/status without explicit aria-live",
fp, ln, _snippet(line),
"4.1.3 Status Messages",
"Add aria-live=\"assertive\" (alert) or aria-live=\"polite\" (status)."))
return findings
# ---------- Color/Contrast --------------------------------------------------
def check_inline_color(tag, attrs, fp, ln, snip):
style = attrs.get("style", "")
if isinstance(style, str) and re.search(r"\bcolor\s*:", style, re.I):
if not re.search(r"background", style, re.I):
return _find("color-inline-no-bg", "color", "moderate",
"Inline color set without background — contrast may be insufficient",
fp, ln, snip, "1.4.3 Contrast (Minimum)",
"Ensure foreground and background colors meet 4.5:1 contrast ratio.")
def check_text_over_image(lines, fp):
"""Detects patterns where text is positioned over background images without overlay."""
findings = []
for ln, line in enumerate(lines, 1):
if re.search(r"background-image\s*:", line, re.I):
if not re.search(r"(overlay|rgba|linear-gradient)", line, re.I):
findings.append(_find("color-text-over-image", "color", "serious",
"Background image without contrast overlay for text",
fp, ln, _snippet(line),
"1.4.3 Contrast (Minimum)",
"Add a semi-transparent overlay or ensure text contrast."))
return findings
# ---------- Links -----------------------------------------------------------
def check_empty_link(tag, attrs, fp, ln, snip):
if tag == "a" and not attrs.get("aria-label") and not attrs.get("aria-labelledby"):
return None # handled by line-level check below
def check_empty_links_line(lines, fp):
findings = []
for ln, line in enumerate(lines, 1):
# <a ...></a> or <a ...> </a>
if re.search(r"<a\b[^>]*>\s*</a>", line, re.I):
if "aria-label" not in line and "aria-labelledby" not in line:
findings.append(_find("link-empty", "links", "critical",
"Empty link — no text or accessible name",
fp, ln, _snippet(line), "2.4.4 Link Purpose",
"Add link text or aria-label."))
# Bad link text
if BAD_LINK_TEXT.search(line):
findings.append(_find("link-bad-text", "links", "serious",
"Link uses vague text like 'click here'",
fp, ln, _snippet(line), "2.4.4 Link Purpose",
"Use descriptive link text that makes sense out of context."))
return findings
def check_same_page_link(tag, attrs, fp, ln, snip):
href = attrs.get("href", "")
if tag == "a" and isinstance(href, str) and href == "#":
return _find("link-empty-fragment", "links", "moderate",
"Link with href=\"#\" — use a button or valid fragment",
fp, ln, snip, "2.4.4 Link Purpose",
"Use <button> for actions or href=\"#section-id\" for anchors.")
# ---------- Tables ----------------------------------------------------------
def check_table_headers(lines, fp):
findings = []
in_table = False
table_start = 0
has_th = False
has_caption = False
has_aria_label = False
for ln, line in enumerate(lines, 1):
if re.search(r"<table\b", line, re.I):
in_table = True
table_start = ln
has_th = False
has_caption = False
has_aria_label = "aria-label" in line
if in_table:
if "<th" in line.lower():
has_th = True
if "<caption" in line.lower():
has_caption = True
if re.search(r"</table>", line, re.I):
if not has_th:
findings.append(_find("table-no-headers", "tables", "serious",
"<table> has no <th> header cells",
fp, table_start, _snippet(lines[table_start - 1]),
"1.3.1 Info and Relationships",
"Add <th> elements to identify column/row headers."))
if not has_caption and not has_aria_label:
findings.append(_find("table-no-caption", "tables", "moderate",
"<table> missing <caption> or aria-label",
fp, table_start, _snippet(lines[table_start - 1]),
"1.3.1 Info and Relationships",
"Add <caption> or aria-label to describe the table."))
in_table = False
return findings
# ---------- Media -----------------------------------------------------------
def check_media_captions(tag, attrs, fp, ln, snip):
if tag == "video":
return None # handled at block level
def check_media_captions_block(lines, fp):
findings = []
in_video = False
video_start = 0
has_track = False
has_controls = False
has_autoplay = False
for ln, line in enumerate(lines, 1):
if re.search(r"<video\b", line, re.I):
in_video = True
video_start = ln
has_track = False
has_controls = "controls" in line.lower()
has_autoplay = "autoplay" in line.lower()
if in_video:
if re.search(r'<track\b[^>]*kind\s*=\s*["\']captions["\']', line, re.I):
has_track = True
if "controls" in line.lower():
has_controls = True
if re.search(r"</video>", line, re.I) or (re.search(r"<video\b", line, re.I) and "/>" in line):
if not has_track:
findings.append(_find("media-no-captions", "media", "critical",
"<video> missing captions track",
fp, video_start, _snippet(lines[video_start - 1]),
"1.2.2 Captions (Prerecorded)",
"Add <track kind=\"captions\" src=\"...\" srclang=\"en\">."))
if has_autoplay and not has_controls:
findings.append(_find("media-autoplay-no-controls", "media", "serious",
"<video> has autoplay without controls",
fp, video_start, _snippet(lines[video_start - 1]),
"1.4.2 Audio Control",
"Add the controls attribute so users can pause/stop."))
in_video = False
# Single-line video tags
for ln, line in enumerate(lines, 1):
if re.search(r"<audio\b", line, re.I):
if "autoplay" in line.lower() and "controls" not in line.lower():
findings.append(_find("media-audio-autoplay", "media", "serious",
"<audio> has autoplay without controls",
fp, ln, _snippet(line), "1.4.2 Audio Control",
"Add the controls attribute to <audio>."))
return findings
# ---------------------------------------------------------------------------
# Scanner engine
# ---------------------------------------------------------------------------
SUPPORTED_EXTENSIONS = {".html", ".htm", ".jsx", ".tsx", ".vue", ".svelte", ".css"}
TAG_LEVEL_CHECKS = [
check_img_missing_alt,
check_img_empty_alt_informative,
check_img_decorative_has_alt,
check_input_missing_label,
check_input_no_aria_label,
check_tabindex_positive,
check_click_no_keyboard,
check_autofocus_misuse,
check_aria_hidden_focusable,
check_inline_color,
check_same_page_link,
]
TAG_LEVEL_MULTI_CHECKS = [
check_invalid_aria,
]
def scan_file(filepath: str) -> List[Finding]:
"""Scan a single file and return all findings."""
findings: List[Finding] = []
try:
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
except (OSError, IOError):
return findings
# Tag-level checks
for ln, line in enumerate(lines, 1):
for m in TAG_RE.finditer(line):
tag = m.group(1).lower()
attr_str = m.group(2)
attrs = _attrs(attr_str)
snip = _snippet(line)
for check in TAG_LEVEL_CHECKS:
result = check(tag, attrs, filepath, ln, snip)
if result:
findings.append(result)
for check in TAG_LEVEL_MULTI_CHECKS:
results = check(tag, attrs, filepath, ln, snip)
if results:
findings.extend(results)
# File-level / multi-line checks
findings.extend(check_orphan_label(lines, filepath))
findings.extend(check_fieldset_legend(lines, filepath))
findings.extend(check_headings(lines, filepath))
findings.extend(check_landmarks(lines, filepath))
findings.extend(check_aria_live_missing(lines, filepath))
findings.extend(check_text_over_image(lines, filepath))
findings.extend(check_empty_links_line(lines, filepath))
findings.extend(check_table_headers(lines, filepath))
findings.extend(check_media_captions_block(lines, filepath))
return findings
def collect_files(path: str) -> List[str]:
"""Recursively collect scannable files under path."""
files = []
if os.path.isfile(path):
_, ext = os.path.splitext(path)
if ext.lower() in SUPPORTED_EXTENSIONS:
files.append(path)
return files
for root, dirs, filenames in os.walk(path):
# Skip common non-source directories
dirs[:] = [d for d in dirs if d not in (
"node_modules", ".git", "dist", "build", "__pycache__",
".next", ".nuxt", "vendor", "coverage"
)]
for fname in filenames:
_, ext = os.path.splitext(fname)
if ext.lower() in SUPPORTED_EXTENSIONS:
files.append(os.path.join(root, fname))
files.sort()
return files
# ---------------------------------------------------------------------------
# Output formatting
# ---------------------------------------------------------------------------
SEVERITY_ORDER = {"critical": 0, "serious": 1, "moderate": 2, "minor": 3}
def format_human(findings: List[Finding], files_scanned: int) -> str:
"""Format findings as human-readable text report."""
if not findings:
return (f"Scanned {files_scanned} file(s) -- no accessibility issues found.\n"
"All checks passed.")
lines = []
lines.append(f"WCAG 2.2 Accessibility Scan Results")
lines.append(f"{'=' * 50}")
lines.append(f"Files scanned: {files_scanned}")
lines.append(f"Issues found: {len(findings)}")
# Summary by severity
severity_counts = {}
for f in findings:
severity_counts[f.severity] = severity_counts.get(f.severity, 0) + 1
for sev in ("critical", "serious", "moderate", "minor"):
if sev in severity_counts:
lines.append(f" {sev.upper():10s}: {severity_counts[sev]}")
lines.append("")
# Summary by category
cat_counts = {}
for f in findings:
cat_counts[f.category] = cat_counts.get(f.category, 0) + 1
lines.append("By category:")
for cat in sorted(cat_counts, key=lambda c: -cat_counts[c]):
lines.append(f" {cat:20s}: {cat_counts[cat]}")
lines.append("")
# Detailed findings sorted by severity then file
sorted_findings = sorted(findings, key=lambda f: (SEVERITY_ORDER.get(f.severity, 9), f.file, f.line))
for i, f in enumerate(sorted_findings, 1):
lines.append(f"[{f.severity.upper()}] {f.rule_id}")
lines.append(f" File: {f.file}:{f.line}")
lines.append(f" WCAG: {f.wcag_criterion}")
lines.append(f" Issue: {f.message}")
if f.snippet:
lines.append(f" Code: {f.snippet}")
lines.append(f" Fix: {f.fix}")
lines.append("")
return "\n".join(lines)
def format_json(findings: List[Finding], files_scanned: int) -> str:
"""Format findings as JSON."""
severity_counts = {}
for f in findings:
severity_counts[f.severity] = severity_counts.get(f.severity, 0) + 1
report = {
"summary": {
"files_scanned": files_scanned,
"total_issues": len(findings),
"by_severity": severity_counts,
},
"findings": [asdict(f) for f in findings],
}
return json.dumps(report, indent=2)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="a11y_scanner",
description="Scan frontend codebases for WCAG 2.2 accessibility violations.",
epilog=(
"Supported file types: .html, .htm, .jsx, .tsx, .vue, .svelte, .css\n"
"Exit codes: 0 = pass, 1 = critical/serious found, 2 = moderate/minor only"
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"path",
help="File or directory to scan",
)
parser.add_argument(
"--json", dest="json_flag", action="store_true",
help="Output results as JSON (shorthand for --format json)",
)
parser.add_argument(
"--format", dest="output_format", choices=["text", "json"],
default="text",
help="Output format: text (default) or json",
)
parser.add_argument(
"--severity", dest="severity",
default=None,
help="Comma-separated severity filter (e.g. critical,serious)",
)
return parser
def main():
parser = build_parser()
args = parser.parse_args()
path = os.path.abspath(args.path)
if not os.path.exists(path):
print(f"Error: path does not exist: {path}", file=sys.stderr)
sys.exit(1)
use_json = args.json_flag or args.output_format == "json"
# Collect and scan files
files = collect_files(path)
if not files:
print(f"No scannable files found in: {path}", file=sys.stderr)
sys.exit(0)
all_findings: List[Finding] = []
for fpath in files:
all_findings.extend(scan_file(fpath))
# Filter by severity if requested
if args.severity:
allowed = {s.strip().lower() for s in args.severity.split(",")}
all_findings = [f for f in all_findings if f.severity in allowed]
# Output
if use_json:
print(format_json(all_findings, len(files)))
else:
print(format_human(all_findings, len(files)))
# Exit code
severities = {f.severity for f in all_findings}
if severities & {"critical", "serious"}:
sys.exit(1)
elif severities & {"moderate", "minor"}:
sys.exit(2)
else:
sys.exit(0)
if __name__ == "__main__":
main()
@@ -0,0 +1,504 @@
#!/usr/bin/env python3
"""WCAG 2.2 Color Contrast Checker.
Checks foreground/background color pairs against WCAG 2.2 contrast ratio
thresholds for normal text, large text, and UI components. Supports hex,
rgb(), and named CSS colors.
Usage:
python contrast_checker.py "#ffffff" "#000000"
python contrast_checker.py --suggest "#336699"
python contrast_checker.py --batch styles.css
python contrast_checker.py --demo
"""
import argparse
import json
import re
import sys
# ---------------------------------------------------------------------------
# Named CSS colors (25 common ones)
# ---------------------------------------------------------------------------
NAMED_COLORS = {
"black": (0, 0, 0),
"white": (255, 255, 255),
"red": (255, 0, 0),
"green": (0, 128, 0),
"blue": (0, 0, 255),
"yellow": (255, 255, 0),
"cyan": (0, 255, 255),
"magenta": (255, 0, 255),
"gray": (128, 128, 128),
"grey": (128, 128, 128),
"orange": (255, 165, 0),
"purple": (128, 0, 128),
"pink": (255, 192, 203),
"brown": (165, 42, 42),
"navy": (0, 0, 128),
"teal": (0, 128, 128),
"olive": (128, 128, 0),
"maroon": (128, 0, 0),
"lime": (0, 255, 0),
"aqua": (0, 255, 255),
"silver": (192, 192, 192),
"gold": (255, 215, 0),
"coral": (255, 127, 80),
"salmon": (250, 128, 114),
"tomato": (255, 99, 71),
}
# WCAG thresholds: (label, required_ratio)
WCAG_THRESHOLDS = [
("AA Normal Text", 4.5),
("AA Large Text", 3.0),
("AA UI Components", 3.0),
("AAA Normal Text", 7.0),
("AAA Large Text", 4.5),
]
# ---------------------------------------------------------------------------
# Color parsing
# ---------------------------------------------------------------------------
def parse_color(color_str: str) -> tuple:
"""Parse a color string into an (R, G, B) tuple.
Accepts:
- #RRGGBB or #RGB hex
- rgb(r, g, b) with values 0-255
- Named CSS colors
"""
s = color_str.strip().lower()
# Named color
if s in NAMED_COLORS:
return NAMED_COLORS[s]
# Hex: #RGB or #RRGGBB
hex_match = re.match(r"^#([0-9a-f]{3}|[0-9a-f]{6})$", s)
if hex_match:
h = hex_match.group(1)
if len(h) == 3:
r, g, b = int(h[0] * 2, 16), int(h[1] * 2, 16), int(h[2] * 2, 16)
else:
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
return (r, g, b)
# rgb(r, g, b)
rgb_match = re.match(r"^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$", s)
if rgb_match:
r, g, b = int(rgb_match.group(1)), int(rgb_match.group(2)), int(rgb_match.group(3))
if not all(0 <= c <= 255 for c in (r, g, b)):
raise ValueError(f"RGB values must be 0-255, got rgb({r},{g},{b})")
return (r, g, b)
raise ValueError(
f"Invalid color format: '{color_str}'. "
"Use #RRGGBB, #RGB, rgb(r,g,b), or a named color."
)
def color_to_hex(rgb: tuple) -> str:
"""Convert an (R, G, B) tuple to #RRGGBB."""
return f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}"
# ---------------------------------------------------------------------------
# WCAG luminance and contrast
# ---------------------------------------------------------------------------
def relative_luminance(rgb: tuple) -> float:
"""Calculate relative luminance per WCAG 2.2 (sRGB).
https://www.w3.org/TR/WCAG22/#dfn-relative-luminance
"""
channels = []
for c in rgb:
s = c / 255.0
channels.append(s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4)
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]
def contrast_ratio(rgb1: tuple, rgb2: tuple) -> float:
"""Return the WCAG contrast ratio between two colors (>= 1.0)."""
l1 = relative_luminance(rgb1)
l2 = relative_luminance(rgb2)
lighter = max(l1, l2)
darker = min(l1, l2)
return (lighter + 0.05) / (darker + 0.05)
def evaluate_contrast(ratio: float) -> list:
"""Return pass/fail results for each WCAG threshold."""
results = []
for label, threshold in WCAG_THRESHOLDS:
results.append({
"level": label,
"required": threshold,
"ratio": round(ratio, 2),
"pass": ratio >= threshold,
})
return results
# ---------------------------------------------------------------------------
# Suggest accessible backgrounds
# ---------------------------------------------------------------------------
def suggest_backgrounds(fg_rgb: tuple, target_ratio: float = 4.5, count: int = 8) -> list:
"""Given a foreground color, suggest background colors passing AA normal text.
Strategy: walk luminance in both directions (lighter / darker) from the
foreground and collect the first colors that meet the target ratio.
"""
suggestions = []
# Try a spread of grays and tinted variants
candidates = []
for v in range(0, 256, 1):
candidates.append((v, v, v)) # grays
# Also try tinted versions toward the complement
fr, fg, fb = fg_rgb
for v in range(0, 256, 2):
candidates.append((v, min(255, v + 20), min(255, v + 40)))
candidates.append((min(255, v + 40), v, min(255, v + 20)))
candidates.append((min(255, v + 20), min(255, v + 40), v))
seen = set()
scored = []
for c in candidates:
cr = contrast_ratio(fg_rgb, c)
if cr >= target_ratio and c not in seen:
seen.add(c)
scored.append((cr, c))
# Sort by ratio closest to target (prefer minimal-change backgrounds)
scored.sort(key=lambda x: x[0])
for cr, c in scored[:count]:
suggestions.append({"hex": color_to_hex(c), "rgb": list(c), "ratio": round(cr, 2)})
return suggestions
# ---------------------------------------------------------------------------
# Batch CSS parsing
# ---------------------------------------------------------------------------
_COLOR_RE = re.compile(
r"(#[0-9a-fA-F]{3,6}|rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\))"
)
def extract_css_pairs(css_text: str) -> list:
"""Extract color / background-color pairs from CSS declarations.
Returns a list of dicts with selector, foreground, and background strings.
"""
pairs = []
# Split into rule blocks
block_re = re.compile(r"([^{}]+)\{([^}]+)\}", re.DOTALL)
for m in block_re.finditer(css_text):
selector = m.group(1).strip()
body = m.group(2)
fg = bg = None
# Match color: ... (but not background-color)
fg_match = re.search(
r"(?<![-])color\s*:\s*([^;]+);", body, re.IGNORECASE
)
bg_match = re.search(
r"background(?:-color)?\s*:\s*([^;]+);", body, re.IGNORECASE
)
if fg_match:
val = fg_match.group(1).strip()
c = _COLOR_RE.search(val)
if c:
fg = c.group(1)
elif val.lower() in NAMED_COLORS:
fg = val.lower()
if bg_match:
val = bg_match.group(1).strip()
c = _COLOR_RE.search(val)
if c:
bg = c.group(1)
elif val.lower() in NAMED_COLORS:
bg = val.lower()
if fg and bg:
pairs.append({"selector": selector, "foreground": fg, "background": bg})
return pairs
# ---------------------------------------------------------------------------
# Output formatting
# ---------------------------------------------------------------------------
def format_result_human(fg_str: str, bg_str: str, ratio: float, results: list) -> str:
"""Format a contrast check result for the terminal."""
lines = [
f"Foreground : {fg_str}",
f"Background : {bg_str}",
f"Contrast : {ratio:.2f}:1",
"",
]
for r in results:
status = "PASS" if r["pass"] else "FAIL"
lines.append(f" [{status}] {r['level']:20s} (requires {r['required']}:1)")
return "\n".join(lines)
def format_suggestions_human(fg_str: str, suggestions: list) -> str:
"""Format suggested backgrounds for the terminal."""
lines = [f"Foreground: {fg_str}", "Suggested accessible backgrounds (AA Normal Text):"]
if not suggestions:
lines.append(" No suggestions found.")
for s in suggestions:
lines.append(f" {s['hex']} ratio={s['ratio']}:1")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Demo
# ---------------------------------------------------------------------------
DEMO_PAIRS = [
("#ffffff", "#000000"),
("#336699", "#ffffff"),
("#ff6600", "#ffffff"),
("navy", "white"),
("rgb(100,100,100)", "#eeeeee"),
]
def run_demo(as_json: bool) -> None:
"""Run demo checks and print results."""
all_results = []
for fg_str, bg_str in DEMO_PAIRS:
fg_rgb = parse_color(fg_str)
bg_rgb = parse_color(bg_str)
ratio = contrast_ratio(fg_rgb, bg_rgb)
results = evaluate_contrast(ratio)
entry = {
"foreground": fg_str,
"background": bg_str,
"foreground_hex": color_to_hex(fg_rgb),
"background_hex": color_to_hex(bg_rgb),
"ratio": round(ratio, 2),
"results": results,
}
all_results.append(entry)
if as_json:
print(json.dumps({"demo": True, "checks": all_results}, indent=2))
else:
print("=" * 60)
print("WCAG 2.2 Contrast Checker - Demo")
print("=" * 60)
for entry in all_results:
print()
print(
format_result_human(
entry["foreground"], entry["background"],
entry["ratio"], entry["results"],
)
)
print()
print("-" * 60)
print("Suggestion demo for foreground #336699:")
suggestions = suggest_backgrounds(parse_color("#336699"))
print(format_suggestions_human("#336699", suggestions))
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="WCAG 2.2 Color Contrast Checker. "
"Checks foreground/background pairs against AA and AAA thresholds.",
epilog="Examples:\n"
" %(prog)s '#ffffff' '#000000'\n"
" %(prog)s --suggest '#336699'\n"
" %(prog)s --batch styles.css\n"
" %(prog)s --demo\n",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"foreground",
nargs="?",
help="Foreground (text) color: #RRGGBB, #RGB, rgb(r,g,b), or named color",
)
parser.add_argument(
"background",
nargs="?",
help="Background color: #RRGGBB, #RGB, rgb(r,g,b), or named color",
)
parser.add_argument(
"--suggest",
metavar="COLOR",
help="Suggest accessible background colors for the given foreground color",
)
parser.add_argument(
"--batch",
metavar="CSS_FILE",
help="Extract color pairs from a CSS file and check each",
)
parser.add_argument(
"--json",
action="store_true",
dest="json_output",
help="Output results as JSON",
)
parser.add_argument(
"--demo",
action="store_true",
help="Show example output with sample color pairs",
)
parser.add_argument(
"--sample",
action="store_true",
help="Run with embedded sample color pairs (alias for --demo)",
)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
# --demo / --sample mode
if args.demo or args.sample:
run_demo(args.json_output)
return 0
# --suggest mode
if args.suggest:
try:
fg_rgb = parse_color(args.suggest)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
suggestions = suggest_backgrounds(fg_rgb)
if args.json_output:
print(json.dumps({
"foreground": args.suggest,
"foreground_hex": color_to_hex(fg_rgb),
"suggestions": suggestions,
}, indent=2))
else:
print(format_suggestions_human(args.suggest, suggestions))
return 0
# --batch mode
if args.batch:
try:
with open(args.batch, "r", encoding="utf-8") as fh:
css_text = fh.read()
except FileNotFoundError:
print(f"Error: file not found: {args.batch}", file=sys.stderr)
return 1
except OSError as exc:
print(f"Error reading file: {exc}", file=sys.stderr)
return 1
pairs = extract_css_pairs(css_text)
if not pairs:
msg = "No color/background-color pairs found in the CSS file."
if args.json_output:
print(json.dumps({"batch": args.batch, "pairs": [], "message": msg}, indent=2))
else:
print(msg)
return 0
all_results = []
has_failure = False
for pair in pairs:
try:
fg_rgb = parse_color(pair["foreground"])
bg_rgb = parse_color(pair["background"])
except ValueError as exc:
entry = {
"selector": pair["selector"],
"foreground": pair["foreground"],
"background": pair["background"],
"error": str(exc),
}
all_results.append(entry)
continue
ratio = contrast_ratio(fg_rgb, bg_rgb)
results = evaluate_contrast(ratio)
if not results[0]["pass"]: # AA Normal Text
has_failure = True
entry = {
"selector": pair["selector"],
"foreground": pair["foreground"],
"background": pair["background"],
"foreground_hex": color_to_hex(fg_rgb),
"background_hex": color_to_hex(bg_rgb),
"ratio": round(ratio, 2),
"results": results,
}
all_results.append(entry)
if args.json_output:
print(json.dumps({"batch": args.batch, "pairs": all_results}, indent=2))
else:
print(f"Batch check: {args.batch}")
print("=" * 60)
for entry in all_results:
print(f"\nSelector: {entry['selector']}")
if "error" in entry:
print(f" Error: {entry['error']}")
else:
print(
format_result_human(
entry["foreground"], entry["background"],
entry["ratio"], entry["results"],
)
)
print()
summary_pass = sum(1 for e in all_results if "ratio" in e and e["results"][0]["pass"])
summary_total = sum(1 for e in all_results if "ratio" in e)
print(f"Summary: {summary_pass}/{summary_total} pairs pass AA Normal Text")
return 1 if has_failure else 0
# Default: check a single pair
if not args.foreground or not args.background:
parser.error(
"Provide foreground and background colors, or use --suggest, --batch, or --demo."
)
try:
fg_rgb = parse_color(args.foreground)
except ValueError as exc:
print(f"Error (foreground): {exc}", file=sys.stderr)
return 1
try:
bg_rgb = parse_color(args.background)
except ValueError as exc:
print(f"Error (background): {exc}", file=sys.stderr)
return 1
ratio = contrast_ratio(fg_rgb, bg_rgb)
results = evaluate_contrast(ratio)
if args.json_output:
print(json.dumps({
"foreground": args.foreground,
"background": args.background,
"foreground_hex": color_to_hex(fg_rgb),
"background_hex": color_to_hex(bg_rgb),
"ratio": round(ratio, 2),
"results": results,
}, indent=2))
else:
print(format_result_human(args.foreground, args.background, ratio, results))
return 0 if results[0]["pass"] else 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,15 @@
{
"name": "google-workspace-cli",
"version": "2.9.0",
"description": "Google Workspace administration via the gws CLI (github.com/googleworkspace/cli, install: npm i -g @googleworkspace/cli). Authenticate and automate Gmail, Drive, Sheets, Calendar, Docs, Chat, and Tasks. 5 Python tools, 3 reference guides, a local catalog of 43 recipe command templates, and 10 persona bundles. Verify generated commands against gws --help.",
"author": {
"name": "Alireza Rezvani",
"url": "https://alirezarezvani.com"
},
"repository": "https://github.com/alirezarezvani/claude-skills",
"license": "MIT",
"skills": [
"./skills"
],
"homepage": "https://github.com/alirezarezvani/claude-skills/tree/main/engineering-team/google-workspace-cli"
}
@@ -0,0 +1,14 @@
# Python
__pycache__/
*.pyc
*.pyo
# Auth tokens
*.token
*.json.bak
.env
.env.*
# OS
.DS_Store
Thumbs.db
@@ -0,0 +1,376 @@
---
name: "google-workspace-cli"
description: "Google Workspace administration via the gws CLI (github.com/googleworkspace/cli). Install, authenticate, and automate Gmail, Drive, Sheets, Calendar, Docs, Chat, and Tasks. Run security audits and use local recipe templates and persona bundles. Use for Google Workspace admin, gws CLI setup, Gmail automation, Drive management, or Calendar scheduling."
---
# Google Workspace CLI
Expert guidance and automation for Google Workspace administration using the open-source `gws` CLI ([github.com/googleworkspace/cli](https://github.com/googleworkspace/cli), Apache-2.0). The CLI builds its command surface dynamically from Google's Discovery Service, so it covers every supported Workspace API plus `+`-prefixed helper commands. This skill adds local Python tools (doctor, auth guide, recipe catalog, security audit, output analyzer).
> **Verify before scripting:** `gws` generates commands at runtime from Google's API discovery documents, and the CLI is pre-v1.0. Always confirm a command's exact surface with `gws --help`, `gws <service> --help`, or `gws schema <service>.<resource>.<method>` before putting it in automation. Commands in this skill marked *(verify)* are illustrative of the `gws <service> <resource> <method>` pattern and must be checked against your installed version.
---
## Quick Start
### Check Installation
```bash
# Verify gws is installed and authenticated
python3 scripts/gws_doctor.py
```
### Send an Email
```bash
gws gmail +send --to "team@company.com" \
--subject "Weekly Update" --body "Here's this week's summary..."
```
### List Drive Files
```bash
gws drive files list --params '{"pageSize": 20}' | python3 scripts/output_analyzer.py --select "name,mimeType,modifiedTime" --format table
```
---
## Installation
### npm (recommended; requires Node.js 18+)
```bash
npm install -g @googleworkspace/cli
gws --version
```
### Homebrew (macOS/Linux)
```bash
brew install googleworkspace-cli
```
### Cargo (from source)
```bash
cargo install --git https://github.com/googleworkspace/cli --locked
gws --version
```
### Pre-built Binaries
Download from [github.com/googleworkspace/cli/releases](https://github.com/googleworkspace/cli/releases) for macOS, Linux, or Windows. Nix users: `nix run github:googleworkspace/cli`.
### Verify Installation
```bash
python3 scripts/gws_doctor.py
# Checks: PATH, version, auth status, service connectivity
```
---
## Authentication
### OAuth Setup (Interactive)
```bash
# Step 1: Create Google Cloud project and OAuth credentials
python3 scripts/auth_setup_guide.py --guide oauth
# Step 2: Run interactive auth setup (uses gcloud if available)
gws auth setup
# Step 3: Log in, requesting only the scopes you need
gws auth login -s drive,gmail,sheets
```
### Headless/CI
```bash
# Generate setup instructions
python3 scripts/auth_setup_guide.py --guide service-account
# Export credentials from an interactive machine, then point the CLI at them
gws auth export --unmasked > credentials.json
export GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE=/path/to/credentials.json
```
### Environment Variables
```bash
# Generate .env template
python3 scripts/auth_setup_guide.py --generate-env
```
| Variable | Purpose |
|----------|---------|
| `GOOGLE_WORKSPACE_CLI_CLIENT_ID` | OAuth client ID |
| `GOOGLE_WORKSPACE_CLI_CLIENT_SECRET` | OAuth client secret |
| `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` | Path to exported credentials JSON |
| `GOOGLE_WORKSPACE_CLI_TOKEN` | Pre-obtained OAuth token |
| `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` | Override default config location |
| `GOOGLE_WORKSPACE_CLI_LOG` | Enable debug logging |
### Validate Authentication
```bash
python3 scripts/auth_setup_guide.py --validate --json
# Tests each service endpoint
```
---
## Workflow 1: Gmail Automation
**Goal:** Automate email operations — send, search, label, and filter management.
### Send, Reply, Forward (helper commands)
```bash
# Send a new email
gws gmail +send --to "client@example.com" \
--subject "Proposal" --body "Please find attached..."
# Reply to a message (auto-threading); check exact flags with: gws gmail +reply --help
gws gmail +reply ...
# Forward a message; check exact flags with: gws gmail +forward --help
gws gmail +forward ...
# Unread inbox summary
gws gmail +triage
```
### Search and Inspect (discovery commands)
Discovery commands follow `gws <service> <resource> <method>` and take request
parameters as JSON via `--params` (query/path params) and `--json` (request body).
Inspect any method's exact schema first:
```bash
# What does messages.list accept? (verify)
gws schema gmail.users.messages.list
# Search emails (verify against the schema above)
gws gmail users messages list --params '{"userId": "me", "q": "from:client@example.com after:2025/01/01"}' \
| python3 scripts/output_analyzer.py --count
# List labels (verify)
gws gmail users labels list --params '{"userId": "me"}'
```
### Bulk Operations
Use `--dry-run` first, and `--page-all` to paginate (one JSON line per page):
```bash
# Preview, then archive read emails older than 30 days (verify method schema first)
gws gmail users messages list --params '{"userId": "me", "q": "is:read older_than:30d"}' --page-all \
| python3 scripts/output_analyzer.py --select "id" --format json
# Then feed ids to gmail users messages modify (see: gws schema gmail.users.messages.modify)
```
---
## Workflow 2: Drive & Sheets
**Goal:** Manage files, create spreadsheets, configure sharing, and export data.
### File Operations
```bash
# List files
gws drive files list --params '{"pageSize": 50}' \
| python3 scripts/output_analyzer.py --select "name,mimeType,size" --format table
# Upload a file (helper)
gws drive +upload ./report.pdf --name "Q1 Report"
# Create a Google Sheet
gws sheets spreadsheets create --json '{"properties": {"title": "Budget 2026"}}'
# Download/export — inspect the method first (verify)
gws schema drive.files.export
```
### Sharing (verify schemas first)
```bash
# Inspect the permissions API surface
gws schema drive.permissions.create
# Share with user (verify against schema)
gws drive permissions create --params '{"fileId": "<FILE_ID>"}' \
--json '{"type": "user", "role": "writer", "emailAddress": "colleague@company.com"}'
# List who has access (verify)
gws drive permissions list --params '{"fileId": "<FILE_ID>"}'
```
### Sheets Data
```bash
# Read values (helper); check exact flags with: gws sheets +read --help
gws sheets +read ...
# Append a row (helper); check exact flags with: gws sheets +append --help
gws sheets +append ...
# Or use discovery methods (verify):
gws schema sheets.spreadsheets.values.update
gws sheets spreadsheets values get --params '{"spreadsheetId": "<SHEET_ID>", "range": "Sheet1!A1:D10"}'
```
---
## Workflow 3: Calendar & Meetings
**Goal:** Schedule events, find available times, and generate standup reports.
### Event Management
```bash
# Create an event (helper); check exact flags with: gws calendar +insert --help
gws calendar +insert ...
# Upcoming events (helper, timezone-aware)
gws calendar +agenda
# Or via discovery (verify):
gws schema calendar.events.insert
gws calendar events list --params '{"calendarId": "primary", "maxResults": 10}'
```
### Find Available Time
```bash
# Free/busy via the Calendar API (verify schema first)
gws schema calendar.freebusy.query
gws calendar freebusy query --json '{"timeMin": "...", "timeMax": "...", "items": [{"id": "alice@co.com"}]}'
```
### Standup Report (workflow helpers)
```bash
# Today's meetings + tasks
gws workflow +standup-report \
| python3 scripts/output_analyzer.py --format table
# Next meeting prep; check exact flags with: gws workflow +meeting-prep --help
gws workflow +meeting-prep
```
---
## Workflow 4: Security Audit
**Goal:** Audit Google Workspace security configuration and generate remediation commands.
### Run Full Audit
```bash
# Full audit across all services
python3 scripts/workspace_audit.py --json
# Audit specific services
python3 scripts/workspace_audit.py --services gmail,drive,calendar
# Demo mode (no gws required)
python3 scripts/workspace_audit.py --demo
```
### Audit Checks
| Area | Check | Risk |
|------|-------|------|
| Drive | External sharing enabled | Data exfiltration |
| Gmail | Auto-forwarding rules | Data exfiltration |
| Gmail | DMARC/SPF/DKIM records | Email spoofing |
| Calendar | Default sharing visibility | Information leak |
| OAuth | Third-party app grants | Unauthorized access |
| Admin | Super admin count | Privilege escalation |
| Admin | 2-Step verification enforcement | Account takeover |
### Review and Remediate
```bash
# Review findings
python3 scripts/workspace_audit.py --json | python3 scripts/output_analyzer.py \
--filter "status=FAIL" --select "area,check,remediation"
# Execute remediation (example: check current Drive settings first; verify)
gws drive about get --params '{"fields": "*"}'
# Follow remediation commands from audit output (verify each against gws --help)
```
---
## Python Tools
| Script | Purpose | Usage |
|--------|---------|-------|
| `gws_doctor.py` | Pre-flight diagnostics | `python3 scripts/gws_doctor.py [--json] [--services gmail,drive]` |
| `auth_setup_guide.py` | Guided auth setup | `python3 scripts/auth_setup_guide.py --guide oauth` |
| `gws_recipe_runner.py` | Recipe catalog & runner | `python3 scripts/gws_recipe_runner.py --list [--persona pm]` |
| `workspace_audit.py` | Security/config audit | `python3 scripts/workspace_audit.py [--json] [--demo]` |
| `output_analyzer.py` | JSON/NDJSON analysis | `gws ... --json \| python3 scripts/output_analyzer.py --count` |
All scripts are stdlib-only, support `--json` output, and include demo mode with embedded sample data.
---
## Best Practices
### Security
1. Use OAuth with minimal scopes — request only what each workflow needs
2. Store tokens in the system keyring, never in plain text files
3. Rotate service account keys every 90 days
4. Audit third-party OAuth app grants quarterly
5. Use `--dry-run` before bulk destructive operations
### Automation
1. All `gws` output is structured JSON — pipe it through `output_analyzer.py` for filtering and aggregation
2. Use `gws workflow +*` helpers for multi-step operations instead of chaining raw commands
3. Use the local recipe catalog (`gws_recipe_runner.py`) as command templates, then verify each against `gws --help`
4. `--page-all` emits one JSON line per page (NDJSON) for streaming large result sets
5. Use `--dry-run` to preview any request before executing it
### Performance
1. Request only needed fields via the API's `fields` parameter in `--params` (reduces payload size)
2. Use `pageSize` in `--params` to cap results when browsing
3. Use `--page-all` only when you need complete datasets; tune with `--page-limit` / `--page-delay`
4. Prefer `+` helpers (single optimized calls) over hand-chained API calls
5. Cache frequently accessed data (e.g., label IDs, folder IDs) in variables
---
## Limitations
| Constraint | Impact |
|------------|--------|
| OAuth tokens expire after 1 hour | Re-auth needed for long-running scripts |
| API rate limits (per-user, per-service) | Bulk operations may hit 429 errors |
| Scope requirements vary by service | Must request correct scopes during auth |
| Pre-v1.0 CLI status | Breaking changes possible between releases |
| Google Cloud project required | Free, but requires setup in Cloud Console |
| Admin API needs admin privileges | Some audit checks require Workspace Admin role |
### Required Scopes by Service
```bash
# List scopes for specific services
python3 scripts/auth_setup_guide.py --scopes gmail,drive,calendar,sheets
```
| Service | Key Scopes |
|---------|-----------|
| Gmail | `gmail.modify`, `gmail.send`, `gmail.labels` |
| Drive | `drive.file`, `drive.metadata.readonly` |
| Sheets | `spreadsheets` |
| Calendar | `calendar`, `calendar.events` |
| Admin | `admin.directory.user.readonly`, `admin.directory.group` |
| Tasks | `tasks` |
@@ -0,0 +1,225 @@
# Google Workspace CLI Persona Profiles
10 role-based bundles that scope recipes and commands to your daily workflow.
> **These are command templates, not verified invocations.** The `gws` CLI ([github.com/googleworkspace/cli](https://github.com/googleworkspace/cli)) generates its command surface dynamically from Google's Discovery Service and is pre-v1.0. Before running any command below, verify its exact syntax with `gws --help`, `gws <service> --help`, or `gws schema <service>.<resource>.<method>`. Verified upstream patterns: discovery commands are `gws <service> <resource> <method> --params '{...}' --json '{...}'`; helpers are `+`-prefixed (`gws gmail +send`, `gws calendar +agenda`, `gws workflow +standup-report`).
---
## 1. Executive Assistant
**Description:** Managing schedules, emails, and communications for executives.
**Top Commands:**
- `python3 scripts/gws_recipe_runner.py --describe morning-briefing` — Start the day with schedule + inbox overview
- `gws calendar freebusy query ...` (verify: `gws schema calendar.freebusy.query`) — Find available slots for meetings
- `gws workflow +meeting-prep` — Prepare for the next meeting
- `gws gmail users.messages send me` — Send emails on behalf
- `python3 scripts/gws_recipe_runner.py --describe eod-wrap` — End of day summary
**Recommended Recipes:** morning-briefing, today-schedule, find-time, send-email, reply-to-thread, meeting-prep, eod-wrap, quick-event, inbox-zero, standup-report
**Daily Workflow:**
1. Run `morning-briefing` at 8:00 AM
2. Process inbox with `inbox-zero`
3. Schedule meetings with `find-time` + `create-event`
4. Prep for meetings with `meeting-prep`
5. Close day with `eod-wrap`
---
## 2. Project Manager
**Description:** Tracking tasks, meetings, and project deliverables.
**Top Commands:**
- `gws workflow +standup-report` — Generate standup updates
- `gws calendar freebusy query ...` (verify: `gws schema calendar.freebusy.query`) — Schedule sprint ceremonies
- `gws tasks tasks insert` — Create and assign tasks
- `gws sheets spreadsheets.values get` — Read project trackers
- `python3 scripts/gws_recipe_runner.py --describe project-status` — Aggregate project status
**Recommended Recipes:** standup-report, create-event, find-time, task-create, task-progress, project-status, weekly-summary, share-folder, sheet-read, morning-briefing
**Daily Workflow:**
1. Run `standup-report` before standup
2. Update project tracker via `sheet-write`
3. Create action items with `task-create`
4. Run `weekly-summary` on Fridays
5. Share updates via `chat-message`
---
## 3. HR
**Description:** Managing people, onboarding, and team communications.
**Top Commands:**
- `gws admin users list` — List all domain users
- `gws admin users get <email>` — Look up employee details
- `gws docs documents create` — Create onboarding docs
- `gws drive permissions create` — Share folders with new hires
- `gws people people.connections list` — Export contact directory
**Recommended Recipes:** list-users, user-info, send-email, create-event, create-doc, share-folder, chat-message, list-groups, export-contacts, today-schedule
**Daily Workflow:**
1. Check new hire onboarding queue
2. Create welcome docs with `create-doc`
3. Set up 1:1s with `create-event`
4. Share team folders with `share-folder`
5. Send announcements via `send-email`
---
## 4. Sales
**Description:** Managing client communications, proposals, and scheduling.
**Top Commands:**
- `gws gmail users.messages send me` — Send proposals and follow-ups
- `gws gmail users.messages list me --query` — Search client conversations
- `gws calendar freebusy query ...` (verify: `gws schema calendar.freebusy.query`) — Schedule client meetings
- `gws docs documents create` — Create proposals
- `gws sheets spreadsheets.values update` — Update pipeline tracker
**Recommended Recipes:** send-email, search-emails, create-event, find-time, create-doc, share-file, sheet-read, sheet-write, export-file, morning-briefing
**Daily Workflow:**
1. Run `morning-briefing` for meeting overview
2. Search emails for client updates
3. Update pipeline in Sheets
4. Send proposals via `send-email` + `share-file`
5. Schedule follow-ups with `create-event`
---
## 5. IT Admin
**Description:** Managing Workspace configuration, security, and user administration.
**Top Commands:**
- `gws admin users list --domain` — Audit user accounts
- `gws admin activities list login` — Monitor login activity
- `gws admin groups list` — Manage groups
- `python3 workspace_audit.py` — Run security audit
- `gws drive files list --orderBy "quotaBytesUsed desc"` — Find storage hogs
**Recommended Recipes:** list-users, list-groups, user-info, audit-logins, drive-activity, find-large-files, cleanup-trash, label-manager, filter-setup, share-folder
**Daily Workflow:**
1. Check `audit-logins` for suspicious activity
2. Run `workspace_audit.py` weekly
3. Process user provisioning requests
4. Monitor storage with `find-large-files`
5. Review group memberships
---
## 6. Developer
**Description:** Using Workspace APIs for automation and data integration.
**Top Commands:**
- `gws sheets spreadsheets.values get` — Read config/data from Sheets
- `gws sheets spreadsheets.values update` — Write results to Sheets
- `gws drive files create --upload` — Upload build artifacts
- `gws chat spaces.messages create` — Post deployment notifications
- `gws tasks tasks insert` — Create tasks from CI/CD
**Recommended Recipes:** sheet-read, sheet-write, sheet-append, upload-file, create-doc, chat-message, task-create, list-files, export-file, send-email
**Daily Workflow:**
1. Read config from Sheets API
2. Run automated reports to Sheets
3. Post updates to Chat spaces
4. Upload artifacts to Drive
5. Create tasks for bugs/issues
---
## 7. Marketing
**Description:** Managing campaigns, content creation, and team coordination.
**Top Commands:**
- `gws docs documents create` — Draft blog posts and briefs
- `gws drive files create --upload` — Upload creative assets
- `gws sheets spreadsheets.values append` — Log campaign metrics
- `gws gmail users.messages send me` — Send campaign emails
- `gws chat spaces.messages create` — Coordinate with team
**Recommended Recipes:** send-email, create-doc, share-file, upload-file, create-sheet, sheet-write, chat-message, create-event, email-stats, weekly-summary
**Daily Workflow:**
1. Check `email-stats` for campaign performance
2. Create content in Docs
3. Upload assets to shared Drive folders
4. Update metrics in Sheets
5. Coordinate launches via Chat
---
## 8. Finance
**Description:** Managing spreadsheets, financial reports, and data analysis.
**Top Commands:**
- `gws sheets spreadsheets.values get` — Pull financial data
- `gws sheets spreadsheets.values update` — Update forecasts
- `gws sheets spreadsheets create` — Create new reports
- `gws drive files export` — Export reports as PDF
- `gws drive permissions create` — Share with auditors
**Recommended Recipes:** sheet-read, sheet-write, sheet-append, create-sheet, export-file, share-file, send-email, find-large-files, drive-activity, weekly-summary
**Daily Workflow:**
1. Pull latest data into Sheets
2. Update financial models
3. Generate PDF reports with `export-file`
4. Share reports with stakeholders
5. Weekly summary for leadership
---
## 9. Legal
**Description:** Managing documents, contracts, and compliance.
**Top Commands:**
- `gws docs documents create` — Draft contracts
- `gws drive files export` — Export final versions as PDF
- `gws drive permissions create` — Manage document access
- `gws gmail users.messages list me --query` — Search for compliance emails
- `gws admin activities list` — Audit trail for compliance
**Recommended Recipes:** create-doc, share-file, export-file, search-emails, send-email, upload-file, list-files, drive-activity, audit-logins, find-large-files
**Daily Workflow:**
1. Draft and review documents
2. Search email for contract references
3. Export finalized docs as PDF
4. Set precise sharing permissions
5. Maintain audit trail
---
## 10. Customer Support
**Description:** Managing customer communications and ticket tracking.
**Top Commands:**
- `gws gmail users.messages list me --query` — Search customer emails
- `gws gmail users.messages reply me` — Reply to tickets
- `gws gmail users.labels create` — Organize by ticket status
- `gws tasks tasks insert` — Create follow-up tasks
- `gws chat spaces.messages create` — Escalate to team
**Recommended Recipes:** search-emails, send-email, reply-to-thread, label-manager, filter-setup, task-create, chat-message, unread-digest, inbox-zero, morning-briefing
**Daily Workflow:**
1. Run `morning-briefing` for ticket overview
2. Process inbox with label-based triage
3. Reply to open tickets
4. Escalate via Chat for urgent issues
5. Create follow-up tasks for pending items
@@ -0,0 +1,61 @@
{
"_comment": "Google Workspace CLI automation config template. Copy and customize for your environment.",
"auth": {
"method": "oauth",
"client_id": "",
"client_secret": "",
"token_path": "~/.config/gws/token.json",
"service_account_key": "",
"delegated_user": ""
},
"defaults": {
"output_format": "json",
"pagination_limit": 100,
"timeout_ms": 30000,
"log_level": "warn"
},
"persona": "developer",
"scopes": [
"gmail.modify",
"gmail.send",
"drive.file",
"drive.metadata.readonly",
"spreadsheets",
"calendar",
"calendar.events",
"tasks"
],
"scheduled_tasks": [
{
"name": "morning-briefing",
"recipe": "morning-briefing",
"schedule": "0 8 * * 1-5",
"output": "~/workspace-reports/morning-{date}.json"
},
{
"name": "eod-wrap",
"recipe": "eod-wrap",
"schedule": "0 17 * * 1-5",
"output": "~/workspace-reports/eod-{date}.json"
},
{
"name": "weekly-summary",
"recipe": "weekly-summary",
"schedule": "0 9 * * 5",
"output": "~/workspace-reports/weekly-{date}.json"
},
{
"name": "security-audit",
"command": "python3 scripts/workspace_audit.py --json",
"schedule": "0 10 * * 1",
"output": "~/workspace-reports/audit-{date}.json"
}
],
"aliases": {
"inbox": "gws gmail users.messages list me --query 'is:inbox' --limit 20 --json",
"unread": "gws gmail users.messages list me --query 'is:unread' --limit 20 --json",
"files": "gws drive files list --limit 20 --json",
"events": "gws calendar events list primary --timeMin $(date -u +%Y-%m-%dT%H:%M:%SZ) --maxResults 10 --json",
"tasks": "gws tasks tasks list @default --json"
}
}
@@ -0,0 +1,308 @@
# Google Workspace CLI Command Reference
Working reference for the `gws` CLI ([github.com/googleworkspace/cli](https://github.com/googleworkspace/cli)) covering services, helper commands, global flags, and environment variables.
> **Verify against your installed version.** `gws` builds its command surface dynamically from Google's Discovery Service and is pre-v1.0. Treat command syntax in this document as a template: confirm exact syntax with `gws --help`, `gws <service> --help`, or `gws schema <service>.<resource>.<method>` before scripting. Verified-from-upstream facts: install via `npm install -g @googleworkspace/cli`; discovery commands follow `gws <service> <resource> <method>` with `--params` (query/path params as JSON) and `--json` (request body); helpers are `+`-prefixed (e.g. `gws gmail +send`); all output is structured JSON.
---
## Global Flags (verified)
| Flag | Description |
|------|-------------|
| `--params <json>` | Query/path parameters as JSON for discovery commands |
| `--json <json>` | Request body as JSON for discovery commands |
| `--dry-run` | Preview the request without executing |
| `--page-all` | Auto-paginate; one JSON line per page (NDJSON) |
| `--page-limit <n>` | Max pages to fetch |
| `--page-delay <ms>` | Delay between pages |
| `--sanitize` | Scan responses via a Model Armor template |
---
## Environment Variables (verified)
| Variable | Description |
|----------|-------------|
| `GOOGLE_WORKSPACE_CLI_CLIENT_ID` | OAuth client ID |
| `GOOGLE_WORKSPACE_CLI_CLIENT_SECRET` | OAuth client secret |
| `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` | Path to credentials JSON (from `gws auth export`) |
| `GOOGLE_WORKSPACE_CLI_TOKEN` | Pre-obtained OAuth token |
| `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` | Override default config location |
| `GOOGLE_WORKSPACE_CLI_LOG` | Enable debug logging |
---
## Services
### Gmail
```bash
gws gmail users.messages list me --query "<query>" --json
gws gmail users.messages get me <messageId> --json
gws gmail users.messages send me --to <email> --subject <subj> --body <body>
gws gmail users.messages reply me --thread-id <id> --body <body>
gws gmail users.messages forward me --message-id <id> --to <email>
gws gmail users.messages modify me <id> --addLabelIds <label> --removeLabelIds INBOX
gws gmail users.messages trash me <id>
gws gmail users.labels list me --json
gws gmail users.labels create me --name <name>
gws gmail users.settings.filters create me --criteria <json> --action <json>
gws gmail users.settings.forwardingAddresses list me --json
gws gmail users getProfile me --json
```
### Google Drive
```bash
gws drive files list --json --limit <n>
gws drive files list --query "name contains '<term>'" --json
gws drive files list --parents <folderId> --json
gws drive files get <fileId> --json
gws drive files create --name <name> --upload <path> --parents <folderId>
gws drive files create --name <name> --mimeType application/vnd.google-apps.folder
gws drive files update <fileId> --upload <path>
gws drive files delete <fileId>
gws drive files export <fileId> --mime <mimeType> --output <path>
gws drive files copy <fileId> --name <newName>
gws drive permissions list <fileId> --json
gws drive permissions create <fileId> --type <user|group|domain> --role <reader|writer|owner> --emailAddress <email>
gws drive permissions delete <fileId> <permissionId>
gws drive about get --json
gws drive files emptyTrash
```
### Google Sheets
```bash
gws sheets spreadsheets create --title <title> --json
gws sheets spreadsheets get <spreadsheetId> --json
gws sheets spreadsheets.values get <spreadsheetId> --range <range> --json
gws sheets spreadsheets.values update <spreadsheetId> --range <range> --values <json>
gws sheets spreadsheets.values append <spreadsheetId> --range <range> --values <json>
gws sheets spreadsheets.values clear <spreadsheetId> --range <range>
gws sheets spreadsheets.values batchGet <spreadsheetId> --ranges <range1>,<range2> --json
gws sheets spreadsheets.values batchUpdate <spreadsheetId> --data <json>
```
### Google Calendar
```bash
gws calendar calendarList list --json
gws calendar calendarList get <calendarId> --json
gws calendar events list <calendarId> --timeMin <datetime> --timeMax <datetime> --json
gws calendar events get <calendarId> <eventId> --json
gws calendar events insert <calendarId> --summary <title> --start <datetime> --end <datetime> --attendees <emails>
gws calendar events update <calendarId> <eventId> --summary <title>
gws calendar events patch <calendarId> <eventId> --start <datetime> --end <datetime>
gws calendar events delete <calendarId> <eventId>
gws calendar freebusy query --timeMin <start> --timeMax <end> --items <calendarId1>,<calendarId2> --json
```
### Google Docs
```bash
gws docs documents create --title <title> --json
gws docs documents get <documentId> --json
gws docs documents batchUpdate <documentId> --requests <json>
```
### Google Slides
```bash
gws slides presentations create --title <title> --json
gws slides presentations get <presentationId> --json
gws slides presentations.pages get <presentationId> <pageId> --json
gws slides presentations.pages getThumbnail <presentationId> <pageId> --json
```
### Google Chat
```bash
gws chat spaces list --json
gws chat spaces get <spaceName> --json
gws chat spaces.messages create <spaceName> --text <message>
gws chat spaces.messages list <spaceName> --json
gws chat spaces.messages get <messageName> --json
gws chat spaces.members list <spaceName> --json
```
### Google Tasks
```bash
gws tasks tasklists list --json
gws tasks tasklists get <tasklistId> --json
gws tasks tasklists insert --title <title> --json
gws tasks tasks list <tasklistId> --json
gws tasks tasks get <tasklistId> <taskId> --json
gws tasks tasks insert <tasklistId> --title <title> --due <datetime>
gws tasks tasks update <tasklistId> <taskId> --status completed
gws tasks tasks delete <tasklistId> <taskId>
```
### Admin SDK (Directory)
```bash
gws admin users list --domain <domain> --json
gws admin users get <email> --json
gws admin users insert --primaryEmail <email> --name.givenName <first> --name.familyName <last>
gws admin users update <email> --suspended true
gws admin groups list --domain <domain> --json
gws admin groups get <email> --json
gws admin groups insert --email <email> --name <name>
gws admin groups.members list <groupEmail> --json
gws admin groups.members insert <groupEmail> --email <memberEmail> --role MEMBER
gws admin orgunits list --customerId my_customer --json
```
### Google Groups
```bash
gws groups groups list --domain <domain> --json
gws groups groups get <email> --json
gws groups memberships list <groupEmail> --json
```
### Google People (Contacts)
```bash
gws people people.connections list me --personFields names,emailAddresses --json
gws people people get <resourceName> --personFields names,emailAddresses,phoneNumbers --json
gws people people searchContacts --query <term> --readMask names,emailAddresses --json
```
### Google Meet
```bash
gws meet spaces create --json
gws meet spaces get <spaceName> --json
gws meet conferenceRecords list --json
```
### Google Classroom
```bash
gws classroom courses list --json
gws classroom courses get <courseId> --json
gws classroom courses.courseWork list <courseId> --json
gws classroom courses.students list <courseId> --json
```
### Google Forms
```bash
gws forms forms get <formId> --json
gws forms forms.responses list <formId> --json
```
### Google Keep
```bash
gws keep notes list --json
gws keep notes get <noteId> --json
```
### Google Sites
```bash
gws sites sites list --json
gws sites sites get <siteId> --json
```
### Google Vault
```bash
gws vault matters list --json
gws vault matters get <matterId> --json
gws vault matters.holds list <matterId> --json
```
### Admin Reports / Activities
```bash
gws admin activities list <applicationName> --json
gws admin activities list login --json
gws admin activities list drive --json
gws admin activities list admin --json
```
---
## Helper Commands (verified `+`-prefixed surface)
Helpers are prefixed with `+` so they never collide with Discovery-generated method names. Check each helper's flags with `gws <service> +<helper> --help`.
| Service | Helper | Description |
|---------|--------|-------------|
| gmail | `+send` | Send an email (`gws gmail +send --to a@b.com --subject "Hi" --body "Hello"`) |
| gmail | `+reply` | Reply to a message (auto-threading) |
| gmail | `+reply-all` | Reply-all to a message |
| gmail | `+forward` | Forward a message |
| gmail | `+triage` | Unread inbox summary |
| gmail | `+watch` | Watch for new emails as NDJSON |
| sheets | `+append` | Append a row |
| sheets | `+read` | Read values |
| docs | `+write` | Append text |
| chat | `+send` | Send a space message |
| drive | `+upload` | Upload a file (`gws drive +upload ./report.pdf --name "Q1 Report"`) |
| calendar | `+insert` | Create an event |
| calendar | `+agenda` | Show upcoming events (timezone-aware) |
| script | `+push` | Replace all Apps Script files |
| workflow | `+standup-report` | Today's meetings + tasks |
| workflow | `+meeting-prep` | Next meeting prep |
| workflow | `+email-to-task` | Convert Gmail to Tasks |
| workflow | `+weekly-digest` | Weekly summary |
| workflow | `+file-announce` | Announce Drive file in Chat |
| events | `+subscribe` | Subscribe to Workspace events |
| events | `+renew` | Renew event subscriptions |
| modelarmor | `+sanitize-prompt` | Sanitize user prompt |
| modelarmor | `+sanitize-response` | Sanitize model response |
| modelarmor | `+create-template` | Create Model Armor template |
---
## Schema Introspection
```bash
# View the API schema for any service method
gws schema gmail.users.messages.list
gws schema drive.files.create
gws schema calendar.events.insert
# Discover available schema/introspection options for your version
gws schema --help
```
---
## Authentication Commands (verified)
```bash
gws auth setup # Interactive OAuth setup (uses gcloud if available)
gws auth login # Log in / re-consent
gws auth login -s drive,gmail,sheets # Request specific scopes
gws auth export --unmasked # Export credentials for headless reuse
gws auth --help # Discover further auth subcommands in your version
```
---
## Recipe Commands (local catalog, not built into gws)
Recipes ship with this skill as a local catalog of command templates:
```bash
python3 scripts/gws_recipe_runner.py --list # List all 43 recipe templates
python3 scripts/gws_recipe_runner.py --search "email" # Search by keyword
python3 scripts/gws_recipe_runner.py --describe standup-report # Show recipe details
python3 scripts/gws_recipe_runner.py --run <name> --dry-run # Preview recipe commands
```
---
## Persona Commands (local catalog, not built into gws)
```bash
python3 scripts/gws_recipe_runner.py --personas # List all 10 personas
python3 scripts/gws_recipe_runner.py --list --persona pm # Recipes for a persona
```
@@ -0,0 +1,345 @@
# Google Workspace CLI Recipes Cookbook
Catalog of 43 recipe command templates (a local catalog shipped with this skill, not built into the gws CLI) organized by category, with command sequences and persona mapping.
> **These are command templates, not verified invocations.** The `gws` CLI ([github.com/googleworkspace/cli](https://github.com/googleworkspace/cli)) generates its command surface dynamically from Google's Discovery Service and is pre-v1.0. Before running any command below, verify its exact syntax with `gws --help`, `gws <service> --help`, or `gws schema <service>.<resource>.<method>`. Verified upstream patterns: discovery commands are `gws <service> <resource> <method> --params '{...}' --json '{...}'`; helpers are `+`-prefixed (`gws gmail +send`, `gws calendar +agenda`, `gws workflow +standup-report`).
---
## Recipe Categories
| Category | Count | Description |
|----------|-------|-------------|
| Email | 8 | Gmail operations — send, search, label, filter |
| Files | 7 | Drive file management — upload, share, export |
| Calendar | 6 | Events, scheduling, meeting prep |
| Reporting | 5 | Activity summaries and analytics |
| Collaboration | 5 | Chat, Docs, Tasks teamwork |
| Data | 4 | Sheets read/write and contacts |
| Admin | 4 | User and group management |
| Cross-Service | 4 | Multi-service workflows |
---
## Email Recipes (8)
### send-email
Send an email with optional attachments.
```bash
gws gmail users.messages send me --to "recipient@example.com" \
--subject "Subject" --body "Body text" [--attachment file.pdf]
```
### reply-to-thread
Reply to an existing email thread.
```bash
gws gmail users.messages reply me --thread-id <THREAD_ID> --body "Reply text"
```
### forward-email
Forward an email to another recipient.
```bash
gws gmail users.messages forward me --message-id <MSG_ID> --to "forward@example.com"
```
### search-emails
Search emails using Gmail query syntax.
```bash
gws gmail users.messages list me --query "from:sender@example.com after:2025/01/01" --json
```
**Query examples:** `is:unread`, `has:attachment`, `label:important`, `newer_than:7d`
### archive-old
Archive read emails older than N days.
```bash
gws gmail users.messages list me --query "is:read older_than:30d" --json
# Extract IDs, then batch modify to remove INBOX label
```
### label-manager
Create and organize Gmail labels.
```bash
gws gmail users.labels list me --json
gws gmail users.labels create me --name "Projects/Alpha"
```
### filter-setup
Create auto-labeling filters.
```bash
gws gmail users.settings.filters create me \
--criteria '{"from":"notifications@service.com"}' \
--action '{"addLabelIds":["Label_123"],"removeLabelIds":["INBOX"]}'
```
### unread-digest
Get digest of unread emails.
```bash
gws gmail users.messages list me --query "is:unread" --limit 20 --json
```
---
## Files Recipes (7)
### upload-file
Upload a file to Google Drive.
```bash
gws drive files create --name "Report Q1" --upload report.pdf --parents <FOLDER_ID>
```
### create-sheet
Create a new Google Spreadsheet.
```bash
gws sheets spreadsheets create --title "Budget 2026" --json
```
### share-file
Share a Drive file with a user or domain.
```bash
gws drive permissions create <FILE_ID> --type user --role writer --emailAddress "user@example.com"
```
### export-file
Export a Google Doc/Sheet as PDF.
```bash
gws drive files export <FILE_ID> --mime "application/pdf" --output report.pdf
```
### list-files
List files in a Drive folder.
```bash
gws drive files list --parents <FOLDER_ID> --json
```
### find-large-files
Find the largest files in Drive.
```bash
gws drive files list --orderBy "quotaBytesUsed desc" --limit 20 --json
```
### cleanup-trash
Empty Drive trash.
```bash
gws drive files emptyTrash
```
---
## Calendar Recipes (6)
### create-event
Create a calendar event with attendees.
```bash
gws calendar events insert primary \
--summary "Sprint Planning" \
--start "2026-03-15T10:00:00" --end "2026-03-15T11:00:00" \
--attendees "team@company.com" --location "Room A"
```
### quick-event
Create event from natural language.
```bash
gws calendar +insert ... # see: gws calendar +insert --help
```
### find-time
Find available time slots for a meeting.
```bash
gws calendar freebusy query --json '{"timeMin": "...", "timeMax": "...", "items": [{"id": "alice@co.com"}]}' # verify: gws schema calendar.freebusy.query
```
### today-schedule
Show today's calendar events.
```bash
gws calendar events list primary \
--timeMin "$(date -u +%Y-%m-%dT00:00:00Z)" \
--timeMax "$(date -u +%Y-%m-%dT23:59:59Z)" --json
```
### meeting-prep
Prepare for an upcoming meeting.
```bash
gws workflow +meeting-prep
```
**Output:** Agenda, attendee list, related Drive files, previous meeting notes.
### reschedule
Move an event to a new time.
```bash
gws calendar events patch primary <EVENT_ID> \
--start "2026-03-16T14:00:00" --end "2026-03-16T15:00:00"
```
---
## Reporting Recipes (5)
### standup-report
Generate daily standup from calendar and tasks.
```bash
gws workflow +standup-report
```
**Output:** Yesterday's events, today's schedule, pending tasks, blockers.
### weekly-summary
Summarize week's emails, events, and tasks.
```bash
gws workflow +weekly-digest
```
### drive-activity
Report on Drive file activity.
```bash
gws drive activities list --json
```
### email-stats
Email volume statistics for the past 7 days.
```bash
gws gmail users.messages list me --query "newer_than:7d" --json | python3 output_analyzer.py --count
```
### task-progress
Report on task completion.
```bash
gws tasks tasks list <TASKLIST_ID> --json | python3 output_analyzer.py --group-by "status"
```
---
## Collaboration Recipes (5)
### share-folder
Share a Drive folder with a team.
```bash
gws drive permissions create <FOLDER_ID> --type group --role writer --emailAddress "team@company.com"
```
### create-doc
Create a Google Doc with initial content.
```bash
gws docs documents create --title "Meeting Notes - March 15" --json
```
### chat-message
Send a message to a Google Chat space.
```bash
gws chat spaces.messages create <SPACE_NAME> --text "Deployment complete!"
```
### list-spaces
List Google Chat spaces.
```bash
gws chat spaces list --json
```
### task-create
Create a task in Google Tasks.
```bash
gws tasks tasks insert <TASKLIST_ID> --title "Review PR #42" --due "2026-03-16"
```
---
## Data Recipes (4)
### sheet-read
Read data from a spreadsheet range.
```bash
gws sheets spreadsheets.values get <SHEET_ID> --range "Sheet1!A1:D10" --json
```
### sheet-write
Write data to a spreadsheet.
```bash
gws sheets spreadsheets.values update <SHEET_ID> --range "Sheet1!A1" \
--values '[["Name","Score"],["Alice",95],["Bob",87]]'
```
### sheet-append
Append rows to a spreadsheet.
```bash
gws sheets spreadsheets.values append <SHEET_ID> --range "Sheet1!A1" \
--values '[["Charlie",92]]'
```
### export-contacts
Export contacts list.
```bash
gws people people.connections list me --personFields names,emailAddresses --json
```
---
## Admin Recipes (4)
### list-users
List all users in the Workspace domain.
```bash
gws admin users list --domain company.com --json
```
**Prerequisites:** Admin SDK API enabled, `admin.directory.user.readonly` scope.
### list-groups
List all groups in the domain.
```bash
gws admin groups list --domain company.com --json
```
### user-info
Get detailed user information.
```bash
gws admin users get user@company.com --json
```
### audit-logins
Audit recent login activity.
```bash
gws admin activities list login --json
```
---
## Cross-Service Recipes (4)
### morning-briefing
Today's events + unread emails + pending tasks.
```bash
python3 scripts/gws_recipe_runner.py --run morning-briefing --dry-run # prints the command sequence
```
**Combines:** Calendar events, Gmail unread count, Tasks pending.
### eod-wrap
End-of-day summary: completed, pending, tomorrow's schedule.
```bash
python3 scripts/gws_recipe_runner.py --run eod-wrap --dry-run
```
### project-status
Aggregate project status from Drive, Sheets, Tasks.
```bash
python3 scripts/gws_recipe_runner.py --run project-status --dry-run
```
### inbox-zero
Process inbox to zero: label, archive, reply, or create task.
```bash
python3 scripts/gws_recipe_runner.py --run inbox-zero --dry-run
```
---
## Persona Mapping
| Persona | Top Recipes |
|---------|-------------|
| Executive Assistant | morning-briefing, today-schedule, find-time, send-email, meeting-prep, eod-wrap |
| Project Manager | standup-report, create-event, find-time, task-create, project-status, weekly-summary |
| HR | list-users, user-info, send-email, create-event, create-doc, export-contacts |
| Sales | send-email, search-emails, create-event, find-time, create-doc, share-file |
| IT Admin | list-users, list-groups, audit-logins, drive-activity, find-large-files, cleanup-trash |
| Developer | sheet-read, sheet-write, upload-file, chat-message, task-create, send-email |
| Marketing | send-email, create-doc, share-file, upload-file, create-sheet, chat-message |
| Finance | sheet-read, sheet-write, sheet-append, create-sheet, export-file, share-file |
| Legal | create-doc, share-file, export-file, search-emails, upload-file, audit-logins |
| Customer Support | search-emails, send-email, reply-to-thread, label-manager, task-create, inbox-zero |
@@ -0,0 +1,321 @@
# Google Workspace CLI Troubleshooting
Common errors, fixes, and platform-specific guidance for the `gws` CLI ([github.com/googleworkspace/cli](https://github.com/googleworkspace/cli)).
> **Verify against your installed version.** `gws` is pre-v1.0 and generates its command surface dynamically from Google's Discovery Service. Confirm any `gws` command below with `gws --help` / `gws auth --help` before relying on it.
---
## Installation Issues
### gws not found on PATH
**Error:** `command not found: gws`
**Fixes:**
```bash
# Check if installed
npm list -g @googleworkspace/cli 2>/dev/null || echo "Not installed via npm"
which gws || echo "Not on PATH"
# Install via npm
npm install -g @googleworkspace/cli
# If npm global bin not on PATH
export PATH="$(npm config get prefix)/bin:$PATH"
# Add to ~/.zshrc or ~/.bashrc for persistence
```
### npm permission errors
**Error:** `EACCES: permission denied`
**Fixes:**
```bash
# Option 1: Fix npm prefix (recommended)
mkdir -p ~/.npm-global
npm config set prefix '~/.npm-global'
export PATH=~/.npm-global/bin:$PATH
# Option 2: Use npx without installing
npx @googleworkspace/cli --version
```
### Cargo build failures
**Error:** `error[E0463]: can't find crate`
**Fixes:**
```bash
# Ensure Rust is up to date
rustup update stable
# Clean build
cargo clean && cargo install --git https://github.com/googleworkspace/cli --locked
```
---
## Authentication Errors
### Token expired
**Error:** `401 Unauthorized: Token has been expired or revoked`
**Cause:** OAuth tokens expire after 1 hour.
**Fix:**
```bash
gws auth login # Re-authenticate (see: gws auth --help)
# If that fails, redo setup:
gws auth setup
```
### Insufficient scopes
**Error:** `403 Forbidden: Request had insufficient authentication scopes`
**Fix:**
```bash
# Re-auth requesting the scopes you need
gws auth login -s gmail,drive,calendar,sheets,tasks
# Or list required scopes for a service
python3 scripts/auth_setup_guide.py --scopes gmail,drive
```
### Keyring/keychain errors
**Error:** `Failed to access keyring` or `SecKeychainFindGenericPassword failed`
**Fixes:**
```bash
# macOS: Unlock keychain
security unlock-keychain ~/Library/Keychains/login.keychain-db
# Linux: Install keyring backend
sudo apt install gnome-keyring # or libsecret
# Fallback: Use file-based token storage
export GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE=/path/to/credentials.json # from: gws auth export --unmasked
gws auth setup
```
### Service account delegation errors
**Error:** `403: Not Authorized to access this resource/api`
**Fix:**
1. Verify domain-wide delegation is enabled on the service account
2. Verify client ID is authorized in Admin Console > Security > API Controls
3. Verify scopes match exactly (no trailing slashes)
4. Verify the delegated user is a valid admin account
```bash
# Debug — confirm how your gws version configures service accounts first:
gws auth --help
echo $GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE # Should point to valid credentials JSON
```
---
## API Errors
### Rate limit exceeded (429)
**Error:** `429 Too Many Requests: Rate Limit Exceeded`
**Cause:** Google Workspace APIs have per-user, per-service rate limits.
**Fix:**
```bash
# Add delays between bulk operations
for id in $(cat file_ids.txt); do
gws drive files get $id --json >> results.json
sleep 0.5 # 500ms delay
done
# Use --limit to reduce result size
gws drive files list --limit 100 --json
# For admin operations, batch in groups of 50
```
**Rate limits by service:**
| Service | Limit |
|---------|-------|
| Gmail | 250 quota units/second/user |
| Drive | 1,000 requests/100 seconds/user |
| Sheets | 60 read requests/minute/user |
| Calendar | 500 requests/100 seconds/user |
| Admin SDK | 2,400 requests/minute |
### Permission denied (403)
**Error:** `403 Forbidden: The caller does not have permission`
**Causes and fixes:**
1. **Wrong scope** — Re-auth with correct scopes
2. **Not the file owner** — Request access from the owner
3. **Domain policy** — Check Admin Console sharing policies
4. **API not enabled** — Enable the API in Google Cloud Console
```bash
# Check which APIs are enabled
gws schema --list
# Enable an API
# Go to: console.cloud.google.com > APIs & Services > Library
```
### Not found (404)
**Error:** `404 Not Found: File not found`
**Causes:**
1. File was deleted or moved to trash
2. File ID is incorrect
3. No permission to see the file
```bash
# Check trash
gws drive files list --query "trashed=true and name='filename'" --json
# Verify file ID
gws drive files get <fileId> --json
```
---
## Output Parsing Issues
### NDJSON vs JSON array
**Problem:** Output format varies between commands and versions.
```bash
# Force JSON array output
gws drive files list --json
# Force NDJSON output
gws drive files list --format ndjson
# Handle both in output_analyzer.py (automatic detection)
gws drive files list --json | python3 scripts/output_analyzer.py --count
```
### Pagination
**Problem:** Only partial results returned.
```bash
# Fetch all pages
gws drive files list --page-all --json
# Or set a high limit
gws drive files list --limit 1000 --json
# Check if more pages exist (look for nextPageToken in output)
gws drive files list --params '{"pageSize": 100}' | grep nextPageToken
```
### Empty response
**Problem:** Command returns empty or `{}`.
```bash
# Check auth (see: gws auth --help for your version's status command)
gws auth login
# Enable debug logging
GOOGLE_WORKSPACE_CLI_LOG=debug gws drive files list --params '{"pageSize": 1}'
# Check if the service is accessible (verify: gws schema drive.about.get)
gws drive about get --params '{"fields": "*"}'
```
---
## Platform-Specific Issues
### macOS
**Keychain access prompts:**
```bash
# Allow gws to access keychain without repeated prompts
# In Keychain Access.app, find "gws" entries and set "Allow all applications"
# Or use file-based storage
export GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE=/path/to/credentials.json # from: gws auth export --unmasked
```
**Browser not opening for OAuth:**
```bash
# If default browser doesn't open
gws auth setup --no-browser
# Copy the URL manually and paste in browser
```
### Linux
**Headless OAuth (no browser):**
```bash
# Use out-of-band flow
gws auth setup --no-browser
# Prints a URL — open on another machine, paste code back
# Or export credentials from an interactive machine (documented headless flow)
gws auth export --unmasked > credentials.json
export GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE=/path/to/credentials.json
```
**Missing keyring backend:**
```bash
# Install a keyring backend
sudo apt install gnome-keyring libsecret-1-dev
# Or use file-based storage
export GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE=/path/to/credentials.json # from: gws auth export --unmasked
```
### Windows
**PATH issues:**
```powershell
# Add npm global bin to PATH
$env:PATH += ";$(npm config get prefix)\bin"
# Or use npx
npx @googleworkspace/cli --version
```
**PowerShell quoting:**
```powershell
# Use single quotes for JSON arguments
gws gmail users.settings.filters create me `
--criteria '{"from":"test@example.com"}' `
--action '{"addLabelIds":["Label_1"]}'
```
---
## Getting Help
```bash
# General help
gws --help
gws <service> --help
gws <service> <resource> --help
# API schema for a method
gws schema gmail.users.messages.send
# Version info
gws --version
# Debug mode
gws --verbose <command>
# Report issues
# https://github.com/googleworkspace/cli/issues
```
@@ -0,0 +1,396 @@
#!/usr/bin/env python3
"""
Google Workspace CLI Auth Setup Guide — Guided authentication configuration.
Prints step-by-step instructions for OAuth and service account setup,
generates .env templates, lists required scopes, and validates auth.
Usage:
python3 auth_setup_guide.py --guide oauth
python3 auth_setup_guide.py --guide service-account
python3 auth_setup_guide.py --scopes gmail,drive,calendar
python3 auth_setup_guide.py --generate-env
python3 auth_setup_guide.py --validate [--json]
python3 auth_setup_guide.py --check [--json]
"""
import argparse
import json
import shutil
import subprocess
import sys
from dataclasses import dataclass, field, asdict
from typing import List, Dict
SERVICE_SCOPES: Dict[str, List[str]] = {
"gmail": [
"https://www.googleapis.com/auth/gmail.modify",
"https://www.googleapis.com/auth/gmail.send",
"https://www.googleapis.com/auth/gmail.labels",
"https://www.googleapis.com/auth/gmail.settings.basic",
],
"drive": [
"https://www.googleapis.com/auth/drive",
"https://www.googleapis.com/auth/drive.file",
"https://www.googleapis.com/auth/drive.metadata.readonly",
],
"sheets": [
"https://www.googleapis.com/auth/spreadsheets",
],
"calendar": [
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/calendar.events",
],
"tasks": [
"https://www.googleapis.com/auth/tasks",
],
"chat": [
"https://www.googleapis.com/auth/chat.spaces.readonly",
"https://www.googleapis.com/auth/chat.messages",
],
"docs": [
"https://www.googleapis.com/auth/documents",
],
"admin": [
"https://www.googleapis.com/auth/admin.directory.user.readonly",
"https://www.googleapis.com/auth/admin.directory.group",
"https://www.googleapis.com/auth/admin.directory.orgunit.readonly",
],
"meet": [
"https://www.googleapis.com/auth/meetings.space.created",
],
}
OAUTH_GUIDE = """
=== Google Workspace CLI: OAuth Setup Guide ===
Step 1: Create a Google Cloud Project
1. Go to https://console.cloud.google.com/
2. Click "Select a project" -> "New Project"
3. Name it (e.g., "gws-cli-access") and click Create
4. Note the Project ID
Step 2: Enable Required APIs
1. Go to APIs & Services -> Library
2. Search and enable each API you need:
- Gmail API
- Google Drive API
- Google Sheets API
- Google Calendar API
- Tasks API
- Admin SDK API (for admin operations)
Step 3: Configure OAuth Consent Screen
1. Go to APIs & Services -> OAuth consent screen
2. Select "Internal" (for Workspace) or "External" (for personal)
3. Fill in app name, support email
4. Add scopes for the services you need
5. Save and continue
Step 4: Create OAuth Credentials
1. Go to APIs & Services -> Credentials
2. Click "Create Credentials" -> "OAuth client ID"
3. Application type: "Desktop app"
4. Name it "gws-cli"
5. Download the JSON file
Step 5: Configure gws CLI
1. Set environment variables:
export GOOGLE_WORKSPACE_CLI_CLIENT_ID=<your-client-id>
export GOOGLE_WORKSPACE_CLI_CLIENT_SECRET=<your-client-secret>
2. Or point the CLI at a credentials JSON file:
export GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE=/path/to/credentials.json
Step 6: Authenticate
gws auth setup # interactive setup (uses gcloud if available)
gws auth login -s gmail,drive,calendar # request only needed scopes
Step 7: Verify (check exact syntax with: gws gmail --help)
gws gmail users getProfile --params '{"userId": "me"}'
"""
SERVICE_ACCOUNT_GUIDE = """
=== Google Workspace CLI: Service Account Setup Guide ===
Step 1: Create a Google Cloud Project
(Same as OAuth Step 1)
Step 2: Create a Service Account
1. Go to IAM & Admin -> Service Accounts
2. Click "Create Service Account"
3. Name: "gws-cli-service"
4. Grant roles as needed (no role needed for Workspace API access)
5. Click "Done"
Step 3: Create Key
1. Click on the service account
2. Go to "Keys" tab
3. Add Key -> Create new key -> JSON
4. Download and store securely
Step 4: Enable Domain-Wide Delegation
1. On the service account page, click "Edit"
2. Check "Enable Google Workspace domain-wide delegation"
3. Save
4. Note the Client ID (numeric)
Step 5: Authorize in Google Admin
1. Go to admin.google.com
2. Security -> API Controls -> Domain-wide Delegation
3. Add new:
- Client ID: <numeric client ID from Step 4>
- Scopes: (paste required scopes)
4. Authorize
Step 6: Configure gws CLI for headless use
NOTE: The documented headless flow for gws is to export credentials from an
interactive machine and reuse them (see: gws auth --help):
gws auth export --unmasked > credentials.json
export GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE=/path/to/credentials.json
Or supply a pre-obtained OAuth token:
export GOOGLE_WORKSPACE_CLI_TOKEN=<token>
Verify service-account/domain-wide-delegation support against your installed
version (gws auth --help) before relying on it.
Step 7: Verify (check exact syntax with: gws gmail --help)
gws gmail users getProfile --params '{"userId": "me"}'
"""
ENV_TEMPLATE = """# Google Workspace CLI Configuration
# Copy to .env and fill in values
# OAuth Credentials (for interactive auth)
GOOGLE_WORKSPACE_CLI_CLIENT_ID=
GOOGLE_WORKSPACE_CLI_CLIENT_SECRET=
# Headless/CI auth (export from an interactive machine: gws auth export --unmasked)
# GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE=/path/to/credentials.json
# GOOGLE_WORKSPACE_CLI_TOKEN=
# Optional
# GOOGLE_WORKSPACE_CLI_CONFIG_DIR=~/.config/gws
# GOOGLE_WORKSPACE_CLI_LOG=debug
"""
@dataclass
class ValidationResult:
service: str
status: str # PASS, FAIL
message: str
@dataclass
class ValidationReport:
auth_method: str = ""
user: str = ""
results: List[dict] = field(default_factory=list)
summary: str = ""
demo_mode: bool = False
DEMO_VALIDATION = ValidationReport(
auth_method="oauth",
user="admin@company.com",
results=[
{"service": "gmail", "status": "PASS", "message": "Gmail API accessible"},
{"service": "drive", "status": "PASS", "message": "Drive API accessible"},
{"service": "calendar", "status": "PASS", "message": "Calendar API accessible"},
{"service": "sheets", "status": "PASS", "message": "Sheets API accessible"},
{"service": "tasks", "status": "FAIL", "message": "Scope not authorized"},
],
summary="4/5 services validated (demo mode)",
demo_mode=True,
)
def check_auth_status() -> dict:
"""Check current gws auth status."""
try:
result = subprocess.run(
["gws", "auth", "status", "--json"],
capture_output=True, text=True, timeout=15
)
if result.returncode == 0:
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
return {"status": "authenticated", "raw": result.stdout.strip()}
return {"status": "unknown",
"note": "could not determine auth status ('gws auth status' may not exist "
"in your version; check 'gws auth --help')",
"error": result.stderr.strip()[:200]}
except (FileNotFoundError, OSError):
return {"status": "gws_not_found"}
def validate_services(services: List[str]) -> ValidationReport:
"""Validate auth by testing each service."""
report = ValidationReport()
auth = check_auth_status()
if auth.get("status") == "gws_not_found":
report.summary = "gws CLI not installed"
return report
if auth.get("status") == "not_authenticated":
report.auth_method = "none"
report.summary = "Not authenticated"
return report
report.auth_method = auth.get("method", "oauth")
report.user = auth.get("user", auth.get("email", "unknown"))
service_cmds = {
"gmail": ["gws", "gmail", "users", "getProfile", "--params", '{"userId": "me"}'],
"drive": ["gws", "drive", "files", "list", "--params", '{"pageSize": 1}'],
"calendar": ["gws", "calendar", "calendarList", "list", "--params", '{"maxResults": 1}'],
"sheets": ["gws", "schema", "sheets.spreadsheets.get"],
"tasks": ["gws", "tasks", "tasklists", "list", "--params", '{"maxResults": 1}'],
}
for svc in services:
cmd = service_cmds.get(svc)
if not cmd:
report.results.append(asdict(
ValidationResult(svc, "WARN", f"No test available for {svc}")
))
continue
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
if result.returncode == 0:
report.results.append(asdict(
ValidationResult(svc, "PASS", f"{svc.title()} API accessible")
))
else:
report.results.append(asdict(
ValidationResult(svc, "FAIL", result.stderr.strip()[:100])
))
except (subprocess.TimeoutExpired, OSError) as e:
report.results.append(asdict(
ValidationResult(svc, "FAIL", str(e)[:100])
))
passed = sum(1 for r in report.results if r["status"] == "PASS")
total = len(report.results)
report.summary = f"{passed}/{total} services validated"
return report
def main():
parser = argparse.ArgumentParser(
description="Guided authentication setup for Google Workspace CLI (gws)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s --guide oauth # OAuth setup instructions
%(prog)s --guide service-account # Service account setup
%(prog)s --scopes gmail,drive # Show required scopes
%(prog)s --generate-env # Generate .env template
%(prog)s --check # Check current auth status
%(prog)s --validate --json # Validate all services (JSON)
""",
)
parser.add_argument("--guide", choices=["oauth", "service-account"],
help="Print setup guide")
parser.add_argument("--scopes", help="Comma-separated services to show scopes for")
parser.add_argument("--generate-env", action="store_true",
help="Generate .env template")
parser.add_argument("--check", action="store_true",
help="Check current auth status")
parser.add_argument("--validate", action="store_true",
help="Validate auth by testing services")
parser.add_argument("--services", default="gmail,drive,calendar,sheets,tasks",
help="Services to validate (default: gmail,drive,calendar,sheets,tasks)")
parser.add_argument("--json", action="store_true", help="Output JSON")
args = parser.parse_args()
if not any([args.guide, args.scopes, args.generate_env, args.check, args.validate]):
parser.print_help()
return
if args.guide:
if args.guide == "oauth":
print(OAUTH_GUIDE)
else:
print(SERVICE_ACCOUNT_GUIDE)
return
if args.scopes:
services = [s.strip() for s in args.scopes.split(",") if s.strip()]
if args.json:
output = {}
for svc in services:
output[svc] = SERVICE_SCOPES.get(svc, [])
print(json.dumps(output, indent=2))
else:
print(f"\n{'='*60}")
print(f" REQUIRED OAUTH SCOPES")
print(f"{'='*60}\n")
for svc in services:
scopes = SERVICE_SCOPES.get(svc, [])
print(f" {svc.upper()}:")
if scopes:
for scope in scopes:
print(f" - {scope}")
else:
print(f" (no scopes defined for '{svc}')")
print()
# Print combined for easy copy-paste
all_scopes = []
for svc in services:
all_scopes.extend(SERVICE_SCOPES.get(svc, []))
if all_scopes:
print(f" COMBINED (for consent screen):")
print(f" {','.join(all_scopes)}")
print(f"\n{'='*60}\n")
return
if args.generate_env:
print(ENV_TEMPLATE)
return
if args.check:
if shutil.which("gws"):
status = check_auth_status()
else:
status = {"status": "gws_not_found",
"note": "Install gws first: npm install -g @googleworkspace/cli OR https://github.com/googleworkspace/cli/releases"}
if args.json:
print(json.dumps(status, indent=2))
else:
print(f"\nAuth Status: {status.get('status', 'unknown')}")
for k, v in status.items():
if k != "status":
print(f" {k}: {v}")
print()
return
if args.validate:
services = [s.strip() for s in args.services.split(",") if s.strip()]
if not shutil.which("gws"):
report = DEMO_VALIDATION
else:
report = validate_services(services)
if args.json:
print(json.dumps(asdict(report), indent=2))
else:
print(f"\n{'='*60}")
print(f" AUTH VALIDATION REPORT")
if report.demo_mode:
print(f" (DEMO MODE)")
print(f"{'='*60}\n")
if report.user:
print(f" User: {report.user}")
print(f" Method: {report.auth_method}\n")
for r in report.results:
icon = "PASS" if r["status"] == "PASS" else "FAIL"
print(f" [{icon}] {r['service']}: {r['message']}")
print(f"\n {report.summary}")
print(f"\n{'='*60}\n")
if __name__ == "__main__":
main()
@@ -0,0 +1,247 @@
#!/usr/bin/env python3
"""
Google Workspace CLI Doctor — Pre-flight diagnostics for gws CLI.
Checks installation, version, authentication status, and service
connectivity. Runs in demo mode with embedded sample data when gws
is not installed.
Usage:
python3 gws_doctor.py
python3 gws_doctor.py --json
python3 gws_doctor.py --services gmail,drive,calendar
"""
import argparse
import json
import shutil
import subprocess
import sys
from dataclasses import dataclass, field, asdict
from typing import List, Optional
@dataclass
class Check:
name: str
status: str # PASS, WARN, FAIL
message: str
fix: str = ""
@dataclass
class DiagnosticReport:
gws_installed: bool = False
gws_version: str = ""
auth_status: str = ""
checks: List[dict] = field(default_factory=list)
summary: str = ""
demo_mode: bool = False
DEMO_CHECKS = [
Check("gws-installed", "PASS", "gws v0.9.2 found at /usr/local/bin/gws"),
Check("gws-version", "PASS", "Version 0.9.2 (latest)"),
Check("auth-status", "PASS", "Authenticated as admin@company.com"),
Check("token-expiry", "WARN", "Token expires in 23 minutes",
"Run 'gws auth refresh' to extend token lifetime"),
Check("gmail-access", "PASS", "Gmail API accessible — user profile retrieved"),
Check("drive-access", "PASS", "Drive API accessible — root folder listed"),
Check("calendar-access", "PASS", "Calendar API accessible — primary calendar found"),
Check("sheets-access", "PASS", "Sheets API accessible"),
Check("tasks-access", "FAIL", "Tasks API not authorized",
"Run 'gws auth setup' and add 'tasks' scope"),
]
SERVICE_TEST_COMMANDS = {
"gmail": ["gws", "gmail", "users", "getProfile", "--params", '{"userId": "me"}'],
"drive": ["gws", "drive", "files", "list", "--params", '{"pageSize": 1}'],
"calendar": ["gws", "calendar", "calendarList", "list", "--params", '{"maxResults": 1}'],
"sheets": ["gws", "schema", "sheets.spreadsheets.get"],
"tasks": ["gws", "tasks", "tasklists", "list", "--params", '{"maxResults": 1}'],
"chat": ["gws", "chat", "spaces", "list", "--params", '{"pageSize": 1}'],
"docs": ["gws", "schema", "docs.documents.get"],
}
def check_installation() -> Check:
"""Check if gws is installed and on PATH."""
path = shutil.which("gws")
if path:
return Check("gws-installed", "PASS", f"gws found at {path}")
return Check("gws-installed", "FAIL", "gws not found on PATH",
"Install via: npm install -g @googleworkspace/cli OR download from https://github.com/googleworkspace/cli/releases")
def check_version() -> Check:
"""Get gws version."""
try:
result = subprocess.run(
["gws", "--version"], capture_output=True, text=True, timeout=10
)
version = result.stdout.strip()
if version:
return Check("gws-version", "PASS", f"Version: {version}")
return Check("gws-version", "WARN", "Could not parse version output")
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
return Check("gws-version", "FAIL", f"Version check failed: {e}")
def check_auth() -> Check:
"""Check authentication status."""
try:
result = subprocess.run(
["gws", "auth", "status", "--json"],
capture_output=True, text=True, timeout=15
)
if result.returncode == 0:
try:
data = json.loads(result.stdout)
user = data.get("user", data.get("email", "unknown"))
return Check("auth-status", "PASS", f"Authenticated as {user}")
except json.JSONDecodeError:
return Check("auth-status", "PASS", "Authenticated (could not parse details)")
return Check("auth-status", "WARN",
"Could not confirm authentication ('gws auth status' may not exist "
"in your version; check 'gws auth --help')",
"Run 'gws auth setup' then 'gws auth login' to configure authentication")
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
return Check("auth-status", "FAIL", f"Auth check failed: {e}",
"Run 'gws auth setup' then 'gws auth login' to configure authentication")
def check_service(service: str) -> Check:
"""Test connectivity to a specific service."""
cmd = SERVICE_TEST_COMMANDS.get(service)
if not cmd:
return Check(f"{service}-access", "WARN", f"No test command for {service}")
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
if result.returncode == 0:
return Check(f"{service}-access", "PASS", f"{service.title()} API accessible")
stderr = result.stderr.strip()[:100]
if "403" in stderr or "permission" in stderr.lower():
return Check(f"{service}-access", "FAIL",
f"{service.title()} API permission denied",
f"Add '{service}' scope: gws auth setup --scopes {service}")
return Check(f"{service}-access", "FAIL",
f"{service.title()} API error: {stderr}",
f"Check scope and permissions for {service}")
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
return Check(f"{service}-access", "FAIL", f"{service.title()} test failed: {e}")
def run_diagnostics(services: List[str]) -> DiagnosticReport:
"""Run all diagnostic checks."""
report = DiagnosticReport()
checks = []
# Installation check
install_check = check_installation()
checks.append(install_check)
report.gws_installed = install_check.status == "PASS"
if not report.gws_installed:
report.checks = [asdict(c) for c in checks]
report.summary = "FAIL: gws is not installed"
return report
# Version check
version_check = check_version()
checks.append(version_check)
if version_check.status == "PASS":
report.gws_version = version_check.message.replace("Version: ", "")
# Auth check
auth_check = check_auth()
checks.append(auth_check)
report.auth_status = auth_check.status
if auth_check.status != "PASS":
report.checks = [asdict(c) for c in checks]
report.summary = "FAIL: Authentication not configured"
return report
# Service checks
for svc in services:
checks.append(check_service(svc))
report.checks = [asdict(c) for c in checks]
# Summary
fails = sum(1 for c in checks if c.status == "FAIL")
warns = sum(1 for c in checks if c.status == "WARN")
passes = sum(1 for c in checks if c.status == "PASS")
if fails > 0:
report.summary = f"ISSUES FOUND: {passes} passed, {warns} warnings, {fails} failures"
elif warns > 0:
report.summary = f"MOSTLY OK: {passes} passed, {warns} warnings"
else:
report.summary = f"ALL CLEAR: {passes}/{passes} checks passed"
return report
def run_demo() -> DiagnosticReport:
"""Return demo report with embedded sample data."""
report = DiagnosticReport(
gws_installed=True,
gws_version="0.9.2",
auth_status="PASS",
checks=[asdict(c) for c in DEMO_CHECKS],
summary="MOSTLY OK: 7 passed, 1 warning, 1 failure (demo mode)",
demo_mode=True,
)
return report
def main():
parser = argparse.ArgumentParser(
description="Pre-flight diagnostics for Google Workspace CLI (gws)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s # Run all checks
%(prog)s --json # JSON output
%(prog)s --services gmail,drive # Check specific services only
%(prog)s --demo # Demo mode (no gws required)
""",
)
parser.add_argument("--json", action="store_true", help="Output JSON")
parser.add_argument(
"--services", default="gmail,drive,calendar,sheets,tasks",
help="Comma-separated services to check (default: gmail,drive,calendar,sheets,tasks)"
)
parser.add_argument("--demo", action="store_true", help="Run with demo data")
args = parser.parse_args()
services = [s.strip() for s in args.services.split(",") if s.strip()]
# Use demo mode if requested or gws not installed
if args.demo or not shutil.which("gws"):
report = run_demo()
else:
report = run_diagnostics(services)
if args.json:
print(json.dumps(asdict(report), indent=2))
else:
print(f"\n{'='*60}")
print(f" GWS CLI DIAGNOSTIC REPORT")
if report.demo_mode:
print(f" (DEMO MODE — sample data)")
print(f"{'='*60}\n")
for c in report.checks:
icon = {"PASS": "PASS", "WARN": "WARN", "FAIL": "FAIL"}.get(c["status"], "????")
print(f" [{icon}] {c['name']}: {c['message']}")
if c.get("fix") and c["status"] != "PASS":
print(f" -> {c['fix']}")
print(f"\n {'-'*56}")
print(f" {report.summary}")
print(f"\n{'='*60}\n")
if __name__ == "__main__":
main()
@@ -0,0 +1,412 @@
#!/usr/bin/env python3
"""
Google Workspace CLI Recipe Runner — Catalog, search, and execute gws command templates.
Browse 43 recipe command templates (a LOCAL catalog shipped with this skill —
NOT built into the gws CLI), filter by persona, search by keyword, and run
with dry-run support.
IMPORTANT: The gws CLI (github.com/googleworkspace/cli) generates its command
surface dynamically from Google's Discovery Service. Command strings in this
catalog are templates — verify each against `gws --help`, `gws <service> --help`,
or `gws schema <service>.<resource>.<method>` before relying on it.
Usage:
python3 gws_recipe_runner.py --list
python3 gws_recipe_runner.py --search "email"
python3 gws_recipe_runner.py --describe standup-report
python3 gws_recipe_runner.py --run standup-report --dry-run
python3 gws_recipe_runner.py --persona pm --list
python3 gws_recipe_runner.py --list --json
"""
import argparse
import json
import subprocess
import sys
from dataclasses import dataclass, field, asdict
from typing import List, Dict, Optional
TEMPLATE_NOTE = ("NOTE: Recipe commands are templates from this local catalog (not shipped by "
"the gws CLI). Verify each against 'gws --help' / 'gws schema' before use.")
@dataclass
class Recipe:
name: str
description: str
category: str
services: List[str]
commands: List[str]
prerequisites: str = ""
RECIPES: Dict[str, Recipe] = {
# Email (8)
"send-email": Recipe("send-email", "Send an email with optional attachments", "email",
["gmail"], ["gws gmail users.messages send me --to {to} --subject {subject} --body {body}"]),
"reply-to-thread": Recipe("reply-to-thread", "Reply to an existing email thread", "email",
["gmail"], ["gws gmail users.messages reply me --thread-id {thread_id} --body {body}"]),
"forward-email": Recipe("forward-email", "Forward an email to another recipient", "email",
["gmail"], ["gws gmail users.messages forward me --message-id {msg_id} --to {to}"]),
"search-emails": Recipe("search-emails", "Search emails with Gmail query syntax", "email",
["gmail"], ["gws gmail users.messages list me --query {query} --json"]),
"archive-old": Recipe("archive-old", "Archive read emails older than N days", "email",
["gmail"], [
"gws gmail users.messages list me --query 'is:read older_than:{days}d' --json",
"# Pipe IDs to batch modify to remove INBOX label",
]),
"label-manager": Recipe("label-manager", "Create, list, and organize Gmail labels", "email",
["gmail"], ["gws gmail users.labels list me --json", "gws gmail users.labels create me --name {name}"]),
"filter-setup": Recipe("filter-setup", "Create email filters for auto-labeling", "email",
["gmail"], ["gws gmail users.settings.filters create me --criteria {criteria} --action {action}"]),
"unread-digest": Recipe("unread-digest", "Get digest of unread emails", "email",
["gmail"], ["gws gmail users.messages list me --query 'is:unread' --limit 20 --json"]),
# Files (7)
"upload-file": Recipe("upload-file", "Upload a file to Google Drive", "files",
["drive"], ["gws drive files create --name {name} --upload {path} --parents {folder_id}"]),
"create-sheet": Recipe("create-sheet", "Create a new Google Spreadsheet", "files",
["sheets"], ["gws sheets spreadsheets create --title {title} --json"]),
"share-file": Recipe("share-file", "Share a Drive file with a user or domain", "files",
["drive"], ["gws drive permissions create {file_id} --type user --role writer --emailAddress {email}"]),
"export-file": Recipe("export-file", "Export a Google Doc/Sheet as PDF", "files",
["drive"], ["gws drive files export {file_id} --mime application/pdf --output {output}"]),
"list-files": Recipe("list-files", "List files in a Drive folder", "files",
["drive"], ["gws drive files list --parents {folder_id} --json"]),
"find-large-files": Recipe("find-large-files", "Find largest files in Drive", "files",
["drive"], ["gws drive files list --orderBy 'quotaBytesUsed desc' --limit 20 --json"]),
"cleanup-trash": Recipe("cleanup-trash", "Empty Drive trash", "files",
["drive"], ["gws drive files emptyTrash"]),
# Calendar (6)
"create-event": Recipe("create-event", "Create a calendar event with attendees", "calendar",
["calendar"], [
"gws calendar events insert primary --summary {title} "
"--start {start} --end {end} --attendees {attendees}"
]),
"quick-event": Recipe("quick-event", "Create an event via the calendar helper", "calendar",
["calendar"], ["gws calendar +insert {details} # see: gws calendar +insert --help"]),
"find-time": Recipe("find-time", "Find available time slots via free/busy", "calendar",
["calendar"], ["gws calendar freebusy query --json '<freebusy-request>' # verify: gws schema calendar.freebusy.query"]),
"today-schedule": Recipe("today-schedule", "Show today's calendar events", "calendar",
["calendar"], ["gws calendar events list primary --timeMin {today_start} --timeMax {today_end} --json"]),
"meeting-prep": Recipe("meeting-prep", "Prepare for an upcoming meeting (agenda + attendees)", "calendar",
["calendar"], ["gws workflow +meeting-prep"]),
"reschedule": Recipe("reschedule", "Move an event to a new time", "calendar",
["calendar"], ["gws calendar events patch primary {event_id} --start {new_start} --end {new_end}"]),
# Reporting (5)
"standup-report": Recipe("standup-report", "Generate daily standup from calendar and tasks", "reporting",
["calendar", "tasks"], ["gws workflow +standup-report"]),
"weekly-summary": Recipe("weekly-summary", "Summarize week's emails, events, and tasks", "reporting",
["gmail", "calendar", "tasks"], ["gws workflow +weekly-digest"]),
"drive-activity": Recipe("drive-activity", "Report on Drive file activity", "reporting",
["drive"], ["gws drive activities list --json"]),
"email-stats": Recipe("email-stats", "Email volume statistics", "reporting",
["gmail"], [
"gws gmail users.messages list me --query 'newer_than:7d' --json",
"# Pipe through output_analyzer.py --count",
]),
"task-progress": Recipe("task-progress", "Report on task completion", "reporting",
["tasks"], ["gws tasks tasks list {tasklist_id} --json"]),
# Collaboration (5)
"share-folder": Recipe("share-folder", "Share a Drive folder with a team", "collaboration",
["drive"], ["gws drive permissions create {folder_id} --type group --role writer --emailAddress {group}"]),
"create-doc": Recipe("create-doc", "Create a Google Doc with initial content", "collaboration",
["docs"], ["gws docs documents create --title {title} --json"]),
"chat-message": Recipe("chat-message", "Send a message to a Google Chat space", "collaboration",
["chat"], ["gws chat spaces.messages create {space} --text {message}"]),
"list-spaces": Recipe("list-spaces", "List Google Chat spaces", "collaboration",
["chat"], ["gws chat spaces list --json"]),
"task-create": Recipe("task-create", "Create a task in Google Tasks", "collaboration",
["tasks"], ["gws tasks tasks insert {tasklist_id} --title {title} --due {due_date}"]),
# Data (4)
"sheet-read": Recipe("sheet-read", "Read data from a spreadsheet range", "data",
["sheets"], ["gws sheets spreadsheets.values get {sheet_id} --range {range} --json"]),
"sheet-write": Recipe("sheet-write", "Write data to a spreadsheet", "data",
["sheets"], ["gws sheets spreadsheets.values update {sheet_id} --range {range} --values {data}"]),
"sheet-append": Recipe("sheet-append", "Append rows to a spreadsheet", "data",
["sheets"], ["gws sheets spreadsheets.values append {sheet_id} --range {range} --values {data}"]),
"export-contacts": Recipe("export-contacts", "Export contacts list", "data",
["people"], ["gws people people.connections list me --personFields names,emailAddresses --json"]),
# Admin (4)
"list-users": Recipe("list-users", "List all users in the Workspace domain", "admin",
["admin"], ["gws admin users list --domain {domain} --json"],
"Requires Admin SDK API and admin.directory.user.readonly scope"),
"list-groups": Recipe("list-groups", "List all groups in the domain", "admin",
["admin"], ["gws admin groups list --domain {domain} --json"]),
"user-info": Recipe("user-info", "Get detailed user information", "admin",
["admin"], ["gws admin users get {email} --json"]),
"audit-logins": Recipe("audit-logins", "Audit recent login activity", "admin",
["admin"], ["gws admin activities list login --json"]),
# Cross-Service (4)
"morning-briefing": Recipe("morning-briefing", "Today's events + unread emails + pending tasks", "cross-service",
["gmail", "calendar", "tasks"], [
"gws calendar events list primary --timeMin {today} --maxResults 10 --json",
"gws gmail users.messages list me --query 'is:unread' --limit 10 --json",
"gws tasks tasks list {default_tasklist} --json",
]),
"eod-wrap": Recipe("eod-wrap", "End-of-day wrap up: summarize completed, pending, tomorrow", "cross-service",
["calendar", "tasks"], [
"gws calendar events list primary --timeMin {today_start} --timeMax {today_end} --json",
"gws tasks tasks list {default_tasklist} --json",
]),
"project-status": Recipe("project-status", "Aggregate project status from Drive, Sheets, Tasks", "cross-service",
["drive", "sheets", "tasks"], [
"gws drive files list --query 'name contains {project}' --json",
"gws tasks tasks list {tasklist_id} --json",
]),
"inbox-zero": Recipe("inbox-zero", "Process inbox to zero: label, archive, reply, task", "cross-service",
["gmail", "tasks"], [
"gws gmail users.messages list me --query 'is:inbox' --json",
"# Process each: label, archive, or create task",
]),
}
PERSONAS: Dict[str, Dict] = {
"executive-assistant": {
"description": "Executive assistant managing schedules, emails, and communications",
"recipes": ["morning-briefing", "today-schedule", "find-time", "send-email", "reply-to-thread",
"standup-report", "meeting-prep", "eod-wrap", "quick-event", "inbox-zero"],
},
"pm": {
"description": "Project manager tracking tasks, meetings, and deliverables",
"recipes": ["standup-report", "create-event", "find-time", "task-create", "task-progress",
"project-status", "weekly-summary", "share-folder", "sheet-read", "morning-briefing"],
},
"hr": {
"description": "HR managing people, onboarding, and communications",
"recipes": ["list-users", "user-info", "send-email", "create-event", "create-doc",
"share-folder", "chat-message", "list-groups", "export-contacts", "today-schedule"],
},
"sales": {
"description": "Sales rep managing client communications and proposals",
"recipes": ["send-email", "search-emails", "create-event", "find-time", "create-doc",
"share-file", "sheet-read", "sheet-write", "export-file", "morning-briefing"],
},
"it-admin": {
"description": "IT administrator managing Workspace configuration and security",
"recipes": ["list-users", "list-groups", "user-info", "audit-logins", "drive-activity",
"find-large-files", "cleanup-trash", "label-manager", "filter-setup", "share-folder"],
},
"developer": {
"description": "Developer using Workspace APIs for automation",
"recipes": ["sheet-read", "sheet-write", "sheet-append", "upload-file", "create-doc",
"chat-message", "task-create", "list-files", "export-file", "send-email"],
},
"marketing": {
"description": "Marketing team member managing campaigns and content",
"recipes": ["send-email", "create-doc", "share-file", "upload-file", "create-sheet",
"sheet-write", "chat-message", "create-event", "email-stats", "weekly-summary"],
},
"finance": {
"description": "Finance team managing spreadsheets and reports",
"recipes": ["sheet-read", "sheet-write", "sheet-append", "create-sheet", "export-file",
"share-file", "send-email", "find-large-files", "drive-activity", "weekly-summary"],
},
"legal": {
"description": "Legal team managing documents and compliance",
"recipes": ["create-doc", "share-file", "export-file", "search-emails", "send-email",
"upload-file", "list-files", "drive-activity", "audit-logins", "find-large-files"],
},
"support": {
"description": "Customer support managing tickets and communications",
"recipes": ["search-emails", "send-email", "reply-to-thread", "label-manager", "filter-setup",
"task-create", "chat-message", "unread-digest", "inbox-zero", "morning-briefing"],
},
}
def list_recipes(persona: Optional[str], output_json: bool):
"""List all recipes, optionally filtered by persona."""
if persona:
if persona not in PERSONAS:
print(f"Unknown persona: {persona}. Available: {', '.join(PERSONAS.keys())}")
sys.exit(1)
recipe_names = PERSONAS[persona]["recipes"]
recipes = {k: v for k, v in RECIPES.items() if k in recipe_names}
title = f"Recipes for {persona.upper()}: {PERSONAS[persona]['description']}"
else:
recipes = RECIPES
title = "All 43 Google Workspace CLI Recipes"
if output_json:
output = []
for name, r in recipes.items():
output.append(asdict(r))
print(json.dumps(output, indent=2))
return
print(f"\n{'='*60}")
print(f" {title}")
print(f"{'='*60}\n")
by_category: Dict[str, list] = {}
for name, r in recipes.items():
by_category.setdefault(r.category, []).append(r)
for cat, cat_recipes in sorted(by_category.items()):
print(f" {cat.upper()} ({len(cat_recipes)})")
for r in cat_recipes:
svcs = ",".join(r.services)
print(f" {r.name:<24} {r.description:<40} [{svcs}]")
print()
print(f" Total: {len(recipes)} recipes")
print(f"\n{'='*60}\n")
def search_recipes(keyword: str, output_json: bool):
"""Search recipes by keyword."""
keyword_lower = keyword.lower()
matches = {k: v for k, v in RECIPES.items()
if keyword_lower in k.lower()
or keyword_lower in v.description.lower()
or keyword_lower in v.category.lower()
or any(keyword_lower in s for s in v.services)}
if output_json:
print(json.dumps([asdict(r) for r in matches.values()], indent=2))
return
print(f"\n Search results for '{keyword}': {len(matches)} matches\n")
for name, r in matches.items():
print(f" {r.name:<24} {r.description}")
print()
def describe_recipe(name: str, output_json: bool):
"""Show full details for a recipe."""
recipe = RECIPES.get(name)
if not recipe:
print(f"Unknown recipe: {name}")
print(f"Use --list to see available recipes")
sys.exit(1)
if output_json:
print(json.dumps(asdict(recipe), indent=2))
return
print(f"\n{'='*60}")
print(f" Recipe: {recipe.name}")
print(f"{'='*60}\n")
print(f" Description: {recipe.description}")
print(f" Category: {recipe.category}")
print(f" Services: {', '.join(recipe.services)}")
if recipe.prerequisites:
print(f" Prerequisites: {recipe.prerequisites}")
print(f"\n Commands:")
for i, cmd in enumerate(recipe.commands, 1):
print(f" {i}. {cmd}")
print(f"\n {TEMPLATE_NOTE}")
print(f"\n{'='*60}\n")
def run_recipe(name: str, dry_run: bool):
"""Execute a recipe (or print commands in dry-run mode)."""
recipe = RECIPES.get(name)
if not recipe:
print(f"Unknown recipe: {name}")
sys.exit(1)
if dry_run:
print(f"\n [DRY RUN] Recipe: {recipe.name}\n")
for i, cmd in enumerate(recipe.commands, 1):
print(f" {i}. {cmd}")
print(f"\n {TEMPLATE_NOTE}")
print(f"\n (No commands executed)")
return
print(f"\n Executing recipe: {recipe.name}")
print(f" {TEMPLATE_NOTE}\n")
for cmd in recipe.commands:
if cmd.startswith("#"):
print(f" {cmd}")
continue
print(f" $ {cmd}")
try:
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30)
if result.stdout:
print(result.stdout)
if result.returncode != 0 and result.stderr:
print(f" Error: {result.stderr.strip()[:200]}")
except subprocess.TimeoutExpired:
print(f" Timeout after 30s")
except OSError as e:
print(f" Execution error: {e}")
def list_personas(output_json: bool):
"""List all available personas."""
if output_json:
print(json.dumps(PERSONAS, indent=2))
return
print(f"\n{'='*60}")
print(f" 10 PERSONA BUNDLES")
print(f"{'='*60}\n")
for name, p in PERSONAS.items():
print(f" {name:<24} {p['description']}")
print(f" {'':24} Recipes: {', '.join(p['recipes'][:5])}...")
print()
print(f"{'='*60}\n")
def main():
parser = argparse.ArgumentParser(
description="Catalog, search, and execute Google Workspace CLI recipes",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s --list # List all 43 recipes
%(prog)s --list --persona pm # Recipes for project managers
%(prog)s --search "email" # Search by keyword
%(prog)s --describe standup-report # Full recipe details
%(prog)s --run standup-report --dry-run # Preview recipe commands
%(prog)s --personas # List all 10 personas
%(prog)s --list --json # JSON output
""",
)
parser.add_argument("--list", action="store_true", help="List all recipes")
parser.add_argument("--search", help="Search recipes by keyword")
parser.add_argument("--describe", help="Show full details for a recipe")
parser.add_argument("--run", help="Execute a recipe")
parser.add_argument("--dry-run", action="store_true", help="Print commands without executing")
parser.add_argument("--persona", help="Filter recipes by persona")
parser.add_argument("--personas", action="store_true", help="List all personas")
parser.add_argument("--json", action="store_true", help="Output JSON")
args = parser.parse_args()
if not any([args.list, args.search, args.describe, args.run, args.personas]):
parser.print_help()
return
if args.personas:
list_personas(args.json)
return
if args.list:
list_recipes(args.persona, args.json)
return
if args.search:
search_recipes(args.search, args.json)
return
if args.describe:
describe_recipe(args.describe, args.json)
return
if args.run:
run_recipe(args.run, args.dry_run)
return
if __name__ == "__main__":
main()
@@ -0,0 +1,332 @@
#!/usr/bin/env python3
"""
Google Workspace CLI Output Analyzer — Parse, filter, and aggregate JSON/NDJSON output.
Reads JSON arrays or NDJSON streams from stdin or file, applies filters,
projections, sorting, grouping, and outputs in table/csv/json format.
Usage:
gws drive files list | python3 output_analyzer.py --count
gws drive files list | python3 output_analyzer.py --filter "mimeType=application/pdf"
gws drive files list | python3 output_analyzer.py --select "name,size" --format table
python3 output_analyzer.py --input results.json --group-by "mimeType"
python3 output_analyzer.py --demo --select "name,mimeType,size" --format table
"""
import argparse
import csv
import io
import json
import sys
from dataclasses import dataclass
from typing import List, Dict, Any, Optional
DEMO_DATA = [
{"id": "1", "name": "Q1 Report.pdf", "mimeType": "application/pdf", "size": "245760",
"modifiedTime": "2026-03-10T14:30:00Z", "shared": True, "owners": [{"displayName": "Alice"}]},
{"id": "2", "name": "Budget 2026.xlsx", "mimeType": "application/vnd.google-apps.spreadsheet",
"size": "0", "modifiedTime": "2026-03-09T09:15:00Z", "shared": True,
"owners": [{"displayName": "Bob"}]},
{"id": "3", "name": "Meeting Notes.docx", "mimeType": "application/vnd.google-apps.document",
"size": "0", "modifiedTime": "2026-03-08T16:00:00Z", "shared": False,
"owners": [{"displayName": "Alice"}]},
{"id": "4", "name": "Logo.png", "mimeType": "image/png", "size": "102400",
"modifiedTime": "2026-03-07T11:00:00Z", "shared": False,
"owners": [{"displayName": "Charlie"}]},
{"id": "5", "name": "Presentation.pptx", "mimeType": "application/vnd.google-apps.presentation",
"size": "0", "modifiedTime": "2026-03-06T10:00:00Z", "shared": True,
"owners": [{"displayName": "Alice"}]},
{"id": "6", "name": "Invoice-001.pdf", "mimeType": "application/pdf", "size": "89000",
"modifiedTime": "2026-03-05T08:30:00Z", "shared": False,
"owners": [{"displayName": "Bob"}]},
{"id": "7", "name": "Project Plan.xlsx", "mimeType": "application/vnd.google-apps.spreadsheet",
"size": "0", "modifiedTime": "2026-03-04T13:45:00Z", "shared": True,
"owners": [{"displayName": "Charlie"}]},
{"id": "8", "name": "Contract Draft.docx", "mimeType": "application/vnd.google-apps.document",
"size": "0", "modifiedTime": "2026-03-03T09:00:00Z", "shared": False,
"owners": [{"displayName": "Alice"}]},
]
def read_input(input_file: Optional[str]) -> List[Dict[str, Any]]:
"""Read JSON array or NDJSON from file or stdin."""
if input_file:
with open(input_file, "r") as f:
text = f.read().strip()
else:
if sys.stdin.isatty():
return []
text = sys.stdin.read().strip()
if not text:
return []
# Try JSON array first
try:
data = json.loads(text)
if isinstance(data, list):
return data
if isinstance(data, dict):
# Some gws commands wrap results in a key
for key in ("files", "messages", "events", "items", "results",
"spreadsheets", "spaces", "tasks", "users", "groups"):
if key in data and isinstance(data[key], list):
return data[key]
return [data]
except json.JSONDecodeError:
pass
# Try NDJSON
records = []
for line in text.split("\n"):
line = line.strip()
if line:
try:
records.append(json.loads(line))
except json.JSONDecodeError:
continue
return records
def get_nested(obj: Dict, path: str) -> Any:
"""Get a nested value by dot-separated path."""
parts = path.split(".")
current = obj
for part in parts:
if isinstance(current, dict):
current = current.get(part)
elif isinstance(current, list) and part.isdigit():
idx = int(part)
current = current[idx] if idx < len(current) else None
else:
return None
if current is None:
return None
return current
def apply_filter(records: List[Dict], filter_expr: str) -> List[Dict]:
"""Filter records by field=value expression."""
if "=" not in filter_expr:
return records
field_path, value = filter_expr.split("=", 1)
result = []
for rec in records:
rec_val = get_nested(rec, field_path)
if rec_val is None:
continue
rec_str = str(rec_val).lower()
if rec_str == value.lower() or value.lower() in rec_str:
result.append(rec)
return result
def apply_select(records: List[Dict], fields: str) -> List[Dict]:
"""Project specific fields from records."""
field_list = [f.strip() for f in fields.split(",")]
result = []
for rec in records:
projected = {}
for f in field_list:
projected[f] = get_nested(rec, f)
result.append(projected)
return result
def apply_sort(records: List[Dict], sort_field: str, reverse: bool = False) -> List[Dict]:
"""Sort records by a field."""
def sort_key(rec):
val = get_nested(rec, sort_field)
if val is None:
return ""
if isinstance(val, (int, float)):
return val
try:
return float(val)
except (ValueError, TypeError):
return str(val).lower()
return sorted(records, key=sort_key, reverse=reverse)
def apply_group_by(records: List[Dict], field: str) -> Dict[str, int]:
"""Group records by a field and count."""
groups: Dict[str, int] = {}
for rec in records:
val = get_nested(rec, field)
key = str(val) if val is not None else "(null)"
groups[key] = groups.get(key, 0) + 1
return dict(sorted(groups.items(), key=lambda x: x[1], reverse=True))
def compute_stats(records: List[Dict], field: str) -> Dict[str, Any]:
"""Compute min/max/avg/sum for a numeric field."""
values = []
for rec in records:
val = get_nested(rec, field)
if val is not None:
try:
values.append(float(val))
except (ValueError, TypeError):
continue
if not values:
return {"field": field, "count": 0, "error": "No numeric values found"}
return {
"field": field,
"count": len(values),
"min": min(values),
"max": max(values),
"sum": sum(values),
"avg": sum(values) / len(values),
}
def format_table(records: List[Dict]) -> str:
"""Format records as an aligned text table."""
if not records:
return "(no records)"
headers = list(records[0].keys())
# Calculate column widths
widths = {h: len(h) for h in headers}
for rec in records:
for h in headers:
val = str(rec.get(h, ""))
if len(val) > 60:
val = val[:57] + "..."
widths[h] = max(widths[h], len(val))
# Header
header_line = " ".join(h.ljust(widths[h]) for h in headers)
sep_line = " ".join("-" * widths[h] for h in headers)
lines = [header_line, sep_line]
# Rows
for rec in records:
row = []
for h in headers:
val = str(rec.get(h, ""))
if len(val) > 60:
val = val[:57] + "..."
row.append(val.ljust(widths[h]))
lines.append(" ".join(row))
return "\n".join(lines)
def format_csv_output(records: List[Dict]) -> str:
"""Format records as CSV."""
if not records:
return ""
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=records[0].keys())
writer.writeheader()
writer.writerows(records)
return output.getvalue()
def main():
parser = argparse.ArgumentParser(
description="Parse, filter, and aggregate JSON/NDJSON from gws CLI output",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
gws drive files list | %(prog)s --count
gws drive files list | %(prog)s --filter "mimeType=pdf" --select "name,size"
gws drive files list | %(prog)s --group-by "mimeType" --format table
gws drive files list | %(prog)s --sort "size" --reverse --format table
gws drive files list | %(prog)s --stats "size"
%(prog)s --input results.json --select "name,mimeType" --format csv
%(prog)s --demo --select "name,mimeType,size" --format table
""",
)
parser.add_argument("--input", help="Input file (default: stdin)")
parser.add_argument("--demo", action="store_true", help="Use demo data")
parser.add_argument("--count", action="store_true", help="Count records")
parser.add_argument("--filter", help="Filter by field=value")
parser.add_argument("--select", help="Comma-separated fields to project")
parser.add_argument("--sort", help="Sort by field")
parser.add_argument("--reverse", action="store_true", help="Reverse sort order")
parser.add_argument("--group-by", help="Group by field and count")
parser.add_argument("--stats", help="Compute stats for a numeric field")
parser.add_argument("--format", choices=["json", "table", "csv"], default="json",
help="Output format (default: json)")
parser.add_argument("--json", action="store_true",
help="Shorthand for --format json")
args = parser.parse_args()
if args.json:
args.format = "json"
# Read input
if args.demo:
records = DEMO_DATA[:]
else:
records = read_input(args.input)
if not records and not args.demo:
# If no pipe input and no file, use demo
records = DEMO_DATA[:]
print("(No input detected, using demo data)\n", file=sys.stderr)
# Apply operations in order
if args.filter:
records = apply_filter(records, args.filter)
if args.sort:
records = apply_sort(records, args.sort, args.reverse)
# Count
if args.count:
if args.format == "json":
print(json.dumps({"count": len(records)}))
else:
print(f"Count: {len(records)}")
return
# Group by
if args.group_by:
groups = apply_group_by(records, args.group_by)
if args.format == "json":
print(json.dumps(groups, indent=2))
elif args.format == "csv":
print(f"{args.group_by},count")
for k, v in groups.items():
print(f"{k},{v}")
else:
print(f"\n Group by: {args.group_by}\n")
for k, v in groups.items():
print(f" {k:<50} {v}")
print(f"\n Total groups: {len(groups)}")
return
# Stats
if args.stats:
stats = compute_stats(records, args.stats)
if args.format == "json":
print(json.dumps(stats, indent=2))
else:
print(f"\n Stats for '{args.stats}':")
for k, v in stats.items():
if isinstance(v, float):
print(f" {k}: {v:,.2f}")
else:
print(f" {k}: {v}")
return
# Select fields
if args.select:
records = apply_select(records, args.select)
# Output
if args.format == "json":
print(json.dumps(records, indent=2))
elif args.format == "csv":
print(format_csv_output(records))
else:
print(f"\n{format_table(records)}\n")
print(f" ({len(records)} records)\n")
if __name__ == "__main__":
main()
@@ -0,0 +1,310 @@
#!/usr/bin/env python3
"""
Google Workspace Security Audit — Audit Workspace configuration for security risks.
Checks Drive external sharing, Gmail forwarding rules, OAuth app grants,
Calendar visibility, admin settings, and generates remediation commands.
Runs in demo mode with embedded sample data when gws is not installed.
Usage:
python3 workspace_audit.py
python3 workspace_audit.py --json
python3 workspace_audit.py --services gmail,drive,calendar
python3 workspace_audit.py --demo
"""
import argparse
import json
import shutil
import subprocess
import sys
from dataclasses import dataclass, field, asdict
from typing import List, Dict, Optional
@dataclass
class AuditFinding:
area: str
check: str
status: str # PASS, WARN, FAIL
message: str
risk: str = ""
remediation: str = ""
@dataclass
class AuditReport:
findings: List[dict] = field(default_factory=list)
score: int = 0
max_score: int = 100
grade: str = ""
summary: str = ""
demo_mode: bool = False
DEMO_FINDINGS = [
AuditFinding("drive", "External sharing", "WARN",
"External sharing is enabled for the domain",
"Data exfiltration via shared links",
"Review sharing settings in Admin Console > Apps > Google Workspace > Drive"),
AuditFinding("drive", "Link sharing defaults", "FAIL",
"Default link sharing is set to 'Anyone with the link'",
"Sensitive files accessible without authentication",
"Restrict default link sharing: Admin Console > Apps > Google Workspace > Drive > Sharing settings"),
AuditFinding("gmail", "Auto-forwarding", "PASS",
"No auto-forwarding rules detected for admin accounts"),
AuditFinding("gmail", "SPF record", "PASS",
"SPF record configured correctly"),
AuditFinding("gmail", "DMARC record", "WARN",
"DMARC policy is set to 'none' (monitoring only)",
"Email spoofing not actively blocked",
"Update DMARC DNS record: v=DMARC1; p=quarantine; rua=mailto:dmarc@company.com"),
AuditFinding("gmail", "DKIM signing", "PASS",
"DKIM signing is enabled"),
AuditFinding("calendar", "Default visibility", "WARN",
"Calendar default visibility is 'See all event details'",
"Meeting details visible to all domain users",
"Admin Console > Apps > Calendar > Sharing settings > Set to 'Free/Busy'"),
AuditFinding("calendar", "External sharing", "PASS",
"External calendar sharing is restricted"),
AuditFinding("oauth", "Third-party apps", "FAIL",
"12 third-party OAuth apps with broad access detected",
"Unauthorized data access via OAuth grants",
"Review: Admin Console > Security > API controls > App access control"),
AuditFinding("oauth", "High-risk apps", "WARN",
"3 apps have Drive full access scope",
"Apps can read/modify all Drive files",
"Audit each app via the Directory API tokens resource (verify: gws schema admin.tokens.list)"),
AuditFinding("admin", "Super admin count", "WARN",
"4 super admin accounts detected (recommended: 2-3)",
"Increased attack surface for privilege escalation",
"Reduce super admins: list them via the Directory API (verify: gws schema admin.users.list)"),
AuditFinding("admin", "2-Step verification", "PASS",
"2-Step verification enforced for all users"),
AuditFinding("admin", "Password policy", "PASS",
"Minimum password length: 12 characters"),
AuditFinding("admin", "Login challenges", "PASS",
"Suspicious login challenges enabled"),
]
def run_gws_command(cmd: List[str]) -> Optional[str]:
"""Run a gws command and return stdout, or None on failure."""
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
if result.returncode == 0:
return result.stdout
return None
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return None
def audit_drive() -> List[AuditFinding]:
"""Audit Drive sharing and security settings."""
findings = []
# Check sharing settings
output = run_gws_command(["gws", "drive", "about", "get", "--params", '{"fields": "*"}'])
if output:
try:
data = json.loads(output)
# Check if external sharing is enabled
if data.get("canShareOutsideDomain", True):
findings.append(AuditFinding(
"drive", "External sharing", "WARN",
"External sharing is enabled",
"Data exfiltration via shared links",
"Review Admin Console > Apps > Drive > Sharing settings"
))
else:
findings.append(AuditFinding(
"drive", "External sharing", "PASS",
"External sharing is restricted"
))
except json.JSONDecodeError:
findings.append(AuditFinding(
"drive", "External sharing", "WARN",
"Could not parse Drive settings"
))
else:
findings.append(AuditFinding(
"drive", "External sharing", "WARN",
"Could not retrieve Drive settings"
))
return findings
def audit_gmail() -> List[AuditFinding]:
"""Audit Gmail forwarding and email security."""
findings = []
# Check forwarding rules
output = run_gws_command(["gws", "gmail", "users", "settings", "forwardingAddresses", "list",
"--params", '{"userId": "me"}'])
if output:
try:
data = json.loads(output)
addrs = data if isinstance(data, list) else data.get("forwardingAddresses", [])
if addrs:
findings.append(AuditFinding(
"gmail", "Auto-forwarding", "WARN",
f"{len(addrs)} forwarding addresses configured",
"Data exfiltration via email forwarding",
"Review forwarding addresses (verify: gws schema gmail.users.settings.forwardingAddresses.list)"
))
else:
findings.append(AuditFinding(
"gmail", "Auto-forwarding", "PASS",
"No forwarding addresses configured"
))
except json.JSONDecodeError:
pass
else:
findings.append(AuditFinding(
"gmail", "Auto-forwarding", "WARN",
"Could not check forwarding settings"
))
return findings
def audit_calendar() -> List[AuditFinding]:
"""Audit Calendar sharing settings."""
findings = []
output = run_gws_command(["gws", "calendar", "calendarList", "get", "--params", '{"calendarId": "primary"}'])
if output:
findings.append(AuditFinding(
"calendar", "Primary calendar", "PASS",
"Primary calendar accessible"
))
else:
findings.append(AuditFinding(
"calendar", "Primary calendar", "WARN",
"Could not access primary calendar"
))
return findings
def run_live_audit(services: List[str]) -> AuditReport:
"""Run live audit against actual gws installation."""
report = AuditReport()
all_findings = []
audit_map = {
"drive": audit_drive,
"gmail": audit_gmail,
"calendar": audit_calendar,
}
for svc in services:
fn = audit_map.get(svc)
if fn:
all_findings.extend(fn())
report.findings = [asdict(f) for f in all_findings]
report = calculate_score(report)
return report
def run_demo_audit() -> AuditReport:
"""Return demo audit report with embedded sample data."""
report = AuditReport(
findings=[asdict(f) for f in DEMO_FINDINGS],
demo_mode=True,
)
report = calculate_score(report)
return report
def calculate_score(report: AuditReport) -> AuditReport:
"""Calculate audit score and grade."""
total = len(report.findings)
if total == 0:
report.score = 0
report.grade = "N/A"
report.summary = "No checks performed"
return report
passes = sum(1 for f in report.findings if f["status"] == "PASS")
warns = sum(1 for f in report.findings if f["status"] == "WARN")
fails = sum(1 for f in report.findings if f["status"] == "FAIL")
# Score: PASS=100, WARN=50, FAIL=0
score = int(((passes * 100) + (warns * 50)) / total)
report.score = score
report.max_score = 100
if score >= 90:
report.grade = "A"
elif score >= 75:
report.grade = "B"
elif score >= 60:
report.grade = "C"
elif score >= 40:
report.grade = "D"
else:
report.grade = "F"
report.summary = f"{passes} passed, {warns} warnings, {fails} failures — Score: {score}/100 (Grade: {report.grade})"
return report
def main():
parser = argparse.ArgumentParser(
description="Security and configuration audit for Google Workspace",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s # Full audit (or demo if gws not installed)
%(prog)s --json # JSON output
%(prog)s --services gmail,drive # Audit specific services
%(prog)s --demo # Demo mode with sample data
""",
)
parser.add_argument("--json", action="store_true", help="Output JSON")
parser.add_argument("--services", default="gmail,drive,calendar",
help="Comma-separated services to audit (default: gmail,drive,calendar)")
parser.add_argument("--demo", action="store_true", help="Run with demo data")
args = parser.parse_args()
services = [s.strip() for s in args.services.split(",") if s.strip()]
if args.demo or not shutil.which("gws"):
report = run_demo_audit()
else:
report = run_live_audit(services)
if args.json:
print(json.dumps(asdict(report), indent=2))
else:
print(f"\n{'='*60}")
print(f" GOOGLE WORKSPACE SECURITY AUDIT")
if report.demo_mode:
print(f" (DEMO MODE — sample data)")
print(f"{'='*60}\n")
print(f" Score: {report.score}/{report.max_score} (Grade: {report.grade})\n")
current_area = ""
for f in report.findings:
if f["area"] != current_area:
current_area = f["area"]
print(f"\n {current_area.upper()}")
print(f" {'-'*40}")
icon = {"PASS": "PASS", "WARN": "WARN", "FAIL": "FAIL"}.get(f["status"], "????")
print(f" [{icon}] {f['check']}: {f['message']}")
if f.get("risk") and f["status"] != "PASS":
print(f" Risk: {f['risk']}")
if f.get("remediation") and f["status"] != "PASS":
print(f" Fix: {f['remediation']}")
print(f"\n {'='*56}")
print(f" {report.summary}")
print(f"\n{'='*60}\n")
if __name__ == "__main__":
main()
@@ -0,0 +1,15 @@
{
"name": "pw",
"description": "Production-grade Playwright testing toolkit. Generate tests from specs, fix flaky failures, migrate from Cypress/Selenium, sync with TestRail, run on BrowserStack. 55+ ready-to-use templates, 3 specialized agents, smart reporting that plugs into your existing workflow.",
"version": "2.9.0",
"author": {
"name": "Alireza Rezvani",
"url": "https://alirezarezvani.com"
},
"homepage": "https://github.com/alirezarezvani/claude-skills/tree/main/engineering-team/playwright-pro",
"repository": "https://github.com/alirezarezvani/claude-skills",
"license": "MIT",
"skills": [
"./skills"
]
}
+27
View File
@@ -0,0 +1,27 @@
{
"mcpServers": {
"pw-testrail": {
"command": "npx",
"args": [
"tsx",
"${CLAUDE_PLUGIN_ROOT}/integrations/testrail-mcp/src/index.ts"
],
"env": {
"TESTRAIL_URL": "${TESTRAIL_URL}",
"TESTRAIL_USER": "${TESTRAIL_USER}",
"TESTRAIL_API_KEY": "${TESTRAIL_API_KEY}"
}
},
"pw-browserstack": {
"command": "npx",
"args": [
"tsx",
"${CLAUDE_PLUGIN_ROOT}/integrations/browserstack-mcp/src/index.ts"
],
"env": {
"BROWSERSTACK_USERNAME": "${BROWSERSTACK_USERNAME}",
"BROWSERSTACK_ACCESS_KEY": "${BROWSERSTACK_ACCESS_KEY}"
}
}
}
}
+84
View File
@@ -0,0 +1,84 @@
# Playwright Pro — Agent Context
You are working in a project with the Playwright Pro plugin installed. Follow these rules for all test-related work.
## Golden Rules (Non-Negotiable)
1. **`getByRole()` over CSS/XPath** — resilient to markup changes, mirrors how users see the page
2. **Never `page.waitForTimeout()`** — use `expect(locator).toBeVisible()` or `page.waitForURL()`
3. **Web-first assertions**`expect(locator)` auto-retries; `expect(await locator.textContent())` does not
4. **Isolate every test** — no shared state, no execution-order dependencies
5. **`baseURL` in config** — zero hardcoded URLs in tests
6. **Retries: `2` in CI, `0` locally** — surface flakiness where it matters
7. **Traces: `'on-first-retry'`** — rich debugging without CI slowdown
8. **Fixtures over globals** — share state via `test.extend()`, not module-level variables
9. **One behavior per test** — multiple related `expect()` calls are fine
10. **Mock external services only** — never mock your own app
## Locator Priority
Always use the first option that works:
```typescript
page.getByRole('button', { name: 'Submit' }) // 1. Role (default)
page.getByLabel('Email address') // 2. Label (form fields)
page.getByText('Welcome back') // 3. Text (non-interactive)
page.getByPlaceholder('Search...') // 4. Placeholder
page.getByAltText('Company logo') // 5. Alt text (images)
page.getByTitle('Close dialog') // 6. Title attribute
page.getByTestId('checkout-summary') // 7. Test ID (last semantic)
page.locator('.legacy-widget') // 8. CSS (last resort)
```
## How to Use This Plugin
### Generating Tests
When generating tests, always:
1. Use the `Explore` subagent to scan the project structure first
2. Check `playwright.config.ts` for `testDir`, `baseURL`, and project settings
3. Load relevant templates from `templates/` directory
4. Match the project's language (check for `tsconfig.json` → TypeScript, else JavaScript)
5. Place tests in the configured `testDir` (default: `tests/` or `e2e/`)
6. Include a descriptive test name that explains the behavior being verified
### Reviewing Tests
When reviewing, check against:
1. All 10 golden rules above
2. The anti-patterns in `skills/review/anti-patterns.md`
3. Missing edge cases (empty state, error state, loading state)
4. Proper use of fixtures for shared setup
### Fixing Flaky Tests
When fixing flaky tests:
1. Categorize first: timing, isolation, environment, or infrastructure
2. Use `npx playwright test <file> --repeat-each=10` to reproduce
3. Use `--trace=on` for every attempt
4. Apply the targeted fix from `skills/fix/flaky-taxonomy.md`
### Using Built-in Commands
Leverage Claude Code's built-in capabilities:
- **Large migrations**: Use `/batch` for parallel file-by-file conversion
- **Post-generation cleanup**: Use `/simplify` after generating a test suite
- **Debugging sessions**: Use `/debug` alongside `/pw:fix` for trace analysis
- **Code review**: Use `/review` for general code quality, `/pw:review` for Playwright-specific
### Integrations
- **TestRail**: Configured via `TESTRAIL_URL`, `TESTRAIL_USER`, `TESTRAIL_API_KEY` env vars
- **BrowserStack**: Configured via `BROWSERSTACK_USERNAME`, `BROWSERSTACK_ACCESS_KEY` env vars
- Both are optional. The plugin works fully without them.
## File Conventions
- Test files: `*.spec.ts` or `*.spec.js`
- Page objects: `*.page.ts` in a `pages/` directory
- Fixtures: `fixtures.ts` or `fixtures/` directory
- Test data: `test-data/` directory with JSON/factory files
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Reza Rezvani
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+133
View File
@@ -0,0 +1,133 @@
# Playwright Pro
> Production-grade Playwright testing toolkit for AI coding agents.
Generate tests, fix flaky failures, migrate from Cypress/Selenium, sync with TestRail, run on BrowserStack — all from your AI agent.
## Install
```bash
# Claude Code plugin
claude plugin install pw@claude-skills
# Or load directly
claude --plugin-dir ./engineering-team/playwright-pro
```
## Commands
| Command | What it does |
|---|---|
| `/pw:init` | Set up Playwright in your project — detects framework, generates config, CI, first test |
| `/pw:generate <spec>` | Generate tests from a user story, URL, or component name |
| `/pw:review` | Review existing tests for anti-patterns and coverage gaps |
| `/pw:fix <test>` | Diagnose and fix a failing or flaky test |
| `/pw:migrate` | Migrate from Cypress or Selenium to Playwright |
| `/pw:coverage` | Analyze what's tested vs. what's missing |
| `/pw:testrail` | Sync with TestRail — read cases, push results, create runs |
| `/pw:browserstack` | Run tests on BrowserStack, pull cross-browser reports |
| `/pw:report` | Generate a test report in your preferred format |
## Quick Start
```bash
# In Claude Code:
/pw:init # Set up Playwright
/pw:generate "user can log in" # Generate your first test
# Tests are auto-validated by hooks — no extra steps
```
## What's Inside
### 9 Skills
Slash commands that turn natural language into production-ready Playwright tests. Each skill leverages Claude Code's built-in capabilities (`/batch` for parallel work, `Explore` for codebase analysis, `/debug` for trace inspection).
### 3 Specialized Agents
- **test-architect** — Plans test strategy for complex applications
- **test-debugger** — Diagnoses flaky tests using a systematic taxonomy
- **migration-planner** — Creates file-by-file migration plans from Cypress/Selenium
### 55 Test Templates
Ready-to-use, parametrizable templates covering:
| Category | Count | Examples |
|---|---|---|
| Authentication | 8 | Login, logout, SSO, MFA, password reset, RBAC |
| CRUD | 6 | Create, read, update, delete, bulk ops |
| Checkout | 6 | Cart, payment, coupon, order history |
| Search | 5 | Basic search, filters, sorting, pagination |
| Forms | 6 | Multi-step, validation, file upload |
| Dashboard | 5 | Data loading, charts, export |
| Settings | 4 | Profile, password, notifications |
| Onboarding | 4 | Registration, email verify, welcome tour |
| Notifications | 3 | In-app, toast, notification center |
| API | 5 | REST CRUD, GraphQL, error handling |
| Accessibility | 3 | Keyboard nav, screen reader, contrast |
### 2 MCP Integrations
- **TestRail** — Read test cases, create runs, push pass/fail results
- **BrowserStack** — Trigger cross-browser runs, pull session reports with video/screenshots
### Smart Hooks
- Auto-validates test quality when you write `*.spec.ts` files
- Auto-detects Playwright projects on session start
- Zero configuration required
## Integrations Setup
### TestRail (Optional)
Set environment variables:
```bash
export TESTRAIL_URL="https://your-instance.testrail.io"
export TESTRAIL_USER="your@email.com"
export TESTRAIL_API_KEY="your-api-key"
```
Then use `/pw:testrail` to sync test cases and push results.
### BrowserStack (Optional)
```bash
export BROWSERSTACK_USERNAME="your-username"
export BROWSERSTACK_ACCESS_KEY="your-access-key"
```
Then use `/pw:browserstack` to run tests across browsers.
## Works With
| Agent | How |
|---|---|
| **Claude Code** | Full plugin — slash commands, MCP tools, hooks, agents |
| **Codex CLI** | Copy `CLAUDE.md` to your project root as `AGENTS.md` |
| **OpenClaw** | Use as a skill with `SKILL.md` entry point |
## Built-in Command Integration
Playwright Pro doesn't reinvent what your AI agent already does. It orchestrates built-in capabilities:
- `/pw:generate` uses Claude's `Explore` subagent to understand your codebase before generating tests
- `/pw:migrate` uses `/batch` for parallel file-by-file conversion on large test suites
- `/pw:fix` uses `/debug` for trace analysis alongside Playwright-specific diagnostics
- `/pw:review` extends `/review` with Playwright anti-pattern detection
## Reference
Based on battle-tested patterns from production test suites. Includes curated guidance on:
- Locator strategies and priority hierarchy
- Assertion patterns and auto-retry behavior
- Fixture architecture and composition
- Common pitfalls (top 20, ranked by frequency)
- Flaky test diagnosis taxonomy
## License
MIT
@@ -0,0 +1,122 @@
---
name: migration-planner
description: >-
Analyzes Cypress or Selenium test suites and creates a file-by-file
migration plan. Invoked by /pw:migrate before conversion starts.
tools:
- Read
- Grep
- Glob
- LS
model: inherit
---
# Migration Planner Agent
You are a test migration specialist. Your job is to analyze an existing Cypress or Selenium test suite and create a detailed, ordered migration plan.
## Planning Protocol
### Step 1: Detect Source Framework
Scan the project:
**Cypress indicators:**
- `cypress/` directory
- `cypress.config.ts` or `cypress.config.js`
- `@cypress` packages in `package.json`
- `.cy.ts` or `.cy.js` test files
**Selenium indicators:**
- `selenium-webdriver` in dependencies
- `webdriver` or `wdio` in dependencies
- Test files importing `selenium-webdriver`
- `chromedriver` or `geckodriver` in dependencies
- Python files importing `selenium`
### Step 2: Inventory All Test Files
List every test file with:
- File path
- Number of tests (count `it()`, `test()`, or test methods)
- Dependencies (custom commands, page objects, fixtures)
- Complexity (simple/medium/complex based on lines and patterns)
```
## Test Inventory
| # | File | Tests | Dependencies | Complexity |
|---|---|---|---|---|
| 1 | cypress/e2e/login.cy.ts | 5 | login command | Simple |
| 2 | cypress/e2e/checkout.cy.ts | 12 | api helpers, fixtures | Complex |
| 3 | cypress/e2e/search.cy.ts | 8 | none | Medium |
```
### Step 3: Map Dependencies
Identify shared resources that need migration:
**Custom commands** (`cypress/support/commands.ts`):
- List each command and what it does
- Map to Playwright equivalent (fixture, helper function, or page object)
**Fixtures** (`cypress/fixtures/`):
- List data files
- Plan: copy to `test-data/` with any format adjustments
**Plugins** (`cypress/plugins/`):
- List plugin functionality
- Map to Playwright config options or fixtures
**Page Objects** (if used):
- List page object files
- Plan: convert API calls (minimal structural change)
**Support files** (`cypress/support/`):
- List setup/teardown logic
- Map to `playwright.config.ts` or `fixtures/`
### Step 4: Determine Migration Order
Order files by dependency graph:
1. **Shared resources first**: custom commands → fixtures, page objects → helpers
2. **Simple tests next**: files with no dependencies, few tests
3. **Complex tests last**: files with many dependencies, custom commands
```
## Migration Order
### Phase 1: Foundation (do first)
1. Convert custom commands → fixtures.ts
2. Copy fixtures → test-data/
3. Convert page objects (API changes only)
### Phase 2: Simple Tests (quick wins)
4. login.cy.ts → auth/login.spec.ts (5 tests, ~15 min)
5. about.cy.ts → static/about.spec.ts (2 tests, ~5 min)
### Phase 3: Complex Tests
6. checkout.cy.ts → checkout/checkout.spec.ts (12 tests, ~45 min)
7. search.cy.ts → search/search.spec.ts (8 tests, ~30 min)
```
### Step 5: Estimate Effort
| Complexity | Time per test | Notes |
|---|---|---|
| Simple | 2-3 min | Direct API mapping |
| Medium | 5-10 min | Needs locator upgrade |
| Complex | 10-20 min | Custom commands, plugins, complex flows |
### Step 6: Identify Risks
Flag tests that may need manual intervention:
- Tests using Cypress-only features (`cy.origin()`, `cy.session()`)
- Tests with complex `cy.intercept()` patterns
- Tests relying on Cypress retry-ability semantics
- Tests using Cypress plugins with no Playwright equivalent
### Step 7: Return Plan
Return the complete migration plan to `/pw:migrate` for execution.
@@ -0,0 +1,106 @@
---
name: test-architect
description: >-
Plans test strategy for complex applications. Invoked by /pw:generate and
/pw:coverage when the app has multiple routes, complex state, or requires
a structured test plan before writing tests.
tools:
- Read
- Grep
- Glob
- LS
model: inherit
---
# Test Architect Agent
You are a test architecture specialist. Your job is to analyze an application's structure and create a comprehensive test plan before any tests are written.
## Your Responsibilities
1. **Map the application surface**: routes, components, API endpoints, user flows
2. **Identify critical paths**: the flows that, if broken, cause revenue loss or user churn
3. **Design test structure**: folder organization, fixture strategy, data management
4. **Prioritize**: which tests deliver the most confidence per effort
5. **Select patterns**: which template or approach fits each test scenario
## How You Work
You are a read-only agent. You analyze and plan — you do not write test files.
### Step 1: Scan the Codebase
- Read route definitions (Next.js `app/`, React Router, Vue Router, Angular routes)
- Read `package.json` for framework and dependencies
- Check for existing tests and their patterns
- Identify state management (Redux, Zustand, Pinia, etc.)
- Check for API layer (REST, GraphQL, tRPC)
### Step 2: Catalog Testable Surfaces
Create a structured inventory:
```
## Application Surface
### Pages (by priority)
1. /login — Auth entry point [CRITICAL]
2. /dashboard — Main user view [CRITICAL]
3. /settings — User preferences [HIGH]
4. /admin — Admin panel [HIGH]
5. /about — Static page [LOW]
### Interactive Components
1. SearchBar — complex state, debounced API calls
2. DataTable — sorting, filtering, pagination
3. FileUploader — drag-drop, progress, error handling
### API Endpoints
1. POST /api/auth/login — authentication
2. GET /api/users — user list with pagination
3. PUT /api/users/:id — user update
### User Flows (multi-page)
1. Registration → Email Verify → Onboarding → Dashboard
2. Search → Filter → Select → Add to Cart → Checkout → Confirm
```
### Step 3: Design Test Plan
```
## Test Plan
### Folder Structure
e2e/
├── auth/ # Authentication tests
├── dashboard/ # Dashboard tests
├── checkout/ # Checkout flow tests
├── fixtures/ # Shared fixtures
├── pages/ # Page object models
└── test-data/ # Test data files
### Fixture Strategy
- Auth fixture: shared `storageState` for logged-in tests
- API fixture: request context for data seeding
- Data fixture: factory functions for test entities
### Test Distribution
| Area | Tests | Template | Effort |
|---|---|---|---|
| Auth | 8 | auth/* | 1h |
| Dashboard | 6 | dashboard/* | 1h |
| Checkout | 10 | checkout/* | 2h |
| Search | 5 | search/* | 45m |
| Settings | 4 | settings/* | 30m |
| API | 5 | api/* | 45m |
### Priority Order
1. Auth (blocks everything else)
2. Core user flow (the main thing users do)
3. Payment/checkout (revenue-critical)
4. Everything else
```
### Step 4: Return Plan
Return the complete plan to the calling skill. Do not write files.
@@ -0,0 +1,130 @@
---
name: test-debugger
description: >-
Diagnoses flaky or failing Playwright tests using systematic taxonomy.
Invoked by /pw:fix when a test needs deep analysis including running
tests, reading traces, and identifying root causes.
tools:
- Read
- Grep
- Glob
- LS
- Bash(npx playwright test *)
- Bash(npx playwright show-trace *)
- Bash(npx playwright codegen *)
- Bash(node *)
- Bash(npm test *)
- Bash(npm run *)
disallowedTools:
- Bash(rm *)
- Bash(rmdir *)
- Bash(curl *)
- Bash(wget *)
- Bash(git push *)
- Bash(git reset --hard *)
model: inherit
---
# Test Debugger Agent
You are a Playwright test debugging specialist. Your job is to systematically diagnose why a test fails or behaves flakily, identify the root cause category, and return a specific fix.
## Debugging Protocol
### Step 1: Read the Test
Read the test file and understand:
- What behavior it's testing
- Which pages/URLs it visits
- Which locators it uses
- Which assertions it makes
- Any setup/teardown (fixtures, beforeEach)
### Step 2: Run the Test
Run it multiple ways to classify the failure:
```bash
# Single run — get the error
npx playwright test <file> --grep "<test name>" --reporter=list 2>&1
# Burn-in — expose timing issues
npx playwright test <file> --grep "<test name>" --repeat-each=10 --reporter=list 2>&1
# Isolation check — expose state leaks
npx playwright test <file> --grep "<test name>" --workers=1 --reporter=list 2>&1
# Full suite — expose interaction
npx playwright test --reporter=list 2>&1
```
### Step 3: Capture Trace
```bash
npx playwright test <file> --grep "<test name>" --trace=on --retries=0 2>&1
```
Read the trace output for:
- Network requests that failed or were slow
- Elements that weren't visible when expected
- Navigation timing issues
- Console errors
### Step 4: Classify
| Category | Evidence |
|---|---|
| **Timing/Async** | Fails on `--repeat-each=10`; error mentions timeout or element not found intermittently |
| **Test Isolation** | Passes alone (`--workers=1 --grep`), fails in full suite |
| **Environment** | Passes locally, fails in CI (check viewport, fonts, timezone) |
| **Infrastructure** | Random crash errors, OOM, browser process killed |
### Step 5: Identify Specific Cause
Common root causes per category:
**Timing:**
- Missing `await` on a Playwright call
- `waitForTimeout()` that's too short
- Clicking before element is actionable
- Asserting before data loads
- Animation interference
**Isolation:**
- Global variable shared between tests
- Database not cleaned between tests
- localStorage/cookies leaking
- Test creates data with non-unique identifier
**Environment:**
- Different viewport size in CI
- Font rendering differences affect screenshots
- Timezone affects date assertions
- Network latency in CI is higher
**Infrastructure:**
- Browser runs out of memory with too many workers
- File system race condition
- DNS resolution failure
### Step 6: Return Diagnosis
Return to the calling skill:
```
## Diagnosis
**Category:** Timing/Async
**Root Cause:** Missing await on line 23 — `page.goto('/dashboard')` runs without
waiting, so the assertion on line 24 runs before navigation completes.
**Evidence:** Fails 3/10 times on `--repeat-each=10`. Trace shows assertion firing
before navigation response received.
## Fix
Line 23: Add `await` before `page.goto('/dashboard')`
## Verification
After fix: 10/10 passes on `--repeat-each=10`
```
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Session start hook: detects if the project uses Playwright.
# Outputs context hint for Claude if playwright.config exists.
set -euo pipefail
# Check for Playwright config in current directory or common locations
PW_CONFIG=""
for config in playwright.config.ts playwright.config.js playwright.config.mjs; do
if [[ -f "$config" ]]; then
PW_CONFIG="$config"
break
fi
done
if [[ -z "$PW_CONFIG" ]]; then
exit 0
fi
# Count existing test files
TEST_COUNT=$(find . -name "*.spec.ts" -o -name "*.spec.js" -o -name "*.test.ts" -o -name "*.test.js" 2>/dev/null | grep -v node_modules | wc -l | tr -d ' ')
echo "🎭 Playwright detected ($PW_CONFIG) — $TEST_COUNT test files found. Use /pw: commands for testing workflows."
@@ -0,0 +1,25 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/validate-test.sh"
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/detect-playwright.sh"
}
]
}
]
}
}
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Post-write hook: validates Playwright test files for common anti-patterns.
# Runs silently — only outputs warnings if issues found.
# Input: JSON on stdin with tool_input.file_path
set -euo pipefail
# Read the file path from stdin JSON
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
print(data.get('tool_input', {}).get('file_path', ''))
except:
print('')
" 2>/dev/null || echo "")
# Only check .spec.ts and .spec.js files
if [[ ! "$FILE_PATH" =~ \.(spec|test)\.(ts|js|mjs)$ ]]; then
exit 0
fi
# Check if file exists
if [[ ! -f "$FILE_PATH" ]]; then
exit 0
fi
WARNINGS=""
# Check for waitForTimeout
if grep -n 'waitForTimeout' "$FILE_PATH" >/dev/null 2>&1; then
LINES=$(grep -n 'waitForTimeout' "$FILE_PATH" | head -3)
WARNINGS="${WARNINGS}\n⚠️ waitForTimeout() found — use web-first assertions instead:\n${LINES}\n"
fi
# Check for non-web-first assertions
if grep -n 'expect(await ' "$FILE_PATH" >/dev/null 2>&1; then
LINES=$(grep -n 'expect(await ' "$FILE_PATH" | head -3)
WARNINGS="${WARNINGS}\n⚠️ Non-web-first assertion — use expect(locator) instead:\n${LINES}\n"
fi
# Check for hardcoded localhost URLs
if grep -n "http://localhost\|https://localhost\|http://127.0.0.1" "$FILE_PATH" >/dev/null 2>&1; then
LINES=$(grep -n "http://localhost\|https://localhost\|http://127.0.0.1" "$FILE_PATH" | head -3)
WARNINGS="${WARNINGS}\n⚠️ Hardcoded URL — use baseURL from config:\n${LINES}\n"
fi
# Check for page.$() usage
if grep -n 'page\.\$(' "$FILE_PATH" >/dev/null 2>&1; then
LINES=$(grep -n 'page\.\$(' "$FILE_PATH" | head -3)
WARNINGS="${WARNINGS}\n⚠️ page.\$() is deprecated — use page.locator() or getByRole():\n${LINES}\n"
fi
# Output warnings if any found
if [[ -n "$WARNINGS" ]]; then
echo -e "\n🎭 Playwright Pro — Test Validation${WARNINGS}"
fi
@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(npx playwright*)",
"Bash(npx tsx*)"
]
}
}
@@ -0,0 +1,168 @@
---
name: "browserstack"
description: >-
Run tests on BrowserStack. Use when user mentions "browserstack",
"cross-browser", "cloud testing", "browser matrix", "test on safari",
"test on firefox", or "browser compatibility".
---
# BrowserStack Integration
Run Playwright tests on BrowserStack's cloud grid for cross-browser and cross-device testing.
## Prerequisites
Environment variables must be set:
- `BROWSERSTACK_USERNAME` — your BrowserStack username
- `BROWSERSTACK_ACCESS_KEY` — your access key
If not set, inform the user how to get them from [browserstack.com/accounts/settings](https://www.browserstack.com/accounts/settings) and stop.
## Capabilities
### 1. Configure for BrowserStack
```
/pw:browserstack setup
```
Steps:
1. Check current `playwright.config.ts`
2. Add BrowserStack connect options:
```typescript
// Add to playwright.config.ts
import { defineConfig } from '@playwright/test';
const isBS = !!process.env.BROWSERSTACK_USERNAME;
export default defineConfig({
// ... existing config
projects: isBS ? [
{
name: "chromelatestwindows-11",
use: {
connectOptions: {
wsEndpoint: `wss://cdp.browserstack.com/playwright?caps=${encodeURIComponent(JSON.stringify({
'browser': 'chrome',
'browser_version': 'latest',
'os': 'Windows',
'os_version': '11',
'browserstack.username': process.env.BROWSERSTACK_USERNAME,
'browserstack.accessKey': process.env.BROWSERSTACK_ACCESS_KEY,
}))}`,
},
},
},
{
name: "firefoxlatestwindows-11",
use: {
connectOptions: {
wsEndpoint: `wss://cdp.browserstack.com/playwright?caps=${encodeURIComponent(JSON.stringify({
'browser': 'playwright-firefox',
'browser_version': 'latest',
'os': 'Windows',
'os_version': '11',
'browserstack.username': process.env.BROWSERSTACK_USERNAME,
'browserstack.accessKey': process.env.BROWSERSTACK_ACCESS_KEY,
}))}`,
},
},
},
{
name: "webkitlatestos-x-ventura",
use: {
connectOptions: {
wsEndpoint: `wss://cdp.browserstack.com/playwright?caps=${encodeURIComponent(JSON.stringify({
'browser': 'playwright-webkit',
'browser_version': 'latest',
'os': 'OS X',
'os_version': 'Ventura',
'browserstack.username': process.env.BROWSERSTACK_USERNAME,
'browserstack.accessKey': process.env.BROWSERSTACK_ACCESS_KEY,
}))}`,
},
},
},
] : [
// ... local projects fallback
],
});
```
3. Add npm script: `"test:e2e:cloud": "npx playwright test --project='chrome@*' --project='firefox@*' --project='webkit@*'"`
### 2. Run Tests on BrowserStack
```
/pw:browserstack run
```
Steps:
1. Verify credentials are set
2. Run tests with BrowserStack projects:
```bash
BROWSERSTACK_USERNAME=$BROWSERSTACK_USERNAME \
BROWSERSTACK_ACCESS_KEY=$BROWSERSTACK_ACCESS_KEY \
npx playwright test --project='chrome@*' --project='firefox@*'
```
3. Monitor execution
4. Report results per browser
### 3. Get Build Results
```
/pw:browserstack results
```
Steps:
1. Call `browserstack_get_builds` MCP tool
2. Get latest build's sessions
3. For each session:
- Status (pass/fail)
- Browser and OS
- Duration
- Video URL
- Log URLs
4. Format as summary table
### 4. Check Available Browsers
```
/pw:browserstack browsers
```
Steps:
1. Call `browserstack_get_browsers` MCP tool
2. Filter for Playwright-compatible browsers
3. Display available browser/OS combinations
### 5. Local Testing
```
/pw:browserstack local
```
For testing localhost or staging behind firewall:
1. Install BrowserStack Local: `npm install -D browserstack-local`
2. Add local tunnel to config
3. Provide setup instructions
## MCP Tools Used
| Tool | When |
|---|---|
| `browserstack_get_plan` | Check account limits |
| `browserstack_get_browsers` | List available browsers |
| `browserstack_get_builds` | List recent builds |
| `browserstack_get_sessions` | Get sessions in a build |
| `browserstack_get_session` | Get session details (video, logs) |
| `browserstack_update_session` | Mark pass/fail |
| `browserstack_get_logs` | Get text/network logs |
## Output
- Cross-browser test results table
- Per-browser pass/fail status
- Links to BrowserStack dashboard for video/screenshots
- Any browser-specific failures highlighted
@@ -0,0 +1,98 @@
---
name: "coverage"
description: >-
Analyze test coverage gaps. Use when user says "test coverage",
"what's not tested", "coverage gaps", "missing tests", "coverage report",
or "what needs testing".
---
# Analyze Test Coverage Gaps
Map all testable surfaces in the application and identify what's tested vs. what's missing.
## Steps
### 1. Map Application Surface
Use the `Explore` subagent to catalog:
**Routes/Pages:**
- Scan route definitions (Next.js `app/`, React Router config, Vue Router, etc.)
- List all user-facing pages with their paths
**Components:**
- Identify interactive components (forms, modals, dropdowns, tables)
- Note components with complex state logic
**API Endpoints:**
- Scan API route files or backend controllers
- List all endpoints with their methods
**User Flows:**
- Identify critical paths: auth, checkout, onboarding, core features
- Map multi-step workflows
### 2. Map Existing Tests
Scan all `*.spec.ts` / `*.spec.js` files:
- Extract which pages/routes are covered (by `page.goto()` calls)
- Extract which components are tested (by locator usage)
- Extract which API endpoints are mocked or hit
- Count tests per area
### 3. Generate Coverage Matrix
```
## Coverage Matrix
| Area | Route | Tests | Status |
|---|---|---|---|
| Auth | /login | 5 | ✅ Covered |
| Auth | /register | 0 | ❌ Missing |
| Auth | /forgot-password | 0 | ❌ Missing |
| Dashboard | /dashboard | 3 | ⚠️ Partial (no error states) |
| Settings | /settings | 0 | ❌ Missing |
| Checkout | /checkout | 8 | ✅ Covered |
```
### 4. Prioritize Gaps
Rank uncovered areas by business impact:
1. **Critical** — auth, payment, core features → test first
2. **High** — user-facing CRUD, search, navigation
3. **Medium** — settings, preferences, edge cases
4. **Low** — static pages, about, terms
### 5. Suggest Test Plan
For each gap, recommend:
- Number of tests needed
- Which template from `templates/` to use
- Estimated effort (quick/medium/complex)
```
## Recommended Test Plan
### Priority 1: Critical
1. /register (4 tests) — use auth/registration template — quick
2. /forgot-password (3 tests) — use auth/password-reset template — quick
### Priority 2: High
3. /settings (4 tests) — use settings/ templates — medium
4. Dashboard error states (2 tests) — use dashboard/data-loading template — quick
```
### 6. Auto-Generate (Optional)
Ask user: "Generate tests for the top N gaps? [Yes/No/Pick specific]"
If yes, invoke `/pw:generate` for each gap with the recommended template.
## Output
- Coverage matrix (table format)
- Coverage percentage estimate
- Prioritized gap list with effort estimates
- Option to auto-generate missing tests
@@ -0,0 +1,113 @@
---
name: "fix"
description: >-
Fix failing or flaky Playwright tests. Use when user says "fix test",
"flaky test", "test failing", "debug test", "test broken", "test passes
sometimes", or "intermittent failure".
---
# Fix Failing or Flaky Tests
Diagnose and fix a Playwright test that fails or passes intermittently using a systematic taxonomy.
## Input
`$ARGUMENTS` contains:
- A test file path: `e2e/login.spec.ts`
- A test name: ""should redirect after login"`
- A description: `"the checkout test fails in CI but passes locally"`
## Steps
### 1. Reproduce the Failure
Run the test to capture the error:
```bash
npx playwright test <file> --reporter=list
```
If the test passes, it's likely flaky. Run burn-in:
```bash
npx playwright test <file> --repeat-each=10 --reporter=list
```
If it still passes, try with parallel workers:
```bash
npx playwright test --fully-parallel --workers=4 --repeat-each=5
```
### 2. Capture Trace
Run with full tracing:
```bash
npx playwright test <file> --trace=on --retries=0
```
Read the trace output. Use `/debug` to analyze trace files if available.
### 3. Categorize the Failure
Load `flaky-taxonomy.md` from this skill directory.
Every failing test falls into one of four categories:
| Category | Symptom | Diagnosis |
|---|---|---|
| **Timing/Async** | Fails intermittently everywhere | `--repeat-each=20` reproduces locally |
| **Test Isolation** | Fails in suite, passes alone | `--workers=1 --grep "test name"` passes |
| **Environment** | Fails in CI, passes locally | Compare CI vs local screenshots/traces |
| **Infrastructure** | Random, no pattern | Error references browser internals |
### 4. Apply Targeted Fix
**Timing/Async:**
- Replace `waitForTimeout()` with web-first assertions
- Add `await` to missing Playwright calls
- Wait for specific network responses before asserting
- Use `toBeVisible()` before interacting with elements
**Test Isolation:**
- Remove shared mutable state between tests
- Create test data per-test via API or fixtures
- Use unique identifiers (timestamps, random strings) for test data
- Check for database state leaks
**Environment:**
- Match viewport sizes between local and CI
- Account for font rendering differences in screenshots
- Use `docker` locally to match CI environment
- Check for timezone-dependent assertions
**Infrastructure:**
- Increase timeout for slow CI runners
- Add retries in CI config (`retries: 2`)
- Check for browser OOM (reduce parallel workers)
- Ensure browser dependencies are installed
### 5. Verify the Fix
Run the test 10 times to confirm stability:
```bash
npx playwright test <file> --repeat-each=10 --reporter=list
```
All 10 must pass. If any fail, go back to step 3.
### 6. Prevent Recurrence
Suggest:
- Add to CI with `retries: 2` if not already
- Enable `trace: 'on-first-retry'` in config
- Add the fix pattern to project's test conventions doc
## Output
- Root cause category and specific issue
- The fix applied (with diff)
- Verification result (10/10 passes)
- Prevention recommendation
@@ -0,0 +1,134 @@
# Flaky Test Taxonomy
## Decision Tree
```
Test is flaky
├── Fails locally with --repeat-each=20?
│ ├── YES → TIMING / ASYNC
│ │ ├── Missing await? → Add await
│ │ ├── waitForTimeout? → Replace with assertion
│ │ ├── Race condition? → Wait for specific event
│ │ └── Animation? → Wait for animation end or disable
│ │
│ └── NO → Continue...
├── Passes alone, fails in suite?
│ ├── YES → TEST ISOLATION
│ │ ├── Shared variable? → Make per-test
│ │ ├── Database state? → Reset per-test
│ │ ├── localStorage? → Clear in beforeEach
│ │ └── Cookie leak? → Use isolated contexts
│ │
│ └── NO → Continue...
├── Fails in CI, passes locally?
│ ├── YES → ENVIRONMENT
│ │ ├── Viewport? → Set explicit size
│ │ ├── Fonts? → Use Docker locally
│ │ ├── Timezone? → Use UTC everywhere
│ │ └── Network? → Mock external services
│ │
│ └── NO → INFRASTRUCTURE
│ ├── Browser crash? → Reduce workers
│ ├── OOM? → Limit parallel tests
│ ├── DNS? → Add retry config
│ └── File system? → Use unique temp dirs
```
## Common Fixes by Category
### Timing / Async
**Missing await:**
```typescript
// BAD — race condition
page.goto('/dashboard');
expect(page.getByText('Welcome')).toBeVisible();
// GOOD
await page.goto('/dashboard');
await expect(page.getByText('Welcome')).toBeVisible();
```
**Clicking before visible:**
```typescript
// BAD — element may not be ready
await page.getByRole('button', { name: 'Submit' }).click();
// GOOD — ensure visible first
const submitBtn = page.getByRole('button', { name: 'Submit' });
await expect(submitBtn).toBeVisible();
await submitBtn.click();
```
**Race with network:**
```typescript
// BAD — data might not be loaded
await page.goto('/users');
await expect(page.getByRole('table')).toBeVisible();
// GOOD — wait for API response
const responsePromise = page.waitForResponse('**/api/users');
await page.goto('/users');
await responsePromise;
await expect(page.getByRole('table')).toBeVisible();
```
### Test Isolation
**Shared state fix:**
```typescript
// BAD — tests share userId
let userId: string;
test('create', async () => { userId = '123'; });
test('read', async () => { /* uses userId */ });
// GOOD — each test is independent
test('read user', async ({ request }) => {
const response = await request.post('/api/users', { data: { name: 'Test' } });
const { id } = await response.json();
// Use id within this test
});
```
**localStorage cleanup:**
```typescript
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.evaluate(() => localStorage.clear());
});
```
### Environment
**Explicit viewport:**
```typescript
test.use({ viewport: { width: 1280, height: 720 } });
```
**Timezone-safe dates:**
```typescript
// BAD
expect(dateText).toBe('March 5, 2026');
// GOOD — timezone independent
expect(dateText).toMatch(/\d{1,2}\/\d{1,2}\/\d{4}/);
```
### Infrastructure
**Retry config:**
```typescript
// playwright.config.ts
export default defineConfig({
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
});
```
**Increase timeout for CI:**
```typescript
test.setTimeout(60_000); // 60s for slow CI
```
@@ -0,0 +1,144 @@
---
name: "generate"
description: >-
Generate Playwright tests. Use when user says "write tests", "generate tests",
"add tests for", "test this component", "e2e test", "create test for",
"test this page", or "test this feature".
---
# Generate Playwright Tests
Generate production-ready Playwright tests from a user story, URL, component name, or feature description.
## Input
`$ARGUMENTS` contains what to test. Examples:
- `"user can log in with email and password"`
- `"the checkout flow"`
- `"src/components/UserProfile.tsx"`
- `"the search page with filters"`
## Steps
### 1. Understand the Target
Parse `$ARGUMENTS` to determine:
- **User story**: Extract the behavior to verify
- **Component path**: Read the component source code
- **Page/URL**: Identify the route and its elements
- **Feature name**: Map to relevant app areas
### 2. Explore the Codebase
Use the `Explore` subagent to gather context:
- Read `playwright.config.ts` for `testDir`, `baseURL`, `projects`
- Check existing tests in `testDir` for patterns, fixtures, and conventions
- If a component path is given, read the component to understand its props, states, and interactions
- Check for existing page objects in `pages/`
- Check for existing fixtures in `fixtures/`
- Check for auth setup (`auth.setup.ts` or `storageState` config)
### 3. Select Templates
Check `templates/` in this plugin for matching patterns:
| If testing... | Load template from |
|---|---|
| Login/auth flow | `../pw/templates/auth/login.md` |
| CRUD operations | `templates/crud/` |
| Checkout/payment | `templates/checkout/` |
| Search/filter UI | `templates/search/` |
| Form submission | `templates/forms/` |
| Dashboard/data | `templates/dashboard/` |
| Settings page | `templates/settings/` |
| Onboarding flow | `templates/onboarding/` |
| API endpoints | `templates/api/` |
| Accessibility | `templates/accessibility/` |
Adapt the template to the specific app — replace `{{placeholders}}` with actual selectors, URLs, and data.
### 4. Generate the Test
Follow these rules:
**Structure:**
```typescript
import { test, expect } from '@playwright/test';
// Import custom fixtures if the project uses them
test.describe('Feature Name', () => {
// Group related behaviors
test('should <expected behavior>', async ({ page }) => {
// Arrange: navigate, set up state
// Act: perform user action
// Assert: verify outcome
});
});
```
**Locator priority** (use the first that works):
1. `getByRole()` — buttons, links, headings, form elements
2. `getByLabel()` — form fields with labels
3. `getByText()` — non-interactive text content
4. `getByPlaceholder()` — inputs with placeholder text
5. `getByTestId()` — when semantic options aren't available
**Assertions** — always web-first:
```typescript
// GOOD — auto-retries
await expect(page.getByRole('heading')).toBeVisible();
await expect(page.getByRole('alert')).toHaveText('Success');
// BAD — no retry
const text = await page.textContent('.msg');
expect(text).toBe('Success');
```
**Never use:**
- `page.waitForTimeout()`
- `page.$(selector)` or `page.$$(selector)`
- Bare CSS selectors unless absolutely necessary
- `page.evaluate()` for things locators can do
**Always include:**
- Descriptive test names that explain the behavior
- Error/edge case tests alongside happy path
- Proper `await` on every Playwright call
- `baseURL`-relative navigation (`page.goto('/')` not `page.goto('http://...')`)
### 5. Match Project Conventions
- If project uses TypeScript → generate `.spec.ts`
- If project uses JavaScript → generate `.spec.js` with `require()` imports
- If project has page objects → use them instead of inline locators
- If project has custom fixtures → import and use them
- If project has a test data directory → create test data files there
### 6. Generate Supporting Files (If Needed)
- **Page object**: If the test touches 5+ unique locators on one page, create a page object
- **Fixture**: If the test needs shared setup (auth, data), create or extend a fixture
- **Test data**: If the test uses structured data, create a JSON file in `test-data/`
### 7. Verify
Run the generated test:
```bash
npx playwright test <generated-file> --reporter=list
```
If it fails:
1. Read the error
2. Fix the test (not the app)
3. Run again
4. If it's an app issue, report it to the user
## Output
- Generated test file(s) with path
- Any supporting files created (page objects, fixtures, data)
- Test run result
- Coverage note: what behaviors are now tested
@@ -0,0 +1,163 @@
# Test Generation Patterns
## Pattern: Authentication Flow
```typescript
test.describe('Authentication', () => {
test('should login with valid credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
test('should show error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('wrong@example.com');
await page.getByLabel('Password').fill('wrong');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('alert')).toHaveText(/invalid/i);
await expect(page).toHaveURL('/login');
});
});
```
## Pattern: CRUD Operations
```typescript
test.describe('Items', () => {
test('should create a new item', async ({ page }) => {
await page.goto('/items');
await page.getByRole('button', { name: 'Add item' }).click();
await page.getByLabel('Name').fill('Test Item');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Test Item')).toBeVisible();
});
test('should edit an existing item', async ({ page }) => {
await page.goto('/items');
await page.getByRole('row', { name: /Test Item/ })
.getByRole('button', { name: 'Edit' }).click();
await page.getByLabel('Name').clear();
await page.getByLabel('Name').fill('Updated Item');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Updated Item')).toBeVisible();
});
test('should delete an item with confirmation', async ({ page }) => {
await page.goto('/items');
await page.getByRole('row', { name: /Test Item/ })
.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByText('Test Item')).not.toBeVisible();
});
});
```
## Pattern: Form with Validation
```typescript
test.describe('Contact Form', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/contact');
});
test('should submit valid form', async ({ page }) => {
await page.getByLabel('Name').fill('Jane Doe');
await page.getByLabel('Email').fill('jane@example.com');
await page.getByLabel('Message').fill('Hello, this is a test message.');
await page.getByRole('button', { name: 'Send' }).click();
await expect(page.getByText('Message sent')).toBeVisible();
});
test('should show validation errors for empty required fields', async ({ page }) => {
await page.getByRole('button', { name: 'Send' }).click();
await expect(page.getByText('Name is required')).toBeVisible();
await expect(page.getByText('Email is required')).toBeVisible();
});
test('should validate email format', async ({ page }) => {
await page.getByLabel('Email').fill('not-an-email');
await page.getByRole('button', { name: 'Send' }).click();
await expect(page.getByText('Invalid email')).toBeVisible();
});
});
```
## Pattern: Search and Filter
```typescript
test.describe('Product Search', () => {
test('should return results for valid query', async ({ page }) => {
await page.goto('/products');
await page.getByPlaceholder('Search products').fill('laptop');
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByRole('list')).toBeVisible();
const results = page.getByRole('listitem');
await expect(results).not.toHaveCount(0);
});
test('should show empty state for no results', async ({ page }) => {
await page.goto('/products');
await page.getByPlaceholder('Search products').fill('xyznonexistent');
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByText('No products found')).toBeVisible();
});
test('should filter by category', async ({ page }) => {
await page.goto('/products');
await page.getByRole('combobox', { name: 'Category' }).selectOption('Electronics');
await expect(page.getByRole('listitem')).not.toHaveCount(0);
});
});
```
## Pattern: Navigation and Layout
```typescript
test.describe('Navigation', () => {
test('should navigate between pages', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: 'About' }).click();
await expect(page).toHaveURL('/about');
await expect(page.getByRole('heading', { level: 1 })).toHaveText('About');
});
test('should show mobile menu on small screens', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/');
await expect(page.getByRole('navigation')).not.toBeVisible();
await page.getByRole('button', { name: 'Menu' }).click();
await expect(page.getByRole('navigation')).toBeVisible();
});
});
```
## Pattern: API Mocking
```typescript
test.describe('Dashboard with mocked API', () => {
test('should display data from API', async ({ page }) => {
await page.route('**/api/dashboard', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ revenue: 50000, users: 1200 }),
});
});
await page.goto('/dashboard');
await expect(page.getByText('$50,000')).toBeVisible();
await expect(page.getByText('1,200')).toBeVisible();
});
test('should handle API errors gracefully', async ({ page }) => {
await page.route('**/api/dashboard', (route) => {
route.fulfill({ status: 500 });
});
await page.goto('/dashboard');
await expect(page.getByText(/error|try again/i)).toBeVisible();
});
});
```
@@ -0,0 +1,201 @@
---
name: "init"
description: >-
Set up Playwright in a project. Use when user says "set up playwright",
"add e2e tests", "configure playwright", "testing setup", "init playwright",
or "add test infrastructure".
---
# Initialize Playwright Project
Set up a production-ready Playwright testing environment. Detect the framework, generate config, folder structure, example test, and CI workflow.
## Steps
### 1. Analyze the Project
Use the `Explore` subagent to scan the project:
- Check `package.json` for framework (React, Next.js, Vue, Angular, Svelte)
- Check for `tsconfig.json` → use TypeScript; otherwise JavaScript
- Check if Playwright is already installed (`@playwright/test` in dependencies)
- Check for existing test directories (`tests/`, `e2e/`, `__tests__/`)
- Check for existing CI config (`.github/workflows/`, `.gitlab-ci.yml`)
### 2. Install Playwright
If not already installed:
```bash
npm init playwright@latest -- --quiet
```
Or if the user prefers manual setup:
```bash
npm install -D @playwright/test
npx playwright install --with-deps chromium
```
### 3. Generate `playwright.config.ts`
Adapt to the detected framework:
**Next.js:**
```typescript
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { open: 'never' }],
['list'],
],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: "chromium", use: { ...devices['Desktop Chrome'] } },
{ name: "firefox", use: { ...devices['Desktop Firefox'] } },
{ name: "webkit", use: { ...devices['Desktop Safari'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
```
**React (Vite):**
- Change `baseURL` to `http://localhost:5173`
- Change `webServer.command` to `npm run dev`
**Vue/Nuxt:**
- Change `baseURL` to `http://localhost:3000`
- Change `webServer.command` to `npm run dev`
**Angular:**
- Change `baseURL` to `http://localhost:4200`
- Change `webServer.command` to `npm run start`
**No framework detected:**
- Omit `webServer` block
- Set `baseURL` from user input or leave as placeholder
### 4. Create Folder Structure
```
e2e/
├── fixtures/
│ └── index.ts # Custom fixtures
├── pages/
│ └── .gitkeep # Page object models
├── test-data/
│ └── .gitkeep # Test data files
└── example.spec.ts # First example test
```
### 5. Generate Example Test
```typescript
import { test, expect } from '@playwright/test';
test.describe('Homepage', () => {
test('should load successfully', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/.+/);
});
test('should have visible navigation', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('navigation')).toBeVisible();
});
});
```
### 6. Generate CI Workflow
If `.github/workflows/` exists, create `playwright.yml`:
```yaml
name: "playwright-tests"
on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: "install-dependencies"
run: npm ci
- name: "install-playwright-browsers"
run: npx playwright install --with-deps
- name: "run-playwright-tests"
run: npx playwright test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: "playwright-report"
path: playwright-report/
retention-days: 30
```
If `.gitlab-ci.yml` exists, add a Playwright stage instead.
### 7. Update `.gitignore`
Append if not already present:
```
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
```
### 8. Add npm Scripts
Add to `package.json` scripts:
```json
{
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug"
}
```
### 9. Verify Setup
Run the example test:
```bash
npx playwright test
```
Report the result. If it fails, diagnose and fix before completing.
## Output
Confirm what was created:
- Config file path and key settings
- Test directory and example test
- CI workflow (if applicable)
- npm scripts added
- How to run: `npx playwright test` or `npm run test:e2e`
@@ -0,0 +1,135 @@
---
name: "migrate"
description: >-
Migrate from Cypress or Selenium to Playwright. Use when user mentions
"cypress", "selenium", "migrate tests", "convert tests", "switch to
playwright", "move from cypress", or "replace selenium".
---
# Migrate to Playwright
Interactive migration from Cypress or Selenium to Playwright with file-by-file conversion.
## Input
`$ARGUMENTS` can be:
- `"from cypress"` — migrate Cypress test suite
- `"from selenium"` — migrate Selenium/WebDriver tests
- A file path: convert a specific test file
- Empty: auto-detect source framework
## Steps
### 1. Detect Source Framework
Use `Explore` subagent to scan:
- `cypress/` directory or `cypress.config.ts` → Cypress
- `selenium`, `webdriver` in `package.json` deps → Selenium
- `.py` test files with `selenium` imports → Selenium (Python)
### 2. Assess Migration Scope
Count files and categorize:
```
Migration Assessment:
- Total test files: X
- Cypress custom commands: Y
- Cypress fixtures: Z
- Estimated effort: [small|medium|large]
```
| Size | Files | Approach |
|---|---|---|
| Small (1-10) | Convert sequentially | Direct conversion |
| Medium (11-30) | Batch in groups of 5 | Use sub-agents |
| Large (31+) | Use `/batch` | Parallel conversion with `/batch` |
### 3. Set Up Playwright (If Not Present)
Run `/pw:init` first if Playwright isn't configured.
### 4. Convert Files
For each file, apply the appropriate mapping:
#### Cypress → Playwright
Load `cypress-mapping.md` for complete reference.
Key translations:
```
cy.visit(url) → page.goto(url)
cy.get(selector) → page.locator(selector) or page.getByRole(...)
cy.contains(text) → page.getByText(text)
cy.find(selector) → locator.locator(selector)
cy.click() → locator.click()
cy.type(text) → locator.fill(text)
cy.should('be.visible') → expect(locator).toBeVisible()
cy.should('have.text') → expect(locator).toHaveText(text)
cy.intercept() → page.route()
cy.wait('@alias') → page.waitForResponse()
cy.fixture() → JSON import or test data file
```
**Cypress custom commands** → Playwright fixtures or helper functions
**Cypress plugins** → Playwright config or fixtures
**`before`/`beforeEach`** → `test.beforeAll()` / `test.beforeEach()`
#### Selenium → Playwright
Load `selenium-mapping.md` for complete reference.
Key translations:
```
driver.get(url) → page.goto(url)
driver.findElement(By.id('x')) → page.locator('#x') or page.getByTestId('x')
driver.findElement(By.css('.x')) → page.locator('.x') or page.getByRole(...)
element.click() → locator.click()
element.sendKeys(text) → locator.fill(text)
element.getText() → locator.textContent()
WebDriverWait + ExpectedConditions → expect(locator).toBeVisible()
driver.switchTo().frame() → page.frameLocator()
Actions → locator.hover(), locator.dragTo()
```
### 5. Upgrade Locators
During conversion, upgrade selectors to Playwright best practices:
- `#id``getByTestId()` or `getByRole()`
- `.class``getByRole()` or `getByText()`
- `[data-testid]``getByTestId()`
- XPath → role-based locators
### 6. Convert Custom Commands / Utilities
- Cypress custom commands → Playwright custom fixtures via `test.extend()`
- Selenium page objects → Playwright page objects (keep structure, update API)
- Shared helpers → TypeScript utility functions
### 7. Verify Each Converted File
After converting each file:
```bash
npx playwright test <converted-file> --reporter=list
```
Fix any compilation or runtime errors before moving to the next file.
### 8. Clean Up
After all files are converted:
- Remove Cypress/Selenium dependencies from `package.json`
- Remove old config files (`cypress.config.ts`, etc.)
- Update CI workflow to use Playwright
- Update README with new test commands
Ask user before deleting anything.
## Output
- Conversion summary: files converted, total tests migrated
- Any tests that couldn't be auto-converted (manual intervention needed)
- Updated CI config
- Before/after comparison of test run results
@@ -0,0 +1,79 @@
# Cypress → Playwright Mapping
## Commands
| Cypress | Playwright | Notes |
|---|---|---|
| `cy.visit('/page')` | `await page.goto('/page')` | Use `baseURL` in config |
| `cy.get('.selector')` | `page.locator('.selector')` | Prefer `getByRole()` |
| `cy.get('[data-cy=x]')` | `page.getByTestId('x')` | |
| `cy.contains('text')` | `page.getByText('text')` | |
| `cy.find('.child')` | `parent.locator('.child')` | Chain from parent locator |
| `cy.first()` | `locator.first()` | |
| `cy.last()` | `locator.last()` | |
| `cy.eq(n)` | `locator.nth(n)` | |
| `cy.parent()` | `locator.locator('..')` | Or restructure with better locators |
| `cy.children()` | `locator.locator('> *')` | |
| `cy.siblings()` | Not direct — restructure test | Use parent + filter |
## Actions
| Cypress | Playwright | Notes |
|---|---|---|
| `.click()` | `await locator.click()` | Always `await` |
| `.dblclick()` | `await locator.dblclick()` | |
| `.rightclick()` | `await locator.click({ button: 'right' })` | |
| `.type('text')` | `await locator.fill('text')` | `fill()` clears first |
| `.type('text', { delay: 50 })` | `await locator.pressSequentially('text', { delay: 50 })` | Simulates typing |
| `.clear()` | `await locator.clear()` | |
| `.check()` | `await locator.check()` | |
| `.uncheck()` | `await locator.uncheck()` | |
| `.select('value')` | `await locator.selectOption('value')` | |
| `.scrollTo()` | `await locator.scrollIntoViewIfNeeded()` | |
| `.trigger('event')` | `await locator.dispatchEvent('event')` | |
| `.focus()` | `await locator.focus()` | |
| `.blur()` | `await locator.blur()` | |
## Assertions
| Cypress | Playwright | Notes |
|---|---|---|
| `.should('be.visible')` | `await expect(locator).toBeVisible()` | Web-first, auto-retry |
| `.should('not.exist')` | `await expect(locator).not.toBeVisible()` | Or `.toHaveCount(0)` |
| `.should('have.text', 'x')` | `await expect(locator).toHaveText('x')` | |
| `.should('contain', 'x')` | `await expect(locator).toContainText('x')` | |
| `.should('have.value', 'x')` | `await expect(locator).toHaveValue('x')` | |
| `.should('have.attr', 'x', 'y')` | `await expect(locator).toHaveAttribute('x', 'y')` | |
| `.should('have.class', 'x')` | `await expect(locator).toHaveClass(/x/)` | |
| `.should('be.disabled')` | `await expect(locator).toBeDisabled()` | |
| `.should('be.checked')` | `await expect(locator).toBeChecked()` | |
| `.should('have.length', n)` | `await expect(locator).toHaveCount(n)` | |
| `cy.url().should('include', '/x')` | `await expect(page).toHaveURL(/\/x/)` | |
| `cy.title().should('eq', 'x')` | `await expect(page).toHaveTitle('x')` | |
## Network
| Cypress | Playwright |
|---|---|
| `cy.intercept('GET', '/api/*', { body: data })` | `await page.route('**/api/*', route => route.fulfill({ body: JSON.stringify(data) }))` |
| `cy.intercept('POST', '/api/*').as('save')` | `const savePromise = page.waitForResponse('**/api/*')` |
| `cy.wait('@save')` | `await savePromise` |
## Fixtures & Custom Commands
| Cypress | Playwright |
|---|---|
| `cy.fixture('data.json')` | `import data from './test-data/data.json'` |
| `Cypress.Commands.add('login', ...)` | `test.extend({ authenticatedPage: ... })` |
| `beforeEach(() => { ... })` | `test.beforeEach(async ({ page }) => { ... })` |
| `before(() => { ... })` | `test.beforeAll(async () => { ... })` |
## Config
| Cypress | Playwright |
|---|---|
| `baseUrl` in `cypress.config.ts` | `use.baseURL` in `playwright.config.ts` |
| `defaultCommandTimeout` | `expect.timeout` or `use.actionTimeout` |
| `video: true` | `use.video: 'on'` |
| `screenshotOnRunFailure` | `use.screenshot: 'only-on-failure'` |
| `retries: { runMode: 2 }` | `retries: 2` |
@@ -0,0 +1,94 @@
# Selenium → Playwright Mapping
## Driver Setup
| Selenium (JS) | Playwright |
|---|---|
| `new Builder().forBrowser('chrome').build()` | Handled by config — no driver setup |
| `driver.quit()` | Automatic — Playwright manages browser lifecycle |
| `driver.manage().setTimeouts(...)` | Config: `timeout`, `expect.timeout` |
## Navigation
| Selenium | Playwright | Notes |
|---|---|---|
| `driver.get(url)` | `await page.goto(url)` | Use `baseURL` |
| `driver.navigate().back()` | `await page.goBack()` | |
| `driver.navigate().forward()` | `await page.goForward()` | |
| `driver.navigate().refresh()` | `await page.reload()` | |
| `driver.getCurrentUrl()` | `page.url()` | |
| `driver.getTitle()` | `await page.title()` | |
## Element Location
| Selenium | Playwright | Preferred |
|---|---|---|
| `By.id('x')` | `page.locator('#x')` | `page.getByTestId('x')` |
| `By.css('.x')` | `page.locator('.x')` | `page.getByRole(...)` |
| `By.xpath('//div')` | `page.locator('xpath=//div')` | Avoid — use role-based |
| `By.name('x')` | `page.locator('[name=x]')` | `page.getByLabel(...)` |
| `By.linkText('x')` | `page.getByRole('link', { name: 'x' })` | ✅ Best practice |
| `By.partialLinkText('x')` | `page.getByRole('link', { name: /x/ })` | ✅ Best practice |
| `By.tagName('button')` | `page.getByRole('button')` | ✅ Best practice |
| `By.className('x')` | `page.locator('.x')` | `page.getByRole(...)` |
| `findElement()` | Returns first match | `locator.first()` |
| `findElements()` | `page.locator(selector)` | Use `.count()` or `.all()` |
## Actions
| Selenium | Playwright |
|---|---|
| `element.click()` | `await locator.click()` |
| `element.sendKeys('text')` | `await locator.fill('text')` |
| `element.sendKeys(Key.ENTER)` | `await locator.press('Enter')` |
| `element.clear()` | `await locator.clear()` |
| `element.submit()` | `await locator.press('Enter')` or click submit button |
| `element.getText()` | `await locator.textContent()` |
| `element.getAttribute('x')` | `await locator.getAttribute('x')` |
| `element.isDisplayed()` | `await locator.isVisible()` |
| `element.isEnabled()` | `await locator.isEnabled()` |
| `element.isSelected()` | `await locator.isChecked()` |
## Waits
| Selenium | Playwright | Notes |
|---|---|---|
| `WebDriverWait(driver, 10).until(EC.visibilityOf(el))` | `await expect(locator).toBeVisible()` | Auto-retries |
| `WebDriverWait(driver, 10).until(EC.elementToBeClickable(el))` | `await locator.click()` | Auto-waits for clickable |
| `WebDriverWait(driver, 10).until(EC.presenceOf(el))` | `await expect(locator).toBeAttached()` | |
| `WebDriverWait(driver, 10).until(EC.textToBe(el, 'x'))` | `await expect(locator).toHaveText('x')` | |
| `Thread.sleep(3000)` | ❌ Never use | Use assertions instead |
| `driver.manage().setTimeouts({ implicit: 10000 })` | Not needed | Playwright auto-waits |
## Advanced
| Selenium | Playwright |
|---|---|
| `Actions(driver).moveToElement(el).perform()` | `await locator.hover()` |
| `Actions(driver).dragAndDrop(src, tgt).perform()` | `await src.dragTo(tgt)` |
| `Actions(driver).doubleClick(el).perform()` | `await locator.dblclick()` |
| `Actions(driver).contextClick(el).perform()` | `await locator.click({ button: 'right' })` |
| `driver.switchTo().frame(el)` | `page.frameLocator('#frame')` |
| `driver.switchTo().defaultContent()` | Not needed — use `page` directly |
| `driver.switchTo().alert()` | `page.on('dialog', d => d.accept())` |
| `driver.switchTo().window(handle)` | `const popup = await page.waitForEvent('popup')` |
| `driver.executeScript(js)` | `await page.evaluate(js)` |
| `driver.takeScreenshot()` | `await page.screenshot({ path: 'x.png' })` |
## Test Structure
| Selenium (Jest/Mocha) | Playwright |
|---|---|
| `describe('Suite', () => { ... })` | `test.describe('Suite', () => { ... })` |
| `it('should...', () => { ... })` | `test('should...', async ({ page }) => { ... })` |
| `beforeAll(() => { ... })` | `test.beforeAll(async () => { ... })` |
| `beforeEach(() => { ... })` | `test.beforeEach(async ({ page }) => { ... })` |
| `afterEach(() => { ... })` | `test.afterEach(async ({ page }) => { ... })` |
## Key Differences
1. **No implicit waits** — Playwright auto-waits for actionability
2. **No driver management** — Playwright handles browser lifecycle
3. **Built-in assertions**`expect(locator)` with auto-retry
4. **Parallel by default** — tests run in parallel, must be isolated
5. **Traces instead of screenshots** — richer debugging artifacts
@@ -0,0 +1,124 @@
---
name: "playwright-pro"
description: "Production-grade Playwright testing toolkit. Use when the user mentions Playwright tests, end-to-end testing, browser automation, fixing flaky tests, test migration, CI/CD testing, or test suites. Generate tests, fix flaky failures, migrate from Cypress/Selenium, sync with TestRail, run on BrowserStack. 55 templates, 3 agents, smart reporting."
---
# Playwright Pro
Production-grade Playwright testing toolkit for AI coding agents.
## Available Commands
When installed as a Claude Code plugin, these are available as `/pw:` commands:
| Command | What it does |
|---|---|
| `/pw:init` | Set up Playwright — detects framework, generates config, CI, first test |
| `/pw:generate <spec>` | Generate tests from user story, URL, or component |
| `/pw:review` | Review tests for anti-patterns and coverage gaps |
| `/pw:fix <test>` | Diagnose and fix failing or flaky tests |
| `/pw:migrate` | Migrate from Cypress or Selenium to Playwright |
| `/pw:coverage` | Analyze what's tested vs. what's missing |
| `/pw:testrail` | Sync with TestRail — read cases, push results |
| `/pw:browserstack` | Run on BrowserStack, pull cross-browser reports |
| `/pw:report` | Generate test report in your preferred format |
## Quick Start Workflow
The recommended sequence for most projects:
```
1. /pw:init → scaffolds config, CI pipeline, and a first smoke test
2. /pw:generate → generates tests from your spec or URL
3. /pw:review → validates quality and flags anti-patterns ← always run after generate
4. /pw:fix <test> → diagnoses and repairs any failing/flaky tests ← run when CI turns red
```
**Validation checkpoints:**
- After `/pw:generate` — always run `/pw:review` before committing; it catches locator anti-patterns and missing assertions automatically.
- After `/pw:fix` — re-run the full suite locally (`npx playwright test`) to confirm the fix doesn't introduce regressions.
- After `/pw:migrate` — run `/pw:coverage` to confirm parity with the old suite before decommissioning Cypress/Selenium tests.
### Example: Generate → Review → Fix
```bash
# 1. Generate tests from a user story
/pw:generate "As a user I can log in with email and password"
# Generated: tests/auth/login.spec.ts
# → Playwright Pro creates the file using the auth template.
# 2. Review the generated tests
/pw:review tests/auth/login.spec.ts
# → Flags: one test used page.locator('input[type=password]') — suggests getByLabel('Password')
# → Fix applied automatically.
# 3. Run locally to confirm
npx playwright test tests/auth/login.spec.ts --headed
# 4. If a test is flaky in CI, diagnose it
/pw:fix tests/auth/login.spec.ts
# → Identifies missing web-first assertion; replaces waitForTimeout(2000) with expect(locator).toBeVisible()
```
## Golden Rules
1. `getByRole()` over CSS/XPath — resilient to markup changes
2. Never `page.waitForTimeout()` — use web-first assertions
3. `expect(locator)` auto-retries; `expect(await locator.textContent())` does not
4. Isolate every test — no shared state between tests
5. `baseURL` in config — zero hardcoded URLs
6. Retries: `2` in CI, `0` locally
7. Traces: `'on-first-retry'` — rich debugging without slowdown
8. Fixtures over globals — `test.extend()` for shared state
9. One behavior per test — multiple related assertions are fine
10. Mock external services only — never mock your own app
## Locator Priority
```
1. getByRole() — buttons, links, headings, form elements
2. getByLabel() — form fields with labels
3. getByText() — non-interactive text
4. getByPlaceholder() — inputs with placeholder
5. getByTestId() — when no semantic option exists
6. page.locator() — CSS/XPath as last resort
```
## What's Included
- **9 skills** with detailed step-by-step instructions
- **3 specialized agents**: test-architect, test-debugger, migration-planner
- **55 test templates**: auth, CRUD, checkout, search, forms, dashboard, settings, onboarding, notifications, API, accessibility
- **2 MCP servers** (TypeScript): TestRail and BrowserStack integrations
- **Smart hooks**: auto-validate test quality, auto-detect Playwright projects
- **6 reference docs**: golden rules, locators, assertions, fixtures, pitfalls, flaky tests
- **Migration guides**: Cypress and Selenium mapping tables
## Integration Setup
### TestRail (Optional)
```bash
export TESTRAIL_URL="https://your-instance.testrail.io"
export TESTRAIL_USER="your@email.com"
export TESTRAIL_API_KEY="your-api-key"
```
### BrowserStack (Optional)
```bash
export BROWSERSTACK_USERNAME="your-username"
export BROWSERSTACK_ACCESS_KEY="your-access-key"
```
## Quick Reference
See `reference/` directory for:
- `golden-rules.md` — The 10 non-negotiable rules
- `locators.md` — Complete locator priority with cheat sheet
- `assertions.md` — Web-first assertions reference
- `fixtures.md` — Custom fixtures and storageState patterns
- `common-pitfalls.md` — Top 10 mistakes and fixes
- `flaky-tests.md` — Diagnosis commands and quick fixes
See `templates/README.md` for the full template index.
@@ -0,0 +1,89 @@
# Assertions Reference
## Web-First Assertions (Always Use These)
Auto-retry until timeout. Safe for dynamic content.
```typescript
// Visibility
await expect(locator).toBeVisible();
await expect(locator).not.toBeVisible();
await expect(locator).toBeHidden();
// Text
await expect(locator).toHaveText('exact text');
await expect(locator).toHaveText(/partial/i);
await expect(locator).toContainText('partial');
// Value (inputs)
await expect(locator).toHaveValue('entered text');
await expect(locator).toHaveValues(['option1', 'option2']);
// Attributes
await expect(locator).toHaveAttribute('href', '/dashboard');
await expect(locator).toHaveClass(/active/);
await expect(locator).toHaveId('main-nav');
// State
await expect(locator).toBeEnabled();
await expect(locator).toBeDisabled();
await expect(locator).toBeChecked();
await expect(locator).toBeEditable();
await expect(locator).toBeFocused();
await expect(locator).toBeAttached();
// Count
await expect(locator).toHaveCount(5);
await expect(locator).toHaveCount(0); // element doesn't exist
// CSS
await expect(locator).toHaveCSS('color', 'rgb(255, 0, 0)');
// Screenshots
await expect(locator).toHaveScreenshot('button.png');
await expect(page).toHaveScreenshot('full-page.png');
```
## Page Assertions
```typescript
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveURL(/\/dashboard/);
await expect(page).toHaveTitle('Dashboard - App');
await expect(page).toHaveTitle(/Dashboard/);
```
## Anti-Patterns (Never Do This)
```typescript
// BAD — no auto-retry
const text = await locator.textContent();
expect(text).toBe('Hello');
// BAD — snapshot in time, not reactive
const isVisible = await locator.isVisible();
expect(isVisible).toBe(true);
// BAD — evaluating in page context
const value = await page.evaluate(() =>
document.querySelector('input')?.value
);
expect(value).toBe('test');
```
## Custom Timeout
```typescript
// Override timeout for slow operations
await expect(locator).toBeVisible({ timeout: 30_000 });
```
## Soft Assertions
Continue test even if assertion fails (report all failures at end):
```typescript
await expect.soft(locator).toHaveText('Expected');
await expect.soft(page).toHaveURL('/next');
// Test continues even if above fail
```
@@ -0,0 +1,137 @@
# Common Pitfalls (Top 10)
## 1. waitForTimeout
**Symptom:** Slow, flaky tests.
```typescript
// BAD
await page.waitForTimeout(3000);
// GOOD
await expect(page.getByTestId('result')).toBeVisible();
```
## 2. Non-Web-First Assertions
**Symptom:** Assertions fail on dynamic content.
```typescript
// BAD — checks once, no retry
const text = await page.textContent('.msg');
expect(text).toBe('Done');
// GOOD — retries until timeout
await expect(page.getByText('Done')).toBeVisible();
```
## 3. Missing await
**Symptom:** Random passes/failures, tests seem to skip steps.
```typescript
// BAD
page.goto('/dashboard');
expect(page.getByText('Welcome')).toBeVisible();
// GOOD
await page.goto('/dashboard');
await expect(page.getByText('Welcome')).toBeVisible();
```
## 4. Hardcoded URLs
**Symptom:** Tests break in different environments.
```typescript
// BAD
await page.goto('http://localhost:3000/login');
// GOOD — uses baseURL from config
await page.goto('/login');
```
## 5. CSS Selectors Instead of Roles
**Symptom:** Tests break after CSS refactors.
```typescript
// BAD
await page.click('#submit-btn');
// GOOD
await page.getByRole('button', { name: 'Submit' }).click();
```
## 6. Shared State Between Tests
**Symptom:** Tests pass alone, fail in suite.
```typescript
// BAD — test B depends on test A
let userId: string;
test('create user', async () => { userId = '123'; });
test('edit user', async () => { /* uses userId */ });
// GOOD — each test is independent
test('edit user', async ({ request }) => {
const res = await request.post('/api/users', { data: { name: 'Test' } });
const { id } = await res.json();
// ...
});
```
## 7. Using networkidle
**Symptom:** Tests hang or timeout unpredictably.
```typescript
// BAD — waits for all network activity to stop
await page.goto('/dashboard', { waitUntil: 'networkidle' });
// GOOD — wait for specific content
await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
```
## 8. Not Waiting for Navigation
**Symptom:** Assertions run on wrong page.
```typescript
// BAD — click navigates but we don't wait
await page.getByRole('link', { name: 'Settings' }).click();
await expect(page.getByRole('heading')).toHaveText('Settings');
// GOOD — wait for URL change
await page.getByRole('link', { name: 'Settings' }).click();
await expect(page).toHaveURL('/settings');
await expect(page.getByRole('heading')).toHaveText('Settings');
```
## 9. Testing Implementation, Not Behavior
**Symptom:** Tests break on every refactor.
```typescript
// BAD — tests CSS class (implementation detail)
await expect(page.locator('.btn')).toHaveClass('btn-primary active');
// GOOD — tests what the user sees
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled();
```
## 10. No Error Case Tests
**Symptom:** App breaks on errors but all tests pass.
```typescript
// Missing: what happens when the API fails?
test('should handle API error', async ({ page }) => {
await page.route('**/api/data', (route) =>
route.fulfill({ status: 500 })
);
await page.goto('/dashboard');
await expect(page.getByText(/error|try again/i)).toBeVisible();
});
```
@@ -0,0 +1,121 @@
# Fixtures Reference
## What Are Fixtures
Fixtures provide setup/teardown for each test. They replace `beforeEach`/`afterEach` for shared state and are composable, type-safe, and lazy (only run when used).
## Creating Custom Fixtures
```typescript
// fixtures.ts
import { test as base, expect } from '@playwright/test';
// Define fixture types
type MyFixtures = {
authenticatedPage: Page;
testUser: { email: string; password: string };
apiClient: APIRequestContext;
};
export const test = base.extend<MyFixtures>({
// Simple value fixture
testUser: async ({}, use) => {
await use({
email: `test-${Date.now()}@example.com`,
password: 'Test123!',
});
},
// Fixture with setup and teardown
authenticatedPage: async ({ page, testUser }, use) => {
// Setup: log in
await page.goto('/login');
await page.getByLabel('Email').fill(testUser.email);
await page.getByLabel('Password').fill(testUser.password);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('/dashboard');
// Provide the authenticated page to the test
await use(page);
// Teardown: clean up (optional)
await page.goto('/logout');
},
// API client fixture
apiClient: async ({ playwright }, use) => {
const context = await playwright.request.newContext({
baseURL: 'http://localhost:3000',
extraHTTPHeaders: {
Authorization: `Bearer ${process.env.API_TOKEN}`,
},
});
await use(context);
await context.dispose();
},
});
export { expect };
```
## Using Fixtures in Tests
```typescript
import { test, expect } from './fixtures';
test('should show dashboard for logged in user', async ({ authenticatedPage }) => {
// authenticatedPage is already logged in
await expect(authenticatedPage.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
test('should create item via API', async ({ apiClient }) => {
const response = await apiClient.post('/api/items', {
data: { name: 'Test Item' },
});
expect(response.ok()).toBeTruthy();
});
```
## Shared Auth State (storageState)
For performance, authenticate once and reuse:
```typescript
// auth.setup.ts
import { test as setup } from '@playwright/test';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('admin@example.com');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: '.auth/user.json' });
});
```
```typescript
// playwright.config.ts
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: {
storageState: '.auth/user.json',
},
dependencies: ['setup'],
},
],
});
```
## When to Use What
| Need | Use |
|---|---|
| Shared login state | `storageState` + setup project |
| Per-test data creation | Custom fixture with API calls |
| Reusable page helpers | Custom fixture returning page |
| Test data cleanup | Fixture teardown (after `use()`) |
| Config values | Simple value fixture |
@@ -0,0 +1,56 @@
# Flaky Test Quick Reference
## Diagnosis Commands
```bash
# Burn-in: expose timing issues
npx playwright test tests/checkout.spec.ts --repeat-each=10
# Isolation: expose state leaks
npx playwright test tests/checkout.spec.ts --grep "adds item" --workers=1
# Full trace: capture everything
npx playwright test tests/checkout.spec.ts --trace=on --retries=0
# Parallel stress: expose race conditions
npx playwright test --fully-parallel --workers=4 --repeat-each=5
```
## Four Categories
| Category | Symptom | Fix |
|---|---|---|
| **Timing** | Fails intermittently | Replace waits with assertions |
| **Isolation** | Fails in suite, passes alone | Remove shared state |
| **Environment** | Fails in CI only | Match viewport, fonts, timezone |
| **Infrastructure** | Random crashes | Reduce workers, increase memory |
## Quick Fixes
**Timing → Add proper waits:**
```typescript
// Wait for specific response
const response = page.waitForResponse('**/api/data');
await page.getByRole('button', { name: 'Load' }).click();
await response;
await expect(page.getByTestId('results')).toBeVisible();
```
**Isolation → Unique test data:**
```typescript
const uniqueEmail = `test-${Date.now()}@example.com`;
```
**Environment → Explicit viewport:**
```typescript
test.use({ viewport: { width: 1280, height: 720 } });
```
**Infrastructure → CI-safe config:**
```typescript
export default defineConfig({
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
timeout: process.env.CI ? 60_000 : 30_000,
});
```
@@ -0,0 +1,12 @@
# Golden Rules
1. **`getByRole()` over CSS/XPath** — resilient to markup changes, mirrors assistive technology
2. **Never `page.waitForTimeout()`** — use `expect(locator).toBeVisible()` or `page.waitForURL()`
3. **Web-first assertions**`expect(locator)` auto-retries; `expect(await locator.textContent())` does not
4. **Isolate every test** — no shared state, no execution-order dependencies
5. **`baseURL` in config** — zero hardcoded URLs in tests
6. **Retries: `2` in CI, `0` locally** — surface flakiness where it matters
7. **Traces: `'on-first-retry'`** — rich debugging artifacts without CI slowdown
8. **Fixtures over globals** — share state via `test.extend()`, not module-level variables
9. **One behavior per test** — multiple related `expect()` calls are fine
10. **Mock external services only** — never mock your own app; mock third-party APIs, payment gateways, email
@@ -0,0 +1,77 @@
# Locator Priority
Use the first option that works:
| Priority | Locator | Use for |
|---|---|---|
| 1 | `getByRole('button', { name: 'Submit' })` | Buttons, links, headings, form elements |
| 2 | `getByLabel('Email address')` | Form fields with associated labels |
| 3 | `getByText('Welcome back')` | Non-interactive text content |
| 4 | `getByPlaceholder('Search...')` | Inputs with placeholder text |
| 5 | `getByAltText('Company logo')` | Images with alt text |
| 6 | `getByTitle('Close dialog')` | Elements with title attribute |
| 7 | `getByTestId('checkout-summary')` | When no semantic option exists |
| 8 | `page.locator('.legacy-widget')` | CSS/XPath — absolute last resort |
## Role Locator Cheat Sheet
```typescript
// Buttons — <button>, <input type="submit">, [role="button"]
page.getByRole('button', { name: 'Save changes' })
// Links — <a href>
page.getByRole('link', { name: 'View profile' })
// Headings — h1-h6
page.getByRole('heading', { name: 'Dashboard', level: 1 })
// Text inputs — by label association
page.getByRole('textbox', { name: 'Email' })
// Checkboxes
page.getByRole('checkbox', { name: 'Remember me' })
// Radio buttons
page.getByRole('radio', { name: 'Monthly billing' })
// Dropdowns — <select>
page.getByRole('combobox', { name: 'Country' })
// Navigation
page.getByRole('navigation', { name: 'Main' })
// Tables
page.getByRole('table', { name: 'Recent orders' })
// Rows within tables
page.getByRole('row', { name: /Order #123/ })
// Tab panels
page.getByRole('tab', { name: 'Settings' })
// Dialogs
page.getByRole('dialog', { name: 'Confirm deletion' })
// Alerts
page.getByRole('alert')
```
## Filtering and Chaining
```typescript
// Filter by text
page.getByRole('listitem').filter({ hasText: 'Product A' })
// Filter by child locator
page.getByRole('listitem').filter({
has: page.getByRole('button', { name: 'Buy' })
})
// Chain locators
page.getByRole('navigation').getByRole('link', { name: 'Settings' })
// Nth match
page.getByRole('listitem').nth(0)
page.getByRole('listitem').first()
page.getByRole('listitem').last()
```
@@ -0,0 +1,123 @@
# Test Case Templates
55 ready-to-use, parametrizable Playwright test templates. Each includes TypeScript and JavaScript examples with `{{placeholder}}` markers for customization.
## Usage
Templates are loaded by `/pw:generate` when it detects a matching scenario. You can also reference them directly:
```
/pw:generate "login flow" → loads templates/auth/login.md
```
## Template Index
### Authentication (8)
| Template | Tests |
|---|---|
| [login.md](auth/login.md) | Email/password login, social login, remember me |
| [logout.md](auth/logout.md) | Logout from nav, session cleanup |
| [sso.md](auth/sso.md) | SSO redirect flow, callback handling |
| [mfa.md](auth/mfa.md) | 2FA code entry, backup codes |
| [password-reset.md](auth/password-reset.md) | Request reset, enter new password, expired link |
| [session-timeout.md](auth/session-timeout.md) | Auto-logout, session refresh |
| [remember-me.md](auth/remember-me.md) | Persistent login, cookie expiry |
| [rbac.md](auth/rbac.md) | Role-based access, forbidden page |
### CRUD Operations (6)
| Template | Tests |
|---|---|
| [create.md](crud/create.md) | Create entity with form |
| [read.md](crud/read.md) | View details, list view |
| [update.md](crud/update.md) | Edit entity, inline edit |
| [delete.md](crud/delete.md) | Delete with confirmation |
| [bulk-operations.md](crud/bulk-operations.md) | Select multiple, bulk actions |
| [soft-delete.md](crud/soft-delete.md) | Archive, restore |
### Checkout (6)
| Template | Tests |
|---|---|
| [add-to-cart.md](checkout/add-to-cart.md) | Add item, update cart |
| [update-quantity.md](checkout/update-quantity.md) | Increase, decrease, remove |
| [apply-coupon.md](checkout/apply-coupon.md) | Valid/invalid/expired codes |
| [payment.md](checkout/payment.md) | Card form, validation, processing |
| [order-confirm.md](checkout/order-confirm.md) | Success page, order details |
| [order-history.md](checkout/order-history.md) | List orders, pagination |
### Search & Filter (5)
| Template | Tests |
|---|---|
| [basic-search.md](search/basic-search.md) | Search input, results |
| [filters.md](search/filters.md) | Category, price, checkboxes |
| [sorting.md](search/sorting.md) | Sort by name, date, price |
| [pagination.md](search/pagination.md) | Page nav, items per page |
| [empty-state.md](search/empty-state.md) | No results, clear filters |
### Forms (6)
| Template | Tests |
|---|---|
| [single-step.md](forms/single-step.md) | Simple form submission |
| [multi-step.md](forms/multi-step.md) | Wizard with progress |
| [validation.md](forms/validation.md) | Required, format, inline errors |
| [file-upload.md](forms/file-upload.md) | Single, multiple, drag-drop |
| [conditional-fields.md](forms/conditional-fields.md) | Show/hide based on selection |
| [autosave.md](forms/autosave.md) | Draft save, restore |
### Dashboard (5)
| Template | Tests |
|---|---|
| [data-loading.md](dashboard/data-loading.md) | Loading state, skeleton, data |
| [chart-rendering.md](dashboard/chart-rendering.md) | Chart visible, tooltips |
| [date-range-filter.md](dashboard/date-range-filter.md) | Date picker, presets |
| [export.md](dashboard/export.md) | CSV/PDF download |
| [realtime-updates.md](dashboard/realtime-updates.md) | Live data, websocket |
### Settings (4)
| Template | Tests |
|---|---|
| [profile-update.md](settings/profile-update.md) | Name, email, avatar |
| [password-change.md](settings/password-change.md) | Current + new password |
| [notification-prefs.md](settings/notification-prefs.md) | Toggle, save prefs |
| [account-delete.md](settings/account-delete.md) | Confirm deletion |
### Onboarding (4)
| Template | Tests |
|---|---|
| [registration.md](onboarding/registration.md) | Signup form, validation |
| [email-verification.md](onboarding/email-verification.md) | Verify link, resend |
| [welcome-tour.md](onboarding/welcome-tour.md) | Step tour, skip |
| [first-time-setup.md](onboarding/first-time-setup.md) | Initial config |
### Notifications (3)
| Template | Tests |
|---|---|
| [in-app.md](notifications/in-app.md) | Badge, dropdown, mark read |
| [toast-messages.md](notifications/toast-messages.md) | Success/error toasts |
| [notification-center.md](notifications/notification-center.md) | List, filter, clear |
### API Testing (5)
| Template | Tests |
|---|---|
| [rest-crud.md](api/rest-crud.md) | GET/POST/PUT/DELETE |
| [graphql.md](api/graphql.md) | Query, mutation |
| [auth-headers.md](api/auth-headers.md) | Token, expired, refresh |
| [error-responses.md](api/error-responses.md) | 400-500 status handling |
| [rate-limiting.md](api/rate-limiting.md) | Rate limit, retry-after |
### Accessibility (3)
| Template | Tests |
|---|---|
| [keyboard-navigation.md](accessibility/keyboard-navigation.md) | Tab order, focus |
| [screen-reader.md](accessibility/screen-reader.md) | ARIA labels, live regions |
| [color-contrast.md](accessibility/color-contrast.md) | Contrast ratios |
@@ -0,0 +1,162 @@
# Color Contrast Template
Tests contrast ratios, color-blind safe palettes, and focus indicator visibility.
## Prerequisites
- App running at `{{baseUrl}}`
- axe-playwright installed: `npm i -D @axe-core/playwright`
- Page under test: `{{baseUrl}}/{{pagePath}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Color Contrast', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{pagePath}}');
});
// Happy path: no color contrast violations (axe)
test('has no color contrast violations', async ({ page }) => {
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.withRules(['color-contrast'])
.analyze();
expect(results.violations).toEqual([]);
});
// Happy path: body text contrast ratio ≥ 4.5:1
test('body text meets WCAG AA contrast ratio', async ({ page }) => {
const ratio = await page.evaluate(() => {
const el = document.querySelector('p, main, [class*="body"]') as HTMLElement;
if (!el) return null;
const style = getComputedStyle(el);
// Simplified check — use axe for full verification
return style.color !== 'rgba(0, 0, 0, 0)' ? style.color : null;
});
expect(ratio).toBeTruthy();
});
// Happy path: large text contrast ratio ≥ 3:1
test('headings have sufficient contrast', async ({ page }) => {
const results = await new AxeBuilder({ page })
.withRules(['color-contrast'])
.include('h1, h2, h3, h4, h5, h6')
.analyze();
expect(results.violations).toEqual([]);
});
// Happy path: focus indicator meets contrast requirement
test('focus indicator is visible and meets contrast', async ({ page }) => {
await page.getByRole('button').first().focus();
const outline = await page.getByRole('button').first().evaluate(el => {
const s = getComputedStyle(el, ':focus');
return {
outlineWidth: parseFloat(s.outlineWidth),
outlineColor: s.outlineColor,
outlineStyle: s.outlineStyle,
};
});
expect(outline.outlineWidth).toBeGreaterThanOrEqual(2);
expect(outline.outlineColor).not.toBe('rgba(0, 0, 0, 0)');
});
// Happy path: error text contrast
test('error messages have sufficient contrast', async ({ page }) => {
await page.goto('{{baseUrl}}/{{formPath}}');
await page.getByRole('button', { name: /submit/i }).click();
const results = await new AxeBuilder({ page })
.withRules(['color-contrast'])
.include('[class*="error"], [role="alert"]')
.analyze();
expect(results.violations).toEqual([]);
});
// Happy path: no information conveyed by color alone
test('status badges use text or icon in addition to color', async ({ page }) => {
const badges = page.getByRole('status');
const count = await badges.count();
for (let i = 0; i < count; i++) {
const text = await badges.nth(i).textContent();
const ariaLabel = await badges.nth(i).getAttribute('aria-label');
expect(text?.trim() || ariaLabel).toBeTruthy();
}
});
// Edge case: full page axe scan for all WCAG 2.1 AA issues
test('full page passes WCAG 2.1 AA axe scan', async ({ page }) => {
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.exclude('{{knownExcludedSelector}}')
.analyze();
if (results.violations.length > 0) {
const messages = results.violations.map(v =>
`${v.id}: ${v.description}${v.nodes.map(n => n.target).join(', ')}`
).join('\n');
throw new Error(`Axe violations:\n${messages}`);
}
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
const AxeBuilder = require('@axe-core/playwright').default;
test.describe('Color Contrast', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{pagePath}}');
});
test('no color contrast violations', async ({ page }) => {
const results = await new AxeBuilder({ page })
.withRules(['color-contrast'])
.analyze();
expect(results.violations).toEqual([]);
});
test('focus indicator is visible', async ({ page }) => {
await page.getByRole('button').first().focus();
const outlineWidth = await page.getByRole('button').first().evaluate(
el => parseFloat(getComputedStyle(el).outlineWidth)
);
expect(outlineWidth).toBeGreaterThanOrEqual(2);
});
test('status badges use text not just color', async ({ page }) => {
const badges = page.getByRole('status');
const count = await badges.count();
for (let i = 0; i < count; i++) {
const text = await badges.nth(i).textContent();
const label = await badges.nth(i).getAttribute('aria-label');
expect((text?.trim()) || label).toBeTruthy();
}
});
test('full page passes WCAG 2.1 AA', async ({ page }) => {
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.analyze();
expect(results.violations).toEqual([]);
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Contrast violations | axe color-contrast rule → no violations |
| Body text contrast | Text color non-transparent |
| Heading contrast | axe include h1-h6 → no violations |
| Focus indicator | outline-width ≥ 2px and non-transparent |
| Error text contrast | Error messages pass axe |
| Color-only info | Badges have text or aria-label |
| Full axe scan | WCAG 2.1 AA complete scan |
@@ -0,0 +1,149 @@
# Keyboard Navigation Template
Tests tab order, focus visibility, and keyboard shortcuts.
## Prerequisites
- App running at `{{baseUrl}}`
- Page under test: `{{baseUrl}}/{{pagePath}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Keyboard Navigation', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{pagePath}}');
});
// Happy path: Tab moves through interactive elements in logical order
test('Tab key cycles through focusable elements in correct order', async ({ page }) => {
await page.keyboard.press('Tab');
await expect(page.getByRole('link', { name: /skip.*main|skip navigation/i }))
.toBeFocused();
await page.keyboard.press('Tab');
// First nav link focused
const navLinks = page.getByRole('navigation').getByRole('link');
await expect(navLinks.first()).toBeFocused();
});
// Happy path: skip link skips to main content
test('skip-to-content link moves focus to main', async ({ page }) => {
await page.keyboard.press('Tab');
await page.keyboard.press('Enter');
await expect(page.getByRole('main')).toBeFocused();
});
// Happy path: focus visible on all interactive elements
test('focus ring visible on interactive elements', async ({ page }) => {
const interactive = page.getByRole('button').first();
await interactive.focus();
const box = await interactive.boundingBox();
// Take screenshot with focus and assert element has outline (visual only — use CSS check)
const outline = await interactive.evaluate(el =>
getComputedStyle(el).outlineWidth
);
expect(parseFloat(outline)).toBeGreaterThan(0);
});
// Happy path: modal traps focus
test('focus is trapped within modal when open', async ({ page }) => {
await page.getByRole('button', { name: /open modal/i }).click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible();
// Repeatedly Tab and verify focus stays within dialog
for (let i = 0; i < 10; i++) {
await page.keyboard.press('Tab');
const focused = page.locator(':focus');
await expect(modal).toContainElement(focused);
}
});
// Happy path: Escape closes modal
test('Escape key closes modal', async ({ page }) => {
await page.getByRole('button', { name: /open modal/i }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).toBeHidden();
// Focus returns to trigger button
await expect(page.getByRole('button', { name: /open modal/i })).toBeFocused();
});
// Happy path: keyboard shortcut
test('keyboard shortcut {{shortcutKey}} triggers action', async ({ page }) => {
await page.keyboard.press('{{shortcutKey}}');
await expect(page.getByRole('{{shortcutTargetRole}}', { name: /{{shortcutTargetName}}/i })).toBeVisible();
});
// Error case: focus not lost on dynamic content update
test('focus stays on element after async update', async ({ page }) => {
const btn = page.getByRole('button', { name: /{{asyncButton}}/i });
await btn.focus();
await btn.press('Enter');
await expect(btn).toBeFocused();
});
// Edge case: arrow keys navigate within component (listbox, tabs)
test('arrow keys navigate within tab list', async ({ page }) => {
const firstTab = page.getByRole('tab').first();
await firstTab.focus();
await page.keyboard.press('ArrowRight');
await expect(page.getByRole('tab').nth(1)).toBeFocused();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Keyboard Navigation', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{pagePath}}');
});
test('skip link moves focus to main content', async ({ page }) => {
await page.keyboard.press('Tab');
await page.keyboard.press('Enter');
await expect(page.getByRole('main')).toBeFocused();
});
test('Escape closes modal and returns focus', async ({ page }) => {
await page.getByRole('button', { name: /open modal/i }).click();
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).toBeHidden();
await expect(page.getByRole('button', { name: /open modal/i })).toBeFocused();
});
test('focus ring visible on buttons', async ({ page }) => {
await page.getByRole('button').first().focus();
const outline = await page.getByRole('button').first().evaluate(
el => getComputedStyle(el).outlineWidth
);
expect(parseFloat(outline)).toBeGreaterThan(0);
});
test('arrow keys navigate tab list', async ({ page }) => {
await page.getByRole('tab').first().focus();
await page.keyboard.press('ArrowRight');
await expect(page.getByRole('tab').nth(1)).toBeFocused();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Tab order | Skip link first, nav links after |
| Skip link | Moves focus to `<main>` |
| Focus ring | CSS outline-width > 0 on focus |
| Focus trap | Tab stays within open modal |
| Escape closes | Modal closed, trigger re-focused |
| Keyboard shortcut | Custom key triggers action |
| Focus after update | Focus not lost on async update |
| Arrow keys | Tab/listbox/menu arrow navigation |
@@ -0,0 +1,159 @@
# Screen Reader Template
Tests ARIA labels, live regions, and announcements for assistive technology.
## Prerequisites
- App running at `{{baseUrl}}`
- Page under test: `{{baseUrl}}/{{pagePath}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Screen Reader Accessibility', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{pagePath}}');
});
// Happy path: page has descriptive title
test('page has meaningful title', async ({ page }) => {
await expect(page).toHaveTitle(/{{expectedPageTitle}}/i);
});
// Happy path: main landmark exists
test('page has main landmark', async ({ page }) => {
await expect(page.getByRole('main')).toBeVisible();
});
// Happy path: images have alt text
test('informational images have non-empty alt text', async ({ page }) => {
const images = page.getByRole('img');
const count = await images.count();
for (let i = 0; i < count; i++) {
const alt = await images.nth(i).getAttribute('alt');
const isDecorative = await images.nth(i).getAttribute('role') === 'presentation'
|| alt === '';
if (!isDecorative) {
expect(alt).toBeTruthy();
}
}
});
// Happy path: form fields have accessible labels
test('all form inputs have associated labels', async ({ page }) => {
const inputs = page.getByRole('textbox');
const count = await inputs.count();
for (let i = 0; i < count; i++) {
const input = inputs.nth(i);
const labelledBy = await input.getAttribute('aria-labelledby');
const ariaLabel = await input.getAttribute('aria-label');
const id = await input.getAttribute('id');
const hasLabel = labelledBy || ariaLabel || (id && await page.locator(`label[for="${id}"]`).count() > 0);
expect(hasLabel).toBeTruthy();
}
});
// Happy path: live region announces updates
test('live region announces async updates', async ({ page }) => {
const liveRegion = page.getByRole('status').or(page.locator('[aria-live]'));
await page.getByRole('button', { name: /{{asyncTrigger}}/i }).click();
await expect(liveRegion).not.toBeEmpty();
});
// Happy path: alert role used for errors
test('validation errors use role="alert"', async ({ page }) => {
await page.goto('{{baseUrl}}/{{formPath}}');
await page.getByRole('button', { name: /submit/i }).click();
await expect(page.getByRole('alert')).toBeVisible();
const liveValue = await page.getByRole('alert').first().getAttribute('aria-live');
expect(liveValue ?? 'assertive').toBe('assertive');
});
// Happy path: buttons have accessible names
test('icon-only buttons have aria-label', async ({ page }) => {
const buttons = page.getByRole('button');
const count = await buttons.count();
for (let i = 0; i < count; i++) {
const btn = buttons.nth(i);
const text = (await btn.textContent())?.trim();
const ariaLabel = await btn.getAttribute('aria-label');
const ariaLabelledBy = await btn.getAttribute('aria-labelledby');
// Must have visible text or aria-label or aria-labelledby
expect(text || ariaLabel || ariaLabelledBy).toBeTruthy();
}
});
// Happy path: navigation landmark labelled
test('multiple nav elements have distinct aria-labels', async ({ page }) => {
const navs = page.getByRole('navigation');
const count = await navs.count();
if (count > 1) {
const labels = new Set<string>();
for (let i = 0; i < count; i++) {
const label = await navs.nth(i).getAttribute('aria-label') ?? '';
labels.add(label);
}
expect(labels.size).toBe(count); // all unique
}
});
// Edge case: expanded/collapsed state communicated
test('accordion aria-expanded reflects open/closed state', async ({ page }) => {
const trigger = page.getByRole('button', { name: /{{accordionItem}}/i });
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
await trigger.click();
await expect(trigger).toHaveAttribute('aria-expanded', 'true');
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Screen Reader Accessibility', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{pagePath}}');
});
test('page has meaningful title', async ({ page }) => {
await expect(page).toHaveTitle(/{{expectedPageTitle}}/i);
});
test('main landmark exists', async ({ page }) => {
await expect(page.getByRole('main')).toBeVisible();
});
test('validation errors use role=alert', async ({ page }) => {
await page.goto('{{baseUrl}}/{{formPath}}');
await page.getByRole('button', { name: /submit/i }).click();
await expect(page.getByRole('alert')).toBeVisible();
});
test('accordion aria-expanded toggles', async ({ page }) => {
const trigger = page.getByRole('button', { name: /{{accordionItem}}/i });
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
await trigger.click();
await expect(trigger).toHaveAttribute('aria-expanded', 'true');
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Page title | `<title>` matches expected pattern |
| Main landmark | `<main>` present and visible |
| Image alt text | Informational images have non-empty alt |
| Form labels | All inputs have accessible label |
| Live region | Status region updated on async action |
| Alert role | Errors use role=alert (assertive) |
| Button names | Icon buttons have aria-label |
| Unique nav labels | Multiple navs have distinct labels |
| aria-expanded | Accordion state communicated |
@@ -0,0 +1,148 @@
# Auth Headers Template
Tests token authentication, expired token handling, and token refresh flow.
## Prerequisites
- Valid token: `{{apiToken}}`
- Expired token: `{{expiredApiToken}}`
- Refresh token: `{{refreshToken}}`
- API base: `{{apiBaseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('API Auth Headers', () => {
// Happy path: valid Bearer token accepted
test('accepts valid Bearer token', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/me', {
headers: { 'Authorization': `Bearer {{apiToken}}` },
});
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.id).toBeTruthy();
});
// Happy path: API key in header accepted
test('accepts API key header', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/{{entityName}}s', {
headers: { 'X-API-Key': '{{apiKey}}' },
});
expect(res.status()).toBe(200);
});
// Error case: no auth header returns 401
test('returns 401 without auth header', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/me');
expect(res.status()).toBe(401);
const body = await res.json();
expect(body.error ?? body.message).toMatch(/unauthorized|authentication required/i);
});
// Error case: expired token returns 401
test('returns 401 for expired token', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/me', {
headers: { 'Authorization': `Bearer {{expiredApiToken}}` },
});
expect(res.status()).toBe(401);
const body = await res.json();
expect(body.error ?? body.code).toMatch(/token.*expired|expired_token/i);
});
// Happy path: refresh token obtains new access token
test('refreshes expired token and retries request', async ({ request }) => {
// Step 1: refresh
const refresh = await request.post('{{apiBaseUrl}}/auth/refresh', {
data: { refresh_token: '{{refreshToken}}' },
});
expect(refresh.status()).toBe(200);
const { access_token } = await refresh.json();
expect(access_token).toBeTruthy();
// Step 2: use new token
const res = await request.get('{{apiBaseUrl}}/me', {
headers: { 'Authorization': `Bearer ${access_token}` },
});
expect(res.status()).toBe(200);
});
// Error case: invalid token format returns 401
test('returns 401 for malformed token', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/me', {
headers: { 'Authorization': 'Bearer not.a.jwt' },
});
expect(res.status()).toBe(401);
});
// Edge case: token in cookie vs header
test('accepts session cookie as auth alternative', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/me', {
headers: { 'Cookie': `{{sessionCookieName}}={{sessionCookieValue}}` },
});
expect(res.status()).toBe(200);
});
// Edge case: revoked token returns 401
test('returns 401 for revoked token', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/me', {
headers: { 'Authorization': `Bearer {{revokedApiToken}}` },
});
expect(res.status()).toBe(401);
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('API Auth Headers', () => {
test('accepts valid Bearer token', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/me', {
headers: { 'Authorization': `Bearer {{apiToken}}` },
});
expect(res.status()).toBe(200);
});
test('returns 401 without auth header', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/me');
expect(res.status()).toBe(401);
});
test('returns 401 for expired token', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/me', {
headers: { 'Authorization': `Bearer {{expiredApiToken}}` },
});
expect(res.status()).toBe(401);
});
test('refreshes token and retries', async ({ request }) => {
const refresh = await request.post('{{apiBaseUrl}}/auth/refresh', {
data: { refresh_token: '{{refreshToken}}' },
});
const { access_token } = await refresh.json();
const res = await request.get('{{apiBaseUrl}}/me', {
headers: { 'Authorization': `Bearer ${access_token}` },
});
expect(res.status()).toBe(200);
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Valid Bearer | 200 with user data |
| API key | X-API-Key header accepted |
| No auth | 401 + error message |
| Expired token | 401 + expired error code |
| Token refresh | New token from refresh endpoint |
| Malformed token | 401 for non-JWT |
| Cookie auth | Session cookie accepted |
| Revoked token | 401 for revoked token |
@@ -0,0 +1,157 @@
# API Error Responses Template
Tests 400, 401, 403, 404, and 500 HTTP error handling.
## Prerequisites
- Valid auth token: `{{apiToken}}`
- API base: `{{apiBaseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
const validHeaders = {
'Authorization': `Bearer {{apiToken}}`,
'Content-Type': 'application/json',
};
test.describe('API Error Responses', () => {
// 400 Bad Request
test('POST with invalid body returns 400', async ({ request }) => {
const res = await request.post('{{apiBaseUrl}}/{{entityName}}s', {
headers: validHeaders,
data: { name: '' }, // name too short / blank
});
expect(res.status()).toBe(400);
const body = await res.json();
expect(body.message ?? body.error).toMatch(/bad request|invalid/i);
expect(body.errors ?? body.details).toBeDefined();
});
// 401 Unauthorized
test('request without token returns 401', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/{{entityName}}s');
expect(res.status()).toBe(401);
const body = await res.json();
expect(body.message ?? body.error).toMatch(/unauthorized|authentication/i);
});
// 403 Forbidden
test('accessing admin endpoint as regular user returns 403', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/admin/users', {
headers: { 'Authorization': `Bearer {{userToken}}` },
});
expect(res.status()).toBe(403);
const body = await res.json();
expect(body.message ?? body.error).toMatch(/forbidden|insufficient.*permission/i);
});
// 404 Not Found
test('GET non-existent resource returns 404', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/{{entityName}}s/999999', { headers: validHeaders });
expect(res.status()).toBe(404);
const body = await res.json();
expect(body.message ?? body.error).toMatch(/not found/i);
});
// 422 Unprocessable Entity
test('POST with missing required field returns 422', async ({ request }) => {
const res = await request.post('{{apiBaseUrl}}/{{entityName}}s', {
headers: validHeaders,
data: { description: 'no name provided' },
});
expect([422, 400]).toContain(res.status());
const body = await res.json();
expect(body.errors ?? body.details).toBeDefined();
});
// 429 Too Many Requests (handled in rate-limiting template — kept here for completeness)
test('returns 429 when rate limit exceeded', async ({ request }) => {
let lastStatus = 0;
for (let i = 0; i < {{rateLimitThreshold}} + 1; i++) {
const res = await request.get('{{apiBaseUrl}}/{{rateLimitedEndpoint}}', { headers: validHeaders });
lastStatus = res.status();
if (lastStatus === 429) break;
}
expect(lastStatus).toBe(429);
});
// 500 Internal Server Error
test('server error returns 500 with error body', async ({ page }) => {
await page.route('{{apiBaseUrl}}/{{entityName}}s', route =>
route.fulfill({ status: 500, body: JSON.stringify({ error: 'Internal Server Error' }) })
);
const res = await page.request.get('{{apiBaseUrl}}/{{entityName}}s', { headers: validHeaders });
expect(res.status()).toBe(500);
const body = await res.json();
expect(body.error ?? body.message).toBeTruthy();
});
// Edge case: error response has consistent shape
test('all errors return JSON with error field', async ({ request }) => {
const endpoints = [
{ method: 'get' as const, url: '{{apiBaseUrl}}/{{entityName}}s/000000', headers: validHeaders },
{ method: 'get' as const, url: '{{apiBaseUrl}}/{{entityName}}s' },
];
for (const ep of endpoints) {
const res = await request[ep.method](ep.url, { headers: ep.headers });
if (res.status() >= 400) {
const body = await res.json();
expect(body.error ?? body.message ?? body.errors).toBeDefined();
}
}
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
const headers = { 'Authorization': `Bearer {{apiToken}}`, 'Content-Type': 'application/json' };
test.describe('API Error Responses', () => {
test('POST with invalid body returns 400', async ({ request }) => {
const res = await request.post('{{apiBaseUrl}}/{{entityName}}s', {
headers,
data: { name: '' },
});
expect(res.status()).toBe(400);
});
test('no token returns 401', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/{{entityName}}s');
expect(res.status()).toBe(401);
});
test('regular user on admin endpoint returns 403', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/admin/users', {
headers: { 'Authorization': `Bearer {{userToken}}` },
});
expect(res.status()).toBe(403);
});
test('non-existent resource returns 404', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/{{entityName}}s/999999', { headers });
expect(res.status()).toBe(404);
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| 400 Bad Request | Invalid body → 400 + errors detail |
| 401 Unauthorized | No token → 401 |
| 403 Forbidden | Wrong role → 403 |
| 404 Not Found | Missing resource → 404 |
| 422 Unprocessable | Missing required field → 422/400 |
| 429 Rate Limit | Threshold exceeded → 429 |
| 500 Server Error | Mocked 500 → error body present |
| Consistent shape | All errors have error/message field |
@@ -0,0 +1,174 @@
# GraphQL API Template
Tests query, mutation, and subscription via Playwright's request API.
## Prerequisites
- Valid auth token: `{{apiToken}}`
- GraphQL endpoint: `{{graphqlEndpoint}}`
- WebSocket endpoint for subscriptions: `{{graphqlWsEndpoint}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
const GQL_URL = '{{graphqlEndpoint}}';
const headers = {
'Authorization': `Bearer {{apiToken}}`,
'Content-Type': 'application/json',
};
async function gql(request: any, query: string, variables = {}) {
const res = await request.post(GQL_URL, { headers, data: { query, variables } });
const body = await res.json();
expect(body.errors).toBeUndefined();
return body.data;
}
test.describe('GraphQL API', () => {
// Happy path: query
test('query fetches {{entityName}} list', async ({ request }) => {
const data = await gql(request, `
query Get{{EntityName}}s($limit: Int) {
{{entityName}}s(limit: $limit) { id name createdAt }
}
`, { limit: 10 });
expect(Array.isArray(data.{{entityName}}s)).toBe(true);
expect(data.{{entityName}}s.length).toBeLessThanOrEqual(10);
});
// Happy path: query single entity
test('query fetches single {{entityName}} by id', async ({ request }) => {
const data = await gql(request, `
query Get{{EntityName}}($id: ID!) {
{{entityName}}(id: $id) { id name description }
}
`, { id: '{{existingEntityId}}' });
expect(data.{{entityName}}.id).toBe('{{existingEntityId}}');
});
// Happy path: mutation creates entity
test('mutation creates {{entityName}}', async ({ request }) => {
const data = await gql(request, `
mutation Create{{EntityName}}($input: {{EntityName}}Input!) {
create{{EntityName}}(input: $input) { id name }
}
`, { input: { name: '{{testEntityName}}', description: '{{testDescription}}' } });
expect(data.create{{EntityName}}.id).toBeTruthy();
expect(data.create{{EntityName}}.name).toBe('{{testEntityName}}');
});
// Happy path: mutation updates entity
test('mutation updates {{entityName}}', async ({ request }) => {
const data = await gql(request, `
mutation Update{{EntityName}}($id: ID!, $input: {{EntityName}}Input!) {
update{{EntityName}}(id: $id, input: $input) { id name }
}
`, { id: '{{existingEntityId}}', input: { name: '{{updatedName}}' } });
expect(data.update{{EntityName}}.name).toBe('{{updatedName}}');
});
// Happy path: mutation deletes entity
test('mutation deletes {{entityName}}', async ({ request }) => {
const data = await gql(request, `
mutation Delete{{EntityName}}($id: ID!) {
delete{{EntityName}}(id: $id) { success }
}
`, { id: '{{deletableEntityId}}' });
expect(data.delete{{EntityName}}.success).toBe(true);
});
// Error case: invalid query returns errors array
test('invalid query returns errors', async ({ request }) => {
const res = await request.post(GQL_URL, {
headers,
data: { query: '{ invalidField }' },
});
const body = await res.json();
expect(body.errors).toBeDefined();
expect(body.errors.length).toBeGreaterThan(0);
});
// Error case: unauthorized query
test('query without auth returns unauthorized error', async ({ request }) => {
const res = await request.post(GQL_URL, {
headers: { 'Content-Type': 'application/json' }, // No auth
data: { query: '{ {{entityName}}s { id } }' },
});
const body = await res.json();
expect(body.errors?.[0]?.extensions?.code).toMatch(/UNAUTHENTICATED|UNAUTHORIZED/);
});
// Edge case: subscription via page WebSocket
test('subscription receives real-time update', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
const received: any[] = [];
await page.evaluate(() => {
const ws = new WebSocket('{{graphqlWsEndpoint}}');
ws.onmessage = e => (window as any).__gqlMsg = JSON.parse(e.data);
});
// Trigger mutation to fire subscription
await page.request.post(GQL_URL, {
headers,
data: { query: 'mutation { trigger{{EntityName}}Event { id } }' },
});
const msg = await page.evaluate(() => (window as any).__gqlMsg);
expect(msg?.type).toBe('data');
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
const headers = { 'Authorization': `Bearer {{apiToken}}`, 'Content-Type': 'application/json' };
async function gql(request, query, variables = {}) {
const res = await request.post('{{graphqlEndpoint}}', { headers, data: { query, variables } });
const body = await res.json();
expect(body.errors).toBeUndefined();
return body.data;
}
test.describe('GraphQL API', () => {
test('query fetches entity list', async ({ request }) => {
const data = await gql(request, '{ {{entityName}}s { id name } }');
expect(Array.isArray(data.{{entityName}}s)).toBe(true);
});
test('mutation creates entity', async ({ request }) => {
const data = await gql(request,
'mutation($input: {{EntityName}}Input!) { create{{EntityName}}(input: $input) { id } }',
{ input: { name: '{{testEntityName}}' } }
);
expect(data.create{{EntityName}}.id).toBeTruthy();
});
test('invalid query returns errors array', async ({ request }) => {
const res = await request.post('{{graphqlEndpoint}}', {
headers,
data: { query: '{ nonExistentField }' },
});
const body = await res.json();
expect(body.errors?.length).toBeGreaterThan(0);
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| List query | Returns array of entities |
| Single query | Returns entity by ID |
| Create mutation | Returns new entity with ID |
| Update mutation | Returns updated field value |
| Delete mutation | Returns success: true |
| Invalid query | errors[] defined in response |
| Unauthenticated | UNAUTHENTICATED extension code |
| Subscription | Real-time message via WebSocket |
@@ -0,0 +1,152 @@
# Rate Limiting Template
Tests rate limit headers, 429 response, and Retry-After handling.
## Prerequisites
- Valid auth token: `{{apiToken}}`
- Rate-limited endpoint: `{{rateLimitedEndpoint}}`
- Rate limit: `{{rateLimit}}` requests per `{{rateLimitWindow}}`
- API base: `{{apiBaseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
const headers = {
'Authorization': `Bearer {{apiToken}}`,
'Content-Type': 'application/json',
};
test.describe('Rate Limiting', () => {
// Happy path: rate limit headers present on normal requests
test('includes rate limit headers on success response', async ({ request }) => {
const res = await request.get(`{{apiBaseUrl}}/{{rateLimitedEndpoint}}`, { headers });
expect(res.status()).toBe(200);
expect(res.headers()['x-ratelimit-limit']).toBeTruthy();
expect(res.headers()['x-ratelimit-remaining']).toBeTruthy();
expect(Number(res.headers()['x-ratelimit-limit'])).toBe({{rateLimit}});
});
// Happy path: remaining count decrements
test('x-ratelimit-remaining decrements with each request', async ({ request }) => {
const first = await request.get(`{{apiBaseUrl}}/{{rateLimitedEndpoint}}`, { headers });
const second = await request.get(`{{apiBaseUrl}}/{{rateLimitedEndpoint}}`, { headers });
const remaining1 = Number(first.headers()['x-ratelimit-remaining']);
const remaining2 = Number(second.headers()['x-ratelimit-remaining']);
expect(remaining2).toBeLessThan(remaining1);
});
// Error case: 429 when limit exceeded
test('returns 429 when rate limit exceeded', async ({ request }) => {
let lastStatus = 200;
let retryAfter: string | undefined;
for (let i = 0; i <= {{rateLimit}}; i++) {
const res = await request.get(`{{apiBaseUrl}}/{{rateLimitedEndpoint}}`, { headers });
lastStatus = res.status();
if (lastStatus === 429) {
retryAfter = res.headers()['retry-after'];
break;
}
}
expect(lastStatus).toBe(429);
expect(retryAfter).toBeTruthy();
});
// Error case: 429 body contains error message
test('429 response body contains error and retry info', async ({ request }) => {
// Exhaust limit
for (let i = 0; i <= {{rateLimit}}; i++) {
await request.get(`{{apiBaseUrl}}/{{rateLimitedEndpoint}}`, { headers });
}
const res = await request.get(`{{apiBaseUrl}}/{{rateLimitedEndpoint}}`, { headers });
if (res.status() === 429) {
const body = await res.json();
expect(body.error ?? body.message).toMatch(/rate limit|too many requests/i);
expect(Number(res.headers()['retry-after'])).toBeGreaterThan(0);
}
});
// Happy path: different users have separate rate limit buckets
test('rate limit is per-user, not global', async ({ request }) => {
// Exhaust limit for user 1
for (let i = 0; i <= {{rateLimit}}; i++) {
await request.get(`{{apiBaseUrl}}/{{rateLimitedEndpoint}}`, {
headers: { 'Authorization': `Bearer {{apiToken}}` },
});
}
// User 2 should still succeed
const res = await request.get(`{{apiBaseUrl}}/{{rateLimitedEndpoint}}`, {
headers: { 'Authorization': `Bearer {{apiToken2}}` },
});
expect(res.status()).toBe(200);
});
// Edge case: reset after window expires
test('rate limit resets after window expires', async ({ page, request }) => {
// Exhaust limit
for (let i = 0; i <= {{rateLimit}}; i++) {
await request.get(`{{apiBaseUrl}}/{{rateLimitedEndpoint}}`, { headers });
}
// Advance clock past the window
await page.clock.install();
await page.clock.fastForward({{rateLimitWindowMs}});
// Should succeed again
const res = await request.get(`{{apiBaseUrl}}/{{rateLimitedEndpoint}}`, { headers });
expect(res.status()).toBe(200);
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
const headers = { 'Authorization': `Bearer {{apiToken}}` };
test.describe('Rate Limiting', () => {
test('includes rate limit headers on success', async ({ request }) => {
const res = await request.get(`{{apiBaseUrl}}/{{rateLimitedEndpoint}}`, { headers });
expect(res.status()).toBe(200);
expect(res.headers()['x-ratelimit-limit']).toBeTruthy();
expect(res.headers()['x-ratelimit-remaining']).toBeTruthy();
});
test('returns 429 with Retry-After when limit exceeded', async ({ request }) => {
let lastStatus = 200;
let retryAfter;
for (let i = 0; i <= {{rateLimit}}; i++) {
const res = await request.get(`{{apiBaseUrl}}/{{rateLimitedEndpoint}}`, { headers });
lastStatus = res.status();
if (lastStatus === 429) { retryAfter = res.headers()['retry-after']; break; }
}
expect(lastStatus).toBe(429);
expect(retryAfter).toBeTruthy();
});
test('per-user buckets: other user unaffected', async ({ request }) => {
for (let i = 0; i <= {{rateLimit}}; i++) {
await request.get(`{{apiBaseUrl}}/{{rateLimitedEndpoint}}`, { headers });
}
const res = await request.get(`{{apiBaseUrl}}/{{rateLimitedEndpoint}}`, {
headers: { 'Authorization': `Bearer {{apiToken2}}` },
});
expect(res.status()).toBe(200);
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Headers present | x-ratelimit-limit and -remaining on 200 |
| Decrement | remaining decreases each request |
| 429 triggered | Limit exceeded → 429 + Retry-After |
| 429 body | Error message + retry info in body |
| Per-user bucket | Exhausted user doesn't affect others |
| Window reset | Clock advanced → limit resets |
@@ -0,0 +1,152 @@
# REST CRUD API Template
Tests GET, POST, PUT, and DELETE API endpoints directly via Playwright's request API.
## Prerequisites
- Valid auth token: `{{apiToken}}`
- Base API URL: `{{apiBaseUrl}}`
- Test entity endpoint: `/{{entityName}}s`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('REST CRUD — /{{entityName}}s', () => {
let createdId: string;
const headers = {
'Authorization': `Bearer {{apiToken}}`,
'Content-Type': 'application/json',
};
// Happy path: GET list
test('GET /{{entityName}}s returns list', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/{{entityName}}s', { headers });
expect(res.status()).toBe(200);
const body = await res.json();
expect(Array.isArray(body.data ?? body)).toBe(true);
});
// Happy path: POST creates entity
test('POST /{{entityName}}s creates new entity', async ({ request }) => {
const res = await request.post('{{apiBaseUrl}}/{{entityName}}s', {
headers,
data: { name: '{{testEntityName}}', description: '{{testDescription}}' },
});
expect(res.status()).toBe(201);
const body = await res.json();
expect(body.id).toBeTruthy();
expect(body.name).toBe('{{testEntityName}}');
createdId = body.id;
});
// Happy path: GET single entity
test('GET /{{entityName}}s/:id returns entity', async ({ request }) => {
const res = await request.get(`{{apiBaseUrl}}/{{entityName}}s/{{existingEntityId}}`, { headers });
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.id).toBe('{{existingEntityId}}');
expect(body.name).toBeTruthy();
});
// Happy path: PUT updates entity
test('PUT /{{entityName}}s/:id updates entity', async ({ request }) => {
const res = await request.put(`{{apiBaseUrl}}/{{entityName}}s/{{existingEntityId}}`, {
headers,
data: { name: '{{updatedEntityName}}' },
});
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.name).toBe('{{updatedEntityName}}');
});
// Happy path: PATCH partial update
test('PATCH /{{entityName}}s/:id partially updates entity', async ({ request }) => {
const res = await request.patch(`{{apiBaseUrl}}/{{entityName}}s/{{existingEntityId}}`, {
headers,
data: { description: '{{patchedDescription}}' },
});
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.description).toBe('{{patchedDescription}}');
});
// Happy path: DELETE removes entity
test('DELETE /{{entityName}}s/:id deletes entity', async ({ request }) => {
const del = await request.delete(`{{apiBaseUrl}}/{{entityName}}s/{{deletableEntityId}}`, { headers });
expect(del.status()).toBe(204);
// Verify gone
const get = await request.get(`{{apiBaseUrl}}/{{entityName}}s/{{deletableEntityId}}`, { headers });
expect(get.status()).toBe(404);
});
// Error case: POST with missing required field returns 422
test('POST with missing required field returns 422', async ({ request }) => {
const res = await request.post('{{apiBaseUrl}}/{{entityName}}s', {
headers,
data: {},
});
expect(res.status()).toBe(422);
const body = await res.json();
expect(body.errors).toBeTruthy();
});
// Error case: GET non-existent entity returns 404
test('GET non-existent entity returns 404', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/{{entityName}}s/999999', { headers });
expect(res.status()).toBe(404);
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
const headers = {
'Authorization': `Bearer {{apiToken}}`,
'Content-Type': 'application/json',
};
test.describe('REST CRUD — /{{entityName}}s', () => {
test('GET list returns 200 and array', async ({ request }) => {
const res = await request.get('{{apiBaseUrl}}/{{entityName}}s', { headers });
expect(res.status()).toBe(200);
const body = await res.json();
expect(Array.isArray(body.data ?? body)).toBe(true);
});
test('POST creates entity and returns 201', async ({ request }) => {
const res = await request.post('{{apiBaseUrl}}/{{entityName}}s', {
headers,
data: { name: '{{testEntityName}}' },
});
expect(res.status()).toBe(201);
expect((await res.json()).id).toBeTruthy();
});
test('DELETE removes entity, GET returns 404', async ({ request }) => {
await request.delete(`{{apiBaseUrl}}/{{entityName}}s/{{deletableEntityId}}`, { headers });
const res = await request.get(`{{apiBaseUrl}}/{{entityName}}s/{{deletableEntityId}}`, { headers });
expect(res.status()).toBe(404);
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| GET list | 200 + array body |
| POST create | 201 + id in response |
| GET single | 200 + correct entity body |
| PUT update | 200 + updated field in response |
| PATCH partial | 200 + patched field only changed |
| DELETE | 204 → subsequent GET returns 404 |
| POST validation | Missing field → 422 + errors |
| GET 404 | Non-existent ID → 404 |
@@ -0,0 +1,119 @@
# Login Template
Tests email/password login, social login, and remember me functionality.
## Prerequisites
- Valid user account: `{{username}}` / `{{password}}`
- Social provider configured (Google/GitHub)
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Login', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/login');
});
// Happy path: email/password login
test('logs in with valid credentials', async ({ page }) => {
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('textbox', { name: /password/i }).fill('{{password}}');
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
});
// Happy path: remember me
test('persists session with remember me checked', async ({ page, context }) => {
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('textbox', { name: /password/i }).fill('{{password}}');
await page.getByRole('checkbox', { name: /remember me/i }).check();
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
const cookies = await context.cookies();
const session = cookies.find(c => c.name === '{{sessionCookieName}}');
expect(session?.expires).toBeGreaterThan(Date.now() / 1000 + 86400);
});
// Happy path: social login
test('redirects to social provider', async ({ page }) => {
await page.getByRole('button', { name: /continue with google/i }).click();
await expect(page).toHaveURL(/accounts\.google\.com/);
});
// Error case: invalid credentials
test('shows error for wrong password', async ({ page }) => {
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('textbox', { name: /password/i }).fill('wrong-password');
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page.getByRole('alert')).toContainText(/invalid.*credentials/i);
await expect(page).toHaveURL('{{baseUrl}}/login');
});
// Edge case: empty fields
test('shows validation for empty submission', async ({ page }) => {
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page.getByRole('textbox', { name: /email/i })).toBeFocused();
await expect(page.getByText(/email is required/i)).toBeVisible();
});
// Edge case: locked account
test('shows account locked message after multiple failures', async ({ page }) => {
for (let i = 0; i < {{lockoutAttempts}}; i++) {
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('textbox', { name: /password/i }).fill('wrong');
await page.getByRole('button', { name: /sign in/i }).click();
}
await expect(page.getByRole('alert')).toContainText(/account.*locked/i);
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Login', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/login');
});
test('logs in with valid credentials', async ({ page }) => {
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('textbox', { name: /password/i }).fill('{{password}}');
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
});
test('shows error for wrong password', async ({ page }) => {
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('textbox', { name: /password/i }).fill('wrong-password');
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page.getByRole('alert')).toContainText(/invalid.*credentials/i);
});
test('shows validation for empty submission', async ({ page }) => {
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page.getByText(/email is required/i)).toBeVisible();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Happy path | Valid credentials → dashboard redirect |
| Remember me | Long-lived cookie set |
| Social login | OAuth redirect to provider |
| Wrong password | Alert with error message |
| Empty form | Inline validation shown |
| Locked account | Lockout message after N failures |
@@ -0,0 +1,112 @@
# Logout Template
Tests logout from navigation, session cleanup, and redirect behaviour.
## Prerequisites
- Authenticated session (use `storageState` or login fixture)
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Logout', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
// Happy path: logout via nav menu
test('logs out from user menu', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /user menu/i }).click();
await page.getByRole('menuitem', { name: /sign out/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/login');
await expect(page.getByRole('heading', { name: /sign in/i })).toBeVisible();
});
// Happy path: session cookies cleared
test('clears session cookie on logout', async ({ page, context }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /user menu/i }).click();
await page.getByRole('menuitem', { name: /sign out/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/login');
const cookies = await context.cookies();
const session = cookies.find(c => c.name === '{{sessionCookieName}}');
expect(session).toBeUndefined();
});
// Happy path: accessing protected page after logout redirects
test('redirects to login when accessing protected page after logout', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /user menu/i }).click();
await page.getByRole('menuitem', { name: /sign out/i }).click();
await page.goto('{{baseUrl}}/dashboard');
await expect(page).toHaveURL(/\/login/);
});
// Error case: double logout (stale session)
test('handles logout gracefully when session already expired', async ({ page, context }) => {
await page.goto('{{baseUrl}}/dashboard');
await context.clearCookies();
await page.getByRole('button', { name: /user menu/i }).click();
await page.getByRole('menuitem', { name: /sign out/i }).click();
await expect(page).toHaveURL(/\/login/);
});
// Edge case: logout from multiple tabs
test('invalidates session across tabs', async ({ page, context }) => {
const tab2 = await context.newPage();
await page.goto('{{baseUrl}}/dashboard');
await tab2.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /user menu/i }).click();
await page.getByRole('menuitem', { name: /sign out/i }).click();
await tab2.reload();
await expect(tab2).toHaveURL(/\/login/);
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Logout', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('logs out from user menu', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /user menu/i }).click();
await page.getByRole('menuitem', { name: /sign out/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/login');
});
test('clears session cookie on logout', async ({ page, context }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /user menu/i }).click();
await page.getByRole('menuitem', { name: /sign out/i }).click();
const cookies = await context.cookies();
expect(cookies.find(c => c.name === '{{sessionCookieName}}')).toBeUndefined();
});
test('redirects protected page to login after logout', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /user menu/i }).click();
await page.getByRole('menuitem', { name: /sign out/i }).click();
await page.goto('{{baseUrl}}/dashboard');
await expect(page).toHaveURL(/\/login/);
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Happy path | Nav menu → sign out → login page |
| Cookie cleanup | Session cookie removed after logout |
| Protected redirect | Accessing /dashboard after logout → /login |
| Stale session | Already-expired session handled gracefully |
| Multi-tab | Logout invalidates other open tabs |
@@ -0,0 +1,125 @@
# MFA Template
Tests 2FA TOTP code entry, backup codes, and MFA enrollment flow.
## Prerequisites
- MFA-enabled account: `{{mfaUsername}}` / `{{mfaPassword}}`
- TOTP secret for generating codes: `{{totpSecret}}`
- Backup code: `{{backupCode}}`
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
import { authenticator } from 'otplib'; // npm i otplib
test.describe('MFA', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('textbox', { name: /email/i }).fill('{{mfaUsername}}');
await page.getByRole('textbox', { name: /password/i }).fill('{{mfaPassword}}');
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL(/\/mfa|\/two-factor/);
});
// Happy path: valid TOTP code
test('accepts valid TOTP code', async ({ page }) => {
const token = authenticator.generate('{{totpSecret}}');
await page.getByRole('textbox', { name: /code|token/i }).fill(token);
await page.getByRole('button', { name: /verify/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
});
// Happy path: backup code
test('accepts backup code', async ({ page }) => {
await page.getByRole('link', { name: /use backup code/i }).click();
await page.getByRole('textbox', { name: /backup code/i }).fill('{{backupCode}}');
await page.getByRole('button', { name: /verify/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
// Backup code consumed — warning shown
await expect(page.getByRole('alert')).toContainText(/backup code used/i);
});
// Error case: wrong TOTP code
test('rejects invalid TOTP code', async ({ page }) => {
await page.getByRole('textbox', { name: /code|token/i }).fill('000000');
await page.getByRole('button', { name: /verify/i }).click();
await expect(page.getByRole('alert')).toContainText(/invalid.*code/i);
await expect(page).toHaveURL(/\/mfa|\/two-factor/);
});
// Error case: expired code (simulate by providing code + 1 step)
test('rejects expired TOTP code', async ({ page }) => {
const expiredToken = authenticator.generate('{{totpSecret}}');
// Advance time simulation via clock if supported, else use a fixed stale code
await page.getByRole('textbox', { name: /code|token/i }).fill(expiredToken);
await page.clock.fastForward(60_000); // advance 60s past TOTP window
await page.getByRole('button', { name: /verify/i }).click();
await expect(page.getByRole('alert')).toContainText(/expired|invalid.*code/i);
});
// Edge case: MFA enrollment for new user
test('enrolls MFA via QR code scan', async ({ page: enrollPage }) => {
await enrollPage.goto('{{baseUrl}}/settings/security');
await enrollPage.getByRole('button', { name: /enable.*two-factor/i }).click();
await expect(enrollPage.getByRole('img', { name: /qr code/i })).toBeVisible();
await expect(enrollPage.getByText(/scan.*authenticator/i)).toBeVisible();
// User scans QR → enters token
const token = authenticator.generate('{{totpSecret}}');
await enrollPage.getByRole('textbox', { name: /verification code/i }).fill(token);
await enrollPage.getByRole('button', { name: /activate/i }).click();
await expect(enrollPage.getByRole('heading', { name: /backup codes/i })).toBeVisible();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
const { authenticator } = require('otplib');
test.describe('MFA', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('textbox', { name: /email/i }).fill('{{mfaUsername}}');
await page.getByRole('textbox', { name: /password/i }).fill('{{mfaPassword}}');
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL(/\/mfa|\/two-factor/);
});
test('accepts valid TOTP code', async ({ page }) => {
const token = authenticator.generate('{{totpSecret}}');
await page.getByRole('textbox', { name: /code|token/i }).fill(token);
await page.getByRole('button', { name: /verify/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
});
test('accepts backup code', async ({ page }) => {
await page.getByRole('link', { name: /use backup code/i }).click();
await page.getByRole('textbox', { name: /backup code/i }).fill('{{backupCode}}');
await page.getByRole('button', { name: /verify/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
});
test('rejects invalid TOTP code', async ({ page }) => {
await page.getByRole('textbox', { name: /code|token/i }).fill('000000');
await page.getByRole('button', { name: /verify/i }).click();
await expect(page.getByRole('alert')).toContainText(/invalid.*code/i);
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Valid TOTP | Correct time-based code → dashboard |
| Backup code | Single-use backup code accepted; warning shown |
| Invalid code | Wrong code → alert, stays on MFA page |
| Expired code | Clock-advanced token rejected |
| MFA enrollment | QR shown → token verified → backup codes displayed |
@@ -0,0 +1,129 @@
# Password Reset Template
Tests reset request, setting a new password, and expired link handling.
## Prerequisites
- Account with email: `{{username}}`
- Reset link / token available in test environment (`{{resetToken}}`)
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Password Reset', () => {
// Happy path: request reset email
test('sends reset email for known address', async ({ page }) => {
await page.goto('{{baseUrl}}/forgot-password');
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('button', { name: /send reset/i }).click();
await expect(page.getByRole('alert')).toContainText(/check your email/i);
});
// Happy path: set new password via reset link
test('sets new password with valid reset token', async ({ page }) => {
await page.goto('{{baseUrl}}/reset-password?token={{resetToken}}');
await expect(page.getByRole('heading', { name: /set.*new password/i })).toBeVisible();
await page.getByRole('textbox', { name: /^new password$/i }).fill('{{newPassword}}');
await page.getByRole('textbox', { name: /confirm password/i }).fill('{{newPassword}}');
await page.getByRole('button', { name: /reset password/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/login');
await expect(page.getByRole('alert')).toContainText(/password.*updated/i);
});
// Happy path: login with new password
test('can log in with updated password', async ({ page }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('textbox', { name: /password/i }).fill('{{newPassword}}');
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
});
// Error case: expired reset link
test('shows error for expired reset token', async ({ page }) => {
await page.goto('{{baseUrl}}/reset-password?token={{expiredResetToken}}');
await expect(page.getByRole('alert')).toContainText(/link.*expired|token.*invalid/i);
await expect(page.getByRole('link', { name: /request new link/i })).toBeVisible();
});
// Error case: unknown email
test('shows generic message for unknown email (anti-enumeration)', async ({ page }) => {
await page.goto('{{baseUrl}}/forgot-password');
await page.getByRole('textbox', { name: /email/i }).fill('unknown@example.com');
await page.getByRole('button', { name: /send reset/i }).click();
// Should NOT reveal whether email exists
await expect(page.getByRole('alert')).toContainText(/check your email/i);
});
// Error case: passwords do not match
test('validates that passwords match', async ({ page }) => {
await page.goto('{{baseUrl}}/reset-password?token={{resetToken}}');
await page.getByRole('textbox', { name: /^new password$/i }).fill('{{newPassword}}');
await page.getByRole('textbox', { name: /confirm password/i }).fill('different-password');
await page.getByRole('button', { name: /reset password/i }).click();
await expect(page.getByText(/passwords.*do not match/i)).toBeVisible();
});
// Edge case: weak password rejected
test('rejects password that does not meet strength requirements', async ({ page }) => {
await page.goto('{{baseUrl}}/reset-password?token={{resetToken}}');
await page.getByRole('textbox', { name: /^new password$/i }).fill('123');
await page.getByRole('textbox', { name: /confirm password/i }).fill('123');
await page.getByRole('button', { name: /reset password/i }).click();
await expect(page.getByText(/password.*too weak|must be at least/i)).toBeVisible();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Password Reset', () => {
test('sends reset email for known address', async ({ page }) => {
await page.goto('{{baseUrl}}/forgot-password');
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('button', { name: /send reset/i }).click();
await expect(page.getByRole('alert')).toContainText(/check your email/i);
});
test('sets new password with valid reset token', async ({ page }) => {
await page.goto('{{baseUrl}}/reset-password?token={{resetToken}}');
await page.getByRole('textbox', { name: /^new password$/i }).fill('{{newPassword}}');
await page.getByRole('textbox', { name: /confirm password/i }).fill('{{newPassword}}');
await page.getByRole('button', { name: /reset password/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/login');
});
test('shows error for expired reset token', async ({ page }) => {
await page.goto('{{baseUrl}}/reset-password?token={{expiredResetToken}}');
await expect(page.getByRole('alert')).toContainText(/link.*expired|token.*invalid/i);
});
test('validates passwords match', async ({ page }) => {
await page.goto('{{baseUrl}}/reset-password?token={{resetToken}}');
await page.getByRole('textbox', { name: /^new password$/i }).fill('{{newPassword}}');
await page.getByRole('textbox', { name: /confirm password/i }).fill('other');
await page.getByRole('button', { name: /reset password/i }).click();
await expect(page.getByText(/passwords.*do not match/i)).toBeVisible();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Request reset | Known email → check email message |
| Set new password | Valid token → new password set → login page |
| Login with new pw | Updated credentials accepted |
| Expired token | Error + "request new link" shown |
| Unknown email | Generic response (anti-enumeration) |
| Passwords mismatch | Inline validation error |
| Weak password | Strength requirement error |
@@ -0,0 +1,132 @@
# RBAC Template
Tests role-based access control: admin vs user permissions and forbidden pages.
## Prerequisites
- Admin account: `{{adminUsername}}` / `{{adminPassword}}`
- Regular user: `{{userUsername}}` / `{{userPassword}}`
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
const adminState = '{{adminStorageStatePath}}';
const userState = '{{userStorageStatePath}}';
test.describe('RBAC — Admin', () => {
test.use({ storageState: adminState });
// Happy path: admin accesses admin panel
test('admin can access admin panel', async ({ page }) => {
await page.goto('{{baseUrl}}/admin');
await expect(page.getByRole('heading', { name: /admin/i })).toBeVisible();
});
test('admin can see user management menu item', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await expect(page.getByRole('link', { name: /user management/i })).toBeVisible();
});
test('admin can delete any resource', async ({ page }) => {
await page.goto('{{baseUrl}}/admin/{{entityName}}s');
await page.getByRole('row').nth(1).getByRole('button', { name: /delete/i }).click();
await page.getByRole('button', { name: /confirm/i }).click();
await expect(page.getByRole('alert')).toContainText(/deleted/i);
});
});
test.describe('RBAC — Regular User', () => {
test.use({ storageState: userState });
// Error case: user cannot access admin panel
test('regular user sees 403 on admin panel', async ({ page }) => {
await page.goto('{{baseUrl}}/admin');
await expect(page).toHaveURL(/\/403|\/forbidden|\/dashboard/);
const forbidden = page.getByRole('heading', { name: /403|forbidden|not authorized/i });
await expect(forbidden).toBeVisible();
});
test('regular user does not see admin menu items', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await expect(page.getByRole('link', { name: /user management/i })).toBeHidden();
});
// Error case: user cannot delete others' resources
test('regular user cannot delete another user\'s resource', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{otherUsersEntityId}}');
await expect(page.getByRole('button', { name: /delete/i })).toBeHidden();
});
// Edge case: direct navigation to admin API returns 403
test('API returns 403 for unauthorized role', async ({ page }) => {
const response = await page.request.get('{{baseUrl}}/api/admin/users');
expect(response.status()).toBe(403);
});
});
test.describe('RBAC — Role Elevation', () => {
// Edge case: user promoted to admin gains access
test('newly promoted admin can access admin panel', async ({ browser }) => {
// Step 1: use admin context to promote user
const adminCtx = await browser.newContext({ storageState: adminState });
const adminPage = await adminCtx.newPage();
await adminPage.goto('{{baseUrl}}/admin/users/{{promotedUserId}}/role');
await adminPage.getByRole('combobox', { name: /role/i }).selectOption('admin');
await adminPage.getByRole('button', { name: /save/i }).click();
await adminCtx.close();
// Step 2: promoted user can now access admin panel
const userCtx = await browser.newContext({ storageState: userState });
const userPage = await userCtx.newPage();
await userPage.goto('{{baseUrl}}/admin');
await expect(userPage.getByRole('heading', { name: /admin/i })).toBeVisible();
await userCtx.close();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('RBAC — Admin', () => {
test.use({ storageState: '{{adminStorageStatePath}}' });
test('admin can access admin panel', async ({ page }) => {
await page.goto('{{baseUrl}}/admin');
await expect(page.getByRole('heading', { name: /admin/i })).toBeVisible();
});
});
test.describe('RBAC — Regular User', () => {
test.use({ storageState: '{{userStorageStatePath}}' });
test('regular user sees 403 on admin panel', async ({ page }) => {
await page.goto('{{baseUrl}}/admin');
await expect(page.getByRole('heading', { name: /403|forbidden/i })).toBeVisible();
});
test('API returns 403 for unauthorized role', async ({ page }) => {
const res = await page.request.get('{{baseUrl}}/api/admin/users');
expect(res.status()).toBe(403);
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Admin access | Admin reaches /admin panel |
| Admin menu | Admin-only nav items visible |
| Admin delete | Admin can delete any resource |
| User forbidden | Regular user → 403/redirect on /admin |
| User hidden menu | Admin nav items not rendered for user |
| API 403 | Backend enforces role on API routes |
| Role elevation | Promoted user gains new access immediately |
@@ -0,0 +1,127 @@
# Remember Me Template
Tests persistent login cookie behaviour and expiry.
## Prerequisites
- Valid account: `{{username}}` / `{{password}}`
- `{{sessionCookieName}}` cookie used for auth
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Remember Me', () => {
// Happy path: cookie is long-lived when remember me is checked
test('sets persistent cookie when remember me is checked', async ({ page, context }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('textbox', { name: /password/i }).fill('{{password}}');
await page.getByRole('checkbox', { name: /remember me/i }).check();
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
const cookies = await context.cookies();
const session = cookies.find(c => c.name === '{{sessionCookieName}}');
// Cookie should expire > 7 days from now
expect(session?.expires).toBeGreaterThan(Date.now() / 1000 + 7 * 86400);
});
// Happy path: session cookie (no remember me) is session-scoped
test('sets session-scoped cookie when remember me is unchecked', async ({ page, context }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('textbox', { name: /password/i }).fill('{{password}}');
const checkbox = page.getByRole('checkbox', { name: /remember me/i });
if (await checkbox.isChecked()) await checkbox.uncheck();
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
const cookies = await context.cookies();
const session = cookies.find(c => c.name === '{{sessionCookieName}}');
// Session cookie: expires = -1 (browser session only)
expect(session?.expires).toBeLessThanOrEqual(0);
});
// Happy path: persistent login survives page reload
test('stays logged in across browser restart with remember me', async ({ page, context }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('textbox', { name: /password/i }).fill('{{password}}');
await page.getByRole('checkbox', { name: /remember me/i }).check();
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
// Simulate new browser session by closing & reopening page (cookies persist)
await page.close();
const newPage = await context.newPage();
await newPage.goto('{{baseUrl}}/dashboard');
await expect(newPage).toHaveURL('{{baseUrl}}/dashboard');
await expect(newPage.getByRole('heading', { name: /dashboard/i })).toBeVisible();
});
// Error case: expired persistent cookie redirects to login
test('redirects to login when persistent cookie has expired', async ({ page, context }) => {
await context.addCookies([{
name: '{{sessionCookieName}}',
value: '{{expiredCookieValue}}',
domain: '{{cookieDomain}}',
path: '/',
expires: Math.floor(Date.now() / 1000) - 1, // already expired
}]);
await page.goto('{{baseUrl}}/dashboard');
await expect(page).toHaveURL(/\/login/);
});
// Edge case: remember me checkbox state is preserved on validation error
test('retains remember me checkbox state after failed login', async ({ page }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('checkbox', { name: /remember me/i }).check();
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('textbox', { name: /password/i }).fill('wrong');
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page.getByRole('alert')).toContainText(/invalid/i);
await expect(page.getByRole('checkbox', { name: /remember me/i })).toBeChecked();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Remember Me', () => {
test('sets persistent cookie when remember me is checked', async ({ page, context }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('textbox', { name: /password/i }).fill('{{password}}');
await page.getByRole('checkbox', { name: /remember me/i }).check();
await page.getByRole('button', { name: /sign in/i }).click();
const cookies = await context.cookies();
const session = cookies.find(c => c.name === '{{sessionCookieName}}');
expect(session?.expires).toBeGreaterThan(Date.now() / 1000 + 7 * 86400);
});
test('sets session cookie when remember me is unchecked', async ({ page, context }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('textbox', { name: /email/i }).fill('{{username}}');
await page.getByRole('textbox', { name: /password/i }).fill('{{password}}');
await page.getByRole('button', { name: /sign in/i }).click();
const cookies = await context.cookies();
const session = cookies.find(c => c.name === '{{sessionCookieName}}');
expect(session?.expires).toBeLessThanOrEqual(0);
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Persistent cookie | Remember me → long-lived cookie (>7 days) |
| Session cookie | No remember me → session-scoped cookie |
| Survives reload | Persistent cookie keeps user logged in across restart |
| Expired cookie | Stale cookie → redirect to /login |
| Checkbox retained | State preserved after failed login attempt |
@@ -0,0 +1,113 @@
# Session Timeout Template
Tests auto-logout after inactivity and session refresh behaviour.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- Session timeout configured to `{{sessionTimeoutMs}}` ms in test env
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Session Timeout', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
// Happy path: session refresh on activity
test('refreshes session on user activity', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.clock.install();
// Advance to just before timeout
await page.clock.fastForward({{sessionTimeoutMs}} - 5000);
await page.getByRole('button', { name: /any interactive element/i }).click();
// Advance past original timeout — session should still be valid
await page.clock.fastForward(10_000);
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
});
// Happy path: warning dialog shown before logout
test('shows session-expiry warning before auto-logout', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.clock.install();
await page.clock.fastForward({{sessionTimeoutMs}} - {{warningLeadMs}});
await expect(page.getByRole('dialog', { name: /session.*expiring/i })).toBeVisible();
await expect(page.getByRole('button', { name: /stay signed in/i })).toBeVisible();
});
// Happy path: extend session from warning dialog
test('extends session when "stay signed in" clicked', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.clock.install();
await page.clock.fastForward({{sessionTimeoutMs}} - {{warningLeadMs}});
await page.getByRole('button', { name: /stay signed in/i }).click();
await expect(page.getByRole('dialog', { name: /session.*expiring/i })).toBeHidden();
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
});
// Error case: auto-logout after inactivity
test('redirects to login after session timeout', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.clock.install();
await page.clock.fastForward({{sessionTimeoutMs}} + 1000);
await expect(page).toHaveURL(/\/login/);
await expect(page.getByText(/session.*expired|signed out/i)).toBeVisible();
});
// Edge case: API calls return 401 after timeout
test('shows re-auth prompt when API returns 401', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.route('{{baseUrl}}/api/**', route =>
route.fulfill({ status: 401, body: JSON.stringify({ error: 'Unauthorized' }) })
);
await page.getByRole('button', { name: /refresh|reload/i }).click();
await expect(page.getByRole('dialog', { name: /session.*expired/i })).toBeVisible();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Session Timeout', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('shows warning before auto-logout', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.clock.install();
await page.clock.fastForward({{sessionTimeoutMs}} - {{warningLeadMs}});
await expect(page.getByRole('dialog', { name: /session.*expiring/i })).toBeVisible();
});
test('auto-logs out after inactivity', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.clock.install();
await page.clock.fastForward({{sessionTimeoutMs}} + 1000);
await expect(page).toHaveURL(/\/login/);
});
test('extends session on "stay signed in"', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.clock.install();
await page.clock.fastForward({{sessionTimeoutMs}} - {{warningLeadMs}});
await page.getByRole('button', { name: /stay signed in/i }).click();
await expect(page.getByRole('dialog', { name: /session.*expiring/i })).toBeHidden();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Session refresh | Activity before timeout resets the clock |
| Warning dialog | Shown N ms before timeout |
| Extend session | "Stay signed in" dismisses warning |
| Auto-logout | Inactivity past timeout → /login |
| 401 from API | Re-auth dialog shown when backend rejects request |
@@ -0,0 +1,115 @@
# SSO Template
Tests SSO redirect flow, IdP callback handling, and attribute mapping.
## Prerequisites
- SSO provider configured (SAML / OIDC) at `{{ssoProviderUrl}}`
- Test IdP with user `{{ssoUsername}}`
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect, Page } from '@playwright/test';
async function completeSsoLogin(page: Page, username: string): Promise<void> {
// Fill IdP login form — adapt selectors to your provider
await page.getByRole('textbox', { name: /username/i }).fill(username);
await page.getByRole('button', { name: /login/i }).click();
}
test.describe('SSO', () => {
// Happy path: SSO redirect and callback
test('redirects to IdP and returns authenticated', async ({ page }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('button', { name: /sign in with sso/i }).click();
await expect(page).toHaveURL(/{{ssoProviderDomain}}/);
await completeSsoLogin(page, '{{ssoUsername}}');
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
});
// Happy path: SSO with domain hint
test('pre-fills organisation domain and redirects', async ({ page }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('textbox', { name: /work email/i }).fill('{{ssoUsername}}');
await page.getByRole('button', { name: /continue/i }).click();
await expect(page).toHaveURL(/{{ssoProviderDomain}}/);
});
// Happy path: attributes mapped to user profile
test('maps SSO attributes to user profile', async ({ page }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('button', { name: /sign in with sso/i }).click();
await completeSsoLogin(page, '{{ssoUsername}}');
await page.goto('{{baseUrl}}/settings/profile');
await expect(page.getByRole('textbox', { name: /email/i })).toHaveValue('{{ssoUsername}}');
});
// Error case: IdP returns error
test('shows error page when IdP returns error response', async ({ page }) => {
await page.goto('{{baseUrl}}/auth/callback?error=access_denied&error_description=User+denied+access');
await expect(page.getByRole('alert')).toContainText(/access denied/i);
await expect(page.getByRole('link', { name: /back to login/i })).toBeVisible();
});
// Error case: invalid callback state
test('rejects callback with invalid state parameter', async ({ page }) => {
await page.goto('{{baseUrl}}/auth/callback?code=valid_code&state=tampered_state');
await expect(page.getByRole('alert')).toContainText(/invalid.*state|authentication failed/i);
});
// Edge case: SSO user first login provisions account
test('provisions new account on first SSO login', async ({ page }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('button', { name: /sign in with sso/i }).click();
await completeSsoLogin(page, '{{newSsoUsername}}');
await expect(page).toHaveURL(/{{baseUrl}}\/(dashboard|onboarding)/);
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
async function completeSsoLogin(page, username) {
await page.getByRole('textbox', { name: /username/i }).fill(username);
await page.getByRole('button', { name: /login/i }).click();
}
test.describe('SSO', () => {
test('redirects to IdP and returns authenticated', async ({ page }) => {
await page.goto('{{baseUrl}}/login');
await page.getByRole('button', { name: /sign in with sso/i }).click();
await expect(page).toHaveURL(/{{ssoProviderDomain}}/);
await completeSsoLogin(page, '{{ssoUsername}}');
await expect(page).toHaveURL('{{baseUrl}}/dashboard');
});
test('shows error when IdP returns access_denied', async ({ page }) => {
await page.goto('{{baseUrl}}/auth/callback?error=access_denied');
await expect(page.getByRole('alert')).toContainText(/access denied/i);
});
test('rejects tampered state parameter', async ({ page }) => {
await page.goto('{{baseUrl}}/auth/callback?code=abc&state=tampered');
await expect(page.getByRole('alert')).toContainText(/invalid.*state|authentication failed/i);
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Happy path | SSO button → IdP → callback → dashboard |
| Domain hint | Email triggers org-specific IdP redirect |
| Attribute mapping | SSO profile fields populate user record |
| IdP error | access_denied → error page with back link |
| Invalid state | CSRF protection rejects tampered callback |
| First login | Auto-provisions account on initial SSO |
@@ -0,0 +1,112 @@
# Add to Cart Template
Tests adding items to cart and quantity updates.
## Prerequisites
- Authenticated (or guest) session
- Product: ID `{{productId}}`, name `{{productName}}`, price `{{productPrice}}`
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Add to Cart', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/products/{{productId}}');
});
// Happy path: add single item
test('adds product to cart', async ({ page }) => {
await page.getByRole('button', { name: /add to cart/i }).click();
await expect(page.getByRole('status', { name: /cart/i })).toContainText('1');
await expect(page.getByRole('alert')).toContainText(/added to cart/i);
});
// Happy path: add multiple items increments count
test('increments cart count on repeated add', async ({ page }) => {
await page.getByRole('button', { name: /add to cart/i }).click();
await page.getByRole('button', { name: /add to cart/i }).click();
await expect(page.getByRole('status', { name: /cart/i })).toContainText('2');
});
// Happy path: add with quantity selector
test('adds specified quantity to cart', async ({ page }) => {
await page.getByRole('spinbutton', { name: /quantity/i }).fill('3');
await page.getByRole('button', { name: /add to cart/i }).click();
await expect(page.getByRole('status', { name: /cart/i })).toContainText('3');
});
// Happy path: cart persists on navigation
test('cart persists after navigating away', async ({ page }) => {
await page.getByRole('button', { name: /add to cart/i }).click();
await page.goto('{{baseUrl}}/products');
await expect(page.getByRole('status', { name: /cart/i })).toContainText('1');
});
// Error case: out of stock product cannot be added
test('add to cart button disabled for out-of-stock product', async ({ page }) => {
await page.goto('{{baseUrl}}/products/{{outOfStockProductId}}');
await expect(page.getByRole('button', { name: /add to cart/i })).toBeDisabled();
await expect(page.getByText(/out of stock/i)).toBeVisible();
});
// Error case: quantity exceeds stock
test('shows error when quantity exceeds available stock', async ({ page }) => {
await page.getByRole('spinbutton', { name: /quantity/i }).fill('{{overStockQuantity}}');
await page.getByRole('button', { name: /add to cart/i }).click();
await expect(page.getByRole('alert')).toContainText(/only.*available|exceeds.*stock/i);
});
// Edge case: cart opens after add
test('cart drawer opens after adding item', async ({ page }) => {
await page.getByRole('button', { name: /add to cart/i }).click();
await expect(page.getByRole('dialog', { name: /cart/i })).toBeVisible();
await expect(page.getByRole('dialog').getByText('{{productName}}')).toBeVisible();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Add to Cart', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/products/{{productId}}');
});
test('adds product to cart', async ({ page }) => {
await page.getByRole('button', { name: /add to cart/i }).click();
await expect(page.getByRole('status', { name: /cart/i })).toContainText('1');
});
test('add to cart disabled for out-of-stock', async ({ page }) => {
await page.goto('{{baseUrl}}/products/{{outOfStockProductId}}');
await expect(page.getByRole('button', { name: /add to cart/i })).toBeDisabled();
});
test('cart persists after navigation', async ({ page }) => {
await page.getByRole('button', { name: /add to cart/i }).click();
await page.goto('{{baseUrl}}/products');
await expect(page.getByRole('status', { name: /cart/i })).toContainText('1');
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Single add | Product added, cart count = 1 |
| Repeated add | Cart count increments |
| Quantity selector | Specified quantity added |
| Persist on nav | Cart count survives page change |
| Out of stock | Button disabled, label shown |
| Quantity exceeds stock | Error alert |
| Cart drawer | Slide-in cart opens showing added item |
@@ -0,0 +1,123 @@
# Apply Coupon Template
Tests valid coupon code, invalid code, and expired coupon handling.
## Prerequisites
- Cart with items totalling `{{cartTotal}}`
- Valid coupon: `{{validCouponCode}}` ({{discountPercent}}% off)
- Expired coupon: `{{expiredCouponCode}}`
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Apply Coupon', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/cart');
});
// Happy path: valid coupon applied
test('applies valid coupon and shows discount', async ({ page }) => {
await page.getByRole('textbox', { name: /coupon|promo code/i }).fill('{{validCouponCode}}');
await page.getByRole('button', { name: /apply/i }).click();
await expect(page.getByText(/{{discountPercent}}%.*off|discount applied/i)).toBeVisible();
await expect(page.getByText('{{discountedTotal}}')).toBeVisible();
await expect(page.getByRole('button', { name: /remove coupon/i })).toBeVisible();
});
// Happy path: percentage discount calculated correctly
test('calculates discount amount correctly', async ({ page }) => {
await page.getByRole('textbox', { name: /coupon|promo code/i }).fill('{{validCouponCode}}');
await page.getByRole('button', { name: /apply/i }).click();
const discountLine = page.getByRole('row', { name: /discount/i });
await expect(discountLine).toContainText('-{{discountAmount}}');
});
// Happy path: remove applied coupon
test('removes applied coupon and restores original total', async ({ page }) => {
await page.getByRole('textbox', { name: /coupon|promo code/i }).fill('{{validCouponCode}}');
await page.getByRole('button', { name: /apply/i }).click();
await page.getByRole('button', { name: /remove coupon/i }).click();
await expect(page.getByText('{{cartTotal}}')).toBeVisible();
await expect(page.getByRole('button', { name: /remove coupon/i })).toBeHidden();
});
// Error case: invalid coupon code
test('shows error for invalid coupon code', async ({ page }) => {
await page.getByRole('textbox', { name: /coupon|promo code/i }).fill('INVALID123');
await page.getByRole('button', { name: /apply/i }).click();
await expect(page.getByRole('alert')).toContainText(/invalid.*coupon|code not found/i);
await expect(page.getByText('{{cartTotal}}')).toBeVisible();
});
// Error case: expired coupon
test('shows error for expired coupon', async ({ page }) => {
await page.getByRole('textbox', { name: /coupon|promo code/i }).fill('{{expiredCouponCode}}');
await page.getByRole('button', { name: /apply/i }).click();
await expect(page.getByRole('alert')).toContainText(/expired|no longer valid/i);
});
// Error case: coupon not applicable to cart items
test('shows error when coupon excludes cart products', async ({ page }) => {
await page.getByRole('textbox', { name: /coupon|promo code/i }).fill('{{categoryRestrictedCoupon}}');
await page.getByRole('button', { name: /apply/i }).click();
await expect(page.getByRole('alert')).toContainText(/not applicable|excluded/i);
});
// Edge case: empty coupon field
test('apply button disabled when coupon field is empty', async ({ page }) => {
const applyBtn = page.getByRole('button', { name: /apply/i });
await expect(applyBtn).toBeDisabled();
await page.getByRole('textbox', { name: /coupon|promo code/i }).fill('X');
await expect(applyBtn).toBeEnabled();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Apply Coupon', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/cart');
});
test('applies valid coupon and shows discount', async ({ page }) => {
await page.getByRole('textbox', { name: /coupon|promo code/i }).fill('{{validCouponCode}}');
await page.getByRole('button', { name: /apply/i }).click();
await expect(page.getByText(/discount applied/i)).toBeVisible();
await expect(page.getByText('{{discountedTotal}}')).toBeVisible();
});
test('shows error for invalid coupon', async ({ page }) => {
await page.getByRole('textbox', { name: /coupon|promo code/i }).fill('INVALID123');
await page.getByRole('button', { name: /apply/i }).click();
await expect(page.getByRole('alert')).toContainText(/invalid.*coupon/i);
});
test('shows error for expired coupon', async ({ page }) => {
await page.getByRole('textbox', { name: /coupon|promo code/i }).fill('{{expiredCouponCode}}');
await page.getByRole('button', { name: /apply/i }).click();
await expect(page.getByRole('alert')).toContainText(/expired/i);
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Valid coupon | Discount applied, total updated |
| Discount calculation | Discount line shows correct amount |
| Remove coupon | Original total restored |
| Invalid code | Error alert, total unchanged |
| Expired coupon | Expiry error shown |
| Category restriction | Coupon not applicable error |
| Empty field | Apply button disabled |
@@ -0,0 +1,108 @@
# Order Confirmation Template
Tests the success page and order details after checkout.
## Prerequisites
- Completed order with ID `{{orderId}}`
- Authenticated session via `{{authStorageStatePath}}`
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Order Confirmation', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
// Happy path: confirmation page content
test('shows order confirmation with correct details', async ({ page }) => {
await page.goto('{{baseUrl}}/order-confirmation/{{orderId}}');
await expect(page.getByRole('heading', { name: /order confirmed|thank you/i })).toBeVisible();
await expect(page.getByText('{{orderId}}')).toBeVisible();
await expect(page.getByText('{{productName}}')).toBeVisible();
await expect(page.getByText('{{orderTotal}}')).toBeVisible();
});
// Happy path: confirmation email notice
test('shows confirmation email notice', async ({ page }) => {
await page.goto('{{baseUrl}}/order-confirmation/{{orderId}}');
await expect(page.getByText(/confirmation.*sent to|email.*{{username}}/i)).toBeVisible();
});
// Happy path: billing and shipping details shown
test('displays shipping address on confirmation page', async ({ page }) => {
await page.goto('{{baseUrl}}/order-confirmation/{{orderId}}');
await expect(page.getByText('{{shippingAddress}}')).toBeVisible();
await expect(page.getByText('{{billingAddress}}')).toBeVisible();
});
// Happy path: CTA navigates to order history
test('"view your orders" link navigates to order history', async ({ page }) => {
await page.goto('{{baseUrl}}/order-confirmation/{{orderId}}');
await page.getByRole('link', { name: /view.*orders|my orders/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/orders');
});
// Happy path: continue shopping CTA
test('"continue shopping" returns to products', async ({ page }) => {
await page.goto('{{baseUrl}}/order-confirmation/{{orderId}}');
await page.getByRole('link', { name: /continue shopping/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/products');
});
// Error case: accessing another user's order shows 403
test('cannot access another user\'s confirmation page', async ({ page }) => {
await page.goto('{{baseUrl}}/order-confirmation/{{otherUsersOrderId}}');
await expect(page).toHaveURL(/\/403|\/dashboard/);
});
// Edge case: cart is empty after successful checkout
test('cart is empty after order confirmed', async ({ page }) => {
await page.goto('{{baseUrl}}/order-confirmation/{{orderId}}');
await expect(page.getByRole('status', { name: /cart/i })).toContainText('0');
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Order Confirmation', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('shows order id and total on confirmation', async ({ page }) => {
await page.goto('{{baseUrl}}/order-confirmation/{{orderId}}');
await expect(page.getByRole('heading', { name: /order confirmed|thank you/i })).toBeVisible();
await expect(page.getByText('{{orderId}}')).toBeVisible();
await expect(page.getByText('{{orderTotal}}')).toBeVisible();
});
test('cart is empty after checkout', async ({ page }) => {
await page.goto('{{baseUrl}}/order-confirmation/{{orderId}}');
await expect(page.getByRole('status', { name: /cart/i })).toContainText('0');
});
test('cannot access another user\'s order', async ({ page }) => {
await page.goto('{{baseUrl}}/order-confirmation/{{otherUsersOrderId}}');
await expect(page).toHaveURL(/\/403|\/dashboard/);
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Confirmation content | Order ID, product, total visible |
| Email notice | Confirmation email address shown |
| Shipping/billing | Addresses displayed |
| View orders CTA | Navigates to /orders |
| Continue shopping | Returns to /products |
| Unauthorized | Other user's order → 403 |
| Cart cleared | Cart count = 0 after checkout |
@@ -0,0 +1,119 @@
# Order History Template
Tests listing orders, viewing order details, and pagination.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- At least `{{orderCount}}` orders seeded for user
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Order History', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
// Happy path: order list
test('displays list of orders with key details', async ({ page }) => {
await page.goto('{{baseUrl}}/orders');
await expect(page.getByRole('heading', { name: /orders|order history/i })).toBeVisible();
const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
await expect(rows.first()).toContainText('{{latestOrderId}}');
await expect(rows.first()).toContainText('{{latestOrderStatus}}');
await expect(rows.first()).toContainText('{{latestOrderTotal}}');
});
// Happy path: view order details
test('navigates to order detail from history', async ({ page }) => {
await page.goto('{{baseUrl}}/orders');
await page.getByRole('link', { name: new RegExp('{{latestOrderId}}') }).click();
await expect(page).toHaveURL(`{{baseUrl}}/orders/{{latestOrderId}}`);
await expect(page.getByRole('heading', { name: '{{latestOrderId}}' })).toBeVisible();
await expect(page.getByText('{{productName}}')).toBeVisible();
});
// Happy path: order status badge
test('shows correct status badge for each order', async ({ page }) => {
await page.goto('{{baseUrl}}/orders');
const deliveredBadge = page.getByRole('status', { name: /delivered/i }).first();
await expect(deliveredBadge).toBeVisible();
});
// Happy path: pagination
test('paginates through orders', async ({ page }) => {
await page.goto('{{baseUrl}}/orders');
const firstPageFirstOrder = await page.getByRole('row').nth(1).textContent();
await page.getByRole('button', { name: /next page|>/i }).click();
await expect(page.getByRole('row').nth(1)).not.toHaveText(firstPageFirstOrder!);
await expect(page.getByRole('button', { name: /previous page|</i })).toBeEnabled();
});
// Happy path: items per page selector
test('changes items per page', async ({ page }) => {
await page.goto('{{baseUrl}}/orders');
await page.getByRole('combobox', { name: /per page|items per page/i }).selectOption('50');
const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
await expect(rows).toHaveCount(Math.min(50, {{orderCount}}));
});
// Error case: empty order history
test('shows empty state for user with no orders', async ({ page }) => {
await page.goto('{{baseUrl}}/orders');
// Assumes this user context has no orders
await expect(page.getByText(/no orders yet|start shopping/i)).toBeVisible();
});
// Edge case: reorder from history
test('adds previous order items to cart via reorder', async ({ page }) => {
await page.goto('{{baseUrl}}/orders/{{latestOrderId}}');
await page.getByRole('button', { name: /reorder|buy again/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/cart');
await expect(page.getByText('{{productName}}')).toBeVisible();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Order History', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('displays orders with id, status, and total', async ({ page }) => {
await page.goto('{{baseUrl}}/orders');
const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
await expect(rows.first()).toContainText('{{latestOrderId}}');
});
test('navigates to order detail', async ({ page }) => {
await page.goto('{{baseUrl}}/orders');
await page.getByRole('link', { name: new RegExp('{{latestOrderId}}') }).click();
await expect(page).toHaveURL(`{{baseUrl}}/orders/{{latestOrderId}}`);
});
test('paginates through orders', async ({ page }) => {
await page.goto('{{baseUrl}}/orders');
await page.getByRole('button', { name: /next page|>/i }).click();
await expect(page.getByRole('button', { name: /previous page|</i })).toBeEnabled();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Order list | ID, status, total visible per row |
| Order detail | Clicking order → detail page |
| Status badge | Correct badge per order state |
| Pagination | Next page loads different orders |
| Items per page | Selector changes row count |
| Empty state | No-orders message with CTA |
| Reorder | Previous order items added to cart |
@@ -0,0 +1,148 @@
# Payment Template
Tests card form entry, validation, and payment processing.
## Prerequisites
- Cart with items, shipping filled
- Test card numbers: `{{testCardNumber}}` (success), `{{declinedCardNumber}}` (decline)
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect, Page } from '@playwright/test';
async function fillCardForm(page: Page, card: {
number: string; expiry: string; cvc: string; name: string;
}): Promise<void> {
// Stripe/Braintree iframes — adapt frame locator to your provider
const cardFrame = page.frameLocator('[data-testid="card-number-frame"]');
await cardFrame.getByRole('textbox', { name: /card number/i }).fill(card.number);
const expiryFrame = page.frameLocator('[data-testid="expiry-frame"]');
await expiryFrame.getByRole('textbox', { name: /expiry/i }).fill(card.expiry);
const cvcFrame = page.frameLocator('[data-testid="cvc-frame"]');
await cvcFrame.getByRole('textbox', { name: /cvc|cvv/i }).fill(card.cvc);
await page.getByRole('textbox', { name: /cardholder name/i }).fill(card.name);
}
test.describe('Payment', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/checkout/payment');
});
// Happy path: successful payment
test('completes payment with valid card', async ({ page }) => {
await fillCardForm(page, {
number: '{{testCardNumber}}',
expiry: '12/28',
cvc: '123',
name: '{{cardholderName}}',
});
await page.getByRole('button', { name: /pay|place order/i }).click();
await expect(page).toHaveURL(/\/order-confirmation|\/success/);
await expect(page.getByRole('heading', { name: /order confirmed|thank you/i })).toBeVisible();
});
// Happy path: processing state shown
test('shows processing state while payment is pending', async ({ page }) => {
await fillCardForm(page, {
number: '{{testCardNumber}}',
expiry: '12/28',
cvc: '123',
name: '{{cardholderName}}',
});
const payBtn = page.getByRole('button', { name: /pay|place order/i });
await payBtn.click();
await expect(payBtn).toBeDisabled();
await expect(page.getByText(/processing|please wait/i)).toBeVisible();
});
// Error case: declined card
test('shows decline error for rejected card', async ({ page }) => {
await fillCardForm(page, {
number: '{{declinedCardNumber}}',
expiry: '12/28',
cvc: '123',
name: '{{cardholderName}}',
});
await page.getByRole('button', { name: /pay|place order/i }).click();
await expect(page.getByRole('alert')).toContainText(/declined|card.*not accepted/i);
await expect(page).toHaveURL(/\/checkout\/payment/);
});
// Error case: invalid card number format
test('shows inline error for invalid card number', async ({ page }) => {
const cardFrame = page.frameLocator('[data-testid="card-number-frame"]');
await cardFrame.getByRole('textbox', { name: /card number/i }).fill('1234');
await page.getByRole('button', { name: /pay|place order/i }).click();
await expect(page.getByText(/invalid.*card number/i)).toBeVisible();
});
// Error case: expired card
test('shows error for expired card', async ({ page }) => {
await fillCardForm(page, {
number: '{{testCardNumber}}',
expiry: '01/20',
cvc: '123',
name: '{{cardholderName}}',
});
await page.getByRole('button', { name: /pay|place order/i }).click();
await expect(page.getByRole('alert')).toContainText(/expired|invalid.*expiry/i);
});
// Edge case: 3DS authentication required
test('handles 3DS challenge and completes payment', async ({ page }) => {
await fillCardForm(page, {
number: '{{threeDsCardNumber}}',
expiry: '12/28',
cvc: '123',
name: '{{cardholderName}}',
});
await page.getByRole('button', { name: /pay|place order/i }).click();
// 3DS modal appears
const challengeFrame = page.frameLocator('[data-testid="3ds-challenge-frame"]');
await challengeFrame.getByRole('button', { name: /complete authentication/i }).click();
await expect(page).toHaveURL(/\/order-confirmation|\/success/);
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Payment', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/checkout/payment');
});
test('completes payment with valid card', async ({ page }) => {
const cardFrame = page.frameLocator('[data-testid="card-number-frame"]');
await cardFrame.getByRole('textbox', { name: /card number/i }).fill('{{testCardNumber}}');
await page.getByRole('button', { name: /pay|place order/i }).click();
await expect(page).toHaveURL(/\/order-confirmation/);
});
test('shows decline error for rejected card', async ({ page }) => {
const cardFrame = page.frameLocator('[data-testid="card-number-frame"]');
await cardFrame.getByRole('textbox', { name: /card number/i }).fill('{{declinedCardNumber}}');
await page.getByRole('button', { name: /pay|place order/i }).click();
await expect(page.getByRole('alert')).toContainText(/declined/i);
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Successful payment | Valid test card → order confirmation |
| Processing state | Button disabled + spinner during processing |
| Declined card | Error alert, stays on payment page |
| Invalid card number | Inline validation from provider |
| Expired card | Expiry error |
| 3DS challenge | Modal completed, payment succeeds |
@@ -0,0 +1,125 @@
# Update Cart Quantity Template
Tests increasing, decreasing, and removing items from cart.
## Prerequisites
- Cart with at least one item: `{{productName}}` (quantity 2)
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Update Cart Quantity', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/cart');
// Assumes cart is pre-populated via storageState or API setup
});
// Happy path: increase quantity
test('increases item quantity', async ({ page }) => {
const row = page.getByRole('row', { name: new RegExp('{{productName}}') });
await row.getByRole('button', { name: /increase|plus|\+/i }).click();
await expect(row.getByRole('spinbutton', { name: /quantity/i })).toHaveValue('3');
await expect(page.getByRole('region', { name: /order summary/i })).toContainText('{{updatedTotal}}');
});
// Happy path: decrease quantity
test('decreases item quantity', async ({ page }) => {
const row = page.getByRole('row', { name: new RegExp('{{productName}}') });
await row.getByRole('button', { name: /decrease|minus|/i }).click();
await expect(row.getByRole('spinbutton', { name: /quantity/i })).toHaveValue('1');
});
// Happy path: type quantity directly
test('updates quantity by typing in field', async ({ page }) => {
const row = page.getByRole('row', { name: new RegExp('{{productName}}') });
const qtyInput = row.getByRole('spinbutton', { name: /quantity/i });
await qtyInput.fill('5');
await qtyInput.press('Tab');
await expect(qtyInput).toHaveValue('5');
});
// Happy path: remove item with remove button
test('removes item from cart', async ({ page }) => {
const row = page.getByRole('row', { name: new RegExp('{{productName}}') });
await row.getByRole('button', { name: /remove|delete/i }).click();
await expect(row).toBeHidden();
await expect(page.getByText(/cart is empty/i)).toBeVisible();
});
// Happy path: decrease to 0 removes item
test('removing to quantity 0 removes item', async ({ page }) => {
const row = page.getByRole('row', { name: new RegExp('{{productName}}') });
await row.getByRole('button', { name: /decrease|minus/i }).click(); // from 2 to 1
await row.getByRole('button', { name: /decrease|minus/i }).click(); // should trigger remove
await expect(row).toBeHidden();
});
// Error case: quantity cannot go below 1 via decrease button
test('decrease button disabled at minimum quantity', async ({ page }) => {
const row = page.getByRole('row').nth(1);
const qty = row.getByRole('spinbutton', { name: /quantity/i });
await qty.fill('1');
await qty.press('Tab');
await expect(row.getByRole('button', { name: /decrease|minus/i })).toBeDisabled();
});
// Edge case: quantity clamped to stock limit
test('quantity capped at available stock', async ({ page }) => {
const row = page.getByRole('row', { name: new RegExp('{{productName}}') });
const qtyInput = row.getByRole('spinbutton', { name: /quantity/i });
await qtyInput.fill('{{overStockQuantity}}');
await qtyInput.press('Tab');
await expect(qtyInput).toHaveValue('{{maxStock}}');
await expect(page.getByRole('alert')).toContainText(/max.*available|stock limit/i);
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Update Cart Quantity', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/cart');
});
test('increases item quantity', async ({ page }) => {
const row = page.getByRole('row', { name: new RegExp('{{productName}}') });
await row.getByRole('button', { name: /increase|plus|\+/i }).click();
await expect(row.getByRole('spinbutton', { name: /quantity/i })).toHaveValue('3');
});
test('removes item from cart', async ({ page }) => {
await page.getByRole('row', { name: new RegExp('{{productName}}') })
.getByRole('button', { name: /remove|delete/i }).click();
await expect(page.getByText(/cart is empty/i)).toBeVisible();
});
test('decrease button disabled at quantity 1', async ({ page }) => {
const row = page.getByRole('row').nth(1);
await row.getByRole('spinbutton', { name: /quantity/i }).fill('1');
await row.getByRole('spinbutton', { name: /quantity/i }).press('Tab');
await expect(row.getByRole('button', { name: /decrease|minus/i })).toBeDisabled();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Increase | +1 → quantity updates, total recalculates |
| Decrease | -1 → quantity updates |
| Type directly | Manual quantity input accepted on blur/tab |
| Remove button | Item removed, empty-cart message shown |
| Decrease to 0 | Triggers item removal |
| Min quantity | Decrease button disabled at 1 |
| Stock cap | Input clamped to available stock |
@@ -0,0 +1,129 @@
# Bulk Operations Template
Tests selecting multiple items and performing bulk delete/update actions.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- At least `{{minItemCount}}` entities seeded in list
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Bulk Operations', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s');
});
// Happy path: select all and bulk delete
test('selects all and bulk deletes', async ({ page }) => {
await page.getByRole('checkbox', { name: /select all/i }).check();
const checkboxes = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') })
.getByRole('checkbox');
await expect(checkboxes.first()).toBeChecked();
await page.getByRole('button', { name: /bulk delete/i }).click();
await page.getByRole('dialog').getByRole('button', { name: /confirm/i }).click();
await expect(page.getByRole('alert')).toContainText(/deleted/i);
await expect(page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') }))
.toHaveCount(0);
});
// Happy path: select specific rows and bulk update status
test('updates status of selected rows', async ({ page }) => {
const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
await rows.nth(0).getByRole('checkbox').check();
await rows.nth(1).getByRole('checkbox').check();
await expect(page.getByText(/2 selected/i)).toBeVisible();
await page.getByRole('button', { name: /bulk actions/i }).click();
await page.getByRole('menuitem', { name: /mark as active/i }).click();
await expect(page.getByRole('alert')).toContainText(/2.*updated/i);
});
// Happy path: toolbar appears only when items selected
test('shows bulk action toolbar only when items are selected', async ({ page }) => {
await expect(page.getByRole('toolbar', { name: /bulk actions/i })).toBeHidden();
await page.getByRole('row').nth(1).getByRole('checkbox').check();
await expect(page.getByRole('toolbar', { name: /bulk actions/i })).toBeVisible();
});
// Happy path: deselect all clears toolbar
test('hides toolbar after deselecting all', async ({ page }) => {
await page.getByRole('checkbox', { name: /select all/i }).check();
await page.getByRole('checkbox', { name: /select all/i }).uncheck();
await expect(page.getByRole('toolbar', { name: /bulk actions/i })).toBeHidden();
});
// Error case: bulk delete requires confirmation
test('requires confirmation before bulk delete', async ({ page }) => {
await page.getByRole('checkbox', { name: /select all/i }).check();
await page.getByRole('button', { name: /bulk delete/i }).click();
await expect(page.getByRole('dialog', { name: /confirm/i })).toBeVisible();
await page.getByRole('button', { name: /cancel/i }).click();
const rowCount = await page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') }).count();
expect(rowCount).toBeGreaterThan(0);
});
// Edge case: select all across pages
test('shows "select all across pages" option when applicable', async ({ page }) => {
await page.getByRole('checkbox', { name: /select all/i }).check();
const crossPage = page.getByRole('button', { name: /select all.*across pages/i });
if (await crossPage.isVisible()) {
await crossPage.click();
await expect(page.getByText(/all.*selected/i)).toBeVisible();
}
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Bulk Operations', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s');
});
test('shows bulk action toolbar when items selected', async ({ page }) => {
await expect(page.getByRole('toolbar', { name: /bulk actions/i })).toBeHidden();
await page.getByRole('row').nth(1).getByRole('checkbox').check();
await expect(page.getByRole('toolbar', { name: /bulk actions/i })).toBeVisible();
});
test('selects all and bulk deletes', async ({ page }) => {
await page.getByRole('checkbox', { name: /select all/i }).check();
await page.getByRole('button', { name: /bulk delete/i }).click();
await page.getByRole('dialog').getByRole('button', { name: /confirm/i }).click();
await expect(page.getByRole('alert')).toContainText(/deleted/i);
});
test('requires confirmation before bulk delete', async ({ page }) => {
await page.getByRole('checkbox', { name: /select all/i }).check();
await page.getByRole('button', { name: /bulk delete/i }).click();
await expect(page.getByRole('dialog', { name: /confirm/i })).toBeVisible();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Select all + delete | All rows selected → confirmed delete → empty list |
| Partial select + update | N rows selected → status updated → success |
| Toolbar visibility | Appears on select, hides on deselect |
| Deselect all | Select all → uncheck → toolbar gone |
| Confirmation required | Bulk delete shows dialog first |
| Cross-page select | Select-all-pages option shown on multi-page lists |
@@ -0,0 +1,118 @@
# Create Entity Template
Tests creating a new entity via form submission.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- Entity type: `{{entityName}}` (e.g. "Project", "Product", "User")
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Create {{entityName}}', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/new');
});
// Happy path: create with valid data
test('creates {{entityName}} with valid data', async ({ page }) => {
await page.getByRole('textbox', { name: /name/i }).fill('{{testEntityName}}');
await page.getByRole('textbox', { name: /description/i }).fill('{{testEntityDescription}}');
await page.getByRole('combobox', { name: /category/i }).selectOption('{{testEntityCategory}}');
await page.getByRole('button', { name: /create|save/i }).click();
await expect(page).toHaveURL(/\/{{entityName}}s\/\d+/);
await expect(page.getByRole('heading', { name: '{{testEntityName}}' })).toBeVisible();
await expect(page.getByRole('alert')).toContainText(/created successfully/i);
});
// Happy path: create and add another
test('clears form after "save and add another"', async ({ page }) => {
await page.getByRole('textbox', { name: /name/i }).fill('{{testEntityName}}');
await page.getByRole('button', { name: /save and add another/i }).click();
await expect(page.getByRole('textbox', { name: /name/i })).toHaveValue('');
await expect(page.getByRole('alert')).toContainText(/created successfully/i);
});
// Error case: required fields missing
test('shows validation errors for empty required fields', async ({ page }) => {
await page.getByRole('button', { name: /create|save/i }).click();
await expect(page.getByText(/name is required/i)).toBeVisible();
await expect(page).toHaveURL('{{baseUrl}}/{{entityName}}s/new');
});
// Error case: duplicate name
test('shows error when entity name already exists', async ({ page }) => {
await page.getByRole('textbox', { name: /name/i }).fill('{{existingEntityName}}');
await page.getByRole('button', { name: /create|save/i }).click();
await expect(page.getByRole('alert')).toContainText(/already exists|duplicate/i);
});
// Edge case: max length enforcement
test('enforces max length on name field', async ({ page }) => {
const longName = 'A'.repeat({{maxNameLength}} + 1);
await page.getByRole('textbox', { name: /name/i }).fill(longName);
const actualValue = await page.getByRole('textbox', { name: /name/i }).inputValue();
expect(actualValue.length).toBeLessThanOrEqual({{maxNameLength}});
});
// Edge case: cancel navigates away without saving
test('cancel navigates back without creating', async ({ page }) => {
await page.getByRole('textbox', { name: /name/i }).fill('should-not-save');
await page.getByRole('button', { name: /cancel/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/{{entityName}}s');
await expect(page.getByRole('cell', { name: 'should-not-save' })).toBeHidden();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Create {{entityName}}', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/new');
});
test('creates entity with valid data', async ({ page }) => {
await page.getByRole('textbox', { name: /name/i }).fill('{{testEntityName}}');
await page.getByRole('textbox', { name: /description/i }).fill('{{testEntityDescription}}');
await page.getByRole('button', { name: /create|save/i }).click();
await expect(page).toHaveURL(/\/{{entityName}}s\/\d+/);
await expect(page.getByRole('alert')).toContainText(/created successfully/i);
});
test('shows validation errors for empty form', async ({ page }) => {
await page.getByRole('button', { name: /create|save/i }).click();
await expect(page.getByText(/name is required/i)).toBeVisible();
});
test('cancel navigates back without saving', async ({ page }) => {
await page.getByRole('textbox', { name: /name/i }).fill('not-saved');
await page.getByRole('button', { name: /cancel/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/{{entityName}}s');
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Happy path | Valid form → entity created → detail page |
| Save and add | Form cleared, ready for next entry |
| Required fields | Empty submit → inline validation |
| Duplicate name | Server error shown |
| Max length | Input truncated at field max |
| Cancel | No entity created, returns to list |
@@ -0,0 +1,116 @@
# Delete Entity Template
Tests deletion with confirmation dialog and post-delete behaviour.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- Entity to delete: ID `{{entityId}}`, name `{{entityName}}`
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Delete {{entityName}}', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
// Happy path: delete from detail page
test('deletes entity after confirming dialog', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}');
await page.getByRole('button', { name: /delete/i }).click();
const dialog = page.getByRole('dialog', { name: /delete|confirm/i });
await expect(dialog).toBeVisible();
await expect(dialog).toContainText('{{entityName}}');
await dialog.getByRole('button', { name: /delete|confirm/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/{{entityName}}s');
await expect(page.getByRole('alert')).toContainText(/deleted successfully/i);
await expect(page.getByRole('link', { name: '{{entityName}}' })).toBeHidden();
});
// Happy path: delete from list view
test('deletes entity from list row action', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s');
const row = page.getByRole('row', { name: new RegExp('{{entityName}}') });
await row.getByRole('button', { name: /delete/i }).click();
await page.getByRole('dialog').getByRole('button', { name: /confirm|delete/i }).click();
await expect(row).toBeHidden();
});
// Error case: cancel deletion
test('does not delete when cancel is clicked in dialog', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}');
await page.getByRole('button', { name: /delete/i }).click();
await page.getByRole('dialog').getByRole('button', { name: /cancel/i }).click();
await expect(page.getByRole('dialog')).toBeHidden();
await expect(page).toHaveURL(`{{baseUrl}}/{{entityName}}s/{{entityId}}`);
await expect(page.getByRole('heading', { name: '{{entityName}}' })).toBeVisible();
});
// Error case: delete entity with dependents
test('shows error when entity has dependent records', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityWithDependentsId}}');
await page.getByRole('button', { name: /delete/i }).click();
await page.getByRole('dialog').getByRole('button', { name: /confirm|delete/i }).click();
await expect(page.getByRole('alert')).toContainText(/cannot delete|has dependents/i);
await expect(page).toHaveURL(`{{baseUrl}}/{{entityName}}s/{{entityWithDependentsId}}`);
});
// Edge case: confirmation dialog requires typing entity name
test('requires typing entity name to confirm deletion', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}');
await page.getByRole('button', { name: /delete/i }).click();
const confirmBtn = page.getByRole('dialog').getByRole('button', { name: /confirm|delete/i });
await expect(confirmBtn).toBeDisabled();
await page.getByRole('textbox', { name: /type.*to confirm/i }).fill('{{entityName}}');
await expect(confirmBtn).toBeEnabled();
await confirmBtn.click();
await expect(page).toHaveURL('{{baseUrl}}/{{entityName}}s');
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Delete {{entityName}}', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('deletes entity after confirming dialog', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}');
await page.getByRole('button', { name: /delete/i }).click();
await page.getByRole('dialog').getByRole('button', { name: /confirm|delete/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/{{entityName}}s');
await expect(page.getByRole('alert')).toContainText(/deleted successfully/i);
});
test('does not delete when cancel clicked', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}');
await page.getByRole('button', { name: /delete/i }).click();
await page.getByRole('dialog').getByRole('button', { name: /cancel/i }).click();
await expect(page.getByRole('heading', { name: '{{entityName}}' })).toBeVisible();
});
test('shows error for entity with dependents', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityWithDependentsId}}');
await page.getByRole('button', { name: /delete/i }).click();
await page.getByRole('dialog').getByRole('button', { name: /confirm|delete/i }).click();
await expect(page.getByRole('alert')).toContainText(/cannot delete/i);
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Delete confirmed | Dialog confirmed → entity removed → list page |
| Delete from list | Row action → confirm → row removed |
| Cancel deletion | Dialog cancelled → entity intact |
| Dependent error | Entity with children → deletion blocked |
| Type-to-confirm | Confirm button disabled until name typed |
@@ -0,0 +1,117 @@
# Read Entity Template
Tests viewing entity details and list view with correct data display.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- Seeded entity with ID `{{entityId}}` and name `{{entityName}}`
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Read {{entityName}}', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
// Happy path: detail page
test('displays entity details correctly', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}');
await expect(page.getByRole('heading', { name: '{{expectedTitle}}' })).toBeVisible();
await expect(page.getByText('{{expectedField}}')).toBeVisible();
await expect(page.getByText('{{expectedCategory}}')).toBeVisible();
});
// Happy path: list view shows all items
test('displays list of entities', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s');
await expect(page.getByRole('table')).toBeVisible();
const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
await expect(rows).toHaveCount({{expectedItemCount}});
});
// Happy path: list item links to detail
test('clicking list item navigates to detail page', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s');
await page.getByRole('link', { name: '{{expectedTitle}}' }).click();
await expect(page).toHaveURL(`{{baseUrl}}/{{entityName}}s/{{entityId}}`);
});
// Happy path: breadcrumb navigation
test('breadcrumb shows correct path', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}');
await expect(page.getByRole('navigation', { name: /breadcrumb/i })).toContainText('{{entityName}}s');
await expect(page.getByRole('navigation', { name: /breadcrumb/i })).toContainText('{{expectedTitle}}');
});
// Error case: non-existent entity shows 404
test('shows 404 for non-existent entity', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/999999');
await expect(page.getByRole('heading', { name: /404|not found/i })).toBeVisible();
});
// Edge case: loading state resolves to data
test('shows data after loading completes', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}');
// Skeleton/spinner should be gone, data visible
await expect(page.getByTestId('skeleton')).toBeHidden();
await expect(page.getByRole('heading', { name: '{{expectedTitle}}' })).toBeVisible();
});
// Edge case: empty list state
test('shows empty state when no entities exist', async ({ page }) => {
// Assumes a fresh context or filter that returns no results
await page.goto('{{baseUrl}}/{{entityName}}s?filter={{emptyFilter}}');
await expect(page.getByText(/no {{entityName}}s found/i)).toBeVisible();
await expect(page.getByRole('button', { name: /create|add/i })).toBeVisible();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Read {{entityName}}', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('displays entity details correctly', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}');
await expect(page.getByRole('heading', { name: '{{expectedTitle}}' })).toBeVisible();
await expect(page.getByText('{{expectedField}}')).toBeVisible();
});
test('displays list of entities with correct count', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s');
const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
await expect(rows).toHaveCount({{expectedItemCount}});
});
test('shows 404 for non-existent entity', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/999999');
await expect(page.getByRole('heading', { name: /404|not found/i })).toBeVisible();
});
test('shows empty state when list is empty', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s?filter={{emptyFilter}}');
await expect(page.getByText(/no {{entityName}}s found/i)).toBeVisible();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Detail view | Entity fields rendered correctly |
| List view | Correct row count in table |
| List → detail | Clicking row/link navigates correctly |
| Breadcrumb | Path reflects current location |
| 404 | Non-existent ID shows not-found page |
| Loading → data | Skeleton hidden, data visible after load |
| Empty list | No-results state with call to action |
@@ -0,0 +1,113 @@
# Soft Delete (Archive/Restore) Template
Tests archiving an entity, viewing archived items, and restoring them.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- Active entity: ID `{{entityId}}`, name `{{entityName}}`
- Archived entity: ID `{{archivedEntityId}}`
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Soft Delete — Archive & Restore', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
// Happy path: archive entity
test('archives entity and removes from active list', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}');
await page.getByRole('button', { name: /archive/i }).click();
await page.getByRole('dialog').getByRole('button', { name: /archive|confirm/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/{{entityName}}s');
await expect(page.getByRole('alert')).toContainText(/archived/i);
await expect(page.getByRole('link', { name: '{{entityName}}' })).toBeHidden();
});
// Happy path: archived entity appears in archived view
test('archived entity visible in archived list', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s?status=archived');
await expect(page.getByRole('link', { name: '{{entityName}}' })).toBeVisible();
});
// Happy path: restore archived entity
test('restores archived entity to active list', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s?status=archived');
const row = page.getByRole('row', { name: new RegExp('{{entityName}}') });
await row.getByRole('button', { name: /restore/i }).click();
await expect(page.getByRole('alert')).toContainText(/restored/i);
await expect(row).toBeHidden();
await page.goto('{{baseUrl}}/{{entityName}}s');
await expect(page.getByRole('link', { name: '{{entityName}}' })).toBeVisible();
});
// Happy path: active list does not show archived by default
test('active list does not include archived entities', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s');
await expect(page.getByRole('link', { name: /{{archivedEntityName}}/i })).toBeHidden();
});
// Error case: archived entity cannot be edited
test('archived entity edit button is disabled', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{archivedEntityId}}');
await expect(page.getByRole('button', { name: /edit/i })).toBeDisabled();
await expect(page.getByText(/archived/i)).toBeVisible();
});
// Edge case: permanently delete archived entity
test('permanently deletes archived entity', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{archivedEntityId}}');
await page.getByRole('button', { name: /delete permanently/i }).click();
await page.getByRole('dialog').getByRole('button', { name: /delete permanently/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/{{entityName}}s?status=archived');
await expect(page.getByRole('link', { name: '{{archivedEntityName}}' })).toBeHidden();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Soft Delete — Archive & Restore', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('archives entity and removes from active list', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}');
await page.getByRole('button', { name: /archive/i }).click();
await page.getByRole('dialog').getByRole('button', { name: /archive|confirm/i }).click();
await expect(page).toHaveURL('{{baseUrl}}/{{entityName}}s');
await expect(page.getByRole('link', { name: '{{entityName}}' })).toBeHidden();
});
test('restores archived entity to active list', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s?status=archived');
await page.getByRole('row', { name: new RegExp('{{entityName}}') })
.getByRole('button', { name: /restore/i }).click();
await expect(page.getByRole('alert')).toContainText(/restored/i);
});
test('archived entity edit button is disabled', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{archivedEntityId}}');
await expect(page.getByRole('button', { name: /edit/i })).toBeDisabled();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Archive | Entity moved to archived list, removed from active |
| Archived list | Archived items visible with status=archived filter |
| Restore | Archived entity returned to active list |
| Active list clean | Archived items hidden from default view |
| Edit disabled | Archived entity cannot be edited |
| Permanent delete | Hard-delete of archived entity |
@@ -0,0 +1,129 @@
# Update Entity Template
Tests editing an entity via form and inline edit interactions.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- Existing entity ID: `{{entityId}}`, name: `{{originalEntityName}}`
- App running at `{{baseUrl}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Update {{entityName}}', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
// Happy path: edit via form
test('updates entity via edit form', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}/edit');
const nameField = page.getByRole('textbox', { name: /name/i });
await nameField.clear();
await nameField.fill('{{updatedEntityName}}');
await page.getByRole('button', { name: /save|update/i }).click();
await expect(page).toHaveURL(`{{baseUrl}}/{{entityName}}s/{{entityId}}`);
await expect(page.getByRole('heading', { name: '{{updatedEntityName}}' })).toBeVisible();
await expect(page.getByRole('alert')).toContainText(/updated successfully/i);
});
// Happy path: inline edit
test('updates name via inline edit', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}');
await page.getByRole('button', { name: /edit name/i }).click();
const inlineInput = page.getByRole('textbox', { name: /name/i });
await inlineInput.clear();
await inlineInput.fill('{{updatedEntityName}}');
await inlineInput.press('Enter');
await expect(page.getByRole('heading', { name: '{{updatedEntityName}}' })).toBeVisible();
});
// Happy path: edit then navigate away — unsaved changes warning
test('warns before discarding unsaved changes', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}/edit');
await page.getByRole('textbox', { name: /name/i }).fill('unsaved-change');
await page.getByRole('link', { name: /cancel|back/i }).click();
await expect(page.getByRole('dialog', { name: /unsaved changes/i })).toBeVisible();
await page.getByRole('button', { name: /discard/i }).click();
await expect(page).toHaveURL(`{{baseUrl}}/{{entityName}}s/{{entityId}}`);
await expect(page.getByRole('heading', { name: '{{originalEntityName}}' })).toBeVisible();
});
// Error case: clearing required field
test('shows validation error when required field is cleared', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}/edit');
await page.getByRole('textbox', { name: /name/i }).clear();
await page.getByRole('button', { name: /save|update/i }).click();
await expect(page.getByText(/name is required/i)).toBeVisible();
await expect(page).toHaveURL(`{{baseUrl}}/{{entityName}}s/{{entityId}}/edit`);
});
// Error case: conflict (optimistic update failure)
test('handles concurrent edit conflict gracefully', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}/edit');
// Simulate another user modifying the record
await page.request.put(`{{baseUrl}}/api/{{entityName}}s/{{entityId}}`, {
data: { name: 'modified-by-other', version: 999 },
});
await page.getByRole('textbox', { name: /name/i }).fill('my-change');
await page.getByRole('button', { name: /save|update/i }).click();
await expect(page.getByRole('alert')).toContainText(/conflict|modified by another/i);
});
// Edge case: inline edit cancelled with Escape
test('cancels inline edit on Escape key', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}');
await page.getByRole('button', { name: /edit name/i }).click();
await page.getByRole('textbox', { name: /name/i }).fill('should-not-save');
await page.keyboard.press('Escape');
await expect(page.getByRole('heading', { name: '{{originalEntityName}}' })).toBeVisible();
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Update {{entityName}}', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('updates entity via edit form', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}/edit');
await page.getByRole('textbox', { name: /name/i }).clear();
await page.getByRole('textbox', { name: /name/i }).fill('{{updatedEntityName}}');
await page.getByRole('button', { name: /save|update/i }).click();
await expect(page.getByRole('heading', { name: '{{updatedEntityName}}' })).toBeVisible();
});
test('shows validation error when required field cleared', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}/edit');
await page.getByRole('textbox', { name: /name/i }).clear();
await page.getByRole('button', { name: /save|update/i }).click();
await expect(page.getByText(/name is required/i)).toBeVisible();
});
test('cancels inline edit on Escape', async ({ page }) => {
await page.goto('{{baseUrl}}/{{entityName}}s/{{entityId}}');
await page.getByRole('button', { name: /edit name/i }).click();
await page.getByRole('textbox', { name: /name/i }).fill('nope');
await page.keyboard.press('Escape');
await expect(page.getByRole('heading', { name: '{{originalEntityName}}' })).toBeVisible();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Edit form | Full edit form → save → detail page |
| Inline edit | Click field → type → Enter to save |
| Unsaved changes | Navigation shows discard confirmation |
| Required field | Cleared required field → validation |
| Conflict | Concurrent edit → conflict error |
| Escape cancel | Inline edit cancelled, original value restored |
@@ -0,0 +1,131 @@
# Chart Rendering Template
Tests chart visibility, interactive tooltips, and legend behaviour.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- Dashboard with charts at `{{baseUrl}}/dashboard`
- Chart library: `{{chartLibrary}}` (e.g. Chart.js, Recharts, D3)
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Chart Rendering', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
// Wait for chart container to be visible
await expect(page.getByRole('img', { name: /{{chartName}} chart/i })
.or(page.getByTestId('{{chartTestId}}'))).toBeVisible();
});
// Happy path: chart rendered and visible
test('renders {{chartName}} chart', async ({ page }) => {
const chart = page.getByTestId('{{chartTestId}}');
await expect(chart).toBeVisible();
// Chart has non-zero dimensions
const box = await chart.boundingBox();
expect(box?.width).toBeGreaterThan(0);
expect(box?.height).toBeGreaterThan(0);
});
// Happy path: tooltip shown on hover
test('shows tooltip on data point hover', async ({ page }) => {
const chart = page.getByTestId('{{chartTestId}}');
const box = await chart.boundingBox();
// Hover over the centre of the chart
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2);
await expect(page.getByRole('tooltip')).toBeVisible();
await expect(page.getByRole('tooltip')).toContainText(/\d/);
});
// Happy path: legend visible with correct labels
test('displays chart legend with correct series labels', async ({ page }) => {
const legend = page.getByRole('list', { name: /legend/i });
await expect(legend).toBeVisible();
await expect(legend.getByRole('listitem', { name: '{{seriesName1}}' })).toBeVisible();
await expect(legend.getByRole('listitem', { name: '{{seriesName2}}' })).toBeVisible();
});
// Happy path: clicking legend toggles series visibility
test('toggles series visibility via legend click', async ({ page }) => {
await page.getByRole('button', { name: '{{seriesName1}}' }).click();
// Series hidden — legend item shows struck-through or disabled state
await expect(page.getByRole('button', { name: '{{seriesName1}}' })).toHaveAttribute('aria-pressed', 'false');
});
// Happy path: chart updates when date range changed
test('updates chart when date range filter applied', async ({ page }) => {
const before = await page.getByTestId('{{chartTestId}}').screenshot();
await page.getByRole('combobox', { name: /date range/i }).selectOption('last_7_days');
const after = await page.getByTestId('{{chartTestId}}').screenshot();
expect(Buffer.compare(before, after)).not.toBe(0);
});
// Error case: empty data shows no-data state
test('shows no-data message when chart has no data', async ({ page }) => {
await page.route('{{baseUrl}}/api/chart-data*', route =>
route.fulfill({ status: 200, body: JSON.stringify({ data: [] }) })
);
await page.reload();
const chart = page.getByTestId('{{chartTestId}}');
await expect(chart.getByText(/no data|no results/i)).toBeVisible();
});
// Edge case: chart accessible via aria
test('chart has accessible title and description', async ({ page }) => {
const chart = page.getByTestId('{{chartTestId}}');
await expect(chart.getByRole('img')).toHaveAttribute('aria-label', /{{chartName}}/i);
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Chart Rendering', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('renders chart with non-zero dimensions', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
const chart = page.getByTestId('{{chartTestId}}');
await expect(chart).toBeVisible();
const box = await chart.boundingBox();
expect(box?.width).toBeGreaterThan(0);
expect(box?.height).toBeGreaterThan(0);
});
test('shows tooltip on hover', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
const chart = page.getByTestId('{{chartTestId}}');
const box = await chart.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await expect(page.getByRole('tooltip')).toBeVisible();
});
test('displays legend labels', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await expect(page.getByRole('list', { name: /legend/i })).toBeVisible();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Chart visible | Non-zero bounding box confirmed |
| Tooltip on hover | Tooltip appears with numeric value |
| Legend labels | Series names present in legend |
| Legend toggle | Click hides/shows series |
| Date range update | Chart changes when filter applied |
| No-data state | Empty dataset → no-data message |
| Accessible label | aria-label present on chart element |
@@ -0,0 +1,128 @@
# Dashboard Data Loading Template
Tests loading state, skeleton screens, and data display after fetch.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- Dashboard at `{{baseUrl}}/dashboard`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Dashboard Data Loading', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
// Happy path: skeleton shown then replaced by data
test('shows skeleton during load, then displays data', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
// Skeleton should resolve; real data appears
await expect(page.getByTestId('skeleton')).toBeHidden();
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
await expect(page.getByRole('region', { name: /{{widgetName}}/i })).toBeVisible();
});
// Happy path: all metric cards populated
test('renders metric cards with values', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
const cards = page.getByRole('article', { name: /metric/i });
await expect(cards).toHaveCount({{expectedMetricCount}});
await expect(cards.first().getByText(/\d/)).toBeVisible();
});
// Happy path: data updates on refresh
test('refreshes data when refresh button clicked', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await expect(page.getByTestId('skeleton')).toBeHidden();
const before = await page.getByTestId('{{metricId}}').textContent();
await page.getByRole('button', { name: /refresh/i }).click();
await expect(page.getByTestId('skeleton')).toBeHidden();
// Value may or may not change — just confirm data loads again
await expect(page.getByTestId('{{metricId}}')).toBeVisible();
});
// Error case: shows error state when API fails
test('shows error state when data fetch fails', async ({ page }) => {
await page.route('{{baseUrl}}/api/dashboard*', route =>
route.fulfill({ status: 500, body: JSON.stringify({ error: 'Internal Server Error' }) })
);
await page.goto('{{baseUrl}}/dashboard');
await expect(page.getByRole('alert')).toContainText(/failed to load|error loading/i);
await expect(page.getByRole('button', { name: /retry/i })).toBeVisible();
});
// Error case: retry after failure loads data
test('retries and loads data after error', async ({ page }) => {
let callCount = 0;
await page.route('{{baseUrl}}/api/dashboard*', route => {
callCount++;
if (callCount === 1) return route.fulfill({ status: 500, body: '{}' });
return route.continue();
});
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /retry/i }).click();
await expect(page.getByTestId('skeleton')).toBeHidden();
await expect(page.getByRole('region', { name: /{{widgetName}}/i })).toBeVisible();
});
// Edge case: slow network shows skeleton for duration
test('skeleton persists during slow API response', async ({ page }) => {
await page.route('{{baseUrl}}/api/dashboard*', async route => {
await new Promise(r => setTimeout(r, 2000));
await route.continue();
});
await page.goto('{{baseUrl}}/dashboard');
await expect(page.getByTestId('skeleton')).toBeVisible();
await expect(page.getByTestId('skeleton')).toBeHidden(); // eventually resolves
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Dashboard Data Loading', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('renders metric cards after loading', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await expect(page.getByTestId('skeleton')).toBeHidden();
await expect(page.getByRole('article', { name: /metric/i }).first()).toBeVisible();
});
test('shows error state on API failure', async ({ page }) => {
await page.route('{{baseUrl}}/api/dashboard*', route =>
route.fulfill({ status: 500, body: '{}' })
);
await page.goto('{{baseUrl}}/dashboard');
await expect(page.getByRole('alert')).toContainText(/failed to load|error/i);
await expect(page.getByRole('button', { name: /retry/i })).toBeVisible();
});
test('skeleton visible during slow response', async ({ page }) => {
await page.route('{{baseUrl}}/api/dashboard*', async route => {
await new Promise(r => setTimeout(r, 1500));
await route.continue();
});
await page.goto('{{baseUrl}}/dashboard');
await expect(page.getByTestId('skeleton')).toBeVisible();
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Skeleton → data | Loading state resolves to populated widgets |
| Metric cards | N cards each showing a numeric value |
| Refresh | Data reloaded on button click |
| API error | Error alert + retry button shown |
| Retry success | Second request succeeds after failure |
| Slow network | Skeleton persists during delay |
@@ -0,0 +1,136 @@
# Date Range Filter Template
Tests date picker interaction, preset ranges, and data refresh on selection.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- Dashboard at `{{baseUrl}}/dashboard`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
test.describe('Date Range Filter', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
});
// Happy path: preset range — last 7 days
test('applies "last 7 days" preset', async ({ page }) => {
await page.getByRole('button', { name: /date range/i }).click();
await page.getByRole('option', { name: /last 7 days/i }).click();
await expect(page).toHaveURL(/from=|start_date=/);
await expect(page.getByRole('button', { name: /date range/i })).toContainText(/last 7 days/i);
});
// Happy path: preset range — last 30 days
test('applies "last 30 days" preset', async ({ page }) => {
await page.getByRole('button', { name: /date range/i }).click();
await page.getByRole('option', { name: /last 30 days/i }).click();
await expect(page.getByRole('button', { name: /date range/i })).toContainText(/last 30 days/i);
await expect(page.getByTestId('skeleton')).toBeHidden();
});
// Happy path: custom date range via date picker
test('applies custom date range from picker', async ({ page }) => {
await page.getByRole('button', { name: /date range/i }).click();
await page.getByRole('option', { name: /custom/i }).click();
const picker = page.getByRole('dialog', { name: /date range/i });
await expect(picker).toBeVisible();
// Select start date
await picker.getByRole('button', { name: '{{startDay}}' }).click();
// Select end date
await picker.getByRole('button', { name: '{{endDay}}' }).click();
await picker.getByRole('button', { name: /apply/i }).click();
await expect(picker).toBeHidden();
await expect(page.getByRole('button', { name: /date range/i })).toContainText('{{startDateFormatted}}');
});
// Happy path: data reloads on range change
test('reloads dashboard data on date range change', async ({ page }) => {
let requestCount = 0;
await page.route('{{baseUrl}}/api/dashboard*', route => {
requestCount++;
return route.continue();
});
await page.getByRole('button', { name: /date range/i }).click();
await page.getByRole('option', { name: /last 7 days/i }).click();
expect(requestCount).toBeGreaterThan(0);
await expect(page.getByTestId('skeleton')).toBeHidden();
});
// Error case: invalid custom range (end before start)
test('shows error when end date is before start date', async ({ page }) => {
await page.getByRole('button', { name: /date range/i }).click();
await page.getByRole('option', { name: /custom/i }).click();
const picker = page.getByRole('dialog', { name: /date range/i });
await picker.getByRole('button', { name: '{{endDay}}' }).click(); // pick later date first
await picker.getByRole('button', { name: '{{startDay}}' }).click(); // then earlier
await expect(picker.getByText(/end.*after.*start|invalid.*range/i)).toBeVisible();
await expect(picker.getByRole('button', { name: /apply/i })).toBeDisabled();
});
// Edge case: range persists after page reload
test('date range persists in URL after reload', async ({ page }) => {
await page.getByRole('button', { name: /date range/i }).click();
await page.getByRole('option', { name: /last 7 days/i }).click();
const url = page.url();
await page.reload();
await expect(page).toHaveURL(url);
await expect(page.getByRole('button', { name: /date range/i })).toContainText(/last 7 days/i);
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
test.describe('Date Range Filter', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('applies last-7-days preset', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /date range/i }).click();
await page.getByRole('option', { name: /last 7 days/i }).click();
await expect(page.getByRole('button', { name: /date range/i })).toContainText(/last 7 days/i);
});
test('shows error for invalid range', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /date range/i }).click();
await page.getByRole('option', { name: /custom/i }).click();
const picker = page.getByRole('dialog', { name: /date range/i });
await picker.getByRole('button', { name: '{{endDay}}' }).click();
await picker.getByRole('button', { name: '{{startDay}}' }).click();
await expect(picker.getByRole('button', { name: /apply/i })).toBeDisabled();
});
test('range persists after page reload', async ({ page }) => {
await page.goto('{{baseUrl}}/dashboard');
await page.getByRole('button', { name: /date range/i }).click();
await page.getByRole('option', { name: /last 7 days/i }).click();
const url = page.url();
await page.reload();
await expect(page).toHaveURL(url);
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| Last 7 days | Preset applied, URL updated |
| Last 30 days | Preset applied, data refreshed |
| Custom range | Date picker → start + end → apply |
| Data reload | API called again on range change |
| Invalid range | End before start → apply disabled |
| URL persistence | Range in URL survives reload |
@@ -0,0 +1,146 @@
# Export Template
Tests CSV and PDF export, download triggering, and file verification.
## Prerequisites
- Authenticated session via `{{authStorageStatePath}}`
- Dashboard or report page at `{{baseUrl}}/{{reportPath}}`
---
## TypeScript
```typescript
import { test, expect } from '@playwright/test';
import path from 'path';
import fs from 'fs';
test.describe('Export', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{reportPath}}');
});
// Happy path: CSV download
test('downloads CSV export', async ({ page }) => {
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: /export.*csv|download.*csv/i }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/\.csv$/);
const filePath = path.join('/tmp', download.suggestedFilename());
await download.saveAs(filePath);
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).toContain('{{expectedCsvHeader}}');
expect(content.split('\n').length).toBeGreaterThan(1);
});
// Happy path: PDF download
test('downloads PDF export', async ({ page }) => {
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: /export.*pdf|download.*pdf/i }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/\.pdf$/);
const filePath = path.join('/tmp', download.suggestedFilename());
await download.saveAs(filePath);
const buffer = fs.readFileSync(filePath);
// PDF magic bytes
expect(buffer.slice(0, 4).toString()).toBe('%PDF');
});
// Happy path: export with current filters applied
test('export respects active date range filter', async ({ page }) => {
await page.getByRole('button', { name: /date range/i }).click();
await page.getByRole('option', { name: /last 7 days/i }).click();
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: /export.*csv/i }).click();
const download = await downloadPromise;
const filePath = path.join('/tmp', download.suggestedFilename());
await download.saveAs(filePath);
const content = fs.readFileSync(filePath, 'utf-8');
expect(content.split('\n').length).toBeGreaterThan(1);
});
// Happy path: export loading indicator
test('shows loading state during export generation', async ({ page }) => {
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: /export.*csv/i }).click();
await expect(page.getByRole('button', { name: /export.*csv/i })).toBeDisabled();
await downloadPromise;
await expect(page.getByRole('button', { name: /export.*csv/i })).toBeEnabled();
});
// Error case: export fails with server error
test('shows error when export generation fails', async ({ page }) => {
await page.route('{{baseUrl}}/api/export*', route =>
route.fulfill({ status: 500, body: JSON.stringify({ error: 'Export failed' }) })
);
await page.getByRole('button', { name: /export.*csv/i }).click();
await expect(page.getByRole('alert')).toContainText(/export failed|could not generate/i);
});
// Edge case: export with no data shows warning
test('shows warning when exporting empty dataset', async ({ page }) => {
await page.route('{{baseUrl}}/api/{{reportEndpoint}}*', route =>
route.fulfill({ status: 200, body: JSON.stringify({ data: [] }) })
);
await page.reload();
await page.getByRole('button', { name: /export.*csv/i }).click();
await expect(page.getByRole('alert')).toContainText(/no data to export|empty/i);
});
});
```
---
## JavaScript
```javascript
const { test, expect } = require('@playwright/test');
const path = require('path');
const fs = require('fs');
test.describe('Export', () => {
test.use({ storageState: '{{authStorageStatePath}}' });
test('downloads CSV export with correct header', async ({ page }) => {
await page.goto('{{baseUrl}}/{{reportPath}}');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: /export.*csv/i }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/\.csv$/);
const filePath = path.join('/tmp', download.suggestedFilename());
await download.saveAs(filePath);
expect(fs.readFileSync(filePath, 'utf-8')).toContain('{{expectedCsvHeader}}');
});
test('downloads PDF with correct magic bytes', async ({ page }) => {
await page.goto('{{baseUrl}}/{{reportPath}}');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: /export.*pdf/i }).click();
const download = await downloadPromise;
const filePath = path.join('/tmp', download.suggestedFilename());
await download.saveAs(filePath);
expect(fs.readFileSync(filePath).slice(0, 4).toString()).toBe('%PDF');
});
test('shows error when export fails', async ({ page }) => {
await page.goto('{{baseUrl}}/{{reportPath}}');
await page.route('{{baseUrl}}/api/export*', route =>
route.fulfill({ status: 500, body: '{}' })
);
await page.getByRole('button', { name: /export.*csv/i }).click();
await expect(page.getByRole('alert')).toContainText(/export failed/i);
});
});
```
## Variants
| Variant | Description |
|---------|-------------|
| CSV download | File downloaded, header row verified |
| PDF download | File downloaded, %PDF magic bytes checked |
| Filtered export | Active filters applied to exported data |
| Loading state | Button disabled during generation |
| Server error | Export failure → error alert |
| Empty dataset | No-data warning shown |

Some files were not shown because too many files have changed in this diff Show More