@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.
- package/README.md +293 -0
- package/extensions/dino.ts +70 -0
- package/extensions/mabl-mcp.ts +306 -0
- package/extensions/playwright-mcp.ts +142 -0
- package/package.json +47 -0
- package/prompts/create-pr.md +110 -0
- package/prompts/generate-bdd-from-url.md +65 -0
- package/prompts/generate-pom-from-url-cli.md +48 -0
- package/prompts/generate-pom-from-url.md +45 -0
- package/prompts/generate-spec-from-feature.md +64 -0
- package/prompts/heal-playwright-failures.md +106 -0
- package/prompts/setup-playwright.md +81 -0
- package/scripts/start-dino.cmd +8 -0
- package/scripts/start-dino.mjs +226 -0
- package/scripts/start-dino.ps1 +9 -0
- package/scripts/start-dino.sh +10 -0
- package/skills/acli/SKILL.md +292 -0
- package/skills/bdd-gherkin/SKILL.md +552 -0
- package/skills/build-review-interface/SKILL.md +96 -0
- package/skills/error-analysis/SKILL.md +164 -0
- package/skills/eval-audit/SKILL.md +183 -0
- package/skills/evaluate-rag/SKILL.md +177 -0
- package/skills/generate-synthetic-data/SKILL.md +131 -0
- package/skills/mabl-mcp/SKILL.md +79 -0
- package/skills/playwright-cli/SKILL.md +246 -0
- package/skills/playwright-cli/advanced-workflows.md +679 -0
- package/skills/playwright-cli/core-commands.md +375 -0
- package/skills/playwright-cli/device-emulation.md +475 -0
- package/skills/playwright-cli/request-mocking.md +373 -0
- package/skills/playwright-cli/running-custom-code.md +553 -0
- package/skills/playwright-cli/screenshots-and-media.md +457 -0
- package/skills/playwright-cli/session-management.md +425 -0
- package/skills/playwright-cli/storage-and-auth.md +487 -0
- package/skills/playwright-cli/test-generation.md +336 -0
- package/skills/playwright-cli/tracing-and-debugging.md +501 -0
- package/skills/playwright-core/SKILL.md +101 -0
- package/skills/playwright-core/accessibility.md +1517 -0
- package/skills/playwright-core/angular.md +1086 -0
- package/skills/playwright-core/api-testing.md +1623 -0
- package/skills/playwright-core/assertions-and-waiting.md +703 -0
- package/skills/playwright-core/auth-flows.md +1081 -0
- package/skills/playwright-core/authentication.md +1409 -0
- package/skills/playwright-core/browser-apis.md +673 -0
- package/skills/playwright-core/browser-extensions.md +319 -0
- package/skills/playwright-core/canvas-and-webgl.md +494 -0
- package/skills/playwright-core/clock-and-time-mocking.md +427 -0
- package/skills/playwright-core/common-pitfalls.md +1318 -0
- package/skills/playwright-core/component-testing.md +1179 -0
- package/skills/playwright-core/configuration.md +731 -0
- package/skills/playwright-core/crud-testing.md +945 -0
- package/skills/playwright-core/debugging.md +827 -0
- package/skills/playwright-core/drag-and-drop.md +992 -0
- package/skills/playwright-core/electron-testing.md +622 -0
- package/skills/playwright-core/error-and-edge-cases.md +1181 -0
- package/skills/playwright-core/error-index.md +1903 -0
- package/skills/playwright-core/file-operations.md +749 -0
- package/skills/playwright-core/file-upload-download.md +1012 -0
- package/skills/playwright-core/fixtures-and-hooks.md +1072 -0
- package/skills/playwright-core/flaky-tests.md +883 -0
- package/skills/playwright-core/forms-and-validation.md +1056 -0
- package/skills/playwright-core/i18n-and-localization.md +622 -0
- package/skills/playwright-core/iframes-and-shadow-dom.md +488 -0
- package/skills/playwright-core/locator-strategy.md +588 -0
- package/skills/playwright-core/locators.md +743 -0
- package/skills/playwright-core/mobile-and-responsive.md +1669 -0
- package/skills/playwright-core/multi-context-and-popups.md +581 -0
- package/skills/playwright-core/multi-user-and-collaboration.md +477 -0
- package/skills/playwright-core/network-mocking.md +1395 -0
- package/skills/playwright-core/nextjs.md +1013 -0
- package/skills/playwright-core/performance-testing.md +608 -0
- package/skills/playwright-core/react.md +1082 -0
- package/skills/playwright-core/search-and-filter.md +1366 -0
- package/skills/playwright-core/security-testing.md +624 -0
- package/skills/playwright-core/service-workers-and-pwa.md +524 -0
- package/skills/playwright-core/test-architecture.md +569 -0
- package/skills/playwright-core/test-data-management.md +1209 -0
- package/skills/playwright-core/test-organization.md +947 -0
- package/skills/playwright-core/third-party-integrations.md +754 -0
- package/skills/playwright-core/trace-analysis.md +161 -0
- package/skills/playwright-core/visual-regression.md +1027 -0
- package/skills/playwright-core/vue.md +1162 -0
- package/skills/playwright-core/websockets-and-realtime.md +623 -0
- package/skills/playwright-core/when-to-mock.md +827 -0
- package/skills/playwright-pom/SKILL.md +94 -0
- package/skills/playwright-pom/page-object-model.md +921 -0
- package/skills/playwright-pom/pom-vs-fixtures-vs-helpers.md +937 -0
- package/skills/validate-evaluator/SKILL.md +215 -0
- package/skills/write-judge-prompt/SKILL.md +144 -0
- 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
|