@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,1181 @@
|
|
|
1
|
+
# Error States and Edge Cases
|
|
2
|
+
|
|
3
|
+
> **When to use**: Testing how your application handles errors, failures, boundary conditions, and unusual user behavior. These tests catch bugs that happy-path tests miss.
|
|
4
|
+
> **Prerequisites**: [core/assertions-and-waiting.md](assertions-and-waiting.md), [core/network-mocking.md](network-mocking.md) for route interception
|
|
5
|
+
|
|
6
|
+
## Quick Reference
|
|
7
|
+
|
|
8
|
+
```typescript
|
|
9
|
+
// Mock a 500 server error
|
|
10
|
+
await page.route('**/api/data', (route) => route.fulfill({ status: 500 }));
|
|
11
|
+
|
|
12
|
+
// Simulate offline mode
|
|
13
|
+
await page.context().setOffline(true);
|
|
14
|
+
|
|
15
|
+
// Test empty state
|
|
16
|
+
await page.route('**/api/items', (route) =>
|
|
17
|
+
route.fulfill({ status: 200, json: [] })
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
// Browser back/forward
|
|
21
|
+
await page.goBack();
|
|
22
|
+
await page.goForward();
|
|
23
|
+
|
|
24
|
+
// Abort a network request (simulate network failure)
|
|
25
|
+
await page.route('**/api/save', (route) => route.abort('connectionfailed'));
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Patterns
|
|
29
|
+
|
|
30
|
+
### HTTP Error Status Codes
|
|
31
|
+
|
|
32
|
+
**Use when**: Testing that your application displays appropriate error pages or messages for 4xx and 5xx responses.
|
|
33
|
+
**Avoid when**: The error is handled silently (no user-facing feedback). Test via API or logs instead.
|
|
34
|
+
|
|
35
|
+
**TypeScript**
|
|
36
|
+
```typescript
|
|
37
|
+
import { test, expect } from '@playwright/test';
|
|
38
|
+
|
|
39
|
+
test('displays 404 page for missing resources', async ({ page }) => {
|
|
40
|
+
// Navigate directly to a non-existent URL
|
|
41
|
+
await page.goto('/this-page-does-not-exist');
|
|
42
|
+
|
|
43
|
+
await expect(page.getByRole('heading', { name: /not found/i })).toBeVisible();
|
|
44
|
+
await expect(page.getByRole('link', { name: 'Go home' })).toBeVisible();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('handles 500 server error gracefully', async ({ page }) => {
|
|
48
|
+
// Intercept the API call and return a 500
|
|
49
|
+
await page.route('**/api/dashboard', (route) =>
|
|
50
|
+
route.fulfill({
|
|
51
|
+
status: 500,
|
|
52
|
+
contentType: 'application/json',
|
|
53
|
+
body: JSON.stringify({ error: 'Internal server error' }),
|
|
54
|
+
})
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
await page.goto('/dashboard');
|
|
58
|
+
|
|
59
|
+
await expect(page.getByText('Something went wrong')).toBeVisible();
|
|
60
|
+
await expect(page.getByRole('button', { name: 'Try again' })).toBeVisible();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('handles 403 forbidden with redirect to login', async ({ page }) => {
|
|
64
|
+
await page.route('**/api/admin/**', (route) =>
|
|
65
|
+
route.fulfill({ status: 403 })
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
await page.goto('/admin/settings');
|
|
69
|
+
|
|
70
|
+
// Should redirect to login or show access denied
|
|
71
|
+
await expect(page.getByText(/access denied|not authorized/i)).toBeVisible();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('handles 429 rate limiting', async ({ page }) => {
|
|
75
|
+
await page.route('**/api/search*', (route) =>
|
|
76
|
+
route.fulfill({
|
|
77
|
+
status: 429,
|
|
78
|
+
headers: { 'Retry-After': '30' },
|
|
79
|
+
body: JSON.stringify({ error: 'Too many requests' }),
|
|
80
|
+
})
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
await page.goto('/search');
|
|
84
|
+
await page.getByLabel('Search').fill('test');
|
|
85
|
+
await page.getByRole('button', { name: 'Search' }).click();
|
|
86
|
+
|
|
87
|
+
await expect(page.getByText(/too many requests|try again later/i)).toBeVisible();
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**JavaScript**
|
|
92
|
+
```javascript
|
|
93
|
+
const { test, expect } = require('@playwright/test');
|
|
94
|
+
|
|
95
|
+
test('displays 404 page for missing resources', async ({ page }) => {
|
|
96
|
+
await page.goto('/this-page-does-not-exist');
|
|
97
|
+
|
|
98
|
+
await expect(page.getByRole('heading', { name: /not found/i })).toBeVisible();
|
|
99
|
+
await expect(page.getByRole('link', { name: 'Go home' })).toBeVisible();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('handles 500 server error gracefully', async ({ page }) => {
|
|
103
|
+
await page.route('**/api/dashboard', (route) =>
|
|
104
|
+
route.fulfill({
|
|
105
|
+
status: 500,
|
|
106
|
+
contentType: 'application/json',
|
|
107
|
+
body: JSON.stringify({ error: 'Internal server error' }),
|
|
108
|
+
})
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
await page.goto('/dashboard');
|
|
112
|
+
|
|
113
|
+
await expect(page.getByText('Something went wrong')).toBeVisible();
|
|
114
|
+
await expect(page.getByRole('button', { name: 'Try again' })).toBeVisible();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('handles 403 forbidden with redirect to login', async ({ page }) => {
|
|
118
|
+
await page.route('**/api/admin/**', (route) =>
|
|
119
|
+
route.fulfill({ status: 403 })
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
await page.goto('/admin/settings');
|
|
123
|
+
await expect(page.getByText(/access denied|not authorized/i)).toBeVisible();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('handles 429 rate limiting', async ({ page }) => {
|
|
127
|
+
await page.route('**/api/search*', (route) =>
|
|
128
|
+
route.fulfill({
|
|
129
|
+
status: 429,
|
|
130
|
+
headers: { 'Retry-After': '30' },
|
|
131
|
+
body: JSON.stringify({ error: 'Too many requests' }),
|
|
132
|
+
})
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
await page.goto('/search');
|
|
136
|
+
await page.getByLabel('Search').fill('test');
|
|
137
|
+
await page.getByRole('button', { name: 'Search' }).click();
|
|
138
|
+
|
|
139
|
+
await expect(page.getByText(/too many requests|try again later/i)).toBeVisible();
|
|
140
|
+
});
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Network Failure and Offline Mode
|
|
144
|
+
|
|
145
|
+
**Use when**: Testing how the app behaves when the network is down, requests fail, or the connection is intermittent.
|
|
146
|
+
**Avoid when**: The app has no offline or error handling behavior to test.
|
|
147
|
+
|
|
148
|
+
**TypeScript**
|
|
149
|
+
```typescript
|
|
150
|
+
import { test, expect } from '@playwright/test';
|
|
151
|
+
|
|
152
|
+
test('offline mode shows offline banner', async ({ page }) => {
|
|
153
|
+
await page.goto('/dashboard');
|
|
154
|
+
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
|
155
|
+
|
|
156
|
+
// Go offline
|
|
157
|
+
await page.context().setOffline(true);
|
|
158
|
+
|
|
159
|
+
// Trigger a network-dependent action
|
|
160
|
+
await page.getByRole('button', { name: 'Refresh' }).click();
|
|
161
|
+
|
|
162
|
+
await expect(page.getByText(/offline|no connection/i)).toBeVisible();
|
|
163
|
+
|
|
164
|
+
// Go back online
|
|
165
|
+
await page.context().setOffline(false);
|
|
166
|
+
|
|
167
|
+
await page.getByRole('button', { name: 'Refresh' }).click();
|
|
168
|
+
await expect(page.getByText(/offline|no connection/i)).not.toBeVisible();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('network request failure shows error state', async ({ page }) => {
|
|
172
|
+
// Abort specific requests to simulate network failure
|
|
173
|
+
await page.route('**/api/user/profile', (route) =>
|
|
174
|
+
route.abort('connectionfailed')
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
await page.goto('/profile');
|
|
178
|
+
|
|
179
|
+
await expect(page.getByText('Failed to load profile')).toBeVisible();
|
|
180
|
+
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('request timeout shows timeout message', async ({ page }) => {
|
|
184
|
+
// Delay the response beyond the app's timeout threshold
|
|
185
|
+
await page.route('**/api/reports', async (route) => {
|
|
186
|
+
await new Promise((resolve) => setTimeout(resolve, 30_000));
|
|
187
|
+
await route.fulfill({ status: 200, json: { data: [] } });
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
await page.goto('/reports');
|
|
191
|
+
|
|
192
|
+
// App should show timeout message before Playwright's own timeout
|
|
193
|
+
await expect(page.getByText(/timed out|taking too long/i)).toBeVisible({
|
|
194
|
+
timeout: 20_000,
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('intermittent connectivity — request fails then succeeds', async ({ page }) => {
|
|
199
|
+
let requestCount = 0;
|
|
200
|
+
|
|
201
|
+
await page.route('**/api/data', (route) => {
|
|
202
|
+
requestCount++;
|
|
203
|
+
if (requestCount <= 2) {
|
|
204
|
+
return route.abort('connectionfailed');
|
|
205
|
+
}
|
|
206
|
+
return route.fulfill({ status: 200, json: { items: ['a', 'b', 'c'] } });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
await page.goto('/data');
|
|
210
|
+
|
|
211
|
+
// First load fails
|
|
212
|
+
await expect(page.getByText(/failed|error/i)).toBeVisible();
|
|
213
|
+
|
|
214
|
+
// User retries — still fails
|
|
215
|
+
await page.getByRole('button', { name: 'Retry' }).click();
|
|
216
|
+
await expect(page.getByText(/failed|error/i)).toBeVisible();
|
|
217
|
+
|
|
218
|
+
// Third attempt succeeds
|
|
219
|
+
await page.getByRole('button', { name: 'Retry' }).click();
|
|
220
|
+
await expect(page.getByRole('listitem')).toHaveCount(3);
|
|
221
|
+
});
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
**JavaScript**
|
|
225
|
+
```javascript
|
|
226
|
+
const { test, expect } = require('@playwright/test');
|
|
227
|
+
|
|
228
|
+
test('offline mode shows offline banner', async ({ page }) => {
|
|
229
|
+
await page.goto('/dashboard');
|
|
230
|
+
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
|
231
|
+
|
|
232
|
+
await page.context().setOffline(true);
|
|
233
|
+
|
|
234
|
+
await page.getByRole('button', { name: 'Refresh' }).click();
|
|
235
|
+
await expect(page.getByText(/offline|no connection/i)).toBeVisible();
|
|
236
|
+
|
|
237
|
+
await page.context().setOffline(false);
|
|
238
|
+
|
|
239
|
+
await page.getByRole('button', { name: 'Refresh' }).click();
|
|
240
|
+
await expect(page.getByText(/offline|no connection/i)).not.toBeVisible();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('network request failure shows error state', async ({ page }) => {
|
|
244
|
+
await page.route('**/api/user/profile', (route) =>
|
|
245
|
+
route.abort('connectionfailed')
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
await page.goto('/profile');
|
|
249
|
+
|
|
250
|
+
await expect(page.getByText('Failed to load profile')).toBeVisible();
|
|
251
|
+
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('intermittent connectivity — request fails then succeeds', async ({ page }) => {
|
|
255
|
+
let requestCount = 0;
|
|
256
|
+
|
|
257
|
+
await page.route('**/api/data', (route) => {
|
|
258
|
+
requestCount++;
|
|
259
|
+
if (requestCount <= 2) {
|
|
260
|
+
return route.abort('connectionfailed');
|
|
261
|
+
}
|
|
262
|
+
return route.fulfill({ status: 200, json: { items: ['a', 'b', 'c'] } });
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
await page.goto('/data');
|
|
266
|
+
|
|
267
|
+
await expect(page.getByText(/failed|error/i)).toBeVisible();
|
|
268
|
+
|
|
269
|
+
await page.getByRole('button', { name: 'Retry' }).click();
|
|
270
|
+
await expect(page.getByText(/failed|error/i)).toBeVisible();
|
|
271
|
+
|
|
272
|
+
await page.getByRole('button', { name: 'Retry' }).click();
|
|
273
|
+
await expect(page.getByRole('listitem')).toHaveCount(3);
|
|
274
|
+
});
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### Aborting on Unexpected Requests (`test.abort()`, Playwright 1.60+)
|
|
278
|
+
|
|
279
|
+
**Use when**: A route handler sees a request that should *never* happen (a call to a forbidden endpoint, a leaked third-party request, a missing mock), and you'd rather fail loudly at the source than chase a confusing downstream assertion.
|
|
280
|
+
**Avoid when**: You can assert the condition in the test body — use `expect()` there. `test.abort()` shines in route handlers and fixtures, which run outside the test body.
|
|
281
|
+
|
|
282
|
+
Playwright 1.60 adds `test.abort(message?)` to stop the running test immediately with a clear message, from inside a route handler or fixture.
|
|
283
|
+
|
|
284
|
+
**TypeScript**
|
|
285
|
+
```typescript
|
|
286
|
+
import { test, expect } from '@playwright/test';
|
|
287
|
+
|
|
288
|
+
test('fails fast if the app calls a forbidden endpoint', async ({ page }) => {
|
|
289
|
+
await page.route('**/api/**', (route) => {
|
|
290
|
+
const url = route.request().url();
|
|
291
|
+
if (url.includes('/api/admin/delete-all')) {
|
|
292
|
+
// Stop now with a precise message instead of letting the page error out
|
|
293
|
+
test.abort(`Blocked dangerous request: ${url}`);
|
|
294
|
+
}
|
|
295
|
+
return route.continue();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
await page.goto('/dashboard');
|
|
299
|
+
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
|
300
|
+
});
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
**JavaScript**
|
|
304
|
+
```javascript
|
|
305
|
+
const { test, expect } = require('@playwright/test');
|
|
306
|
+
|
|
307
|
+
test('fails fast if the app calls a forbidden endpoint', async ({ page }) => {
|
|
308
|
+
await page.route('**/api/**', (route) => {
|
|
309
|
+
if (route.request().url().includes('/api/admin/delete-all')) {
|
|
310
|
+
test.abort('Blocked dangerous request');
|
|
311
|
+
}
|
|
312
|
+
return route.continue();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
await page.goto('/dashboard');
|
|
316
|
+
});
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
See [core/fixtures-and-hooks.md](fixtures-and-hooks.md) for aborting from fixture setup.
|
|
320
|
+
|
|
321
|
+
### Empty States and Boundary Testing
|
|
322
|
+
|
|
323
|
+
**Use when**: Testing what the UI shows when there is no data, when inputs are at their minimum or maximum values, or when inputs contain special characters.
|
|
324
|
+
**Avoid when**: Never. Every feature should have empty state and boundary tests.
|
|
325
|
+
|
|
326
|
+
**TypeScript**
|
|
327
|
+
```typescript
|
|
328
|
+
import { test, expect } from '@playwright/test';
|
|
329
|
+
|
|
330
|
+
test('empty state is shown when no items exist', async ({ page }) => {
|
|
331
|
+
// Mock an empty response
|
|
332
|
+
await page.route('**/api/tasks', (route) =>
|
|
333
|
+
route.fulfill({ status: 200, json: [] })
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
await page.goto('/tasks');
|
|
337
|
+
|
|
338
|
+
await expect(page.getByText('No tasks yet')).toBeVisible();
|
|
339
|
+
await expect(page.getByRole('link', { name: 'Create your first task' })).toBeVisible();
|
|
340
|
+
|
|
341
|
+
// List elements should not be present
|
|
342
|
+
await expect(page.getByRole('listitem')).toHaveCount(0);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test('handles maximum length input', async ({ page }) => {
|
|
346
|
+
await page.goto('/profile');
|
|
347
|
+
|
|
348
|
+
// Fill with max-length string
|
|
349
|
+
const maxLengthName = 'A'.repeat(255);
|
|
350
|
+
await page.getByLabel('Display name').fill(maxLengthName);
|
|
351
|
+
await page.getByRole('button', { name: 'Save' }).click();
|
|
352
|
+
|
|
353
|
+
// Verify the name was saved (or truncated, depending on app behavior)
|
|
354
|
+
await expect(page.getByText('Profile updated')).toBeVisible();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test('handles special characters in input', async ({ page }) => {
|
|
358
|
+
await page.goto('/search');
|
|
359
|
+
|
|
360
|
+
const specialInputs = [
|
|
361
|
+
'<script>alert("xss")</script>',
|
|
362
|
+
'"; DROP TABLE users; --',
|
|
363
|
+
'unicode: \u00e9\u00e0\u00fc\u00f1 \u4f60\u597d \ud83d\ude80',
|
|
364
|
+
'null bytes: \x00\x01\x02',
|
|
365
|
+
'path traversal: ../../etc/passwd',
|
|
366
|
+
];
|
|
367
|
+
|
|
368
|
+
for (const input of specialInputs) {
|
|
369
|
+
await page.getByLabel('Search').fill(input);
|
|
370
|
+
await page.getByRole('button', { name: 'Search' }).click();
|
|
371
|
+
|
|
372
|
+
// App should not crash — either show results or "no results"
|
|
373
|
+
await expect(
|
|
374
|
+
page.getByText(/results|no results|no matches/i)
|
|
375
|
+
).toBeVisible();
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test('handles zero, one, and many items (0-1-N pattern)', async ({ page }) => {
|
|
380
|
+
// Zero items
|
|
381
|
+
await page.route('**/api/notifications', (route) =>
|
|
382
|
+
route.fulfill({ status: 200, json: [] })
|
|
383
|
+
);
|
|
384
|
+
await page.goto('/notifications');
|
|
385
|
+
await expect(page.getByText('No notifications')).toBeVisible();
|
|
386
|
+
|
|
387
|
+
// One item
|
|
388
|
+
await page.route('**/api/notifications', (route) =>
|
|
389
|
+
route.fulfill({
|
|
390
|
+
status: 200,
|
|
391
|
+
json: [{ id: 1, message: 'Welcome!' }],
|
|
392
|
+
})
|
|
393
|
+
);
|
|
394
|
+
await page.reload();
|
|
395
|
+
await expect(page.getByRole('listitem')).toHaveCount(1);
|
|
396
|
+
await expect(page.getByText('No notifications')).not.toBeVisible();
|
|
397
|
+
|
|
398
|
+
// Many items — verify pagination or "load more"
|
|
399
|
+
await page.route('**/api/notifications', (route) =>
|
|
400
|
+
route.fulfill({
|
|
401
|
+
status: 200,
|
|
402
|
+
json: Array.from({ length: 50 }, (_, i) => ({
|
|
403
|
+
id: i + 1,
|
|
404
|
+
message: `Notification ${i + 1}`,
|
|
405
|
+
})),
|
|
406
|
+
})
|
|
407
|
+
);
|
|
408
|
+
await page.reload();
|
|
409
|
+
await expect(page.getByRole('listitem').first()).toBeVisible();
|
|
410
|
+
await expect(page.getByRole('button', { name: /load more|show all/i })).toBeVisible();
|
|
411
|
+
});
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
**JavaScript**
|
|
415
|
+
```javascript
|
|
416
|
+
const { test, expect } = require('@playwright/test');
|
|
417
|
+
|
|
418
|
+
test('empty state is shown when no items exist', async ({ page }) => {
|
|
419
|
+
await page.route('**/api/tasks', (route) =>
|
|
420
|
+
route.fulfill({ status: 200, json: [] })
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
await page.goto('/tasks');
|
|
424
|
+
|
|
425
|
+
await expect(page.getByText('No tasks yet')).toBeVisible();
|
|
426
|
+
await expect(page.getByRole('link', { name: 'Create your first task' })).toBeVisible();
|
|
427
|
+
await expect(page.getByRole('listitem')).toHaveCount(0);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test('handles maximum length input', async ({ page }) => {
|
|
431
|
+
await page.goto('/profile');
|
|
432
|
+
|
|
433
|
+
const maxLengthName = 'A'.repeat(255);
|
|
434
|
+
await page.getByLabel('Display name').fill(maxLengthName);
|
|
435
|
+
await page.getByRole('button', { name: 'Save' }).click();
|
|
436
|
+
|
|
437
|
+
await expect(page.getByText('Profile updated')).toBeVisible();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test('handles special characters in input', async ({ page }) => {
|
|
441
|
+
await page.goto('/search');
|
|
442
|
+
|
|
443
|
+
const specialInputs = [
|
|
444
|
+
'<script>alert("xss")</script>',
|
|
445
|
+
'"; DROP TABLE users; --',
|
|
446
|
+
'unicode: \u00e9\u00e0\u00fc\u00f1 \u4f60\u597d \ud83d\ude80',
|
|
447
|
+
'path traversal: ../../etc/passwd',
|
|
448
|
+
];
|
|
449
|
+
|
|
450
|
+
for (const input of specialInputs) {
|
|
451
|
+
await page.getByLabel('Search').fill(input);
|
|
452
|
+
await page.getByRole('button', { name: 'Search' }).click();
|
|
453
|
+
|
|
454
|
+
await expect(
|
|
455
|
+
page.getByText(/results|no results|no matches/i)
|
|
456
|
+
).toBeVisible();
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test('handles zero, one, and many items (0-1-N pattern)', async ({ page }) => {
|
|
461
|
+
await page.route('**/api/notifications', (route) =>
|
|
462
|
+
route.fulfill({ status: 200, json: [] })
|
|
463
|
+
);
|
|
464
|
+
await page.goto('/notifications');
|
|
465
|
+
await expect(page.getByText('No notifications')).toBeVisible();
|
|
466
|
+
|
|
467
|
+
await page.route('**/api/notifications', (route) =>
|
|
468
|
+
route.fulfill({
|
|
469
|
+
status: 200,
|
|
470
|
+
json: [{ id: 1, message: 'Welcome!' }],
|
|
471
|
+
})
|
|
472
|
+
);
|
|
473
|
+
await page.reload();
|
|
474
|
+
await expect(page.getByRole('listitem')).toHaveCount(1);
|
|
475
|
+
|
|
476
|
+
await page.route('**/api/notifications', (route) =>
|
|
477
|
+
route.fulfill({
|
|
478
|
+
status: 200,
|
|
479
|
+
json: Array.from({ length: 50 }, (_, i) => ({
|
|
480
|
+
id: i + 1,
|
|
481
|
+
message: `Notification ${i + 1}`,
|
|
482
|
+
})),
|
|
483
|
+
})
|
|
484
|
+
);
|
|
485
|
+
await page.reload();
|
|
486
|
+
await expect(page.getByRole('listitem').first()).toBeVisible();
|
|
487
|
+
await expect(page.getByRole('button', { name: /load more|show all/i })).toBeVisible();
|
|
488
|
+
});
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
### Loading States and Skeletons
|
|
492
|
+
|
|
493
|
+
**Use when**: Verifying that loading indicators, skeleton screens, or spinners appear during data fetching and disappear when data arrives.
|
|
494
|
+
**Avoid when**: The application renders synchronously with no loading indicators (SSR without client-side fetching).
|
|
495
|
+
|
|
496
|
+
**TypeScript**
|
|
497
|
+
```typescript
|
|
498
|
+
import { test, expect } from '@playwright/test';
|
|
499
|
+
|
|
500
|
+
test('loading skeleton appears and resolves', async ({ page }) => {
|
|
501
|
+
// Delay the API response to observe the loading state
|
|
502
|
+
let resolveResponse: () => void;
|
|
503
|
+
const responseReady = new Promise<void>((resolve) => {
|
|
504
|
+
resolveResponse = resolve;
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
await page.route('**/api/dashboard', async (route) => {
|
|
508
|
+
await responseReady;
|
|
509
|
+
await route.fulfill({
|
|
510
|
+
status: 200,
|
|
511
|
+
json: { revenue: 12400, users: 350 },
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
await page.goto('/dashboard');
|
|
516
|
+
|
|
517
|
+
// Skeleton should be visible while loading
|
|
518
|
+
await expect(page.getByTestId('skeleton-revenue')).toBeVisible();
|
|
519
|
+
await expect(page.getByTestId('skeleton-users')).toBeVisible();
|
|
520
|
+
|
|
521
|
+
// Real content should not be visible yet
|
|
522
|
+
await expect(page.getByText('$12,400')).not.toBeVisible();
|
|
523
|
+
|
|
524
|
+
// Release the response
|
|
525
|
+
resolveResponse!();
|
|
526
|
+
|
|
527
|
+
// Skeleton disappears, real content appears
|
|
528
|
+
await expect(page.getByTestId('skeleton-revenue')).not.toBeVisible();
|
|
529
|
+
await expect(page.getByText('$12,400')).toBeVisible();
|
|
530
|
+
await expect(page.getByText('350')).toBeVisible();
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test('spinner shown during form submission', async ({ page }) => {
|
|
534
|
+
let resolveSubmit: () => void;
|
|
535
|
+
const submitReady = new Promise<void>((resolve) => {
|
|
536
|
+
resolveSubmit = resolve;
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
await page.route('**/api/contact', async (route) => {
|
|
540
|
+
await submitReady;
|
|
541
|
+
await route.fulfill({ status: 200, json: { success: true } });
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
await page.goto('/contact');
|
|
545
|
+
await page.getByLabel('Name').fill('Jane');
|
|
546
|
+
await page.getByLabel('Email').fill('jane@example.com');
|
|
547
|
+
await page.getByLabel('Message').fill('Test');
|
|
548
|
+
|
|
549
|
+
await page.getByRole('button', { name: 'Send' }).click();
|
|
550
|
+
|
|
551
|
+
// Loading spinner/state during submission
|
|
552
|
+
await expect(page.getByRole('button', { name: /sending/i })).toBeVisible();
|
|
553
|
+
await expect(page.getByRole('button', { name: /sending/i })).toBeDisabled();
|
|
554
|
+
|
|
555
|
+
// Complete the submission
|
|
556
|
+
resolveSubmit!();
|
|
557
|
+
|
|
558
|
+
await expect(page.getByText('Message sent')).toBeVisible();
|
|
559
|
+
});
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
**JavaScript**
|
|
563
|
+
```javascript
|
|
564
|
+
const { test, expect } = require('@playwright/test');
|
|
565
|
+
|
|
566
|
+
test('loading skeleton appears and resolves', async ({ page }) => {
|
|
567
|
+
let resolveResponse;
|
|
568
|
+
const responseReady = new Promise((resolve) => {
|
|
569
|
+
resolveResponse = resolve;
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
await page.route('**/api/dashboard', async (route) => {
|
|
573
|
+
await responseReady;
|
|
574
|
+
await route.fulfill({
|
|
575
|
+
status: 200,
|
|
576
|
+
json: { revenue: 12400, users: 350 },
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
await page.goto('/dashboard');
|
|
581
|
+
|
|
582
|
+
await expect(page.getByTestId('skeleton-revenue')).toBeVisible();
|
|
583
|
+
await expect(page.getByTestId('skeleton-users')).toBeVisible();
|
|
584
|
+
await expect(page.getByText('$12,400')).not.toBeVisible();
|
|
585
|
+
|
|
586
|
+
resolveResponse();
|
|
587
|
+
|
|
588
|
+
await expect(page.getByTestId('skeleton-revenue')).not.toBeVisible();
|
|
589
|
+
await expect(page.getByText('$12,400')).toBeVisible();
|
|
590
|
+
await expect(page.getByText('350')).toBeVisible();
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test('spinner shown during form submission', async ({ page }) => {
|
|
594
|
+
let resolveSubmit;
|
|
595
|
+
const submitReady = new Promise((resolve) => {
|
|
596
|
+
resolveSubmit = resolve;
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
await page.route('**/api/contact', async (route) => {
|
|
600
|
+
await submitReady;
|
|
601
|
+
await route.fulfill({ status: 200, json: { success: true } });
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
await page.goto('/contact');
|
|
605
|
+
await page.getByLabel('Name').fill('Jane');
|
|
606
|
+
await page.getByLabel('Email').fill('jane@example.com');
|
|
607
|
+
await page.getByLabel('Message').fill('Test');
|
|
608
|
+
|
|
609
|
+
await page.getByRole('button', { name: 'Send' }).click();
|
|
610
|
+
|
|
611
|
+
await expect(page.getByRole('button', { name: /sending/i })).toBeVisible();
|
|
612
|
+
await expect(page.getByRole('button', { name: /sending/i })).toBeDisabled();
|
|
613
|
+
|
|
614
|
+
resolveSubmit();
|
|
615
|
+
|
|
616
|
+
await expect(page.getByText('Message sent')).toBeVisible();
|
|
617
|
+
});
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
### Retry Behavior Testing
|
|
621
|
+
|
|
622
|
+
**Use when**: Testing that the application retries failed requests automatically or via a user-triggered "retry" button.
|
|
623
|
+
**Avoid when**: The application has no retry mechanism.
|
|
624
|
+
|
|
625
|
+
**TypeScript**
|
|
626
|
+
```typescript
|
|
627
|
+
import { test, expect } from '@playwright/test';
|
|
628
|
+
|
|
629
|
+
test('retry button recovers from a failed API call', async ({ page }) => {
|
|
630
|
+
let callCount = 0;
|
|
631
|
+
|
|
632
|
+
await page.route('**/api/feed', (route) => {
|
|
633
|
+
callCount++;
|
|
634
|
+
if (callCount === 1) {
|
|
635
|
+
return route.fulfill({ status: 500 });
|
|
636
|
+
}
|
|
637
|
+
return route.fulfill({
|
|
638
|
+
status: 200,
|
|
639
|
+
json: { posts: [{ id: 1, title: 'Hello World' }] },
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
await page.goto('/feed');
|
|
644
|
+
|
|
645
|
+
// First load fails
|
|
646
|
+
await expect(page.getByText(/something went wrong/i)).toBeVisible();
|
|
647
|
+
|
|
648
|
+
// Click retry — second call succeeds
|
|
649
|
+
await page.getByRole('button', { name: 'Try again' }).click();
|
|
650
|
+
|
|
651
|
+
await expect(page.getByText('Hello World')).toBeVisible();
|
|
652
|
+
expect(callCount).toBe(2);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
test('automatic retry with exponential backoff', async ({ page }) => {
|
|
656
|
+
const callTimestamps: number[] = [];
|
|
657
|
+
|
|
658
|
+
await page.route('**/api/status', (route) => {
|
|
659
|
+
callTimestamps.push(Date.now());
|
|
660
|
+
if (callTimestamps.length <= 3) {
|
|
661
|
+
return route.fulfill({ status: 503 });
|
|
662
|
+
}
|
|
663
|
+
return route.fulfill({ status: 200, json: { status: 'ok' } });
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
await page.goto('/status');
|
|
667
|
+
|
|
668
|
+
// Wait for the auto-retry to eventually succeed
|
|
669
|
+
await expect(page.getByText('System operational')).toBeVisible({
|
|
670
|
+
timeout: 30_000,
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
// Verify multiple retry attempts were made
|
|
674
|
+
expect(callTimestamps.length).toBeGreaterThanOrEqual(4);
|
|
675
|
+
|
|
676
|
+
// Verify backoff: gaps between retries should increase
|
|
677
|
+
if (callTimestamps.length >= 3) {
|
|
678
|
+
const gap1 = callTimestamps[1] - callTimestamps[0];
|
|
679
|
+
const gap2 = callTimestamps[2] - callTimestamps[1];
|
|
680
|
+
expect(gap2).toBeGreaterThanOrEqual(gap1);
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
**JavaScript**
|
|
686
|
+
```javascript
|
|
687
|
+
const { test, expect } = require('@playwright/test');
|
|
688
|
+
|
|
689
|
+
test('retry button recovers from a failed API call', async ({ page }) => {
|
|
690
|
+
let callCount = 0;
|
|
691
|
+
|
|
692
|
+
await page.route('**/api/feed', (route) => {
|
|
693
|
+
callCount++;
|
|
694
|
+
if (callCount === 1) {
|
|
695
|
+
return route.fulfill({ status: 500 });
|
|
696
|
+
}
|
|
697
|
+
return route.fulfill({
|
|
698
|
+
status: 200,
|
|
699
|
+
json: { posts: [{ id: 1, title: 'Hello World' }] },
|
|
700
|
+
});
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
await page.goto('/feed');
|
|
704
|
+
|
|
705
|
+
await expect(page.getByText(/something went wrong/i)).toBeVisible();
|
|
706
|
+
|
|
707
|
+
await page.getByRole('button', { name: 'Try again' }).click();
|
|
708
|
+
|
|
709
|
+
await expect(page.getByText('Hello World')).toBeVisible();
|
|
710
|
+
expect(callCount).toBe(2);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
test('automatic retry with exponential backoff', async ({ page }) => {
|
|
714
|
+
const callTimestamps = [];
|
|
715
|
+
|
|
716
|
+
await page.route('**/api/status', (route) => {
|
|
717
|
+
callTimestamps.push(Date.now());
|
|
718
|
+
if (callTimestamps.length <= 3) {
|
|
719
|
+
return route.fulfill({ status: 503 });
|
|
720
|
+
}
|
|
721
|
+
return route.fulfill({ status: 200, json: { status: 'ok' } });
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
await page.goto('/status');
|
|
725
|
+
|
|
726
|
+
await expect(page.getByText('System operational')).toBeVisible({
|
|
727
|
+
timeout: 30_000,
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
expect(callTimestamps.length).toBeGreaterThanOrEqual(4);
|
|
731
|
+
});
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
### Browser Back/Forward Navigation
|
|
735
|
+
|
|
736
|
+
**Use when**: Testing that the application handles browser history navigation correctly — preserving state, URL updates, and content after going back or forward.
|
|
737
|
+
**Avoid when**: The app is a single-page application that does not use the browser history API. Focus on client-side routing tests instead.
|
|
738
|
+
|
|
739
|
+
**TypeScript**
|
|
740
|
+
```typescript
|
|
741
|
+
import { test, expect } from '@playwright/test';
|
|
742
|
+
|
|
743
|
+
test('browser back preserves navigation context', async ({ page }) => {
|
|
744
|
+
await page.goto('/products');
|
|
745
|
+
await page.getByRole('link', { name: 'Running Shoes' }).click();
|
|
746
|
+
await page.waitForURL('**/products/running-shoes');
|
|
747
|
+
|
|
748
|
+
// Go back
|
|
749
|
+
await page.goBack();
|
|
750
|
+
|
|
751
|
+
await expect(page).toHaveURL(/\/products$/);
|
|
752
|
+
await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
test('browser forward returns to previous page', async ({ page }) => {
|
|
756
|
+
await page.goto('/products');
|
|
757
|
+
await page.getByRole('link', { name: 'Running Shoes' }).click();
|
|
758
|
+
await page.waitForURL('**/products/running-shoes');
|
|
759
|
+
|
|
760
|
+
await page.goBack();
|
|
761
|
+
await page.goForward();
|
|
762
|
+
|
|
763
|
+
await expect(page).toHaveURL(/\/products\/running-shoes/);
|
|
764
|
+
await expect(page.getByRole('heading', { name: 'Running Shoes' })).toBeVisible();
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
test('form state after browser back', async ({ page }) => {
|
|
768
|
+
await page.goto('/checkout');
|
|
769
|
+
|
|
770
|
+
// Fill form on step 1
|
|
771
|
+
await page.getByLabel('Address').fill('123 Main St');
|
|
772
|
+
await page.getByRole('button', { name: 'Continue' }).click();
|
|
773
|
+
|
|
774
|
+
// Arrived at step 2
|
|
775
|
+
await expect(page.getByRole('heading', { name: 'Payment' })).toBeVisible();
|
|
776
|
+
|
|
777
|
+
// Go back to step 1
|
|
778
|
+
await page.goBack();
|
|
779
|
+
|
|
780
|
+
// Data should be preserved (depends on app implementation)
|
|
781
|
+
await expect(page.getByLabel('Address')).toHaveValue('123 Main St');
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
test('back button after form submission does not re-submit', async ({ page }) => {
|
|
785
|
+
await page.goto('/contact');
|
|
786
|
+
|
|
787
|
+
await page.getByLabel('Name').fill('Jane');
|
|
788
|
+
await page.getByLabel('Email').fill('jane@example.com');
|
|
789
|
+
await page.getByLabel('Message').fill('Test');
|
|
790
|
+
await page.getByRole('button', { name: 'Send' }).click();
|
|
791
|
+
|
|
792
|
+
await expect(page.getByText('Message sent')).toBeVisible();
|
|
793
|
+
|
|
794
|
+
// Going back should show the form, not re-submit
|
|
795
|
+
await page.goBack();
|
|
796
|
+
await expect(page.getByLabel('Name')).toBeVisible();
|
|
797
|
+
});
|
|
798
|
+
```
|
|
799
|
+
|
|
800
|
+
**JavaScript**
|
|
801
|
+
```javascript
|
|
802
|
+
const { test, expect } = require('@playwright/test');
|
|
803
|
+
|
|
804
|
+
test('browser back preserves navigation context', async ({ page }) => {
|
|
805
|
+
await page.goto('/products');
|
|
806
|
+
await page.getByRole('link', { name: 'Running Shoes' }).click();
|
|
807
|
+
await page.waitForURL('**/products/running-shoes');
|
|
808
|
+
|
|
809
|
+
await page.goBack();
|
|
810
|
+
|
|
811
|
+
await expect(page).toHaveURL(/\/products$/);
|
|
812
|
+
await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
test('browser forward returns to previous page', async ({ page }) => {
|
|
816
|
+
await page.goto('/products');
|
|
817
|
+
await page.getByRole('link', { name: 'Running Shoes' }).click();
|
|
818
|
+
await page.waitForURL('**/products/running-shoes');
|
|
819
|
+
|
|
820
|
+
await page.goBack();
|
|
821
|
+
await page.goForward();
|
|
822
|
+
|
|
823
|
+
await expect(page).toHaveURL(/\/products\/running-shoes/);
|
|
824
|
+
await expect(page.getByRole('heading', { name: 'Running Shoes' })).toBeVisible();
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
test('form state after browser back', async ({ page }) => {
|
|
828
|
+
await page.goto('/checkout');
|
|
829
|
+
|
|
830
|
+
await page.getByLabel('Address').fill('123 Main St');
|
|
831
|
+
await page.getByRole('button', { name: 'Continue' }).click();
|
|
832
|
+
|
|
833
|
+
await expect(page.getByRole('heading', { name: 'Payment' })).toBeVisible();
|
|
834
|
+
|
|
835
|
+
await page.goBack();
|
|
836
|
+
await expect(page.getByLabel('Address')).toHaveValue('123 Main St');
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
test('back button after form submission does not re-submit', async ({ page }) => {
|
|
840
|
+
await page.goto('/contact');
|
|
841
|
+
|
|
842
|
+
await page.getByLabel('Name').fill('Jane');
|
|
843
|
+
await page.getByLabel('Email').fill('jane@example.com');
|
|
844
|
+
await page.getByLabel('Message').fill('Test');
|
|
845
|
+
await page.getByRole('button', { name: 'Send' }).click();
|
|
846
|
+
|
|
847
|
+
await expect(page.getByText('Message sent')).toBeVisible();
|
|
848
|
+
|
|
849
|
+
await page.goBack();
|
|
850
|
+
await expect(page.getByLabel('Name')).toBeVisible();
|
|
851
|
+
});
|
|
852
|
+
```
|
|
853
|
+
|
|
854
|
+
### Concurrent User Actions
|
|
855
|
+
|
|
856
|
+
**Use when**: Testing that rapid user interactions do not cause race conditions — double-clicking submit, typing while data is loading, navigating during an async operation.
|
|
857
|
+
**Avoid when**: The UI has no async operations that could conflict with user actions.
|
|
858
|
+
|
|
859
|
+
**TypeScript**
|
|
860
|
+
```typescript
|
|
861
|
+
import { test, expect } from '@playwright/test';
|
|
862
|
+
|
|
863
|
+
test('double-click submit does not create duplicate entries', async ({ page }) => {
|
|
864
|
+
const requests: string[] = [];
|
|
865
|
+
|
|
866
|
+
await page.route('**/api/orders', (route) => {
|
|
867
|
+
requests.push(route.request().method());
|
|
868
|
+
return route.fulfill({
|
|
869
|
+
status: 201,
|
|
870
|
+
json: { id: 1, status: 'created' },
|
|
871
|
+
});
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
await page.goto('/checkout');
|
|
875
|
+
await page.getByLabel('Item').fill('Widget');
|
|
876
|
+
|
|
877
|
+
const submitButton = page.getByRole('button', { name: 'Place order' });
|
|
878
|
+
|
|
879
|
+
// Rapid double-click
|
|
880
|
+
await submitButton.dblclick();
|
|
881
|
+
|
|
882
|
+
// Wait for the result
|
|
883
|
+
await expect(page.getByText('Order confirmed')).toBeVisible();
|
|
884
|
+
|
|
885
|
+
// The app should prevent duplicate submissions
|
|
886
|
+
// (button disabled after first click, or server deduplication)
|
|
887
|
+
expect(requests.filter((m) => m === 'POST').length).toBeLessThanOrEqual(1);
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
test('typing during navigation does not crash', async ({ page }) => {
|
|
891
|
+
await page.goto('/search');
|
|
892
|
+
|
|
893
|
+
// Start typing and immediately navigate
|
|
894
|
+
await page.getByLabel('Search').pressSequentially('test query', { delay: 30 });
|
|
895
|
+
await page.getByRole('link', { name: 'Home' }).click();
|
|
896
|
+
|
|
897
|
+
// Should arrive at home page without errors
|
|
898
|
+
await page.waitForURL('**/');
|
|
899
|
+
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
test('rapid filter changes use latest value', async ({ page }) => {
|
|
903
|
+
const requestUrls: string[] = [];
|
|
904
|
+
|
|
905
|
+
await page.route('**/api/products*', (route) => {
|
|
906
|
+
requestUrls.push(route.request().url());
|
|
907
|
+
return route.fulfill({
|
|
908
|
+
status: 200,
|
|
909
|
+
json: { products: [{ name: 'Latest result' }] },
|
|
910
|
+
});
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
await page.goto('/products');
|
|
914
|
+
|
|
915
|
+
// Rapidly change the filter
|
|
916
|
+
await page.getByLabel('Category').selectOption('electronics');
|
|
917
|
+
await page.getByLabel('Category').selectOption('clothing');
|
|
918
|
+
await page.getByLabel('Category').selectOption('books');
|
|
919
|
+
|
|
920
|
+
// Wait for the final result
|
|
921
|
+
await expect(page.getByText('Latest result')).toBeVisible();
|
|
922
|
+
|
|
923
|
+
// The UI should show results for "books", not an earlier selection
|
|
924
|
+
// Some apps debounce; some cancel in-flight requests
|
|
925
|
+
});
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
**JavaScript**
|
|
929
|
+
```javascript
|
|
930
|
+
const { test, expect } = require('@playwright/test');
|
|
931
|
+
|
|
932
|
+
test('double-click submit does not create duplicate entries', async ({ page }) => {
|
|
933
|
+
const requests = [];
|
|
934
|
+
|
|
935
|
+
await page.route('**/api/orders', (route) => {
|
|
936
|
+
requests.push(route.request().method());
|
|
937
|
+
return route.fulfill({
|
|
938
|
+
status: 201,
|
|
939
|
+
json: { id: 1, status: 'created' },
|
|
940
|
+
});
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
await page.goto('/checkout');
|
|
944
|
+
await page.getByLabel('Item').fill('Widget');
|
|
945
|
+
|
|
946
|
+
const submitButton = page.getByRole('button', { name: 'Place order' });
|
|
947
|
+
await submitButton.dblclick();
|
|
948
|
+
|
|
949
|
+
await expect(page.getByText('Order confirmed')).toBeVisible();
|
|
950
|
+
|
|
951
|
+
expect(requests.filter((m) => m === 'POST').length).toBeLessThanOrEqual(1);
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
test('typing during navigation does not crash', async ({ page }) => {
|
|
955
|
+
await page.goto('/search');
|
|
956
|
+
|
|
957
|
+
await page.getByLabel('Search').pressSequentially('test query', { delay: 30 });
|
|
958
|
+
await page.getByRole('link', { name: 'Home' }).click();
|
|
959
|
+
|
|
960
|
+
await page.waitForURL('**/');
|
|
961
|
+
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
test('rapid filter changes use latest value', async ({ page }) => {
|
|
965
|
+
const requestUrls = [];
|
|
966
|
+
|
|
967
|
+
await page.route('**/api/products*', (route) => {
|
|
968
|
+
requestUrls.push(route.request().url());
|
|
969
|
+
return route.fulfill({
|
|
970
|
+
status: 200,
|
|
971
|
+
json: { products: [{ name: 'Latest result' }] },
|
|
972
|
+
});
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
await page.goto('/products');
|
|
976
|
+
|
|
977
|
+
await page.getByLabel('Category').selectOption('electronics');
|
|
978
|
+
await page.getByLabel('Category').selectOption('clothing');
|
|
979
|
+
await page.getByLabel('Category').selectOption('books');
|
|
980
|
+
|
|
981
|
+
await expect(page.getByText('Latest result')).toBeVisible();
|
|
982
|
+
});
|
|
983
|
+
```
|
|
984
|
+
|
|
985
|
+
### Graceful Degradation
|
|
986
|
+
|
|
987
|
+
**Use when**: Testing that the app continues to function when non-critical services fail (analytics, chat widget, recommendations).
|
|
988
|
+
**Avoid when**: The failing service is critical to the core workflow.
|
|
989
|
+
|
|
990
|
+
**TypeScript**
|
|
991
|
+
```typescript
|
|
992
|
+
import { test, expect } from '@playwright/test';
|
|
993
|
+
|
|
994
|
+
test('page works when analytics service fails', async ({ page }) => {
|
|
995
|
+
// Block analytics and tracking scripts
|
|
996
|
+
await page.route('**/analytics/**', (route) => route.abort());
|
|
997
|
+
await page.route('**/tracking/**', (route) => route.abort());
|
|
998
|
+
|
|
999
|
+
await page.goto('/products');
|
|
1000
|
+
|
|
1001
|
+
// Core functionality still works
|
|
1002
|
+
await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();
|
|
1003
|
+
await page.getByRole('link', { name: 'Running Shoes' }).click();
|
|
1004
|
+
await expect(page.getByRole('button', { name: 'Add to cart' })).toBeEnabled();
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
test('page works when recommendation engine fails', async ({ page }) => {
|
|
1008
|
+
await page.route('**/api/recommendations', (route) =>
|
|
1009
|
+
route.fulfill({ status: 500 })
|
|
1010
|
+
);
|
|
1011
|
+
|
|
1012
|
+
await page.goto('/products/running-shoes');
|
|
1013
|
+
|
|
1014
|
+
// Main product content loads
|
|
1015
|
+
await expect(page.getByRole('heading', { name: 'Running Shoes' })).toBeVisible();
|
|
1016
|
+
await expect(page.getByRole('button', { name: 'Add to cart' })).toBeEnabled();
|
|
1017
|
+
|
|
1018
|
+
// Recommendations section shows fallback
|
|
1019
|
+
await expect(
|
|
1020
|
+
page.getByText(/recommendations unavailable|you may also like/i)
|
|
1021
|
+
).toBeVisible();
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
test('page works when third-party chat widget fails to load', async ({ page }) => {
|
|
1025
|
+
// Block the chat widget script
|
|
1026
|
+
await page.route('**/chat-widget.js', (route) => route.abort());
|
|
1027
|
+
|
|
1028
|
+
await page.goto('/support');
|
|
1029
|
+
|
|
1030
|
+
// Core support page loads
|
|
1031
|
+
await expect(page.getByRole('heading', { name: 'Help Center' })).toBeVisible();
|
|
1032
|
+
|
|
1033
|
+
// No console errors from the blocked widget should crash the page
|
|
1034
|
+
const errors: string[] = [];
|
|
1035
|
+
page.on('pageerror', (error) => errors.push(error.message));
|
|
1036
|
+
|
|
1037
|
+
// Navigate around to ensure no cascade failures
|
|
1038
|
+
await page.getByRole('link', { name: 'FAQ' }).click();
|
|
1039
|
+
await expect(page.getByRole('heading', { name: 'FAQ' })).toBeVisible();
|
|
1040
|
+
});
|
|
1041
|
+
```
|
|
1042
|
+
|
|
1043
|
+
**JavaScript**
|
|
1044
|
+
```javascript
|
|
1045
|
+
const { test, expect } = require('@playwright/test');
|
|
1046
|
+
|
|
1047
|
+
test('page works when analytics service fails', async ({ page }) => {
|
|
1048
|
+
await page.route('**/analytics/**', (route) => route.abort());
|
|
1049
|
+
await page.route('**/tracking/**', (route) => route.abort());
|
|
1050
|
+
|
|
1051
|
+
await page.goto('/products');
|
|
1052
|
+
|
|
1053
|
+
await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();
|
|
1054
|
+
await page.getByRole('link', { name: 'Running Shoes' }).click();
|
|
1055
|
+
await expect(page.getByRole('button', { name: 'Add to cart' })).toBeEnabled();
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
test('page works when recommendation engine fails', async ({ page }) => {
|
|
1059
|
+
await page.route('**/api/recommendations', (route) =>
|
|
1060
|
+
route.fulfill({ status: 500 })
|
|
1061
|
+
);
|
|
1062
|
+
|
|
1063
|
+
await page.goto('/products/running-shoes');
|
|
1064
|
+
|
|
1065
|
+
await expect(page.getByRole('heading', { name: 'Running Shoes' })).toBeVisible();
|
|
1066
|
+
await expect(page.getByRole('button', { name: 'Add to cart' })).toBeEnabled();
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
test('page works when third-party chat widget fails to load', async ({ page }) => {
|
|
1070
|
+
await page.route('**/chat-widget.js', (route) => route.abort());
|
|
1071
|
+
|
|
1072
|
+
await page.goto('/support');
|
|
1073
|
+
|
|
1074
|
+
await expect(page.getByRole('heading', { name: 'Help Center' })).toBeVisible();
|
|
1075
|
+
|
|
1076
|
+
await page.getByRole('link', { name: 'FAQ' }).click();
|
|
1077
|
+
await expect(page.getByRole('heading', { name: 'FAQ' })).toBeVisible();
|
|
1078
|
+
});
|
|
1079
|
+
```
|
|
1080
|
+
|
|
1081
|
+
## Decision Guide
|
|
1082
|
+
|
|
1083
|
+
| Scenario | Approach | Key API |
|
|
1084
|
+
|---|---|---|
|
|
1085
|
+
| 404 page | Navigate to non-existent URL, assert error page | `page.goto('/nonexistent')` |
|
|
1086
|
+
| 500 server error | Mock route with `status: 500` | `page.route(url, route => route.fulfill({ status: 500 }))` |
|
|
1087
|
+
| Network failure | Abort the route | `route.abort('connectionfailed')` |
|
|
1088
|
+
| Offline mode | Toggle offline on the browser context | `page.context().setOffline(true)` |
|
|
1089
|
+
| Slow response | Delay route fulfillment with a Promise | `await new Promise(r => setTimeout(r, delay))` in route handler |
|
|
1090
|
+
| Empty state | Mock API to return empty array | `route.fulfill({ json: [] })` |
|
|
1091
|
+
| Boundary values | Fill inputs with min/max/special values | `locator.fill('A'.repeat(255))` |
|
|
1092
|
+
| Loading skeleton | Delay route, assert skeleton visible, release, assert content | Promise-based route handler |
|
|
1093
|
+
| Retry behavior | Track route call count, fail first N, succeed after | Counter in route handler |
|
|
1094
|
+
| Browser history | Use `page.goBack()` and `page.goForward()` | Assert URL and content after navigation |
|
|
1095
|
+
| Double submit | `dblclick()` on submit, verify single POST | Track requests in route handler |
|
|
1096
|
+
| Third-party failure | Abort non-critical routes, verify core works | `route.abort()` on optional services |
|
|
1097
|
+
| Console error monitoring | Listen for `pageerror` event | `page.on('pageerror', handler)` |
|
|
1098
|
+
|
|
1099
|
+
## Anti-Patterns
|
|
1100
|
+
|
|
1101
|
+
| Don't Do This | Problem | Do This Instead |
|
|
1102
|
+
|---|---|---|
|
|
1103
|
+
| Only testing the happy path | Real users hit errors. Errors in production are more expensive than test time. | Add error/edge case tests for every feature |
|
|
1104
|
+
| `page.route('**/*', route => route.abort())` | Blocks ALL requests including the page itself | Target specific URLs: `page.route('**/api/specific', ...)` |
|
|
1105
|
+
| Using `page.waitForTimeout()` to simulate slow loading | Arbitrary, flaky, slows tests | Use promise-based route handlers to control timing precisely |
|
|
1106
|
+
| Hardcoding error messages in assertions | Messages change. Tests break on copy edits. | Use regex or partial matches: `getByText(/error/i)` |
|
|
1107
|
+
| Testing every error code in one mega-test | Hard to debug, slow, first failure hides the rest | One test per error scenario, or use `test.describe` groups |
|
|
1108
|
+
| Skipping empty state tests | Empty states are the first thing new users see; often broken | Always test 0-item state alongside 1-item and N-item |
|
|
1109
|
+
| Testing offline by disconnecting real network | Flaky, affects parallel tests, CI may not support it | Use `context.setOffline(true)` — deterministic and scoped |
|
|
1110
|
+
| Not cleaning up route mocks between tests | Routes persist on the page. Test B inherits test A's mocks. | Each test sets its own routes; Playwright resets between tests by default |
|
|
1111
|
+
| Asserting on exact error strings from the server | Couples tests to backend implementation details | Assert the UI message, not the raw API response body |
|
|
1112
|
+
| Using `try/catch` to "handle" expected errors | Swallows real bugs; test passes when it shouldn't | Let errors propagate; use route mocking to control the response |
|
|
1113
|
+
|
|
1114
|
+
## Troubleshooting
|
|
1115
|
+
|
|
1116
|
+
### Route handler is not intercepting requests
|
|
1117
|
+
|
|
1118
|
+
**Cause**: The URL pattern does not match, or the route was registered after the navigation that triggers the request.
|
|
1119
|
+
|
|
1120
|
+
```typescript
|
|
1121
|
+
// Always register routes BEFORE navigating
|
|
1122
|
+
await page.route('**/api/data', (route) => route.fulfill({ status: 500 }));
|
|
1123
|
+
await page.goto('/dashboard'); // route is active before the page loads
|
|
1124
|
+
|
|
1125
|
+
// Debug: log all requests to find the actual URL pattern
|
|
1126
|
+
page.on('request', (req) => console.log(req.url()));
|
|
1127
|
+
```
|
|
1128
|
+
|
|
1129
|
+
### `context.setOffline(true)` does not affect Service Worker
|
|
1130
|
+
|
|
1131
|
+
**Cause**: Service Workers have their own network handling. `setOffline` simulates offline at the browser level, but a Service Worker may serve cached responses.
|
|
1132
|
+
|
|
1133
|
+
```typescript
|
|
1134
|
+
// Unregister service workers first
|
|
1135
|
+
await page.evaluate(async () => {
|
|
1136
|
+
const registrations = await navigator.serviceWorker.getRegistrations();
|
|
1137
|
+
for (const registration of registrations) {
|
|
1138
|
+
await registration.unregister();
|
|
1139
|
+
}
|
|
1140
|
+
});
|
|
1141
|
+
await page.context().setOffline(true);
|
|
1142
|
+
```
|
|
1143
|
+
|
|
1144
|
+
### Delayed route handler causes test timeout
|
|
1145
|
+
|
|
1146
|
+
**Cause**: The promise in the route handler never resolves, or the delay exceeds the test timeout.
|
|
1147
|
+
|
|
1148
|
+
```typescript
|
|
1149
|
+
// Always ensure the delay is less than the assertion timeout
|
|
1150
|
+
await page.route('**/api/slow', async (route) => {
|
|
1151
|
+
await new Promise((resolve) => setTimeout(resolve, 5_000)); // 5s delay
|
|
1152
|
+
await route.fulfill({ status: 200, json: {} });
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
// Increase assertion timeout to account for the artificial delay
|
|
1156
|
+
await expect(page.getByText('Data loaded')).toBeVisible({ timeout: 10_000 });
|
|
1157
|
+
```
|
|
1158
|
+
|
|
1159
|
+
### `goBack()` does not navigate
|
|
1160
|
+
|
|
1161
|
+
**Cause**: There is no history entry to go back to. `goBack()` requires at least one previous navigation.
|
|
1162
|
+
|
|
1163
|
+
```typescript
|
|
1164
|
+
// Ensure there is history before calling goBack
|
|
1165
|
+
await page.goto('/page-a');
|
|
1166
|
+
await page.goto('/page-b');
|
|
1167
|
+
await page.goBack(); // goes to /page-a
|
|
1168
|
+
|
|
1169
|
+
// If using SPA client-side routing, goBack may not work if the router
|
|
1170
|
+
// does not push to browser history. Use the app's own back button instead.
|
|
1171
|
+
await page.getByRole('button', { name: 'Back' }).click();
|
|
1172
|
+
```
|
|
1173
|
+
|
|
1174
|
+
## Related
|
|
1175
|
+
|
|
1176
|
+
- [core/assertions-and-waiting.md](assertions-and-waiting.md) -- assertion strategies for error states
|
|
1177
|
+
- [core/network-mocking.md](network-mocking.md) -- detailed network interception and mocking patterns
|
|
1178
|
+
- [core/forms-and-validation.md](forms-and-validation.md) -- form validation error testing
|
|
1179
|
+
- [core/flaky-tests.md](flaky-tests.md) -- fixing timing issues in error/edge case tests
|
|
1180
|
+
- [core/service-workers-and-pwa.md](service-workers-and-pwa.md) -- offline-first and PWA testing patterns
|
|
1181
|
+
- [core/multi-context-and-popups.md](multi-context-and-popups.md) -- testing concurrent browser contexts
|