Files
mokosh/tests/background/onstartup-notification.test.ts
Mark 2d7ff7d4e3 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.
2026-05-17 15:27:41 +02:00

235 lines
10 KiB
TypeScript

// tests/background/onstartup-notification.test.ts
//
// Plan 01-09 Task 3 RED — pins the onStartup notification + recovery
// notification + notifications.onClicked contract.
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;
}
describe('Plan 01-09 Task 3: chrome.runtime.onStartup notification contract', () => {
beforeEach(() => {
vi.resetModules();
});
// ──────────────────────────────────────────────────────────────────────
// Test A — chrome.runtime.onStartup.addListener is registered at SW load
// ──────────────────────────────────────────────────────────────────────
it('A: chrome.runtime.onStartup.addListener is registered when SW module loads', 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');
expect(stub.runtime.onStartup._callbacks.length).toBeGreaterThanOrEqual(1);
});
// ──────────────────────────────────────────────────────────────────────
// Test B — onStartup fires exactly one chrome.notifications.create call
// with the mokosh-startup- prefix and the expected message shape.
// ──────────────────────────────────────────────────────────────────────
it('B: firing onStartup creates exactly one mokosh-startup- notification', 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');
const createCallsBefore = stub.notifications.create.mock.calls.length;
// Fire onStartup.
const startupCb = stub.runtime.onStartup._callbacks[0];
startupCb();
for (let i = 0; i < 8; i++) await Promise.resolve();
const startupCalls = stub.notifications.create.mock.calls
.slice(createCallsBefore)
.filter((args: unknown[]) => {
const id = args[0] as unknown;
return typeof id === 'string' && id.startsWith('mokosh-startup-');
});
expect(startupCalls.length).toBe(1);
// Validate the options shape.
const opts = startupCalls[0][1] as {
type?: unknown; title?: unknown; message?: unknown;
};
expect(opts.type).toBe('basic');
expect(opts.title).toBe('Mokosh ready');
expect(typeof opts.message).toBe('string');
expect(/Click/i.test(String(opts.message))).toBe(true);
});
// ──────────────────────────────────────────────────────────────────────
// Test C — chrome.notifications.onClicked with a mokosh-startup- id
// calls clear() AND triggers startVideoCapture (sendMessage START_RECORDING).
// ──────────────────────────────────────────────────────────────────────
it('C: notifications.onClicked with mokosh- id clears + triggers START_RECORDING', 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');
// Set up port + OFFSCREEN_READY so startVideoCapture proceeds.
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());
// Synthesize the notification click.
const notifCb = stub.notifications.onClicked._callbacks[0];
notifCb('mokosh-startup-1234567890');
for (let i = 0; i < 32; i++) await Promise.resolve();
// clear() was called with the same id.
const clearCalls = stub.notifications.clear.mock.calls.filter(
(args: unknown[]) => args[0] === 'mokosh-startup-1234567890',
);
expect(clearCalls.length).toBeGreaterThanOrEqual(1);
// START_RECORDING sent downstream.
const startCalls = stub.runtime.sendMessage.mock.calls.filter(
(args: unknown[]) => {
const msg = args[0] as { type?: unknown };
return typeof msg === 'object' && msg !== null && msg.type === 'START_RECORDING';
},
);
expect(startCalls.length).toBeGreaterThanOrEqual(1);
});
// ──────────────────────────────────────────────────────────────────────
// Test D — receiving RECORDING_ERROR via onMessage triggers a recovery
// notification (id prefix 'mokosh-recovery-').
// ──────────────────────────────────────────────────────────────────────
it('D: RECORDING_ERROR onMessage triggers a mokosh-recovery- notification', 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');
const createCallsBefore = stub.notifications.create.mock.calls.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 recoveryCalls = stub.notifications.create.mock.calls
.slice(createCallsBefore)
.filter((args: unknown[]) => {
const id = args[0] as unknown;
return typeof id === 'string' && id.startsWith('mokosh-recovery-');
});
expect(recoveryCalls.length).toBeGreaterThanOrEqual(1);
});
});