Operator UAT 2026-05-20 rejected the build because the OS notification fired
on `chrome.runtime.onStartup` ("Recording started. I'm watching the last 30
seconds.") implied recording had auto-started when in fact recording was
not running. Per Phase 1 always-on charter recording does NOT auto-start;
the notification is the gesture surface that invites the operator to start
one (notifications.onClicked → startVideoCapture, src/background/index.ts:1038).
Root cause: a single i18n key `notifStartup` conflated the pre-recording
CTA-with-gesture path (the only path actually wired today) and a future
post-manual-start confirmation path. The key's own `.description` field
acknowledged the conflation. Operator-facing text leaned toward the
confirmation phrasing.
Fix (key split, no behavior change):
- `notifStartupCta` — EN: "Mokosh ready. Click to start a recording." /
RU: "Mokosh готов. Нажмите, чтобы начать запись." — wired into the
onStartup handler.
- `notifRecordingStarted` — preserves the original text ("Recording
started. I'm watching the last 30 seconds." / "Запись запущена…") for
a future post-manual-start confirmation flow.
- Fallback constant renamed `NOTIF_STARTUP_FALLBACK` →
`NOTIF_STARTUP_CTA_FALLBACK`; value updated to match the new CTA text.
- Inline test comment in tests/background/onstartup-notification.test.ts
refreshed to reference the new key + fallback. Assertion regex
/recording|recor|click/i covers both fallback + resolved locale variants,
no logic change.
Notification behavior preserved: same id prefix `mokosh-startup-`, same
priority, same icon, same onClicked → startVideoCapture wiring. No new
test-mode symbols (FORBIDDEN_HOOK_STRINGS inventory stays at 12).
Files modified:
- _locales/en/messages.json
- _locales/ru/messages.json
- src/background/index.ts
- tests/background/onstartup-notification.test.ts
Verification:
- npx vitest run --exclude tests/build/** --exclude tests/background/no-test-hooks-in-prod-bundle.test.ts: 104/104 GREEN
- npx vitest run tests/i18n/ tests/background/onstartup-notification.test.ts: 18/18 GREEN (locale-parity 4/4 + onstartup-notification 14/14)
- npx tsc --noEmit clean on src/background/index.ts
The 2 build-dependent vitest gates (tests/build/no-remote-fonts.test.ts +
tests/background/no-test-hooks-in-prod-bundle.test.ts) and npm run test:uat
are deferred to orchestrator-level re-verification after the parallel
Plan 01-10 mark-bundling fix also lands (operator-UAT re-spawn coordinated
by orchestrator).
Debug record: .planning/debug/resolved/01-09-startup-notification-misleading-text.md
Operator UAT rejection event: 2026-05-20
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
251 lines
11 KiB
TypeScript
251 lines
11 KiB
TypeScript
// 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');
|
|
// Plan 01-12 Wave 4: notification title migrated from literal
|
|
// 'Mokosh ready' to chrome.i18n.getMessage('extName') with fallback
|
|
// 'Mokosh' (NOTIF_EXTNAME_FALLBACK). Tests without a chrome.i18n
|
|
// stub observe the fallback. Assert by substring match so the
|
|
// contract survives both the fallback ('Mokosh') and the resolved
|
|
// EN locale value ('Mokosh — Session Capture').
|
|
expect(typeof opts.title).toBe('string');
|
|
expect(/Mokosh/i.test(String(opts.title))).toBe(true);
|
|
expect(typeof opts.message).toBe('string');
|
|
// Plan 01-12 Wave 4: notification message migrated to chrome.i18n.
|
|
// Plan 01-09 amendment 2026-05-20: key split — onStartup handler now
|
|
// reads `notifStartupCta` with fallback `NOTIF_STARTUP_CTA_FALLBACK`
|
|
// ('Mokosh ready. Click to start a recording.'); the prior conflated
|
|
// `notifStartup` key was retired (`notifRecordingStarted` reserved
|
|
// for post-manual-start confirmation). Tests without a chrome.i18n
|
|
// stub observe the CTA fallback; the regex matches both 'click' and
|
|
// 'recording' (case-insensitive) so it survives both the fallback
|
|
// and the resolved locale value.
|
|
expect(/recording|recor|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);
|
|
});
|
|
});
|