From 91b4475ea191000893065078d1c9dcfa5abf09ce Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 17 May 2026 16:35:27 +0200 Subject: [PATCH] =?UTF-8?q?test(01-09):=20RED=20=E2=80=94=20Bug=20B=20rout?= =?UTF-8?q?e=20user-stopped-sharing=20=E2=86=92=20IDLE;=20other=20codes=20?= =?UTF-8?q?=E2=86=92=20ERROR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Tests E + F to tests/background/badge-state-machine.test.ts pinning the conditional-routing contract for RECORDING_ERROR onMessage: E (RED today): RECORDING_ERROR{error:'user-stopped-sharing'} must route through setIdleMode — badge OFF (text '', red #D32F2F), popup ''. The current handler routes ALL codes through setErrorMode, locking the operator out of restart (popup wins toolbar.onClicked forever). F (GREEN today, preserved after fix): RECORDING_ERROR with any other error code (representative: 'codec-unsupported') continues to route through setErrorMode — badge ERR + yellow #F9A825 + popup html. This is the defensive-fallback regression pin guarding against the patch over-rotating to IDLE for all codes. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/background/badge-state-machine.test.ts | 157 +++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/tests/background/badge-state-machine.test.ts b/tests/background/badge-state-machine.test.ts index 688a160..31698e2 100644 --- a/tests/background/badge-state-machine.test.ts +++ b/tests/background/badge-state-machine.test.ts @@ -7,6 +7,15 @@ // // Plus Test D: receiving RECORDING_ERROR via onMessage triggers // setBadgeState('ERROR') (asserted via setBadgeText spy receiving 'ERR'). +// +// Bug B fix (debug session 01-09-recovery-flow) — Tests E + F: +// `RECORDING_ERROR{error:'user-stopped-sharing'}` must route through +// setIdleMode (badge OFF, popup empties) instead of setErrorMode, because +// the operator-initiated stop is NOT an error — leaving popup in SAVE-only +// + badge in ERROR locks the operator out of restart (chrome.action.onClicked +// is gated on popup absence; SAVE-only popup wins the click forever). +// Other error codes preserve setErrorMode routing (defensive fallback for +// genuine capture failures: codec-unsupported, wrong-display-surface, etc.). import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -116,6 +125,27 @@ function badgeTextCallsFor(stub: BgChromeStub, text: string): unknown[][] { ); } +// Helper: filter setBadgeBackgroundColor calls by hex color (case-insensitive). +function badgeColorCallsFor(stub: BgChromeStub, hexPattern: RegExp): unknown[][] { + return stub.action.setBadgeBackgroundColor.mock.calls.filter( + (args: unknown[]) => { + const opts = args[0] as { color?: unknown }; + const color = typeof opts === 'object' && opts !== null ? opts.color : undefined; + return typeof color === 'string' && hexPattern.test(color); + }, + ); +} + +// Helper: filter setPopup calls by popup-arg value. +function setPopupCallsFor(stub: BgChromeStub, popupArg: string): unknown[][] { + return stub.action.setPopup.mock.calls.filter( + (args: unknown[]) => { + const opts = args[0] as { popup?: unknown }; + return typeof opts === 'object' && opts !== null && opts.popup === popupArg; + }, + ); +} + describe('Plan 01-09 Task 3: badge state machine REC/OFF/ERROR contract', () => { beforeEach(() => { vi.resetModules(); @@ -272,4 +302,131 @@ describe('Plan 01-09 Task 3: badge state machine REC/OFF/ERROR contract', () => const errCallsAfter = badgeTextCallsFor(stub, 'ERR').length; expect(errCallsAfter - errCallsBefore).toBeGreaterThanOrEqual(1); }); + + // ────────────────────────────────────────────────────────────────────── + // Test E — Bug B (debug 01-09-recovery-flow) — operator-initiated + // stop-sharing routes to IDLE, NOT to ERROR. + // + // Contract: when the operator clicks Chrome's "Stop sharing" banner + // mid-recording, the offscreen recorder broadcasts + // RECORDING_ERROR{ error: 'user-stopped-sharing' } + // (src/offscreen/recorder.ts: onUserStoppedSharing emits this exact + // payload after resetBuffer + track release). + // + // The SW MUST route this through setIdleMode (badge OFF + popup '') + // rather than setErrorMode, because: + // 1. operator-initiated stop is not an error condition; + // 2. setErrorMode leaves the popup pointing at src/popup/index.html + // (SAVE-only), so toolbar.onClicked CANNOT re-fire — the popup + // wins the click and the operator has no restart path; + // 3. resetBuffer has already cleared the offscreen buffer, so SAVE + // mode is meaningless (nothing to save). + // + // Assertions on the post-dispatch state machine: + // - setBadgeText was called with '' (OFF text), NOT with 'ERR' (after + // the snapshot baseline at init). + // - setBadgeBackgroundColor was called with the OFF/red palette + // (#D32F2F), NOT with the ERROR/yellow palette (#F9A825). + // - setPopup was called with '' (idle popup) so the next toolbar + // click fires chrome.action.onClicked (the restart path). + // + // RED today because src/background/index.ts:725 routes ALL error codes + // through setErrorMode unconditionally. + // ────────────────────────────────────────────────────────────────────── + it('E: RECORDING_ERROR{error:"user-stopped-sharing"} routes to IDLE — badge OFF, popup empties', async () => { + const stub = buildBgStub(); + (globalThis as unknown as GlobalWithBgChrome).chrome = stub; + (globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn() }; + + await import('../../src/background/index'); + for (let i = 0; i < 8; i++) await Promise.resolve(); + + // Snapshot baseline AFTER init (which paints OFF). Bug B assertions + // are about the DELTA introduced by the user-stopped-sharing dispatch. + const offTextBefore = badgeTextCallsFor(stub, '').length; + const errTextBefore = badgeTextCallsFor(stub, 'ERR').length; + const redColorBefore = badgeColorCallsFor(stub, /^#D32F2F$/i).length; + const yellowColorBefore = badgeColorCallsFor(stub, /^#F9A825$/i).length; + const popupIdleBefore = setPopupCallsFor(stub, '').length; + const popupHtmlBefore = setPopupCallsFor(stub, 'src/popup/index.html').length; + + // Dispatch the operator-stopped-sharing payload that the offscreen + // recorder emits via onUserStoppedSharing. + const onMsgHandler = stub.runtime.onMessage._callbacks[0]; + onMsgHandler( + { type: 'RECORDING_ERROR', error: 'user-stopped-sharing' }, + { id: 'ext-id-test' }, + vi.fn(), + ); + for (let i = 0; i < 16; i++) await Promise.resolve(); + + const offTextAfter = badgeTextCallsFor(stub, '').length; + const errTextAfter = badgeTextCallsFor(stub, 'ERR').length; + const redColorAfter = badgeColorCallsFor(stub, /^#D32F2F$/i).length; + const yellowColorAfter = badgeColorCallsFor(stub, /^#F9A825$/i).length; + const popupIdleAfter = setPopupCallsFor(stub, '').length; + const popupHtmlAfter = setPopupCallsFor(stub, 'src/popup/index.html').length; + + // Positive: an OFF transition fired (empty badge text + red color + + // popup empties). + expect(offTextAfter - offTextBefore).toBeGreaterThanOrEqual(1); + expect(redColorAfter - redColorBefore).toBeGreaterThanOrEqual(1); + expect(popupIdleAfter - popupIdleBefore).toBeGreaterThanOrEqual(1); + + // Negative: NO ERROR transition fired (no 'ERR' badge text, no + // yellow color, no popup-html re-set). + expect(errTextAfter - errTextBefore).toBe(0); + expect(yellowColorAfter - yellowColorBefore).toBe(0); + expect(popupHtmlAfter - popupHtmlBefore).toBe(0); + }); + + // ────────────────────────────────────────────────────────────────────── + // Test F — Bug B preservation guard: any RECORDING_ERROR whose code is + // NOT 'user-stopped-sharing' continues to route through setErrorMode. + // + // This is the defensive-fallback half of the conditional routing patch + // — it pins the contract that genuine capture failures (codec- + // unsupported, wrong-display-surface, capture-failed, permission-denied, + // empty-video-buffer, unknown, etc.) still raise the badge to ERROR + // (yellow 'ERR'). Without this test the fix could over-rotate (route + // ALL codes to IDLE) and silently lose the operator-facing error + // surface for genuine failures. + // + // Uses 'codec-unsupported' as the representative non-stop code (a + // realistic capture failure from src/offscreen/recorder.ts + // CaptureErrorCode union, exercised in tests/offscreen/codec-check.test.ts). + // + // GREEN today (the current handler unconditionally raises ERROR), and + // MUST remain GREEN after the Bug B patch — this is the regression + // pin proving the fix didn't over-rotate. + // ────────────────────────────────────────────────────────────────────── + it('F: RECORDING_ERROR{error:"codec-unsupported"} still routes to ERROR — badge ERR + yellow (preserved)', async () => { + const stub = buildBgStub(); + (globalThis as unknown as GlobalWithBgChrome).chrome = stub; + (globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn() }; + + await import('../../src/background/index'); + for (let i = 0; i < 8; i++) await Promise.resolve(); + + // Snapshot baseline AFTER init. + const errTextBefore = badgeTextCallsFor(stub, 'ERR').length; + const yellowColorBefore = badgeColorCallsFor(stub, /^#F9A825$/i).length; + const popupHtmlBefore = setPopupCallsFor(stub, 'src/popup/index.html').length; + + const onMsgHandler = stub.runtime.onMessage._callbacks[0]; + onMsgHandler( + { type: 'RECORDING_ERROR', error: 'codec-unsupported' }, + { id: 'ext-id-test' }, + vi.fn(), + ); + for (let i = 0; i < 16; i++) await Promise.resolve(); + + const errTextAfter = badgeTextCallsFor(stub, 'ERR').length; + const yellowColorAfter = badgeColorCallsFor(stub, /^#F9A825$/i).length; + const popupHtmlAfter = setPopupCallsFor(stub, 'src/popup/index.html').length; + + expect(errTextAfter - errTextBefore).toBeGreaterThanOrEqual(1); + expect(yellowColorAfter - yellowColorBefore).toBeGreaterThanOrEqual(1); + expect(popupHtmlAfter - popupHtmlBefore).toBeGreaterThanOrEqual(1); + }); });