test(01-09): RED — toolbar-onClicked + badge state machine + onStartup notification + popup SAVE-only
Plan 01-09 Task 3 RED — 13 tests across 3 new files:
tests/background/toolbar-action.test.ts (5 tests):
A: chrome.action.onClicked.addListener registered at SW init
B: onClicked while not recording triggers startVideoCapture
C: onClicked while isRecording does NOT double-start
D: setPopup('') in OFF mode, popup html path in REC mode
E: popup init does NOT send REQUEST_PERMISSIONS + saveButton enabled
(W-02 fix — without jsdom, uses node-env document stub)
tests/background/badge-state-machine.test.ts (4 tests):
A: REC state = text 'REC' + #00C853 green + Recording title
B: OFF state = text '' + #D32F2F red + Not recording title
(fired at SW init via initialize → setIdleMode)
C: ERROR state = text 'ERR' + #F9A825 yellow + error title
D: RECORDING_ERROR onMessage triggers setBadgeText('ERR') within microtask
tests/background/onstartup-notification.test.ts (4 tests):
A: chrome.runtime.onStartup.addListener registered at SW load
B: onStartup fires exactly one mokosh-startup- notification
with basic type + 'Mokosh ready' title + Click-instructed message
C: notifications.onClicked with mokosh- id clears + triggers START_RECORDING
D: RECORDING_ERROR onMessage triggers mokosh-recovery- notification
Task 4 will flip all 13 to GREEN by adding the listeners + state machine
+ helpers in src/background/index.ts, popup SAVE-only, manifest update.
Deviation Rule 3: jsdom not in node_modules; refactored Test E to use a
node-env document stub instead of @vitest-environment jsdom pragma.
This commit is contained in:
275
tests/background/badge-state-machine.test.ts
Normal file
275
tests/background/badge-state-machine.test.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
// 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').
|
||||
|
||||
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;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
// setBadgeBackgroundColor was called with a green-ish color.
|
||||
const greenCalls = 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' && /^#00[Cc]853$/.test(color);
|
||||
},
|
||||
);
|
||||
expect(greenCalls.length).toBeGreaterThanOrEqual(1);
|
||||
// setTitle was called with the recording title.
|
||||
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/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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user