openspec-playwright 0.2.6 → 0.2.8
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/.claude/skills/openspec-e2e/SKILL.md +198 -285
- package/package.json +1 -1
|
@@ -5,7 +5,7 @@ license: MIT
|
|
|
5
5
|
compatibility: Requires openspec CLI, Playwright (with browsers installed), and @playwright/mcp (globally installed via `claude mcp add playwright npx @playwright/mcp@latest`).
|
|
6
6
|
metadata:
|
|
7
7
|
author: openspec-playwright
|
|
8
|
-
version: "2.
|
|
8
|
+
version: "2.20"
|
|
9
9
|
---
|
|
10
10
|
|
|
11
11
|
## Input
|
|
@@ -20,6 +20,7 @@ metadata:
|
|
|
20
20
|
- **Page Objects** (all mode): `tests/playwright/pages/<Route>Page.ts`
|
|
21
21
|
- **Auth setup**: `tests/playwright/auth.setup.ts` (if auth required)
|
|
22
22
|
- **Report**: `openspec/reports/playwright-e2e-<name>-<timestamp>.md`
|
|
23
|
+
- **App Bug Registry**: `openspec/reports/app-bug-registry.md` (cumulative, per-project)
|
|
23
24
|
- **Test plan**: `openspec/changes/<name>/specs/playwright/test-plan.md` (change mode only)
|
|
24
25
|
|
|
25
26
|
## Architecture
|
|
@@ -59,6 +60,46 @@ Can the user SEE this on screen?
|
|
|
59
60
|
→ No → Record reason → page.request acceptable
|
|
60
61
|
```
|
|
61
62
|
|
|
63
|
+
**Business logic assertion rule — numerical/calculated values MUST use API assertion**:
|
|
64
|
+
|
|
65
|
+
UI assertions verify **rendering** correctness, not **calculation** correctness. A UI that correctly displays a wrong value will pass UI-only tests.
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
Is the assertion about a computed/counted/calculated value?
|
|
69
|
+
(e.g., balance, total, discount, count, percentage, score, ranking)
|
|
70
|
+
→ Yes → Use page.request to fetch backend data → assert the raw value
|
|
71
|
+
→ No → UI assertion is sufficient
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Examples where API assertion is required:**
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
// ❌ UI-only assertion — hides calculation bugs
|
|
78
|
+
await page.getByText('¥800').click(); // buy item
|
|
79
|
+
await expect(page.getByText('总金额: ¥800')).toBeVisible(); // passes even if backend rounded wrong
|
|
80
|
+
|
|
81
|
+
// ✅ API assertion — catches calculation bugs
|
|
82
|
+
const order = await page.request.get(`${BASE_URL}/api/orders/${orderId}`);
|
|
83
|
+
const body = await order.json();
|
|
84
|
+
expect(body.total).toBe(800); // backend calculation is verified
|
|
85
|
+
|
|
86
|
+
// ❌ UI-only assertion — hides optimistic update failures
|
|
87
|
+
await page.getByRole('button', { name: '点赞' }).click();
|
|
88
|
+
await expect(page.getByText('1 个赞')).toBeVisible(); // passes even if POST failed silently
|
|
89
|
+
|
|
90
|
+
// ✅ API assertion — catches backend sync failures
|
|
91
|
+
const post = await page.request.get(`${BASE_URL}/api/posts/${postId}`);
|
|
92
|
+
const body = await post.json();
|
|
93
|
+
expect(body.likeCount).toBeGreaterThan(0); // backend state is verified
|
|
94
|
+
|
|
95
|
+
// ✅ Optimistic update with API verification
|
|
96
|
+
await page.getByRole('button', { name: '点赞' }).click();
|
|
97
|
+
await expect(page.getByText('1 个赞')).toBeVisible(); // optimistic UI
|
|
98
|
+
await page.waitForResponse(r => r.url().includes('/api/like')); // wait for backend
|
|
99
|
+
const post = await page.request.get(`${BASE_URL}/api/posts/${postId}`);
|
|
100
|
+
expect((await post.json()).likeCount).toBeGreaterThan(0); // verify persistence
|
|
101
|
+
```
|
|
102
|
+
|
|
62
103
|
**Never use API calls to replace routine UI flows.** If a test completes in < 200ms, it is almost certainly using `page.request` instead of real UI interactions.
|
|
63
104
|
|
|
64
105
|
## Steps
|
|
@@ -169,6 +210,32 @@ browser_navigate → browser_console_messages → browser_snapshot → browser_t
|
|
|
169
210
|
| Auth required, no credentials | Missing auth setup | Continue — skip protected routes, explore login page |
|
|
170
211
|
| Suspicious network request | API returned 4xx/5xx | Continue — mark `⚠️ API error: <endpoint> returned <code>` in app-exploration.md |
|
|
171
212
|
|
|
213
|
+
**Redirect / Refresh loop detection** — run after initial navigate:
|
|
214
|
+
|
|
215
|
+
```
|
|
216
|
+
// 1. Initial capture
|
|
217
|
+
await browser_navigate(`${BASE_URL}/<route>`);
|
|
218
|
+
await new Promise(r => setTimeout(r, 1500)); // wait for SPA hydration
|
|
219
|
+
const url1 = await browser_evaluate(() => window.location.href);
|
|
220
|
+
|
|
221
|
+
// 2. Observe stability
|
|
222
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
223
|
+
const url2 = await browser_evaluate(() => window.location.href);
|
|
224
|
+
const msgs = await browser_console_messages();
|
|
225
|
+
|
|
226
|
+
// 3. Detect
|
|
227
|
+
if (url1 !== url2) {
|
|
228
|
+
→ ❌ URL changed — redirect loop (ERR_TOO_MANY_REDIRECTS)
|
|
229
|
+
→ Is this route protected without valid auth.storageState?
|
|
230
|
+
→ Yes → auth.setup.ts is broken → fix auth first
|
|
231
|
+
→ No → App middleware bug → mark route ❌ skip, record as App Bug
|
|
232
|
+
}
|
|
233
|
+
if (msgs.filter(m => m.type === 'warning' || m.type === 'error').length > 10) {
|
|
234
|
+
→ ❌ Excessive console errors — page refresh / JS crash loop
|
|
235
|
+
→ Mark route ❌ skip, record as App Bug
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
172
239
|
**Network monitoring**: After navigating, use `browser_network_requests` to check for failed API calls. Failed requests (status ≥ 400) on a route indicate an API/backend issue — record in `app-exploration.md` for reference.
|
|
173
240
|
|
|
174
241
|
**For guest routes** (no auth):
|
|
@@ -231,76 +298,17 @@ From `browser_snapshot` + `browser_evaluate`, identify these special elements pe
|
|
|
231
298
|
| File upload | `<input type="file">` | `accept` attribute, `multiple` flag | Medium |
|
|
232
299
|
| Drag-and-drop | drag events in JS | Simulate DnD via coordinate clicks | Low |
|
|
233
300
|
| Date picker | specific `data-testid` or class patterns | Click triggers → evaluate value | Low (skip unless specs mention) |
|
|
234
|
-
| Infinite scroll | Dynamic row insertion | Count elements before/after scroll | Low |
|
|
235
|
-
| WebSocket / SSE | No DOM signal | Check `browser_console_messages` for WS events | Low |
|
|
236
|
-
|
|
237
|
-
**For each detected special element, capture:**
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
let context = 'unknown';
|
|
246
|
-
if (c.getContext('webgl2')) context = 'webgl2';
|
|
247
|
-
else if (c.getContext('webgl')) context = 'webgl';
|
|
248
|
-
else if (c.getContext('2d')) context = '2d';
|
|
249
|
-
return {
|
|
250
|
-
id: c.id || '',
|
|
251
|
-
context,
|
|
252
|
-
width: c.width,
|
|
253
|
-
height: c.height,
|
|
254
|
-
};
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
// Iframe — record frameLocator
|
|
258
|
-
// Note: iframe has src or name attribute
|
|
259
|
-
|
|
260
|
-
// Rich text editor — get content
|
|
261
|
-
const editorContent = await browser_evaluate(() => {
|
|
262
|
-
const el = document.querySelector('[contenteditable]');
|
|
263
|
-
return el ? { tag: el.tagName, content: el.innerHTML, length: el.textContent.length } : null;
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
// Video / Audio — get state via evaluate (snapshot doesn't expose tagName)
|
|
267
|
-
const mediaState = await browser_evaluate(() => {
|
|
268
|
-
const v = document.querySelector('video');
|
|
269
|
-
if (v) return { type: 'video', paused: v.paused, duration: v.duration };
|
|
270
|
-
const a = document.querySelector('audio');
|
|
271
|
-
if (a) return { type: 'audio', paused: a.paused, duration: a.duration };
|
|
272
|
-
return null;
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
// contenteditable — detect via evaluate
|
|
276
|
-
const isContentEditable = await browser_evaluate(() => {
|
|
277
|
-
const el = document.querySelector('[contenteditable]');
|
|
278
|
-
return !!el;
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
// CAPTCHA — detect type
|
|
282
|
-
const captchaInfo = await browser_evaluate(() => {
|
|
283
|
-
const recaptcha = document.querySelector('.g-recaptcha, [data-sitekey]');
|
|
284
|
-
if (recaptcha) return { type: 'recaptcha', sitekey: recaptcha.getAttribute('data-sitekey') };
|
|
285
|
-
const hcaptcha = document.querySelector('.h-captcha');
|
|
286
|
-
if (hcaptcha) return { type: 'hcaptcha', sitekey: hcaptcha.getAttribute('data-sitekey') };
|
|
287
|
-
const turnstile = document.querySelector('[data-sitekey*="cloudflare"]');
|
|
288
|
-
if (turnstile) return { type: 'turnstile' };
|
|
289
|
-
const canvas = document.querySelector('canvas[class*="captcha"]');
|
|
290
|
-
if (canvas) return { type: 'canvas-captcha' };
|
|
291
|
-
const slider = document.querySelector('[class*="slider"], [class*="drag"]');
|
|
292
|
-
if (slider) return { type: 'slider-captcha' };
|
|
293
|
-
return null;
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
// OTP input — detect
|
|
297
|
-
const otpInfo = await browser_evaluate(() => {
|
|
298
|
-
const inputs = document.querySelectorAll('input');
|
|
299
|
-
const otpInputs = Array.from(inputs).filter(i => i.maxLength === 1 && i.type === 'text' || i.type === 'tel');
|
|
300
|
-
if (otpInputs.length >= 4) return { type: 'otp-sms', digits: otpInputs.length };
|
|
301
|
-
return null;
|
|
302
|
-
});
|
|
303
|
-
```
|
|
301
|
+
| Infinite scroll | Dynamic row insertion | Count elements before/after scroll | Low (skip unless specs mention dynamic lists/pagination) |
|
|
302
|
+
| WebSocket / SSE | No DOM signal | Check `browser_console_messages` for WS events | Low (check only if app uses real-time features) |
|
|
303
|
+
|
|
304
|
+
**For each detected special element, capture via `browser_evaluate` with targeted DOM queries:**
|
|
305
|
+
- Canvas: `getContext('webgl2'/'webgl'/'2d')`, `width`, `height`
|
|
306
|
+
- Iframe: `src` attribute → use `frameLocator` in tests
|
|
307
|
+
- CAPTCHA: `.g-recaptcha`, `.h-captcha`, `[data-sitekey]`, canvas+slider detection
|
|
308
|
+
- OTP: `input` elements with `maxLength === 1` or `type === 'tel'`
|
|
309
|
+
- Rich text: `[contenteditable]` → `innerHTML`, `textContent.length`
|
|
310
|
+
- Video/Audio: `querySelector('video'/'audio')` → `paused`, `duration`
|
|
311
|
+
- Shadow DOM: `role="generic"` with no children → check `shadowRoot`
|
|
304
312
|
|
|
305
313
|
Record findings in `app-exploration.md` → **Special Elements Detected** table.
|
|
306
314
|
|
|
@@ -377,7 +385,7 @@ Reply **yes** to proceed, or tell me to exclude routes or adjust strategies.
|
|
|
377
385
|
|
|
378
386
|
Template: `.claude/skills/openspec-e2e/templates/test-plan.md`
|
|
379
387
|
|
|
380
|
-
**Idempotency**: If test-plan.md exists → read and use, do
|
|
388
|
+
**Idempotency**: If test-plan.md exists → read and use, **but you MAY supplement missing test cases**. "Do not regenerate" means: do not discard existing cases, but you CAN add new ones discovered during Step 4 exploration that weren't in the original spec (e.g., empty states, error paths found during DOM exploration).
|
|
381
389
|
|
|
382
390
|
**⚠️ Human verification — STOP before generating code.**
|
|
383
391
|
|
|
@@ -408,15 +416,9 @@ After creating (or reading existing) test-plan.md, **stop and display the test p
|
|
|
408
416
|
- `<element or scenario not testable>`
|
|
409
417
|
````
|
|
410
418
|
|
|
411
|
-
|
|
419
|
+
**Important**: Only list special elements that were actually detected in Step 4. Do not pre-populate with all possible types. If no special elements were found → omit the **Special Elements** section entirely.
|
|
412
420
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
**Confirmation criteria**:
|
|
416
|
-
- All scenarios from OpenSpec specs are covered
|
|
417
|
-
- Special elements (Canvas, Iframe, Video, Audio, CAPTCHA, OTP, File Upload, Drag-drop, WebSocket) have correct automation strategy
|
|
418
|
-
- Auth states and roles are accurate
|
|
419
|
-
- Nothing important is missing
|
|
421
|
+
Then ask: "Does this coverage match your intent? Reply **yes** to proceed, or tell me what to add/change."
|
|
420
422
|
|
|
421
423
|
If the user requests changes → update test-plan.md → re-display summary → re-confirm → proceed.
|
|
422
424
|
|
|
@@ -424,7 +426,7 @@ If the user requests changes → update test-plan.md → re-display summary →
|
|
|
424
426
|
|
|
425
427
|
**"all" mode**: Build and expand Page Objects for future Change tests.
|
|
426
428
|
|
|
427
|
-
**Prerequisite
|
|
429
|
+
**Prerequisite** (change mode only): If `app-exploration.md` does not exist → **STOP**. Run Step 4 first. For **all mode**, exploration is embedded dynamically in Step 6 — no pre-existing app-exploration.md is required.
|
|
428
430
|
|
|
429
431
|
**Page Object pattern** — read before writing any page file:
|
|
430
432
|
|
|
@@ -466,7 +468,7 @@ For each discovered route:
|
|
|
466
468
|
2. Navigate to route with correct auth state
|
|
467
469
|
3. browser_snapshot to extract interactive elements (see 4.3 table)
|
|
468
470
|
4. Write or update `pages/<Route>Page.ts` — extend with newly discovered elements
|
|
469
|
-
5. Also write `tests/playwright/app-all.spec.ts` — smoke test
|
|
471
|
+
5. Also write `tests/playwright/app-all.spec.ts` — smoke test. **Minimum standard**: verify at least one heading or key interactive element is visible — not just "no crash". If the page loads but shows an empty shell or an error, the test must fail.
|
|
470
472
|
|
|
471
473
|
**Output priority**: Page Objects (`pages/*.ts`) are the primary asset. Smoke test is secondary. Existing Page Objects are never overwritten — only extended.
|
|
472
474
|
|
|
@@ -495,84 +497,13 @@ Is this assertion about a visible UI result?
|
|
|
495
497
|
|
|
496
498
|
**Test coverage — empty states**: For list/detail pages, explore the empty state. If the app shows a "no data" UI when the list is empty, generate a test to verify it. Empty states are often missing from specs but are real user paths.
|
|
497
499
|
|
|
498
|
-
**Test coverage — special elements**: Check `app-exploration.md` → **Special Elements Detected** table. For each special element
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
await expect(canvas).toBeVisible();
|
|
506
|
-
const box = await canvas.boundingBox();
|
|
507
|
-
expect(box.width).toBeGreaterThan(0);
|
|
508
|
-
await canvas.screenshot({ path: '__screenshots__/canvas.png' });
|
|
509
|
-
});
|
|
510
|
-
|
|
511
|
-
// Canvas — 2D pixel verification
|
|
512
|
-
test('canvas 2D content is not blank', async ({ page }) => {
|
|
513
|
-
await page.goto(`${BASE_URL}/<route>`);
|
|
514
|
-
const hasContent = await page.evaluate(() => {
|
|
515
|
-
const c = document.querySelector('canvas');
|
|
516
|
-
if (!c) return false;
|
|
517
|
-
const ctx = c.getContext('2d');
|
|
518
|
-
if (!ctx) return false;
|
|
519
|
-
const data = ctx.getImageData(0, 0, c.width, c.height).data;
|
|
520
|
-
return data.some((v, i) => i % 4 !== 3 && v !== 0); // non-transparent non-black pixel
|
|
521
|
-
});
|
|
522
|
-
expect(hasContent).toBe(true);
|
|
523
|
-
});
|
|
524
|
-
|
|
525
|
-
// Canvas — WebGL screenshot
|
|
526
|
-
test('canvas WebGL renders', async ({ page }) => {
|
|
527
|
-
await page.goto(`${BASE_URL}/<route>`);
|
|
528
|
-
const canvas = page.locator('canvas');
|
|
529
|
-
await expect(canvas).toBeVisible();
|
|
530
|
-
await canvas.screenshot({ path: '__screenshots__/webgl.png' });
|
|
531
|
-
// No pixel comparison — WebGL rendering may vary
|
|
532
|
-
});
|
|
533
|
-
|
|
534
|
-
// Iframe — switch context
|
|
535
|
-
test('iframe content is accessible', async ({ page }) => {
|
|
536
|
-
await page.goto(`${BASE_URL}/<route>`);
|
|
537
|
-
const frame = page.frameLocator('iframe[name="<name>"]');
|
|
538
|
-
await expect(frame.locator('<selector-inside-frame>')).toBeVisible();
|
|
539
|
-
});
|
|
540
|
-
|
|
541
|
-
// Rich text editor — evaluate content
|
|
542
|
-
test('editor content persists', async ({ page }) => {
|
|
543
|
-
await page.goto(`${BASE_URL}/<route>`);
|
|
544
|
-
const editor = page.locator('[contenteditable]');
|
|
545
|
-
await editor.click();
|
|
546
|
-
await page.keyboard.type('Hello E2E');
|
|
547
|
-
const content = await page.evaluate(() => {
|
|
548
|
-
const el = document.querySelector('[contenteditable]');
|
|
549
|
-
return el?.textContent;
|
|
550
|
-
});
|
|
551
|
-
expect(content).toContain('Hello E2E');
|
|
552
|
-
});
|
|
553
|
-
|
|
554
|
-
// Video — playback state
|
|
555
|
-
test('video can be played', async ({ page }) => {
|
|
556
|
-
await page.goto(`${BASE_URL}/<route>`);
|
|
557
|
-
const video = page.locator('video');
|
|
558
|
-
await expect(video).toBeVisible();
|
|
559
|
-
await video.evaluate((v: HTMLVideoElement) => { v.play(); });
|
|
560
|
-
const isPlaying = await video.evaluate((v: HTMLVideoElement) => !v.paused);
|
|
561
|
-
expect(isPlaying).toBe(true);
|
|
562
|
-
});
|
|
563
|
-
|
|
564
|
-
// Audio — playback state
|
|
565
|
-
test('audio can be played', async ({ page }) => {
|
|
566
|
-
await page.goto(`${BASE_URL}/<route>`);
|
|
567
|
-
const audio = page.locator('audio');
|
|
568
|
-
await expect(audio).toBeVisible();
|
|
569
|
-
await audio.evaluate((a: HTMLAudioElement) => { a.play(); });
|
|
570
|
-
const isPlaying = await audio.evaluate((a: HTMLAudioElement) => !a.paused);
|
|
571
|
-
expect(isPlaying).toBe(true);
|
|
572
|
-
});
|
|
573
|
-
```
|
|
574
|
-
|
|
575
|
-
See `.claude/skills/openspec-e2e/templates/test-plan.md` → **Special Element Test Cases** for full templates including Canvas, Video, Audio, Iframe, and Rich Text Editor.
|
|
500
|
+
**Test coverage — special elements**: Check `app-exploration.md` → **Special Elements Detected** table. For each special element, generate tests using templates from `.claude/skills/openspec-e2e/templates/test-plan.md` → **Special Element Test Cases**:
|
|
501
|
+
- Canvas: screenshot + boundingBox → dimensions > 0, or 2D pixel verification
|
|
502
|
+
- WebGL: screenshot only (no pixel comparison — rendering varies)
|
|
503
|
+
- Iframe: `frameLocator` + assert inner content visible
|
|
504
|
+
- Rich text: `contenteditable` → type + `textContent` assertion
|
|
505
|
+
- Video/Audio: `play()` → assert `!paused`
|
|
506
|
+
- CAPTCHA/OTP/File upload/Drag-drop: See AI-Opaque Elements section in templates
|
|
576
507
|
|
|
577
508
|
**Test coverage — AI-opaque elements**: For CAPTCHA, OTP, slider CAPTCHA, file upload, and drag-drop — elements that Playwright cannot reliably automate:
|
|
578
509
|
|
|
@@ -584,24 +515,7 @@ See `.claude/skills/openspec-e2e/templates/test-plan.md` → **Special Element T
|
|
|
584
515
|
- **Drag-drop**: Use `page.dragAndDrop()` or `page.evaluate()` with custom event dispatching
|
|
585
516
|
3. If the element is truly non-automatable, write `test.skip()` with a comment explaining why, and mark with `/handoff` for manual testing
|
|
586
517
|
|
|
587
|
-
**Test coverage — performance**:
|
|
588
|
-
|
|
589
|
-
```typescript
|
|
590
|
-
// Performance — Core Web Vitals
|
|
591
|
-
test('page loads within performance budget', async ({ page }) => {
|
|
592
|
-
await page.goto(`${BASE_URL}/<route>`);
|
|
593
|
-
await expect(page.getByRole('heading')).toBeVisible();
|
|
594
|
-
const timings = await page.evaluate(() => {
|
|
595
|
-
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
|
596
|
-
return {
|
|
597
|
-
ttfb: nav.responseStart - nav.requestStart,
|
|
598
|
-
lcp: nav.loadEventEnd - nav.requestStart,
|
|
599
|
-
};
|
|
600
|
-
});
|
|
601
|
-
expect(timings.ttfb).toBeLessThan(500);
|
|
602
|
-
expect(timings.lcp).toBeLessThan(2500);
|
|
603
|
-
});
|
|
604
|
-
```
|
|
518
|
+
**Test coverage — performance**: Generate a Core Web Vitals test **only if** the OpenSpec spec or app-exploration.md specifies explicit performance targets (e.g., "LCP must be under 2s"). If no business target is defined, skip performance testing — hard-coded thresholds (lcp < 2500ms) produce false passes and add noise.
|
|
605
519
|
|
|
606
520
|
```typescript
|
|
607
521
|
// 🚫 Avoid for special elements:
|
|
@@ -665,98 +579,14 @@ test('user can login', async ({ page }) => {
|
|
|
665
579
|
|
|
666
580
|
**If a shared page object doesn't exist yet**: define it inline in the spec AND write it to `tests/playwright/pages/<PageName>.ts` so future tests can reuse it.
|
|
667
581
|
|
|
668
|
-
#### 6.2. Selector
|
|
669
|
-
|
|
670
|
-
```typescript
|
|
671
|
-
// 🚫 Fragile — CSS class selectors break on style refactors
|
|
672
|
-
page.locator('.notification-bell')
|
|
673
|
-
page.locator('.header-bar')
|
|
674
|
-
page.locator('.skeleton-overlay')
|
|
675
|
-
|
|
676
|
-
// ✅ Robust — semantic selectors survive style changes
|
|
677
|
-
page.getByRole('button', { name: '通知' })
|
|
678
|
-
page.getByTestId('header-bar')
|
|
679
|
-
page.getByText('加载中')
|
|
680
|
-
|
|
681
|
-
// 🚫 Fragile — CSS ID selectors can duplicate in React HMR
|
|
682
|
-
page.locator('#avatarBtn')
|
|
683
|
-
page.locator('#userAvatarBtn')
|
|
684
|
-
|
|
685
|
-
// ✅ Robust — prefer role/label/testid over CSS ID
|
|
686
|
-
page.getByTestId('user-avatar')
|
|
687
|
-
page.getByRole('button', { name: '用户菜单' })
|
|
688
|
-
|
|
689
|
-
// 🚫 Missing wait — leads to random CI failures
|
|
690
|
-
await page.locator('.submit-btn').click();
|
|
691
|
-
|
|
692
|
-
// ✅ Safe — scroll into view first
|
|
693
|
-
await page.locator('.submit-btn').scrollIntoViewIfNeeded();
|
|
694
|
-
await page.locator('.submit-btn').click();
|
|
695
|
-
|
|
696
|
-
// ✅ Better — use BasePage click with built-in wait
|
|
697
|
-
const app = new AppPage(page);
|
|
698
|
-
await app.click(app.byRole('button', { name: '提交' }));
|
|
699
|
-
```
|
|
700
|
-
|
|
701
|
-
**Code examples — UI first:**
|
|
702
|
-
|
|
703
|
-
```typescript
|
|
704
|
-
// ✅ UI 测试 — fill 后必须验证值,确保框架同步完成
|
|
705
|
-
const app = new AppPage(page);
|
|
706
|
-
await app.goto(`${BASE_URL}/orders`);
|
|
707
|
-
await app.click(app.byRole('button', { name: '新建订单' }));
|
|
708
|
-
await app.fillAndVerify(app.byLabel('订单名称'), 'Test Order');
|
|
709
|
-
await app.click(app.byRole('button', { name: '提交' }));
|
|
710
|
-
await expect(page.getByText('订单创建成功')).toBeVisible();
|
|
711
|
-
|
|
712
|
-
// ✅ Error path
|
|
713
|
-
await page.goto(`${BASE_URL}/orders`);
|
|
714
|
-
await page.getByRole("button", { name: "提交" }).click();
|
|
715
|
-
await expect(page.getByRole("alert")).toContainText("名称不能为空");
|
|
716
|
-
|
|
717
|
-
// ✅ API fallback (only when UI cannot reach the scenario)
|
|
718
|
-
const res = await page.request.get(`${BASE_URL}/api/orders/99999`);
|
|
719
|
-
expect(res.status()).toBe(404);
|
|
720
|
-
|
|
721
|
-
// ✅ Auth guard — fresh browser context (no cookies)
|
|
722
|
-
test("redirects to login when unauthenticated", async ({ browser }) => {
|
|
723
|
-
const freshPage = await browser.newContext().newPage();
|
|
724
|
-
await freshPage.goto(`${BASE_URL}/dashboard`);
|
|
725
|
-
await expect(freshPage).toHaveURL(/login|auth/);
|
|
726
|
-
});
|
|
727
|
-
|
|
728
|
-
// ✅ Session — logout clears protected state
|
|
729
|
-
await page.getByRole("button", { name: "退出登录" }).click();
|
|
730
|
-
await expect(page).toHaveURL(/login|auth/);
|
|
731
|
-
const freshPage2 = await browser.newContext().newPage();
|
|
732
|
-
await freshPage2.goto(`${BASE_URL}/dashboard`);
|
|
733
|
-
await expect(freshPage2).toHaveURL(/login|auth/); // session revoked
|
|
734
|
-
|
|
735
|
-
// ✅ Browser history — SPA back/forward
|
|
736
|
-
await page.goto(`${BASE_URL}/list`);
|
|
737
|
-
await page.getByRole("link", { name: "详情" }).first().click();
|
|
738
|
-
await expect(page).toHaveURL(/detail/);
|
|
739
|
-
await page.goBack();
|
|
740
|
-
await expect(page).toHaveURL(/list/);
|
|
582
|
+
#### 6.2. Selector patterns
|
|
741
583
|
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
// Baselines stored in __snapshots__/ (auto-excluded from audit)
|
|
747
|
-
await page.goto(`${BASE_URL}/dashboard`);
|
|
748
|
-
await expect(page).toHaveScreenshot('dashboard-authenticated.png', { animations: 'disabled' });
|
|
749
|
-
|
|
750
|
-
// ✅ Form state snapshot — after submit
|
|
751
|
-
await page.goto(`${BASE_URL}/contact`);
|
|
752
|
-
await page.getByLabel('邮箱').fill('test@example.com');
|
|
753
|
-
await page.getByLabel('内容').fill('Hello');
|
|
754
|
-
await expect(page).toHaveScreenshot('contact-form-filled.png');
|
|
584
|
+
| Prefer (robust) | Avoid (fragile) |
|
|
585
|
+
| — | — |
|
|
586
|
+
| `getByRole`, `getByTestId`, `getByLabel` | CSS class (`'.notification-bell'`), CSS ID (`'#avatarBtn'`) |
|
|
587
|
+
| `waitForSelector(targetElement)` | hardcoded `200ms` / `500ms` delays |
|
|
755
588
|
|
|
756
|
-
|
|
757
|
-
const canvas = page.locator('canvas');
|
|
758
|
-
await expect(canvas).toHaveScreenshot('webgl-render.png');
|
|
759
|
-
```
|
|
589
|
+
See `.claude/skills/openspec-e2e/templates/e2e-test.ts` for full examples of Page Object pattern, UI-first flows, error paths, auth guards, session handling, and visual regression.
|
|
760
590
|
|
|
761
591
|
If the file exists → diff against test-plan, add only missing test cases.
|
|
762
592
|
|
|
@@ -832,22 +662,69 @@ If tests fail → use Playwright MCP tools to inspect UI, fix selectors, re-run.
|
|
|
832
662
|
|
|
833
663
|
**Healer — Phase 1: Triage**
|
|
834
664
|
|
|
835
|
-
When a test fails, classify before attempting repair
|
|
665
|
+
When a test fails, classify before attempting repair.
|
|
666
|
+
|
|
667
|
+
**Batch Failure Detection — run this FIRST when multiple tests fail:**
|
|
668
|
+
|
|
669
|
+
```
|
|
670
|
+
Collect ALL failing test names + their failure reasons.
|
|
671
|
+
Group by: same route + same action + same error pattern.
|
|
672
|
+
If ≥2 tests fall into the same group:
|
|
673
|
+
→ Pause individual healing
|
|
674
|
+
→ Navigate to that route + perform the action manually
|
|
675
|
+
→ Check browser_console_messages + browser_network_requests
|
|
676
|
+
→ If console error or 4xx/5xx present:
|
|
677
|
+
→ This is an App Bug (backend/API change), NOT Test Bugs
|
|
678
|
+
→ Classify all tests in this group as App Bug
|
|
679
|
+
→ Skip all → record 1 App Bug in registry (not N bugs)
|
|
680
|
+
→ Skip the rest of individual Triage for this group
|
|
681
|
+
→ If no console/network error but all still fail:
|
|
682
|
+
→ Likely a shared state issue → RAFT
|
|
683
|
+
→ Skip all → note RAFT in report
|
|
684
|
+
Proceed with individual Triage only for tests NOT in a batch failure group.
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
**After Batch Detection, individual Triage:**
|
|
836
688
|
|
|
837
689
|
| Failure Type | Signal | Classification | Action |
|
|
838
690
|
| — | — | — | — |
|
|
839
|
-
| **Network/Backend** | `net::ERR`, 4xx/5xx in console/network | **App Bug** | `test.skip()` +
|
|
840
|
-
| **JS Runtime Error** | Console error (non-network) | **App Bug** | `test.skip()` +
|
|
691
|
+
| **Network/Backend** | `net::ERR`, 4xx/5xx in console/network | **App Bug** | `test.skip()` + record in `app-bug-registry.md` |
|
|
692
|
+
| **JS Runtime Error** | Console error (non-network) | **App Bug** | `test.skip()` + record in `app-bug-registry.md` |
|
|
841
693
|
| **Auth Expired** | Redirected to login mid-test | **Flaky** | Re-run auth.setup → re-run |
|
|
694
|
+
| **Redirect Loop** | `ERR_TOO_MANY_REDIRECTS`, URL keeps changing on each snapshot | **App Bug** | Check auth first (see Step 4.2 loop detection). If auth is the cause → re-run auth. Otherwise → `test.skip()` + App Bug Registry |
|
|
695
|
+
| **Page Refresh Loop** | Excessive console errors (>10) after navigation, page unstable | **App Bug** | `test.skip()` + record in App Bug Registry |
|
|
842
696
|
| **Selector Not Found** | Element not found | **Test Bug** | → Phase 2 Healer |
|
|
843
697
|
| **Assertion Mismatch** | Wrong content/value | **Ambiguous** | → Phase 2 Healer |
|
|
844
698
|
| **Timeout** | waitFor/evaluate timeout | **Flaky** | Retry isolated: `openspec-pw run <name> --grep "<test-name>"` (1×, not counted in heal attempts). If it passes isolated but fails in suite → **RAFT**. If it consistently times out → check framework: React 19 / Next.js App Router: add `page.waitForLoadState('networkidle')`. Vue/Angular/React 18 / Plain JS / jQuery: use `waitForSelector(targetElement)` instead of timeout tuning. |
|
|
845
699
|
| **Same test fails in suite, passes isolated** | — | **RAFT** | `test.skip()` in suite, note RAFT in report |
|
|
846
700
|
|
|
847
|
-
- **App Bug** → skip immediately (no healing needed)
|
|
701
|
+
- **App Bug** → skip immediately (no healing needed) → record in App Bug Registry
|
|
848
702
|
- **Flaky** → retry once isolated
|
|
849
703
|
- **Test Bug / Ambiguous** → Phase 2
|
|
850
704
|
|
|
705
|
+
#### App Bug Registry
|
|
706
|
+
|
|
707
|
+
For every App Bug classified in Phase 1, record it in `openspec/reports/app-bug-registry.md` (create if missing):
|
|
708
|
+
|
|
709
|
+
```markdown
|
|
710
|
+
# App Bug Registry
|
|
711
|
+
|
|
712
|
+
<!-- Auto-generated. Do not edit manually. -->
|
|
713
|
+
|
|
714
|
+
## Active App Bugs
|
|
715
|
+
|
|
716
|
+
| # | Test | Route | Signal | First Detected | Status |
|
|
717
|
+
|---|------|-------|--------|---------------|--------|
|
|
718
|
+
| 1 | test-name | /route | net::ERR_CONNECTION_REFUSED | 2026-04-09 | open |
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
**Update rules**:
|
|
722
|
+
- **New App Bug**: Append new row, increment `#`
|
|
723
|
+
- **App Bug re-run and now passes**: Keep row, change status to `resolved` + add `Resolved` column with date
|
|
724
|
+
- **Keep all rows** (never delete) — the resolved count is the signal that bugs are being fixed
|
|
725
|
+
|
|
726
|
+
> **Why this matters**: `test.skip()` hides App Bugs from the pass/fail count. Without an explicit registry, "all tests passed" is a false positive when App Bugs exist. The registry makes the invisible visible.
|
|
727
|
+
|
|
851
728
|
> **Global attempt guard**: Each test has an independent heal counter (max 3 per test). If the same test enters Phase 2 more than once and reaches the cap each time → treat as "consecutive escalation without progress" → Phase 3 immediately.
|
|
852
729
|
|
|
853
730
|
> **Type ≠ Blame**: "Test Bug" means the assertion or selector is wrong — it does NOT mean "blame the test author." The test was generated from the spec. Root cause may be spec ambiguity, spec→test generation error, or app→spec deviation. Only a human can determine blame.
|
|
@@ -865,13 +742,38 @@ After Triage classifies failure as "Test Bug" or "Ambiguous":
|
|
|
865
742
|
MATCH: <yes/no>
|
|
866
743
|
```
|
|
867
744
|
4. If MATCH=no:
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
745
|
+
|
|
746
|
+
**⚠️ Assertion modification guard — never skip Phase 3 unless ALL conditions are met:**
|
|
747
|
+
|
|
748
|
+
- The test has **never passed with the current assertion** (newly generated test, or this is the first time this assertion fails)
|
|
749
|
+
- The ACTUAL value is **verifiably** from a different spec section (e.g., this test was in the wrong describe block)
|
|
750
|
+
- You can point to the **specific line in the spec** that defines the ACTUAL behavior
|
|
751
|
+
|
|
752
|
+
**If ANY condition is uncertain → Phase 3 immediately.** Do NOT modify the assertion.
|
|
753
|
+
|
|
754
|
+
**Safe to fix without Phase 3:**
|
|
755
|
+
- Typo in assertion (e.g., "Submmit" vs "Submit" in the expected text)
|
|
756
|
+
- Selector was correct but the element was moved to a different location (same text, different selector)
|
|
757
|
+
- Explicit spec drift confirmed by reading the spec (e.g., spec says "button says Submit" but test says "button says Submit Form")
|
|
758
|
+
|
|
759
|
+
**Never fix without Phase 3:**
|
|
760
|
+
- App behavior changed after an action (e.g., "after clicking submit, balance should decrease" → ACTUAL shows no change → **Phase 3**, could be optimistic update bug, backend failure, or spec mismatch)
|
|
761
|
+
- Data values differ (e.g., expected "¥1000" but got "¥999" → **Phase 3**, could be rounding, discount, or calculation bug)
|
|
762
|
+
- Missing elements after interaction (e.g., "after creating order, success message should appear" → no message → **Phase 3**)
|
|
763
|
+
|
|
871
764
|
5. If selector issue → find equivalent stable selector from snapshot
|
|
872
765
|
6. Apply fix → re-run **only that test** (attempt 1/3)
|
|
873
766
|
7. If healed → append to `app-knowledge.md` → **Selector Fixes** table (route, old → new selector, reason)
|
|
874
767
|
|
|
768
|
+
**Element Missing handling (when browser_snapshot shows element not found):**
|
|
769
|
+
|
|
770
|
+
| Situation | Check | Action |
|
|
771
|
+
| — | — | — |
|
|
772
|
+
| JS error in console after action | `browser_console_messages` | **App Bug** → Phase 1 → App Bug classification |
|
|
773
|
+
| Auth redirected mid-action | URL changed to `/login` | **Flaky** → re-run with fresh auth |
|
|
774
|
+
| SPA route didn't update | URL is correct but element missing | Wait for SPA hydration → `page.waitForLoadState('networkidle')` or `waitForSelector(target)` |
|
|
775
|
+
| Element genuinely missing | None of the above | **Test Bug** → find alternative selector or **Phase 3** if no equivalent exists |
|
|
776
|
+
|
|
875
777
|
**Healer — Phase 3: Escalate**
|
|
876
778
|
|
|
877
779
|
When Phase 2 tried ≥3 heals without success, OR ASSERTION vs ACTUAL comparison is ambiguous:
|
|
@@ -917,7 +819,7 @@ openspec-pw run <change-name>
|
|
|
917
819
|
```
|
|
918
820
|
`/opsx:e2e <change-name>` re-runs the full 11-step workflow — unnecessary after Phase 3. The test file and auth context are already correct. Use `openspec-pw run` to verify fixes directly.
|
|
919
821
|
|
|
920
|
-
### 10. False Pass Detection + RAFT Detection
|
|
822
|
+
### 10. False Pass Detection + RAFT Detection + App Bug Accumulation
|
|
921
823
|
|
|
922
824
|
Run after test suite completes (even if all pass).
|
|
923
825
|
|
|
@@ -933,11 +835,23 @@ Run after test suite completes (even if all pass).
|
|
|
933
835
|
- This is **NOT** a test bug or app bug. Mark as RAFT, add `test.skip()` in suite, note in report
|
|
934
836
|
- RAFTs are infrastructure coupling issues (CPU/memory/I/O contention), not fixable by changing test or app
|
|
935
837
|
|
|
838
|
+
**App Bug Accumulation Detection** (most critical):
|
|
839
|
+
|
|
840
|
+
1. Read `openspec/reports/app-bug-registry.md`
|
|
841
|
+
2. Count `open` status rows
|
|
842
|
+
3. If ≥ 3 active App Bugs → add "⚠️ App Bug accumulation: N bugs unresolved" to report summary. **Do NOT suppress or hide this warning.** The test suite may report "all passed" (skipped ≠ failed), but N broken features is not a passing system.
|
|
843
|
+
4. Check for **App Bugs that became "resolved"** (passing on re-run) → this is the signal that bugs were fixed. Update `app-bug-registry.md` accordingly, and remove `test.skip()` from those tests so they re-enter the regression suite.
|
|
844
|
+
|
|
936
845
|
### 11. Report results
|
|
937
846
|
|
|
938
847
|
Read report at `openspec/reports/playwright-e2e-<name>-<timestamp>.md`. Present:
|
|
939
848
|
|
|
940
|
-
- Summary table with failure type breakdown (App Bugs, Test Bugs/healed, Flaky-RAFT, Human Escalations)
|
|
849
|
+
- **Summary table** with failure type breakdown (App Bugs, Test Bugs/healed, Flaky-RAFT, Human Escalations)
|
|
850
|
+
- **App Bug Summary**: Table of all active App Bugs from `app-bug-registry.md` — test name, route, signal, first detected. If ≥ 3 active → display "⚠️ App Bug accumulation warning" prominently.
|
|
851
|
+
- **Conditional "All Pass" conclusion**:
|
|
852
|
+
- ✅ **"All tests passed"** — only if 0 active App Bugs and 0 skipped tests
|
|
853
|
+
- ⚠️ **"All tests passed (N skipped)"** — if skipped tests exist but no active App Bugs
|
|
854
|
+
- ⚠️ **"All tests passed (N skipped, M App Bugs unresolved)"** — if active App Bugs exist
|
|
941
855
|
- Failure Classification table (test, type, action, healed?)
|
|
942
856
|
- Auto-heal log (assertion vs actual comparison, fix applied, result)
|
|
943
857
|
- RAFT Summary (if any detected)
|
|
@@ -946,11 +860,9 @@ Read report at `openspec/reports/playwright-e2e-<name>-<timestamp>.md`. Present:
|
|
|
946
860
|
|
|
947
861
|
Report template: `.claude/skills/openspec-e2e/templates/report.md`
|
|
948
862
|
|
|
949
|
-
**Update tasks.md
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
Reference: `.claude/skills/openspec-e2e/templates/report.md`
|
|
863
|
+
**Update tasks.md**:
|
|
864
|
+
- If 0 active App Bugs → find E2E-related items, append `✅ Verified via Playwright E2E (<timestamp>)`.
|
|
865
|
+
- If active App Bugs exist → **do not mark as verified**. Append instead: `⚠️ App Bug blocked: <bug summary> (<timestamp>)`.
|
|
954
866
|
|
|
955
867
|
## Graceful Degradation
|
|
956
868
|
|
|
@@ -960,13 +872,14 @@ Reference: `.claude/skills/openspec-e2e/templates/report.md`
|
|
|
960
872
|
| ------- | ------- |
|
|
961
873
|
| No specs / app-exploration.md missing (change mode) | **STOP** |
|
|
962
874
|
| JS errors or HTTP 5xx during exploration | **STOP** → user fixes app → re-run `/opsx:e2e <name>` to re-explore from Step 4 |
|
|
963
|
-
|
|
|
875
|
+
| Redirect loop / page refresh loop during exploration | **App Bug** — **STOP** → check auth.setup.ts first (common cause). If auth is valid → app middleware/cookie bug → mark route skipped, record in App Bug Registry. Re-run exploration after fix. |
|
|
964
876
|
| File already exists (app-exploration, test-plan, app-all.spec.ts, Page Objects) | Read and use — never regenerate |
|
|
965
|
-
| Test fails (network/backend) | **App Bug** — `test.skip()` +
|
|
877
|
+
| Test fails (network/backend) | **App Bug** — `test.skip()` + record in `app-bug-registry.md` |
|
|
966
878
|
| Test fails (selector/assertion) | **Test Bug/Ambiguous** — Healer Phase 1→2 (≤3 attempts) |
|
|
967
879
|
| RAFT detected (suite fail, isolated pass) | **Flaky** — `test.skip()` in suite, note RAFT in report |
|
|
968
880
|
| Phase 3 escalation | **Human needed** — STOP + ask user |
|
|
969
881
|
| False pass detected | Add "⚠️ Coverage Gap" to report |
|
|
882
|
+
| App Bug skip accumulation (≥3 active App Bugs) | **Warning** — add "⚠️ App Bug accumulation: N bugs unresolved" to report summary. Do not suppress. |
|
|
970
883
|
|
|
971
884
|
## Guardrails
|
|
972
885
|
|
|
@@ -985,6 +898,6 @@ Reference: `.claude/skills/openspec-e2e/templates/report.md`
|
|
|
985
898
|
|
|
986
899
|
> `tests/playwright/` — spec files, Page Objects, auth, credentials, app-knowledge.md
|
|
987
900
|
> `openspec/changes/<name>/specs/playwright/` — app-exploration.md, test-plan.md (change mode)
|
|
988
|
-
> `openspec/reports/` — test reports
|
|
901
|
+
> `openspec/reports/` — test reports, app-bug-registry.md
|
|
989
902
|
|
|
990
903
|
**Never write to:** any other directory
|