Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. # Recurse (Polling) Utility
  2. ## Principle
  3. 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.
  4. ## Rationale
  5. Testing async operations (background jobs, eventual consistency, webhook processing) requires polling:
  6. - Vanilla `expect.poll` is verbose
  7. - No built-in logging for debugging
  8. - Generic timeout errors
  9. - No post-poll hooks
  10. The `recurse` utility provides:
  11. - **Clean syntax**: Inspired by cypress-recurse
  12. - **Enhanced errors**: Timeout vs command failure vs predicate errors
  13. - **Built-in logging**: Track polling progress
  14. - **Post-poll callbacks**: Process results after success
  15. - **Type-safe**: Full TypeScript generic support
  16. ## Quick Start
  17. ```typescript
  18. import { test } from '@seontechnologies/playwright-utils/recurse/fixtures';
  19. test('wait for job completion', async ({ recurse, apiRequest }) => {
  20. const { body } = await apiRequest({
  21. method: 'POST',
  22. path: '/api/jobs',
  23. body: { type: 'export' },
  24. });
  25. // Poll until job completes
  26. const result = await recurse(
  27. () => apiRequest({ method: 'GET', path: `/api/jobs/${body.id}` }),
  28. (response) => response.body.status === 'completed',
  29. { timeout: 60000 },
  30. );
  31. expect(result.body.downloadUrl).toBeDefined();
  32. });
  33. ```
  34. ## Pattern Examples
  35. ### Example 1: Basic Polling
  36. **Context**: Wait for async operation to complete with custom timeout and interval.
  37. **Implementation**:
  38. ```typescript
  39. import { test } from '@seontechnologies/playwright-utils/recurse/fixtures';
  40. test('should wait for job completion', async ({ recurse, apiRequest }) => {
  41. // Start job
  42. const { body } = await apiRequest({
  43. method: 'POST',
  44. path: '/api/jobs',
  45. body: { type: 'export' },
  46. });
  47. // Poll until ready
  48. const result = await recurse(
  49. () => apiRequest({ method: 'GET', path: `/api/jobs/${body.id}` }),
  50. (response) => response.body.status === 'completed',
  51. {
  52. timeout: 60000, // 60 seconds max
  53. interval: 2000, // Check every 2 seconds
  54. log: 'Waiting for export job to complete',
  55. },
  56. );
  57. expect(result.body.downloadUrl).toBeDefined();
  58. });
  59. ```
  60. **Key Points**:
  61. - First arg: command function (what to execute)
  62. - Second arg: predicate function (when to stop)
  63. - Options: timeout, interval, log message
  64. - Returns the value when predicate returns true
  65. ### Example 2: Working with Assertions
  66. **Context**: Use assertions directly in predicate for more expressive tests.
  67. **Implementation**:
  68. ```typescript
  69. test('should poll with assertions', async ({ recurse, apiRequest }) => {
  70. await apiRequest({
  71. method: 'POST',
  72. path: '/api/events',
  73. body: { type: 'user-created', userId: '123' },
  74. });
  75. // Poll with assertions in predicate - no return true needed!
  76. await recurse(
  77. async () => {
  78. const { body } = await apiRequest({ method: 'GET', path: '/api/events/123' });
  79. return body;
  80. },
  81. (event) => {
  82. // If all assertions pass, predicate succeeds
  83. expect(event.processed).toBe(true);
  84. expect(event.timestamp).toBeDefined();
  85. // No need to return true - just let assertions pass
  86. },
  87. { timeout: 30000 },
  88. );
  89. });
  90. ```
  91. **Why no `return true` needed?**
  92. 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!
  93. The utility handles this by checking if:
  94. 1. The predicate didn't throw (assertions passed)
  95. 2. The return value was either `undefined` (implicit return) or truthy
  96. So you can:
  97. ```typescript
  98. // Option 1: Use assertions only (recommended)
  99. (event) => {
  100. expect(event.processed).toBe(true);
  101. };
  102. // Option 2: Return boolean (also works)
  103. (event) => event.processed === true;
  104. // Option 3: Mixed (assertions + explicit return)
  105. (event) => {
  106. expect(event.processed).toBe(true);
  107. return true;
  108. };
  109. ```
  110. ### Example 3: Error Handling
  111. **Context**: Understanding the different error types.
  112. **Error Types:**
  113. ```typescript
  114. // RecurseTimeoutError - Predicate never returned true within timeout
  115. // Contains last command value and predicate error
  116. try {
  117. await recurse(/* ... */);
  118. } catch (error) {
  119. if (error instanceof RecurseTimeoutError) {
  120. console.log('Timed out. Last value:', error.lastCommandValue);
  121. console.log('Last predicate error:', error.lastPredicateError);
  122. }
  123. }
  124. // RecurseCommandError - Command function threw an error
  125. // The command itself failed (e.g., network error, API error)
  126. // RecursePredicateError - Predicate function threw (not from assertions failing)
  127. // Logic error in your predicate code
  128. ```
  129. **Custom Error Messages:**
  130. ```typescript
  131. test('custom error on timeout', async ({ recurse, apiRequest }) => {
  132. try {
  133. await recurse(
  134. () => apiRequest({ method: 'GET', path: '/api/status' }),
  135. (res) => res.body.ready === true,
  136. {
  137. timeout: 10000,
  138. error: 'System failed to become ready within 10 seconds - check background workers',
  139. },
  140. );
  141. } catch (error) {
  142. // Error message includes custom context
  143. expect(error.message).toContain('check background workers');
  144. throw error;
  145. }
  146. });
  147. ```
  148. ### Example 4: Post-Polling Callback
  149. **Context**: Process or log results after successful polling.
  150. **Implementation**:
  151. ```typescript
  152. test('post-poll processing', async ({ recurse, apiRequest }) => {
  153. const finalResult = await recurse(
  154. () => apiRequest({ method: 'GET', path: '/api/batch-job/123' }),
  155. (res) => res.body.status === 'completed',
  156. {
  157. timeout: 60000,
  158. post: (result) => {
  159. // Runs after successful polling
  160. console.log(`Job completed in ${result.body.duration}ms`);
  161. console.log(`Processed ${result.body.itemsProcessed} items`);
  162. return result.body;
  163. },
  164. },
  165. );
  166. expect(finalResult.itemsProcessed).toBeGreaterThan(0);
  167. });
  168. ```
  169. **Key Points**:
  170. - `post` callback runs after predicate succeeds
  171. - Receives the final result
  172. - Can transform or log results
  173. - Return value becomes final `recurse` result
  174. ### Example 5: UI Testing Scenarios
  175. **Context**: Wait for UI elements to reach a specific state through polling.
  176. **Implementation**:
  177. ```typescript
  178. test('table data loads', async ({ page, recurse }) => {
  179. await page.goto('/reports');
  180. // Poll for table rows to appear
  181. await recurse(
  182. async () => page.locator('table tbody tr').count(),
  183. (count) => count >= 10, // Wait for at least 10 rows
  184. {
  185. timeout: 15000,
  186. interval: 500,
  187. log: 'Waiting for table data to load',
  188. },
  189. );
  190. // Now safe to interact with table
  191. await page.locator('table tbody tr').first().click();
  192. });
  193. ```
  194. ### Example 6: Event-Based Systems (Kafka/Message Queues)
  195. **Context**: Testing eventual consistency with message queue processing.
  196. **Implementation**:
  197. ```typescript
  198. test('kafka event processed', async ({ recurse, apiRequest }) => {
  199. // Trigger action that publishes Kafka event
  200. await apiRequest({
  201. method: 'POST',
  202. path: '/api/orders',
  203. body: { productId: 'ABC123', quantity: 2 },
  204. });
  205. // Poll for downstream effect of Kafka consumer processing
  206. const inventoryResult = await recurse(
  207. () => apiRequest({ method: 'GET', path: '/api/inventory/ABC123' }),
  208. (res) => {
  209. // Assumes test fixture seeds inventory at 100; in production tests,
  210. // fetch baseline first and assert: expect(res.body.available).toBe(baseline - 2)
  211. expect(res.body.available).toBeLessThanOrEqual(98);
  212. },
  213. {
  214. timeout: 30000, // Kafka processing may take time
  215. interval: 1000,
  216. log: 'Waiting for Kafka event to be processed',
  217. },
  218. );
  219. expect(inventoryResult.body.lastOrderId).toBeDefined();
  220. });
  221. ```
  222. ### Example 7: Integration with API Request (Common Pattern)
  223. **Context**: Most common use case - polling API endpoints for state changes.
  224. **Implementation**:
  225. ```typescript
  226. import { test } from '@seontechnologies/playwright-utils/fixtures';
  227. test('end-to-end polling', async ({ apiRequest, recurse }) => {
  228. // Trigger async operation
  229. const { body: createResp } = await apiRequest({
  230. method: 'POST',
  231. path: '/api/data-import',
  232. body: { source: 's3://bucket/data.csv' },
  233. });
  234. // Poll until import completes
  235. const importResult = await recurse(
  236. () => apiRequest({ method: 'GET', path: `/api/data-import/${createResp.importId}` }),
  237. (response) => {
  238. const { status, rowsImported } = response.body;
  239. return status === 'completed' && rowsImported > 0;
  240. },
  241. {
  242. timeout: 120000, // 2 minutes for large imports
  243. interval: 5000, // Check every 5 seconds
  244. log: `Polling import ${createResp.importId}`,
  245. },
  246. );
  247. expect(importResult.body.rowsImported).toBeGreaterThan(1000);
  248. expect(importResult.body.errors).toHaveLength(0);
  249. });
  250. ```
  251. **Key Points**:
  252. - Combine `apiRequest` + `recurse` for API polling
  253. - Both from `@seontechnologies/playwright-utils/fixtures`
  254. - Complex predicates with multiple conditions
  255. - Logging shows polling progress in test reports
  256. ## API Reference
  257. ### RecurseOptions
  258. | Option | Type | Default | Description |
  259. | ---------- | ------------------ | ----------- | ------------------------------------ |
  260. | `timeout` | `number` | `30000` | Maximum time to wait (ms) |
  261. | `interval` | `number` | `1000` | Time between polls (ms) |
  262. | `log` | `string` | `undefined` | Message logged on each poll |
  263. | `error` | `string` | `undefined` | Custom error message for timeout |
  264. | `post` | `(result: T) => R` | `undefined` | Callback after successful poll |
  265. | `delay` | `number` | `0` | Initial delay before first poll (ms) |
  266. ### Error Types
  267. | Error Type | When Thrown | Properties |
  268. | ----------------------- | --------------------------------------- | ---------------------------------------- |
  269. | `RecurseTimeoutError` | Predicate never passed within timeout | `lastCommandValue`, `lastPredicateError` |
  270. | `RecurseCommandError` | Command function threw an error | `cause` (original error) |
  271. | `RecursePredicateError` | Predicate threw (not assertion failure) | `cause` (original error) |
  272. ## Comparison with Vanilla Playwright
  273. | Vanilla Playwright | recurse Utility |
  274. | ----------------------------------------------------------------- | ------------------------------------------------------------------------- |
  275. | `await expect.poll(() => { ... }, { timeout: 30000 }).toBe(true)` | `await recurse(() => { ... }, (val) => val === true, { timeout: 30000 })` |
  276. | No logging | Built-in log option |
  277. | Generic timeout errors | Categorized errors (timeout/command/predicate) |
  278. | No post-poll hooks | `post` callback support |
  279. ## When to Use
  280. **Use recurse for:**
  281. - Background job completion
  282. - Webhook/event processing
  283. - Database eventual consistency
  284. - Cache propagation
  285. - State machine transitions
  286. **Stick with vanilla expect.poll for:**
  287. - Simple UI element visibility (use `expect(locator).toBeVisible()`)
  288. - Single-property checks
  289. - Cases where logging isn't needed
  290. ## Related Fragments
  291. - `api-testing-patterns.md` - Comprehensive pure API testing patterns
  292. - `api-request.md` - Combine for API endpoint polling
  293. - `overview.md` - Fixture composition patterns
  294. - `fixtures-composition.md` - Using with mergeTests
  295. - `contract-testing.md` - Contract testing with async verification
  296. ## Anti-Patterns
  297. **DON'T use hard waits instead of polling:**
  298. ```typescript
  299. await page.click('#export');
  300. await page.waitForTimeout(5000); // Arbitrary wait
  301. expect(await page.textContent('#status')).toBe('Ready');
  302. ```
  303. **DO poll for actual condition:**
  304. ```typescript
  305. await page.click('#export');
  306. await recurse(
  307. () => page.textContent('#status'),
  308. (status) => status === 'Ready',
  309. { timeout: 10000 },
  310. );
  311. ```
  312. **DON'T poll too frequently:**
  313. ```typescript
  314. await recurse(
  315. () => apiRequest({ method: 'GET', path: '/status' }),
  316. (res) => res.body.ready,
  317. { interval: 100 }, // Hammers API every 100ms!
  318. );
  319. ```
  320. **DO use reasonable interval for API calls:**
  321. ```typescript
  322. await recurse(
  323. () => apiRequest({ method: 'GET', path: '/status' }),
  324. (res) => res.body.ready,
  325. { interval: 2000 }, // Check every 2 seconds (reasonable)
  326. );
  327. ```