@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,1669 @@
1
+ # Mobile and Responsive Testing
2
+
3
+ > **When to use**: Testing how your application behaves on phones, tablets, and across viewport sizes. Covers device emulation, touch interactions, geolocation, orientation changes, and mobile-specific UI patterns.
4
+ > **Prerequisites**: [core/configuration.md](configuration.md), [core/locators.md](locators.md)
5
+
6
+ ## Quick Reference
7
+
8
+ ```typescript
9
+ import { devices } from '@playwright/test';
10
+
11
+ // Predefined device profiles (viewport, userAgent, touch, deviceScaleFactor)
12
+ devices['iPhone 14'] // 390x844, touch, Safari mobile UA
13
+ devices['iPhone 14 Pro Max'] // 430x932, touch, Safari mobile UA
14
+ devices['Pixel 7'] // 412x915, touch, Chrome mobile UA
15
+ devices['iPad Pro 11'] // 834x1194, touch, Safari tablet UA
16
+ devices['Galaxy S9+'] // 320x658, touch, Chrome mobile UA
17
+ devices['Desktop Chrome'] // 1280x720, no touch, Chrome desktop UA
18
+ devices['Desktop Safari'] // 1280x720, no touch, Safari desktop UA
19
+
20
+ // Landscape variants
21
+ devices['iPhone 14 landscape'] // 844x390, touch, Safari mobile UA
22
+ devices['iPad Pro 11 landscape'] // 1194x834, touch, Safari tablet UA
23
+ ```
24
+
25
+ ```bash
26
+ # Run mobile project only
27
+ npx playwright test --project=mobile-chrome
28
+ npx playwright test --project=mobile-safari
29
+
30
+ # Run all projects (desktop + mobile in parallel)
31
+ npx playwright test
32
+
33
+ # List available device names
34
+ npx playwright test --list-devices 2>/dev/null || node -e "const {devices}=require('@playwright/test');console.log(Object.keys(devices).join('\n'))"
35
+ ```
36
+
37
+ ## Patterns
38
+
39
+ ### 1. Device Emulation
40
+
41
+ **Use when**: Testing your app as it appears on a specific real-world device -- iPhone, Pixel, iPad. Applies the correct viewport, user agent, device scale factor, and touch support in one shot.
42
+ **Avoid when**: You only need to test a specific viewport width (use custom viewports instead). Device emulation is not a substitute for real device testing when pixel-perfect rendering matters.
43
+
44
+ #### TypeScript
45
+
46
+ ```typescript
47
+ // playwright.config.ts
48
+ import { defineConfig, devices } from '@playwright/test';
49
+
50
+ export default defineConfig({
51
+ testDir: './tests',
52
+ projects: [
53
+ {
54
+ name: 'Desktop Chrome',
55
+ use: { ...devices['Desktop Chrome'] },
56
+ },
57
+ {
58
+ name: 'Mobile Chrome',
59
+ use: { ...devices['Pixel 7'] },
60
+ },
61
+ {
62
+ name: 'Mobile Safari',
63
+ use: { ...devices['iPhone 14'] },
64
+ },
65
+ {
66
+ name: 'Tablet',
67
+ use: { ...devices['iPad Pro 11'] },
68
+ },
69
+ ],
70
+ });
71
+ ```
72
+
73
+ ```typescript
74
+ // tests/mobile-navigation.spec.ts
75
+ import { test, expect } from '@playwright/test';
76
+
77
+ test('mobile user can navigate via hamburger menu', async ({ page, isMobile }) => {
78
+ await page.goto('/');
79
+
80
+ if (isMobile) {
81
+ // Mobile: hamburger menu is visible, desktop nav is hidden
82
+ await expect(page.getByRole('button', { name: 'Menu' })).toBeVisible();
83
+ await expect(page.getByRole('navigation', { name: 'Main' })).toBeHidden();
84
+
85
+ // Open hamburger menu
86
+ await page.getByRole('button', { name: 'Menu' }).click();
87
+ await expect(page.getByRole('navigation', { name: 'Main' })).toBeVisible();
88
+ await page.getByRole('link', { name: 'Products' }).click();
89
+ await page.waitForURL('**/products');
90
+ } else {
91
+ // Desktop: nav links are directly visible
92
+ await expect(page.getByRole('navigation', { name: 'Main' })).toBeVisible();
93
+ await page.getByRole('link', { name: 'Products' }).click();
94
+ await page.waitForURL('**/products');
95
+ }
96
+
97
+ await expect(page.getByRole('heading', { name: 'Products', level: 1 })).toBeVisible();
98
+ });
99
+ ```
100
+
101
+ #### JavaScript
102
+
103
+ ```javascript
104
+ // playwright.config.js
105
+ const { defineConfig, devices } = require('@playwright/test');
106
+
107
+ module.exports = defineConfig({
108
+ testDir: './tests',
109
+ projects: [
110
+ {
111
+ name: 'Desktop Chrome',
112
+ use: { ...devices['Desktop Chrome'] },
113
+ },
114
+ {
115
+ name: 'Mobile Chrome',
116
+ use: { ...devices['Pixel 7'] },
117
+ },
118
+ {
119
+ name: 'Mobile Safari',
120
+ use: { ...devices['iPhone 14'] },
121
+ },
122
+ {
123
+ name: 'Tablet',
124
+ use: { ...devices['iPad Pro 11'] },
125
+ },
126
+ ],
127
+ });
128
+ ```
129
+
130
+ ```javascript
131
+ // tests/mobile-navigation.spec.js
132
+ const { test, expect } = require('@playwright/test');
133
+
134
+ test('mobile user can navigate via hamburger menu', async ({ page, isMobile }) => {
135
+ await page.goto('/');
136
+
137
+ if (isMobile) {
138
+ await expect(page.getByRole('button', { name: 'Menu' })).toBeVisible();
139
+ await expect(page.getByRole('navigation', { name: 'Main' })).toBeHidden();
140
+
141
+ await page.getByRole('button', { name: 'Menu' }).click();
142
+ await expect(page.getByRole('navigation', { name: 'Main' })).toBeVisible();
143
+ await page.getByRole('link', { name: 'Products' }).click();
144
+ await page.waitForURL('**/products');
145
+ } else {
146
+ await expect(page.getByRole('navigation', { name: 'Main' })).toBeVisible();
147
+ await page.getByRole('link', { name: 'Products' }).click();
148
+ await page.waitForURL('**/products');
149
+ }
150
+
151
+ await expect(page.getByRole('heading', { name: 'Products', level: 1 })).toBeVisible();
152
+ });
153
+ ```
154
+
155
+ ### 2. Custom Viewports
156
+
157
+ **Use when**: Testing responsive layouts at specific breakpoints without full device emulation. Ideal for verifying CSS media queries fire at the right widths.
158
+ **Avoid when**: You need realistic mobile behavior (touch events, mobile user agent, device scale factor). Use device emulation instead.
159
+
160
+ #### TypeScript
161
+
162
+ ```typescript
163
+ // tests/responsive-layout.spec.ts
164
+ import { test, expect } from '@playwright/test';
165
+
166
+ const breakpoints = [
167
+ { name: 'mobile-small', width: 320, height: 568 },
168
+ { name: 'mobile', width: 375, height: 667 },
169
+ { name: 'tablet', width: 768, height: 1024 },
170
+ { name: 'desktop', width: 1024, height: 768 },
171
+ { name: 'desktop-large', width: 1440, height: 900 },
172
+ ];
173
+
174
+ for (const bp of breakpoints) {
175
+ test(`layout adapts correctly at ${bp.name} (${bp.width}px)`, async ({ page }) => {
176
+ await page.setViewportSize({ width: bp.width, height: bp.height });
177
+ await page.goto('/');
178
+
179
+ if (bp.width < 768) {
180
+ // Mobile layout: stacked, hamburger menu visible
181
+ await expect(page.getByRole('button', { name: 'Menu' })).toBeVisible();
182
+ await expect(page.getByTestId('sidebar')).toBeHidden();
183
+ } else if (bp.width < 1024) {
184
+ // Tablet layout: collapsible sidebar
185
+ await expect(page.getByRole('button', { name: 'Menu' })).toBeHidden();
186
+ await expect(page.getByTestId('sidebar')).toBeVisible();
187
+ } else {
188
+ // Desktop layout: full sidebar, no hamburger
189
+ await expect(page.getByRole('button', { name: 'Menu' })).toBeHidden();
190
+ await expect(page.getByTestId('sidebar')).toBeVisible();
191
+ await expect(page.getByTestId('sidebar')).toHaveCSS('width', '280px');
192
+ }
193
+ });
194
+ }
195
+ ```
196
+
197
+ ```typescript
198
+ // Per-file viewport override (applies to all tests in this file)
199
+ import { test, expect } from '@playwright/test';
200
+
201
+ test.use({ viewport: { width: 375, height: 667 } });
202
+
203
+ test('mobile checkout flow fits on small screen', async ({ page }) => {
204
+ await page.goto('/checkout');
205
+
206
+ // Verify no horizontal scrollbar
207
+ const hasHorizontalScroll = await page.evaluate(
208
+ () => document.documentElement.scrollWidth > document.documentElement.clientWidth
209
+ );
210
+ expect(hasHorizontalScroll).toBe(false);
211
+
212
+ // Verify all form fields are visible without horizontal scroll
213
+ await expect(page.getByLabel('Card number')).toBeVisible();
214
+ await expect(page.getByRole('button', { name: 'Pay now' })).toBeVisible();
215
+ });
216
+ ```
217
+
218
+ #### JavaScript
219
+
220
+ ```javascript
221
+ // tests/responsive-layout.spec.js
222
+ const { test, expect } = require('@playwright/test');
223
+
224
+ const breakpoints = [
225
+ { name: 'mobile-small', width: 320, height: 568 },
226
+ { name: 'mobile', width: 375, height: 667 },
227
+ { name: 'tablet', width: 768, height: 1024 },
228
+ { name: 'desktop', width: 1024, height: 768 },
229
+ { name: 'desktop-large', width: 1440, height: 900 },
230
+ ];
231
+
232
+ for (const bp of breakpoints) {
233
+ test(`layout adapts correctly at ${bp.name} (${bp.width}px)`, async ({ page }) => {
234
+ await page.setViewportSize({ width: bp.width, height: bp.height });
235
+ await page.goto('/');
236
+
237
+ if (bp.width < 768) {
238
+ await expect(page.getByRole('button', { name: 'Menu' })).toBeVisible();
239
+ await expect(page.getByTestId('sidebar')).toBeHidden();
240
+ } else if (bp.width < 1024) {
241
+ await expect(page.getByRole('button', { name: 'Menu' })).toBeHidden();
242
+ await expect(page.getByTestId('sidebar')).toBeVisible();
243
+ } else {
244
+ await expect(page.getByRole('button', { name: 'Menu' })).toBeHidden();
245
+ await expect(page.getByTestId('sidebar')).toBeVisible();
246
+ await expect(page.getByTestId('sidebar')).toHaveCSS('width', '280px');
247
+ }
248
+ });
249
+ }
250
+ ```
251
+
252
+ ```javascript
253
+ // Per-file viewport override
254
+ const { test, expect } = require('@playwright/test');
255
+
256
+ test.use({ viewport: { width: 375, height: 667 } });
257
+
258
+ test('mobile checkout flow fits on small screen', async ({ page }) => {
259
+ await page.goto('/checkout');
260
+
261
+ const hasHorizontalScroll = await page.evaluate(
262
+ () => document.documentElement.scrollWidth > document.documentElement.clientWidth
263
+ );
264
+ expect(hasHorizontalScroll).toBe(false);
265
+
266
+ await expect(page.getByLabel('Card number')).toBeVisible();
267
+ await expect(page.getByRole('button', { name: 'Pay now' })).toBeVisible();
268
+ });
269
+ ```
270
+
271
+ ### 3. Touch Events
272
+
273
+ **Use when**: Testing touch-specific interactions -- tap, swipe, pinch. Required for mobile-only gestures that have no mouse equivalent.
274
+ **Avoid when**: The feature works identically with mouse clicks. Playwright's `click()` dispatches touch events automatically on touch-enabled device profiles.
275
+
276
+ #### TypeScript
277
+
278
+ ```typescript
279
+ // tests/touch-interactions.spec.ts
280
+ import { test, expect, devices } from '@playwright/test';
281
+
282
+ test.use({ ...devices['iPhone 14'] });
283
+
284
+ test('tap to select an item', async ({ page }) => {
285
+ await page.goto('/gallery');
286
+
287
+ // tap() dispatches touchstart → touchend → click
288
+ // Only available when hasTouch is true (device profiles set this)
289
+ await page.getByRole('img', { name: 'Sunset photo' }).tap();
290
+ await expect(page.getByText('Selected: Sunset photo')).toBeVisible();
291
+ });
292
+
293
+ test('swipe to dismiss a card', async ({ page }) => {
294
+ await page.goto('/notifications');
295
+
296
+ const card = page.getByTestId('notification-card').first();
297
+ const box = await card.boundingBox();
298
+
299
+ if (box) {
300
+ // Simulate a left swipe: touchstart on right side, touchmove to left, touchend
301
+ await page.touchscreen.tap(box.x + box.width - 20, box.y + box.height / 2);
302
+
303
+ // Swipe gesture using mouse (touch events are synthesized from mouse on emulated devices)
304
+ await card.hover({ position: { x: box.width - 20, y: box.height / 2 } });
305
+ await page.mouse.down();
306
+ await page.mouse.move(box.x - 100, box.y + box.height / 2, { steps: 10 });
307
+ await page.mouse.up();
308
+
309
+ await expect(card).toBeHidden();
310
+ }
311
+ });
312
+
313
+ test('long press to open context menu', async ({ page }) => {
314
+ await page.goto('/files');
315
+
316
+ const file = page.getByRole('listitem').filter({ hasText: 'Report.pdf' });
317
+
318
+ // Long press: dispatch pointerdown, wait, then pointerup
319
+ await file.click({ delay: 800 }); // delay in ms simulates long press
320
+ await expect(page.getByRole('menu')).toBeVisible();
321
+ await page.getByRole('menuitem', { name: 'Delete' }).click();
322
+ });
323
+
324
+ test('pinch to zoom on a map', async ({ page }) => {
325
+ await page.goto('/map');
326
+
327
+ // Pinch-to-zoom requires dispatching multi-touch events via JavaScript
328
+ const mapElement = page.getByTestId('map-container');
329
+
330
+ await mapElement.evaluate((el) => {
331
+ // Simulate pinch-out (zoom in) with two touch points moving apart
332
+ const center = { x: el.clientWidth / 2, y: el.clientHeight / 2 };
333
+
334
+ const touch1 = new Touch({
335
+ identifier: 0,
336
+ target: el,
337
+ clientX: center.x - 50,
338
+ clientY: center.y,
339
+ });
340
+ const touch2 = new Touch({
341
+ identifier: 1,
342
+ target: el,
343
+ clientX: center.x + 50,
344
+ clientY: center.y,
345
+ });
346
+
347
+ el.dispatchEvent(new TouchEvent('touchstart', {
348
+ touches: [touch1, touch2],
349
+ changedTouches: [touch1, touch2],
350
+ bubbles: true,
351
+ }));
352
+
353
+ const touch1Moved = new Touch({
354
+ identifier: 0,
355
+ target: el,
356
+ clientX: center.x - 120,
357
+ clientY: center.y,
358
+ });
359
+ const touch2Moved = new Touch({
360
+ identifier: 1,
361
+ target: el,
362
+ clientX: center.x + 120,
363
+ clientY: center.y,
364
+ });
365
+
366
+ el.dispatchEvent(new TouchEvent('touchmove', {
367
+ touches: [touch1Moved, touch2Moved],
368
+ changedTouches: [touch1Moved, touch2Moved],
369
+ bubbles: true,
370
+ }));
371
+
372
+ el.dispatchEvent(new TouchEvent('touchend', {
373
+ touches: [],
374
+ changedTouches: [touch1Moved, touch2Moved],
375
+ bubbles: true,
376
+ }));
377
+ });
378
+
379
+ // Verify zoom level changed
380
+ await expect(page.getByTestId('zoom-level')).not.toHaveText('1x');
381
+ });
382
+ ```
383
+
384
+ #### JavaScript
385
+
386
+ ```javascript
387
+ // tests/touch-interactions.spec.js
388
+ const { test, expect, devices } = require('@playwright/test');
389
+
390
+ test.use({ ...devices['iPhone 14'] });
391
+
392
+ test('tap to select an item', async ({ page }) => {
393
+ await page.goto('/gallery');
394
+
395
+ await page.getByRole('img', { name: 'Sunset photo' }).tap();
396
+ await expect(page.getByText('Selected: Sunset photo')).toBeVisible();
397
+ });
398
+
399
+ test('swipe to dismiss a card', async ({ page }) => {
400
+ await page.goto('/notifications');
401
+
402
+ const card = page.getByTestId('notification-card').first();
403
+ const box = await card.boundingBox();
404
+
405
+ if (box) {
406
+ await card.hover({ position: { x: box.width - 20, y: box.height / 2 } });
407
+ await page.mouse.down();
408
+ await page.mouse.move(box.x - 100, box.y + box.height / 2, { steps: 10 });
409
+ await page.mouse.up();
410
+
411
+ await expect(card).toBeHidden();
412
+ }
413
+ });
414
+
415
+ test('long press to open context menu', async ({ page }) => {
416
+ await page.goto('/files');
417
+
418
+ const file = page.getByRole('listitem').filter({ hasText: 'Report.pdf' });
419
+ await file.click({ delay: 800 });
420
+ await expect(page.getByRole('menu')).toBeVisible();
421
+ await page.getByRole('menuitem', { name: 'Delete' }).click();
422
+ });
423
+ ```
424
+
425
+ ### 4. Mobile-Specific UI
426
+
427
+ **Use when**: Testing UI components that only appear on mobile -- hamburger menus, bottom sheets, pull-to-refresh, sticky mobile headers, floating action buttons.
428
+ **Avoid when**: The component renders identically on desktop and mobile.
429
+
430
+ #### TypeScript
431
+
432
+ ```typescript
433
+ // tests/mobile-ui.spec.ts
434
+ import { test, expect, devices } from '@playwright/test';
435
+
436
+ test.use({ ...devices['iPhone 14'] });
437
+
438
+ test('hamburger menu opens and closes', async ({ page }) => {
439
+ await page.goto('/');
440
+
441
+ const menuButton = page.getByRole('button', { name: 'Menu' });
442
+ const nav = page.getByRole('navigation', { name: 'Main' });
443
+
444
+ // Menu starts closed
445
+ await expect(nav).toBeHidden();
446
+
447
+ // Open menu
448
+ await menuButton.click();
449
+ await expect(nav).toBeVisible();
450
+
451
+ // Verify all nav items are visible
452
+ await expect(nav.getByRole('link', { name: 'Home' })).toBeVisible();
453
+ await expect(nav.getByRole('link', { name: 'Products' })).toBeVisible();
454
+ await expect(nav.getByRole('link', { name: 'Account' })).toBeVisible();
455
+
456
+ // Close menu by tapping outside (overlay)
457
+ await page.getByTestId('menu-overlay').click();
458
+ await expect(nav).toBeHidden();
459
+ });
460
+
461
+ test('bottom sheet slides up on mobile', async ({ page }) => {
462
+ await page.goto('/products/1');
463
+
464
+ await page.getByRole('button', { name: 'Add to cart' }).click();
465
+
466
+ // Bottom sheet appears with cart summary
467
+ const bottomSheet = page.getByTestId('bottom-sheet');
468
+ await expect(bottomSheet).toBeVisible();
469
+ await expect(bottomSheet).toContainText('Added to cart');
470
+
471
+ // Dismiss by swiping down
472
+ const box = await bottomSheet.boundingBox();
473
+ if (box) {
474
+ const startX = box.x + box.width / 2;
475
+ const startY = box.y + 20;
476
+ await page.mouse.move(startX, startY);
477
+ await page.mouse.down();
478
+ await page.mouse.move(startX, startY + 300, { steps: 10 });
479
+ await page.mouse.up();
480
+ }
481
+
482
+ await expect(bottomSheet).toBeHidden();
483
+ });
484
+
485
+ test('pull to refresh reloads content', async ({ page }) => {
486
+ await page.goto('/feed');
487
+
488
+ // Store initial first item text
489
+ const firstItem = page.getByRole('listitem').first();
490
+ const initialText = await firstItem.textContent();
491
+
492
+ // Pull-to-refresh: swipe down from top of scrollable area
493
+ const feed = page.getByTestId('feed-container');
494
+ const box = await feed.boundingBox();
495
+
496
+ if (box) {
497
+ await page.mouse.move(box.x + box.width / 2, box.y + 10);
498
+ await page.mouse.down();
499
+ await page.mouse.move(box.x + box.width / 2, box.y + 250, { steps: 15 });
500
+ await page.mouse.up();
501
+ }
502
+
503
+ // Wait for refresh indicator and then content reload
504
+ await expect(page.getByTestId('refresh-spinner')).toBeVisible();
505
+ await expect(page.getByTestId('refresh-spinner')).toBeHidden();
506
+ });
507
+
508
+ test('sticky mobile header remains visible on scroll', async ({ page }) => {
509
+ await page.goto('/products');
510
+
511
+ const header = page.getByRole('banner');
512
+
513
+ // Scroll down significantly
514
+ await page.evaluate(() => window.scrollTo(0, 2000));
515
+
516
+ // Header should remain visible (sticky positioning)
517
+ await expect(header).toBeVisible();
518
+ await expect(header).toBeInViewport();
519
+ });
520
+ ```
521
+
522
+ #### JavaScript
523
+
524
+ ```javascript
525
+ // tests/mobile-ui.spec.js
526
+ const { test, expect, devices } = require('@playwright/test');
527
+
528
+ test.use({ ...devices['iPhone 14'] });
529
+
530
+ test('hamburger menu opens and closes', async ({ page }) => {
531
+ await page.goto('/');
532
+
533
+ const menuButton = page.getByRole('button', { name: 'Menu' });
534
+ const nav = page.getByRole('navigation', { name: 'Main' });
535
+
536
+ await expect(nav).toBeHidden();
537
+ await menuButton.click();
538
+ await expect(nav).toBeVisible();
539
+
540
+ await expect(nav.getByRole('link', { name: 'Home' })).toBeVisible();
541
+ await expect(nav.getByRole('link', { name: 'Products' })).toBeVisible();
542
+ await expect(nav.getByRole('link', { name: 'Account' })).toBeVisible();
543
+
544
+ await page.getByTestId('menu-overlay').click();
545
+ await expect(nav).toBeHidden();
546
+ });
547
+
548
+ test('bottom sheet slides up on mobile', async ({ page }) => {
549
+ await page.goto('/products/1');
550
+
551
+ await page.getByRole('button', { name: 'Add to cart' }).click();
552
+
553
+ const bottomSheet = page.getByTestId('bottom-sheet');
554
+ await expect(bottomSheet).toBeVisible();
555
+ await expect(bottomSheet).toContainText('Added to cart');
556
+
557
+ const box = await bottomSheet.boundingBox();
558
+ if (box) {
559
+ const startX = box.x + box.width / 2;
560
+ const startY = box.y + 20;
561
+ await page.mouse.move(startX, startY);
562
+ await page.mouse.down();
563
+ await page.mouse.move(startX, startY + 300, { steps: 10 });
564
+ await page.mouse.up();
565
+ }
566
+
567
+ await expect(bottomSheet).toBeHidden();
568
+ });
569
+
570
+ test('sticky mobile header remains visible on scroll', async ({ page }) => {
571
+ await page.goto('/products');
572
+
573
+ const header = page.getByRole('banner');
574
+ await page.evaluate(() => window.scrollTo(0, 2000));
575
+ await expect(header).toBeVisible();
576
+ await expect(header).toBeInViewport();
577
+ });
578
+ ```
579
+
580
+ ### 5. Geolocation
581
+
582
+ **Use when**: Testing location-dependent features -- store finders, delivery zones, weather widgets, location-based pricing.
583
+ **Avoid when**: The feature does not use the Geolocation API. If it uses IP-based location, mock the API response instead.
584
+
585
+ #### TypeScript
586
+
587
+ ```typescript
588
+ // playwright.config.ts -- set geolocation per project
589
+ import { defineConfig, devices } from '@playwright/test';
590
+
591
+ export default defineConfig({
592
+ testDir: './tests',
593
+ projects: [
594
+ {
595
+ name: 'mobile-nyc',
596
+ use: {
597
+ ...devices['iPhone 14'],
598
+ geolocation: { latitude: 40.7128, longitude: -74.0060 },
599
+ permissions: ['geolocation'],
600
+ },
601
+ },
602
+ {
603
+ name: 'mobile-london',
604
+ use: {
605
+ ...devices['iPhone 14'],
606
+ geolocation: { latitude: 51.5074, longitude: -0.1278 },
607
+ permissions: ['geolocation'],
608
+ locale: 'en-GB',
609
+ timezoneId: 'Europe/London',
610
+ },
611
+ },
612
+ ],
613
+ });
614
+ ```
615
+
616
+ ```typescript
617
+ // tests/store-locator.spec.ts
618
+ import { test, expect } from '@playwright/test';
619
+
620
+ test('shows nearby stores based on geolocation', async ({ page, context }) => {
621
+ // Geolocation is already set via project config above.
622
+ // To override in a specific test:
623
+ await context.setGeolocation({ latitude: 34.0522, longitude: -118.2437 }); // Los Angeles
624
+
625
+ await page.goto('/store-locator');
626
+ await page.getByRole('button', { name: 'Use my location' }).click();
627
+
628
+ // Verify the app uses the mocked location
629
+ await expect(page.getByText('Stores near Los Angeles')).toBeVisible();
630
+ await expect(page.getByRole('listitem')).toHaveCount(5); // nearest 5 stores
631
+ });
632
+
633
+ test('geolocation updates in real time', async ({ page, context }) => {
634
+ await context.setGeolocation({ latitude: 40.7128, longitude: -74.0060 });
635
+ await page.goto('/delivery-tracker');
636
+
637
+ await expect(page.getByText('New York')).toBeVisible();
638
+
639
+ // Simulate user moving to a new location
640
+ await context.setGeolocation({ latitude: 40.7580, longitude: -73.9855 }); // Times Square
641
+
642
+ // Trigger a location refresh (app-specific)
643
+ await page.getByRole('button', { name: 'Refresh location' }).click();
644
+ await expect(page.getByText('Times Square')).toBeVisible();
645
+ });
646
+
647
+ test('handles geolocation permission denied', async ({ page, context }) => {
648
+ // Clear geolocation permissions to simulate denial
649
+ await context.clearPermissions();
650
+ await page.goto('/store-locator');
651
+
652
+ await page.getByRole('button', { name: 'Use my location' }).click();
653
+
654
+ // App should show fallback UI
655
+ await expect(page.getByText('Location access denied')).toBeVisible();
656
+ await expect(page.getByLabel('Enter your zip code')).toBeVisible();
657
+ });
658
+ ```
659
+
660
+ #### JavaScript
661
+
662
+ ```javascript
663
+ // tests/store-locator.spec.js
664
+ const { test, expect } = require('@playwright/test');
665
+
666
+ test('shows nearby stores based on geolocation', async ({ page, context }) => {
667
+ await context.setGeolocation({ latitude: 34.0522, longitude: -118.2437 });
668
+
669
+ await page.goto('/store-locator');
670
+ await page.getByRole('button', { name: 'Use my location' }).click();
671
+
672
+ await expect(page.getByText('Stores near Los Angeles')).toBeVisible();
673
+ await expect(page.getByRole('listitem')).toHaveCount(5);
674
+ });
675
+
676
+ test('geolocation updates in real time', async ({ page, context }) => {
677
+ await context.setGeolocation({ latitude: 40.7128, longitude: -74.0060 });
678
+ await page.goto('/delivery-tracker');
679
+
680
+ await expect(page.getByText('New York')).toBeVisible();
681
+
682
+ await context.setGeolocation({ latitude: 40.7580, longitude: -73.9855 });
683
+ await page.getByRole('button', { name: 'Refresh location' }).click();
684
+ await expect(page.getByText('Times Square')).toBeVisible();
685
+ });
686
+
687
+ test('handles geolocation permission denied', async ({ page, context }) => {
688
+ await context.clearPermissions();
689
+ await page.goto('/store-locator');
690
+
691
+ await page.getByRole('button', { name: 'Use my location' }).click();
692
+
693
+ await expect(page.getByText('Location access denied')).toBeVisible();
694
+ await expect(page.getByLabel('Enter your zip code')).toBeVisible();
695
+ });
696
+ ```
697
+
698
+ ### 6. Multi-Project Responsive Testing
699
+
700
+ **Use when**: Running the same tests across desktop and mobile browsers in parallel. The standard approach for responsive apps.
701
+ **Avoid when**: Your app is desktop-only or mobile-only.
702
+
703
+ #### TypeScript
704
+
705
+ ```typescript
706
+ // playwright.config.ts
707
+ import { defineConfig, devices } from '@playwright/test';
708
+
709
+ export default defineConfig({
710
+ testDir: './tests',
711
+ fullyParallel: true,
712
+ retries: process.env.CI ? 2 : 0,
713
+
714
+ projects: [
715
+ // ── Desktop ────────────────────────────────────────────
716
+ {
717
+ name: 'desktop-chrome',
718
+ use: { ...devices['Desktop Chrome'] },
719
+ },
720
+ {
721
+ name: 'desktop-firefox',
722
+ use: { ...devices['Desktop Firefox'] },
723
+ },
724
+ {
725
+ name: 'desktop-safari',
726
+ use: { ...devices['Desktop Safari'] },
727
+ },
728
+
729
+ // ── Mobile ─────────────────────────────────────────────
730
+ {
731
+ name: 'mobile-chrome',
732
+ use: { ...devices['Pixel 7'] },
733
+ },
734
+ {
735
+ name: 'mobile-safari',
736
+ use: { ...devices['iPhone 14'] },
737
+ },
738
+
739
+ // ── Tablet ─────────────────────────────────────────────
740
+ {
741
+ name: 'tablet',
742
+ use: { ...devices['iPad Pro 11'] },
743
+ },
744
+ ],
745
+ });
746
+ ```
747
+
748
+ ```typescript
749
+ // tests/responsive-checkout.spec.ts
750
+ import { test, expect } from '@playwright/test';
751
+
752
+ test('checkout works across all viewports', async ({ page, isMobile }) => {
753
+ await page.goto('/products');
754
+
755
+ // Add item -- same action on all viewports
756
+ await page.getByRole('button', { name: 'Add to cart' }).first().click();
757
+
758
+ // Navigate to cart
759
+ if (isMobile) {
760
+ // Mobile: cart link may be in hamburger or bottom nav
761
+ await page.getByRole('link', { name: 'Cart' }).click();
762
+ } else {
763
+ // Desktop: cart icon in header
764
+ await page.getByRole('link', { name: /Cart \(\d+\)/ }).click();
765
+ }
766
+
767
+ await page.waitForURL('**/cart');
768
+ await expect(page.getByRole('heading', { name: 'Your cart' })).toBeVisible();
769
+
770
+ // Proceed to checkout
771
+ await page.getByRole('link', { name: 'Checkout' }).click();
772
+ await page.waitForURL('**/checkout');
773
+
774
+ // Fill form -- same fields on all viewports
775
+ await page.getByLabel('Email').fill('test@example.com');
776
+ await page.getByLabel('Card number').fill('4242424242424242');
777
+ await page.getByRole('button', { name: 'Pay now' }).click();
778
+
779
+ await expect(page.getByText('Order confirmed')).toBeVisible();
780
+ });
781
+ ```
782
+
783
+ ```bash
784
+ # Run desktop projects only
785
+ npx playwright test --project=desktop-chrome --project=desktop-firefox --project=desktop-safari
786
+
787
+ # Run mobile projects only
788
+ npx playwright test --project=mobile-chrome --project=mobile-safari
789
+
790
+ # Run specific test file on all projects
791
+ npx playwright test tests/responsive-checkout.spec.ts
792
+
793
+ # CI optimization: mobile + desktop-chrome on PRs, all browsers on main
794
+ # In CI config:
795
+ # PR: npx playwright test --project=desktop-chrome --project=mobile-chrome --project=mobile-safari
796
+ # Main: npx playwright test
797
+ ```
798
+
799
+ #### JavaScript
800
+
801
+ ```javascript
802
+ // playwright.config.js
803
+ const { defineConfig, devices } = require('@playwright/test');
804
+
805
+ module.exports = defineConfig({
806
+ testDir: './tests',
807
+ fullyParallel: true,
808
+ retries: process.env.CI ? 2 : 0,
809
+
810
+ projects: [
811
+ {
812
+ name: 'desktop-chrome',
813
+ use: { ...devices['Desktop Chrome'] },
814
+ },
815
+ {
816
+ name: 'desktop-firefox',
817
+ use: { ...devices['Desktop Firefox'] },
818
+ },
819
+ {
820
+ name: 'desktop-safari',
821
+ use: { ...devices['Desktop Safari'] },
822
+ },
823
+ {
824
+ name: 'mobile-chrome',
825
+ use: { ...devices['Pixel 7'] },
826
+ },
827
+ {
828
+ name: 'mobile-safari',
829
+ use: { ...devices['iPhone 14'] },
830
+ },
831
+ {
832
+ name: 'tablet',
833
+ use: { ...devices['iPad Pro 11'] },
834
+ },
835
+ ],
836
+ });
837
+ ```
838
+
839
+ ```javascript
840
+ // tests/responsive-checkout.spec.js
841
+ const { test, expect } = require('@playwright/test');
842
+
843
+ test('checkout works across all viewports', async ({ page, isMobile }) => {
844
+ await page.goto('/products');
845
+
846
+ await page.getByRole('button', { name: 'Add to cart' }).first().click();
847
+
848
+ if (isMobile) {
849
+ await page.getByRole('link', { name: 'Cart' }).click();
850
+ } else {
851
+ await page.getByRole('link', { name: /Cart \(\d+\)/ }).click();
852
+ }
853
+
854
+ await page.waitForURL('**/cart');
855
+ await expect(page.getByRole('heading', { name: 'Your cart' })).toBeVisible();
856
+
857
+ await page.getByRole('link', { name: 'Checkout' }).click();
858
+ await page.waitForURL('**/checkout');
859
+
860
+ await page.getByLabel('Email').fill('test@example.com');
861
+ await page.getByLabel('Card number').fill('4242424242424242');
862
+ await page.getByRole('button', { name: 'Pay now' }).click();
863
+
864
+ await expect(page.getByText('Order confirmed')).toBeVisible();
865
+ });
866
+ ```
867
+
868
+ ### 7. Responsive Breakpoint Testing
869
+
870
+ **Use when**: Systematically verifying layout behavior at every major CSS breakpoint. Best used alongside visual regression testing.
871
+ **Avoid when**: You only need one or two viewport sizes -- just use `test.use()` overrides instead.
872
+
873
+ #### TypeScript
874
+
875
+ ```typescript
876
+ // tests/fixtures/responsive.fixture.ts
877
+ import { test as base, expect } from '@playwright/test';
878
+
879
+ type Breakpoint = {
880
+ name: string;
881
+ width: number;
882
+ height: number;
883
+ isMobileExpected: boolean;
884
+ };
885
+
886
+ const BREAKPOINTS: Breakpoint[] = [
887
+ { name: 'xs', width: 320, height: 568, isMobileExpected: true },
888
+ { name: 'sm', width: 640, height: 800, isMobileExpected: true },
889
+ { name: 'md', width: 768, height: 1024, isMobileExpected: false },
890
+ { name: 'lg', width: 1024, height: 768, isMobileExpected: false },
891
+ { name: 'xl', width: 1280, height: 800, isMobileExpected: false },
892
+ { name: '2xl', width: 1440, height: 900, isMobileExpected: false },
893
+ ];
894
+
895
+ // Custom fixture that runs a test at every breakpoint
896
+ export const test = base.extend<{ forEachBreakpoint: void }>({
897
+ forEachBreakpoint: [async ({ page }, use, testInfo) => {
898
+ // This fixture is used as a tag -- actual breakpoint iteration is done via test.describe
899
+ await use();
900
+ }, { auto: false }],
901
+ });
902
+
903
+ // Helper: create a describe block that tests every breakpoint
904
+ export function describeBreakpoints(
905
+ title: string,
906
+ fn: (bp: Breakpoint) => void
907
+ ) {
908
+ for (const bp of BREAKPOINTS) {
909
+ base.describe(`${title} @ ${bp.name} (${bp.width}px)`, () => {
910
+ base.use({ viewport: { width: bp.width, height: bp.height } });
911
+ fn(bp);
912
+ });
913
+ }
914
+ }
915
+
916
+ export { expect, BREAKPOINTS };
917
+ ```
918
+
919
+ ```typescript
920
+ // tests/responsive-grid.spec.ts
921
+ import { test, expect, describeBreakpoints } from './fixtures/responsive.fixture';
922
+
923
+ describeBreakpoints('product grid', (bp) => {
924
+ test('shows correct number of columns', async ({ page }) => {
925
+ await page.goto('/products');
926
+
927
+ const grid = page.getByTestId('product-grid');
928
+ const columns = await grid.evaluate((el) => {
929
+ const style = window.getComputedStyle(el);
930
+ return style.gridTemplateColumns.split(' ').length;
931
+ });
932
+
933
+ if (bp.width < 640) {
934
+ expect(columns).toBe(1); // xs: single column
935
+ } else if (bp.width < 1024) {
936
+ expect(columns).toBe(2); // sm-md: two columns
937
+ } else {
938
+ expect(columns).toBe(4); // lg+: four columns
939
+ }
940
+ });
941
+
942
+ test('no content overflow', async ({ page }) => {
943
+ await page.goto('/products');
944
+
945
+ const hasOverflow = await page.evaluate(
946
+ () => document.documentElement.scrollWidth > document.documentElement.clientWidth
947
+ );
948
+ expect(hasOverflow).toBe(false);
949
+ });
950
+ });
951
+ ```
952
+
953
+ #### JavaScript
954
+
955
+ ```javascript
956
+ // tests/fixtures/responsive.fixture.js
957
+ const { test: base, expect } = require('@playwright/test');
958
+
959
+ const BREAKPOINTS = [
960
+ { name: 'xs', width: 320, height: 568, isMobileExpected: true },
961
+ { name: 'sm', width: 640, height: 800, isMobileExpected: true },
962
+ { name: 'md', width: 768, height: 1024, isMobileExpected: false },
963
+ { name: 'lg', width: 1024, height: 768, isMobileExpected: false },
964
+ { name: 'xl', width: 1280, height: 800, isMobileExpected: false },
965
+ { name: '2xl', width: 1440, height: 900, isMobileExpected: false },
966
+ ];
967
+
968
+ function describeBreakpoints(title, fn) {
969
+ for (const bp of BREAKPOINTS) {
970
+ base.describe(`${title} @ ${bp.name} (${bp.width}px)`, () => {
971
+ base.use({ viewport: { width: bp.width, height: bp.height } });
972
+ fn(bp);
973
+ });
974
+ }
975
+ }
976
+
977
+ module.exports = { test: base, expect, BREAKPOINTS, describeBreakpoints };
978
+ ```
979
+
980
+ ```javascript
981
+ // tests/responsive-grid.spec.js
982
+ const { test, expect, describeBreakpoints } = require('./fixtures/responsive.fixture');
983
+
984
+ describeBreakpoints('product grid', (bp) => {
985
+ test('shows correct number of columns', async ({ page }) => {
986
+ await page.goto('/products');
987
+
988
+ const grid = page.getByTestId('product-grid');
989
+ const columns = await grid.evaluate((el) => {
990
+ const style = window.getComputedStyle(el);
991
+ return style.gridTemplateColumns.split(' ').length;
992
+ });
993
+
994
+ if (bp.width < 640) {
995
+ expect(columns).toBe(1);
996
+ } else if (bp.width < 1024) {
997
+ expect(columns).toBe(2);
998
+ } else {
999
+ expect(columns).toBe(4);
1000
+ }
1001
+ });
1002
+
1003
+ test('no content overflow', async ({ page }) => {
1004
+ await page.goto('/products');
1005
+
1006
+ const hasOverflow = await page.evaluate(
1007
+ () => document.documentElement.scrollWidth > document.documentElement.clientWidth
1008
+ );
1009
+ expect(hasOverflow).toBe(false);
1010
+ });
1011
+ });
1012
+ ```
1013
+
1014
+ ### 8. Orientation Testing
1015
+
1016
+ **Use when**: Testing portrait vs landscape layouts. Critical for tablet apps, media players, dashboards, and any app that adapts to orientation.
1017
+ **Avoid when**: Your app does not change layout based on orientation (purely responsive to width only -- test with custom viewports instead).
1018
+
1019
+ #### TypeScript
1020
+
1021
+ ```typescript
1022
+ // tests/orientation.spec.ts
1023
+ import { test, expect, devices } from '@playwright/test';
1024
+
1025
+ test.describe('iPad orientation changes', () => {
1026
+ test.use({ ...devices['iPad Pro 11'] });
1027
+
1028
+ test('dashboard adjusts layout in landscape', async ({ page }) => {
1029
+ // Start in portrait (834x1194 -- default for iPad Pro 11)
1030
+ await page.goto('/dashboard');
1031
+
1032
+ // Portrait: sidebar collapses, chart stacks vertically
1033
+ await expect(page.getByTestId('sidebar')).toHaveCSS('position', 'fixed');
1034
+
1035
+ // Switch to landscape by changing viewport
1036
+ await page.setViewportSize({ width: 1194, height: 834 });
1037
+
1038
+ // Landscape: sidebar visible inline, charts side-by-side
1039
+ await expect(page.getByTestId('sidebar')).toHaveCSS('position', 'relative');
1040
+ });
1041
+
1042
+ test('video player switches to fullscreen in landscape', async ({ page }) => {
1043
+ await page.goto('/videos/1');
1044
+
1045
+ // Portrait: video in inline player
1046
+ const player = page.getByTestId('video-player');
1047
+ const portraitBox = await player.boundingBox();
1048
+
1049
+ // Switch to landscape
1050
+ await page.setViewportSize({ width: 1194, height: 834 });
1051
+
1052
+ // Video should expand to fill width
1053
+ const landscapeBox = await player.boundingBox();
1054
+ expect(landscapeBox!.width).toBeGreaterThan(portraitBox!.width);
1055
+ });
1056
+ });
1057
+
1058
+ test.describe('iPhone orientation changes', () => {
1059
+ test('portrait mode', () => {
1060
+ test.use({ ...devices['iPhone 14'] }); // 390x844
1061
+ });
1062
+
1063
+ test('landscape mode', () => {
1064
+ test.use({ ...devices['iPhone 14 landscape'] }); // 844x390
1065
+ });
1066
+ });
1067
+
1068
+ // Test both orientations with a loop
1069
+ const orientations = [
1070
+ { name: 'portrait', device: devices['iPad Pro 11'] },
1071
+ { name: 'landscape', device: devices['iPad Pro 11 landscape'] },
1072
+ ];
1073
+
1074
+ for (const { name, device } of orientations) {
1075
+ test.describe(`form usability in ${name}`, () => {
1076
+ test.use({ ...device });
1077
+
1078
+ test('all form fields are visible without scrolling', async ({ page }) => {
1079
+ await page.goto('/contact');
1080
+
1081
+ const form = page.getByRole('form');
1082
+ await expect(form).toBeVisible();
1083
+
1084
+ // Check form fits within viewport
1085
+ const formBox = await form.boundingBox();
1086
+ const viewport = page.viewportSize()!;
1087
+ expect(formBox!.width).toBeLessThanOrEqual(viewport.width);
1088
+ });
1089
+ });
1090
+ }
1091
+ ```
1092
+
1093
+ #### JavaScript
1094
+
1095
+ ```javascript
1096
+ // tests/orientation.spec.js
1097
+ const { test, expect, devices } = require('@playwright/test');
1098
+
1099
+ test.describe('iPad orientation changes', () => {
1100
+ test.use({ ...devices['iPad Pro 11'] });
1101
+
1102
+ test('dashboard adjusts layout in landscape', async ({ page }) => {
1103
+ await page.goto('/dashboard');
1104
+
1105
+ await expect(page.getByTestId('sidebar')).toHaveCSS('position', 'fixed');
1106
+
1107
+ await page.setViewportSize({ width: 1194, height: 834 });
1108
+
1109
+ await expect(page.getByTestId('sidebar')).toHaveCSS('position', 'relative');
1110
+ });
1111
+
1112
+ test('video player switches to fullscreen in landscape', async ({ page }) => {
1113
+ await page.goto('/videos/1');
1114
+
1115
+ const player = page.getByTestId('video-player');
1116
+ const portraitBox = await player.boundingBox();
1117
+
1118
+ await page.setViewportSize({ width: 1194, height: 834 });
1119
+
1120
+ const landscapeBox = await player.boundingBox();
1121
+ expect(landscapeBox.width).toBeGreaterThan(portraitBox.width);
1122
+ });
1123
+ });
1124
+
1125
+ const orientations = [
1126
+ { name: 'portrait', device: devices['iPad Pro 11'] },
1127
+ { name: 'landscape', device: devices['iPad Pro 11 landscape'] },
1128
+ ];
1129
+
1130
+ for (const { name, device } of orientations) {
1131
+ test.describe(`form usability in ${name}`, () => {
1132
+ test.use({ ...device });
1133
+
1134
+ test('all form fields are visible without scrolling', async ({ page }) => {
1135
+ await page.goto('/contact');
1136
+
1137
+ const form = page.getByRole('form');
1138
+ await expect(form).toBeVisible();
1139
+
1140
+ const formBox = await form.boundingBox();
1141
+ const viewport = page.viewportSize();
1142
+ expect(formBox.width).toBeLessThanOrEqual(viewport.width);
1143
+ });
1144
+ });
1145
+ }
1146
+ ```
1147
+
1148
+ ### 9. Mobile Performance
1149
+
1150
+ **Use when**: Simulating real-world mobile conditions -- slow 3G networks, underpowered CPUs. Critical for testing loading states, skeleton screens, and timeout handling.
1151
+ **Avoid when**: Testing functional correctness only. Performance throttling slows down your test suite significantly.
1152
+
1153
+ #### TypeScript
1154
+
1155
+ ```typescript
1156
+ // tests/mobile-performance.spec.ts
1157
+ import { test, expect, devices } from '@playwright/test';
1158
+
1159
+ test.describe('mobile on slow network', () => {
1160
+ test.use({ ...devices['Pixel 7'] });
1161
+
1162
+ test('shows skeleton loader on slow 3G', async ({ page }) => {
1163
+ // Get the CDP session for network throttling (Chromium only)
1164
+ const cdpSession = await page.context().newCDPSession(page);
1165
+
1166
+ // Simulate slow 3G: 500kbps download, 500kbps upload, 400ms RTT
1167
+ await cdpSession.send('Network.emulateNetworkConditions', {
1168
+ offline: false,
1169
+ downloadThroughput: (500 * 1024) / 8, // bytes per second
1170
+ uploadThroughput: (500 * 1024) / 8,
1171
+ latency: 400, // ms
1172
+ });
1173
+
1174
+ await page.goto('/products');
1175
+
1176
+ // Skeleton screen should appear while content loads
1177
+ await expect(page.getByTestId('product-skeleton')).toBeVisible();
1178
+
1179
+ // Eventually, real content replaces skeleton
1180
+ await expect(page.getByRole('listitem')).toHaveCount(12, { timeout: 30_000 });
1181
+ await expect(page.getByTestId('product-skeleton')).toBeHidden();
1182
+ });
1183
+
1184
+ test('shows offline message when network drops', async ({ page }) => {
1185
+ await page.goto('/dashboard');
1186
+ await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
1187
+
1188
+ // Drop network entirely
1189
+ await page.context().setOffline(true);
1190
+
1191
+ // Try to navigate -- should show offline message
1192
+ await page.getByRole('link', { name: 'Settings' }).click();
1193
+ await expect(page.getByText(/you.*offline|no.*connection/i)).toBeVisible();
1194
+
1195
+ // Restore network
1196
+ await page.context().setOffline(false);
1197
+ await page.reload();
1198
+ await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
1199
+ });
1200
+
1201
+ test('images lazy-load on slow connections', async ({ page }) => {
1202
+ const cdpSession = await page.context().newCDPSession(page);
1203
+ await cdpSession.send('Network.emulateNetworkConditions', {
1204
+ offline: false,
1205
+ downloadThroughput: (1000 * 1024) / 8,
1206
+ uploadThroughput: (500 * 1024) / 8,
1207
+ latency: 200,
1208
+ });
1209
+
1210
+ await page.goto('/gallery');
1211
+
1212
+ // Images below the fold should have loading="lazy" and not load immediately
1213
+ const belowFoldImages = page.locator('img[loading="lazy"]');
1214
+ const count = await belowFoldImages.count();
1215
+ expect(count).toBeGreaterThan(0);
1216
+
1217
+ // Scroll to trigger lazy loading
1218
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
1219
+ await expect(belowFoldImages.first()).toHaveJSProperty('complete', true);
1220
+ });
1221
+
1222
+ test('CPU throttling shows performance impact', async ({ page }) => {
1223
+ const cdpSession = await page.context().newCDPSession(page);
1224
+
1225
+ // Simulate 4x CPU slowdown (typical mid-range mobile device)
1226
+ await cdpSession.send('Emulation.setCPUThrottlingRate', { rate: 4 });
1227
+
1228
+ await page.goto('/');
1229
+
1230
+ // Measure time to interactive
1231
+ const tti = await page.evaluate(() => {
1232
+ const entries = performance.getEntriesByType('navigation') as PerformanceNavigationTiming[];
1233
+ return entries[0]?.domInteractive ?? 0;
1234
+ });
1235
+
1236
+ // Assert TTI is within acceptable range for throttled mobile
1237
+ expect(tti).toBeLessThan(5000);
1238
+
1239
+ // Reset throttling
1240
+ await cdpSession.send('Emulation.setCPUThrottlingRate', { rate: 1 });
1241
+ });
1242
+ });
1243
+ ```
1244
+
1245
+ #### JavaScript
1246
+
1247
+ ```javascript
1248
+ // tests/mobile-performance.spec.js
1249
+ const { test, expect, devices } = require('@playwright/test');
1250
+
1251
+ test.describe('mobile on slow network', () => {
1252
+ test.use({ ...devices['Pixel 7'] });
1253
+
1254
+ test('shows skeleton loader on slow 3G', async ({ page }) => {
1255
+ const cdpSession = await page.context().newCDPSession(page);
1256
+
1257
+ await cdpSession.send('Network.emulateNetworkConditions', {
1258
+ offline: false,
1259
+ downloadThroughput: (500 * 1024) / 8,
1260
+ uploadThroughput: (500 * 1024) / 8,
1261
+ latency: 400,
1262
+ });
1263
+
1264
+ await page.goto('/products');
1265
+
1266
+ await expect(page.getByTestId('product-skeleton')).toBeVisible();
1267
+ await expect(page.getByRole('listitem')).toHaveCount(12, { timeout: 30_000 });
1268
+ await expect(page.getByTestId('product-skeleton')).toBeHidden();
1269
+ });
1270
+
1271
+ test('shows offline message when network drops', async ({ page }) => {
1272
+ await page.goto('/dashboard');
1273
+ await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
1274
+
1275
+ await page.context().setOffline(true);
1276
+
1277
+ await page.getByRole('link', { name: 'Settings' }).click();
1278
+ await expect(page.getByText(/you.*offline|no.*connection/i)).toBeVisible();
1279
+
1280
+ await page.context().setOffline(false);
1281
+ await page.reload();
1282
+ await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
1283
+ });
1284
+
1285
+ test('CPU throttling shows performance impact', async ({ page }) => {
1286
+ const cdpSession = await page.context().newCDPSession(page);
1287
+
1288
+ await cdpSession.send('Emulation.setCPUThrottlingRate', { rate: 4 });
1289
+
1290
+ await page.goto('/');
1291
+
1292
+ const tti = await page.evaluate(() => {
1293
+ const entries = performance.getEntriesByType('navigation');
1294
+ return entries[0]?.domInteractive ?? 0;
1295
+ });
1296
+
1297
+ expect(tti).toBeLessThan(5000);
1298
+ await cdpSession.send('Emulation.setCPUThrottlingRate', { rate: 1 });
1299
+ });
1300
+ });
1301
+ ```
1302
+
1303
+ ### 10. PWA Mobile Testing
1304
+
1305
+ **Use when**: Testing Progressive Web App features -- service workers, install prompts, offline mode, push notifications on mobile.
1306
+ **Avoid when**: Your app is not a PWA. For general offline testing, see the network offline pattern in Pattern 9.
1307
+
1308
+ #### TypeScript
1309
+
1310
+ ```typescript
1311
+ // tests/pwa-mobile.spec.ts
1312
+ import { test, expect, devices } from '@playwright/test';
1313
+
1314
+ test.use({ ...devices['Pixel 7'] });
1315
+
1316
+ test('service worker registers and caches resources', async ({ page }) => {
1317
+ await page.goto('/');
1318
+
1319
+ // Wait for service worker to register
1320
+ const swRegistered = await page.evaluate(async () => {
1321
+ const registration = await navigator.serviceWorker.ready;
1322
+ return registration.active?.state === 'activated';
1323
+ });
1324
+ expect(swRegistered).toBe(true);
1325
+
1326
+ // Verify critical resources are cached
1327
+ const cacheContents = await page.evaluate(async () => {
1328
+ const cache = await caches.open('app-shell-v1');
1329
+ const keys = await cache.keys();
1330
+ return keys.map((req) => new URL(req.url).pathname);
1331
+ });
1332
+
1333
+ expect(cacheContents).toContain('/');
1334
+ expect(cacheContents).toContain('/offline.html');
1335
+ });
1336
+
1337
+ test('app works offline after initial load', async ({ page }) => {
1338
+ // Load the app online first -- service worker caches resources
1339
+ await page.goto('/');
1340
+ await expect(page.getByRole('heading', { name: 'Home' })).toBeVisible();
1341
+
1342
+ // Wait for service worker to finish caching
1343
+ await page.evaluate(async () => {
1344
+ const registration = await navigator.serviceWorker.ready;
1345
+ // Wait for the service worker to activate
1346
+ if (registration.active?.state !== 'activated') {
1347
+ await new Promise<void>((resolve) => {
1348
+ registration.active?.addEventListener('statechange', () => {
1349
+ if (registration.active?.state === 'activated') resolve();
1350
+ });
1351
+ });
1352
+ }
1353
+ });
1354
+
1355
+ // Go offline
1356
+ await page.context().setOffline(true);
1357
+
1358
+ // Navigate to a cached page
1359
+ await page.goto('/about');
1360
+
1361
+ // Cached page should render
1362
+ await expect(page.getByRole('heading', { name: 'About' })).toBeVisible();
1363
+ });
1364
+
1365
+ test('offline fallback page shows when uncached route is accessed', async ({ page }) => {
1366
+ await page.goto('/');
1367
+
1368
+ // Wait for service worker
1369
+ await page.evaluate(() => navigator.serviceWorker.ready);
1370
+
1371
+ // Go offline and navigate to an uncached route
1372
+ await page.context().setOffline(true);
1373
+ await page.goto('/uncached-page');
1374
+
1375
+ // Should show the offline fallback page
1376
+ await expect(page.getByText(/offline|no.*connection/i)).toBeVisible();
1377
+ await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
1378
+ });
1379
+
1380
+ test('app prompts to install on mobile', async ({ page, context }) => {
1381
+ // Listen for the beforeinstallprompt event
1382
+ await page.goto('/');
1383
+
1384
+ const installPromptFired = await page.evaluate(() => {
1385
+ return new Promise<boolean>((resolve) => {
1386
+ // Check if the event has already fired
1387
+ window.addEventListener('beforeinstallprompt', (e) => {
1388
+ e.preventDefault(); // Prevent auto-prompt
1389
+ resolve(true);
1390
+ });
1391
+
1392
+ // If PWA criteria are met, the event fires automatically
1393
+ // Timeout after 5s if it doesn't fire
1394
+ setTimeout(() => resolve(false), 5000);
1395
+ });
1396
+ });
1397
+
1398
+ // Note: beforeinstallprompt only fires in Chromium and when PWA criteria are met
1399
+ // This test validates the event handler, not the browser chrome UI
1400
+ if (installPromptFired) {
1401
+ // App should show a custom install banner
1402
+ await expect(page.getByTestId('install-banner')).toBeVisible();
1403
+ await page.getByRole('button', { name: 'Install app' }).click();
1404
+ }
1405
+ });
1406
+
1407
+ test('push notification permission request on mobile', async ({ page, context }) => {
1408
+ // Grant notification permission
1409
+ await context.grantPermissions(['notifications']);
1410
+
1411
+ await page.goto('/settings/notifications');
1412
+
1413
+ await page.getByRole('button', { name: 'Enable notifications' }).click();
1414
+
1415
+ // Verify the app registered for push
1416
+ const pushSubscription = await page.evaluate(async () => {
1417
+ const registration = await navigator.serviceWorker.ready;
1418
+ const subscription = await registration.pushManager.getSubscription();
1419
+ return subscription !== null;
1420
+ });
1421
+
1422
+ expect(pushSubscription).toBe(true);
1423
+ await expect(page.getByText('Notifications enabled')).toBeVisible();
1424
+ });
1425
+ ```
1426
+
1427
+ #### JavaScript
1428
+
1429
+ ```javascript
1430
+ // tests/pwa-mobile.spec.js
1431
+ const { test, expect, devices } = require('@playwright/test');
1432
+
1433
+ test.use({ ...devices['Pixel 7'] });
1434
+
1435
+ test('service worker registers and caches resources', async ({ page }) => {
1436
+ await page.goto('/');
1437
+
1438
+ const swRegistered = await page.evaluate(async () => {
1439
+ const registration = await navigator.serviceWorker.ready;
1440
+ return registration.active?.state === 'activated';
1441
+ });
1442
+ expect(swRegistered).toBe(true);
1443
+
1444
+ const cacheContents = await page.evaluate(async () => {
1445
+ const cache = await caches.open('app-shell-v1');
1446
+ const keys = await cache.keys();
1447
+ return keys.map((req) => new URL(req.url).pathname);
1448
+ });
1449
+
1450
+ expect(cacheContents).toContain('/');
1451
+ expect(cacheContents).toContain('/offline.html');
1452
+ });
1453
+
1454
+ test('app works offline after initial load', async ({ page }) => {
1455
+ await page.goto('/');
1456
+ await expect(page.getByRole('heading', { name: 'Home' })).toBeVisible();
1457
+
1458
+ await page.evaluate(async () => {
1459
+ await navigator.serviceWorker.ready;
1460
+ });
1461
+
1462
+ await page.context().setOffline(true);
1463
+ await page.goto('/about');
1464
+
1465
+ await expect(page.getByRole('heading', { name: 'About' })).toBeVisible();
1466
+ });
1467
+
1468
+ test('offline fallback page shows when uncached route is accessed', async ({ page }) => {
1469
+ await page.goto('/');
1470
+ await page.evaluate(() => navigator.serviceWorker.ready);
1471
+
1472
+ await page.context().setOffline(true);
1473
+ await page.goto('/uncached-page');
1474
+
1475
+ await expect(page.getByText(/offline|no.*connection/i)).toBeVisible();
1476
+ await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
1477
+ });
1478
+
1479
+ test('push notification permission request on mobile', async ({ page, context }) => {
1480
+ await context.grantPermissions(['notifications']);
1481
+
1482
+ await page.goto('/settings/notifications');
1483
+
1484
+ await page.getByRole('button', { name: 'Enable notifications' }).click();
1485
+
1486
+ const pushSubscription = await page.evaluate(async () => {
1487
+ const registration = await navigator.serviceWorker.ready;
1488
+ const subscription = await registration.pushManager.getSubscription();
1489
+ return subscription !== null;
1490
+ });
1491
+
1492
+ expect(pushSubscription).toBe(true);
1493
+ await expect(page.getByText('Notifications enabled')).toBeVisible();
1494
+ });
1495
+ ```
1496
+
1497
+ ## Decision Guide
1498
+
1499
+ | Question | Answer | Approach |
1500
+ |---|---|---|
1501
+ | Need to test how app looks on iPhone 14? | Yes | Use `devices['iPhone 14']` -- gets viewport, UA, touch, scale factor in one shot |
1502
+ | Need to test a CSS breakpoint at 768px? | Yes | Use `test.use({ viewport: { width: 768, height: 1024 } })` -- simpler, no UA change |
1503
+ | Need to test both portrait and landscape? | Yes | Use named device + landscape variant: `devices['iPad Pro 11']` and `devices['iPad Pro 11 landscape']` |
1504
+ | Need realistic mobile performance? | Yes | Use CDP `Network.emulateNetworkConditions` + `Emulation.setCPUThrottlingRate` (Chromium only) |
1505
+ | Need to test touch gestures? | Yes | Use device profile with `hasTouch: true`, then use `tap()`, mouse gestures for swipe |
1506
+ | Need to test geolocation? | Yes | Set `geolocation` and `permissions: ['geolocation']` in context options |
1507
+ | Need pixel-perfect mobile testing? | No -- use real devices | Playwright emulation approximates; font rendering and native UI differ from real hardware |
1508
+ | Which devices to test by default? | Start with 3 | `Desktop Chrome` + `Pixel 7` (Android) + `iPhone 14` (iOS). Add tablet if your app has a tablet layout. |
1509
+ | When to add more device projects? | When bugs escape | If users report device-specific bugs, add that device profile permanently |
1510
+ | Run all devices on every PR? | No | Run desktop + one mobile on PRs. Run all devices on main branch merges. |
1511
+ | Device emulation vs custom viewport? | Depends on goal | Emulation: testing real-world device behavior. Custom viewport: testing CSS breakpoints. |
1512
+ | Should I test every screen size? | No | Test your actual CSS breakpoints, plus smallest (320px) and largest (1440px+) supported sizes |
1513
+
1514
+ ## Anti-Patterns
1515
+
1516
+ | Don't Do This | Problem | Do This Instead |
1517
+ |---|---|---|
1518
+ | Only testing at 1280x720 desktop | Misses all mobile and tablet layout bugs; most web traffic is mobile | Add at least `Pixel 7` and `iPhone 14` projects |
1519
+ | Using device emulation for pixel-perfect testing | Emulation approximates real devices -- font rendering, sub-pixel antialiasing, and native browser chrome differ | Use emulation for layout and interaction testing; use real device labs (BrowserStack, Sauce Labs) for pixel-perfect validation |
1520
+ | Ignoring touch interactions | Mobile users tap, swipe, and long-press; `click()` alone may not trigger touch-specific event handlers | Use `tap()` on touch device profiles; test swipe gestures with mouse move sequences |
1521
+ | Not testing orientation changes | Users rotate tablets and phones; layouts may break in landscape | Test both portrait and landscape with device variants or `page.setViewportSize()` |
1522
+ | `page.setViewportSize()` without `hasTouch: true` | Viewport is small but browser reports no touch support -- `@media (hover: hover)` still matches desktop | Use device profiles or explicitly set `hasTouch: true` in `test.use()` |
1523
+ | Hardcoding `isMobile` checks in every test | Duplicates logic, hard to maintain; tests become brittle | Use page objects that abstract mobile vs desktop behavior behind methods |
1524
+ | Testing mobile layout with `visibility: hidden` checks only | Element may still take up space; CSS may use `display: none` or `transform: translateX(-100%)` | Use `toBeHidden()` (checks not visible) or `toHaveCSS('display', 'none')` for specific CSS behavior |
1525
+ | Running 10+ device projects on every CI run | Massive CI time and cost with diminishing returns beyond 3-4 devices | Pick representative devices: one Android phone, one iPhone, one tablet, and desktop browsers |
1526
+ | Network throttling in every test | Slows entire suite dramatically for minimal extra coverage | Create a separate `mobile-perf` project or tag performance tests with `@slow` and run them separately |
1527
+ | `await page.waitForTimeout(2000)` after orientation change | Arbitrary delay; layout reflow may be faster or slower | `await expect(locator).toHaveCSS('property', 'value')` -- assertion auto-retries until layout settles |
1528
+ | Testing geolocation without setting permissions | Browser blocks geolocation silently; test sees no location data and passes incorrectly | Always set `permissions: ['geolocation']` alongside `geolocation` coordinates |
1529
+ | CDP throttling on Firefox or WebKit | CDP sessions are Chromium-only; test will throw on other browsers | Guard CDP calls with a browser check or use Chromium-only projects for performance tests |
1530
+
1531
+ ## Troubleshooting
1532
+
1533
+ ### `tap()` throws "Page.tap: Not supported" error
1534
+
1535
+ **Cause**: The browser context was created without `hasTouch: true`. Device profiles set this automatically, but custom viewport configurations do not.
1536
+
1537
+ ```typescript
1538
+ // Wrong -- custom viewport without touch support
1539
+ test.use({ viewport: { width: 375, height: 667 } });
1540
+ test('tap fails', async ({ page }) => {
1541
+ await page.goto('/');
1542
+ await page.getByRole('button').tap(); // Error: Not supported
1543
+ });
1544
+
1545
+ // Fix -- enable touch explicitly
1546
+ test.use({
1547
+ viewport: { width: 375, height: 667 },
1548
+ hasTouch: true,
1549
+ });
1550
+ test('tap works', async ({ page }) => {
1551
+ await page.goto('/');
1552
+ await page.getByRole('button').tap(); // Works
1553
+ });
1554
+
1555
+ // Or use a device profile that includes hasTouch
1556
+ test.use({ ...devices['iPhone 14'] });
1557
+ ```
1558
+
1559
+ ### `isMobile` is always `false`
1560
+
1561
+ **Cause**: `isMobile` is set by the device profile's `isMobile` property, not by viewport size. Custom viewports do not set it.
1562
+
1563
+ ```typescript
1564
+ // isMobile is false here -- no device profile used
1565
+ test.use({ viewport: { width: 375, height: 667 } });
1566
+
1567
+ test('isMobile is false', async ({ page, isMobile }) => {
1568
+ console.log(isMobile); // false
1569
+ });
1570
+
1571
+ // Fix: use a device profile, or set isMobile explicitly
1572
+ test.use({
1573
+ viewport: { width: 375, height: 667 },
1574
+ isMobile: true,
1575
+ hasTouch: true,
1576
+ });
1577
+
1578
+ test('isMobile is true', async ({ page, isMobile }) => {
1579
+ console.log(isMobile); // true
1580
+ });
1581
+ ```
1582
+
1583
+ ### Geolocation not working -- location remains default
1584
+
1585
+ **Cause**: Missing `permissions: ['geolocation']` in context options. The browser silently denies the Geolocation API without this permission.
1586
+
1587
+ ```typescript
1588
+ // Wrong -- geolocation set but no permission granted
1589
+ test.use({
1590
+ geolocation: { latitude: 40.7128, longitude: -74.0060 },
1591
+ // Missing: permissions: ['geolocation']
1592
+ });
1593
+
1594
+ // Fix
1595
+ test.use({
1596
+ geolocation: { latitude: 40.7128, longitude: -74.0060 },
1597
+ permissions: ['geolocation'],
1598
+ });
1599
+ ```
1600
+
1601
+ ### CDP session throws on Firefox/WebKit
1602
+
1603
+ **Cause**: `page.context().newCDPSession(page)` is Chromium-only. Firefox and WebKit do not support CDP.
1604
+
1605
+ ```typescript
1606
+ // Wrong -- crashes on Firefox and WebKit
1607
+ test('throttle network', async ({ page }) => {
1608
+ const cdp = await page.context().newCDPSession(page); // Throws on non-Chromium
1609
+ });
1610
+
1611
+ // Fix -- guard with browser name check
1612
+ test('throttle network', async ({ page, browserName }) => {
1613
+ test.skip(browserName !== 'chromium', 'CDP throttling is Chromium-only');
1614
+
1615
+ const cdp = await page.context().newCDPSession(page);
1616
+ await cdp.send('Network.emulateNetworkConditions', {
1617
+ offline: false,
1618
+ downloadThroughput: (500 * 1024) / 8,
1619
+ uploadThroughput: (500 * 1024) / 8,
1620
+ latency: 400,
1621
+ });
1622
+ });
1623
+
1624
+ // Alternative -- use context.setOffline() which works on all browsers
1625
+ test('offline mode', async ({ page }) => {
1626
+ await page.context().setOffline(true); // Works everywhere
1627
+ });
1628
+ ```
1629
+
1630
+ ### Viewport change does not trigger CSS media queries
1631
+
1632
+ **Cause**: `page.setViewportSize()` changes the viewport but does not trigger `resize` or `orientationchange` events in some frameworks that rely on JavaScript-based responsive logic rather than CSS media queries.
1633
+
1634
+ ```typescript
1635
+ // If media queries don't fire after setViewportSize, dispatch manually
1636
+ await page.setViewportSize({ width: 375, height: 667 });
1637
+ await page.evaluate(() => window.dispatchEvent(new Event('resize')));
1638
+
1639
+ // Better: use the viewport in test.use() so it's set before page load
1640
+ test.use({ viewport: { width: 375, height: 667 } });
1641
+ ```
1642
+
1643
+ ### Service worker not registering in tests
1644
+
1645
+ **Cause**: Service workers require HTTPS or localhost. Playwright's default `baseURL` of `http://localhost:3000` works, but other HTTP origins do not.
1646
+
1647
+ ```typescript
1648
+ // Ensure your baseURL is localhost or HTTPS
1649
+ // playwright.config.ts
1650
+ export default defineConfig({
1651
+ use: {
1652
+ baseURL: 'http://localhost:3000', // Works for service workers
1653
+ // baseURL: 'http://192.168.1.100:3000', // Does NOT work -- not localhost
1654
+ },
1655
+ });
1656
+
1657
+ // If testing against a non-localhost server, use HTTPS
1658
+ // or use serviceWorkers: 'allow' (default) in context options
1659
+ ```
1660
+
1661
+ ## Related
1662
+
1663
+ - [core/configuration.md](configuration.md) -- project setup with device profiles and multi-project config
1664
+ - [core/visual-regression.md](visual-regression.md) -- combine responsive testing with screenshot comparison across viewports
1665
+ - [core/network-mocking.md](network-mocking.md) -- mock API responses alongside mobile testing
1666
+ - [core/service-workers-and-pwa.md](service-workers-and-pwa.md) -- in-depth PWA testing beyond mobile context
1667
+ - [core/performance-testing.md](performance-testing.md) -- comprehensive performance testing including mobile metrics
1668
+ - [core/browser-apis.md](browser-apis.md) -- geolocation, permissions, and other browser APIs in detail
1669
+ - [ci/projects-and-dependencies.md](../ci/projects-and-dependencies.md) -- advanced multi-project patterns for responsive testing matrices