test(01-10): wave-0 task-1 — RED onboarding tests (3 tests pin install/update/flag + storage-key)
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':<number>}), 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 <verify> 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) <noreply@anthropic.com>
This commit is contained in:
269
tests/background/onboarding.test.ts
Normal file
269
tests/background/onboarding.test.ts
Normal file
@@ -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<void>; }
|
||||||
|
interface OnNotificationClickedCallback { (notificationId: string): void; }
|
||||||
|
interface OnInstalledCallback {
|
||||||
|
(details: chrome.runtime.InstalledDetails): void | Promise<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>;
|
||||||
|
_callbacks: OnInstalledCallback[];
|
||||||
|
};
|
||||||
|
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>;
|
||||||
|
create: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
storage: {
|
||||||
|
local: {
|
||||||
|
get: ReturnType<typeof vi.fn>;
|
||||||
|
set: 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> };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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.
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user