aboutsummaryrefslogtreecommitdiff
path: root/playwright-js
diff options
context:
space:
mode:
Diffstat (limited to 'playwright-js')
-rw-r--r--playwright-js/API_REFERENCE.md653
-rw-r--r--playwright-js/LICENSE21
-rw-r--r--playwright-js/SKILL.md513
-rw-r--r--playwright-js/lib/helpers.js441
-rw-r--r--playwright-js/package.json26
-rwxr-xr-xplaywright-js/run.js228
6 files changed, 1882 insertions, 0 deletions
diff --git a/playwright-js/API_REFERENCE.md b/playwright-js/API_REFERENCE.md
new file mode 100644
index 0000000..9ee2975
--- /dev/null
+++ b/playwright-js/API_REFERENCE.md
@@ -0,0 +1,653 @@
+# Playwright Skill - Complete API Reference
+
+This document contains the comprehensive Playwright API reference and advanced patterns. For quick-start execution patterns, see [SKILL.md](SKILL.md).
+
+## Table of Contents
+
+- [Installation & Setup](#installation--setup)
+- [Core Patterns](#core-patterns)
+- [Selectors & Locators](#selectors--locators)
+- [Common Actions](#common-actions)
+- [Waiting Strategies](#waiting-strategies)
+- [Assertions](#assertions)
+- [Page Object Model](#page-object-model-pom)
+- [Network & API Testing](#network--api-testing)
+- [Authentication & Session Management](#authentication--session-management)
+- [Visual Testing](#visual-testing)
+- [Mobile Testing](#mobile-testing)
+- [Debugging](#debugging)
+- [Performance Testing](#performance-testing)
+- [Parallel Execution](#parallel-execution)
+- [Data-Driven Testing](#data-driven-testing)
+- [Accessibility Testing](#accessibility-testing)
+- [CI/CD Integration](#cicd-integration)
+- [Best Practices](#best-practices)
+- [Common Patterns & Solutions](#common-patterns--solutions)
+- [Troubleshooting](#troubleshooting)
+
+## Installation & Setup
+
+### Prerequisites
+
+Before using this skill, ensure Playwright is available:
+
+```bash
+# Check if Playwright is installed
+npm list playwright 2>/dev/null || echo "Playwright not installed"
+
+# Install (if needed)
+cd ~/.claude/skills/playwright-skill
+npm run setup
+```
+
+### Basic Configuration
+
+Create `playwright.config.ts`:
+
+```typescript
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './tests',
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ workers: process.env.CI ? 1 : undefined,
+ reporter: 'html',
+ use: {
+ baseURL: 'http://localhost:3000',
+ trace: 'on-first-retry',
+ screenshot: 'only-on-failure',
+ video: 'retain-on-failure',
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+ webServer: {
+ command: 'npm run start',
+ url: 'http://localhost:3000',
+ reuseExistingServer: !process.env.CI,
+ },
+});
+```
+
+## Core Patterns
+
+### Basic Browser Automation
+
+```javascript
+const { chromium } = require('playwright');
+
+(async () => {
+ // Launch browser
+ const browser = await chromium.launch({
+ headless: false, // Set to true for headless mode
+ slowMo: 50 // Slow down operations by 50ms
+ });
+
+ const context = await browser.newContext({
+ viewport: { width: 1280, height: 720 },
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
+ });
+
+ const page = await context.newPage();
+
+ // Navigate
+ await page.goto('https://example.com', {
+ waitUntil: 'networkidle' // Wait for network to be idle
+ });
+
+ // Your automation here
+
+ await browser.close();
+})();
+```
+
+### Test Structure
+
+```typescript
+import { test, expect } from '@playwright/test';
+
+test.describe('Feature Name', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/');
+ });
+
+ test('should do something', async ({ page }) => {
+ // Arrange
+ const button = page.locator('button[data-testid="submit"]');
+
+ // Act
+ await button.click();
+
+ // Assert
+ await expect(page).toHaveURL('/success');
+ await expect(page.locator('.message')).toHaveText('Success!');
+ });
+});
+```
+
+## Selectors & Locators
+
+### Best Practices for Selectors
+
+```javascript
+// PREFERRED: Data attributes (most stable)
+await page.locator('[data-testid="submit-button"]').click();
+await page.locator('[data-cy="user-input"]').fill('text');
+
+// GOOD: Role-based selectors (accessible)
+await page.getByRole('button', { name: 'Submit' }).click();
+await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
+await page.getByRole('heading', { level: 1 }).click();
+
+// GOOD: Text content (for unique text)
+await page.getByText('Sign in').click();
+await page.getByText(/welcome back/i).click();
+
+// OK: Semantic HTML
+await page.locator('button[type="submit"]').click();
+await page.locator('input[name="email"]').fill('test@test.com');
+
+// AVOID: Classes and IDs (can change frequently)
+await page.locator('.btn-primary').click(); // Avoid
+await page.locator('#submit').click(); // Avoid
+
+// LAST RESORT: Complex CSS/XPath
+await page.locator('div.container > form > button').click(); // Fragile
+```
+
+### Advanced Locator Patterns
+
+```javascript
+// Filter and chain locators
+const row = page.locator('tr').filter({ hasText: 'John Doe' });
+await row.locator('button').click();
+
+// Nth element
+await page.locator('button').nth(2).click();
+
+// Combining conditions
+await page.locator('button').and(page.locator('[disabled]')).count();
+
+// Parent/child navigation
+const cell = page.locator('td').filter({ hasText: 'Active' });
+const row = cell.locator('..');
+await row.locator('button.edit').click();
+```
+
+## Common Actions
+
+### Form Interactions
+
+```javascript
+// Text input
+await page.getByLabel('Email').fill('user@example.com');
+await page.getByPlaceholder('Enter your name').fill('John Doe');
+
+// Clear and type
+await page.locator('#username').clear();
+await page.locator('#username').type('newuser', { delay: 100 });
+
+// Checkbox
+await page.getByLabel('I agree').check();
+await page.getByLabel('Subscribe').uncheck();
+
+// Radio button
+await page.getByLabel('Option 2').check();
+
+// Select dropdown
+await page.selectOption('select#country', 'usa');
+await page.selectOption('select#country', { label: 'United States' });
+await page.selectOption('select#country', { index: 2 });
+
+// Multi-select
+await page.selectOption('select#colors', ['red', 'blue', 'green']);
+
+// File upload
+await page.setInputFiles('input[type="file"]', 'path/to/file.pdf');
+await page.setInputFiles('input[type="file"]', [
+ 'file1.pdf',
+ 'file2.pdf'
+]);
+```
+
+### Mouse Actions
+
+```javascript
+// Click variations
+await page.click('button'); // Left click
+await page.click('button', { button: 'right' }); // Right click
+await page.dblclick('button'); // Double click
+await page.click('button', { position: { x: 10, y: 10 } }); // Click at position
+
+// Hover
+await page.hover('.menu-item');
+
+// Drag and drop
+await page.dragAndDrop('#source', '#target');
+
+// Manual drag
+await page.locator('#source').hover();
+await page.mouse.down();
+await page.locator('#target').hover();
+await page.mouse.up();
+```
+
+### Keyboard Actions
+
+```javascript
+// Type with delay
+await page.keyboard.type('Hello World', { delay: 100 });
+
+// Key combinations
+await page.keyboard.press('Control+A');
+await page.keyboard.press('Control+C');
+await page.keyboard.press('Control+V');
+
+// Special keys
+await page.keyboard.press('Enter');
+await page.keyboard.press('Tab');
+await page.keyboard.press('Escape');
+await page.keyboard.press('ArrowDown');
+```
+
+## Waiting Strategies
+
+### Smart Waiting
+
+```javascript
+// Wait for element states
+await page.locator('button').waitFor({ state: 'visible' });
+await page.locator('.spinner').waitFor({ state: 'hidden' });
+await page.locator('button').waitFor({ state: 'attached' });
+await page.locator('button').waitFor({ state: 'detached' });
+
+// Wait for specific conditions
+await page.waitForURL('**/success');
+await page.waitForURL(url => url.pathname === '/dashboard');
+
+// Wait for network
+await page.waitForLoadState('networkidle');
+await page.waitForLoadState('domcontentloaded');
+
+// Wait for function
+await page.waitForFunction(() => document.querySelector('.loaded'));
+await page.waitForFunction(
+ text => document.body.innerText.includes(text),
+ 'Content loaded'
+);
+
+// Wait for response
+const responsePromise = page.waitForResponse('**/api/users');
+await page.click('button#load-users');
+const response = await responsePromise;
+
+// Wait for request
+await page.waitForRequest(request =>
+ request.url().includes('/api/') && request.method() === 'POST'
+);
+
+// Custom timeout
+await page.locator('.slow-element').waitFor({
+ state: 'visible',
+ timeout: 10000 // 10 seconds
+});
+```
+
+## Assertions
+
+### Common Assertions
+
+```javascript
+import { expect } from '@playwright/test';
+
+// Page assertions
+await expect(page).toHaveTitle('My App');
+await expect(page).toHaveURL('https://example.com/dashboard');
+await expect(page).toHaveURL(/.*dashboard/);
+
+// Element visibility
+await expect(page.locator('.message')).toBeVisible();
+await expect(page.locator('.spinner')).toBeHidden();
+await expect(page.locator('button')).toBeEnabled();
+await expect(page.locator('input')).toBeDisabled();
+
+// Text content
+await expect(page.locator('h1')).toHaveText('Welcome');
+await expect(page.locator('.message')).toContainText('success');
+await expect(page.locator('.items')).toHaveText(['Item 1', 'Item 2']);
+
+// Input values
+await expect(page.locator('input')).toHaveValue('test@example.com');
+await expect(page.locator('input')).toBeEmpty();
+
+// Attributes
+await expect(page.locator('button')).toHaveAttribute('type', 'submit');
+await expect(page.locator('img')).toHaveAttribute('src', /.*\.png/);
+
+// CSS properties
+await expect(page.locator('.error')).toHaveCSS('color', 'rgb(255, 0, 0)');
+
+// Count
+await expect(page.locator('.item')).toHaveCount(5);
+
+// Checkbox/Radio state
+await expect(page.locator('input[type="checkbox"]')).toBeChecked();
+```
+
+## Page Object Model (POM)
+
+### Basic Page Object
+
+```javascript
+// pages/LoginPage.js
+class LoginPage {
+ constructor(page) {
+ this.page = page;
+ this.usernameInput = page.locator('input[name="username"]');
+ this.passwordInput = page.locator('input[name="password"]');
+ this.submitButton = page.locator('button[type="submit"]');
+ this.errorMessage = page.locator('.error-message');
+ }
+
+ async navigate() {
+ await this.page.goto('/login');
+ }
+
+ async login(username, password) {
+ await this.usernameInput.fill(username);
+ await this.passwordInput.fill(password);
+ await this.submitButton.click();
+ }
+
+ async getErrorMessage() {
+ return await this.errorMessage.textContent();
+ }
+}
+
+// Usage in test
+test('login with valid credentials', async ({ page }) => {
+ const loginPage = new LoginPage(page);
+ await loginPage.navigate();
+ await loginPage.login('user@example.com', 'password123');
+ await expect(page).toHaveURL('/dashboard');
+});
+```
+
+## Network & API Testing
+
+### Intercepting Requests
+
+```javascript
+// Mock API responses
+await page.route('**/api/users', route => {
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify([
+ { id: 1, name: 'John' },
+ { id: 2, name: 'Jane' }
+ ])
+ });
+});
+
+// Modify requests
+await page.route('**/api/**', route => {
+ const headers = {
+ ...route.request().headers(),
+ 'X-Custom-Header': 'value'
+ };
+ route.continue({ headers });
+});
+
+// Block resources
+await page.route('**/*.{png,jpg,jpeg,gif}', route => route.abort());
+```
+
+### Custom Headers via Environment Variables
+
+The skill supports automatic header injection via environment variables:
+
+```bash
+# Single header (simple)
+PW_HEADER_NAME=X-Automated-By PW_HEADER_VALUE=playwright-skill
+
+# Multiple headers (JSON)
+PW_EXTRA_HEADERS='{"X-Automated-By":"playwright-skill","X-Request-ID":"123"}'
+```
+
+These headers are automatically applied to all requests when using:
+- `helpers.createContext(browser)` - headers merged automatically
+- `getContextOptionsWithHeaders(options)` - utility injected by run.js wrapper
+
+**Precedence (highest to lowest):**
+1. Headers passed directly in `options.extraHTTPHeaders`
+2. Environment variable headers
+3. Playwright defaults
+
+**Use case:** Identify automated traffic so your backend can return LLM-optimized responses (e.g., plain text errors instead of styled HTML).
+
+## Visual Testing
+
+### Screenshots
+
+```javascript
+// Full page screenshot
+await page.screenshot({
+ path: 'screenshot.png',
+ fullPage: true
+});
+
+// Element screenshot
+await page.locator('.chart').screenshot({
+ path: 'chart.png'
+});
+
+// Visual comparison
+await expect(page).toHaveScreenshot('homepage.png');
+```
+
+## Mobile Testing
+
+```javascript
+// Device emulation
+const { devices } = require('playwright');
+const iPhone = devices['iPhone 12'];
+
+const context = await browser.newContext({
+ ...iPhone,
+ locale: 'en-US',
+ permissions: ['geolocation'],
+ geolocation: { latitude: 37.7749, longitude: -122.4194 }
+});
+```
+
+## Debugging
+
+### Debug Mode
+
+```bash
+# Run with inspector
+npx playwright test --debug
+
+# Headed mode
+npx playwright test --headed
+
+# Slow motion
+npx playwright test --headed --slowmo=1000
+```
+
+### In-Code Debugging
+
+```javascript
+// Pause execution
+await page.pause();
+
+// Console logs
+page.on('console', msg => console.log('Browser log:', msg.text()));
+page.on('pageerror', error => console.log('Page error:', error));
+```
+
+## Performance Testing
+
+```javascript
+// Measure page load time
+const startTime = Date.now();
+await page.goto('https://example.com');
+const loadTime = Date.now() - startTime;
+console.log(`Page loaded in ${loadTime}ms`);
+```
+
+## Parallel Execution
+
+```javascript
+// Run tests in parallel
+test.describe.parallel('Parallel suite', () => {
+ test('test 1', async ({ page }) => {
+ // Runs in parallel with test 2
+ });
+
+ test('test 2', async ({ page }) => {
+ // Runs in parallel with test 1
+ });
+});
+```
+
+## Data-Driven Testing
+
+```javascript
+// Parameterized tests
+const testData = [
+ { username: 'user1', password: 'pass1', expected: 'Welcome user1' },
+ { username: 'user2', password: 'pass2', expected: 'Welcome user2' },
+];
+
+testData.forEach(({ username, password, expected }) => {
+ test(`login with ${username}`, async ({ page }) => {
+ await page.goto('/login');
+ await page.fill('#username', username);
+ await page.fill('#password', password);
+ await page.click('button[type="submit"]');
+ await expect(page.locator('.message')).toHaveText(expected);
+ });
+});
+```
+
+## Accessibility Testing
+
+```javascript
+import { injectAxe, checkA11y } from 'axe-playwright';
+
+test('accessibility check', async ({ page }) => {
+ await page.goto('/');
+ await injectAxe(page);
+ await checkA11y(page);
+});
+```
+
+## CI/CD Integration
+
+### GitHub Actions
+
+```yaml
+name: Playwright Tests
+on:
+ push:
+ branches: [main, master]
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - uses: actions/setup-node@v3
+ - name: Install dependencies
+ run: npm ci
+ - name: Install Playwright Browsers
+ run: npx playwright install --with-deps
+ - name: Run tests
+ run: npx playwright test
+```
+
+## Best Practices
+
+1. **Test Organization** - Use descriptive test names, group related tests
+2. **Selector Strategy** - Prefer data-testid attributes, use role-based selectors
+3. **Waiting** - Use Playwright's auto-waiting, avoid hard-coded delays
+4. **Error Handling** - Add proper error messages, take screenshots on failure
+5. **Performance** - Run tests in parallel, reuse authentication state
+
+## Common Patterns & Solutions
+
+### Handling Popups
+
+```javascript
+const [popup] = await Promise.all([
+ page.waitForEvent('popup'),
+ page.click('button.open-popup')
+]);
+await popup.waitForLoadState();
+```
+
+### File Downloads
+
+```javascript
+const [download] = await Promise.all([
+ page.waitForEvent('download'),
+ page.click('button.download')
+]);
+await download.saveAs(`./downloads/${download.suggestedFilename()}`);
+```
+
+### iFrames
+
+```javascript
+const frame = page.frameLocator('#my-iframe');
+await frame.locator('button').click();
+```
+
+### Infinite Scroll
+
+```javascript
+async function scrollToBottom(page) {
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
+ await page.waitForTimeout(500);
+}
+```
+
+## Troubleshooting
+
+### Common Issues
+
+1. **Element not found** - Check if element is in iframe, verify visibility
+2. **Timeout errors** - Increase timeout, check network conditions
+3. **Flaky tests** - Use proper waiting strategies, mock external dependencies
+4. **Authentication issues** - Verify auth state is properly saved
+
+## Quick Reference Commands
+
+```bash
+# Run tests
+npx playwright test
+
+# Run in headed mode
+npx playwright test --headed
+
+# Debug tests
+npx playwright test --debug
+
+# Generate code
+npx playwright codegen https://example.com
+
+# Show report
+npx playwright show-report
+```
+
+## Additional Resources
+
+- [Playwright Documentation](https://playwright.dev/docs/intro)
+- [API Reference](https://playwright.dev/docs/api/class-playwright)
+- [Best Practices](https://playwright.dev/docs/best-practices)
diff --git a/playwright-js/LICENSE b/playwright-js/LICENSE
new file mode 100644
index 0000000..5d40ba0
--- /dev/null
+++ b/playwright-js/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 lackeyjb
+
+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.
diff --git a/playwright-js/SKILL.md b/playwright-js/SKILL.md
new file mode 100644
index 0000000..7c5f10c
--- /dev/null
+++ b/playwright-js/SKILL.md
@@ -0,0 +1,513 @@
+---
+name: playwright-js
+description: Browser automation and UI testing with Playwright using the JavaScript bindings. Auto-detects dev servers, writes clean test scripts to /tmp, runs visible Chromium by default for interactive debugging, ships a helper library (safe click/type retries, cookie banner handler, table extraction, dev-server detection, env-driven header injection). Use when testing a web app with a JavaScript or TypeScript stack (React, Next.js, Vue, Svelte, Express, Node frontends generally), automating browser interactions, validating UX, testing login flows, or checking links. Prefer this over playwright-py when the project is JS/TS-native. See also `/playwright-py` for Python-based variant (Django, FastAPI backend smoke tests, pytest integration).
+---
+
+**IMPORTANT - Path Resolution:**
+This skill can be installed in different locations (plugin system, manual installation, global, or project-specific). Before executing any commands, determine the skill directory based on where you loaded this SKILL.md file, and use that path in all commands below. Replace `$SKILL_DIR` with the actual discovered path.
+
+Common installation paths:
+
+- Plugin system: `~/.claude/plugins/marketplaces/playwright-skill/skills/playwright-skill`
+- Manual global: `~/.claude/skills/playwright-skill`
+- Project-specific: `<project>/.claude/skills/playwright-skill`
+
+# Playwright Browser Automation
+
+General-purpose browser automation skill. I'll write custom Playwright code for any automation task you request and execute it via the universal executor.
+
+**CRITICAL WORKFLOW - Follow these steps in order:**
+
+1. **Auto-detect dev servers** - For localhost testing, ALWAYS run server detection FIRST:
+
+ ```bash
+ cd $SKILL_DIR && node -e "require('./lib/helpers').detectDevServers().then(servers => console.log(JSON.stringify(servers)))"
+ ```
+
+ - If **1 server found**: Use it automatically, inform user
+ - If **multiple servers found**: Ask user which one to test
+ - If **no servers found**: Ask for URL or offer to help start dev server
+
+2. **Write scripts to /tmp** - NEVER write test files to skill directory; always use `/tmp/playwright-test-*.js`
+
+3. **Use visible browser by default** - Always use `headless: false` unless user specifically requests headless mode
+
+4. **Parameterize URLs** - Always make URLs configurable via environment variable or constant at top of script
+
+## How It Works
+
+1. You describe what you want to test/automate
+2. I auto-detect running dev servers (or ask for URL if testing external site)
+3. I write custom Playwright code in `/tmp/playwright-test-*.js` (won't clutter your project)
+4. I execute it via: `cd $SKILL_DIR && node run.js /tmp/playwright-test-*.js`
+5. Results displayed in real-time, browser window visible for debugging
+6. Test files auto-cleaned from /tmp by your OS
+
+## Setup (First Time)
+
+```bash
+cd $SKILL_DIR
+npm run setup
+```
+
+This installs Playwright and Chromium browser. Only needed once.
+
+## Execution Pattern
+
+**Step 1: Detect dev servers (for localhost testing)**
+
+```bash
+cd $SKILL_DIR && node -e "require('./lib/helpers').detectDevServers().then(s => console.log(JSON.stringify(s)))"
+```
+
+**Step 2: Write test script to /tmp with URL parameter**
+
+```javascript
+// /tmp/playwright-test-page.js
+const { chromium } = require('playwright');
+
+// Parameterized URL (detected or user-provided)
+const TARGET_URL = 'http://localhost:3001'; // <-- Auto-detected or from user
+
+(async () => {
+ const browser = await chromium.launch({ headless: false });
+ const page = await browser.newPage();
+
+ await page.goto(TARGET_URL);
+ console.log('Page loaded:', await page.title());
+
+ await page.screenshot({ path: '/tmp/screenshot.png', fullPage: true });
+ console.log('šŸ“ø Screenshot saved to /tmp/screenshot.png');
+
+ await browser.close();
+})();
+```
+
+**Step 3: Execute from skill directory**
+
+```bash
+cd $SKILL_DIR && node run.js /tmp/playwright-test-page.js
+```
+
+## Common Patterns
+
+### Test a Page (Multiple Viewports)
+
+```javascript
+// /tmp/playwright-test-responsive.js
+const { chromium } = require('playwright');
+
+const TARGET_URL = 'http://localhost:3001'; // Auto-detected
+
+(async () => {
+ const browser = await chromium.launch({ headless: false, slowMo: 100 });
+ const page = await browser.newPage();
+
+ // Desktop test
+ await page.setViewportSize({ width: 1920, height: 1080 });
+ await page.goto(TARGET_URL);
+ console.log('Desktop - Title:', await page.title());
+ await page.screenshot({ path: '/tmp/desktop.png', fullPage: true });
+
+ // Mobile test
+ await page.setViewportSize({ width: 375, height: 667 });
+ await page.screenshot({ path: '/tmp/mobile.png', fullPage: true });
+
+ await browser.close();
+})();
+```
+
+### Test Login Flow
+
+```javascript
+// /tmp/playwright-test-login.js
+const { chromium } = require('playwright');
+
+const TARGET_URL = 'http://localhost:3001'; // Auto-detected
+
+(async () => {
+ const browser = await chromium.launch({ headless: false });
+ const page = await browser.newPage();
+
+ await page.goto(`${TARGET_URL}/login`);
+
+ await page.fill('input[name="email"]', 'test@example.com');
+ await page.fill('input[name="password"]', 'password123');
+ await page.click('button[type="submit"]');
+
+ // Wait for redirect
+ await page.waitForURL('**/dashboard');
+ console.log('āœ… Login successful, redirected to dashboard');
+
+ await browser.close();
+})();
+```
+
+### Fill and Submit Form
+
+```javascript
+// /tmp/playwright-test-form.js
+const { chromium } = require('playwright');
+
+const TARGET_URL = 'http://localhost:3001'; // Auto-detected
+
+(async () => {
+ const browser = await chromium.launch({ headless: false, slowMo: 50 });
+ const page = await browser.newPage();
+
+ await page.goto(`${TARGET_URL}/contact`);
+
+ await page.fill('input[name="name"]', 'John Doe');
+ await page.fill('input[name="email"]', 'john@example.com');
+ await page.fill('textarea[name="message"]', 'Test message');
+ await page.click('button[type="submit"]');
+
+ // Verify submission
+ await page.waitForSelector('.success-message');
+ console.log('āœ… Form submitted successfully');
+
+ await browser.close();
+})();
+```
+
+### Check for Broken Links
+
+```javascript
+const { chromium } = require('playwright');
+
+(async () => {
+ const browser = await chromium.launch({ headless: false });
+ const page = await browser.newPage();
+
+ await page.goto('http://localhost:3000');
+
+ const links = await page.locator('a[href^="http"]').all();
+ const results = { working: 0, broken: [] };
+
+ for (const link of links) {
+ const href = await link.getAttribute('href');
+ try {
+ const response = await page.request.head(href);
+ if (response.ok()) {
+ results.working++;
+ } else {
+ results.broken.push({ url: href, status: response.status() });
+ }
+ } catch (e) {
+ results.broken.push({ url: href, error: e.message });
+ }
+ }
+
+ console.log(`āœ… Working links: ${results.working}`);
+ console.log(`āŒ Broken links:`, results.broken);
+
+ await browser.close();
+})();
+```
+
+### Take Screenshot with Error Handling
+
+```javascript
+const { chromium } = require('playwright');
+
+(async () => {
+ const browser = await chromium.launch({ headless: false });
+ const page = await browser.newPage();
+
+ try {
+ await page.goto('http://localhost:3000', {
+ waitUntil: 'networkidle',
+ timeout: 10000,
+ });
+
+ await page.screenshot({
+ path: '/tmp/screenshot.png',
+ fullPage: true,
+ });
+
+ console.log('šŸ“ø Screenshot saved to /tmp/screenshot.png');
+ } catch (error) {
+ console.error('āŒ Error:', error.message);
+ } finally {
+ await browser.close();
+ }
+})();
+```
+
+### Test Responsive Design
+
+```javascript
+// /tmp/playwright-test-responsive-full.js
+const { chromium } = require('playwright');
+
+const TARGET_URL = 'http://localhost:3001'; // Auto-detected
+
+(async () => {
+ const browser = await chromium.launch({ headless: false });
+ const page = await browser.newPage();
+
+ const viewports = [
+ { name: 'Desktop', width: 1920, height: 1080 },
+ { name: 'Tablet', width: 768, height: 1024 },
+ { name: 'Mobile', width: 375, height: 667 },
+ ];
+
+ for (const viewport of viewports) {
+ console.log(
+ `Testing ${viewport.name} (${viewport.width}x${viewport.height})`,
+ );
+
+ await page.setViewportSize({
+ width: viewport.width,
+ height: viewport.height,
+ });
+
+ await page.goto(TARGET_URL);
+ await page.waitForTimeout(1000);
+
+ await page.screenshot({
+ path: `/tmp/${viewport.name.toLowerCase()}.png`,
+ fullPage: true,
+ });
+ }
+
+ console.log('āœ… All viewports tested');
+ await browser.close();
+})();
+```
+
+## Inline Execution (Simple Tasks)
+
+For quick one-off tasks, you can execute code inline without creating files:
+
+```bash
+# Take a quick screenshot
+cd $SKILL_DIR && node run.js "
+const browser = await chromium.launch({ headless: false });
+const page = await browser.newPage();
+await page.goto('http://localhost:3001');
+await page.screenshot({ path: '/tmp/quick-screenshot.png', fullPage: true });
+console.log('Screenshot saved');
+await browser.close();
+"
+```
+
+**When to use inline vs files:**
+
+- **Inline**: Quick one-off tasks (screenshot, check if element exists, get page title)
+- **Files**: Complex tests, responsive design checks, anything user might want to re-run
+
+## Available Helpers
+
+Optional utility functions in `lib/helpers.js`:
+
+```javascript
+const helpers = require('./lib/helpers');
+
+// Detect running dev servers (CRITICAL - use this first!)
+const servers = await helpers.detectDevServers();
+console.log('Found servers:', servers);
+
+// Safe click with retry
+await helpers.safeClick(page, 'button.submit', { retries: 3 });
+
+// Safe type with clear
+await helpers.safeType(page, '#username', 'testuser');
+
+// Take timestamped screenshot
+await helpers.takeScreenshot(page, 'test-result');
+
+// Handle cookie banners
+await helpers.handleCookieBanner(page);
+
+// Extract table data
+const data = await helpers.extractTableData(page, 'table.results');
+```
+
+See `lib/helpers.js` for full list.
+
+## Custom HTTP Headers
+
+Configure custom headers for all HTTP requests via environment variables. Useful for:
+
+- Identifying automated traffic to your backend
+- Getting LLM-optimized responses (e.g., plain text errors instead of styled HTML)
+- Adding authentication tokens globally
+
+### Configuration
+
+**Single header (common case):**
+
+```bash
+PW_HEADER_NAME=X-Automated-By PW_HEADER_VALUE=playwright-skill \
+ cd $SKILL_DIR && node run.js /tmp/my-script.js
+```
+
+**Multiple headers (JSON format):**
+
+```bash
+PW_EXTRA_HEADERS='{"X-Automated-By":"playwright-skill","X-Debug":"true"}' \
+ cd $SKILL_DIR && node run.js /tmp/my-script.js
+```
+
+### How It Works
+
+Headers are automatically applied when using `helpers.createContext()`:
+
+```javascript
+const context = await helpers.createContext(browser);
+const page = await context.newPage();
+// All requests from this page include your custom headers
+```
+
+For scripts using raw Playwright API, use the injected `getContextOptionsWithHeaders()`:
+
+```javascript
+const context = await browser.newContext(
+ getContextOptionsWithHeaders({ viewport: { width: 1920, height: 1080 } }),
+);
+```
+
+## Advanced Usage
+
+For comprehensive Playwright API documentation, see [API_REFERENCE.md](API_REFERENCE.md):
+
+- Selectors & Locators best practices
+- Network interception & API mocking
+- Authentication & session management
+- Visual regression testing
+- Mobile device emulation
+- Performance testing
+- Debugging techniques
+- CI/CD integration
+
+## Tips
+
+- **CRITICAL: Detect servers FIRST** - Always run `detectDevServers()` before writing test code for localhost testing
+- **Custom headers** - Use `PW_HEADER_NAME`/`PW_HEADER_VALUE` env vars to identify automated traffic to your backend
+- **Use /tmp for test files** - Write to `/tmp/playwright-test-*.js`, never to skill directory or user's project
+- **Parameterize URLs** - Put detected/provided URL in a `TARGET_URL` constant at the top of every script
+- **DEFAULT: Visible browser** - Always use `headless: false` unless user explicitly asks for headless mode
+- **Headless mode** - Only use `headless: true` when user specifically requests "headless" or "background" execution
+- **Slow down:** Use `slowMo: 100` to make actions visible and easier to follow
+- **Wait strategies:** Use `waitForURL`, `waitForSelector`, `waitForLoadState` instead of fixed timeouts
+- **Error handling:** Always use try-catch for robust automation
+- **Console output:** Use `console.log()` to track progress and show what's happening
+
+## Troubleshooting
+
+**Playwright not installed:**
+
+```bash
+cd $SKILL_DIR && npm run setup
+```
+
+**Module not found:**
+Ensure running from skill directory via `run.js` wrapper
+
+**Browser doesn't open:**
+Check `headless: false` and ensure display available
+
+**Element not found:**
+Add wait: `await page.waitForSelector('.element', { timeout: 10000 })`
+
+## Example Usage
+
+```
+User: "Test if the marketing page looks good"
+
+Claude: I'll test the marketing page across multiple viewports. Let me first detect running servers...
+[Runs: detectDevServers()]
+[Output: Found server on port 3001]
+I found your dev server running on http://localhost:3001
+
+[Writes custom automation script to /tmp/playwright-test-marketing.js with URL parameterized]
+[Runs: cd $SKILL_DIR && node run.js /tmp/playwright-test-marketing.js]
+[Shows results with screenshots from /tmp/]
+```
+
+```
+User: "Check if login redirects correctly"
+
+Claude: I'll test the login flow. First, let me check for running servers...
+[Runs: detectDevServers()]
+[Output: Found servers on ports 3000 and 3001]
+I found 2 dev servers. Which one should I test?
+- http://localhost:3000
+- http://localhost:3001
+
+User: "Use 3001"
+
+[Writes login automation to /tmp/playwright-test-login.js]
+[Runs: cd $SKILL_DIR && node run.js /tmp/playwright-test-login.js]
+[Reports: āœ… Login successful, redirected to /dashboard]
+```
+
+## Notes
+
+- Each automation is custom-written for your specific request
+- Not limited to pre-built scripts - any browser task possible
+- Auto-detects running dev servers to eliminate hardcoded URLs
+- Test scripts written to `/tmp` for automatic cleanup (no clutter)
+- Code executes reliably with proper module resolution via `run.js`
+- Progressive disclosure - API_REFERENCE.md loaded only when advanced features needed
+
+---
+
+## Added: Static HTML vs Dynamic Webapp Decision
+
+Before writing any test, decide which path the target needs. Missing this step causes the most common failure: inspecting a dynamic page before JS has populated it.
+
+```
+User task → Is it static HTML (file:// or plain server-rendered)?
+ ā”œā”€ Yes → Read the HTML source directly, identify selectors from the raw markup,
+ │ write a Playwright script using those selectors.
+ │
+ └─ No (dynamic webapp) →
+ 1. Navigate to the page
+ 2. Wait for networkidle: await page.waitForLoadState('networkidle');
+ 3. Inspect rendered DOM (screenshot, page.content(), or locator().all())
+ 4. Identify selectors from the rendered state, not the source
+ 5. Execute actions with those selectors
+```
+
+**Common pitfall:** inspecting the DOM before `networkidle` on a dynamic app returns stale content or an empty skeleton. Every "element not found" bug on dynamic pages should trigger a "did I wait for networkidle?" check first.
+
+## Added: Reconnaissance-Then-Action Pattern
+
+For any non-trivial interaction on a dynamic page:
+
+1. **Reconnoiter.** Navigate, wait for load, capture state:
+ ```javascript
+ await page.goto(TARGET_URL);
+ await page.waitForLoadState('networkidle');
+ await page.screenshot({ path: '/tmp/inspect.png', fullPage: true });
+ const html = await page.content();
+ const buttons = await page.locator('button').all();
+ ```
+
+2. **Decide.** From the screenshot + content + locator list, pick the selectors you'll use. Don't guess from source.
+
+3. **Act.** Execute the interaction with the discovered selectors.
+
+This beats "write what you think the page looks like, run it, fix the selectors when it breaks." Especially valuable for first-time automation on an unfamiliar app.
+
+## Added: Console Log Capture
+
+Frontend errors often don't surface in the Playwright output unless captured explicitly. For flaky tests or "works in my browser, fails in Playwright" symptoms:
+
+```javascript
+page.on('console', msg => console.log(`[browser.${msg.type()}] ${msg.text()}`));
+page.on('pageerror', err => console.log(`[browser.pageerror] ${err.message}`));
+page.on('requestfailed', req => console.log(`[browser.requestfailed] ${req.url()} ${req.failure()?.errorText}`));
+```
+
+Attach these before `page.goto()`. Messages stream to stdout during the test run, giving you the same signal the browser devtools console would.
+
+---
+
+## Attribution
+
+Forked from [lackeyjb/playwright-skill](https://github.com/lackeyjb/playwright-skill) — MIT licensed. See `LICENSE` in this directory for the original copyright and terms.
+
+**Local additions** (not upstream): the three *Added:* sections above (Static HTML vs Dynamic Webapp Decision, Reconnaissance-Then-Action Pattern, Console Log Capture) were added in this fork, informed by patterns from Anthropic's `webapp-testing` skill (the sibling `playwright-py` in this rulesets repo). The upstream skill is self-contained; these additions pair well with it but are not required.
diff --git a/playwright-js/lib/helpers.js b/playwright-js/lib/helpers.js
new file mode 100644
index 0000000..0920d68
--- /dev/null
+++ b/playwright-js/lib/helpers.js
@@ -0,0 +1,441 @@
+// playwright-helpers.js
+// Reusable utility functions for Playwright automation
+
+const { chromium, firefox, webkit } = require('playwright');
+
+/**
+ * Parse extra HTTP headers from environment variables.
+ * Supports two formats:
+ * - PW_HEADER_NAME + PW_HEADER_VALUE: Single header (simple, common case)
+ * - PW_EXTRA_HEADERS: JSON object for multiple headers (advanced)
+ * Single header format takes precedence if both are set.
+ * @returns {Object|null} Headers object or null if none configured
+ */
+function getExtraHeadersFromEnv() {
+ const headerName = process.env.PW_HEADER_NAME;
+ const headerValue = process.env.PW_HEADER_VALUE;
+
+ if (headerName && headerValue) {
+ return { [headerName]: headerValue };
+ }
+
+ const headersJson = process.env.PW_EXTRA_HEADERS;
+ if (headersJson) {
+ try {
+ const parsed = JSON.parse(headersJson);
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
+ return parsed;
+ }
+ console.warn('PW_EXTRA_HEADERS must be a JSON object, ignoring...');
+ } catch (e) {
+ console.warn('Failed to parse PW_EXTRA_HEADERS as JSON:', e.message);
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Launch browser with standard configuration
+ * @param {string} browserType - 'chromium', 'firefox', or 'webkit'
+ * @param {Object} options - Additional launch options
+ */
+async function launchBrowser(browserType = 'chromium', options = {}) {
+ const defaultOptions = {
+ headless: process.env.HEADLESS !== 'false',
+ slowMo: process.env.SLOW_MO ? parseInt(process.env.SLOW_MO) : 0,
+ args: ['--no-sandbox', '--disable-setuid-sandbox']
+ };
+
+ const browsers = { chromium, firefox, webkit };
+ const browser = browsers[browserType];
+
+ if (!browser) {
+ throw new Error(`Invalid browser type: ${browserType}`);
+ }
+
+ return await browser.launch({ ...defaultOptions, ...options });
+}
+
+/**
+ * Create a new page with viewport and user agent
+ * @param {Object} context - Browser context
+ * @param {Object} options - Page options
+ */
+async function createPage(context, options = {}) {
+ const page = await context.newPage();
+
+ if (options.viewport) {
+ await page.setViewportSize(options.viewport);
+ }
+
+ if (options.userAgent) {
+ await page.setExtraHTTPHeaders({
+ 'User-Agent': options.userAgent
+ });
+ }
+
+ // Set default timeout
+ page.setDefaultTimeout(options.timeout || 30000);
+
+ return page;
+}
+
+/**
+ * Smart wait for page to be ready
+ * @param {Object} page - Playwright page
+ * @param {Object} options - Wait options
+ */
+async function waitForPageReady(page, options = {}) {
+ const waitOptions = {
+ waitUntil: options.waitUntil || 'networkidle',
+ timeout: options.timeout || 30000
+ };
+
+ try {
+ await page.waitForLoadState(waitOptions.waitUntil, {
+ timeout: waitOptions.timeout
+ });
+ } catch (e) {
+ console.warn('Page load timeout, continuing...');
+ }
+
+ // Additional wait for dynamic content if selector provided
+ if (options.waitForSelector) {
+ await page.waitForSelector(options.waitForSelector, {
+ timeout: options.timeout
+ });
+ }
+}
+
+/**
+ * Safe click with retry logic
+ * @param {Object} page - Playwright page
+ * @param {string} selector - Element selector
+ * @param {Object} options - Click options
+ */
+async function safeClick(page, selector, options = {}) {
+ const maxRetries = options.retries || 3;
+ const retryDelay = options.retryDelay || 1000;
+
+ for (let i = 0; i < maxRetries; i++) {
+ try {
+ await page.waitForSelector(selector, {
+ state: 'visible',
+ timeout: options.timeout || 5000
+ });
+ await page.click(selector, {
+ force: options.force || false,
+ timeout: options.timeout || 5000
+ });
+ return true;
+ } catch (e) {
+ if (i === maxRetries - 1) {
+ console.error(`Failed to click ${selector} after ${maxRetries} attempts`);
+ throw e;
+ }
+ console.log(`Retry ${i + 1}/${maxRetries} for clicking ${selector}`);
+ await page.waitForTimeout(retryDelay);
+ }
+ }
+}
+
+/**
+ * Safe text input with clear before type
+ * @param {Object} page - Playwright page
+ * @param {string} selector - Input selector
+ * @param {string} text - Text to type
+ * @param {Object} options - Type options
+ */
+async function safeType(page, selector, text, options = {}) {
+ await page.waitForSelector(selector, {
+ state: 'visible',
+ timeout: options.timeout || 10000
+ });
+
+ if (options.clear !== false) {
+ await page.fill(selector, '');
+ }
+
+ if (options.slow) {
+ await page.type(selector, text, { delay: options.delay || 100 });
+ } else {
+ await page.fill(selector, text);
+ }
+}
+
+/**
+ * Extract text from multiple elements
+ * @param {Object} page - Playwright page
+ * @param {string} selector - Elements selector
+ */
+async function extractTexts(page, selector) {
+ await page.waitForSelector(selector, { timeout: 10000 });
+ return await page.$$eval(selector, elements =>
+ elements.map(el => el.textContent?.trim()).filter(Boolean)
+ );
+}
+
+/**
+ * Take screenshot with timestamp
+ * @param {Object} page - Playwright page
+ * @param {string} name - Screenshot name
+ * @param {Object} options - Screenshot options
+ */
+async function takeScreenshot(page, name, options = {}) {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ const filename = `${name}-${timestamp}.png`;
+
+ await page.screenshot({
+ path: filename,
+ fullPage: options.fullPage !== false,
+ ...options
+ });
+
+ console.log(`Screenshot saved: ${filename}`);
+ return filename;
+}
+
+/**
+ * Handle authentication
+ * @param {Object} page - Playwright page
+ * @param {Object} credentials - Username and password
+ * @param {Object} selectors - Login form selectors
+ */
+async function authenticate(page, credentials, selectors = {}) {
+ const defaultSelectors = {
+ username: 'input[name="username"], input[name="email"], #username, #email',
+ password: 'input[name="password"], #password',
+ submit: 'button[type="submit"], input[type="submit"], button:has-text("Login"), button:has-text("Sign in")'
+ };
+
+ const finalSelectors = { ...defaultSelectors, ...selectors };
+
+ await safeType(page, finalSelectors.username, credentials.username);
+ await safeType(page, finalSelectors.password, credentials.password);
+ await safeClick(page, finalSelectors.submit);
+
+ // Wait for navigation or success indicator
+ await Promise.race([
+ page.waitForNavigation({ waitUntil: 'networkidle' }),
+ page.waitForSelector(selectors.successIndicator || '.dashboard, .user-menu, .logout', { timeout: 10000 })
+ ]).catch(() => {
+ console.log('Login might have completed without navigation');
+ });
+}
+
+/**
+ * Scroll page
+ * @param {Object} page - Playwright page
+ * @param {string} direction - 'down', 'up', 'top', 'bottom'
+ * @param {number} distance - Pixels to scroll (for up/down)
+ */
+async function scrollPage(page, direction = 'down', distance = 500) {
+ switch (direction) {
+ case 'down':
+ await page.evaluate(d => window.scrollBy(0, d), distance);
+ break;
+ case 'up':
+ await page.evaluate(d => window.scrollBy(0, -d), distance);
+ break;
+ case 'top':
+ await page.evaluate(() => window.scrollTo(0, 0));
+ break;
+ case 'bottom':
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
+ break;
+ }
+ await page.waitForTimeout(500); // Wait for scroll animation
+}
+
+/**
+ * Extract table data
+ * @param {Object} page - Playwright page
+ * @param {string} tableSelector - Table selector
+ */
+async function extractTableData(page, tableSelector) {
+ await page.waitForSelector(tableSelector);
+
+ return await page.evaluate((selector) => {
+ const table = document.querySelector(selector);
+ if (!table) return null;
+
+ const headers = Array.from(table.querySelectorAll('thead th')).map(th =>
+ th.textContent?.trim()
+ );
+
+ const rows = Array.from(table.querySelectorAll('tbody tr')).map(tr => {
+ const cells = Array.from(tr.querySelectorAll('td'));
+ if (headers.length > 0) {
+ return cells.reduce((obj, cell, index) => {
+ obj[headers[index] || `column_${index}`] = cell.textContent?.trim();
+ return obj;
+ }, {});
+ } else {
+ return cells.map(cell => cell.textContent?.trim());
+ }
+ });
+
+ return { headers, rows };
+ }, tableSelector);
+}
+
+/**
+ * Wait for and dismiss cookie banners
+ * @param {Object} page - Playwright page
+ * @param {number} timeout - Max time to wait
+ */
+async function handleCookieBanner(page, timeout = 3000) {
+ const commonSelectors = [
+ 'button:has-text("Accept")',
+ 'button:has-text("Accept all")',
+ 'button:has-text("OK")',
+ 'button:has-text("Got it")',
+ 'button:has-text("I agree")',
+ '.cookie-accept',
+ '#cookie-accept',
+ '[data-testid="cookie-accept"]'
+ ];
+
+ for (const selector of commonSelectors) {
+ try {
+ const element = await page.waitForSelector(selector, {
+ timeout: timeout / commonSelectors.length,
+ state: 'visible'
+ });
+ if (element) {
+ await element.click();
+ console.log('Cookie banner dismissed');
+ return true;
+ }
+ } catch (e) {
+ // Continue to next selector
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Retry a function with exponential backoff
+ * @param {Function} fn - Function to retry
+ * @param {number} maxRetries - Maximum retry attempts
+ * @param {number} initialDelay - Initial delay in ms
+ */
+async function retryWithBackoff(fn, maxRetries = 3, initialDelay = 1000) {
+ let lastError;
+
+ for (let i = 0; i < maxRetries; i++) {
+ try {
+ return await fn();
+ } catch (error) {
+ lastError = error;
+ const delay = initialDelay * Math.pow(2, i);
+ console.log(`Attempt ${i + 1} failed, retrying in ${delay}ms...`);
+ await new Promise(resolve => setTimeout(resolve, delay));
+ }
+ }
+
+ throw lastError;
+}
+
+/**
+ * Create browser context with common settings
+ * @param {Object} browser - Browser instance
+ * @param {Object} options - Context options
+ */
+async function createContext(browser, options = {}) {
+ const envHeaders = getExtraHeadersFromEnv();
+
+ // Merge environment headers with any passed in options
+ const mergedHeaders = {
+ ...envHeaders,
+ ...options.extraHTTPHeaders
+ };
+
+ const defaultOptions = {
+ viewport: { width: 1280, height: 720 },
+ userAgent: options.mobile
+ ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Mobile/15E148 Safari/604.1'
+ : undefined,
+ permissions: options.permissions || [],
+ geolocation: options.geolocation,
+ locale: options.locale || 'en-US',
+ timezoneId: options.timezoneId || 'America/New_York',
+ // Only include extraHTTPHeaders if we have any
+ ...(Object.keys(mergedHeaders).length > 0 && { extraHTTPHeaders: mergedHeaders })
+ };
+
+ return await browser.newContext({ ...defaultOptions, ...options });
+}
+
+/**
+ * Detect running dev servers on common ports
+ * @param {Array<number>} customPorts - Additional ports to check
+ * @returns {Promise<Array>} Array of detected server URLs
+ */
+async function detectDevServers(customPorts = []) {
+ const http = require('http');
+
+ // Common dev server ports
+ const commonPorts = [3000, 3001, 3002, 5173, 8080, 8000, 4200, 5000, 9000, 1234];
+ const allPorts = [...new Set([...commonPorts, ...customPorts])];
+
+ const detectedServers = [];
+
+ console.log('šŸ” Checking for running dev servers...');
+
+ for (const port of allPorts) {
+ try {
+ await new Promise((resolve, reject) => {
+ const req = http.request({
+ hostname: 'localhost',
+ port: port,
+ path: '/',
+ method: 'HEAD',
+ timeout: 500
+ }, (res) => {
+ if (res.statusCode < 500) {
+ detectedServers.push(`http://localhost:${port}`);
+ console.log(` āœ… Found server on port ${port}`);
+ }
+ resolve();
+ });
+
+ req.on('error', () => resolve());
+ req.on('timeout', () => {
+ req.destroy();
+ resolve();
+ });
+
+ req.end();
+ });
+ } catch (e) {
+ // Port not available, continue
+ }
+ }
+
+ if (detectedServers.length === 0) {
+ console.log(' āŒ No dev servers detected');
+ }
+
+ return detectedServers;
+}
+
+module.exports = {
+ launchBrowser,
+ createPage,
+ waitForPageReady,
+ safeClick,
+ safeType,
+ extractTexts,
+ takeScreenshot,
+ authenticate,
+ scrollPage,
+ extractTableData,
+ handleCookieBanner,
+ retryWithBackoff,
+ createContext,
+ detectDevServers,
+ getExtraHeadersFromEnv
+};
diff --git a/playwright-js/package.json b/playwright-js/package.json
new file mode 100644
index 0000000..ada6c8b
--- /dev/null
+++ b/playwright-js/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "playwright-skill",
+ "version": "4.1.0",
+ "description": "General-purpose browser automation with Playwright for Claude Code with auto-detection and smart test management",
+ "author": "lackeyjb",
+ "main": "run.js",
+ "scripts": {
+ "setup": "npm install && npx playwright install chromium",
+ "install-all-browsers": "npx playwright install chromium firefox webkit"
+ },
+ "keywords": [
+ "playwright",
+ "automation",
+ "browser-testing",
+ "web-automation",
+ "claude-skill",
+ "general-purpose"
+ ],
+ "dependencies": {
+ "playwright": "^1.57.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "license": "MIT"
+}
diff --git a/playwright-js/run.js b/playwright-js/run.js
new file mode 100755
index 0000000..10f2616
--- /dev/null
+++ b/playwright-js/run.js
@@ -0,0 +1,228 @@
+#!/usr/bin/env node
+/**
+ * Universal Playwright Executor for Claude Code
+ *
+ * Executes Playwright automation code from:
+ * - File path: node run.js script.js
+ * - Inline code: node run.js 'await page.goto("...")'
+ * - Stdin: cat script.js | node run.js
+ *
+ * Ensures proper module resolution by running from skill directory.
+ */
+
+const fs = require('fs');
+const path = require('path');
+const { execSync } = require('child_process');
+
+// Change to skill directory for proper module resolution
+process.chdir(__dirname);
+
+/**
+ * Check if Playwright is installed
+ */
+function checkPlaywrightInstalled() {
+ try {
+ require.resolve('playwright');
+ return true;
+ } catch (e) {
+ return false;
+ }
+}
+
+/**
+ * Install Playwright if missing
+ */
+function installPlaywright() {
+ console.log('šŸ“¦ Playwright not found. Installing...');
+ try {
+ execSync('npm install', { stdio: 'inherit', cwd: __dirname });
+ execSync('npx playwright install chromium', { stdio: 'inherit', cwd: __dirname });
+ console.log('āœ… Playwright installed successfully');
+ return true;
+ } catch (e) {
+ console.error('āŒ Failed to install Playwright:', e.message);
+ console.error('Please run manually: cd', __dirname, '&& npm run setup');
+ return false;
+ }
+}
+
+/**
+ * Get code to execute from various sources
+ */
+function getCodeToExecute() {
+ const args = process.argv.slice(2);
+
+ // Case 1: File path provided
+ if (args.length > 0 && fs.existsSync(args[0])) {
+ const filePath = path.resolve(args[0]);
+ console.log(`šŸ“„ Executing file: ${filePath}`);
+ return fs.readFileSync(filePath, 'utf8');
+ }
+
+ // Case 2: Inline code provided as argument
+ if (args.length > 0) {
+ console.log('⚔ Executing inline code');
+ return args.join(' ');
+ }
+
+ // Case 3: Code from stdin
+ if (!process.stdin.isTTY) {
+ console.log('šŸ“„ Reading from stdin');
+ return fs.readFileSync(0, 'utf8');
+ }
+
+ // No input
+ console.error('āŒ No code to execute');
+ console.error('Usage:');
+ console.error(' node run.js script.js # Execute file');
+ console.error(' node run.js "code here" # Execute inline');
+ console.error(' cat script.js | node run.js # Execute from stdin');
+ process.exit(1);
+}
+
+/**
+ * Clean up old temporary execution files from previous runs
+ */
+function cleanupOldTempFiles() {
+ try {
+ const files = fs.readdirSync(__dirname);
+ const tempFiles = files.filter(f => f.startsWith('.temp-execution-') && f.endsWith('.js'));
+
+ if (tempFiles.length > 0) {
+ tempFiles.forEach(file => {
+ const filePath = path.join(__dirname, file);
+ try {
+ fs.unlinkSync(filePath);
+ } catch (e) {
+ // Ignore errors - file might be in use or already deleted
+ }
+ });
+ }
+ } catch (e) {
+ // Ignore directory read errors
+ }
+}
+
+/**
+ * Wrap code in async IIFE if not already wrapped
+ */
+function wrapCodeIfNeeded(code) {
+ // Check if code already has require() and async structure
+ const hasRequire = code.includes('require(');
+ const hasAsyncIIFE = code.includes('(async () => {') || code.includes('(async()=>{');
+
+ // If it's already a complete script, return as-is
+ if (hasRequire && hasAsyncIIFE) {
+ return code;
+ }
+
+ // If it's just Playwright commands, wrap in full template
+ if (!hasRequire) {
+ return `
+const { chromium, firefox, webkit, devices } = require('playwright');
+const helpers = require('./lib/helpers');
+
+// Extra headers from environment variables (if configured)
+const __extraHeaders = helpers.getExtraHeadersFromEnv();
+
+/**
+ * Utility to merge environment headers into context options.
+ * Use when creating contexts with raw Playwright API instead of helpers.createContext().
+ * @param {Object} options - Context options
+ * @returns {Object} Options with extraHTTPHeaders merged in
+ */
+function getContextOptionsWithHeaders(options = {}) {
+ if (!__extraHeaders) return options;
+ return {
+ ...options,
+ extraHTTPHeaders: {
+ ...__extraHeaders,
+ ...(options.extraHTTPHeaders || {})
+ }
+ };
+}
+
+(async () => {
+ try {
+ ${code}
+ } catch (error) {
+ console.error('āŒ Automation error:', error.message);
+ if (error.stack) {
+ console.error(error.stack);
+ }
+ process.exit(1);
+ }
+})();
+`;
+ }
+
+ // If has require but no async wrapper
+ if (!hasAsyncIIFE) {
+ return `
+(async () => {
+ try {
+ ${code}
+ } catch (error) {
+ console.error('āŒ Automation error:', error.message);
+ if (error.stack) {
+ console.error(error.stack);
+ }
+ process.exit(1);
+ }
+})();
+`;
+ }
+
+ return code;
+}
+
+/**
+ * Main execution
+ */
+async function main() {
+ console.log('šŸŽ­ Playwright Skill - Universal Executor\n');
+
+ // Clean up old temp files from previous runs
+ cleanupOldTempFiles();
+
+ // Check Playwright installation
+ if (!checkPlaywrightInstalled()) {
+ const installed = installPlaywright();
+ if (!installed) {
+ process.exit(1);
+ }
+ }
+
+ // Get code to execute
+ const rawCode = getCodeToExecute();
+ const code = wrapCodeIfNeeded(rawCode);
+
+ // Create temporary file for execution
+ const tempFile = path.join(__dirname, `.temp-execution-${Date.now()}.js`);
+
+ try {
+ // Write code to temp file
+ fs.writeFileSync(tempFile, code, 'utf8');
+
+ // Execute the code
+ console.log('šŸš€ Starting automation...\n');
+ require(tempFile);
+
+ // Note: Temp file will be cleaned up on next run
+ // This allows long-running async operations to complete safely
+
+ } catch (error) {
+ console.error('āŒ Execution failed:', error.message);
+ if (error.stack) {
+ console.error('\nšŸ“‹ Stack trace:');
+ console.error(error.stack);
+ }
+ process.exit(1);
+ }
+}
+
+// Run main function
+main().catch(error => {
+ console.error('āŒ Fatal error:', error.message);
+ process.exit(1);
+});