From 89e1e09d608dc3bbf49cb9443002e31f42e26a69 Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 20 May 2026 09:00:58 +0200 Subject: [PATCH] =?UTF-8?q?test(01-10):=20wave-0=20task-1=20=E2=80=94=20RE?= =?UTF-8?q?D=20onboarding=20tests=20(3=20tests=20pin=20install/update/flag?= =?UTF-8?q?=20+=20storage-key)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three tests in tests/background/onboarding.test.ts pinning the Plan 01-10 D-17-onboarding contract: Test A (RED): first install + empty storage opens exactly ONE welcome tab whose URL contains 'src/welcome/welcome.html', sets chrome.storage.local.set({'onboarding-completed':true, 'installed-at':}), AND calls chrome.storage.local.get with EXACT key 'onboarding-completed' (storage-schema cross-version-compat pin; preserves I-02 lesson from prior draft). Test B (vacuous-GREEN, becomes load-bearing post-Task-3): reason='update' → chrome.tabs.create NOT called. Test C (vacuous-GREEN, becomes load-bearing post-Task-3): flag already true → chrome.tabs.create NOT called. Tests B and C pass vacuously until Task 3 lands openWelcomeIfFirstInstall; they remain load-bearing AFTER Task 3 as no-tab-open guards for the update/already-onboarded branches. Test A flips RED → GREEN at Task 3. Stub scaffold inherits buildBgStub from onstartup-notification.test.ts; extended with chrome.tabs.create + chrome.storage.local.{get,set} + chrome.runtime.onInstalled._callbacks (addListener.mockImplementation pattern to capture the SW's registered listener). DEVIATION NOTE: plan's expected `3 failed` but only Test A (positive contract) goes RED pre-Task-3; Tests B+C are negative-path guards that pass trivially when the helper is absent. This is standard TDD (positive test fails RED; negative tests stay GREEN through GREEN→ REFACTOR). No code change needed — Task 3's GREEN gate is "all 3 GREEN". Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/background/onboarding.test.ts | 269 ++++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 tests/background/onboarding.test.ts diff --git a/tests/background/onboarding.test.ts b/tests/background/onboarding.test.ts new file mode 100644 index 0000000..365a57c --- /dev/null +++ b/tests/background/onboarding.test.ts @@ -0,0 +1,269 @@ +// 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. + }); +});