Milestone v1 (v2.0.0): Mokosh — Session Capture #1
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
234
tests/background/onstartup-notification.test.ts
Normal file
234
tests/background/onstartup-notification.test.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
378
tests/background/toolbar-action.test.ts
Normal file
378
tests/background/toolbar-action.test.ts
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
// tests/background/toolbar-action.test.ts
|
||||||
|
//
|
||||||
|
// Plan 01-09 Task 3 RED — pins the chrome.action.onClicked routing
|
||||||
|
// contract + setPopup state-machine + popup SAVE-only idle-race
|
||||||
|
// behavior (W-02 fix). 5 tests; A/B/C/D RED for background, E RED
|
||||||
|
// for popup-init. Task 4 GREEN.
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
interface PortStub {
|
||||||
|
name: string;
|
||||||
|
sender?: { id?: string };
|
||||||
|
postMessage: ReturnType<typeof vi.fn>;
|
||||||
|
onMessage: { addListener: ReturnType<typeof vi.fn>; _listeners: Array<(m: unknown) => void> };
|
||||||
|
onDisconnect: { addListener: (fn: () => void) => void; _listeners: Array<() => void> };
|
||||||
|
disconnect: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePortStub(name = 'video-keepalive'): PortStub {
|
||||||
|
const port: PortStub = {
|
||||||
|
name,
|
||||||
|
sender: { id: 'ext-id-test' },
|
||||||
|
postMessage: vi.fn(),
|
||||||
|
onMessage: { addListener: vi.fn(), _listeners: [] },
|
||||||
|
onDisconnect: {
|
||||||
|
addListener: (fn) => { port.onDisconnect._listeners.push(fn); },
|
||||||
|
_listeners: [],
|
||||||
|
},
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
};
|
||||||
|
port.onMessage.addListener.mockImplementation((fn: (m: unknown) => void) => {
|
||||||
|
port.onMessage._listeners.push(fn);
|
||||||
|
});
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OnConnectCallback { (port: PortStub): 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> };
|
||||||
|
fetch?: typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.action.onClicked routing + setPopup state-machine', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// Test A — chrome.action.onClicked.addListener is registered at SW init
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
it('A: chrome.action.onClicked.addListener is registered when SW initializes', 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.action.onClicked._callbacks.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// Test B — onClicked while NOT recording invokes startVideoCapture
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
it('B: onClicked while not recording triggers startVideoCapture (sends 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');
|
||||||
|
|
||||||
|
// Synthetically connect an offscreen port + signal OFFSCREEN_READY so
|
||||||
|
// startVideoCapture's await offscreenReady resolves.
|
||||||
|
const port = makePortStub();
|
||||||
|
if (stub.runtime.onConnect._callbacks.length > 0) {
|
||||||
|
stub.runtime.onConnect._callbacks[0](port);
|
||||||
|
}
|
||||||
|
// Fire OFFSCREEN_READY via the onMessage callback so the readiness
|
||||||
|
// Promise resolves.
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Drain microtasks so startVideoCapture's await chain reaches sendMessage.
|
||||||
|
for (let i = 0; i < 32; i++) await Promise.resolve();
|
||||||
|
|
||||||
|
// The handler should have triggered a START_RECORDING sendMessage.
|
||||||
|
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 C — onClicked twice without intermediate state-clear does not
|
||||||
|
// double-start (defensive guard test). After the first onClicked fires
|
||||||
|
// and isRecording flips true, a second onClicked must NOT trigger a
|
||||||
|
// second startVideoCapture.
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
it('C: onClicked while isRecording is true does NOT double-start', 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 port = makePortStub();
|
||||||
|
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];
|
||||||
|
// First click — should fire START_RECORDING and flip isRecording.
|
||||||
|
cb();
|
||||||
|
for (let i = 0; i < 32; i++) await Promise.resolve();
|
||||||
|
// Second click while already recording — should be a no-op.
|
||||||
|
cb();
|
||||||
|
for (let i = 0; i < 32; i++) await Promise.resolve();
|
||||||
|
|
||||||
|
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';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Exactly ONE START_RECORDING across both clicks.
|
||||||
|
expect(startCalls.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// Test D — setPopup state-machine: empty string in OFF (idle) mode, the
|
||||||
|
// popup html path in REC mode. Verified via setPopup spy call args.
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
it('D: setPopup is called with "" in OFF mode and with popup html path in REC mode', 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');
|
||||||
|
|
||||||
|
// After init the SW should have entered idle mode → setPopup('').
|
||||||
|
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||||
|
const offCalls = stub.action.setPopup.mock.calls.filter(
|
||||||
|
(args: unknown[]) => {
|
||||||
|
const opts = args[0] as { popup?: unknown };
|
||||||
|
return typeof opts === 'object' && opts !== null && opts.popup === '';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(offCalls.length).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// Now trigger startVideoCapture via onClicked; this should set the
|
||||||
|
// popup back to the html path (REC mode).
|
||||||
|
const port = makePortStub();
|
||||||
|
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();
|
||||||
|
|
||||||
|
const recCalls = stub.action.setPopup.mock.calls.filter(
|
||||||
|
(args: unknown[]) => {
|
||||||
|
const opts = args[0] as { popup?: unknown };
|
||||||
|
return (
|
||||||
|
typeof opts === 'object' &&
|
||||||
|
opts !== null &&
|
||||||
|
typeof opts.popup === 'string' &&
|
||||||
|
(opts.popup as string).includes('popup/index.html')
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(recCalls.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
// Test E — popup-loads-when-idle race pin (W-02 fix).
|
||||||
|
//
|
||||||
|
// Node-env-friendly: jsdom is not installed; instead we manually stub
|
||||||
|
// the document/event surface the popup module touches at init time.
|
||||||
|
// The popup uses: getElementById('saveButton'/'statusMessage'),
|
||||||
|
// querySelector('.button-text'), addEventListener (button + document).
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ElementStub {
|
||||||
|
textContent: string;
|
||||||
|
className: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
querySelector?: (sel: string) => ElementStub | null;
|
||||||
|
addEventListener?: (ev: string, cb: () => void) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentStub {
|
||||||
|
getElementById: (id: string) => ElementStub | null;
|
||||||
|
addEventListener: (ev: string, cb: () => void) => void;
|
||||||
|
_docListeners: Map<string, Array<() => void>>;
|
||||||
|
querySelector: (sel: string) => ElementStub | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDocumentStub(): {
|
||||||
|
doc: DocumentStub;
|
||||||
|
saveBtn: ElementStub;
|
||||||
|
statusMsg: ElementStub;
|
||||||
|
buttonText: ElementStub;
|
||||||
|
} {
|
||||||
|
const buttonText: ElementStub = {
|
||||||
|
textContent: '',
|
||||||
|
className: '',
|
||||||
|
};
|
||||||
|
const saveBtn: ElementStub = {
|
||||||
|
textContent: '',
|
||||||
|
className: '',
|
||||||
|
disabled: true,
|
||||||
|
querySelector: (sel: string) => (sel === '.button-text' ? buttonText : null),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
};
|
||||||
|
const statusMsg: ElementStub = {
|
||||||
|
textContent: '',
|
||||||
|
className: '',
|
||||||
|
};
|
||||||
|
const docListeners = new Map<string, Array<() => void>>();
|
||||||
|
const doc: DocumentStub = {
|
||||||
|
getElementById: (id: string) => {
|
||||||
|
if (id === 'saveButton') return saveBtn;
|
||||||
|
if (id === 'statusMessage') return statusMsg;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
addEventListener: (ev: string, cb: () => void) => {
|
||||||
|
const list = docListeners.get(ev) ?? [];
|
||||||
|
list.push(cb);
|
||||||
|
docListeners.set(ev, list);
|
||||||
|
},
|
||||||
|
_docListeners: docListeners,
|
||||||
|
querySelector: () => null,
|
||||||
|
};
|
||||||
|
return { doc, saveBtn, statusMsg, buttonText };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Plan 01-09 Task 3 Test E: popup SAVE-only contract (W-02 fix)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('E1+E2: popup init does NOT send REQUEST_PERMISSIONS AND saveButton is enabled', async () => {
|
||||||
|
const { doc, saveBtn } = buildDocumentStub();
|
||||||
|
(globalThis as unknown as { document?: unknown }).document = doc;
|
||||||
|
|
||||||
|
const sendMessageSpy = vi.fn().mockResolvedValue({ granted: false });
|
||||||
|
(globalThis as unknown as { chrome?: unknown }).chrome = {
|
||||||
|
runtime: {
|
||||||
|
id: 'ext-id-test',
|
||||||
|
sendMessage: sendMessageSpy,
|
||||||
|
onMessage: { addListener: vi.fn() },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await import('../../src/popup/index');
|
||||||
|
|
||||||
|
// Fire DOMContentLoaded via the captured doc listeners.
|
||||||
|
const cbs = doc._docListeners.get('DOMContentLoaded') ?? [];
|
||||||
|
cbs.forEach((cb) => cb());
|
||||||
|
|
||||||
|
// Drain microtasks so any async init paths complete.
|
||||||
|
for (let i = 0; i < 16; i++) await Promise.resolve();
|
||||||
|
|
||||||
|
// E1 — no REQUEST_PERMISSIONS at popup-open.
|
||||||
|
const reqPermCalls = sendMessageSpy.mock.calls.filter(
|
||||||
|
(args: unknown[]) => {
|
||||||
|
const msg = args[0] as { type?: unknown };
|
||||||
|
return typeof msg === 'object' && msg !== null && msg.type === 'REQUEST_PERMISSIONS';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(reqPermCalls.length).toBe(0);
|
||||||
|
|
||||||
|
// E2 — SAVE button enabled regardless of chrome round-trip result.
|
||||||
|
expect(saveBtn.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user