docs: addressed PR review concerns

This commit is contained in:
murat 2026-01-09 11:48:16 -06:00
parent caad5d46ae
commit fe0323e12f
6 changed files with 233 additions and 108 deletions

View File

@ -199,7 +199,7 @@ Epic/Release Gate → TEA: *nfr-assess, *trace Phase 2 (release decision)
TEA uniquely requires:
- **Extensive domain knowledge**: 33 fragments covering test patterns, CI/CD, fixtures, quality practices, and optional playwright-utils integration
- **Extensive domain knowledge**: 30+ fragments covering test patterns, CI/CD, fixtures, quality practices, and optional playwright-utils integration
- **Cross-cutting concerns**: Domain-specific testing patterns that apply across all BMad projects (vs project-specific artifacts like PRDs/stories)
- **Optional integrations**: MCP capabilities (exploratory, verification) and playwright-utils support

View File

@ -61,10 +61,11 @@ test('should fetch user data', async ({ apiRequest }) => {
```typescript
import { test } from '@seontechnologies/playwright-utils/api-request/fixtures';
import { z } from 'zod';
test('should validate response schema', async ({ apiRequest }) => {
// JSON Schema validation
const response = await apiRequest({
// JSON Schema validation
test('should validate response schema (JSON Schema)', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/users/123',
validateSchema: {
@ -78,22 +79,25 @@ test('should validate response schema', async ({ apiRequest }) => {
},
});
// Throws if schema validation fails
expect(status).toBe(200);
});
// Zod schema validation
import { z } from 'zod';
// Zod schema validation
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
const response = await apiRequest({
test('should validate response schema (Zod)', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/users/123',
validateSchema: UserSchema,
});
// Response body is type-safe AND validated
expect(status).toBe(200);
expect(body.email).toContain('@');
});
```

View File

@ -247,90 +247,152 @@ test('parallel test 2', async ({ page }) => {
### Example 6: Pure API Authentication (No Browser)
**Context**: Get auth tokens for API-only tests without any browser context.
**Context**: Get auth tokens for API-only tests using auth-session disk persistence.
**Implementation**:
```typescript
// tests/api/authenticated-api.spec.ts
import { test, expect } from '@seontechnologies/playwright-utils/fixtures';
// Step 1: Create API-only auth provider (no browser needed)
// playwright/support/api-auth-provider.ts
import { type AuthProvider } from '@seontechnologies/playwright-utils/auth-session';
test.describe('Authenticated API Tests (No Browser)', () => {
let authToken: string;
const apiAuthProvider: AuthProvider = {
getEnvironment: (options) => options.environment || 'local',
getUserIdentifier: (options) => options.userIdentifier || 'api-user',
test.beforeAll(async ({ request }) => {
// Get token via API login - no browser needed!
extractToken: (storageState) => {
// Token stored in localStorage format for disk persistence
const tokenEntry = storageState.origins?.[0]?.localStorage?.find(
(item) => item.name === 'auth_token'
);
return tokenEntry?.value;
},
isTokenExpired: (storageState) => {
const expiryEntry = storageState.origins?.[0]?.localStorage?.find(
(item) => item.name === 'token_expiry'
);
if (!expiryEntry) return true;
return Date.now() > parseInt(expiryEntry.value, 10);
},
manageAuthToken: async (request, options) => {
const email = process.env.TEST_USER_EMAIL;
const password = process.env.TEST_USER_PASSWORD;
if (!email || !password) {
throw new Error('TEST_USER_EMAIL and TEST_USER_PASSWORD must be set');
}
// Pure API login - no browser!
const response = await request.post('/api/auth/login', {
data: {
email: process.env.TEST_USER_EMAIL,
password: process.env.TEST_USER_PASSWORD,
},
data: { email, password },
});
const { token } = await response.json();
authToken = token;
if (!response.ok()) {
throw new Error(`Auth failed: ${response.status()}`);
}
const { token, expiresIn } = await response.json();
const expiryTime = Date.now() + expiresIn * 1000;
// Return storage state format for disk persistence
return {
cookies: [],
origins: [
{
origin: process.env.API_BASE_URL || 'http://localhost:3000',
localStorage: [
{ name: 'auth_token', value: token },
{ name: 'token_expiry', value: String(expiryTime) },
],
},
],
};
},
};
export default apiAuthProvider;
// Step 2: Create auth fixture
// playwright/support/fixtures.ts
import { test as base } from '@playwright/test';
import { createAuthFixtures, setAuthProvider } from '@seontechnologies/playwright-utils/auth-session';
import apiAuthProvider from './api-auth-provider';
setAuthProvider(apiAuthProvider);
export const test = base.extend(createAuthFixtures());
// Step 3: Use in tests - token persisted to disk!
// tests/api/authenticated-api.spec.ts
import { test } from '../support/fixtures';
import { expect } from '@playwright/test';
test('should access protected endpoint', async ({ authToken, apiRequest }) => {
// authToken is automatically loaded from disk or fetched if expired
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/me',
headers: { Authorization: `Bearer ${authToken}` },
});
test('should access protected endpoint', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'GET',
path: '/api/me',
headers: {
Authorization: `Bearer ${authToken}`,
},
});
expect(status).toBe(200);
});
expect(status).toBe(200);
expect(body.email).toBe(process.env.TEST_USER_EMAIL);
test('should create resource with auth', async ({ authToken, apiRequest }) => {
const { status, body } = await apiRequest({
method: 'POST',
path: '/api/orders',
headers: { Authorization: `Bearer ${authToken}` },
body: { items: [{ productId: 'prod-1', quantity: 2 }] },
});
test('should create resource with auth', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'POST',
path: '/api/orders',
headers: {
Authorization: `Bearer ${authToken}`,
},
body: {
items: [{ productId: 'prod-1', quantity: 2 }],
},
});
expect(status).toBe(201);
expect(body.id).toBeDefined();
});
expect(status).toBe(201);
expect(body.id).toBeDefined();
});
```
**Key Points**:
- Token obtained via API login (no browser!)
- Token reused across all tests in describe block
- Pure API testing - fast and lightweight
- No `page` or `context` needed
- Token persisted to disk (not in-memory) - survives test reruns
- Provider fetches token once, reuses until expired
- Pure API authentication - no browser context needed
- `authToken` fixture handles disk read/write automatically
- Environment variables validated with clear error message
### Example 7: Service-to-Service Authentication
**Context**: Test microservice authentication patterns (API keys, service tokens, mTLS simulation).
**Context**: Test microservice authentication patterns (API keys, service tokens) with proper environment validation.
**Implementation**:
```typescript
// tests/api/service-auth.spec.ts
import { test, expect } from '@seontechnologies/playwright-utils/fixtures';
import { test as base, expect } from '@playwright/test';
import { test as apiFixture } from '@seontechnologies/playwright-utils/api-request/fixtures';
import { mergeTests } from '@playwright/test';
// Validate environment variables at module load
const SERVICE_API_KEY = process.env.SERVICE_API_KEY;
const INTERNAL_SERVICE_URL = process.env.INTERNAL_SERVICE_URL;
if (!SERVICE_API_KEY) {
throw new Error('SERVICE_API_KEY environment variable is required');
}
if (!INTERNAL_SERVICE_URL) {
throw new Error('INTERNAL_SERVICE_URL environment variable is required');
}
const test = mergeTests(base, apiFixture);
test.describe('Service-to-Service Auth', () => {
const SERVICE_API_KEY = process.env.SERVICE_API_KEY;
const INTERNAL_SERVICE_URL = process.env.INTERNAL_SERVICE_URL;
test('should authenticate with API key', async ({ apiRequest }) => {
const { status, body } = await apiRequest({
method: 'GET',
path: '/internal/health',
baseUrl: INTERNAL_SERVICE_URL,
headers: {
'X-API-Key': SERVICE_API_KEY,
},
headers: { 'X-API-Key': SERVICE_API_KEY },
});
expect(status).toBe(200);
@ -342,9 +404,7 @@ test.describe('Service-to-Service Auth', () => {
method: 'GET',
path: '/internal/health',
baseUrl: INTERNAL_SERVICE_URL,
headers: {
'X-API-Key': 'invalid-key',
},
headers: { 'X-API-Key': 'invalid-key' },
});
expect(status).toBe(401);
@ -352,18 +412,15 @@ test.describe('Service-to-Service Auth', () => {
});
test('should call downstream service with propagated auth', async ({ apiRequest }) => {
// Simulate service calling another service with forwarded auth
const { status, body } = await apiRequest({
method: 'POST',
path: '/internal/aggregate-data',
baseUrl: INTERNAL_SERVICE_URL,
headers: {
'X-API-Key': SERVICE_API_KEY,
'X-Request-ID': `test-${Date.now()}`, // Correlation ID
},
body: {
sources: ['users', 'orders', 'inventory'],
'X-Request-ID': `test-${Date.now()}`,
},
body: { sources: ['users', 'orders', 'inventory'] },
});
expect(status).toBe(200);
@ -374,11 +431,14 @@ test.describe('Service-to-Service Auth', () => {
**Key Points**:
- API key authentication (no OAuth flow)
- Environment variables validated at module load with clear errors
- API key authentication (simpler than OAuth - no disk persistence needed)
- Test internal/service endpoints
- Validate auth rejection scenarios
- Correlation ID for request tracing
> **Note**: API keys are typically static secrets that don't expire, so disk persistence (auth-session) isn't needed. For rotating service tokens, use the auth-session provider pattern from Example 6.
## Custom Auth Provider Pattern
**Context**: Adapt auth-session to your authentication system (OAuth2, JWT, SAML, custom).

View File

@ -279,37 +279,94 @@ expect(headers).toContain('age');
### CSV Reader Options
| Option | Type | Default | Description |
| -------------- | ------------------------ | ------- | --------------------------------------- |
| `filePath` | `string` | - | Path to CSV file (mutually exclusive) |
| `content` | `string \| Buffer` | - | Direct content (mutually exclusive) |
| `delimiter` | `string \| 'auto'` | `','` | Value separator, auto-detect if 'auto' |
| `encoding` | `string` | `'utf8'`| File encoding |
| `parseHeaders` | `boolean` | `true` | Use first row as headers |
| `trim` | `boolean` | `true` | Trim whitespace from values |
| Option | Type | Default | Description |
| -------------- | ------------------ | -------- | -------------------------------------- |
| `filePath` | `string` | - | Path to CSV file (mutually exclusive) |
| `content` | `string \| Buffer` | - | Direct content (mutually exclusive) |
| `delimiter` | `string \| 'auto'` | `','` | Value separator, auto-detect if 'auto' |
| `encoding` | `string` | `'utf8'` | File encoding |
| `parseHeaders` | `boolean` | `true` | Use first row as headers |
| `trim` | `boolean` | `true` | Trim whitespace from values |
### XLSX Reader Options
| Option | Type | Description |
| ----------- | -------- | ------------------------------------ |
| `filePath` | `string` | Path to XLSX file |
| `sheetName` | `string` | Name of sheet to set as active |
| Option | Type | Description |
| ----------- | -------- | ------------------------------ |
| `filePath` | `string` | Path to XLSX file |
| `sheetName` | `string` | Name of sheet to set as active |
### PDF Reader Options
| Option | Type | Default | Description |
| ------------ | --------- | ------- | -------------------------------- |
| `filePath` | `string` | - | Path to PDF file (required) |
| `mergePages` | `boolean` | `true` | Merge text from all pages |
| `maxPages` | `number` | - | Maximum pages to extract |
| `debug` | `boolean` | `false` | Enable debug logging |
| Option | Type | Default | Description |
| ------------ | --------- | ------- | --------------------------- |
| `filePath` | `string` | - | Path to PDF file (required) |
| `mergePages` | `boolean` | `true` | Merge text from all pages |
| `maxPages` | `number` | - | Maximum pages to extract |
| `debug` | `boolean` | `false` | Enable debug logging |
### ZIP Reader Options
| Option | Type | Description |
| --------------- | -------- | -------------------------------------- |
| `filePath` | `string` | Path to ZIP file |
| `fileToExtract` | `string` | Specific file to extract to Buffer |
| Option | Type | Description |
| --------------- | -------- | ---------------------------------- |
| `filePath` | `string` | Path to ZIP file |
| `fileToExtract` | `string` | Specific file to extract to Buffer |
### Return Values
#### CSV Reader Return Value
```typescript
{
content: {
data: Array<Array<string | number>>, // Parsed rows (excludes header row if parseHeaders: true)
headers: string[] | null // Column headers (null if parseHeaders: false)
}
}
```
#### XLSX Reader Return Value
```typescript
{
content: {
worksheets: Array<{
name: string, // Sheet name
rows: Array<Array<any>>, // All rows including headers
headers?: string[] // First row as headers (if present)
}>
}
}
```
#### PDF Reader Return Value
```typescript
{
content: string, // Extracted text (merged or per-page based on mergePages)
pagesCount: number, // Total pages in PDF
fileName?: string, // Original filename if available
info?: Record<string, any> // PDF metadata (author, title, etc.)
}
```
> **Note**: When `mergePages: false`, `content` is an array of strings (one per page). When `maxPages` is set, only that many pages are extracted.
#### ZIP Reader Return Value
```typescript
{
content: {
entries: Array<{
name: string, // File/directory path within ZIP
size: number, // Uncompressed size in bytes
isDirectory: boolean // True for directories
}>,
extractedFiles: Record<string, Buffer | string> // Extracted file contents by path
}
}
```
> **Note**: When `fileToExtract` is specified, only that file appears in `extractedFiles`.
## Download Cleanup Pattern

View File

@ -196,6 +196,9 @@ class TodoPage {
```typescript
import { functionTestStep } from '@seontechnologies/playwright-utils';
// Define todo items for the test
const TODO_ITEMS = ['buy groceries', 'pay bills', 'schedule meeting'];
const createDefaultTodos = functionTestStep('Create default todos', async (page: Page) => {
await log.info('Creating default todos');
await log.step('step within a functionWrapper');

View File

@ -267,7 +267,8 @@ test('kafka event processed', async ({ recurse, apiRequest }) => {
const inventoryResult = await recurse(
() => apiRequest({ method: 'GET', path: '/api/inventory/ABC123' }),
(res) => {
// Inventory should decrease by 2 after consumer processes event
// Assumes test fixture seeds inventory at 100; in production tests,
// fetch baseline first and assert: expect(res.body.available).toBe(baseline - 2)
expect(res.body.available).toBeLessThanOrEqual(98);
},
{
@ -328,22 +329,22 @@ test('end-to-end polling', async ({ apiRequest, recurse }) => {
### RecurseOptions
| Option | Type | Default | Description |
| ---------- | ----------------------- | ----------- | ---------------------------------------------- |
| `timeout` | `number` | `30000` | Maximum time to wait (ms) |
| `interval` | `number` | `1000` | Time between polls (ms) |
| `log` | `string` | `undefined` | Message logged on each poll |
| `error` | `string` | `undefined` | Custom error message for timeout |
| `post` | `(result: T) => R` | `undefined` | Callback after successful poll |
| `delay` | `number` | `0` | Initial delay before first poll (ms) |
| Option | Type | Default | Description |
| ---------- | ------------------ | ----------- | ------------------------------------ |
| `timeout` | `number` | `30000` | Maximum time to wait (ms) |
| `interval` | `number` | `1000` | Time between polls (ms) |
| `log` | `string` | `undefined` | Message logged on each poll |
| `error` | `string` | `undefined` | Custom error message for timeout |
| `post` | `(result: T) => R` | `undefined` | Callback after successful poll |
| `delay` | `number` | `0` | Initial delay before first poll (ms) |
### Error Types
| Error Type | When Thrown | Properties |
| ---------------------- | ---------------------------------------- | -------------------------------------------- |
| `RecurseTimeoutError` | Predicate never passed within timeout | `lastCommandValue`, `lastPredicateError` |
| `RecurseCommandError` | Command function threw an error | `cause` (original error) |
| `RecursePredicateError`| Predicate threw (not assertion failure) | `cause` (original error) |
| Error Type | When Thrown | Properties |
| ----------------------- | --------------------------------------- | ---------------------------------------- |
| `RecurseTimeoutError` | Predicate never passed within timeout | `lastCommandValue`, `lastPredicateError` |
| `RecurseCommandError` | Command function threw an error | `cause` (original error) |
| `RecursePredicateError` | Predicate threw (not assertion failure) | `cause` (original error) |
## Comparison with Vanilla Playwright