docs: addressed PR review concerns
This commit is contained in:
parent
caad5d46ae
commit
fe0323e12f
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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('@');
|
||||
});
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue