Files
james.bland 3f0786f1e7 refactor: agents and skills move to a client-neutral source that renders per client
`.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.
2026-09-19 17:27:26 -04:00

9.5 KiB

name, description
name description
browser-testing 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

# 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)

    page.getByRole('button', { name: 'Submit' })
    page.getByLabel('Email')
    
  2. Wait for specific conditions (not arbitrary timeouts)

    await expect(page.getByText('Success')).toBeVisible();
    
  3. Isolate tests (each test sets up its own state)

    test.beforeEach(async ({ page }) => {
      await page.goto('/login');
    });
    
  4. Use Page Objects (DRY, maintainable)

    const loginPage = new LoginPage(page);
    await loginPage.login(email, password);
    
  5. Mock external APIs (fast, reliable)

    await page.route('**/api/**', handler);