openspec-playwright 0.2.6 → 0.2.7

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.
@@ -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.13"
8
+ version: "2.19"
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
@@ -231,76 +272,17 @@ From `browser_snapshot` + `browser_evaluate`, identify these special elements pe
231
272
  | File upload | `<input type="file">` | `accept` attribute, `multiple` flag | Medium |
232
273
  | Drag-and-drop | drag events in JS | Simulate DnD via coordinate clicks | Low |
233
274
  | 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
- ```javascript
240
- // Canvas get metadata (check WebGL first to avoid consuming 2D context)
241
- const canvasData = await browser_evaluate(() => {
242
- const c = document.querySelector('canvas');
243
- if (!c) return null;
244
- // getContext consumes the context check WebGL2 first, then WebGL1, then 2D
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
- ```
275
+ | Infinite scroll | Dynamic row insertion | Count elements before/after scroll | Low (skip unless specs mention dynamic lists/pagination) |
276
+ | WebSocket / SSE | No DOM signal | Check `browser_console_messages` for WS events | Low (check only if app uses real-time features) |
277
+
278
+ **For each detected special element, capture via `browser_evaluate` with targeted DOM queries:**
279
+ - Canvas: `getContext('webgl2'/'webgl'/'2d')`, `width`, `height`
280
+ - Iframe: `src` attribute → use `frameLocator` in tests
281
+ - CAPTCHA: `.g-recaptcha`, `.h-captcha`, `[data-sitekey]`, canvas+slider detection
282
+ - OTP: `input` elements with `maxLength === 1` or `type === 'tel'`
283
+ - Rich text: `[contenteditable]` → `innerHTML`, `textContent.length`
284
+ - Video/Audio: `querySelector('video'/'audio')` `paused`, `duration`
285
+ - Shadow DOM: `role="generic"` with no children check `shadowRoot`
304
286
 
305
287
  Record findings in `app-exploration.md` → **Special Elements Detected** table.
306
288
 
@@ -377,7 +359,7 @@ Reply **yes** to proceed, or tell me to exclude routes or adjust strategies.
377
359
 
378
360
  Template: `.claude/skills/openspec-e2e/templates/test-plan.md`
379
361
 
380
- **Idempotency**: If test-plan.md exists → read and use, do NOT regenerate.
362
+ **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
363
 
382
364
  **⚠️ Human verification — STOP before generating code.**
383
365
 
@@ -408,15 +390,9 @@ After creating (or reading existing) test-plan.md, **stop and display the test p
408
390
  - `<element or scenario not testable>`
409
391
  ````
410
392
 
411
- Then ask: "Does this coverage match your intent? Reply **yes** to proceed, or tell me what to add/change."
412
-
413
- **Why this matters**: Step 5 is the last human-reviewable checkpoint before code generation. Once test code is written, fixes address *how* tests run, not *what* they verify. Reviewing the test plan takes seconds and catches logic errors that Healer cannot fix.
393
+ **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.
414
394
 
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
395
+ Then ask: "Does this coverage match your intent? Reply **yes** to proceed, or tell me what to add/change."
420
396
 
421
397
  If the user requests changes → update test-plan.md → re-display summary → re-confirm → proceed.
422
398
 
@@ -424,7 +400,7 @@ If the user requests changes → update test-plan.md → re-display summary →
424
400
 
425
401
  **"all" mode**: Build and expand Page Objects for future Change tests.
426
402
 
427
- **Prerequisite**: If `app-exploration.md` does not exist → **STOP**. Run Step 4 first. All mode explores routes via browser MCP to build exploration data.
403
+ **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
404
 
429
405
  **Page Object pattern** — read before writing any page file:
430
406
 
@@ -466,7 +442,7 @@ For each discovered route:
466
442
  2. Navigate to route with correct auth state
467
443
  3. browser_snapshot to extract interactive elements (see 4.3 table)
468
444
  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 (route loads without crash)
445
+ 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
446
 
471
447
  **Output priority**: Page Objects (`pages/*.ts`) are the primary asset. Smoke test is secondary. Existing Page Objects are never overwritten — only extended.
472
448
 
@@ -495,84 +471,13 @@ Is this assertion about a visible UI result?
495
471
 
496
472
  **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
473
 
498
- **Test coverage — special elements**: Check `app-exploration.md` → **Special Elements Detected** table. For each special element:
499
-
500
- ```typescript
501
- // Canvas screenshot + dimensions
502
- test('canvas renders with correct dimensions', async ({ page }) => {
503
- await page.goto(`${BASE_URL}/<route>`);
504
- const canvas = page.locator('canvas');
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.
474
+ **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**:
475
+ - Canvas: screenshot + boundingBox → dimensions > 0, or 2D pixel verification
476
+ - WebGL: screenshot only (no pixel comparison — rendering varies)
477
+ - Iframe: `frameLocator` + assert inner content visible
478
+ - Rich text: `contenteditable` type + `textContent` assertion
479
+ - Video/Audio: `play()` → assert `!paused`
480
+ - CAPTCHA/OTP/File upload/Drag-drop: See AI-Opaque Elements section in templates
576
481
 
577
482
  **Test coverage — AI-opaque elements**: For CAPTCHA, OTP, slider CAPTCHA, file upload, and drag-drop — elements that Playwright cannot reliably automate:
578
483
 
@@ -584,24 +489,7 @@ See `.claude/skills/openspec-e2e/templates/test-plan.md` → **Special Element T
584
489
  - **Drag-drop**: Use `page.dragAndDrop()` or `page.evaluate()` with custom event dispatching
585
490
  3. If the element is truly non-automatable, write `test.skip()` with a comment explaining why, and mark with `/handoff` for manual testing
586
491
 
587
- **Test coverage — performance**: Verify Core Web Vitals metrics. If the app specifies performance targets, generate a test:
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
- ```
492
+ **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
493
 
606
494
  ```typescript
607
495
  // 🚫 Avoid for special elements:
@@ -665,98 +553,14 @@ test('user can login', async ({ page }) => {
665
553
 
666
554
  **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
555
 
668
- #### 6.2. Selector anti-patterns
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:**
556
+ #### 6.2. Selector patterns
702
557
 
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
- });
558
+ | Prefer (robust) | Avoid (fragile) |
559
+ | ||
560
+ | `getByRole`, `getByTestId`, `getByLabel` | CSS class (`'.notification-bell'`), CSS ID (`'#avatarBtn'`) |
561
+ | `waitForSelector(targetElement)` | hardcoded `200ms` / `500ms` delays |
727
562
 
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/);
741
-
742
- // ✅ File uploads
743
- await page.locator('input[type="file"]').setInputFiles("/path/to/file.pdf");
744
-
745
- // ✅ Visual regression — toHaveScreenshot() for key pages and state transitions
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');
755
-
756
- // ✅ Canvas/WebGL pixel verification
757
- const canvas = page.locator('canvas');
758
- await expect(canvas).toHaveScreenshot('webgl-render.png');
759
- ```
563
+ 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
564
 
761
565
  If the file exists → diff against test-plan, add only missing test cases.
762
566
 
@@ -832,22 +636,67 @@ If tests fail → use Playwright MCP tools to inspect UI, fix selectors, re-run.
832
636
 
833
637
  **Healer — Phase 1: Triage**
834
638
 
835
- When a test fails, classify before attempting repair:
639
+ When a test fails, classify before attempting repair.
640
+
641
+ **Batch Failure Detection — run this FIRST when multiple tests fail:**
642
+
643
+ ```
644
+ Collect ALL failing test names + their failure reasons.
645
+ Group by: same route + same action + same error pattern.
646
+ If ≥2 tests fall into the same group:
647
+ → Pause individual healing
648
+ → Navigate to that route + perform the action manually
649
+ → Check browser_console_messages + browser_network_requests
650
+ → If console error or 4xx/5xx present:
651
+ → This is an App Bug (backend/API change), NOT Test Bugs
652
+ → Classify all tests in this group as App Bug
653
+ → Skip all → record 1 App Bug in registry (not N bugs)
654
+ → Skip the rest of individual Triage for this group
655
+ → If no console/network error but all still fail:
656
+ → Likely a shared state issue → RAFT
657
+ → Skip all → note RAFT in report
658
+ Proceed with individual Triage only for tests NOT in a batch failure group.
659
+ ```
660
+
661
+ **After Batch Detection, individual Triage:**
836
662
 
837
663
  | Failure Type | Signal | Classification | Action |
838
664
  | — | — | — | — |
839
- | **Network/Backend** | `net::ERR`, 4xx/5xx in console/network | **App Bug** | `test.skip()` + report as app bug |
840
- | **JS Runtime Error** | Console error (non-network) | **App Bug** | `test.skip()` + report as app bug |
665
+ | **Network/Backend** | `net::ERR`, 4xx/5xx in console/network | **App Bug** | `test.skip()` + record in `app-bug-registry.md` |
666
+ | **JS Runtime Error** | Console error (non-network) | **App Bug** | `test.skip()` + record in `app-bug-registry.md` |
841
667
  | **Auth Expired** | Redirected to login mid-test | **Flaky** | Re-run auth.setup → re-run |
842
668
  | **Selector Not Found** | Element not found | **Test Bug** | → Phase 2 Healer |
843
669
  | **Assertion Mismatch** | Wrong content/value | **Ambiguous** | → Phase 2 Healer |
844
670
  | **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
671
  | **Same test fails in suite, passes isolated** | — | **RAFT** | `test.skip()` in suite, note RAFT in report |
846
672
 
847
- - **App Bug** → skip immediately (no healing needed)
673
+ - **App Bug** → skip immediately (no healing needed) → record in App Bug Registry
848
674
  - **Flaky** → retry once isolated
849
675
  - **Test Bug / Ambiguous** → Phase 2
850
676
 
677
+ #### App Bug Registry
678
+
679
+ For every App Bug classified in Phase 1, record it in `openspec/reports/app-bug-registry.md` (create if missing):
680
+
681
+ ```markdown
682
+ # App Bug Registry
683
+
684
+ <!-- Auto-generated. Do not edit manually. -->
685
+
686
+ ## Active App Bugs
687
+
688
+ | # | Test | Route | Signal | First Detected | Status |
689
+ |---|------|-------|--------|---------------|--------|
690
+ | 1 | test-name | /route | net::ERR_CONNECTION_REFUSED | 2026-04-09 | open |
691
+ ```
692
+
693
+ **Update rules**:
694
+ - **New App Bug**: Append new row, increment `#`
695
+ - **App Bug re-run and now passes**: Keep row, change status to `resolved` + add `Resolved` column with date
696
+ - **Keep all rows** (never delete) — the resolved count is the signal that bugs are being fixed
697
+
698
+ > **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.
699
+
851
700
  > **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
701
 
853
702
  > **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 +714,38 @@ After Triage classifies failure as "Test Bug" or "Ambiguous":
865
714
  MATCH: <yes/no>
866
715
  ```
867
716
  4. If MATCH=no:
868
- - Is `ACTUAL` reasonable per the test's intended spec behavior?
869
- - If yes fix the assertion to match ACTUAL (app behavior is correct)
870
- - If uncertain → **Phase 3**
717
+
718
+ **⚠️ Assertion modification guard never skip Phase 3 unless ALL conditions are met:**
719
+
720
+ - The test has **never passed with the current assertion** (newly generated test, or this is the first time this assertion fails)
721
+ - The ACTUAL value is **verifiably** from a different spec section (e.g., this test was in the wrong describe block)
722
+ - You can point to the **specific line in the spec** that defines the ACTUAL behavior
723
+
724
+ **If ANY condition is uncertain → Phase 3 immediately.** Do NOT modify the assertion.
725
+
726
+ **Safe to fix without Phase 3:**
727
+ - Typo in assertion (e.g., "Submmit" vs "Submit" in the expected text)
728
+ - Selector was correct but the element was moved to a different location (same text, different selector)
729
+ - Explicit spec drift confirmed by reading the spec (e.g., spec says "button says Submit" but test says "button says Submit Form")
730
+
731
+ **Never fix without Phase 3:**
732
+ - 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)
733
+ - Data values differ (e.g., expected "¥1000" but got "¥999" → **Phase 3**, could be rounding, discount, or calculation bug)
734
+ - Missing elements after interaction (e.g., "after creating order, success message should appear" → no message → **Phase 3**)
735
+
871
736
  5. If selector issue → find equivalent stable selector from snapshot
872
737
  6. Apply fix → re-run **only that test** (attempt 1/3)
873
738
  7. If healed → append to `app-knowledge.md` → **Selector Fixes** table (route, old → new selector, reason)
874
739
 
740
+ **Element Missing handling (when browser_snapshot shows element not found):**
741
+
742
+ | Situation | Check | Action |
743
+ | — | — | — |
744
+ | JS error in console after action | `browser_console_messages` | **App Bug** → Phase 1 → App Bug classification |
745
+ | Auth redirected mid-action | URL changed to `/login` | **Flaky** → re-run with fresh auth |
746
+ | SPA route didn't update | URL is correct but element missing | Wait for SPA hydration → `page.waitForLoadState('networkidle')` or `waitForSelector(target)` |
747
+ | Element genuinely missing | None of the above | **Test Bug** → find alternative selector or **Phase 3** if no equivalent exists |
748
+
875
749
  **Healer — Phase 3: Escalate**
876
750
 
877
751
  When Phase 2 tried ≥3 heals without success, OR ASSERTION vs ACTUAL comparison is ambiguous:
@@ -917,7 +791,7 @@ openspec-pw run <change-name>
917
791
  ```
918
792
  `/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
793
 
920
- ### 10. False Pass Detection + RAFT Detection
794
+ ### 10. False Pass Detection + RAFT Detection + App Bug Accumulation
921
795
 
922
796
  Run after test suite completes (even if all pass).
923
797
 
@@ -933,11 +807,23 @@ Run after test suite completes (even if all pass).
933
807
  - This is **NOT** a test bug or app bug. Mark as RAFT, add `test.skip()` in suite, note in report
934
808
  - RAFTs are infrastructure coupling issues (CPU/memory/I/O contention), not fixable by changing test or app
935
809
 
810
+ **App Bug Accumulation Detection** (most critical):
811
+
812
+ 1. Read `openspec/reports/app-bug-registry.md`
813
+ 2. Count `open` status rows
814
+ 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.
815
+ 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.
816
+
936
817
  ### 11. Report results
937
818
 
938
819
  Read report at `openspec/reports/playwright-e2e-<name>-<timestamp>.md`. Present:
939
820
 
940
- - Summary table with failure type breakdown (App Bugs, Test Bugs/healed, Flaky-RAFT, Human Escalations)
821
+ - **Summary table** with failure type breakdown (App Bugs, Test Bugs/healed, Flaky-RAFT, Human Escalations)
822
+ - **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.
823
+ - **Conditional "All Pass" conclusion**:
824
+ - ✅ **"All tests passed"** — only if 0 active App Bugs and 0 skipped tests
825
+ - ⚠️ **"All tests passed (N skipped)"** — if skipped tests exist but no active App Bugs
826
+ - ⚠️ **"All tests passed (N skipped, M App Bugs unresolved)"** — if active App Bugs exist
941
827
  - Failure Classification table (test, type, action, healed?)
942
828
  - Auto-heal log (assertion vs actual comparison, fix applied, result)
943
829
  - RAFT Summary (if any detected)
@@ -946,11 +832,9 @@ Read report at `openspec/reports/playwright-e2e-<name>-<timestamp>.md`. Present:
946
832
 
947
833
  Report template: `.claude/skills/openspec-e2e/templates/report.md`
948
834
 
949
- **Update tasks.md** if all tests pass: find E2E-related items, append `✅ Verified via Playwright E2E (<timestamp>)`.
950
-
951
- ## Report Structure
952
-
953
- Reference: `.claude/skills/openspec-e2e/templates/report.md`
835
+ **Update tasks.md**:
836
+ - If 0 active App Bugs → find E2E-related items, append `✅ Verified via Playwright E2E (<timestamp>)`.
837
+ - If active App Bugs exist → **do not mark as verified**. Append instead: `⚠️ App Bug blocked: <bug summary> (<timestamp>)`.
954
838
 
955
839
  ## Graceful Degradation
956
840
 
@@ -962,11 +846,12 @@ Reference: `.claude/skills/openspec-e2e/templates/report.md`
962
846
  | JS errors or HTTP 5xx during exploration | **STOP** → user fixes app → re-run `/opsx:e2e <name>` to re-explore from Step 4 |
963
847
  | Sitemap fails ("all" mode) | Continue with homepage links fallback |
964
848
  | 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()` + report |
849
+ | Test fails (network/backend) | **App Bug** — `test.skip()` + record in `app-bug-registry.md` |
966
850
  | Test fails (selector/assertion) | **Test Bug/Ambiguous** — Healer Phase 1→2 (≤3 attempts) |
967
851
  | RAFT detected (suite fail, isolated pass) | **Flaky** — `test.skip()` in suite, note RAFT in report |
968
852
  | Phase 3 escalation | **Human needed** — STOP + ask user |
969
853
  | False pass detected | Add "⚠️ Coverage Gap" to report |
854
+ | App Bug skip accumulation (≥3 active App Bugs) | **Warning** — add "⚠️ App Bug accumulation: N bugs unresolved" to report summary. Do not suppress. |
970
855
 
971
856
  ## Guardrails
972
857
 
@@ -985,6 +870,6 @@ Reference: `.claude/skills/openspec-e2e/templates/report.md`
985
870
 
986
871
  > `tests/playwright/` — spec files, Page Objects, auth, credentials, app-knowledge.md
987
872
  > `openspec/changes/<name>/specs/playwright/` — app-exploration.md, test-plan.md (change mode)
988
- > `openspec/reports/` — test reports
873
+ > `openspec/reports/` — test reports, app-bug-registry.md
989
874
 
990
875
  **Never write to:** any other directory
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openspec-playwright",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "OpenSpec + Playwright E2E verification setup tool for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {