src/popup/style.css:
- Adds @import "../shared/tokens.css" at top
- All hex literals removed; every color reads from var(--mks-*) per
D-04 loom palette: --mks-surface body bg; --mks-rec/--mks-madder-700
for SAVE button (default/hover); --mks-amber-600 for saving;
--mks-moss-600 for done; --mks-error/--mks-success/--mks-warning for
status messages; --mks-fg-disabled for disabled button
- Font families read from --mks-font-ui (IBM Plex Sans stack)
- Spacing/radius/shadows all token-driven
src/popup/index.html:
- <span class="button-text"> emptied (populated by JS via i18n)
- <p class="info-text" data-mks-key="popupInfoText"> attribute-marked
for populateMksKeys() init-time population
- <title> kept as literal English (chrome doesn't substitute __MSG_*__
in HTML body per RESEARCH Pitfall 3)
src/popup/index.ts:
- New `i18n(key, fallback)` helper: chrome.i18n.getMessage with explicit
`|| <fallback>` for unit-test contexts without chrome.i18n stub
- New `populateMksKeys()` helper: walks [data-mks-key] elements at init
and sets each textContent from i18n
- updateUI() reads popupSaveCta/popupSaving/popupSaveDoneShort at each
state branch (idle/saving/done) with Russian fallbacks
- saveArchive() success branch reads popupSaveDone
- Empty-state path reads popupEmptyState
src/background/index.ts:
- BADGE_REC_COLOR: '#00C853' → '#b2543d' (= --mks-madder-600 per D-04;
RESEARCH §10 Open Question A7 default-action)
- BADGE_OFF_COLOR + BADGE_ERROR_COLOR retained as engineering choices
(no loom-palette token for material-red/amber-700 equivalents)
- BADGE_REC_TITLE/BADGE_OFF_TITLE/BADGE_ERROR_TITLE renamed to
..._FALLBACK and only referenced at the chrome.i18n.getMessage call
sites inside setBadgeState (i18nMessage('tooltipRecPrefix' etc.))
- New `i18nMessage(key, fallback)` helper mirroring popup's i18n()
- Recovery notification: title=i18nMessage('extName',...); message=
i18nMessage('notifRecovery',...)
- Startup notification: title=i18nMessage('extName',...); message=
i18nMessage('notifStartup',...)
- NOTIF_EXTNAME_FALLBACK/NOTIF_STARTUP_FALLBACK/NOTIF_RECOVERY_FALLBACK
module-level constants for the |||| chain (degrade gracefully in
test contexts without chrome.i18n stub)
- NO `await import(...)` added (MV3 SW dynamic-import constraint per
01-11-SUMMARY preserved)
Test-contract updates (3 tests; assertion-shape only — no semantic
regression):
- tests/background/badge-state-machine.test.ts: greenCalls→recColorCalls
regex updated from /^#00[Cc]853$/ to /^#b2543d$/i lockstep with
BADGE_REC_COLOR change; title-substring assertion widened to
/Recording|recording/i to cover both EN locale + fallback
- tests/background/onstartup-notification.test.ts: title equality
('Mokosh ready') replaced with /Mokosh/i substring assertion
(survives both the 'Mokosh' fallback + 'Mokosh — Session Capture'
resolved EN); message regex widened to /recording|recor|click/i
- tests/background/toolbar-action.test.ts: DocumentStub gains
querySelectorAll: () => [] so the new populateMksKeys() init path
doesn't throw under the popup's no-DOM unit-test environment
Verification:
- tests/build/tokens-adopted.test.ts: 4/4 GREEN (was 2 RED + 2 GREEN)
- tests/build/no-remote-fonts.test.ts: 4/4 GREEN after fresh build
(Vite emits the WOFF2 files as content-hashed dist/assets/*.woff2;
tokens.css references resolve through the asset pipeline; no
remote-font URLs anywhere in dist/)
- Full vitest sweep: 147/147 GREEN (was 145/147)
- npx tsc --noEmit: clean
- Tier-1 grep gate: 13/13 GREEN (no new test-mode symbols)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
439 lines
21 KiB
TypeScript
439 lines
21 KiB
TypeScript
// tests/background/badge-state-machine.test.ts
|
|
//
|
|
// Plan 01-09 Task 3 RED — pins the 3-state badge contract:
|
|
// REC (green, 'REC', recording title)
|
|
// OFF (red, '', idle title)
|
|
// ERROR (yellow, 'ERR', error title)
|
|
//
|
|
// 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';
|
|
|
|
interface OnConnectCallback { (port: unknown): void; }
|
|
interface OnMessageCallback {
|
|
(msg: unknown, sender: { id?: string }, sendResponse: (r: unknown) => void): boolean;
|
|
}
|
|
interface OnClickedCallback { (info?: unknown): void | Promise<void>; }
|
|
interface OnNotificationClickedCallback { (notificationId: string): void; }
|
|
|
|
interface BgChromeStub {
|
|
runtime: {
|
|
id: string;
|
|
getURL: (p: string) => string;
|
|
getManifest: () => { version: string };
|
|
sendMessage: ReturnType<typeof vi.fn>;
|
|
onMessage: { addListener: ReturnType<typeof vi.fn>; _callbacks: OnMessageCallback[] };
|
|
onConnect: { addListener: (cb: OnConnectCallback) => void; _callbacks: OnConnectCallback[] };
|
|
onInstalled: { addListener: ReturnType<typeof vi.fn> };
|
|
onStartup: { addListener: (cb: () => void) => void; _callbacks: Array<() => void> };
|
|
};
|
|
offscreen: {
|
|
hasDocument?: () => Promise<boolean>;
|
|
createDocument: ReturnType<typeof vi.fn>;
|
|
Reason: { DISPLAY_MEDIA: 'DISPLAY_MEDIA' };
|
|
};
|
|
tabs: {
|
|
query: ReturnType<typeof vi.fn>;
|
|
sendMessage: ReturnType<typeof vi.fn>;
|
|
captureVisibleTab: ReturnType<typeof vi.fn>;
|
|
};
|
|
downloads: { download: ReturnType<typeof vi.fn> };
|
|
action: {
|
|
onClicked: { addListener: (cb: OnClickedCallback) => void; _callbacks: OnClickedCallback[] };
|
|
setPopup: ReturnType<typeof vi.fn>;
|
|
setBadgeText: ReturnType<typeof vi.fn>;
|
|
setBadgeBackgroundColor: ReturnType<typeof vi.fn>;
|
|
setTitle: ReturnType<typeof vi.fn>;
|
|
};
|
|
notifications: {
|
|
create: ReturnType<typeof vi.fn>;
|
|
clear: ReturnType<typeof vi.fn>;
|
|
onClicked: {
|
|
addListener: (cb: OnNotificationClickedCallback) => void;
|
|
_callbacks: OnNotificationClickedCallback[];
|
|
};
|
|
};
|
|
}
|
|
|
|
interface GlobalWithBgChrome {
|
|
chrome?: BgChromeStub;
|
|
indexedDB?: { deleteDatabase: ReturnType<typeof vi.fn> };
|
|
}
|
|
|
|
function buildBgStub(): BgChromeStub {
|
|
const stub: BgChromeStub = {
|
|
runtime: {
|
|
id: 'ext-id-test',
|
|
getURL: (p) => `chrome-extension://ext-id-test/${p}`,
|
|
getManifest: () => ({ version: '1.0.0' }),
|
|
sendMessage: vi.fn().mockResolvedValue(undefined),
|
|
onMessage: { addListener: vi.fn(), _callbacks: [] },
|
|
onConnect: { addListener: (cb) => stub.runtime.onConnect._callbacks.push(cb), _callbacks: [] },
|
|
onInstalled: { addListener: vi.fn() },
|
|
onStartup: { addListener: (cb) => stub.runtime.onStartup._callbacks.push(cb), _callbacks: [] },
|
|
},
|
|
offscreen: {
|
|
hasDocument: async () => false,
|
|
createDocument: vi.fn().mockResolvedValue(undefined),
|
|
Reason: { DISPLAY_MEDIA: 'DISPLAY_MEDIA' },
|
|
},
|
|
tabs: {
|
|
query: vi.fn().mockResolvedValue([{ id: 1, url: 'https://example.com', windowId: 100 }]),
|
|
sendMessage: vi.fn().mockResolvedValue({ events: [], userEvents: [] }),
|
|
captureVisibleTab: vi.fn().mockResolvedValue('data:image/png;base64,xx'),
|
|
},
|
|
downloads: { download: vi.fn().mockResolvedValue(1) },
|
|
action: {
|
|
onClicked: { addListener: (cb) => stub.action.onClicked._callbacks.push(cb), _callbacks: [] },
|
|
setPopup: vi.fn(),
|
|
setBadgeText: vi.fn(),
|
|
setBadgeBackgroundColor: vi.fn(),
|
|
setTitle: vi.fn(),
|
|
},
|
|
notifications: {
|
|
create: vi.fn(),
|
|
clear: vi.fn(),
|
|
onClicked: {
|
|
addListener: (cb) => stub.notifications.onClicked._callbacks.push(cb),
|
|
_callbacks: [],
|
|
},
|
|
},
|
|
};
|
|
stub.runtime.onMessage.addListener.mockImplementation((cb: OnMessageCallback) => {
|
|
stub.runtime.onMessage._callbacks.push(cb);
|
|
});
|
|
return stub;
|
|
}
|
|
|
|
// Helper: filter setBadgeText calls by the text argument value.
|
|
function badgeTextCallsFor(stub: BgChromeStub, text: string): unknown[][] {
|
|
return stub.action.setBadgeText.mock.calls.filter(
|
|
(args: unknown[]) => {
|
|
const opts = args[0] as { text?: unknown };
|
|
return typeof opts === 'object' && opts !== null && opts.text === text;
|
|
},
|
|
);
|
|
}
|
|
|
|
// 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();
|
|
});
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// Test A — REC state: setBadgeText('REC') + setBadgeBackgroundColor(green) + setTitle
|
|
//
|
|
// Triggered by chrome.action.onClicked firing startVideoCapture
|
|
// successfully (which calls setRecordingMode → setBadgeState('REC')).
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
it('A: setBadgeState("REC") sets text="REC" + green background + recording title', 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');
|
|
|
|
// Connect a port + signal offscreen ready so startVideoCapture completes.
|
|
const port = {
|
|
name: 'video-keepalive',
|
|
sender: { id: 'ext-id-test' },
|
|
postMessage: vi.fn(),
|
|
onMessage: { addListener: vi.fn() },
|
|
onDisconnect: { addListener: vi.fn() },
|
|
disconnect: vi.fn(),
|
|
};
|
|
if (stub.runtime.onConnect._callbacks.length > 0) {
|
|
stub.runtime.onConnect._callbacks[0](port);
|
|
}
|
|
const onMsgHandler = stub.runtime.onMessage._callbacks[0];
|
|
onMsgHandler({ type: 'OFFSCREEN_READY' }, { id: 'ext-id-test' }, vi.fn());
|
|
|
|
const cb = stub.action.onClicked._callbacks[0];
|
|
cb();
|
|
for (let i = 0; i < 32; i++) await Promise.resolve();
|
|
|
|
// setBadgeText was called with text='REC'.
|
|
expect(badgeTextCallsFor(stub, 'REC').length).toBeGreaterThanOrEqual(1);
|
|
// Plan 01-12 Wave 4: BADGE_REC_COLOR migrated from #00C853 (material
|
|
// green) to #b2543d (--mks-madder-600 per D-04 loom palette). The
|
|
// assertion below is updated lockstep.
|
|
const recColorCalls = 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' && /^#b2543d$/i.test(color);
|
|
},
|
|
);
|
|
expect(recColorCalls.length).toBeGreaterThanOrEqual(1);
|
|
// setTitle was called with the recording title. Plan 01-12 Wave 4:
|
|
// setBadgeState now reads chrome.i18n.getMessage('tooltipRecPrefix')
|
|
// with fallback 'Recording — last 30 s buffered. Click to save.'.
|
|
// Tests without a chrome.i18n stub see the fallback, which still
|
|
// matches /Recording/i — assertion unchanged below.
|
|
const titleCalls = stub.action.setTitle.mock.calls.filter(
|
|
(args: unknown[]) => {
|
|
const opts = args[0] as { title?: unknown };
|
|
const title = typeof opts === 'object' && opts !== null ? opts.title : undefined;
|
|
return typeof title === 'string' && /Recording|recording/i.test(title);
|
|
},
|
|
);
|
|
expect(titleCalls.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// Test B — OFF state on SW init (initialize → setIdleMode → setBadgeState('OFF'))
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
it('B: setBadgeState("OFF") sets text="" + red background + idle title (called at init)', 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');
|
|
// Drain so initialize completes.
|
|
for (let i = 0; i < 16; i++) await Promise.resolve();
|
|
|
|
// OFF text is empty string.
|
|
expect(badgeTextCallsFor(stub, '').length).toBeGreaterThanOrEqual(1);
|
|
const redCalls = 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' && /^#D32F2F$/i.test(color);
|
|
},
|
|
);
|
|
expect(redCalls.length).toBeGreaterThanOrEqual(1);
|
|
const titleCalls = stub.action.setTitle.mock.calls.filter(
|
|
(args: unknown[]) => {
|
|
const opts = args[0] as { title?: unknown };
|
|
const title = typeof opts === 'object' && opts !== null ? opts.title : undefined;
|
|
return typeof title === 'string' && /Not recording/i.test(title);
|
|
},
|
|
);
|
|
expect(titleCalls.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// Test C — ERROR state: setBadgeText('ERR') + yellow background + error title.
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
it('C: setBadgeState("ERROR") sets text="ERR" + yellow background + error title', 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();
|
|
|
|
// Trigger ERROR via RECORDING_ERROR onMessage path.
|
|
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();
|
|
|
|
expect(badgeTextCallsFor(stub, 'ERR').length).toBeGreaterThanOrEqual(1);
|
|
const yellowCalls = 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' && /^#F9A825$/i.test(color);
|
|
},
|
|
);
|
|
expect(yellowCalls.length).toBeGreaterThanOrEqual(1);
|
|
const titleCalls = stub.action.setTitle.mock.calls.filter(
|
|
(args: unknown[]) => {
|
|
const opts = args[0] as { title?: unknown };
|
|
const title = typeof opts === 'object' && opts !== null ? opts.title : undefined;
|
|
return typeof title === 'string' && /error/i.test(title);
|
|
},
|
|
);
|
|
expect(titleCalls.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// Test D — RECORDING_ERROR via onMessage triggers ERROR state (already
|
|
// covered by Test C but pinned here as a separate dispatch assertion).
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
it('D: RECORDING_ERROR onMessage triggers setBadgeText("ERR") within a microtask', 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 call count before — only the init OFF call should have fired.
|
|
const errCallsBefore = badgeTextCallsFor(stub, 'ERR').length;
|
|
|
|
const onMsgHandler = stub.runtime.onMessage._callbacks[0];
|
|
onMsgHandler(
|
|
{ type: 'RECORDING_ERROR', error: 'capture-failed' },
|
|
{ id: 'ext-id-test' },
|
|
vi.fn(),
|
|
);
|
|
for (let i = 0; i < 16; i++) await Promise.resolve();
|
|
|
|
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);
|
|
});
|
|
});
|