Initial commit: Brachnha Insight project setup
- Next.js 14+ with App Router and TypeScript - Tailwind CSS and ShadCN UI styling - Zustand state management - Dexie.js for IndexedDB (local-first data) - Auth.js v5 for authentication - BMAD framework integration Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
421
_bmad/bmm/testarch/knowledge/recurse.md
Normal file
421
_bmad/bmm/testarch/knowledge/recurse.md
Normal file
@@ -0,0 +1,421 @@
|
||||
# Recurse (Polling) Utility
|
||||
|
||||
## Principle
|
||||
|
||||
Use Cypress-style polling with Playwright's `expect.poll` to wait for asynchronous conditions. Provides configurable timeout, interval, logging, and post-polling callbacks with enhanced error categorization. **Ideal for backend testing**: polling API endpoints for job completion, database eventual consistency, message queue processing, and cache propagation.
|
||||
|
||||
## Rationale
|
||||
|
||||
Testing async operations (background jobs, eventual consistency, webhook processing) requires polling:
|
||||
|
||||
- Vanilla `expect.poll` is verbose
|
||||
- No built-in logging for debugging
|
||||
- Generic timeout errors
|
||||
- No post-poll hooks
|
||||
|
||||
The `recurse` utility provides:
|
||||
|
||||
- **Clean syntax**: Inspired by cypress-recurse
|
||||
- **Enhanced errors**: Timeout vs command failure vs predicate errors
|
||||
- **Built-in logging**: Track polling progress
|
||||
- **Post-poll callbacks**: Process results after success
|
||||
- **Type-safe**: Full TypeScript generic support
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { test } from '@seontechnologies/playwright-utils/recurse/fixtures';
|
||||
|
||||
test('wait for job completion', async ({ recurse, apiRequest }) => {
|
||||
const { body } = await apiRequest({
|
||||
method: 'POST',
|
||||
path: '/api/jobs',
|
||||
body: { type: 'export' },
|
||||
});
|
||||
|
||||
// Poll until job completes
|
||||
const result = await recurse(
|
||||
() => apiRequest({ method: 'GET', path: `/api/jobs/${body.id}` }),
|
||||
(response) => response.body.status === 'completed',
|
||||
{ timeout: 60000 }
|
||||
);
|
||||
|
||||
expect(result.body.downloadUrl).toBeDefined();
|
||||
});
|
||||
```
|
||||
|
||||
## Pattern Examples
|
||||
|
||||
### Example 1: Basic Polling
|
||||
|
||||
**Context**: Wait for async operation to complete with custom timeout and interval.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```typescript
|
||||
import { test } from '@seontechnologies/playwright-utils/recurse/fixtures';
|
||||
|
||||
test('should wait for job completion', async ({ recurse, apiRequest }) => {
|
||||
// Start job
|
||||
const { body } = await apiRequest({
|
||||
method: 'POST',
|
||||
path: '/api/jobs',
|
||||
body: { type: 'export' },
|
||||
});
|
||||
|
||||
// Poll until ready
|
||||
const result = await recurse(
|
||||
() => apiRequest({ method: 'GET', path: `/api/jobs/${body.id}` }),
|
||||
(response) => response.body.status === 'completed',
|
||||
{
|
||||
timeout: 60000, // 60 seconds max
|
||||
interval: 2000, // Check every 2 seconds
|
||||
log: 'Waiting for export job to complete',
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.body.downloadUrl).toBeDefined();
|
||||
});
|
||||
```
|
||||
|
||||
**Key Points**:
|
||||
|
||||
- First arg: command function (what to execute)
|
||||
- Second arg: predicate function (when to stop)
|
||||
- Options: timeout, interval, log message
|
||||
- Returns the value when predicate returns true
|
||||
|
||||
### Example 2: Working with Assertions
|
||||
|
||||
**Context**: Use assertions directly in predicate for more expressive tests.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```typescript
|
||||
test('should poll with assertions', async ({ recurse, apiRequest }) => {
|
||||
await apiRequest({
|
||||
method: 'POST',
|
||||
path: '/api/events',
|
||||
body: { type: 'user-created', userId: '123' },
|
||||
});
|
||||
|
||||
// Poll with assertions in predicate - no return true needed!
|
||||
await recurse(
|
||||
async () => {
|
||||
const { body } = await apiRequest({ method: 'GET', path: '/api/events/123' });
|
||||
return body;
|
||||
},
|
||||
(event) => {
|
||||
// If all assertions pass, predicate succeeds
|
||||
expect(event.processed).toBe(true);
|
||||
expect(event.timestamp).toBeDefined();
|
||||
// No need to return true - just let assertions pass
|
||||
},
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
**Why no `return true` needed?**
|
||||
|
||||
The predicate checks for "truthiness" of the return value. But there's a catch - in JavaScript, an empty `return` (or no return) returns `undefined`, which is falsy!
|
||||
|
||||
The utility handles this by checking if:
|
||||
|
||||
1. The predicate didn't throw (assertions passed)
|
||||
2. The return value was either `undefined` (implicit return) or truthy
|
||||
|
||||
So you can:
|
||||
|
||||
```typescript
|
||||
// Option 1: Use assertions only (recommended)
|
||||
(event) => {
|
||||
expect(event.processed).toBe(true);
|
||||
};
|
||||
|
||||
// Option 2: Return boolean (also works)
|
||||
(event) => event.processed === true;
|
||||
|
||||
// Option 3: Mixed (assertions + explicit return)
|
||||
(event) => {
|
||||
expect(event.processed).toBe(true);
|
||||
return true;
|
||||
};
|
||||
```
|
||||
|
||||
### Example 3: Error Handling
|
||||
|
||||
**Context**: Understanding the different error types.
|
||||
|
||||
**Error Types:**
|
||||
|
||||
```typescript
|
||||
// RecurseTimeoutError - Predicate never returned true within timeout
|
||||
// Contains last command value and predicate error
|
||||
try {
|
||||
await recurse(/* ... */);
|
||||
} catch (error) {
|
||||
if (error instanceof RecurseTimeoutError) {
|
||||
console.log('Timed out. Last value:', error.lastCommandValue);
|
||||
console.log('Last predicate error:', error.lastPredicateError);
|
||||
}
|
||||
}
|
||||
|
||||
// RecurseCommandError - Command function threw an error
|
||||
// The command itself failed (e.g., network error, API error)
|
||||
|
||||
// RecursePredicateError - Predicate function threw (not from assertions failing)
|
||||
// Logic error in your predicate code
|
||||
```
|
||||
|
||||
**Custom Error Messages:**
|
||||
|
||||
```typescript
|
||||
test('custom error on timeout', async ({ recurse, apiRequest }) => {
|
||||
try {
|
||||
await recurse(
|
||||
() => apiRequest({ method: 'GET', path: '/api/status' }),
|
||||
(res) => res.body.ready === true,
|
||||
{
|
||||
timeout: 10000,
|
||||
error: 'System failed to become ready within 10 seconds - check background workers',
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
// Error message includes custom context
|
||||
expect(error.message).toContain('check background workers');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Example 4: Post-Polling Callback
|
||||
|
||||
**Context**: Process or log results after successful polling.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```typescript
|
||||
test('post-poll processing', async ({ recurse, apiRequest }) => {
|
||||
const finalResult = await recurse(
|
||||
() => apiRequest({ method: 'GET', path: '/api/batch-job/123' }),
|
||||
(res) => res.body.status === 'completed',
|
||||
{
|
||||
timeout: 60000,
|
||||
post: (result) => {
|
||||
// Runs after successful polling
|
||||
console.log(`Job completed in ${result.body.duration}ms`);
|
||||
console.log(`Processed ${result.body.itemsProcessed} items`);
|
||||
return result.body;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(finalResult.itemsProcessed).toBeGreaterThan(0);
|
||||
});
|
||||
```
|
||||
|
||||
**Key Points**:
|
||||
|
||||
- `post` callback runs after predicate succeeds
|
||||
- Receives the final result
|
||||
- Can transform or log results
|
||||
- Return value becomes final `recurse` result
|
||||
|
||||
### Example 5: UI Testing Scenarios
|
||||
|
||||
**Context**: Wait for UI elements to reach a specific state through polling.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```typescript
|
||||
test('table data loads', async ({ page, recurse }) => {
|
||||
await page.goto('/reports');
|
||||
|
||||
// Poll for table rows to appear
|
||||
await recurse(
|
||||
async () => page.locator('table tbody tr').count(),
|
||||
(count) => count >= 10, // Wait for at least 10 rows
|
||||
{
|
||||
timeout: 15000,
|
||||
interval: 500,
|
||||
log: 'Waiting for table data to load',
|
||||
}
|
||||
);
|
||||
|
||||
// Now safe to interact with table
|
||||
await page.locator('table tbody tr').first().click();
|
||||
});
|
||||
```
|
||||
|
||||
### Example 6: Event-Based Systems (Kafka/Message Queues)
|
||||
|
||||
**Context**: Testing eventual consistency with message queue processing.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```typescript
|
||||
test('kafka event processed', async ({ recurse, apiRequest }) => {
|
||||
// Trigger action that publishes Kafka event
|
||||
await apiRequest({
|
||||
method: 'POST',
|
||||
path: '/api/orders',
|
||||
body: { productId: 'ABC123', quantity: 2 },
|
||||
});
|
||||
|
||||
// Poll for downstream effect of Kafka consumer processing
|
||||
const inventoryResult = await recurse(
|
||||
() => apiRequest({ method: 'GET', path: '/api/inventory/ABC123' }),
|
||||
(res) => {
|
||||
// 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);
|
||||
},
|
||||
{
|
||||
timeout: 30000, // Kafka processing may take time
|
||||
interval: 1000,
|
||||
log: 'Waiting for Kafka event to be processed',
|
||||
}
|
||||
);
|
||||
|
||||
expect(inventoryResult.body.lastOrderId).toBeDefined();
|
||||
});
|
||||
```
|
||||
|
||||
### Example 7: Integration with API Request (Common Pattern)
|
||||
|
||||
**Context**: Most common use case - polling API endpoints for state changes.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```typescript
|
||||
import { test } from '@seontechnologies/playwright-utils/fixtures';
|
||||
|
||||
test('end-to-end polling', async ({ apiRequest, recurse }) => {
|
||||
// Trigger async operation
|
||||
const { body: createResp } = await apiRequest({
|
||||
method: 'POST',
|
||||
path: '/api/data-import',
|
||||
body: { source: 's3://bucket/data.csv' },
|
||||
});
|
||||
|
||||
// Poll until import completes
|
||||
const importResult = await recurse(
|
||||
() => apiRequest({ method: 'GET', path: `/api/data-import/${createResp.importId}` }),
|
||||
(response) => {
|
||||
const { status, rowsImported } = response.body;
|
||||
return status === 'completed' && rowsImported > 0;
|
||||
},
|
||||
{
|
||||
timeout: 120000, // 2 minutes for large imports
|
||||
interval: 5000, // Check every 5 seconds
|
||||
log: `Polling import ${createResp.importId}`,
|
||||
}
|
||||
);
|
||||
|
||||
expect(importResult.body.rowsImported).toBeGreaterThan(1000);
|
||||
expect(importResult.body.errors).toHaveLength(0);
|
||||
});
|
||||
```
|
||||
|
||||
**Key Points**:
|
||||
|
||||
- Combine `apiRequest` + `recurse` for API polling
|
||||
- Both from `@seontechnologies/playwright-utils/fixtures`
|
||||
- Complex predicates with multiple conditions
|
||||
- Logging shows polling progress in test reports
|
||||
|
||||
## API Reference
|
||||
|
||||
### 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) |
|
||||
|
||||
### 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) |
|
||||
|
||||
## Comparison with Vanilla Playwright
|
||||
|
||||
| Vanilla Playwright | recurse Utility |
|
||||
| ----------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| `await expect.poll(() => { ... }, { timeout: 30000 }).toBe(true)` | `await recurse(() => { ... }, (val) => val === true, { timeout: 30000 })` |
|
||||
| No logging | Built-in log option |
|
||||
| Generic timeout errors | Categorized errors (timeout/command/predicate) |
|
||||
| No post-poll hooks | `post` callback support |
|
||||
|
||||
## When to Use
|
||||
|
||||
**Use recurse for:**
|
||||
|
||||
- Background job completion
|
||||
- Webhook/event processing
|
||||
- Database eventual consistency
|
||||
- Cache propagation
|
||||
- State machine transitions
|
||||
|
||||
**Stick with vanilla expect.poll for:**
|
||||
|
||||
- Simple UI element visibility (use `expect(locator).toBeVisible()`)
|
||||
- Single-property checks
|
||||
- Cases where logging isn't needed
|
||||
|
||||
## Related Fragments
|
||||
|
||||
- `api-testing-patterns.md` - Comprehensive pure API testing patterns
|
||||
- `api-request.md` - Combine for API endpoint polling
|
||||
- `overview.md` - Fixture composition patterns
|
||||
- `fixtures-composition.md` - Using with mergeTests
|
||||
- `contract-testing.md` - Contract testing with async verification
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
**DON'T use hard waits instead of polling:**
|
||||
|
||||
```typescript
|
||||
await page.click('#export');
|
||||
await page.waitForTimeout(5000); // Arbitrary wait
|
||||
expect(await page.textContent('#status')).toBe('Ready');
|
||||
```
|
||||
|
||||
**DO poll for actual condition:**
|
||||
|
||||
```typescript
|
||||
await page.click('#export');
|
||||
await recurse(
|
||||
() => page.textContent('#status'),
|
||||
(status) => status === 'Ready',
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
```
|
||||
|
||||
**DON'T poll too frequently:**
|
||||
|
||||
```typescript
|
||||
await recurse(
|
||||
() => apiRequest({ method: 'GET', path: '/status' }),
|
||||
(res) => res.body.ready,
|
||||
{ interval: 100 } // Hammers API every 100ms!
|
||||
);
|
||||
```
|
||||
|
||||
**DO use reasonable interval for API calls:**
|
||||
|
||||
```typescript
|
||||
await recurse(
|
||||
() => apiRequest({ method: 'GET', path: '/status' }),
|
||||
(res) => res.body.ready,
|
||||
{ interval: 2000 } // Check every 2 seconds (reasonable)
|
||||
);
|
||||
```
|
||||
Reference in New Issue
Block a user