@assertrx/dino 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +293 -0
  2. package/extensions/dino.ts +70 -0
  3. package/extensions/mabl-mcp.ts +306 -0
  4. package/extensions/playwright-mcp.ts +142 -0
  5. package/package.json +47 -0
  6. package/prompts/create-pr.md +110 -0
  7. package/prompts/generate-bdd-from-url.md +65 -0
  8. package/prompts/generate-pom-from-url-cli.md +48 -0
  9. package/prompts/generate-pom-from-url.md +45 -0
  10. package/prompts/generate-spec-from-feature.md +64 -0
  11. package/prompts/heal-playwright-failures.md +106 -0
  12. package/prompts/setup-playwright.md +81 -0
  13. package/scripts/start-dino.cmd +8 -0
  14. package/scripts/start-dino.mjs +226 -0
  15. package/scripts/start-dino.ps1 +9 -0
  16. package/scripts/start-dino.sh +10 -0
  17. package/skills/acli/SKILL.md +292 -0
  18. package/skills/bdd-gherkin/SKILL.md +552 -0
  19. package/skills/build-review-interface/SKILL.md +96 -0
  20. package/skills/error-analysis/SKILL.md +164 -0
  21. package/skills/eval-audit/SKILL.md +183 -0
  22. package/skills/evaluate-rag/SKILL.md +177 -0
  23. package/skills/generate-synthetic-data/SKILL.md +131 -0
  24. package/skills/mabl-mcp/SKILL.md +79 -0
  25. package/skills/playwright-cli/SKILL.md +246 -0
  26. package/skills/playwright-cli/advanced-workflows.md +679 -0
  27. package/skills/playwright-cli/core-commands.md +375 -0
  28. package/skills/playwright-cli/device-emulation.md +475 -0
  29. package/skills/playwright-cli/request-mocking.md +373 -0
  30. package/skills/playwright-cli/running-custom-code.md +553 -0
  31. package/skills/playwright-cli/screenshots-and-media.md +457 -0
  32. package/skills/playwright-cli/session-management.md +425 -0
  33. package/skills/playwright-cli/storage-and-auth.md +487 -0
  34. package/skills/playwright-cli/test-generation.md +336 -0
  35. package/skills/playwright-cli/tracing-and-debugging.md +501 -0
  36. package/skills/playwright-core/SKILL.md +101 -0
  37. package/skills/playwright-core/accessibility.md +1517 -0
  38. package/skills/playwright-core/angular.md +1086 -0
  39. package/skills/playwright-core/api-testing.md +1623 -0
  40. package/skills/playwright-core/assertions-and-waiting.md +703 -0
  41. package/skills/playwright-core/auth-flows.md +1081 -0
  42. package/skills/playwright-core/authentication.md +1409 -0
  43. package/skills/playwright-core/browser-apis.md +673 -0
  44. package/skills/playwright-core/browser-extensions.md +319 -0
  45. package/skills/playwright-core/canvas-and-webgl.md +494 -0
  46. package/skills/playwright-core/clock-and-time-mocking.md +427 -0
  47. package/skills/playwright-core/common-pitfalls.md +1318 -0
  48. package/skills/playwright-core/component-testing.md +1179 -0
  49. package/skills/playwright-core/configuration.md +731 -0
  50. package/skills/playwright-core/crud-testing.md +945 -0
  51. package/skills/playwright-core/debugging.md +827 -0
  52. package/skills/playwright-core/drag-and-drop.md +992 -0
  53. package/skills/playwright-core/electron-testing.md +622 -0
  54. package/skills/playwright-core/error-and-edge-cases.md +1181 -0
  55. package/skills/playwright-core/error-index.md +1903 -0
  56. package/skills/playwright-core/file-operations.md +749 -0
  57. package/skills/playwright-core/file-upload-download.md +1012 -0
  58. package/skills/playwright-core/fixtures-and-hooks.md +1072 -0
  59. package/skills/playwright-core/flaky-tests.md +883 -0
  60. package/skills/playwright-core/forms-and-validation.md +1056 -0
  61. package/skills/playwright-core/i18n-and-localization.md +622 -0
  62. package/skills/playwright-core/iframes-and-shadow-dom.md +488 -0
  63. package/skills/playwright-core/locator-strategy.md +588 -0
  64. package/skills/playwright-core/locators.md +743 -0
  65. package/skills/playwright-core/mobile-and-responsive.md +1669 -0
  66. package/skills/playwright-core/multi-context-and-popups.md +581 -0
  67. package/skills/playwright-core/multi-user-and-collaboration.md +477 -0
  68. package/skills/playwright-core/network-mocking.md +1395 -0
  69. package/skills/playwright-core/nextjs.md +1013 -0
  70. package/skills/playwright-core/performance-testing.md +608 -0
  71. package/skills/playwright-core/react.md +1082 -0
  72. package/skills/playwright-core/search-and-filter.md +1366 -0
  73. package/skills/playwright-core/security-testing.md +624 -0
  74. package/skills/playwright-core/service-workers-and-pwa.md +524 -0
  75. package/skills/playwright-core/test-architecture.md +569 -0
  76. package/skills/playwright-core/test-data-management.md +1209 -0
  77. package/skills/playwright-core/test-organization.md +947 -0
  78. package/skills/playwright-core/third-party-integrations.md +754 -0
  79. package/skills/playwright-core/trace-analysis.md +161 -0
  80. package/skills/playwright-core/visual-regression.md +1027 -0
  81. package/skills/playwright-core/vue.md +1162 -0
  82. package/skills/playwright-core/websockets-and-realtime.md +623 -0
  83. package/skills/playwright-core/when-to-mock.md +827 -0
  84. package/skills/playwright-pom/SKILL.md +94 -0
  85. package/skills/playwright-pom/page-object-model.md +921 -0
  86. package/skills/playwright-pom/pom-vs-fixtures-vs-helpers.md +937 -0
  87. package/skills/validate-evaluator/SKILL.md +215 -0
  88. package/skills/write-judge-prompt/SKILL.md +144 -0
  89. package/themes/dino.json +66 -0
@@ -0,0 +1,1366 @@
1
+ # Search and Filter Recipes
2
+
3
+ > **When to use**: You need to test search inputs, filters (category, multi-select, date range), autocomplete suggestions, pagination with filters, or "no results" states.
4
+
5
+ ---
6
+
7
+ ## Recipe 1: Search Input with Results
8
+
9
+ ### Complete Example
10
+
11
+ **TypeScript**
12
+
13
+ ```typescript
14
+ import { test, expect } from '@playwright/test';
15
+
16
+ test('searches for a product and displays matching results', async ({ page }) => {
17
+ await page.goto('/products');
18
+
19
+ const searchInput = page.getByRole('searchbox', { name: /search/i });
20
+
21
+ // Type a search query
22
+ await searchInput.fill('wireless keyboard');
23
+
24
+ // Submit the search (press Enter or click the search button)
25
+ await searchInput.press('Enter');
26
+
27
+ // Wait for results to load
28
+ await expect(page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') }).first())
29
+ .toBeVisible();
30
+
31
+ // Verify results match the query
32
+ const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
33
+ const count = await rows.count();
34
+ expect(count).toBeGreaterThan(0);
35
+
36
+ // Each result should contain the search term
37
+ for (let i = 0; i < count; i++) {
38
+ await expect(rows.nth(i)).toContainText(/wireless|keyboard/i);
39
+ }
40
+
41
+ // Verify the search query is preserved in the input
42
+ await expect(searchInput).toHaveValue('wireless keyboard');
43
+
44
+ // Verify result count is displayed
45
+ await expect(page.getByText(/\d+ results? found/i)).toBeVisible();
46
+
47
+ // Verify the URL reflects the search
48
+ await expect(page).toHaveURL(/[?&]q=wireless\+keyboard/);
49
+ });
50
+
51
+ test('search is case-insensitive', async ({ page }) => {
52
+ await page.goto('/products');
53
+
54
+ const searchInput = page.getByRole('searchbox', { name: /search/i });
55
+
56
+ // Search with different cases
57
+ await searchInput.fill('WIRELESS KEYBOARD');
58
+ await searchInput.press('Enter');
59
+
60
+ const uppercaseResults = await page
61
+ .getByRole('row')
62
+ .filter({ hasNot: page.getByRole('columnheader') })
63
+ .count();
64
+
65
+ await searchInput.clear();
66
+ await searchInput.fill('wireless keyboard');
67
+ await searchInput.press('Enter');
68
+
69
+ const lowercaseResults = await page
70
+ .getByRole('row')
71
+ .filter({ hasNot: page.getByRole('columnheader') })
72
+ .count();
73
+
74
+ expect(uppercaseResults).toBe(lowercaseResults);
75
+ });
76
+
77
+ test('search preserves query on page reload', async ({ page }) => {
78
+ await page.goto('/products');
79
+
80
+ const searchInput = page.getByRole('searchbox', { name: /search/i });
81
+ await searchInput.fill('keyboard');
82
+ await searchInput.press('Enter');
83
+
84
+ await expect(page).toHaveURL(/[?&]q=keyboard/);
85
+
86
+ // Reload the page
87
+ await page.reload();
88
+
89
+ // Search input should still have the query
90
+ await expect(searchInput).toHaveValue('keyboard');
91
+
92
+ // Results should still be filtered
93
+ const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
94
+ await expect(rows.first()).toBeVisible();
95
+ });
96
+ ```
97
+
98
+ **JavaScript**
99
+
100
+ ```javascript
101
+ const { test, expect } = require('@playwright/test');
102
+
103
+ test('searches for a product and displays matching results', async ({ page }) => {
104
+ await page.goto('/products');
105
+
106
+ const searchInput = page.getByRole('searchbox', { name: /search/i });
107
+
108
+ await searchInput.fill('wireless keyboard');
109
+ await searchInput.press('Enter');
110
+
111
+ const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
112
+ const count = await rows.count();
113
+ expect(count).toBeGreaterThan(0);
114
+
115
+ for (let i = 0; i < count; i++) {
116
+ await expect(rows.nth(i)).toContainText(/wireless|keyboard/i);
117
+ }
118
+
119
+ await expect(searchInput).toHaveValue('wireless keyboard');
120
+ await expect(page).toHaveURL(/[?&]q=wireless\+keyboard/);
121
+ });
122
+ ```
123
+
124
+ ---
125
+
126
+ ## Recipe 2: Search with Debounce (Waiting for Results)
127
+
128
+ ### Complete Example
129
+
130
+ **TypeScript**
131
+
132
+ ```typescript
133
+ import { test, expect } from '@playwright/test';
134
+
135
+ test('debounced search triggers after user stops typing', async ({ page }) => {
136
+ await page.goto('/products');
137
+
138
+ const searchInput = page.getByRole('searchbox', { name: /search/i });
139
+ const resultsList = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
140
+
141
+ // Track API calls to verify debounce behavior
142
+ let apiCallCount = 0;
143
+ await page.route('**/api/products?*', async (route) => {
144
+ apiCallCount++;
145
+ await route.continue();
146
+ });
147
+
148
+ // Type quickly (simulating real user typing)
149
+ await searchInput.pressSequentially('keyboard', { delay: 50 });
150
+
151
+ // Wait for debounce to settle and results to appear
152
+ // The debounce delay is typically 300-500ms after the last keystroke
153
+ await page.waitForResponse('**/api/products?*');
154
+
155
+ // Only one API call should have been made (debounced), not one per keystroke
156
+ expect(apiCallCount).toBeLessThanOrEqual(2); // Allow for at most 2 calls
157
+
158
+ // Verify results loaded
159
+ await expect(resultsList.first()).toBeVisible();
160
+ });
161
+
162
+ test('shows loading indicator during debounced search', async ({ page }) => {
163
+ // Slow down the search API to observe loading state
164
+ await page.route('**/api/products?*', async (route) => {
165
+ await new Promise((resolve) => setTimeout(resolve, 1000));
166
+ await route.continue();
167
+ });
168
+
169
+ await page.goto('/products');
170
+
171
+ const searchInput = page.getByRole('searchbox', { name: /search/i });
172
+
173
+ await searchInput.fill('keyboard');
174
+
175
+ // Loading indicator should appear while waiting for results
176
+ await expect(page.getByRole('progressbar').or(page.getByText(/loading|searching/i))).toBeVisible();
177
+
178
+ // After results load, loading indicator disappears
179
+ await expect(page.getByRole('progressbar').or(page.getByText(/loading|searching/i))).not.toBeVisible({
180
+ timeout: 5000,
181
+ });
182
+
183
+ // Results should be visible
184
+ await expect(
185
+ page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') }).first()
186
+ ).toBeVisible();
187
+ });
188
+
189
+ test('clears search results when input is emptied', async ({ page }) => {
190
+ await page.goto('/products');
191
+
192
+ const searchInput = page.getByRole('searchbox', { name: /search/i });
193
+
194
+ // Search for something specific
195
+ await searchInput.fill('keyboard');
196
+ await searchInput.press('Enter');
197
+
198
+ const filteredCount = await page
199
+ .getByRole('row')
200
+ .filter({ hasNot: page.getByRole('columnheader') })
201
+ .count();
202
+
203
+ // Clear the search
204
+ await searchInput.clear();
205
+
206
+ // Wait for results to reset (debounce + API call)
207
+ await page.waitForResponse('**/api/products?*');
208
+
209
+ const unfilteredCount = await page
210
+ .getByRole('row')
211
+ .filter({ hasNot: page.getByRole('columnheader') })
212
+ .count();
213
+
214
+ // Unfiltered should have more or equal results
215
+ expect(unfilteredCount).toBeGreaterThanOrEqual(filteredCount);
216
+ });
217
+ ```
218
+
219
+ **JavaScript**
220
+
221
+ ```javascript
222
+ const { test, expect } = require('@playwright/test');
223
+
224
+ test('debounced search triggers after user stops typing', async ({ page }) => {
225
+ await page.goto('/products');
226
+
227
+ const searchInput = page.getByRole('searchbox', { name: /search/i });
228
+
229
+ let apiCallCount = 0;
230
+ await page.route('**/api/products?*', async (route) => {
231
+ apiCallCount++;
232
+ await route.continue();
233
+ });
234
+
235
+ await searchInput.pressSequentially('keyboard', { delay: 50 });
236
+
237
+ await page.waitForResponse('**/api/products?*');
238
+
239
+ expect(apiCallCount).toBeLessThanOrEqual(2);
240
+ });
241
+
242
+ test('shows loading indicator during debounced search', async ({ page }) => {
243
+ await page.route('**/api/products?*', async (route) => {
244
+ await new Promise((resolve) => setTimeout(resolve, 1000));
245
+ await route.continue();
246
+ });
247
+
248
+ await page.goto('/products');
249
+
250
+ const searchInput = page.getByRole('searchbox', { name: /search/i });
251
+ await searchInput.fill('keyboard');
252
+
253
+ await expect(page.getByRole('progressbar').or(page.getByText(/loading|searching/i))).toBeVisible();
254
+
255
+ await expect(page.getByRole('progressbar').or(page.getByText(/loading|searching/i))).not.toBeVisible({
256
+ timeout: 5000,
257
+ });
258
+ });
259
+ ```
260
+
261
+ ---
262
+
263
+ ## Recipe 3: Filter by Category
264
+
265
+ ### Complete Example
266
+
267
+ **TypeScript**
268
+
269
+ ```typescript
270
+ import { test, expect } from '@playwright/test';
271
+
272
+ test('filters products by a single category', async ({ page }) => {
273
+ await page.goto('/products');
274
+
275
+ // Get total count before filtering
276
+ const totalRows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
277
+ const totalCount = await totalRows.count();
278
+
279
+ // Select a category filter
280
+ await page.getByLabel('Category').selectOption('Electronics');
281
+
282
+ // Wait for filtered results
283
+ await page.waitForResponse('**/api/products?*');
284
+
285
+ // Verify fewer results (or at minimum, a different set)
286
+ const filteredRows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
287
+ const filteredCount = await filteredRows.count();
288
+
289
+ expect(filteredCount).toBeLessThanOrEqual(totalCount);
290
+ expect(filteredCount).toBeGreaterThan(0);
291
+
292
+ // Verify all results belong to the selected category
293
+ for (let i = 0; i < filteredCount; i++) {
294
+ await expect(filteredRows.nth(i)).toContainText('Electronics');
295
+ }
296
+
297
+ // Verify URL updated
298
+ await expect(page).toHaveURL(/[?&]category=electronics/i);
299
+
300
+ // Verify active filter is displayed
301
+ await expect(page.getByText(/filtered by.*electronics/i).or(
302
+ page.locator('[data-testid="active-filter"]').filter({ hasText: 'Electronics' })
303
+ )).toBeVisible();
304
+ });
305
+
306
+ test('filters by category using sidebar checkboxes', async ({ page }) => {
307
+ await page.goto('/products');
308
+
309
+ // Click a category checkbox in the sidebar
310
+ const sidebar = page.getByRole('complementary');
311
+ await sidebar.getByLabel('Electronics').check();
312
+
313
+ await page.waitForResponse('**/api/products?*');
314
+
315
+ const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
316
+ const count = await rows.count();
317
+
318
+ for (let i = 0; i < count; i++) {
319
+ await expect(rows.nth(i)).toContainText('Electronics');
320
+ }
321
+ });
322
+
323
+ test('changing category replaces previous filter', async ({ page }) => {
324
+ await page.goto('/products');
325
+
326
+ // Select first category
327
+ await page.getByLabel('Category').selectOption('Electronics');
328
+ await page.waitForResponse('**/api/products?*');
329
+
330
+ const electronicsCount = await page
331
+ .getByRole('row')
332
+ .filter({ hasNot: page.getByRole('columnheader') })
333
+ .count();
334
+
335
+ // Change to a different category
336
+ await page.getByLabel('Category').selectOption('Clothing');
337
+ await page.waitForResponse('**/api/products?*');
338
+
339
+ const clothingCount = await page
340
+ .getByRole('row')
341
+ .filter({ hasNot: page.getByRole('columnheader') })
342
+ .count();
343
+
344
+ // Results should be different
345
+ const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
346
+ for (let i = 0; i < clothingCount; i++) {
347
+ await expect(rows.nth(i)).toContainText('Clothing');
348
+ await expect(rows.nth(i)).not.toContainText('Electronics');
349
+ }
350
+ });
351
+ ```
352
+
353
+ **JavaScript**
354
+
355
+ ```javascript
356
+ const { test, expect } = require('@playwright/test');
357
+
358
+ test('filters products by a single category', async ({ page }) => {
359
+ await page.goto('/products');
360
+
361
+ const totalRows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
362
+ const totalCount = await totalRows.count();
363
+
364
+ await page.getByLabel('Category').selectOption('Electronics');
365
+
366
+ await page.waitForResponse('**/api/products?*');
367
+
368
+ const filteredRows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
369
+ const filteredCount = await filteredRows.count();
370
+
371
+ expect(filteredCount).toBeLessThanOrEqual(totalCount);
372
+
373
+ for (let i = 0; i < filteredCount; i++) {
374
+ await expect(filteredRows.nth(i)).toContainText('Electronics');
375
+ }
376
+
377
+ await expect(page).toHaveURL(/[?&]category=electronics/i);
378
+ });
379
+ ```
380
+
381
+ ---
382
+
383
+ ## Recipe 4: Multi-Select Filter
384
+
385
+ ### Complete Example
386
+
387
+ **TypeScript**
388
+
389
+ ```typescript
390
+ import { test, expect } from '@playwright/test';
391
+
392
+ test('filters by multiple categories simultaneously', async ({ page }) => {
393
+ await page.goto('/products');
394
+
395
+ const sidebar = page.getByRole('complementary');
396
+
397
+ // Select multiple category checkboxes
398
+ await sidebar.getByLabel('Electronics').check();
399
+ await sidebar.getByLabel('Clothing').check();
400
+
401
+ // Wait for results to update
402
+ await page.waitForResponse('**/api/products?*');
403
+
404
+ // Verify results include items from both categories
405
+ const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
406
+ const count = await rows.count();
407
+ expect(count).toBeGreaterThan(0);
408
+
409
+ // Collect all categories from results
410
+ const categories = new Set<string>();
411
+ for (let i = 0; i < count; i++) {
412
+ const text = await rows.nth(i).textContent();
413
+ if (text?.includes('Electronics')) categories.add('Electronics');
414
+ if (text?.includes('Clothing')) categories.add('Clothing');
415
+ }
416
+
417
+ // Should have results from both categories
418
+ expect(categories.has('Electronics')).toBe(true);
419
+ expect(categories.has('Clothing')).toBe(true);
420
+
421
+ // URL should reflect both filters
422
+ await expect(page).toHaveURL(/category=electronics/i);
423
+ await expect(page).toHaveURL(/category=clothing/i);
424
+
425
+ // Active filter chips should be visible
426
+ await expect(page.getByRole('button', { name: /Electronics.*×|Remove Electronics/i })).toBeVisible();
427
+ await expect(page.getByRole('button', { name: /Clothing.*×|Remove Clothing/i })).toBeVisible();
428
+ });
429
+
430
+ test('removes one filter from a multi-select filter', async ({ page }) => {
431
+ await page.goto('/products');
432
+
433
+ const sidebar = page.getByRole('complementary');
434
+
435
+ // Apply two filters
436
+ await sidebar.getByLabel('Electronics').check();
437
+ await sidebar.getByLabel('Clothing').check();
438
+ await page.waitForResponse('**/api/products?*');
439
+
440
+ const bothCount = await page
441
+ .getByRole('row')
442
+ .filter({ hasNot: page.getByRole('columnheader') })
443
+ .count();
444
+
445
+ // Remove one filter by clicking the filter chip's remove button
446
+ await page.getByRole('button', { name: /Clothing.*×|Remove Clothing/i }).click();
447
+ await page.waitForResponse('**/api/products?*');
448
+
449
+ // Verify only Electronics results remain
450
+ const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
451
+ const electronicsOnlyCount = await rows.count();
452
+
453
+ expect(electronicsOnlyCount).toBeLessThanOrEqual(bothCount);
454
+
455
+ for (let i = 0; i < electronicsOnlyCount; i++) {
456
+ await expect(rows.nth(i)).toContainText('Electronics');
457
+ }
458
+
459
+ // Clothing checkbox should be unchecked
460
+ await expect(sidebar.getByLabel('Clothing')).not.toBeChecked();
461
+ await expect(sidebar.getByLabel('Electronics')).toBeChecked();
462
+ });
463
+
464
+ test('multi-select dropdown filter', async ({ page }) => {
465
+ await page.goto('/products');
466
+
467
+ // Open the multi-select dropdown
468
+ await page.getByRole('combobox', { name: 'Tags' }).click();
469
+
470
+ // Select multiple options from the dropdown
471
+ await page.getByRole('option', { name: 'Featured' }).click();
472
+ await page.getByRole('option', { name: 'On Sale' }).click();
473
+
474
+ // Close the dropdown
475
+ await page.keyboard.press('Escape');
476
+
477
+ // Verify selected values displayed
478
+ await expect(page.getByText('Featured')).toBeVisible();
479
+ await expect(page.getByText('On Sale')).toBeVisible();
480
+
481
+ // Wait for results
482
+ await page.waitForResponse('**/api/products?*');
483
+
484
+ const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
485
+ await expect(rows.first()).toBeVisible();
486
+ });
487
+ ```
488
+
489
+ **JavaScript**
490
+
491
+ ```javascript
492
+ const { test, expect } = require('@playwright/test');
493
+
494
+ test('filters by multiple categories simultaneously', async ({ page }) => {
495
+ await page.goto('/products');
496
+
497
+ const sidebar = page.getByRole('complementary');
498
+
499
+ await sidebar.getByLabel('Electronics').check();
500
+ await sidebar.getByLabel('Clothing').check();
501
+
502
+ await page.waitForResponse('**/api/products?*');
503
+
504
+ const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
505
+ const count = await rows.count();
506
+ expect(count).toBeGreaterThan(0);
507
+
508
+ await expect(page).toHaveURL(/category=electronics/i);
509
+ await expect(page).toHaveURL(/category=clothing/i);
510
+ });
511
+
512
+ test('removes one filter from a multi-select filter', async ({ page }) => {
513
+ await page.goto('/products');
514
+
515
+ const sidebar = page.getByRole('complementary');
516
+
517
+ await sidebar.getByLabel('Electronics').check();
518
+ await sidebar.getByLabel('Clothing').check();
519
+ await page.waitForResponse('**/api/products?*');
520
+
521
+ await page.getByRole('button', { name: /Clothing.*×|Remove Clothing/i }).click();
522
+ await page.waitForResponse('**/api/products?*');
523
+
524
+ const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
525
+ const count = await rows.count();
526
+
527
+ for (let i = 0; i < count; i++) {
528
+ await expect(rows.nth(i)).toContainText('Electronics');
529
+ }
530
+ });
531
+ ```
532
+
533
+ ---
534
+
535
+ ## Recipe 5: Date Range Filter
536
+
537
+ ### Complete Example
538
+
539
+ **TypeScript**
540
+
541
+ ```typescript
542
+ import { test, expect } from '@playwright/test';
543
+
544
+ test('filters records by date range', async ({ page }) => {
545
+ await page.goto('/orders');
546
+
547
+ // Fill in the date range inputs
548
+ await page.getByLabel('From date').fill('2024-01-01');
549
+ await page.getByLabel('To date').fill('2024-03-31');
550
+
551
+ // Apply the filter
552
+ await page.getByRole('button', { name: 'Apply filter' }).click();
553
+
554
+ // Wait for filtered results
555
+ await page.waitForResponse('**/api/orders?*');
556
+
557
+ // Verify all results fall within the date range
558
+ const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
559
+ const count = await rows.count();
560
+ expect(count).toBeGreaterThan(0);
561
+
562
+ // Check that each row's date is within the range
563
+ for (let i = 0; i < count; i++) {
564
+ const dateCell = rows.nth(i).getByRole('cell').nth(3); // Assuming date is the 4th column
565
+ const dateText = await dateCell.textContent();
566
+ const date = new Date(dateText!.trim());
567
+ expect(date.getTime()).toBeGreaterThanOrEqual(new Date('2024-01-01').getTime());
568
+ expect(date.getTime()).toBeLessThanOrEqual(new Date('2024-03-31T23:59:59').getTime());
569
+ }
570
+
571
+ // Verify URL updated with date params
572
+ await expect(page).toHaveURL(/from=2024-01-01/);
573
+ await expect(page).toHaveURL(/to=2024-03-31/);
574
+ });
575
+
576
+ test('filters using a date picker component', async ({ page }) => {
577
+ await page.goto('/orders');
578
+
579
+ // Open the date picker
580
+ await page.getByRole('button', { name: /date range|calendar/i }).click();
581
+
582
+ // Navigate to January 2024
583
+ const calendar = page.getByRole('dialog');
584
+ await expect(calendar).toBeVisible();
585
+
586
+ // Select the month/year via navigation
587
+ while (!(await calendar.getByText('January 2024').isVisible())) {
588
+ await calendar.getByRole('button', { name: /previous month/i }).click();
589
+ }
590
+
591
+ // Click start date
592
+ await calendar.getByRole('gridcell', { name: '15' }).click();
593
+
594
+ // Click end date
595
+ await calendar.getByRole('gridcell', { name: '28' }).click();
596
+
597
+ // Apply
598
+ await calendar.getByRole('button', { name: 'Apply' }).click();
599
+ await expect(calendar).not.toBeVisible();
600
+
601
+ // Verify the date range is displayed
602
+ await expect(page.getByText(/Jan 15.*Jan 28/i)).toBeVisible();
603
+
604
+ await page.waitForResponse('**/api/orders?*');
605
+
606
+ const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
607
+ await expect(rows.first()).toBeVisible();
608
+ });
609
+
610
+ test('shows error for invalid date range', async ({ page }) => {
611
+ await page.goto('/orders');
612
+
613
+ // Set end date before start date
614
+ await page.getByLabel('From date').fill('2024-06-01');
615
+ await page.getByLabel('To date').fill('2024-01-01');
616
+
617
+ await page.getByRole('button', { name: 'Apply filter' }).click();
618
+
619
+ await expect(page.getByText(/end date.*before.*start date|invalid.*range/i)).toBeVisible();
620
+ });
621
+
622
+ test('uses preset date ranges', async ({ page }) => {
623
+ await page.goto('/orders');
624
+
625
+ // Click a preset date range shortcut
626
+ await page.getByRole('button', { name: 'Last 30 days' }).click();
627
+
628
+ await page.waitForResponse('**/api/orders?*');
629
+
630
+ // Verify the from date input reflects approximately 30 days ago
631
+ const fromValue = await page.getByLabel('From date').inputValue();
632
+ const fromDate = new Date(fromValue);
633
+ const now = new Date();
634
+ const diffDays = Math.round((now.getTime() - fromDate.getTime()) / (1000 * 60 * 60 * 24));
635
+
636
+ expect(diffDays).toBeGreaterThanOrEqual(29);
637
+ expect(diffDays).toBeLessThanOrEqual(31);
638
+
639
+ const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
640
+ await expect(rows.first()).toBeVisible();
641
+ });
642
+ ```
643
+
644
+ **JavaScript**
645
+
646
+ ```javascript
647
+ const { test, expect } = require('@playwright/test');
648
+
649
+ test('filters records by date range', async ({ page }) => {
650
+ await page.goto('/orders');
651
+
652
+ await page.getByLabel('From date').fill('2024-01-01');
653
+ await page.getByLabel('To date').fill('2024-03-31');
654
+ await page.getByRole('button', { name: 'Apply filter' }).click();
655
+
656
+ await page.waitForResponse('**/api/orders?*');
657
+
658
+ const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
659
+ const count = await rows.count();
660
+ expect(count).toBeGreaterThan(0);
661
+
662
+ await expect(page).toHaveURL(/from=2024-01-01/);
663
+ await expect(page).toHaveURL(/to=2024-03-31/);
664
+ });
665
+
666
+ test('uses preset date ranges', async ({ page }) => {
667
+ await page.goto('/orders');
668
+
669
+ await page.getByRole('button', { name: 'Last 30 days' }).click();
670
+
671
+ await page.waitForResponse('**/api/orders?*');
672
+
673
+ const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
674
+ await expect(rows.first()).toBeVisible();
675
+ });
676
+ ```
677
+
678
+ ---
679
+
680
+ ## Recipe 6: Clear All Filters
681
+
682
+ ### Complete Example
683
+
684
+ **TypeScript**
685
+
686
+ ```typescript
687
+ import { test, expect } from '@playwright/test';
688
+
689
+ test('clears all active filters at once', async ({ page }) => {
690
+ await page.goto('/products');
691
+
692
+ const sidebar = page.getByRole('complementary');
693
+
694
+ // Apply multiple filters
695
+ await sidebar.getByLabel('Electronics').check();
696
+ await page.getByLabel('Min price').fill('50');
697
+ await page.getByLabel('Max price').fill('200');
698
+ await page.getByRole('searchbox', { name: /search/i }).fill('keyboard');
699
+ await page.getByRole('searchbox', { name: /search/i }).press('Enter');
700
+
701
+ // Wait for filtered results
702
+ await page.waitForResponse('**/api/products?*');
703
+
704
+ const filteredCount = await page
705
+ .getByRole('row')
706
+ .filter({ hasNot: page.getByRole('columnheader') })
707
+ .count();
708
+
709
+ // Verify filters are active
710
+ await expect(page.getByText(/\d+ active filter/i)).toBeVisible();
711
+
712
+ // Click "Clear all filters"
713
+ await page.getByRole('button', { name: /clear all|reset filters/i }).click();
714
+
715
+ // Wait for unfiltered results
716
+ await page.waitForResponse('**/api/products?*');
717
+
718
+ // Verify all filters are reset
719
+ await expect(sidebar.getByLabel('Electronics')).not.toBeChecked();
720
+ await expect(page.getByLabel('Min price')).toHaveValue('');
721
+ await expect(page.getByLabel('Max price')).toHaveValue('');
722
+ await expect(page.getByRole('searchbox', { name: /search/i })).toHaveValue('');
723
+
724
+ // Verify more results are shown
725
+ const unfilteredCount = await page
726
+ .getByRole('row')
727
+ .filter({ hasNot: page.getByRole('columnheader') })
728
+ .count();
729
+
730
+ expect(unfilteredCount).toBeGreaterThanOrEqual(filteredCount);
731
+
732
+ // Verify URL is clean
733
+ await expect(page).toHaveURL('/products');
734
+
735
+ // Verify "active filters" indicator is gone
736
+ await expect(page.getByText(/active filter/i)).not.toBeVisible();
737
+ });
738
+
739
+ test('clear all button only appears when filters are active', async ({ page }) => {
740
+ await page.goto('/products');
741
+
742
+ // No filters active -- clear button should not be visible
743
+ await expect(page.getByRole('button', { name: /clear all|reset filters/i })).not.toBeVisible();
744
+
745
+ // Apply a filter
746
+ await page.getByRole('complementary').getByLabel('Electronics').check();
747
+ await page.waitForResponse('**/api/products?*');
748
+
749
+ // Clear button should now be visible
750
+ await expect(page.getByRole('button', { name: /clear all|reset filters/i })).toBeVisible();
751
+
752
+ // Remove the filter manually
753
+ await page.getByRole('complementary').getByLabel('Electronics').uncheck();
754
+ await page.waitForResponse('**/api/products?*');
755
+
756
+ // Clear button should disappear again
757
+ await expect(page.getByRole('button', { name: /clear all|reset filters/i })).not.toBeVisible();
758
+ });
759
+ ```
760
+
761
+ **JavaScript**
762
+
763
+ ```javascript
764
+ const { test, expect } = require('@playwright/test');
765
+
766
+ test('clears all active filters at once', async ({ page }) => {
767
+ await page.goto('/products');
768
+
769
+ const sidebar = page.getByRole('complementary');
770
+
771
+ await sidebar.getByLabel('Electronics').check();
772
+ await page.getByLabel('Min price').fill('50');
773
+ await page.getByLabel('Max price').fill('200');
774
+ await page.getByRole('searchbox', { name: /search/i }).fill('keyboard');
775
+ await page.getByRole('searchbox', { name: /search/i }).press('Enter');
776
+
777
+ await page.waitForResponse('**/api/products?*');
778
+
779
+ await page.getByRole('button', { name: /clear all|reset filters/i }).click();
780
+
781
+ await page.waitForResponse('**/api/products?*');
782
+
783
+ await expect(sidebar.getByLabel('Electronics')).not.toBeChecked();
784
+ await expect(page.getByLabel('Min price')).toHaveValue('');
785
+ await expect(page.getByLabel('Max price')).toHaveValue('');
786
+ await expect(page.getByRole('searchbox', { name: /search/i })).toHaveValue('');
787
+
788
+ await expect(page).toHaveURL('/products');
789
+ });
790
+ ```
791
+
792
+ ---
793
+
794
+ ## Recipe 7: Search with Autocomplete / Suggestions
795
+
796
+ ### Complete Example
797
+
798
+ **TypeScript**
799
+
800
+ ```typescript
801
+ import { test, expect } from '@playwright/test';
802
+
803
+ test('shows autocomplete suggestions while typing', async ({ page }) => {
804
+ await page.goto('/products');
805
+
806
+ const searchInput = page.getByRole('combobox', { name: /search/i });
807
+
808
+ // Type a partial query
809
+ await searchInput.fill('key');
810
+
811
+ // Suggestions dropdown should appear
812
+ const suggestions = page.getByRole('listbox');
813
+ await expect(suggestions).toBeVisible();
814
+
815
+ // Verify suggestions contain the typed text
816
+ const options = suggestions.getByRole('option');
817
+ const count = await options.count();
818
+ expect(count).toBeGreaterThan(0);
819
+
820
+ for (let i = 0; i < count; i++) {
821
+ await expect(options.nth(i)).toContainText(/key/i);
822
+ }
823
+ });
824
+
825
+ test('selects a suggestion with click', async ({ page }) => {
826
+ await page.goto('/products');
827
+
828
+ const searchInput = page.getByRole('combobox', { name: /search/i });
829
+ await searchInput.fill('key');
830
+
831
+ const suggestions = page.getByRole('listbox');
832
+ await expect(suggestions).toBeVisible();
833
+
834
+ // Click a specific suggestion
835
+ await suggestions.getByRole('option', { name: /Wireless Keyboard/i }).click();
836
+
837
+ // Suggestions should close
838
+ await expect(suggestions).not.toBeVisible();
839
+
840
+ // Search input should contain the selected suggestion
841
+ await expect(searchInput).toHaveValue('Wireless Keyboard');
842
+
843
+ // Results should be filtered to the selected item
844
+ await expect(page.getByRole('row', { name: /Wireless Keyboard/i })).toBeVisible();
845
+ });
846
+
847
+ test('navigates suggestions with keyboard', async ({ page }) => {
848
+ await page.goto('/products');
849
+
850
+ const searchInput = page.getByRole('combobox', { name: /search/i });
851
+ await searchInput.fill('key');
852
+
853
+ const suggestions = page.getByRole('listbox');
854
+ await expect(suggestions).toBeVisible();
855
+
856
+ // Navigate down with arrow keys
857
+ await searchInput.press('ArrowDown');
858
+ const firstOption = suggestions.getByRole('option').first();
859
+ await expect(firstOption).toHaveAttribute('aria-selected', 'true');
860
+
861
+ await searchInput.press('ArrowDown');
862
+ const secondOption = suggestions.getByRole('option').nth(1);
863
+ await expect(secondOption).toHaveAttribute('aria-selected', 'true');
864
+ await expect(firstOption).toHaveAttribute('aria-selected', 'false');
865
+
866
+ // Select with Enter
867
+ await searchInput.press('Enter');
868
+
869
+ // Suggestions should close and value should be selected
870
+ await expect(suggestions).not.toBeVisible();
871
+
872
+ const selectedText = await searchInput.inputValue();
873
+ expect(selectedText.length).toBeGreaterThan(3); // More than just "key"
874
+ });
875
+
876
+ test('closes suggestions with Escape key', async ({ page }) => {
877
+ await page.goto('/products');
878
+
879
+ const searchInput = page.getByRole('combobox', { name: /search/i });
880
+ await searchInput.fill('key');
881
+
882
+ const suggestions = page.getByRole('listbox');
883
+ await expect(suggestions).toBeVisible();
884
+
885
+ await searchInput.press('Escape');
886
+
887
+ await expect(suggestions).not.toBeVisible();
888
+
889
+ // Input should retain the typed text
890
+ await expect(searchInput).toHaveValue('key');
891
+ });
892
+
893
+ test('shows recent searches when input is focused', async ({ page }) => {
894
+ await page.goto('/products');
895
+
896
+ const searchInput = page.getByRole('combobox', { name: /search/i });
897
+
898
+ // Focus the input without typing
899
+ await searchInput.click();
900
+
901
+ // Should show recent searches or popular suggestions
902
+ const suggestions = page.getByRole('listbox');
903
+ await expect(suggestions).toBeVisible();
904
+ await expect(suggestions.getByText(/recent|popular/i)).toBeVisible();
905
+ });
906
+ ```
907
+
908
+ **JavaScript**
909
+
910
+ ```javascript
911
+ const { test, expect } = require('@playwright/test');
912
+
913
+ test('shows autocomplete suggestions while typing', async ({ page }) => {
914
+ await page.goto('/products');
915
+
916
+ const searchInput = page.getByRole('combobox', { name: /search/i });
917
+ await searchInput.fill('key');
918
+
919
+ const suggestions = page.getByRole('listbox');
920
+ await expect(suggestions).toBeVisible();
921
+
922
+ const options = suggestions.getByRole('option');
923
+ const count = await options.count();
924
+ expect(count).toBeGreaterThan(0);
925
+
926
+ for (let i = 0; i < count; i++) {
927
+ await expect(options.nth(i)).toContainText(/key/i);
928
+ }
929
+ });
930
+
931
+ test('selects a suggestion with click', async ({ page }) => {
932
+ await page.goto('/products');
933
+
934
+ const searchInput = page.getByRole('combobox', { name: /search/i });
935
+ await searchInput.fill('key');
936
+
937
+ const suggestions = page.getByRole('listbox');
938
+ await suggestions.getByRole('option', { name: /Wireless Keyboard/i }).click();
939
+
940
+ await expect(suggestions).not.toBeVisible();
941
+ await expect(searchInput).toHaveValue('Wireless Keyboard');
942
+ });
943
+
944
+ test('navigates suggestions with keyboard', async ({ page }) => {
945
+ await page.goto('/products');
946
+
947
+ const searchInput = page.getByRole('combobox', { name: /search/i });
948
+ await searchInput.fill('key');
949
+
950
+ const suggestions = page.getByRole('listbox');
951
+ await expect(suggestions).toBeVisible();
952
+
953
+ await searchInput.press('ArrowDown');
954
+ await searchInput.press('ArrowDown');
955
+ await searchInput.press('Enter');
956
+
957
+ await expect(suggestions).not.toBeVisible();
958
+ });
959
+ ```
960
+
961
+ ---
962
+
963
+ ## Recipe 8: No Results State
964
+
965
+ ### Complete Example
966
+
967
+ **TypeScript**
968
+
969
+ ```typescript
970
+ import { test, expect } from '@playwright/test';
971
+
972
+ test('shows "no results" state for unmatched search', async ({ page }) => {
973
+ await page.goto('/products');
974
+
975
+ const searchInput = page.getByRole('searchbox', { name: /search/i });
976
+ await searchInput.fill('xyznonexistentproduct123');
977
+ await searchInput.press('Enter');
978
+
979
+ // Wait for the API response
980
+ await page.waitForResponse('**/api/products?*');
981
+
982
+ // Verify no results message is displayed
983
+ await expect(page.getByText(/no results|no products found|nothing found/i)).toBeVisible();
984
+
985
+ // Table/list should be empty (no data rows)
986
+ const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
987
+ await expect(rows).toHaveCount(0);
988
+
989
+ // Verify helpful suggestions are shown
990
+ await expect(
991
+ page.getByText(/try different|broaden your search|check spelling/i)
992
+ ).toBeVisible();
993
+
994
+ // Verify a "clear search" link or button is available
995
+ await expect(
996
+ page.getByRole('button', { name: /clear search|show all/i }).or(
997
+ page.getByRole('link', { name: /clear search|show all/i })
998
+ )
999
+ ).toBeVisible();
1000
+ });
1001
+
1002
+ test('no results state with active filters offers to clear them', async ({ page }) => {
1003
+ await page.goto('/products');
1004
+
1005
+ // Apply a very restrictive filter combination
1006
+ await page.getByRole('complementary').getByLabel('Electronics').check();
1007
+ await page.getByLabel('Min price').fill('99999');
1008
+ await page.getByRole('button', { name: 'Apply filter' }).click();
1009
+
1010
+ await page.waitForResponse('**/api/products?*');
1011
+
1012
+ // Verify the no results state mentions the active filters
1013
+ await expect(page.getByText(/no results|no products found/i)).toBeVisible();
1014
+ await expect(page.getByRole('button', { name: /clear filters|reset/i })).toBeVisible();
1015
+
1016
+ // Click clear filters and verify results appear
1017
+ await page.getByRole('button', { name: /clear filters|reset/i }).click();
1018
+
1019
+ await page.waitForResponse('**/api/products?*');
1020
+
1021
+ const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
1022
+ expect(await rows.count()).toBeGreaterThan(0);
1023
+ });
1024
+
1025
+ test('no results state illustration is displayed', async ({ page }) => {
1026
+ await page.goto('/products');
1027
+
1028
+ await page.getByRole('searchbox', { name: /search/i }).fill('zzzznotfound');
1029
+ await page.getByRole('searchbox', { name: /search/i }).press('Enter');
1030
+
1031
+ await page.waitForResponse('**/api/products?*');
1032
+
1033
+ // Verify an illustration or icon is shown
1034
+ await expect(
1035
+ page.getByRole('img', { name: /no results|empty/i }).or(
1036
+ page.locator('[data-testid="empty-state-illustration"]')
1037
+ )
1038
+ ).toBeVisible();
1039
+ });
1040
+ ```
1041
+
1042
+ **JavaScript**
1043
+
1044
+ ```javascript
1045
+ const { test, expect } = require('@playwright/test');
1046
+
1047
+ test('shows "no results" state for unmatched search', async ({ page }) => {
1048
+ await page.goto('/products');
1049
+
1050
+ const searchInput = page.getByRole('searchbox', { name: /search/i });
1051
+ await searchInput.fill('xyznonexistentproduct123');
1052
+ await searchInput.press('Enter');
1053
+
1054
+ await page.waitForResponse('**/api/products?*');
1055
+
1056
+ await expect(page.getByText(/no results|no products found|nothing found/i)).toBeVisible();
1057
+
1058
+ const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
1059
+ await expect(rows).toHaveCount(0);
1060
+
1061
+ await expect(page.getByText(/try different|broaden your search/i)).toBeVisible();
1062
+ });
1063
+
1064
+ test('no results state with filters offers to clear them', async ({ page }) => {
1065
+ await page.goto('/products');
1066
+
1067
+ await page.getByRole('complementary').getByLabel('Electronics').check();
1068
+ await page.getByLabel('Min price').fill('99999');
1069
+ await page.getByRole('button', { name: 'Apply filter' }).click();
1070
+
1071
+ await page.waitForResponse('**/api/products?*');
1072
+
1073
+ await expect(page.getByText(/no results|no products found/i)).toBeVisible();
1074
+
1075
+ await page.getByRole('button', { name: /clear filters|reset/i }).click();
1076
+ await page.waitForResponse('**/api/products?*');
1077
+
1078
+ const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
1079
+ expect(await rows.count()).toBeGreaterThan(0);
1080
+ });
1081
+ ```
1082
+
1083
+ ---
1084
+
1085
+ ## Recipe 9: Pagination with Filters
1086
+
1087
+ ### Complete Example
1088
+
1089
+ **TypeScript**
1090
+
1091
+ ```typescript
1092
+ import { test, expect } from '@playwright/test';
1093
+
1094
+ test('pagination resets to page 1 when a filter is applied', async ({ page }) => {
1095
+ await page.goto('/products');
1096
+
1097
+ const pagination = page.getByRole('navigation', { name: /pagination/i });
1098
+
1099
+ // Navigate to page 3
1100
+ await pagination.getByRole('button', { name: '3' }).click();
1101
+ await expect(page).toHaveURL(/page=3/);
1102
+
1103
+ // Apply a filter
1104
+ await page.getByRole('complementary').getByLabel('Electronics').check();
1105
+ await page.waitForResponse('**/api/products?*');
1106
+
1107
+ // Should reset to page 1
1108
+ await expect(page).toHaveURL(/page=1|(?!.*page=)/);
1109
+ await expect(pagination.getByRole('button', { name: '1' })).toHaveAttribute(
1110
+ 'aria-current',
1111
+ 'page'
1112
+ );
1113
+ });
1114
+
1115
+ test('filters persist across pagination', async ({ page }) => {
1116
+ await page.goto('/products');
1117
+
1118
+ // Apply a filter
1119
+ await page.getByRole('complementary').getByLabel('Electronics').check();
1120
+ await page.waitForResponse('**/api/products?*');
1121
+
1122
+ // Get page 1 results
1123
+ const page1FirstItem = await page
1124
+ .getByRole('row')
1125
+ .filter({ hasNot: page.getByRole('columnheader') })
1126
+ .first()
1127
+ .textContent();
1128
+
1129
+ // Navigate to page 2
1130
+ const pagination = page.getByRole('navigation', { name: /pagination/i });
1131
+ await pagination.getByRole('button', { name: '2' }).click();
1132
+ await page.waitForResponse('**/api/products?*');
1133
+
1134
+ // Verify the filter is still applied (URL has both params)
1135
+ await expect(page).toHaveURL(/category=electronics/i);
1136
+ await expect(page).toHaveURL(/page=2/);
1137
+
1138
+ // Verify checkbox is still checked
1139
+ await expect(page.getByRole('complementary').getByLabel('Electronics')).toBeChecked();
1140
+
1141
+ // Verify page 2 data is different from page 1
1142
+ const page2FirstItem = await page
1143
+ .getByRole('row')
1144
+ .filter({ hasNot: page.getByRole('columnheader') })
1145
+ .first()
1146
+ .textContent();
1147
+
1148
+ expect(page2FirstItem).not.toBe(page1FirstItem);
1149
+
1150
+ // All results on page 2 should still match the filter
1151
+ const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
1152
+ const count = await rows.count();
1153
+ for (let i = 0; i < count; i++) {
1154
+ await expect(rows.nth(i)).toContainText('Electronics');
1155
+ }
1156
+ });
1157
+
1158
+ test('total result count updates with filters', async ({ page }) => {
1159
+ await page.goto('/products');
1160
+
1161
+ // Get unfiltered total
1162
+ const totalText = await page.getByText(/showing.*of \d+/i).textContent();
1163
+ const totalMatch = totalText?.match(/of (\d+)/);
1164
+ const totalCount = parseInt(totalMatch?.[1] || '0');
1165
+
1166
+ // Apply a filter
1167
+ await page.getByRole('complementary').getByLabel('Electronics').check();
1168
+ await page.waitForResponse('**/api/products?*');
1169
+
1170
+ // Total count should be less or equal
1171
+ const filteredText = await page.getByText(/showing.*of \d+/i).textContent();
1172
+ const filteredMatch = filteredText?.match(/of (\d+)/);
1173
+ const filteredCount = parseInt(filteredMatch?.[1] || '0');
1174
+
1175
+ expect(filteredCount).toBeLessThanOrEqual(totalCount);
1176
+
1177
+ // Number of pagination buttons may also decrease
1178
+ const paginationButtons = page
1179
+ .getByRole('navigation', { name: /pagination/i })
1180
+ .getByRole('button')
1181
+ .filter({ hasNotText: /previous|next|prev|›|‹/i });
1182
+
1183
+ const pageCount = await paginationButtons.count();
1184
+ expect(pageCount).toBeGreaterThan(0);
1185
+ });
1186
+
1187
+ test('deep link with filters and page works correctly', async ({ page }) => {
1188
+ // Navigate directly to a filtered, paginated URL
1189
+ await page.goto('/products?category=electronics&q=wireless&page=2');
1190
+
1191
+ // Verify all filter controls reflect the URL state
1192
+ await expect(page.getByRole('complementary').getByLabel('Electronics')).toBeChecked();
1193
+ await expect(page.getByRole('searchbox', { name: /search/i })).toHaveValue('wireless');
1194
+
1195
+ const pagination = page.getByRole('navigation', { name: /pagination/i });
1196
+ await expect(pagination.getByRole('button', { name: '2' })).toHaveAttribute(
1197
+ 'aria-current',
1198
+ 'page'
1199
+ );
1200
+
1201
+ // Verify results match the filters
1202
+ const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
1203
+ await expect(rows.first()).toBeVisible();
1204
+ });
1205
+ ```
1206
+
1207
+ **JavaScript**
1208
+
1209
+ ```javascript
1210
+ const { test, expect } = require('@playwright/test');
1211
+
1212
+ test('pagination resets to page 1 when a filter is applied', async ({ page }) => {
1213
+ await page.goto('/products');
1214
+
1215
+ const pagination = page.getByRole('navigation', { name: /pagination/i });
1216
+
1217
+ await pagination.getByRole('button', { name: '3' }).click();
1218
+ await expect(page).toHaveURL(/page=3/);
1219
+
1220
+ await page.getByRole('complementary').getByLabel('Electronics').check();
1221
+ await page.waitForResponse('**/api/products?*');
1222
+
1223
+ await expect(page).toHaveURL(/page=1|(?!.*page=)/);
1224
+ });
1225
+
1226
+ test('filters persist across pagination', async ({ page }) => {
1227
+ await page.goto('/products');
1228
+
1229
+ await page.getByRole('complementary').getByLabel('Electronics').check();
1230
+ await page.waitForResponse('**/api/products?*');
1231
+
1232
+ const pagination = page.getByRole('navigation', { name: /pagination/i });
1233
+ await pagination.getByRole('button', { name: '2' }).click();
1234
+ await page.waitForResponse('**/api/products?*');
1235
+
1236
+ await expect(page).toHaveURL(/category=electronics/i);
1237
+ await expect(page).toHaveURL(/page=2/);
1238
+
1239
+ await expect(page.getByRole('complementary').getByLabel('Electronics')).toBeChecked();
1240
+
1241
+ const rows = page.getByRole('row').filter({ hasNot: page.getByRole('columnheader') });
1242
+ const count = await rows.count();
1243
+ for (let i = 0; i < count; i++) {
1244
+ await expect(rows.nth(i)).toContainText('Electronics');
1245
+ }
1246
+ });
1247
+
1248
+ test('deep link with filters and page works correctly', async ({ page }) => {
1249
+ await page.goto('/products?category=electronics&q=wireless&page=2');
1250
+
1251
+ await expect(page.getByRole('complementary').getByLabel('Electronics')).toBeChecked();
1252
+ await expect(page.getByRole('searchbox', { name: /search/i })).toHaveValue('wireless');
1253
+
1254
+ const pagination = page.getByRole('navigation', { name: /pagination/i });
1255
+ await expect(pagination.getByRole('button', { name: '2' })).toHaveAttribute(
1256
+ 'aria-current',
1257
+ 'page'
1258
+ );
1259
+ });
1260
+ ```
1261
+
1262
+ ---
1263
+
1264
+ ## Variations
1265
+
1266
+ ### Search with Highlight of Matching Terms
1267
+
1268
+ ```typescript
1269
+ test('highlights matching terms in search results', async ({ page }) => {
1270
+ await page.goto('/products');
1271
+
1272
+ await page.getByRole('searchbox', { name: /search/i }).fill('wireless');
1273
+ await page.getByRole('searchbox', { name: /search/i }).press('Enter');
1274
+
1275
+ await page.waitForResponse('**/api/products?*');
1276
+
1277
+ // Verify matching text is wrapped in a highlight element
1278
+ const highlights = page.locator('mark, .highlight, [data-highlight]');
1279
+ const count = await highlights.count();
1280
+ expect(count).toBeGreaterThan(0);
1281
+
1282
+ // Each highlight should contain the search term
1283
+ for (let i = 0; i < count; i++) {
1284
+ await expect(highlights.nth(i)).toContainText(/wireless/i);
1285
+ }
1286
+ });
1287
+ ```
1288
+
1289
+ ### Search with URL Query Params for Sharing
1290
+
1291
+ ```typescript
1292
+ test('search results are shareable via URL', async ({ page, context }) => {
1293
+ await page.goto('/products');
1294
+
1295
+ await page.getByRole('searchbox', { name: /search/i }).fill('keyboard');
1296
+ await page.getByRole('complementary').getByLabel('Electronics').check();
1297
+ await page.getByRole('searchbox', { name: /search/i }).press('Enter');
1298
+
1299
+ await page.waitForResponse('**/api/products?*');
1300
+
1301
+ // Get the current URL
1302
+ const searchUrl = page.url();
1303
+
1304
+ // Open the same URL in a new page (simulating sharing the link)
1305
+ const newPage = await context.newPage();
1306
+ await newPage.goto(searchUrl);
1307
+
1308
+ // Verify the same filters and results are shown
1309
+ await expect(newPage.getByRole('searchbox', { name: /search/i })).toHaveValue('keyboard');
1310
+ await expect(newPage.getByRole('complementary').getByLabel('Electronics')).toBeChecked();
1311
+
1312
+ const rows = newPage.getByRole('row').filter({ hasNot: newPage.getByRole('columnheader') });
1313
+ await expect(rows.first()).toBeVisible();
1314
+
1315
+ await newPage.close();
1316
+ });
1317
+ ```
1318
+
1319
+ ### Faceted Search with Result Counts
1320
+
1321
+ ```typescript
1322
+ test('filter options show result counts', async ({ page }) => {
1323
+ await page.goto('/products');
1324
+
1325
+ const sidebar = page.getByRole('complementary');
1326
+
1327
+ // Verify each filter option shows a count
1328
+ await expect(sidebar.getByText(/Electronics \(\d+\)/)).toBeVisible();
1329
+ await expect(sidebar.getByText(/Clothing \(\d+\)/)).toBeVisible();
1330
+ await expect(sidebar.getByText(/Books \(\d+\)/)).toBeVisible();
1331
+
1332
+ // Apply a filter and verify counts update
1333
+ await sidebar.getByLabel('Electronics').check();
1334
+ await page.waitForResponse('**/api/products?*');
1335
+
1336
+ // Other category counts should update to reflect cross-filtering
1337
+ const clothingText = await sidebar.getByText(/Clothing \(\d+\)/).textContent();
1338
+ const clothingCount = parseInt(clothingText?.match(/\((\d+)\)/)?.[1] || '0');
1339
+
1340
+ // The counts should reflect what would happen if that filter were added
1341
+ expect(clothingCount).toBeGreaterThanOrEqual(0);
1342
+ });
1343
+ ```
1344
+
1345
+ ---
1346
+
1347
+ ## Tips
1348
+
1349
+ 1. **Always verify the URL reflects the filter state**. Search queries, category selections, date ranges, and pagination should all be encoded in query parameters. This ensures deep linking, bookmarking, and browser back/forward work correctly. If your app does not do this, file a bug.
1350
+
1351
+ 2. **Use `waitForResponse` instead of arbitrary waits**. After applying a filter or search, wait for the specific API call to complete: `await page.waitForResponse('**/api/products?*')`. This is deterministic and faster than `waitForTimeout`.
1352
+
1353
+ 3. **Test filter combinations, not just individual filters**. Users apply multiple filters simultaneously -- category + price range + search term. Test that filters compose correctly and that the resulting URL contains all parameters.
1354
+
1355
+ 4. **Verify the controls reflect the state, not just the results**. After filtering, check that the checkbox is checked, the dropdown shows the right value, and the search input still contains the query. After clearing, verify they all reset. This catches one-way binding bugs.
1356
+
1357
+ 5. **Test deep links for every filter combination**. Paste a URL with filter parameters directly (`/products?category=electronics&q=keyboard&page=2`) and verify the page renders correctly. This catches bugs where filters only work when set via the UI but not from URL params.
1358
+
1359
+ ---
1360
+
1361
+ ## Related
1362
+
1363
+ - `recipes/crud-testing.md` -- Filter and search within CRUD resource lists
1364
+ - `foundations/assertions.md` -- Assertion patterns for dynamic content
1365
+ - `patterns/page-objects.md` -- Encapsulate search and filter controls
1366
+ - `foundations/waiting.md` -- Waiting strategies for async search results