Milestone v1 (v2.0.0): Mokosh — Session Capture #1
@@ -7,6 +7,15 @@
|
|||||||
//
|
//
|
||||||
// Plus Test D: receiving RECORDING_ERROR via onMessage triggers
|
// Plus Test D: receiving RECORDING_ERROR via onMessage triggers
|
||||||
// setBadgeState('ERROR') (asserted via setBadgeText spy receiving 'ERR').
|
// 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';
|
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', () => {
|
describe('Plan 01-09 Task 3: badge state machine REC/OFF/ERROR contract', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
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;
|
const errCallsAfter = badgeTextCallsFor(stub, 'ERR').length;
|
||||||
expect(errCallsAfter - errCallsBefore).toBeGreaterThanOrEqual(1);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user