.cursor/skills/playwright-skill/SKILL.md
Complete browser automation with Playwright. Supports both ad-hoc automation and proper E2E testing with @playwright/test framework. Auto-detects dev servers, writes clean test scripts. Test pages, fill forms, take screenshots, check responsive design, validate UX, test login flows, check links, automate any browser task. Use when user wants to test websites, automate browser interactions, validate web functionality, or perform any browser-based testing.
npx skillsauth add FixMyBerlin/tilda-geo playwright-skillInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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:
~/.claude/plugins/marketplaces/playwright-skill/skills/playwright-skill~/.claude/skills/playwright-skill<project>/.claude/skills/playwright-skillGeneral-purpose browser automation skill supporting two use cases:
@playwright/test framework (recommended for Next.js projects)IMPORTANT: For Next.js E2E Testing
@playwright/test framework (not raw Playwright API)bun run build && bun run start), not dev servergetByRole, getByLabel) instead of CSS selectorsplaywright.config.ts with webServer configurationtests/ directory in TypeScriptCRITICAL WORKFLOW - Follow these steps in order:
Determine use case - Is this ad-hoc automation or proper E2E testing?
@playwright/test frameworkFor ad-hoc automation: Auto-detect dev servers - For localhost testing, ALWAYS run server detection FIRST:
cd $SKILL_DIR && node -e "require('./lib/helpers').detectDevServers().then(servers => console.log(JSON.stringify(servers)))"
For ad-hoc: Write scripts to /tmp - NEVER write test files to skill directory; always use /tmp/playwright-test-*.js
For E2E testing: Write tests to tests/ directory - Use TypeScript, proper test structure
Use visible browser by default - Always use headless: false unless user specifically requests headless mode
Parameterize URLs - Always make URLs configurable via environment variable or constant at top of script
/tmp/playwright-test-*.js (won't clutter your project)cd $SKILL_DIR && node run.js /tmp/playwright-test-*.jstests/ directory using @playwright/test frameworkplaywright.config.ts with Next.js webServer configurationbunx playwright test with proper isolation and retry mechanismscd $SKILL_DIR
bun run setup
This installs Playwright and Chromium browser. Only needed once.
# In your Next.js project root
bun add -d @playwright/test
bunx playwright install chromium
Then create playwright.config.ts (see Next.js Configuration section below).
Step 1: Detect dev servers (for localhost testing)
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
// /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
cd $SKILL_DIR && node run.js /tmp/playwright-test-page.js
bun add -d @playwright/test
bunx playwright install chromium
playwright.config.tsimport { defineConfig, devices } from '@playwright/test';
export default defineConfig({
// Test files location
testDir: './tests',
// Run tests in parallel
fullyParallel: true,
// Fail the build on CI if you accidentally left test.only in the source code
forbidOnly: !!process.env.CI,
// Retry on CI only
retries: process.env.CI ? 2 : 0,
// Opt out of parallel tests on CI
workers: process.env.CI ? 1 : undefined,
// Reporter to use
reporter: 'html',
// Shared settings for all projects
use: {
// Base URL to use in actions like `await page.goto('/')`
baseURL: 'http://localhost:3000',
// Collect trace when retrying the failed test
trace: 'on-first-retry',
},
// Configure projects for major browsers
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
// Run your local dev server before starting the tests
webServer: {
command: 'bun run build && bun run start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
});
CRITICAL: The webServer configuration automatically builds and starts your Next.js production server. This ensures tests run against production code, not dev server.
tests/
example.spec.ts
auth.spec.ts
navigation.spec.ts
// tests/example.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Home Page', () => {
test('should display homepage content', async ({ page }) => {
// Navigate to home page (baseURL configured in playwright.config.ts)
await page.goto('/');
// Use semantic locators (not CSS selectors!)
const heading = page.getByRole('heading', { name: /home/i });
await expect(heading).toBeVisible();
// Test navigation link
const aboutLink = page.getByRole('link', { name: /about/i });
await expect(aboutLink).toBeVisible();
// Click and verify navigation
await aboutLink.click();
await expect(page).toHaveURL('/about');
});
test('should handle form submission', async ({ page }) => {
await page.goto('/contact');
// Use getByLabel for form inputs (semantic!)
await page.getByLabel('Name').fill('John Doe');
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Message').fill('Test message');
// Use getByRole for buttons
await page.getByRole('button', { name: /submit/i }).click();
// Wait for success message
await expect(page.getByText(/success/i)).toBeVisible();
});
});
# Run all tests
bunx playwright test
# Run in UI mode (recommended for development)
bunx playwright test --ui
# Run specific test file
bunx playwright test tests/example.spec.ts
# Run in debug mode
bunx playwright test --debug
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Login Flow', () => {
test.beforeEach(async ({ page }) => {
// Each test gets isolated page context
await page.goto('/login');
});
test('should login successfully', async ({ page }) => {
// Use semantic locators
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: /sign in/i }).click();
// Wait for navigation
await expect(page).toHaveURL('/dashboard');
// Verify user is logged in
await expect(page.getByText(/welcome/i)).toBeVisible();
});
test('should show error for invalid credentials', async ({ page }) => {
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill('wrong');
await page.getByRole('button', { name: /sign in/i }).click();
// Verify error message
await expect(page.getByText(/invalid credentials/i)).toBeVisible();
});
});
// /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();
})();
E2E Test Version (Recommended):
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
test('should login successfully', async ({ page }) => {
await page.goto('/login');
// Use semantic locators (resilient to DOM changes)
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: /sign in/i }).click();
// Web-first assertion (auto-waits)
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText(/welcome/i)).toBeVisible();
});
Ad-hoc Automation Version:
// /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`);
// Prefer semantic locators even in ad-hoc scripts
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: /submit/i }).click();
// Wait for redirect
await page.waitForURL('**/dashboard');
console.log('✅ Login successful, redirected to dashboard');
await browser.close();
})();
E2E Test Version (Recommended):
// tests/contact.spec.ts
import { test, expect } from '@playwright/test';
test('should submit contact form', async ({ page }) => {
await page.goto('/contact');
// Use semantic locators
await page.getByLabel('Name').fill('John Doe');
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Message').fill('Test message');
await page.getByRole('button', { name: /submit/i }).click();
// Web-first assertion (auto-waits)
await expect(page.getByText(/success/i)).toBeVisible();
});
Ad-hoc Automation Version:
// /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`);
// Prefer semantic locators
await page.getByLabel('Name').fill('John Doe');
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Message').fill('Test message');
await page.getByRole('button', { name: /submit/i }).click();
// Verify submission with web-first assertion
await expect(page.getByText(/success/i)).toBeVisible();
console.log('✅ Form submitted successfully');
await browser.close();
})();
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();
})();
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();
}
})();
// /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();
})();
For quick one-off tasks, you can execute code inline without creating files:
# 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:
Optional utility functions in lib/helpers.js:
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.
Configure custom headers for all HTTP requests via environment variables. Useful for:
Single header (common case):
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):
PW_EXTRA_HEADERS='{"X-Automated-By":"playwright-skill","X-Debug":"true"}' \
cd $SKILL_DIR && node run.js /tmp/my-script.js
Headers are automatically applied when using helpers.createContext():
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():
const context = await browser.newContext(
getContextOptionsWithHeaders({ viewport: { width: 1920, height: 1080 } }),
);
For comprehensive Playwright API documentation, see API_REFERENCE.md:
@playwright/test framework - Not raw Playwright APIwebServer config to build and start production servergetByRole(), getByLabel(), getByText() instead of CSS selectorsexpect(page.getByText('...')).toBeVisible() auto-waitsbeforeEach hooks.spec.ts files for type safetytests/ directory, not /tmpwaitForURL, waitForSelector, or web-first assertionsdetectDevServers() before writing test code for localhost testingPW_HEADER_NAME/PW_HEADER_VALUE env vars to identify automated traffic to your backend/tmp/playwright-test-*.js, never to skill directory or user's projectTARGET_URL constant at the top of every scriptheadless: false unless user explicitly asks for headless modeheadless: true when user specifically requests "headless" or "background" executionslowMo: 100 to make actions visible and easier to followwaitForURL, waitForSelector, waitForLoadState instead of fixed timeoutsconsole.log() to track progress and show what's happeninggetByRole() when possiblePlaywright not installed:
cd $SKILL_DIR && bun 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 })
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]
/tmp for automatic cleanup (no clutter)run.js@playwright/test framework for proper test structuretests/ directoryUse E2E Testing (@playwright/test):
Use Ad-hoc Automation (Raw Playwright API):
tools
Build type-safe global state in React with Zustand. Supports TypeScript, persist middleware, devtools, slices pattern, and Next.js SSR with hydration handling. Prevents 6 documented errors. Use when setting up React state, migrating from Redux/Context, or troubleshooting hydration errors, TypeScript inference, infinite render loops, or persist race conditions.
testing
Run local Docker processing in reference then fixed diffing mode to validate Lua/SQL topic changes via public.*_diff tables. From app/, use `processing-generate-command` to print a copy-paste shell line (interactive Clack on a TTY); agents/CI pass the full non-interactive flag set (see --help). Triggers on processing verification, bbox/topic-limited runs, or diff regression after editing processing/topics.
development
Migrate Next.js apps to TanStack Start. Covers setup (Vinxi/Vite), data handling with route loaders, converting Server Actions to Server Functions, API routes, and optional Server Component patterns. Use when migrating from Next.js to TanStack Start, setting up TanStack Start, or refactoring server actions, getServerSideProps, getStaticProps, or API routes.
development
React useEffect best practices from official docs and naming discipline. Use when writing/reviewing useEffect, naming effects, useState for derived values, data fetching, or state synchronization. Strong recommendation to name every effect; teaches when NOT to use Effect and better alternatives.