`.claude/agents/` was the source of truth, which made every role Claude-Code shaped. Adding a second client meant rewriting each role in that client's syntax and maintaining both copies — the drift this scaffold exists to prevent, one layer up. Roles and skills now live under `.agents/` and render into each registered client. `.claude/agents/`, `.claude/skills/` and `.codex/agents/` are generated; `scripts/sync-agent-integrations.py --check` fails on drift and belongs in CI. The role metadata is portable rather than vendor-named: `reasoning_tier` (deep/balanced/fast/vision), `capabilities`, `mutation`, `invocation`, and an optional `preload_skills`. A client manifest maps those to native syntax and must declare what it cannot express — `codex.yaml` declares `tier_policy: unsupported` and its adapters say so in the file, rather than the tier silently evaporating and leaving the repository to believe it was enforced. The port is behaviour-preserving where it should be and a fix where it should not. Every instruction body is byte-identical — the whole diff to `.claude/agents/` is 18 added lines and zero deletions. What changed is frontmatter that was missing: - four agents (`code-reviewer`, `tdd-guardian`, `dependency-audit`, `pr-creator`) declared no `tools:` and therefore inherited the ENTIRE tool pool, so three review-only agents could edit and write the code they were reviewing. All eight now declare capabilities explicitly. - the six read-only roles gain a non-editing permission mode, so the constraint is enforced by the client rather than by the prompt asking nicely. - `mutation` is now explicit, which records the two roles that genuinely need to write: `pr-creator` (external-write — it pushes a branch and opens a PR) and `dependency-audit` (workspace-write — package managers rewrite lockfiles). `pr-creator` keeps `shell` because opening a PR needs it, but it is now the only agent here with a write mutation and a declared reason for it, instead of one of four with unlimited access by omission.
390 lines
9.5 KiB
Markdown
390 lines
9.5 KiB
Markdown
---
|
|
name: browser-testing
|
|
description: Browser automation with Playwright for E2E tests and Claude's Chrome MCP for rapid development verification. Use when writing E2E tests or verifying UI during development.
|
|
---
|
|
|
|
# Browser Testing Skill
|
|
|
|
## Two Approaches
|
|
|
|
### 1. Claude Chrome MCP (Development Verification)
|
|
Quick, interactive testing during development to verify UI works before writing permanent tests.
|
|
|
|
### 2. Playwright (Permanent E2E Tests)
|
|
Automated, repeatable tests that run in CI/CD.
|
|
|
|
---
|
|
|
|
## Claude Chrome MCP (Quick Verification)
|
|
|
|
Use Chrome MCP tools for rapid UI verification during development.
|
|
|
|
### Basic Workflow
|
|
|
|
```
|
|
1. Navigate to page
|
|
2. Take screenshot to verify state
|
|
3. Find and interact with elements
|
|
4. Verify expected outcome
|
|
5. Record GIF for complex flows
|
|
```
|
|
|
|
### Navigation
|
|
```typescript
|
|
// Navigate to URL
|
|
mcp__claude-in-chrome__navigate({ url: "http://localhost:5173", tabId: 123 })
|
|
|
|
// Go back/forward
|
|
mcp__claude-in-chrome__navigate({ url: "back", tabId: 123 })
|
|
```
|
|
|
|
### Finding Elements
|
|
```typescript
|
|
// Find by natural language
|
|
mcp__claude-in-chrome__find({ query: "login button", tabId: 123 })
|
|
mcp__claude-in-chrome__find({ query: "email input field", tabId: 123 })
|
|
mcp__claude-in-chrome__find({ query: "product card containing organic", tabId: 123 })
|
|
```
|
|
|
|
### Reading Page State
|
|
```typescript
|
|
// Get accessibility tree (best for understanding structure)
|
|
mcp__claude-in-chrome__read_page({ tabId: 123, filter: "interactive" })
|
|
|
|
// Get text content (for articles/text-heavy pages)
|
|
mcp__claude-in-chrome__get_page_text({ tabId: 123 })
|
|
```
|
|
|
|
### Interactions
|
|
```typescript
|
|
// Click element by reference
|
|
mcp__claude-in-chrome__computer({
|
|
action: "left_click",
|
|
ref: "ref_42",
|
|
tabId: 123
|
|
})
|
|
|
|
// Type text
|
|
mcp__claude-in-chrome__computer({
|
|
action: "type",
|
|
text: "[email protected]",
|
|
tabId: 123
|
|
})
|
|
|
|
// Fill form input
|
|
mcp__claude-in-chrome__form_input({
|
|
ref: "ref_15",
|
|
value: "[email protected]",
|
|
tabId: 123
|
|
})
|
|
|
|
// Press keys
|
|
mcp__claude-in-chrome__computer({
|
|
action: "key",
|
|
text: "Enter",
|
|
tabId: 123
|
|
})
|
|
```
|
|
|
|
### Screenshots and GIFs
|
|
```typescript
|
|
// Take screenshot
|
|
mcp__claude-in-chrome__computer({ action: "screenshot", tabId: 123 })
|
|
|
|
// Record GIF for complex interaction
|
|
mcp__claude-in-chrome__gif_creator({ action: "start_recording", tabId: 123 })
|
|
// ... perform actions ...
|
|
mcp__claude-in-chrome__gif_creator({
|
|
action: "export",
|
|
download: true,
|
|
filename: "login-flow.gif",
|
|
tabId: 123
|
|
})
|
|
```
|
|
|
|
### Verification Pattern
|
|
|
|
```
|
|
1. Navigate to the page
|
|
2. Screenshot to see current state
|
|
3. Find the element you want to interact with
|
|
4. Interact with it
|
|
5. Screenshot to verify the result
|
|
6. If complex flow, record a GIF
|
|
```
|
|
|
|
---
|
|
|
|
## Playwright (Permanent E2E Tests)
|
|
|
|
### Project Setup
|
|
|
|
```typescript
|
|
// playwright.config.ts
|
|
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'],
|
|
['junit', { outputFile: 'results.xml' }],
|
|
],
|
|
use: {
|
|
baseURL: 'http://localhost:5173',
|
|
trace: 'on-first-retry',
|
|
screenshot: 'only-on-failure',
|
|
},
|
|
projects: [
|
|
{
|
|
name: 'chromium',
|
|
use: { ...devices['Desktop Chrome'] },
|
|
},
|
|
{
|
|
name: 'firefox',
|
|
use: { ...devices['Desktop Firefox'] },
|
|
},
|
|
{
|
|
name: 'webkit',
|
|
use: { ...devices['Desktop Safari'] },
|
|
},
|
|
{
|
|
name: 'mobile',
|
|
use: { ...devices['iPhone 13'] },
|
|
},
|
|
],
|
|
webServer: {
|
|
command: 'npm run dev',
|
|
url: 'http://localhost:5173',
|
|
reuseExistingServer: !process.env.CI,
|
|
},
|
|
});
|
|
```
|
|
|
|
### Page Object Pattern
|
|
|
|
```typescript
|
|
// e2e/pages/login.page.ts
|
|
import { Page, Locator, expect } from '@playwright/test';
|
|
|
|
export class LoginPage {
|
|
readonly page: Page;
|
|
readonly emailInput: Locator;
|
|
readonly passwordInput: Locator;
|
|
readonly submitButton: Locator;
|
|
readonly errorMessage: Locator;
|
|
|
|
constructor(page: Page) {
|
|
this.page = page;
|
|
this.emailInput = page.getByLabel('Email');
|
|
this.passwordInput = page.getByLabel('Password');
|
|
this.submitButton = page.getByRole('button', { name: 'Sign in' });
|
|
this.errorMessage = page.getByRole('alert');
|
|
}
|
|
|
|
async goto() {
|
|
await this.page.goto('/login');
|
|
}
|
|
|
|
async login(email: string, password: string) {
|
|
await this.emailInput.fill(email);
|
|
await this.passwordInput.fill(password);
|
|
await this.submitButton.click();
|
|
}
|
|
|
|
async expectError(message: string) {
|
|
await expect(this.errorMessage).toContainText(message);
|
|
}
|
|
|
|
async expectLoggedIn() {
|
|
await expect(this.page).toHaveURL('/dashboard');
|
|
}
|
|
}
|
|
```
|
|
|
|
### Test Patterns
|
|
|
|
```typescript
|
|
// e2e/auth/login.spec.ts
|
|
import { test, expect } from '@playwright/test';
|
|
import { LoginPage } from '../pages/login.page';
|
|
|
|
test.describe('Login', () => {
|
|
let loginPage: LoginPage;
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
loginPage = new LoginPage(page);
|
|
await loginPage.goto();
|
|
});
|
|
|
|
test('should show validation errors for empty form', async () => {
|
|
await loginPage.submitButton.click();
|
|
|
|
await expect(loginPage.page.getByText('Email is required')).toBeVisible();
|
|
await expect(loginPage.page.getByText('Password is required')).toBeVisible();
|
|
});
|
|
|
|
test('should show error for invalid credentials', async () => {
|
|
await loginPage.login('[email protected]', 'wrongpassword');
|
|
|
|
await loginPage.expectError('Invalid credentials');
|
|
});
|
|
|
|
test('should redirect to dashboard on successful login', async () => {
|
|
await loginPage.login('[email protected]', 'correctpassword');
|
|
|
|
await loginPage.expectLoggedIn();
|
|
await expect(loginPage.page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
|
});
|
|
|
|
test('should persist session after page reload', async ({ page }) => {
|
|
await loginPage.login('[email protected]', 'correctpassword');
|
|
await loginPage.expectLoggedIn();
|
|
|
|
await page.reload();
|
|
|
|
await expect(page).toHaveURL('/dashboard');
|
|
});
|
|
});
|
|
```
|
|
|
|
### API Mocking
|
|
|
|
```typescript
|
|
// e2e/dashboard/projects.spec.ts
|
|
import { test, expect } from '@playwright/test';
|
|
|
|
test.describe('Projects Dashboard', () => {
|
|
test('should display projects from API', async ({ page }) => {
|
|
// Mock API response
|
|
await page.route('**/api/projects', async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify([
|
|
{ id: '1', name: 'Project A', status: 'active' },
|
|
{ id: '2', name: 'Project B', status: 'completed' },
|
|
]),
|
|
});
|
|
});
|
|
|
|
await page.goto('/dashboard');
|
|
|
|
await expect(page.getByText('Project A')).toBeVisible();
|
|
await expect(page.getByText('Project B')).toBeVisible();
|
|
});
|
|
|
|
test('should show error state on API failure', async ({ page }) => {
|
|
await page.route('**/api/projects', async (route) => {
|
|
await route.fulfill({ status: 500 });
|
|
});
|
|
|
|
await page.goto('/dashboard');
|
|
|
|
await expect(page.getByRole('alert')).toContainText('Failed to load projects');
|
|
});
|
|
});
|
|
```
|
|
|
|
### Visual Regression Testing
|
|
|
|
```typescript
|
|
// e2e/visual/components.spec.ts
|
|
import { test, expect } from '@playwright/test';
|
|
|
|
test.describe('Visual Regression', () => {
|
|
test('login page matches snapshot', async ({ page }) => {
|
|
await page.goto('/login');
|
|
|
|
await expect(page).toHaveScreenshot('login-page.png');
|
|
});
|
|
|
|
test('dashboard matches snapshot', async ({ page }) => {
|
|
// Setup authenticated state
|
|
await page.goto('/dashboard');
|
|
|
|
await expect(page).toHaveScreenshot('dashboard.png', {
|
|
mask: [page.locator('.timestamp')], // Mask dynamic content
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
### Accessibility Testing
|
|
|
|
```typescript
|
|
// e2e/a11y/accessibility.spec.ts
|
|
import { test, expect } from '@playwright/test';
|
|
import AxeBuilder from '@axe-core/playwright';
|
|
|
|
test.describe('Accessibility', () => {
|
|
test('login page should have no accessibility violations', async ({ page }) => {
|
|
await page.goto('/login');
|
|
|
|
const results = await new AxeBuilder({ page }).analyze();
|
|
|
|
expect(results.violations).toEqual([]);
|
|
});
|
|
|
|
test('dashboard should have no accessibility violations', async ({ page }) => {
|
|
await page.goto('/dashboard');
|
|
|
|
const results = await new AxeBuilder({ page })
|
|
.exclude('.third-party-widget') // Exclude third-party content
|
|
.analyze();
|
|
|
|
expect(results.violations).toEqual([]);
|
|
});
|
|
});
|
|
```
|
|
|
|
## Commands
|
|
|
|
```bash
|
|
# Playwright
|
|
npx playwright test # Run all tests
|
|
npx playwright test --project=chromium # Specific browser
|
|
npx playwright test login.spec.ts # Specific file
|
|
npx playwright test --ui # Interactive UI mode
|
|
npx playwright test --debug # Debug mode
|
|
npx playwright show-report # View HTML report
|
|
npx playwright codegen # Generate tests by recording
|
|
|
|
# Update snapshots
|
|
npx playwright test --update-snapshots
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
1. **Use role-based locators** (accessible to all)
|
|
```typescript
|
|
page.getByRole('button', { name: 'Submit' })
|
|
page.getByLabel('Email')
|
|
```
|
|
|
|
2. **Wait for specific conditions** (not arbitrary timeouts)
|
|
```typescript
|
|
await expect(page.getByText('Success')).toBeVisible();
|
|
```
|
|
|
|
3. **Isolate tests** (each test sets up its own state)
|
|
```typescript
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.goto('/login');
|
|
});
|
|
```
|
|
|
|
4. **Use Page Objects** (DRY, maintainable)
|
|
```typescript
|
|
const loginPage = new LoginPage(page);
|
|
await loginPage.login(email, password);
|
|
```
|
|
|
|
5. **Mock external APIs** (fast, reliable)
|
|
```typescript
|
|
await page.route('**/api/**', handler);
|
|
```
|