# 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)
);
```
