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.
Files changed (39) hide show
  1. package/README.md +461 -0
  2. package/VERSION +1 -0
  3. package/bin/install.js +116 -0
  4. package/commands/qa/continue.md +77 -0
  5. package/commands/qa/full.md +149 -0
  6. package/commands/qa/init.md +105 -0
  7. package/commands/qa/resume.md +91 -0
  8. package/commands/qa/status.md +66 -0
  9. package/package.json +28 -0
  10. package/skills/qa/SKILL.md +420 -0
  11. package/skills/qa/references/continuation-format.md +58 -0
  12. package/skills/qa/references/exit-criteria.md +53 -0
  13. package/skills/qa/references/lifecycle.md +181 -0
  14. package/skills/qa/references/model-profiles.md +77 -0
  15. package/skills/qa/templates/agent-skeleton.md +733 -0
  16. package/skills/qa/templates/component-test.md +1088 -0
  17. package/skills/qa/templates/domain-research-queries.md +101 -0
  18. package/skills/qa/templates/domain-security-profiles.md +182 -0
  19. package/skills/qa/templates/e2e-test.md +1200 -0
  20. package/skills/qa/templates/nielsen-heuristics.md +274 -0
  21. package/skills/qa/templates/performance-benchmarks-base.md +321 -0
  22. package/skills/qa/templates/qa-report-template.md +271 -0
  23. package/skills/qa/templates/security-checklist-owasp.md +451 -0
  24. package/skills/qa/templates/stop-points/bootstrap-complete.md +36 -0
  25. package/skills/qa/templates/stop-points/certified.md +25 -0
  26. package/skills/qa/templates/stop-points/escalated.md +32 -0
  27. package/skills/qa/templates/stop-points/fix-ready.md +43 -0
  28. package/skills/qa/templates/stop-points/phase-transition.md +4 -0
  29. package/skills/qa/templates/stop-points/status-dashboard.md +32 -0
  30. package/skills/qa/templates/test-standards.md +652 -0
  31. package/skills/qa/templates/unit-test.md +998 -0
  32. package/skills/qa/templates/visual-regression.md +418 -0
  33. package/skills/qa/workflows/bootstrap.md +45 -0
  34. package/skills/qa/workflows/decision-gate.md +66 -0
  35. package/skills/qa/workflows/fix-execute.md +132 -0
  36. package/skills/qa/workflows/fix-plan.md +52 -0
  37. package/skills/qa/workflows/report-phase.md +64 -0
  38. package/skills/qa/workflows/test-phase.md +86 -0
  39. 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