// tests/background/onboarding.test.ts // // Plan 01-10 Task 1 — RED tests pinning the onInstalled welcome-tab // contract per Plan 01-10 D-17-onboarding. // // Three tests synthesize chrome.runtime.onInstalled events with the // reasons {install,update} crossed with the storage flag state {empty, // already-set}. The production helper `openWelcomeIfFirstInstall` does // not yet exist (Task 3 lands it); these tests MUST be RED until Task 3 // flips them GREEN. // // Stub scaffold: copied from `tests/background/onstartup-notification.test.ts` // + extended with chrome.tabs.create, chrome.storage.local.{get,set}, and // chrome.runtime.onInstalled.{addListener,_callbacks}. 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; } interface OnNotificationClickedCallback { (notificationId: string): void; } interface OnInstalledCallback { (details: chrome.runtime.InstalledDetails): void | Promise; } interface BgChromeStub { runtime: { id: string; getURL: (p: string) => string; getManifest: () => { version: string }; sendMessage: ReturnType; onMessage: { addListener: ReturnType; _callbacks: OnMessageCallback[] }; onConnect: { addListener: (cb: OnConnectCallback) => void; _callbacks: OnConnectCallback[] }; onInstalled: { addListener: ReturnType; _callbacks: OnInstalledCallback[]; }; onStartup: { addListener: (cb: () => void) => void; _callbacks: Array<() => void> }; }; offscreen: { hasDocument?: () => Promise; createDocument: ReturnType; Reason: { DISPLAY_MEDIA: 'DISPLAY_MEDIA' }; }; tabs: { query: ReturnType; sendMessage: ReturnType; captureVisibleTab: ReturnType; create: ReturnType; }; storage: { local: { get: ReturnType; set: ReturnType; }; }; downloads: { download: ReturnType }; action: { onClicked: { addListener: (cb: OnClickedCallback) => void; _callbacks: OnClickedCallback[] }; setPopup: ReturnType; setBadgeText: ReturnType; setBadgeBackgroundColor: ReturnType; setTitle: ReturnType; }; notifications: { create: ReturnType; clear: ReturnType; onClicked: { addListener: (cb: OnNotificationClickedCallback) => void; _callbacks: OnNotificationClickedCallback[]; }; }; } interface GlobalWithBgChrome { chrome?: BgChromeStub; indexedDB?: { deleteDatabase: ReturnType }; } /** * Build a fresh chrome.* stub object for one test. The onInstalled * `_callbacks` array is the one Plan 01-10's openWelcomeIfFirstInstall * fixture relies on: the addListener mockImplementation pushes any * listener registered by `src/background/index.ts` so the test can * synthesize the install event by invoking _callbacks[0] with the * details object. */ 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(), _callbacks: [] }, 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'), create: vi.fn().mockResolvedValue({ id: 99, url: 'x' }), }, storage: { local: { get: vi.fn(), set: vi.fn().mockResolvedValue(undefined), }, }, 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); }); stub.runtime.onInstalled.addListener.mockImplementation((cb: OnInstalledCallback) => { stub.runtime.onInstalled._callbacks.push(cb); }); return stub; } /** * Drain the microtask queue so any `.then` chains in the SW's * fire-and-forget onInstalled flow have a chance to complete. The * SW handler is synchronous; openWelcomeIfFirstInstall is awaited * inside the helper but kicked off as fire-and-forget from the * listener. We pump 16 microtasks (generous; the helper has at most * three awaits — storage.get, tabs.create, storage.set). */ async function drainMicrotasks(): Promise { for (let i = 0; i < 16; i += 1) { await Promise.resolve(); } } describe('Plan 01-10 onboarding contract', () => { beforeEach(() => { vi.resetModules(); }); // ────────────────────────────────────────────────────────────────────── // Test A — first install + empty storage opens welcome tab + sets flag. // ────────────────────────────────────────────────────────────────────── it('A: first install + empty storage opens exactly one welcome tab and sets the storage flag', async () => { const stub = buildBgStub(); stub.storage.local.get.mockResolvedValue({}); (globalThis as unknown as GlobalWithBgChrome).chrome = stub; (globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn(), }; await import('../../src/background/index'); expect(stub.runtime.onInstalled._callbacks.length).toBeGreaterThanOrEqual(1); const installedCb = stub.runtime.onInstalled._callbacks[0]; expect(installedCb).toBeTypeOf('function'); installedCb({ reason: 'install' } as chrome.runtime.InstalledDetails); await drainMicrotasks(); // 1. chrome.tabs.create called exactly once with a URL pointing at // src/welcome/welcome.html. expect(stub.tabs.create).toHaveBeenCalledTimes(1); const createArgs = stub.tabs.create.mock.calls[0][0] as { url?: unknown }; expect(typeof createArgs.url).toBe('string'); expect(String(createArgs.url)).toContain('src/welcome/welcome.html'); // 2. chrome.storage.local.set called with the onboarding flag + installed-at. expect(stub.storage.local.set).toHaveBeenCalledTimes(1); const setArg = stub.storage.local.set.mock.calls[0][0] as Record; expect(setArg['onboarding-completed']).toBe(true); expect(typeof setArg['installed-at']).toBe('number'); // 3. chrome.storage.local.get called with the EXACT key string // 'onboarding-completed' (storage-schema cross-version-compat pin; // preserves the I-02 lesson from the prior draft). expect(stub.storage.local.get).toHaveBeenCalled(); const getCalls = stub.storage.local.get.mock.calls; const sawExactKey = getCalls.some((args: unknown[]) => { const key = args[0]; return key === 'onboarding-completed'; }); expect(sawExactKey).toBe(true); }); // ────────────────────────────────────────────────────────────────────── // Test B — subsequent install (reason='update') opens NO welcome tab. // ────────────────────────────────────────────────────────────────────── it('B: subsequent install (reason=update) does NOT open a welcome tab', async () => { const stub = buildBgStub(); stub.storage.local.get.mockResolvedValue({}); (globalThis as unknown as GlobalWithBgChrome).chrome = stub; (globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn(), }; await import('../../src/background/index'); const installedCb = stub.runtime.onInstalled._callbacks[0]; expect(installedCb).toBeTypeOf('function'); installedCb({ reason: 'update', previousVersion: '0.9.9' } as chrome.runtime.InstalledDetails); await drainMicrotasks(); expect(stub.tabs.create).not.toHaveBeenCalled(); expect(stub.storage.local.set).not.toHaveBeenCalled(); }); // ────────────────────────────────────────────────────────────────────── // Test C — install + flag already set opens NO welcome tab. // ────────────────────────────────────────────────────────────────────── it('C: install but onboarding-completed already true does NOT open a welcome tab', async () => { const stub = buildBgStub(); stub.storage.local.get.mockResolvedValue({ 'onboarding-completed': true, }); (globalThis as unknown as GlobalWithBgChrome).chrome = stub; (globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn(), }; await import('../../src/background/index'); const installedCb = stub.runtime.onInstalled._callbacks[0]; expect(installedCb).toBeTypeOf('function'); installedCb({ reason: 'install' } as chrome.runtime.InstalledDetails); await drainMicrotasks(); expect(stub.tabs.create).not.toHaveBeenCalled(); // storage.set MAY be called by other init paths; we only assert the // welcome-tab side-effect did NOT fire. The storage-set check here // is intentionally weak — Test A pins the positive contract. }); });