claude-toolkit 0.1.12 → 0.1.20
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/CHANGELOG.md +32 -0
- package/README.md +3 -0
- package/core/skills/ct-testing-patterns/SKILL.md +37 -1
- package/docs/README.md +3 -0
- package/docs/best-practices/testing/README.md +84 -0
- package/docs/best-practices/testing/playwright-e2e.md +649 -0
- package/docs/best-practices/testing/storybook-interaction.md +455 -0
- package/docs/best-practices/testing/vitest-unit.md +451 -0
- package/docs/stacks/playwright-patterns.md +179 -0
- package/docs/stacks/storybook-patterns.md +177 -0
- package/docs/stacks/vite-vitest-patterns.md +485 -0
- package/package.json +1 -1
- package/stacks/playwright/skills/ct-playwright-patterns/SKILL.md +168 -0
- package/stacks/playwright/stack.json +39 -0
- package/stacks/solidjs/stack.json +7 -1
- package/stacks/storybook/skills/ct-storybook-patterns/SKILL.md +166 -0
- package/stacks/storybook/stack.json +46 -0
- package/stacks/vite/skills/ct-vite-vitest-patterns/SKILL.md +492 -0
- package/stacks/vite/stack.json +66 -0
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
# Playwright E2E Testing
|
|
2
|
+
|
|
3
|
+
> Sources: [Playwright Release Notes](https://playwright.dev/docs/release-notes), [Playwright Best Practices](https://playwright.dev/docs/best-practices), [Playwright Docs](https://playwright.dev/docs/intro), [TestDino Guides](https://testdino.com/blog/playwright-best-practices/)
|
|
4
|
+
|
|
5
|
+
E2E tests sit at the top of the testing pyramid. They exercise full user journeys in real browsers -- navigation, authentication, API integration, and cross-browser rendering. Write fewer of them, but make each one count.
|
|
6
|
+
|
|
7
|
+
## Project Structure
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
e2e/
|
|
11
|
+
fixtures/ # Custom Playwright fixtures
|
|
12
|
+
index.ts # Extended test with page objects
|
|
13
|
+
pages/ # Page Object classes
|
|
14
|
+
LoginPage.ts
|
|
15
|
+
DashboardPage.ts
|
|
16
|
+
components/ # Component Object classes (reusable fragments)
|
|
17
|
+
Header.ts
|
|
18
|
+
Modal.ts
|
|
19
|
+
specs/ # Test files organized by feature
|
|
20
|
+
auth.spec.ts
|
|
21
|
+
dashboard.spec.ts
|
|
22
|
+
helpers/ # Utilities (data factories, API helpers)
|
|
23
|
+
.auth/ # storageState files (gitignored)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Page Object Model + Fixtures
|
|
27
|
+
|
|
28
|
+
Combine Page Objects with Playwright's custom fixtures. Tests receive pre-built page objects without manual instantiation:
|
|
29
|
+
|
|
30
|
+
### Page Object
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
// e2e/pages/LoginPage.ts
|
|
34
|
+
import type { Page, Locator } from "@playwright/test";
|
|
35
|
+
import { expect } from "@playwright/test";
|
|
36
|
+
|
|
37
|
+
export class LoginPage {
|
|
38
|
+
private readonly emailInput: Locator;
|
|
39
|
+
private readonly passwordInput: Locator;
|
|
40
|
+
private readonly submitButton: Locator;
|
|
41
|
+
|
|
42
|
+
constructor(private readonly page: Page) {
|
|
43
|
+
this.emailInput = page.getByLabel("Email");
|
|
44
|
+
this.passwordInput = page.getByLabel("Password");
|
|
45
|
+
this.submitButton = page.getByRole("button", { name: "Sign in" });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async goto() {
|
|
49
|
+
await this.page.goto("/login");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async login(email: string, password: string) {
|
|
53
|
+
await this.emailInput.fill(email);
|
|
54
|
+
await this.passwordInput.fill(password);
|
|
55
|
+
await this.submitButton.click();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async expectVisible() {
|
|
59
|
+
await expect(this.emailInput).toBeVisible();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Custom Fixtures
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
// e2e/fixtures/index.ts
|
|
68
|
+
import { test as base } from "@playwright/test";
|
|
69
|
+
import { LoginPage } from "../pages/LoginPage";
|
|
70
|
+
import { DashboardPage } from "../pages/DashboardPage";
|
|
71
|
+
|
|
72
|
+
interface Fixtures {
|
|
73
|
+
loginPage: LoginPage;
|
|
74
|
+
dashboardPage: DashboardPage;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const test = base.extend<Fixtures>({
|
|
78
|
+
loginPage: async ({ page }, use) => {
|
|
79
|
+
await use(new LoginPage(page));
|
|
80
|
+
},
|
|
81
|
+
dashboardPage: async ({ page }, use) => {
|
|
82
|
+
await use(new DashboardPage(page));
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
export { expect } from "@playwright/test";
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Using in Tests
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
// e2e/specs/auth.spec.ts
|
|
93
|
+
import { test, expect } from "../fixtures";
|
|
94
|
+
|
|
95
|
+
test("should login successfully", async ({ loginPage, dashboardPage, page }) => {
|
|
96
|
+
await loginPage.goto();
|
|
97
|
+
await loginPage.login("user@example.com", "password");
|
|
98
|
+
await expect(page).toHaveURL("/dashboard");
|
|
99
|
+
await dashboardPage.expectWelcomeMessage("user@example.com");
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Fixtures are **lazy** (only created when requested) and **composable** (can depend on each other).
|
|
104
|
+
|
|
105
|
+
## Authentication Handling
|
|
106
|
+
|
|
107
|
+
Use **project dependencies** with `storageState` to authenticate once and reuse across all tests:
|
|
108
|
+
|
|
109
|
+
### Setup File
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
// e2e/auth.setup.ts
|
|
113
|
+
import { test as setup, expect } from "@playwright/test";
|
|
114
|
+
|
|
115
|
+
const authFile = "e2e/.auth/user.json";
|
|
116
|
+
|
|
117
|
+
setup("authenticate", async ({ page }) => {
|
|
118
|
+
await page.goto("/login");
|
|
119
|
+
await page.getByLabel("Email").fill("test@example.com");
|
|
120
|
+
await page.getByLabel("Password").fill("password");
|
|
121
|
+
await page.getByRole("button", { name: "Sign in" }).click();
|
|
122
|
+
await page.waitForURL("/dashboard");
|
|
123
|
+
await page.context().storageState({ path: authFile });
|
|
124
|
+
});
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Config
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
// playwright.config.ts (projects section)
|
|
131
|
+
projects: [
|
|
132
|
+
{ name: "setup", testMatch: /.*\.setup\.ts/ },
|
|
133
|
+
{
|
|
134
|
+
name: "chromium",
|
|
135
|
+
use: {
|
|
136
|
+
...devices["Desktop Chrome"],
|
|
137
|
+
storageState: "e2e/.auth/user.json",
|
|
138
|
+
},
|
|
139
|
+
dependencies: ["setup"],
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Multi-Role Testing
|
|
145
|
+
|
|
146
|
+
Separate setup files for different roles:
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
// e2e/admin.setup.ts -> e2e/.auth/admin.json
|
|
150
|
+
// e2e/user.setup.ts -> e2e/.auth/user.json
|
|
151
|
+
|
|
152
|
+
projects: [
|
|
153
|
+
{ name: "admin-setup", testMatch: /admin\.setup\.ts/ },
|
|
154
|
+
{ name: "user-setup", testMatch: /user\.setup\.ts/ },
|
|
155
|
+
{
|
|
156
|
+
name: "admin-tests",
|
|
157
|
+
use: { storageState: "e2e/.auth/admin.json" },
|
|
158
|
+
dependencies: ["admin-setup"],
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: "user-tests",
|
|
162
|
+
use: { storageState: "e2e/.auth/user.json" },
|
|
163
|
+
dependencies: ["user-setup"],
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Add `e2e/.auth/` to `.gitignore`. Session tokens expire -- the setup re-runs each CI pipeline.
|
|
169
|
+
|
|
170
|
+
## Network Mocking
|
|
171
|
+
|
|
172
|
+
### Route Interception
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
// Mock API response
|
|
176
|
+
await page.route("**/api/users", async (route) => {
|
|
177
|
+
await route.fulfill({
|
|
178
|
+
status: 200,
|
|
179
|
+
contentType: "application/json",
|
|
180
|
+
body: JSON.stringify([{ name: "Alice" }]),
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Modify a real response
|
|
185
|
+
await page.route("**/api/config", async (route) => {
|
|
186
|
+
const response = await route.fetch();
|
|
187
|
+
const body = await response.json();
|
|
188
|
+
body.featureFlag = true;
|
|
189
|
+
await route.fulfill({ response, body: JSON.stringify(body) });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Block requests (analytics, tracking)
|
|
193
|
+
await page.route("**/analytics/**", (route) => route.abort());
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Protobuf Responses
|
|
197
|
+
|
|
198
|
+
Since this project uses Protobuf, fulfill with encoded buffers:
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
import { UserResponse } from "../src/generated/proto";
|
|
202
|
+
|
|
203
|
+
await page.route("**/api/user", async (route) => {
|
|
204
|
+
const encoded = UserResponse.encode({ name: "Alice" }).finish();
|
|
205
|
+
await route.fulfill({
|
|
206
|
+
status: 200,
|
|
207
|
+
contentType: "application/octet-stream",
|
|
208
|
+
body: Buffer.from(encoded),
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### HAR Recording/Playback
|
|
214
|
+
|
|
215
|
+
For deterministic replay of complex API interactions:
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
// Record
|
|
219
|
+
await page.routeFromHAR("e2e/fixtures/api.har", { update: true });
|
|
220
|
+
|
|
221
|
+
// Playback
|
|
222
|
+
await page.routeFromHAR("e2e/fixtures/api.har");
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### WebSocket Mocking (v1.53+)
|
|
226
|
+
|
|
227
|
+
```typescript
|
|
228
|
+
await page.routeWebSocket("wss://example.com/ws", (ws) => {
|
|
229
|
+
ws.onMessage((message) => {
|
|
230
|
+
ws.send("mocked response");
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### API Testing Without a Browser
|
|
236
|
+
|
|
237
|
+
```typescript
|
|
238
|
+
test("API health check", async ({ request }) => {
|
|
239
|
+
const response = await request.get("/api/health");
|
|
240
|
+
expect(response.ok()).toBeTruthy();
|
|
241
|
+
});
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
**Best practice:** Mock ~80% of API calls for speed; keep ~20% hitting real endpoints for integration confidence. Always call `route.continue()`, `route.fulfill()`, or `route.abort()` -- never leave a handler hanging. Use `page.unrouteAll()` in cleanup.
|
|
245
|
+
|
|
246
|
+
## Assertions
|
|
247
|
+
|
|
248
|
+
### Web-First Assertions (Always Prefer These)
|
|
249
|
+
|
|
250
|
+
Web-first assertions auto-retry until the condition is met or timeout:
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
// GOOD -- auto-retrying
|
|
254
|
+
await expect(page.getByRole("alert")).toBeVisible();
|
|
255
|
+
await expect(page.getByRole("heading")).toHaveText("Dashboard");
|
|
256
|
+
await expect(page).toHaveURL("/dashboard");
|
|
257
|
+
await expect(page).toHaveTitle(/Dashboard/);
|
|
258
|
+
await expect(page.getByTestId("count")).toHaveText("5");
|
|
259
|
+
|
|
260
|
+
// BAD -- checks once, no retry
|
|
261
|
+
expect(await page.getByRole("alert").isVisible()).toBeTruthy();
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### Key Web-First Assertions
|
|
265
|
+
|
|
266
|
+
| Assertion | Purpose |
|
|
267
|
+
|---|---|
|
|
268
|
+
| `toBeVisible()` / `toBeHidden()` | Element visibility |
|
|
269
|
+
| `toBeEnabled()` / `toBeDisabled()` | Form element state |
|
|
270
|
+
| `toHaveText()` / `toContainText()` | Text content |
|
|
271
|
+
| `toHaveURL()` / `toHaveTitle()` | Page navigation |
|
|
272
|
+
| `toHaveCount()` | List length |
|
|
273
|
+
| `toHaveScreenshot()` | Visual comparison |
|
|
274
|
+
| `toMatchAriaSnapshot()` | Accessibility structure |
|
|
275
|
+
|
|
276
|
+
### Soft Assertions
|
|
277
|
+
|
|
278
|
+
Continue test after failure, report all at end:
|
|
279
|
+
|
|
280
|
+
```typescript
|
|
281
|
+
await expect.soft(page.getByTestId("status")).toHaveText("Active");
|
|
282
|
+
await expect.soft(page.getByTestId("count")).toHaveText("5");
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Custom Timeout
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
await expect(page.getByRole("alert")).toBeVisible({ timeout: 10_000 });
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
## Locator Priority
|
|
292
|
+
|
|
293
|
+
Use the most resilient locator available:
|
|
294
|
+
|
|
295
|
+
1. **`getByRole()`** -- accessibility semantics, survives refactors
|
|
296
|
+
2. **`getByLabel()`** -- form elements by associated label
|
|
297
|
+
3. **`getByPlaceholder()`** -- input hints
|
|
298
|
+
4. **`getByText()`** -- visible text content
|
|
299
|
+
5. **`getByTestId()`** -- explicit contract, stable but less semantic
|
|
300
|
+
6. **CSS/XPath** -- **avoid** unless no alternative
|
|
301
|
+
|
|
302
|
+
## Test Isolation and Parallelization
|
|
303
|
+
|
|
304
|
+
### Parallelism Levels
|
|
305
|
+
|
|
306
|
+
| Level | How | When |
|
|
307
|
+
|---|---|---|
|
|
308
|
+
| File-level (default) | Files run in parallel, tests within a file run sequentially | Start here |
|
|
309
|
+
| In-file | `test.describe.configure({ mode: "parallel" })` | Independent tests within a describe |
|
|
310
|
+
| Full | `fullyParallel: true` in config | All tests properly isolated |
|
|
311
|
+
|
|
312
|
+
Each worker gets its own browser instance and `BrowserContext` -- cookies, localStorage, and sessionStorage are isolated automatically.
|
|
313
|
+
|
|
314
|
+
### Worker Tuning
|
|
315
|
+
|
|
316
|
+
```typescript
|
|
317
|
+
workers: process.env.CI ? 4 : undefined, // undefined = half CPU cores locally
|
|
318
|
+
fullyParallel: true,
|
|
319
|
+
retries: process.env.CI ? 2 : 0,
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Use `test.describe.configure({ mode: "serial" })` sparingly -- only for flows where one test depends on state from another.
|
|
323
|
+
|
|
324
|
+
## Visual Comparison Testing
|
|
325
|
+
|
|
326
|
+
Built-in, no plugins needed:
|
|
327
|
+
|
|
328
|
+
```typescript
|
|
329
|
+
// Full page
|
|
330
|
+
await expect(page).toHaveScreenshot("homepage.png");
|
|
331
|
+
|
|
332
|
+
// Element-level
|
|
333
|
+
await expect(page.getByTestId("sidebar")).toHaveScreenshot("sidebar.png");
|
|
334
|
+
|
|
335
|
+
// Mask dynamic content
|
|
336
|
+
await expect(page).toHaveScreenshot("dashboard.png", {
|
|
337
|
+
mask: [page.getByTestId("timestamp"), page.getByTestId("avatar")],
|
|
338
|
+
});
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
### Best Practices
|
|
342
|
+
|
|
343
|
+
- Run visual tests in CI with a fixed Docker image (avoid font rendering differences)
|
|
344
|
+
- Use `--update-snapshots` only when UI changes are intentional
|
|
345
|
+
- Commit baseline images and review in PRs like code
|
|
346
|
+
- On failure, Playwright generates baseline, actual, and diff images
|
|
347
|
+
|
|
348
|
+
## Accessibility Testing
|
|
349
|
+
|
|
350
|
+
Integrate `@axe-core/playwright` for automated WCAG checks:
|
|
351
|
+
|
|
352
|
+
```typescript
|
|
353
|
+
import AxeBuilder from "@axe-core/playwright";
|
|
354
|
+
|
|
355
|
+
test("homepage meets WCAG 2.1 AA", async ({ page }) => {
|
|
356
|
+
await page.goto("/");
|
|
357
|
+
|
|
358
|
+
const results = await new AxeBuilder({ page })
|
|
359
|
+
.withTags(["wcag2a", "wcag2aa", "wcag21aa"])
|
|
360
|
+
.analyze();
|
|
361
|
+
|
|
362
|
+
expect(results.violations).toEqual([]);
|
|
363
|
+
});
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
### Aria Snapshots (v1.52+)
|
|
367
|
+
|
|
368
|
+
Assert accessibility tree structure:
|
|
369
|
+
|
|
370
|
+
```typescript
|
|
371
|
+
await expect(page.getByRole("navigation")).toMatchAriaSnapshot(`
|
|
372
|
+
- navigation:
|
|
373
|
+
- link "Home" /url: "/"
|
|
374
|
+
- link "About" /url: "/about"
|
|
375
|
+
`);
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
## Mobile and Responsive Testing
|
|
379
|
+
|
|
380
|
+
Use built-in device descriptors:
|
|
381
|
+
|
|
382
|
+
```typescript
|
|
383
|
+
projects: [
|
|
384
|
+
{ name: "Desktop Chrome", use: { ...devices["Desktop Chrome"] } },
|
|
385
|
+
{ name: "Mobile Chrome", use: { ...devices["Pixel 7"] } },
|
|
386
|
+
{ name: "Mobile Safari", use: { ...devices["iPhone 14"] } },
|
|
387
|
+
{ name: "Tablet", use: { ...devices["iPad Pro 11"] } },
|
|
388
|
+
],
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Custom viewport for responsive breakpoints:
|
|
392
|
+
|
|
393
|
+
```typescript
|
|
394
|
+
{
|
|
395
|
+
name: "Mobile",
|
|
396
|
+
use: {
|
|
397
|
+
viewport: { width: 375, height: 812 },
|
|
398
|
+
hasTouch: true,
|
|
399
|
+
isMobile: true,
|
|
400
|
+
},
|
|
401
|
+
}
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
**Limitation:** Playwright emulates devices inside desktop browser engines. Sufficient for responsive layout testing but cannot catch real mobile browser rendering bugs.
|
|
405
|
+
|
|
406
|
+
## i18n Testing
|
|
407
|
+
|
|
408
|
+
Run tests against both locales using Playwright projects:
|
|
409
|
+
|
|
410
|
+
```typescript
|
|
411
|
+
projects: [
|
|
412
|
+
{
|
|
413
|
+
name: "en",
|
|
414
|
+
use: { ...devices["Desktop Chrome"], locale: "en-NZ" },
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
name: "fr",
|
|
418
|
+
use: { ...devices["Desktop Chrome"], locale: "fr-FR" },
|
|
419
|
+
},
|
|
420
|
+
],
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
Verify typesafe-i18n strings render correctly in both locales.
|
|
424
|
+
|
|
425
|
+
## Trace Viewer and Debugging
|
|
426
|
+
|
|
427
|
+
### Trace Config
|
|
428
|
+
|
|
429
|
+
```typescript
|
|
430
|
+
use: {
|
|
431
|
+
trace: "on-first-retry", // Capture traces only on failure retries
|
|
432
|
+
},
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
### Viewing Traces
|
|
436
|
+
|
|
437
|
+
```bash
|
|
438
|
+
# Local CLI
|
|
439
|
+
bunx playwright show-trace test-results/test-name/trace.zip
|
|
440
|
+
|
|
441
|
+
# Browser-based (upload to trace.playwright.dev)
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
Traces contain: DOM snapshots at every step, film strip, network requests, console logs, action timings, source code mapping.
|
|
445
|
+
|
|
446
|
+
### Timeline View (v1.58)
|
|
447
|
+
|
|
448
|
+
The Speedboard tab in HTML reports shows where time is spent across tests -- identifies slow steps and bottlenecks.
|
|
449
|
+
|
|
450
|
+
### Local Debugging
|
|
451
|
+
|
|
452
|
+
- `--ui` flag for interactive UI Mode with time-travel debugging
|
|
453
|
+
- `--debug` flag for step-through with Playwright Inspector
|
|
454
|
+
- VS Code extension for running/debugging individual tests
|
|
455
|
+
|
|
456
|
+
## CI/CD Integration (GitHub Actions)
|
|
457
|
+
|
|
458
|
+
```yaml
|
|
459
|
+
# .github/workflows/e2e.yml
|
|
460
|
+
name: E2E Tests
|
|
461
|
+
on:
|
|
462
|
+
push:
|
|
463
|
+
branches: [main]
|
|
464
|
+
pull_request:
|
|
465
|
+
branches: [main]
|
|
466
|
+
|
|
467
|
+
jobs:
|
|
468
|
+
e2e:
|
|
469
|
+
timeout-minutes: 30
|
|
470
|
+
runs-on: ubuntu-latest
|
|
471
|
+
container:
|
|
472
|
+
image: mcr.microsoft.com/playwright:v1.58.0-noble
|
|
473
|
+
steps:
|
|
474
|
+
- uses: actions/checkout@v4
|
|
475
|
+
|
|
476
|
+
- uses: oven-sh/setup-bun@v2
|
|
477
|
+
with:
|
|
478
|
+
bun-version: latest
|
|
479
|
+
|
|
480
|
+
- run: bun install --frozen-lockfile
|
|
481
|
+
- run: bun run build
|
|
482
|
+
|
|
483
|
+
- run: bunx playwright test
|
|
484
|
+
env:
|
|
485
|
+
BASE_URL: http://localhost:4173
|
|
486
|
+
|
|
487
|
+
- uses: actions/upload-artifact@v4
|
|
488
|
+
if: ${{ !cancelled() }}
|
|
489
|
+
with:
|
|
490
|
+
name: playwright-report
|
|
491
|
+
path: playwright-report/
|
|
492
|
+
retention-days: 14
|
|
493
|
+
|
|
494
|
+
- uses: actions/upload-artifact@v4
|
|
495
|
+
if: failure()
|
|
496
|
+
with:
|
|
497
|
+
name: test-traces
|
|
498
|
+
path: test-results/
|
|
499
|
+
retention-days: 7
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
### Sharding for Large Suites
|
|
503
|
+
|
|
504
|
+
```yaml
|
|
505
|
+
strategy:
|
|
506
|
+
fail-fast: false
|
|
507
|
+
matrix:
|
|
508
|
+
shard: [1/4, 2/4, 3/4, 4/4]
|
|
509
|
+
steps:
|
|
510
|
+
- run: bunx playwright test --shard=${{ matrix.shard }}
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
Then merge reports with `bunx playwright merge-reports`.
|
|
514
|
+
|
|
515
|
+
### Best Practices
|
|
516
|
+
|
|
517
|
+
- Always use the official Playwright Docker image (has all required system libraries)
|
|
518
|
+
- Upload artifacts (reports + traces) unconditionally or on failure
|
|
519
|
+
- Run smoke tests on every commit; full suite as pre-merge gate
|
|
520
|
+
- Use `--shard` for suites exceeding 5 minutes
|
|
521
|
+
|
|
522
|
+
## Performance Measurement
|
|
523
|
+
|
|
524
|
+
Playwright is not a load testing tool, but it can measure client-side performance:
|
|
525
|
+
|
|
526
|
+
```typescript
|
|
527
|
+
test("page load performance", async ({ page }) => {
|
|
528
|
+
await page.goto("/");
|
|
529
|
+
|
|
530
|
+
const perfTiming = await page.evaluate(() => {
|
|
531
|
+
const nav = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming;
|
|
532
|
+
return {
|
|
533
|
+
ttfb: nav.responseStart - nav.requestStart,
|
|
534
|
+
domContentLoaded: nav.domContentLoadedEventEnd - nav.startTime,
|
|
535
|
+
loadComplete: nav.loadEventEnd - nav.startTime,
|
|
536
|
+
};
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
expect(perfTiming.ttfb).toBeLessThan(500);
|
|
540
|
+
expect(perfTiming.domContentLoaded).toBeLessThan(2000);
|
|
541
|
+
});
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
For load testing at scale, pair with Artillery (`artillery-engine-playwright`).
|
|
545
|
+
|
|
546
|
+
## Test Generation
|
|
547
|
+
|
|
548
|
+
### Codegen
|
|
549
|
+
|
|
550
|
+
```bash
|
|
551
|
+
bunx playwright codegen http://localhost:5173
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
Records browser interactions and generates test code with role-based locators. As of v1.51, generates automatic `toBeVisible()` assertions.
|
|
555
|
+
|
|
556
|
+
**Always refactor raw codegen output into Page Objects before committing.**
|
|
557
|
+
|
|
558
|
+
### AI-Assisted (v1.56+)
|
|
559
|
+
|
|
560
|
+
Playwright Test Agents provide planner, generator, and healer loops for AI-assisted test authoring. Use as a starting point, then review and refine.
|
|
561
|
+
|
|
562
|
+
## Recommended Config
|
|
563
|
+
|
|
564
|
+
```typescript
|
|
565
|
+
// playwright.config.ts
|
|
566
|
+
import { defineConfig, devices } from "@playwright/test";
|
|
567
|
+
|
|
568
|
+
export default defineConfig({
|
|
569
|
+
testDir: "./e2e/specs",
|
|
570
|
+
outputDir: "./test-results",
|
|
571
|
+
fullyParallel: true,
|
|
572
|
+
forbidOnly: !!process.env.CI,
|
|
573
|
+
retries: process.env.CI ? 2 : 0,
|
|
574
|
+
workers: process.env.CI ? 4 : undefined,
|
|
575
|
+
reporter: process.env.CI
|
|
576
|
+
? [["blob"], ["github"]]
|
|
577
|
+
: [["html", { open: "never" }]],
|
|
578
|
+
|
|
579
|
+
use: {
|
|
580
|
+
baseURL: process.env.BASE_URL ?? "http://localhost:5173",
|
|
581
|
+
trace: "on-first-retry",
|
|
582
|
+
screenshot: "only-on-failure",
|
|
583
|
+
video: "retain-on-failure",
|
|
584
|
+
},
|
|
585
|
+
|
|
586
|
+
projects: [
|
|
587
|
+
{ name: "setup", testMatch: /.*\.setup\.ts/ },
|
|
588
|
+
{
|
|
589
|
+
name: "chromium",
|
|
590
|
+
use: { ...devices["Desktop Chrome"], storageState: "e2e/.auth/user.json" },
|
|
591
|
+
dependencies: ["setup"],
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
name: "firefox",
|
|
595
|
+
use: { ...devices["Desktop Firefox"], storageState: "e2e/.auth/user.json" },
|
|
596
|
+
dependencies: ["setup"],
|
|
597
|
+
},
|
|
598
|
+
{
|
|
599
|
+
name: "webkit",
|
|
600
|
+
use: { ...devices["Desktop Safari"], storageState: "e2e/.auth/user.json" },
|
|
601
|
+
dependencies: ["setup"],
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
name: "mobile-chrome",
|
|
605
|
+
use: { ...devices["Pixel 7"], storageState: "e2e/.auth/user.json" },
|
|
606
|
+
dependencies: ["setup"],
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
name: "mobile-safari",
|
|
610
|
+
use: { ...devices["iPhone 14"], storageState: "e2e/.auth/user.json" },
|
|
611
|
+
dependencies: ["setup"],
|
|
612
|
+
},
|
|
613
|
+
],
|
|
614
|
+
|
|
615
|
+
webServer: {
|
|
616
|
+
command: "bun run build && bun run preview",
|
|
617
|
+
port: 4173,
|
|
618
|
+
reuseExistingServer: !process.env.CI,
|
|
619
|
+
},
|
|
620
|
+
});
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
## Flaky Test Prevention
|
|
624
|
+
|
|
625
|
+
| Cause | Fix |
|
|
626
|
+
|---|---|
|
|
627
|
+
| `waitForTimeout()` / `page.waitForTimeout(5000)` | Replace with web-first assertions or `waitForResponse` |
|
|
628
|
+
| Brittle selectors (`div > span:nth-child(3)`) | Use `getByRole`, `getByLabel`, `getByTestId` |
|
|
629
|
+
| Shared state between tests | Isolate with fresh `BrowserContext` per test |
|
|
630
|
+
| External API flakiness | Mock network calls with `page.route()` |
|
|
631
|
+
| Animation timing | Use `--force-prefers-reduced-motion` or `* { animation: none !important }` |
|
|
632
|
+
| Race conditions | Use `waitForResponse()`, `waitForURL()`, or `waitForLoadState()` before asserting |
|
|
633
|
+
| Missing `await` | The #1 source of false-passing tests. Lint for it. |
|
|
634
|
+
|
|
635
|
+
## Anti-Patterns
|
|
636
|
+
|
|
637
|
+
| Anti-Pattern | Fix |
|
|
638
|
+
|---|---|
|
|
639
|
+
| Using `waitForTimeout()` | Use web-first assertions or explicit wait conditions |
|
|
640
|
+
| Fragile CSS/XPath selectors | Use role-based and semantic locators |
|
|
641
|
+
| Creating new browser instances per test | Let Playwright manage `BrowserContext` isolation |
|
|
642
|
+
| Using `{ force: true }` | Masks real actionability problems (covered, disabled, invisible) |
|
|
643
|
+
| Missing `await` on assertions | The #1 source of false-passing tests |
|
|
644
|
+
| Tests without assertions | A test that clicks through without asserting is just a script |
|
|
645
|
+
| Redundant `page.reload()` calls | Playwright auto-waits; reload only when testing refresh behavior |
|
|
646
|
+
| Using Playwright for unit tests | Use Vitest for pure logic; Playwright is for browser-dependent behavior |
|
|
647
|
+
| Committing raw codegen output | Always refactor into Page Objects before committing |
|
|
648
|
+
| Not cleaning up route handlers | Use `page.unrouteAll()` to prevent mock leakage |
|
|
649
|
+
| Ignoring test isolation | Shared cookies/localStorage between tests causes cascading failures |
|