qa-workflow-cc 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 +461 -0
- package/VERSION +1 -0
- package/bin/install.js +116 -0
- package/commands/qa/continue.md +77 -0
- package/commands/qa/full.md +149 -0
- package/commands/qa/init.md +105 -0
- package/commands/qa/resume.md +91 -0
- package/commands/qa/status.md +66 -0
- package/package.json +28 -0
- package/skills/qa/SKILL.md +420 -0
- package/skills/qa/references/continuation-format.md +58 -0
- package/skills/qa/references/exit-criteria.md +53 -0
- package/skills/qa/references/lifecycle.md +181 -0
- package/skills/qa/references/model-profiles.md +77 -0
- package/skills/qa/templates/agent-skeleton.md +733 -0
- package/skills/qa/templates/component-test.md +1088 -0
- package/skills/qa/templates/domain-research-queries.md +101 -0
- package/skills/qa/templates/domain-security-profiles.md +182 -0
- package/skills/qa/templates/e2e-test.md +1200 -0
- package/skills/qa/templates/nielsen-heuristics.md +274 -0
- package/skills/qa/templates/performance-benchmarks-base.md +321 -0
- package/skills/qa/templates/qa-report-template.md +271 -0
- package/skills/qa/templates/security-checklist-owasp.md +451 -0
- package/skills/qa/templates/stop-points/bootstrap-complete.md +36 -0
- package/skills/qa/templates/stop-points/certified.md +25 -0
- package/skills/qa/templates/stop-points/escalated.md +32 -0
- package/skills/qa/templates/stop-points/fix-ready.md +43 -0
- package/skills/qa/templates/stop-points/phase-transition.md +4 -0
- package/skills/qa/templates/stop-points/status-dashboard.md +32 -0
- package/skills/qa/templates/test-standards.md +652 -0
- package/skills/qa/templates/unit-test.md +998 -0
- package/skills/qa/templates/visual-regression.md +418 -0
- package/skills/qa/workflows/bootstrap.md +45 -0
- package/skills/qa/workflows/decision-gate.md +66 -0
- package/skills/qa/workflows/fix-execute.md +132 -0
- package/skills/qa/workflows/fix-plan.md +52 -0
- package/skills/qa/workflows/report-phase.md +64 -0
- package/skills/qa/workflows/test-phase.md +86 -0
- package/skills/qa/workflows/verify-phase.md +65 -0
|
@@ -0,0 +1,1200 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: e2e-test
|
|
3
|
+
description: Create Playwright end-to-end tests for user flows. Use when testing complete user journeys across the application.
|
|
4
|
+
context: fork
|
|
5
|
+
agent: test-qa
|
|
6
|
+
allowed-tools:
|
|
7
|
+
- Read
|
|
8
|
+
- Glob
|
|
9
|
+
- Grep
|
|
10
|
+
- Write
|
|
11
|
+
- Edit
|
|
12
|
+
- Bash
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# E2E Test Creation ({{e2eFramework}})
|
|
16
|
+
|
|
17
|
+
This skill guides creation of end-to-end tests using {{e2eFramework}}.
|
|
18
|
+
|
|
19
|
+
## Template Variables
|
|
20
|
+
|
|
21
|
+
| Variable | Description | Default |
|
|
22
|
+
|----------|-------------|---------|
|
|
23
|
+
| `{{e2eFramework}}` | E2E framework (playwright, cypress) | playwright |
|
|
24
|
+
| `{{baseUrl}}` | Base URL for the application | http://localhost:3000 |
|
|
25
|
+
| `{{authType}}` | Auth mechanism (cookie, token, session, oauth) | cookie |
|
|
26
|
+
| `{{e2eCommand}}` | Command to run E2E tests | pnpm e2e |
|
|
27
|
+
| `{{e2eDir}}` | Directory for E2E test files | e2e/ |
|
|
28
|
+
| `{{apiBaseUrl}}` | API base URL for mocking | /api |
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Prerequisites
|
|
33
|
+
|
|
34
|
+
- Understand the user flow being tested
|
|
35
|
+
- Know the expected UI states at each step
|
|
36
|
+
- Have test data/fixtures ready
|
|
37
|
+
- Dev server is running (or configured in playwright.config)
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Test File Structure
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
// {{e2eDir}}/item-management.spec.ts
|
|
45
|
+
import { test, expect } from '@playwright/test'
|
|
46
|
+
|
|
47
|
+
test.describe('Item Management', () => {
|
|
48
|
+
test.beforeEach(async ({ page }) => {
|
|
49
|
+
// Authenticate before each test
|
|
50
|
+
await page.goto('/sign-in')
|
|
51
|
+
await page.fill('[data-testid="email"]', 'test@example.com')
|
|
52
|
+
await page.fill('[data-testid="password"]', 'password123')
|
|
53
|
+
await page.click('[data-testid="sign-in-button"]')
|
|
54
|
+
await page.waitForURL('/dashboard')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('can create a new item', async ({ page }) => {
|
|
58
|
+
// Navigate
|
|
59
|
+
await page.click('[data-testid="nav-items"]')
|
|
60
|
+
await expect(page).toHaveURL('/dashboard/items')
|
|
61
|
+
|
|
62
|
+
// Open create form/modal
|
|
63
|
+
await page.click('[data-testid="create-item-button"]')
|
|
64
|
+
await expect(page.locator('[data-testid="item-modal"]')).toBeVisible()
|
|
65
|
+
|
|
66
|
+
// Fill form
|
|
67
|
+
await page.fill('[data-testid="item-name"]', 'New Item')
|
|
68
|
+
await page.fill('[data-testid="item-email"]', 'item@example.com')
|
|
69
|
+
await page.fill('[data-testid="item-phone"]', '555-123-4567')
|
|
70
|
+
|
|
71
|
+
// Submit
|
|
72
|
+
await page.click('[data-testid="submit-item"]')
|
|
73
|
+
|
|
74
|
+
// Verify success
|
|
75
|
+
await expect(page.locator('[data-testid="toast-success"]')).toBeVisible()
|
|
76
|
+
await expect(page.locator('text=New Item')).toBeVisible()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('can edit an existing item', async ({ page }) => {
|
|
80
|
+
await page.goto('/dashboard/items')
|
|
81
|
+
|
|
82
|
+
// Click edit on first item
|
|
83
|
+
await page.locator('[data-testid="item-card"]').first()
|
|
84
|
+
.locator('[data-testid="edit-button"]').click()
|
|
85
|
+
|
|
86
|
+
// Update name
|
|
87
|
+
await page.fill('[data-testid="item-name"]', 'Updated Name')
|
|
88
|
+
await page.click('[data-testid="submit-item"]')
|
|
89
|
+
|
|
90
|
+
// Verify update
|
|
91
|
+
await expect(page.locator('text=Updated Name')).toBeVisible()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('can delete an item with confirmation', async ({ page }) => {
|
|
95
|
+
await page.goto('/dashboard/items')
|
|
96
|
+
|
|
97
|
+
const itemCount = await page.locator('[data-testid="item-card"]').count()
|
|
98
|
+
|
|
99
|
+
// Click delete
|
|
100
|
+
await page.locator('[data-testid="item-card"]').first()
|
|
101
|
+
.locator('[data-testid="delete-button"]').click()
|
|
102
|
+
|
|
103
|
+
// Confirm deletion
|
|
104
|
+
await expect(page.locator('[data-testid="confirm-dialog"]')).toBeVisible()
|
|
105
|
+
await page.click('[data-testid="confirm-delete"]')
|
|
106
|
+
|
|
107
|
+
// Verify removal
|
|
108
|
+
await expect(page.locator('[data-testid="item-card"]')).toHaveCount(itemCount - 1)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('can filter items by status', async ({ page }) => {
|
|
112
|
+
await page.goto('/dashboard/items')
|
|
113
|
+
|
|
114
|
+
// Select status filter
|
|
115
|
+
await page.click('[data-testid="status-filter"]')
|
|
116
|
+
await page.click('[data-testid="status-option-active"]')
|
|
117
|
+
|
|
118
|
+
// Verify filtered results
|
|
119
|
+
const badges = page.locator('[data-testid="status-badge"]')
|
|
120
|
+
const count = await badges.count()
|
|
121
|
+
for (let i = 0; i < count; i++) {
|
|
122
|
+
await expect(badges.nth(i)).toContainText('ACTIVE')
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('can search items', async ({ page }) => {
|
|
127
|
+
await page.goto('/dashboard/items')
|
|
128
|
+
|
|
129
|
+
await page.fill('[data-testid="search-input"]', 'specific item')
|
|
130
|
+
await page.waitForResponse(resp =>
|
|
131
|
+
resp.url().includes('{{apiBaseUrl}}') && resp.status() === 200
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
const items = page.locator('[data-testid="item-card"]')
|
|
135
|
+
const count = await items.count()
|
|
136
|
+
expect(count).toBeGreaterThan(0)
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Page Object Pattern
|
|
144
|
+
|
|
145
|
+
For complex pages, encapsulate selectors and actions in Page Objects:
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
// {{e2eDir}}/pages/ItemsPage.ts
|
|
149
|
+
import { Page, Locator, expect } from '@playwright/test'
|
|
150
|
+
|
|
151
|
+
export class ItemsPage {
|
|
152
|
+
readonly page: Page
|
|
153
|
+
readonly createButton: Locator
|
|
154
|
+
readonly itemCards: Locator
|
|
155
|
+
readonly searchInput: Locator
|
|
156
|
+
readonly statusFilter: Locator
|
|
157
|
+
readonly modal: Locator
|
|
158
|
+
|
|
159
|
+
constructor(page: Page) {
|
|
160
|
+
this.page = page
|
|
161
|
+
this.createButton = page.locator('[data-testid="create-item-button"]')
|
|
162
|
+
this.itemCards = page.locator('[data-testid="item-card"]')
|
|
163
|
+
this.searchInput = page.locator('[data-testid="search-input"]')
|
|
164
|
+
this.statusFilter = page.locator('[data-testid="status-filter"]')
|
|
165
|
+
this.modal = page.locator('[data-testid="item-modal"]')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async goto() {
|
|
169
|
+
await this.page.goto('/dashboard/items')
|
|
170
|
+
await expect(this.page).toHaveURL('/dashboard/items')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async createItem(data: { name: string; email: string; phone?: string }) {
|
|
174
|
+
await this.createButton.click()
|
|
175
|
+
await expect(this.modal).toBeVisible()
|
|
176
|
+
|
|
177
|
+
await this.page.fill('[data-testid="item-name"]', data.name)
|
|
178
|
+
await this.page.fill('[data-testid="item-email"]', data.email)
|
|
179
|
+
if (data.phone) {
|
|
180
|
+
await this.page.fill('[data-testid="item-phone"]', data.phone)
|
|
181
|
+
}
|
|
182
|
+
await this.page.click('[data-testid="submit-item"]')
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async search(query: string) {
|
|
186
|
+
await this.searchInput.fill(query)
|
|
187
|
+
// Wait for search results to load
|
|
188
|
+
await this.page.waitForResponse(resp =>
|
|
189
|
+
resp.url().includes('{{apiBaseUrl}}') && resp.status() === 200
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async filterByStatus(status: string) {
|
|
194
|
+
await this.statusFilter.click()
|
|
195
|
+
await this.page.click(`[data-testid="status-option-${status.toLowerCase()}"]`)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async getItemCount(): Promise<number> {
|
|
199
|
+
return this.itemCards.count()
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async getItemByName(name: string): Promise<Locator> {
|
|
203
|
+
return this.page.locator(`[data-testid="item-card"]:has-text("${name}")`)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async editItem(name: string, updates: Record<string, string>) {
|
|
207
|
+
const card = await this.getItemByName(name)
|
|
208
|
+
await card.locator('[data-testid="edit-button"]').click()
|
|
209
|
+
|
|
210
|
+
for (const [field, value] of Object.entries(updates)) {
|
|
211
|
+
await this.page.fill(`[data-testid="item-${field}"]`, value)
|
|
212
|
+
}
|
|
213
|
+
await this.page.click('[data-testid="submit-item"]')
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async deleteItem(name: string) {
|
|
217
|
+
const card = await this.getItemByName(name)
|
|
218
|
+
await card.locator('[data-testid="delete-button"]').click()
|
|
219
|
+
await this.page.click('[data-testid="confirm-delete"]')
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Usage in test
|
|
224
|
+
import { ItemsPage } from './pages/ItemsPage'
|
|
225
|
+
|
|
226
|
+
test('can create and search for an item', async ({ page }) => {
|
|
227
|
+
const itemsPage = new ItemsPage(page)
|
|
228
|
+
await itemsPage.goto()
|
|
229
|
+
|
|
230
|
+
await itemsPage.createItem({
|
|
231
|
+
name: 'Test Item',
|
|
232
|
+
email: 'test@example.com',
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
await itemsPage.search('Test Item')
|
|
236
|
+
|
|
237
|
+
expect(await itemsPage.getItemCount()).toBeGreaterThan(0)
|
|
238
|
+
})
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Composing Page Objects
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
// {{e2eDir}}/pages/DashboardPage.ts
|
|
245
|
+
import { Page, Locator } from '@playwright/test'
|
|
246
|
+
import { NavigationComponent } from './components/NavigationComponent'
|
|
247
|
+
|
|
248
|
+
export class DashboardPage {
|
|
249
|
+
readonly page: Page
|
|
250
|
+
readonly nav: NavigationComponent
|
|
251
|
+
readonly statsCards: Locator
|
|
252
|
+
readonly recentActivity: Locator
|
|
253
|
+
|
|
254
|
+
constructor(page: Page) {
|
|
255
|
+
this.page = page
|
|
256
|
+
this.nav = new NavigationComponent(page)
|
|
257
|
+
this.statsCards = page.locator('[data-testid="stats-card"]')
|
|
258
|
+
this.recentActivity = page.locator('[data-testid="recent-activity"]')
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async goto() {
|
|
262
|
+
await this.page.goto('/dashboard')
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async getStatValue(label: string): Promise<string> {
|
|
266
|
+
const card = this.page.locator(`[data-testid="stats-card"]:has-text("${label}")`)
|
|
267
|
+
return card.locator('[data-testid="stat-value"]').textContent() ?? ''
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// {{e2eDir}}/pages/components/NavigationComponent.ts
|
|
272
|
+
export class NavigationComponent {
|
|
273
|
+
readonly page: Page
|
|
274
|
+
|
|
275
|
+
constructor(page: Page) {
|
|
276
|
+
this.page = page
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async navigateTo(section: string) {
|
|
280
|
+
await this.page.click(`[data-testid="nav-${section}"]`)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async getCurrentSection(): Promise<string> {
|
|
284
|
+
const active = this.page.locator('[data-testid^="nav-"].active')
|
|
285
|
+
return active.getAttribute('data-testid') ?? ''
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## Authentication Fixture
|
|
293
|
+
|
|
294
|
+
Create reusable auth fixtures to avoid repeating login logic:
|
|
295
|
+
|
|
296
|
+
```typescript
|
|
297
|
+
// {{e2eDir}}/fixtures/auth.ts
|
|
298
|
+
import { test as base, Page } from '@playwright/test'
|
|
299
|
+
|
|
300
|
+
type AuthFixtures = {
|
|
301
|
+
authenticatedPage: Page
|
|
302
|
+
adminPage: Page
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export const test = base.extend<AuthFixtures>({
|
|
306
|
+
authenticatedPage: async ({ page }, use) => {
|
|
307
|
+
// Login as regular user
|
|
308
|
+
await page.goto('/sign-in')
|
|
309
|
+
await page.fill('[data-testid="email"]', process.env.TEST_USER_EMAIL!)
|
|
310
|
+
await page.fill('[data-testid="password"]', process.env.TEST_USER_PASSWORD!)
|
|
311
|
+
await page.click('[data-testid="sign-in-button"]')
|
|
312
|
+
await page.waitForURL('/dashboard')
|
|
313
|
+
|
|
314
|
+
await use(page)
|
|
315
|
+
|
|
316
|
+
// Cleanup
|
|
317
|
+
await page.goto('/sign-out')
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
adminPage: async ({ page }, use) => {
|
|
321
|
+
// Login as admin
|
|
322
|
+
await page.goto('/sign-in')
|
|
323
|
+
await page.fill('[data-testid="email"]', process.env.TEST_ADMIN_EMAIL!)
|
|
324
|
+
await page.fill('[data-testid="password"]', process.env.TEST_ADMIN_PASSWORD!)
|
|
325
|
+
await page.click('[data-testid="sign-in-button"]')
|
|
326
|
+
await page.waitForURL('/admin/dashboard')
|
|
327
|
+
|
|
328
|
+
await use(page)
|
|
329
|
+
|
|
330
|
+
await page.goto('/sign-out')
|
|
331
|
+
},
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
export { expect } from '@playwright/test'
|
|
335
|
+
|
|
336
|
+
// Usage
|
|
337
|
+
import { test, expect } from './fixtures/auth'
|
|
338
|
+
|
|
339
|
+
test('authenticated user can view dashboard', async ({ authenticatedPage }) => {
|
|
340
|
+
await authenticatedPage.goto('/dashboard')
|
|
341
|
+
await expect(authenticatedPage.locator('h1')).toContainText('Dashboard')
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
test('admin can access admin panel', async ({ adminPage }) => {
|
|
345
|
+
await adminPage.goto('/admin')
|
|
346
|
+
await expect(adminPage.locator('h1')).toContainText('Admin')
|
|
347
|
+
})
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
### Token-Based Auth Fixture
|
|
351
|
+
|
|
352
|
+
```typescript
|
|
353
|
+
// {{e2eDir}}/fixtures/token-auth.ts
|
|
354
|
+
import { test as base, Page } from '@playwright/test'
|
|
355
|
+
|
|
356
|
+
export const test = base.extend<{ authenticatedPage: Page }>({
|
|
357
|
+
authenticatedPage: async ({ page }, use) => {
|
|
358
|
+
// Set auth token directly (faster than UI login)
|
|
359
|
+
await page.addInitScript((token) => {
|
|
360
|
+
window.localStorage.setItem('auth-token', token)
|
|
361
|
+
}, process.env.TEST_AUTH_TOKEN!)
|
|
362
|
+
|
|
363
|
+
await page.goto('/')
|
|
364
|
+
await page.waitForSelector('[data-testid="authenticated-layout"]')
|
|
365
|
+
|
|
366
|
+
await use(page)
|
|
367
|
+
},
|
|
368
|
+
})
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
### Cookie-Based Auth Fixture
|
|
372
|
+
|
|
373
|
+
```typescript
|
|
374
|
+
// {{e2eDir}}/fixtures/cookie-auth.ts
|
|
375
|
+
import { test as base, Page } from '@playwright/test'
|
|
376
|
+
|
|
377
|
+
export const test = base.extend<{ authenticatedPage: Page }>({
|
|
378
|
+
authenticatedPage: async ({ page, context }, use) => {
|
|
379
|
+
// Set auth cookie directly
|
|
380
|
+
await context.addCookies([{
|
|
381
|
+
name: 'session',
|
|
382
|
+
value: process.env.TEST_SESSION_TOKEN!,
|
|
383
|
+
domain: 'localhost',
|
|
384
|
+
path: '/',
|
|
385
|
+
}])
|
|
386
|
+
|
|
387
|
+
await page.goto('/')
|
|
388
|
+
await page.waitForSelector('[data-testid="authenticated-layout"]')
|
|
389
|
+
|
|
390
|
+
await use(page)
|
|
391
|
+
},
|
|
392
|
+
})
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
### Saved Auth State (Persistent Login)
|
|
396
|
+
|
|
397
|
+
```typescript
|
|
398
|
+
// {{e2eDir}}/auth.setup.ts
|
|
399
|
+
import { test as setup, expect } from '@playwright/test'
|
|
400
|
+
import path from 'path'
|
|
401
|
+
|
|
402
|
+
const authFile = path.join(__dirname, '.auth/user.json')
|
|
403
|
+
|
|
404
|
+
setup('authenticate', async ({ page }) => {
|
|
405
|
+
await page.goto('/sign-in')
|
|
406
|
+
await page.fill('[data-testid="email"]', process.env.TEST_USER_EMAIL!)
|
|
407
|
+
await page.fill('[data-testid="password"]', process.env.TEST_USER_PASSWORD!)
|
|
408
|
+
await page.click('[data-testid="sign-in-button"]')
|
|
409
|
+
await page.waitForURL('/dashboard')
|
|
410
|
+
|
|
411
|
+
// Save signed-in state
|
|
412
|
+
await page.context().storageState({ path: authFile })
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
// In playwright.config.ts:
|
|
416
|
+
// projects: [
|
|
417
|
+
// { name: 'setup', testMatch: /.*\.setup\.ts/ },
|
|
418
|
+
// { name: 'tests', dependencies: ['setup'], use: { storageState: authFile } },
|
|
419
|
+
// ]
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
---
|
|
423
|
+
|
|
424
|
+
## Visual Testing
|
|
425
|
+
|
|
426
|
+
```typescript
|
|
427
|
+
test.describe('Visual Regression', () => {
|
|
428
|
+
test('dashboard matches snapshot', async ({ page }) => {
|
|
429
|
+
await page.goto('/dashboard')
|
|
430
|
+
await page.waitForLoadState('networkidle')
|
|
431
|
+
|
|
432
|
+
// Full page screenshot
|
|
433
|
+
await expect(page).toHaveScreenshot('dashboard.png', {
|
|
434
|
+
fullPage: true,
|
|
435
|
+
maxDiffPixels: 100,
|
|
436
|
+
})
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
test('item card matches snapshot', async ({ page }) => {
|
|
440
|
+
await page.goto('/dashboard/items')
|
|
441
|
+
|
|
442
|
+
// Component screenshot
|
|
443
|
+
const card = page.locator('[data-testid="item-card"]').first()
|
|
444
|
+
await expect(card).toHaveScreenshot('item-card.png')
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
test('dark mode matches snapshot', async ({ page }) => {
|
|
448
|
+
await page.emulateMedia({ colorScheme: 'dark' })
|
|
449
|
+
await page.goto('/dashboard')
|
|
450
|
+
await page.waitForLoadState('networkidle')
|
|
451
|
+
|
|
452
|
+
await expect(page).toHaveScreenshot('dashboard-dark.png', {
|
|
453
|
+
fullPage: true,
|
|
454
|
+
})
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
test('responsive layout matches snapshot', async ({ page }) => {
|
|
458
|
+
// Test at different viewport sizes
|
|
459
|
+
const viewports = [
|
|
460
|
+
{ width: 375, height: 812, name: 'mobile' },
|
|
461
|
+
{ width: 768, height: 1024, name: 'tablet' },
|
|
462
|
+
{ width: 1440, height: 900, name: 'desktop' },
|
|
463
|
+
]
|
|
464
|
+
|
|
465
|
+
for (const vp of viewports) {
|
|
466
|
+
await page.setViewportSize({ width: vp.width, height: vp.height })
|
|
467
|
+
await page.goto('/dashboard')
|
|
468
|
+
await page.waitForLoadState('networkidle')
|
|
469
|
+
|
|
470
|
+
await expect(page).toHaveScreenshot(`dashboard-${vp.name}.png`, {
|
|
471
|
+
maxDiffPixels: 50,
|
|
472
|
+
})
|
|
473
|
+
}
|
|
474
|
+
})
|
|
475
|
+
})
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
---
|
|
479
|
+
|
|
480
|
+
## API Mocking
|
|
481
|
+
|
|
482
|
+
Intercept API calls to test specific states without backend dependencies:
|
|
483
|
+
|
|
484
|
+
```typescript
|
|
485
|
+
test.describe('Error Handling', () => {
|
|
486
|
+
test('handles API error gracefully', async ({ page }) => {
|
|
487
|
+
// Mock API to return error
|
|
488
|
+
await page.route('**/{{apiBaseUrl}}/items*', (route) => {
|
|
489
|
+
route.fulfill({
|
|
490
|
+
status: 500,
|
|
491
|
+
contentType: 'application/json',
|
|
492
|
+
body: JSON.stringify({ error: 'Internal Server Error' }),
|
|
493
|
+
})
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
await page.goto('/dashboard/items')
|
|
497
|
+
|
|
498
|
+
// Verify error state
|
|
499
|
+
await expect(page.locator('[data-testid="error-message"]')).toBeVisible()
|
|
500
|
+
await expect(page.locator('[data-testid="retry-button"]')).toBeVisible()
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
test('shows loading state', async ({ page }) => {
|
|
504
|
+
// Delay API response
|
|
505
|
+
await page.route('**/{{apiBaseUrl}}/items*', async (route) => {
|
|
506
|
+
await new Promise(resolve => setTimeout(resolve, 2000))
|
|
507
|
+
route.continue()
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
await page.goto('/dashboard/items')
|
|
511
|
+
|
|
512
|
+
// Verify loading state
|
|
513
|
+
await expect(page.locator('[data-testid="loading-skeleton"]')).toBeVisible()
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
test('handles empty response', async ({ page }) => {
|
|
517
|
+
await page.route('**/{{apiBaseUrl}}/items*', (route) => {
|
|
518
|
+
route.fulfill({
|
|
519
|
+
status: 200,
|
|
520
|
+
contentType: 'application/json',
|
|
521
|
+
body: JSON.stringify({ items: [], total: 0 }),
|
|
522
|
+
})
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
await page.goto('/dashboard/items')
|
|
526
|
+
|
|
527
|
+
await expect(page.locator('[data-testid="empty-state"]')).toBeVisible()
|
|
528
|
+
await expect(page.locator('text=No items yet')).toBeVisible()
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
test('handles network timeout', async ({ page }) => {
|
|
532
|
+
await page.route('**/{{apiBaseUrl}}/items*', (route) => {
|
|
533
|
+
route.abort('timedout')
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
await page.goto('/dashboard/items')
|
|
537
|
+
|
|
538
|
+
await expect(page.locator('[data-testid="error-message"]')).toBeVisible()
|
|
539
|
+
})
|
|
540
|
+
})
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
### Intercepting and Modifying Responses
|
|
544
|
+
|
|
545
|
+
```typescript
|
|
546
|
+
test('shows notification badge for new messages', async ({ page }) => {
|
|
547
|
+
await page.route('**/{{apiBaseUrl}}/notifications/count*', (route) => {
|
|
548
|
+
route.fulfill({
|
|
549
|
+
status: 200,
|
|
550
|
+
contentType: 'application/json',
|
|
551
|
+
body: JSON.stringify({ unreadCount: 5 }),
|
|
552
|
+
})
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
await page.goto('/dashboard')
|
|
556
|
+
|
|
557
|
+
const badge = page.locator('[data-testid="notification-badge"]')
|
|
558
|
+
await expect(badge).toBeVisible()
|
|
559
|
+
await expect(badge).toContainText('5')
|
|
560
|
+
})
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
---
|
|
564
|
+
|
|
565
|
+
## Mobile Testing
|
|
566
|
+
|
|
567
|
+
```typescript
|
|
568
|
+
import { devices } from '@playwright/test'
|
|
569
|
+
|
|
570
|
+
test.describe('Mobile', () => {
|
|
571
|
+
test.use({ ...devices['iPhone 13'] })
|
|
572
|
+
|
|
573
|
+
test('navigation menu opens on mobile', async ({ page }) => {
|
|
574
|
+
await page.goto('/dashboard')
|
|
575
|
+
|
|
576
|
+
// Mobile menu should be hidden initially
|
|
577
|
+
await expect(page.locator('[data-testid="mobile-menu"]')).toBeHidden()
|
|
578
|
+
|
|
579
|
+
// Click hamburger
|
|
580
|
+
await page.click('[data-testid="hamburger-button"]')
|
|
581
|
+
|
|
582
|
+
// Menu should be visible
|
|
583
|
+
await expect(page.locator('[data-testid="mobile-menu"]')).toBeVisible()
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
test('can navigate via mobile menu', async ({ page }) => {
|
|
587
|
+
await page.goto('/dashboard')
|
|
588
|
+
|
|
589
|
+
await page.click('[data-testid="hamburger-button"]')
|
|
590
|
+
await page.click('[data-testid="mobile-nav-items"]')
|
|
591
|
+
|
|
592
|
+
await expect(page).toHaveURL('/dashboard/items')
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
test('forms are usable on mobile', async ({ page }) => {
|
|
596
|
+
await page.goto('/dashboard/items/new')
|
|
597
|
+
|
|
598
|
+
// Verify form fields are tappable (large enough touch targets)
|
|
599
|
+
const nameInput = page.locator('[data-testid="item-name"]')
|
|
600
|
+
const box = await nameInput.boundingBox()
|
|
601
|
+
|
|
602
|
+
expect(box).toBeTruthy()
|
|
603
|
+
expect(box!.height).toBeGreaterThanOrEqual(44) // Min touch target
|
|
604
|
+
|
|
605
|
+
// Fill and submit form
|
|
606
|
+
await nameInput.fill('Mobile Item')
|
|
607
|
+
await page.fill('[data-testid="item-email"]', 'mobile@example.com')
|
|
608
|
+
await page.click('[data-testid="submit-item"]')
|
|
609
|
+
|
|
610
|
+
await expect(page.locator('[data-testid="toast-success"]')).toBeVisible()
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
test('cards stack vertically on mobile', async ({ page }) => {
|
|
614
|
+
await page.goto('/dashboard/items')
|
|
615
|
+
|
|
616
|
+
const cards = page.locator('[data-testid="item-card"]')
|
|
617
|
+
const count = await cards.count()
|
|
618
|
+
|
|
619
|
+
if (count >= 2) {
|
|
620
|
+
const first = await cards.nth(0).boundingBox()
|
|
621
|
+
const second = await cards.nth(1).boundingBox()
|
|
622
|
+
|
|
623
|
+
expect(first).toBeTruthy()
|
|
624
|
+
expect(second).toBeTruthy()
|
|
625
|
+
// Second card should be below the first (stacked vertically)
|
|
626
|
+
expect(second!.y).toBeGreaterThan(first!.y)
|
|
627
|
+
// Both should have similar x position (same column)
|
|
628
|
+
expect(Math.abs(first!.x - second!.x)).toBeLessThan(10)
|
|
629
|
+
}
|
|
630
|
+
})
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
test.describe('Tablet', () => {
|
|
634
|
+
test.use({ ...devices['iPad Pro 11'] })
|
|
635
|
+
|
|
636
|
+
test('shows sidebar navigation on tablet', async ({ page }) => {
|
|
637
|
+
await page.goto('/dashboard')
|
|
638
|
+
|
|
639
|
+
await expect(page.locator('[data-testid="sidebar"]')).toBeVisible()
|
|
640
|
+
await expect(page.locator('[data-testid="hamburger-button"]')).toBeHidden()
|
|
641
|
+
})
|
|
642
|
+
})
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
---
|
|
646
|
+
|
|
647
|
+
## Test Data Management
|
|
648
|
+
|
|
649
|
+
### Using Fixtures
|
|
650
|
+
|
|
651
|
+
```typescript
|
|
652
|
+
// {{e2eDir}}/fixtures/testData.ts
|
|
653
|
+
export const testItems = [
|
|
654
|
+
{
|
|
655
|
+
name: 'Test Item Alpha',
|
|
656
|
+
email: 'alpha@test.com',
|
|
657
|
+
phone: '555-111-1111',
|
|
658
|
+
status: 'ACTIVE',
|
|
659
|
+
},
|
|
660
|
+
{
|
|
661
|
+
name: 'Test Item Beta',
|
|
662
|
+
email: 'beta@test.com',
|
|
663
|
+
phone: '555-222-2222',
|
|
664
|
+
status: 'INACTIVE',
|
|
665
|
+
},
|
|
666
|
+
{
|
|
667
|
+
name: 'Test Item Gamma',
|
|
668
|
+
email: 'gamma@test.com',
|
|
669
|
+
phone: '555-333-3333',
|
|
670
|
+
status: 'PENDING',
|
|
671
|
+
},
|
|
672
|
+
]
|
|
673
|
+
|
|
674
|
+
export const testUsers = {
|
|
675
|
+
regular: {
|
|
676
|
+
email: 'testuser@example.com',
|
|
677
|
+
password: 'TestPass123!',
|
|
678
|
+
name: 'Test User',
|
|
679
|
+
},
|
|
680
|
+
admin: {
|
|
681
|
+
email: 'admin@example.com',
|
|
682
|
+
password: 'AdminPass123!',
|
|
683
|
+
name: 'Admin User',
|
|
684
|
+
},
|
|
685
|
+
}
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
### Seeding and Cleaning Up
|
|
689
|
+
|
|
690
|
+
```typescript
|
|
691
|
+
// Seed data before tests
|
|
692
|
+
test.beforeAll(async ({ request }) => {
|
|
693
|
+
await request.post('{{baseUrl}}/api/test/seed', {
|
|
694
|
+
data: { items: testItems },
|
|
695
|
+
})
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
// Clean up after tests
|
|
699
|
+
test.afterAll(async ({ request }) => {
|
|
700
|
+
await request.post('{{baseUrl}}/api/test/cleanup')
|
|
701
|
+
})
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
### Database Seed Script Pattern
|
|
705
|
+
|
|
706
|
+
```typescript
|
|
707
|
+
// {{e2eDir}}/fixtures/seed.ts
|
|
708
|
+
import { test as setup } from '@playwright/test'
|
|
709
|
+
|
|
710
|
+
setup('seed test data', async ({ request }) => {
|
|
711
|
+
// Create test organization
|
|
712
|
+
const orgResponse = await request.post('{{baseUrl}}/api/test/setup', {
|
|
713
|
+
data: {
|
|
714
|
+
orgName: 'Test Organization',
|
|
715
|
+
users: [
|
|
716
|
+
{ email: 'user@test.com', role: 'member' },
|
|
717
|
+
{ email: 'admin@test.com', role: 'admin' },
|
|
718
|
+
],
|
|
719
|
+
},
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
expect(orgResponse.ok()).toBeTruthy()
|
|
723
|
+
|
|
724
|
+
// Create test data within that org
|
|
725
|
+
await request.post('{{baseUrl}}/api/test/seed', {
|
|
726
|
+
data: { items: testItems },
|
|
727
|
+
headers: {
|
|
728
|
+
'x-test-org': (await orgResponse.json()).orgId,
|
|
729
|
+
},
|
|
730
|
+
})
|
|
731
|
+
})
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
---
|
|
735
|
+
|
|
736
|
+
## Waiting Strategies
|
|
737
|
+
|
|
738
|
+
| Strategy | Use When |
|
|
739
|
+
|----------|----------|
|
|
740
|
+
| `waitForURL(url)` | After navigation (click a link, submit form) |
|
|
741
|
+
| `waitForResponse(url)` | After API call triggered by user action |
|
|
742
|
+
| `waitForLoadState('networkidle')` | Page fully loaded, no pending requests |
|
|
743
|
+
| `waitForSelector(selector)` | Specific element appears in DOM |
|
|
744
|
+
| `expect(locator).toBeVisible()` | Element is visible (auto-waits) |
|
|
745
|
+
| `waitForTimeout(ms)` | Last resort only (avoid in production tests) |
|
|
746
|
+
|
|
747
|
+
```typescript
|
|
748
|
+
// Wait for navigation
|
|
749
|
+
await page.click('[data-testid="nav-items"]')
|
|
750
|
+
await page.waitForURL('/dashboard/items')
|
|
751
|
+
|
|
752
|
+
// Wait for specific API response
|
|
753
|
+
await page.fill('[data-testid="search"]', 'query')
|
|
754
|
+
await page.waitForResponse(resp =>
|
|
755
|
+
resp.url().includes('{{apiBaseUrl}}/search') && resp.status() === 200
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
// Wait for network to be idle
|
|
759
|
+
await page.goto('/dashboard')
|
|
760
|
+
await page.waitForLoadState('networkidle')
|
|
761
|
+
|
|
762
|
+
// Wait for element to appear
|
|
763
|
+
await expect(page.locator('[data-testid="results"]')).toBeVisible()
|
|
764
|
+
|
|
765
|
+
// Wait for element to disappear
|
|
766
|
+
await expect(page.locator('[data-testid="spinner"]')).toBeHidden()
|
|
767
|
+
|
|
768
|
+
// Wait for element text content
|
|
769
|
+
await expect(page.locator('[data-testid="count"]')).toContainText('42')
|
|
770
|
+
|
|
771
|
+
// Combine with timeout for slow operations
|
|
772
|
+
await expect(page.locator('[data-testid="report"]')).toBeVisible({
|
|
773
|
+
timeout: 30_000, // 30s for slow report generation
|
|
774
|
+
})
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
### Anti-Patterns
|
|
778
|
+
|
|
779
|
+
```typescript
|
|
780
|
+
// BAD - Fixed timeout (flaky!)
|
|
781
|
+
await page.waitForTimeout(3000)
|
|
782
|
+
|
|
783
|
+
// GOOD - Wait for specific condition
|
|
784
|
+
await page.waitForResponse('**/api/data')
|
|
785
|
+
|
|
786
|
+
// BAD - Polling with sleep
|
|
787
|
+
while (!(await page.isVisible('.loaded'))) {
|
|
788
|
+
await page.waitForTimeout(100)
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// GOOD - Built-in auto-waiting
|
|
792
|
+
await expect(page.locator('.loaded')).toBeVisible()
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
---
|
|
796
|
+
|
|
797
|
+
## Multi-Tab / Multi-User Testing
|
|
798
|
+
|
|
799
|
+
Test real-time features or multi-role workflows:
|
|
800
|
+
|
|
801
|
+
```typescript
|
|
802
|
+
test('real-time updates between two users', async ({ browser }) => {
|
|
803
|
+
// Open first browser context (User A)
|
|
804
|
+
const contextA = await browser.newContext()
|
|
805
|
+
const pageA = await contextA.newPage()
|
|
806
|
+
// ... authenticate as User A
|
|
807
|
+
|
|
808
|
+
// Open second browser context (User B)
|
|
809
|
+
const contextB = await browser.newContext()
|
|
810
|
+
const pageB = await contextB.newPage()
|
|
811
|
+
// ... authenticate as User B
|
|
812
|
+
|
|
813
|
+
// User A creates an item
|
|
814
|
+
await pageA.goto('/dashboard/items')
|
|
815
|
+
await pageA.click('[data-testid="create-item-button"]')
|
|
816
|
+
await pageA.fill('[data-testid="item-name"]', 'Shared Item')
|
|
817
|
+
await pageA.click('[data-testid="submit-item"]')
|
|
818
|
+
|
|
819
|
+
// User B should see the new item (real-time update)
|
|
820
|
+
await pageB.goto('/dashboard/items')
|
|
821
|
+
await expect(pageB.locator('text=Shared Item')).toBeVisible({ timeout: 10_000 })
|
|
822
|
+
|
|
823
|
+
// Cleanup
|
|
824
|
+
await contextA.close()
|
|
825
|
+
await contextB.close()
|
|
826
|
+
})
|
|
827
|
+
|
|
828
|
+
test('admin and regular user see different views', async ({ browser }) => {
|
|
829
|
+
const adminContext = await browser.newContext()
|
|
830
|
+
const adminPage = await adminContext.newPage()
|
|
831
|
+
// ... authenticate as admin
|
|
832
|
+
|
|
833
|
+
const userContext = await browser.newContext()
|
|
834
|
+
const userPage = await userContext.newPage()
|
|
835
|
+
// ... authenticate as regular user
|
|
836
|
+
|
|
837
|
+
// Admin sees admin panel link
|
|
838
|
+
await adminPage.goto('/dashboard')
|
|
839
|
+
await expect(adminPage.locator('[data-testid="admin-link"]')).toBeVisible()
|
|
840
|
+
|
|
841
|
+
// Regular user does not
|
|
842
|
+
await userPage.goto('/dashboard')
|
|
843
|
+
await expect(userPage.locator('[data-testid="admin-link"]')).toBeHidden()
|
|
844
|
+
|
|
845
|
+
await adminContext.close()
|
|
846
|
+
await userContext.close()
|
|
847
|
+
})
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
---
|
|
851
|
+
|
|
852
|
+
## Form Testing Patterns
|
|
853
|
+
|
|
854
|
+
```typescript
|
|
855
|
+
test.describe('Multi-Step Form', () => {
|
|
856
|
+
test('completes full form wizard', async ({ page }) => {
|
|
857
|
+
await page.goto('/onboarding')
|
|
858
|
+
|
|
859
|
+
// Step 1: Personal info
|
|
860
|
+
await page.fill('[data-testid="first-name"]', 'Jane')
|
|
861
|
+
await page.fill('[data-testid="last-name"]', 'Smith')
|
|
862
|
+
await page.click('[data-testid="next-step"]')
|
|
863
|
+
|
|
864
|
+
// Step 2: Company info
|
|
865
|
+
await expect(page.locator('[data-testid="step-indicator"]')).toContainText('2 of 3')
|
|
866
|
+
await page.fill('[data-testid="company-name"]', 'Acme Corp')
|
|
867
|
+
await page.click('[data-testid="next-step"]')
|
|
868
|
+
|
|
869
|
+
// Step 3: Review and submit
|
|
870
|
+
await expect(page.locator('[data-testid="step-indicator"]')).toContainText('3 of 3')
|
|
871
|
+
await expect(page.locator('text=Jane Smith')).toBeVisible()
|
|
872
|
+
await expect(page.locator('text=Acme Corp')).toBeVisible()
|
|
873
|
+
await page.click('[data-testid="submit-form"]')
|
|
874
|
+
|
|
875
|
+
// Success state
|
|
876
|
+
await expect(page.locator('[data-testid="success-message"]')).toBeVisible()
|
|
877
|
+
})
|
|
878
|
+
|
|
879
|
+
test('can go back and edit previous steps', async ({ page }) => {
|
|
880
|
+
await page.goto('/onboarding')
|
|
881
|
+
|
|
882
|
+
// Fill step 1
|
|
883
|
+
await page.fill('[data-testid="first-name"]', 'Jane')
|
|
884
|
+
await page.click('[data-testid="next-step"]')
|
|
885
|
+
|
|
886
|
+
// Go back
|
|
887
|
+
await page.click('[data-testid="prev-step"]')
|
|
888
|
+
|
|
889
|
+
// Verify data is preserved
|
|
890
|
+
await expect(page.locator('[data-testid="first-name"]')).toHaveValue('Jane')
|
|
891
|
+
})
|
|
892
|
+
|
|
893
|
+
test('shows validation errors on incomplete step', async ({ page }) => {
|
|
894
|
+
await page.goto('/onboarding')
|
|
895
|
+
|
|
896
|
+
// Try to advance without filling required fields
|
|
897
|
+
await page.click('[data-testid="next-step"]')
|
|
898
|
+
|
|
899
|
+
await expect(page.locator('[data-testid="error-first-name"]')).toBeVisible()
|
|
900
|
+
})
|
|
901
|
+
})
|
|
902
|
+
```
|
|
903
|
+
|
|
904
|
+
---
|
|
905
|
+
|
|
906
|
+
## File Upload Testing
|
|
907
|
+
|
|
908
|
+
```typescript
|
|
909
|
+
test('can upload a file', async ({ page }) => {
|
|
910
|
+
await page.goto('/dashboard/items/1')
|
|
911
|
+
|
|
912
|
+
// Trigger file upload
|
|
913
|
+
const fileInput = page.locator('input[type="file"]')
|
|
914
|
+
await fileInput.setInputFiles({
|
|
915
|
+
name: 'test-document.pdf',
|
|
916
|
+
mimeType: 'application/pdf',
|
|
917
|
+
buffer: Buffer.from('fake pdf content'),
|
|
918
|
+
})
|
|
919
|
+
|
|
920
|
+
// Verify upload success
|
|
921
|
+
await expect(page.locator('[data-testid="upload-success"]')).toBeVisible()
|
|
922
|
+
await expect(page.locator('text=test-document.pdf')).toBeVisible()
|
|
923
|
+
})
|
|
924
|
+
|
|
925
|
+
test('can upload multiple images', async ({ page }) => {
|
|
926
|
+
await page.goto('/dashboard/items/1/photos')
|
|
927
|
+
|
|
928
|
+
const fileInput = page.locator('input[type="file"]')
|
|
929
|
+
await fileInput.setInputFiles([
|
|
930
|
+
{ name: 'photo1.jpg', mimeType: 'image/jpeg', buffer: Buffer.from('img1') },
|
|
931
|
+
{ name: 'photo2.jpg', mimeType: 'image/jpeg', buffer: Buffer.from('img2') },
|
|
932
|
+
])
|
|
933
|
+
|
|
934
|
+
await expect(page.locator('[data-testid="photo-thumbnail"]')).toHaveCount(2)
|
|
935
|
+
})
|
|
936
|
+
|
|
937
|
+
test('shows error for invalid file type', async ({ page }) => {
|
|
938
|
+
await page.goto('/dashboard/items/1')
|
|
939
|
+
|
|
940
|
+
const fileInput = page.locator('input[type="file"]')
|
|
941
|
+
await fileInput.setInputFiles({
|
|
942
|
+
name: 'malware.exe',
|
|
943
|
+
mimeType: 'application/octet-stream',
|
|
944
|
+
buffer: Buffer.from('bad content'),
|
|
945
|
+
})
|
|
946
|
+
|
|
947
|
+
await expect(page.locator('[data-testid="upload-error"]')).toBeVisible()
|
|
948
|
+
await expect(page.locator('text=File type not supported')).toBeVisible()
|
|
949
|
+
})
|
|
950
|
+
```
|
|
951
|
+
|
|
952
|
+
---
|
|
953
|
+
|
|
954
|
+
## Accessibility Testing in E2E
|
|
955
|
+
|
|
956
|
+
```typescript
|
|
957
|
+
import { test, expect } from '@playwright/test'
|
|
958
|
+
import AxeBuilder from '@axe-core/playwright'
|
|
959
|
+
|
|
960
|
+
test.describe('Accessibility', () => {
|
|
961
|
+
test('dashboard has no a11y violations', async ({ page }) => {
|
|
962
|
+
await page.goto('/dashboard')
|
|
963
|
+
await page.waitForLoadState('networkidle')
|
|
964
|
+
|
|
965
|
+
const results = await new AxeBuilder({ page }).analyze()
|
|
966
|
+
|
|
967
|
+
expect(results.violations).toEqual([])
|
|
968
|
+
})
|
|
969
|
+
|
|
970
|
+
test('form page has no a11y violations', async ({ page }) => {
|
|
971
|
+
await page.goto('/dashboard/items/new')
|
|
972
|
+
|
|
973
|
+
const results = await new AxeBuilder({ page })
|
|
974
|
+
.include('[data-testid="item-form"]')
|
|
975
|
+
.analyze()
|
|
976
|
+
|
|
977
|
+
expect(results.violations).toEqual([])
|
|
978
|
+
})
|
|
979
|
+
|
|
980
|
+
test('keyboard navigation works', async ({ page }) => {
|
|
981
|
+
await page.goto('/dashboard')
|
|
982
|
+
|
|
983
|
+
// Tab through navigation
|
|
984
|
+
await page.keyboard.press('Tab')
|
|
985
|
+
const firstFocused = await page.evaluate(() => document.activeElement?.tagName)
|
|
986
|
+
expect(firstFocused).toBeTruthy()
|
|
987
|
+
|
|
988
|
+
// Can activate button with Enter
|
|
989
|
+
await page.keyboard.press('Tab') // Move to a button
|
|
990
|
+
await page.keyboard.press('Enter')
|
|
991
|
+
})
|
|
992
|
+
})
|
|
993
|
+
```
|
|
994
|
+
|
|
995
|
+
---
|
|
996
|
+
|
|
997
|
+
## PWA Testing
|
|
998
|
+
|
|
999
|
+
```typescript
|
|
1000
|
+
test.describe('PWA Features', () => {
|
|
1001
|
+
test('service worker registers', async ({ page }) => {
|
|
1002
|
+
await page.goto('{{baseUrl}}')
|
|
1003
|
+
|
|
1004
|
+
const swRegistered = await page.evaluate(async () => {
|
|
1005
|
+
const registrations = await navigator.serviceWorker.getRegistrations()
|
|
1006
|
+
return registrations.length > 0
|
|
1007
|
+
})
|
|
1008
|
+
expect(swRegistered).toBe(true)
|
|
1009
|
+
})
|
|
1010
|
+
|
|
1011
|
+
test('manifest is valid', async ({ page }) => {
|
|
1012
|
+
const response = await page.goto('{{baseUrl}}/manifest.json')
|
|
1013
|
+
const manifest = await response?.json()
|
|
1014
|
+
|
|
1015
|
+
expect(manifest.name).toBeDefined()
|
|
1016
|
+
expect(manifest.icons).toBeDefined()
|
|
1017
|
+
expect(manifest.icons.length).toBeGreaterThan(0)
|
|
1018
|
+
expect(manifest.start_url).toBeDefined()
|
|
1019
|
+
expect(manifest.display).toBe('standalone')
|
|
1020
|
+
})
|
|
1021
|
+
|
|
1022
|
+
test('works offline for cached pages', async ({ page, context }) => {
|
|
1023
|
+
// Load page first to cache
|
|
1024
|
+
await page.goto('{{baseUrl}}/dashboard')
|
|
1025
|
+
await page.waitForLoadState('networkidle')
|
|
1026
|
+
|
|
1027
|
+
// Go offline
|
|
1028
|
+
await context.setOffline(true)
|
|
1029
|
+
|
|
1030
|
+
// Navigate to cached page
|
|
1031
|
+
await page.goto('{{baseUrl}}/dashboard')
|
|
1032
|
+
|
|
1033
|
+
// Should still show content
|
|
1034
|
+
await expect(page.locator('h1')).toBeVisible()
|
|
1035
|
+
})
|
|
1036
|
+
})
|
|
1037
|
+
```
|
|
1038
|
+
|
|
1039
|
+
---
|
|
1040
|
+
|
|
1041
|
+
## Playwright Config
|
|
1042
|
+
|
|
1043
|
+
```typescript
|
|
1044
|
+
// playwright.config.ts
|
|
1045
|
+
import { defineConfig, devices } from '@playwright/test'
|
|
1046
|
+
|
|
1047
|
+
export default defineConfig({
|
|
1048
|
+
testDir: './{{e2eDir}}',
|
|
1049
|
+
fullyParallel: true,
|
|
1050
|
+
forbidOnly: !!process.env.CI,
|
|
1051
|
+
retries: process.env.CI ? 2 : 0,
|
|
1052
|
+
workers: process.env.CI ? 1 : undefined,
|
|
1053
|
+
reporter: [
|
|
1054
|
+
['html'],
|
|
1055
|
+
['json', { outputFile: 'test-results/results.json' }],
|
|
1056
|
+
],
|
|
1057
|
+
|
|
1058
|
+
use: {
|
|
1059
|
+
baseURL: '{{baseUrl}}',
|
|
1060
|
+
trace: 'on-first-retry',
|
|
1061
|
+
screenshot: 'only-on-failure',
|
|
1062
|
+
video: 'retain-on-failure',
|
|
1063
|
+
},
|
|
1064
|
+
|
|
1065
|
+
projects: [
|
|
1066
|
+
// Setup project for auth
|
|
1067
|
+
{
|
|
1068
|
+
name: 'setup',
|
|
1069
|
+
testMatch: /.*\.setup\.ts/,
|
|
1070
|
+
},
|
|
1071
|
+
// Desktop browsers
|
|
1072
|
+
{
|
|
1073
|
+
name: 'chromium',
|
|
1074
|
+
use: { ...devices['Desktop Chrome'] },
|
|
1075
|
+
dependencies: ['setup'],
|
|
1076
|
+
},
|
|
1077
|
+
{
|
|
1078
|
+
name: 'firefox',
|
|
1079
|
+
use: { ...devices['Desktop Firefox'] },
|
|
1080
|
+
dependencies: ['setup'],
|
|
1081
|
+
},
|
|
1082
|
+
{
|
|
1083
|
+
name: 'webkit',
|
|
1084
|
+
use: { ...devices['Desktop Safari'] },
|
|
1085
|
+
dependencies: ['setup'],
|
|
1086
|
+
},
|
|
1087
|
+
// Mobile browsers
|
|
1088
|
+
{
|
|
1089
|
+
name: 'Mobile Chrome',
|
|
1090
|
+
use: { ...devices['Pixel 5'] },
|
|
1091
|
+
dependencies: ['setup'],
|
|
1092
|
+
},
|
|
1093
|
+
{
|
|
1094
|
+
name: 'Mobile Safari',
|
|
1095
|
+
use: { ...devices['iPhone 12'] },
|
|
1096
|
+
dependencies: ['setup'],
|
|
1097
|
+
},
|
|
1098
|
+
],
|
|
1099
|
+
|
|
1100
|
+
webServer: {
|
|
1101
|
+
command: 'pnpm dev',
|
|
1102
|
+
url: '{{baseUrl}}',
|
|
1103
|
+
reuseExistingServer: !process.env.CI,
|
|
1104
|
+
timeout: 120_000,
|
|
1105
|
+
},
|
|
1106
|
+
})
|
|
1107
|
+
```
|
|
1108
|
+
|
|
1109
|
+
---
|
|
1110
|
+
|
|
1111
|
+
## Environment Variables for E2E
|
|
1112
|
+
|
|
1113
|
+
```bash
|
|
1114
|
+
# .env.test
|
|
1115
|
+
TEST_USER_EMAIL=testuser@example.com
|
|
1116
|
+
TEST_USER_PASSWORD=TestPass123!
|
|
1117
|
+
TEST_ADMIN_EMAIL=admin@example.com
|
|
1118
|
+
TEST_ADMIN_PASSWORD=AdminPass123!
|
|
1119
|
+
BASE_URL={{baseUrl}}
|
|
1120
|
+
```
|
|
1121
|
+
|
|
1122
|
+
---
|
|
1123
|
+
|
|
1124
|
+
## Checklist
|
|
1125
|
+
|
|
1126
|
+
Before submitting E2E tests:
|
|
1127
|
+
|
|
1128
|
+
- [ ] Tests cover critical user paths (happy path)
|
|
1129
|
+
- [ ] Error states are tested (API failures, validation errors)
|
|
1130
|
+
- [ ] Page Objects used for complex pages (3+ interactions)
|
|
1131
|
+
- [ ] Authentication handled via fixture (not repeated in each test)
|
|
1132
|
+
- [ ] API mocking for error/edge states
|
|
1133
|
+
- [ ] Mobile viewport tested for responsive layouts
|
|
1134
|
+
- [ ] No flaky selectors (use data-testid, not CSS classes)
|
|
1135
|
+
- [ ] Proper waiting strategies used (no `waitForTimeout`)
|
|
1136
|
+
- [ ] Test data seeded and cleaned up
|
|
1137
|
+
- [ ] Screenshots/traces configured for failures
|
|
1138
|
+
- [ ] Keyboard navigation tested for key flows
|
|
1139
|
+
- [ ] Multi-step flows tested end-to-end
|
|
1140
|
+
- [ ] File uploads tested (if applicable)
|
|
1141
|
+
|
|
1142
|
+
---
|
|
1143
|
+
|
|
1144
|
+
## Running E2E Tests
|
|
1145
|
+
|
|
1146
|
+
```bash
|
|
1147
|
+
# Run all E2E tests
|
|
1148
|
+
{{e2eCommand}}
|
|
1149
|
+
|
|
1150
|
+
# Run with UI mode (interactive debugging)
|
|
1151
|
+
{{e2eCommand}} --ui
|
|
1152
|
+
|
|
1153
|
+
# Run specific test file
|
|
1154
|
+
{{e2eCommand}} {{e2eDir}}/item-management.spec.ts
|
|
1155
|
+
|
|
1156
|
+
# Run in headed mode (visible browser)
|
|
1157
|
+
{{e2eCommand}} --headed
|
|
1158
|
+
|
|
1159
|
+
# Run specific project (browser)
|
|
1160
|
+
{{e2eCommand}} --project=chromium
|
|
1161
|
+
|
|
1162
|
+
# Run with debug mode (step through)
|
|
1163
|
+
PWDEBUG=1 {{e2eCommand}}
|
|
1164
|
+
|
|
1165
|
+
# Generate HTML report
|
|
1166
|
+
{{e2eCommand}} --reporter=html
|
|
1167
|
+
|
|
1168
|
+
# Update visual snapshots
|
|
1169
|
+
{{e2eCommand}} --update-snapshots
|
|
1170
|
+
|
|
1171
|
+
# Run in CI mode (all browsers, retries)
|
|
1172
|
+
CI=true {{e2eCommand}}
|
|
1173
|
+
```
|
|
1174
|
+
|
|
1175
|
+
---
|
|
1176
|
+
|
|
1177
|
+
## Debugging Failed Tests
|
|
1178
|
+
|
|
1179
|
+
```typescript
|
|
1180
|
+
// Add debug logging to failing tests
|
|
1181
|
+
test('debug example', async ({ page }) => {
|
|
1182
|
+
// Enable verbose logging
|
|
1183
|
+
page.on('console', msg => console.log('BROWSER:', msg.text()))
|
|
1184
|
+
page.on('requestfailed', req => console.log('FAILED:', req.url(), req.failure()?.errorText))
|
|
1185
|
+
|
|
1186
|
+
// Pause execution for manual debugging
|
|
1187
|
+
await page.pause() // Opens Playwright Inspector
|
|
1188
|
+
|
|
1189
|
+
// Take screenshot at specific point
|
|
1190
|
+
await page.screenshot({ path: 'debug-screenshot.png', fullPage: true })
|
|
1191
|
+
})
|
|
1192
|
+
```
|
|
1193
|
+
|
|
1194
|
+
---
|
|
1195
|
+
|
|
1196
|
+
## See Also
|
|
1197
|
+
|
|
1198
|
+
- `unit-test.md` - Unit testing patterns
|
|
1199
|
+
- `component-test.md` - Component testing patterns
|
|
1200
|
+
- `test-standards.md` - Coverage thresholds and test type requirements
|