Milestone v1 (v2.0.0): Mokosh — Session Capture #1

Merged
strategy155 merged 297 commits from gsd/phase-04-harden-clean-up-optional into main 2026-05-31 15:34:17 +00:00
Showing only changes of commit 89e1e09d60 - Show all commits

View 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.
});
});