297 lines
8.4 KiB
Markdown
297 lines
8.4 KiB
Markdown
# 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.
|
|
|
|
## 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
|
|
|
|
## 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: Polling 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
|
|
await recurse(
|
|
async () => {
|
|
const { body } = await apiRequest({ method: 'GET', path: '/api/events/123' });
|
|
return body;
|
|
},
|
|
(event) => {
|
|
// Use assertions instead of boolean returns
|
|
expect(event.processed).toBe(true);
|
|
expect(event.timestamp).toBeDefined();
|
|
// If assertions pass, predicate succeeds
|
|
},
|
|
{ timeout: 30000 },
|
|
);
|
|
});
|
|
```
|
|
|
|
**Key Points**:
|
|
|
|
- Predicate can use `expect()` assertions
|
|
- If assertions throw, polling continues
|
|
- If assertions pass, polling succeeds
|
|
- More expressive than boolean returns
|
|
|
|
### Example 3: Custom Error Messages
|
|
|
|
**Context**: Provide context-specific error messages for timeout failures.
|
|
|
|
**Implementation**:
|
|
|
|
```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;
|
|
}
|
|
});
|
|
```
|
|
|
|
**Key Points**:
|
|
|
|
- `error` option provides custom message
|
|
- Replaces default "Timed out after X ms"
|
|
- Include debugging hints in error message
|
|
- Helps diagnose failures faster
|
|
|
|
### 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: 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
|
|
|
|
## Enhanced Error Types
|
|
|
|
The utility categorizes errors for easier debugging:
|
|
|
|
```typescript
|
|
// TimeoutError - Predicate never returned true
|
|
Error: Polling timed out after 30000ms: Job never completed
|
|
|
|
// CommandError - Command function threw
|
|
Error: Command failed: Request failed with status 500
|
|
|
|
// PredicateError - Predicate function threw (not from assertions)
|
|
Error: Predicate failed: Cannot read property 'status' of undefined
|
|
```
|
|
|
|
## 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-request.md` - Combine for API endpoint polling
|
|
- `overview.md` - Fixture composition patterns
|
|
- `fixtures-composition.md` - Using with mergeTests
|
|
|
|
## Anti-Patterns
|
|
|
|
**❌ Using hard waits instead of polling:**
|
|
|
|
```typescript
|
|
await page.click('#export');
|
|
await page.waitForTimeout(5000); // Arbitrary wait
|
|
expect(await page.textContent('#status')).toBe('Ready');
|
|
```
|
|
|
|
**✅ Poll for actual condition:**
|
|
|
|
```typescript
|
|
await page.click('#export');
|
|
await recurse(
|
|
() => page.textContent('#status'),
|
|
(status) => status === 'Ready',
|
|
{ timeout: 10000 },
|
|
);
|
|
```
|
|
|
|
**❌ Polling too frequently:**
|
|
|
|
```typescript
|
|
await recurse(
|
|
() => apiRequest({ method: 'GET', path: '/status' }),
|
|
(res) => res.body.ready,
|
|
{ interval: 100 }, // Hammers API every 100ms!
|
|
);
|
|
```
|
|
|
|
**✅ Reasonable interval for API calls:**
|
|
|
|
```typescript
|
|
await recurse(
|
|
() => apiRequest({ method: 'GET', path: '/status' }),
|
|
(res) => res.body.ready,
|
|
{ interval: 2000 }, // Check every 2 seconds (reasonable)
|
|
);
|
|
```
|