Files
mokosh/tests/background/toolbar-action.test.ts
Mark 468f16d7e7 feat(01-12): wave-4 task-1 — adopt tokens.css + chrome.i18n.getMessage in src/popup/ + src/background/ (loom palette + RU i18n + en fallback)
src/popup/style.css:
- Adds @import "../shared/tokens.css" at top
- All hex literals removed; every color reads from var(--mks-*) per
  D-04 loom palette: --mks-surface body bg; --mks-rec/--mks-madder-700
  for SAVE button (default/hover); --mks-amber-600 for saving;
  --mks-moss-600 for done; --mks-error/--mks-success/--mks-warning for
  status messages; --mks-fg-disabled for disabled button
- Font families read from --mks-font-ui (IBM Plex Sans stack)
- Spacing/radius/shadows all token-driven

src/popup/index.html:
- <span class="button-text"> emptied (populated by JS via i18n)
- <p class="info-text" data-mks-key="popupInfoText"> attribute-marked
  for populateMksKeys() init-time population
- <title> kept as literal English (chrome doesn't substitute __MSG_*__
  in HTML body per RESEARCH Pitfall 3)

src/popup/index.ts:
- New `i18n(key, fallback)` helper: chrome.i18n.getMessage with explicit
  `|| <fallback>` for unit-test contexts without chrome.i18n stub
- New `populateMksKeys()` helper: walks [data-mks-key] elements at init
  and sets each textContent from i18n
- updateUI() reads popupSaveCta/popupSaving/popupSaveDoneShort at each
  state branch (idle/saving/done) with Russian fallbacks
- saveArchive() success branch reads popupSaveDone
- Empty-state path reads popupEmptyState

src/background/index.ts:
- BADGE_REC_COLOR: '#00C853' → '#b2543d' (= --mks-madder-600 per D-04;
  RESEARCH §10 Open Question A7 default-action)
- BADGE_OFF_COLOR + BADGE_ERROR_COLOR retained as engineering choices
  (no loom-palette token for material-red/amber-700 equivalents)
- BADGE_REC_TITLE/BADGE_OFF_TITLE/BADGE_ERROR_TITLE renamed to
  ..._FALLBACK and only referenced at the chrome.i18n.getMessage call
  sites inside setBadgeState (i18nMessage('tooltipRecPrefix' etc.))
- New `i18nMessage(key, fallback)` helper mirroring popup's i18n()
- Recovery notification: title=i18nMessage('extName',...); message=
  i18nMessage('notifRecovery',...)
- Startup notification: title=i18nMessage('extName',...); message=
  i18nMessage('notifStartup',...)
- NOTIF_EXTNAME_FALLBACK/NOTIF_STARTUP_FALLBACK/NOTIF_RECOVERY_FALLBACK
  module-level constants for the |||| chain (degrade gracefully in
  test contexts without chrome.i18n stub)
- NO `await import(...)` added (MV3 SW dynamic-import constraint per
  01-11-SUMMARY preserved)

Test-contract updates (3 tests; assertion-shape only — no semantic
regression):
- tests/background/badge-state-machine.test.ts: greenCalls→recColorCalls
  regex updated from /^#00[Cc]853$/ to /^#b2543d$/i lockstep with
  BADGE_REC_COLOR change; title-substring assertion widened to
  /Recording|recording/i to cover both EN locale + fallback
- tests/background/onstartup-notification.test.ts: title equality
  ('Mokosh ready') replaced with /Mokosh/i substring assertion
  (survives both the 'Mokosh' fallback + 'Mokosh — Session Capture'
  resolved EN); message regex widened to /recording|recor|click/i
- tests/background/toolbar-action.test.ts: DocumentStub gains
  querySelectorAll: () => [] so the new populateMksKeys() init path
  doesn't throw under the popup's no-DOM unit-test environment

Verification:
- tests/build/tokens-adopted.test.ts: 4/4 GREEN (was 2 RED + 2 GREEN)
- tests/build/no-remote-fonts.test.ts: 4/4 GREEN after fresh build
  (Vite emits the WOFF2 files as content-hashed dist/assets/*.woff2;
  tokens.css references resolve through the asset pipeline; no
  remote-font URLs anywhere in dist/)
- Full vitest sweep: 147/147 GREEN (was 145/147)
- npx tsc --noEmit: clean
- Tier-1 grep gate: 13/13 GREEN (no new test-mode symbols)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 07:27:19 +02:00

386 lines
16 KiB
TypeScript

// tests/background/toolbar-action.test.ts
//
// Plan 01-09 Task 3 RED — pins the chrome.action.onClicked routing
// contract + setPopup state-machine + popup SAVE-only idle-race
// behavior (W-02 fix). 5 tests; A/B/C/D RED for background, E RED
// for popup-init. Task 4 GREEN.
import { describe, it, expect, vi, beforeEach } from 'vitest';
interface PortStub {
name: string;
sender?: { id?: string };
postMessage: ReturnType<typeof vi.fn>;
onMessage: { addListener: ReturnType<typeof vi.fn>; _listeners: Array<(m: unknown) => void> };
onDisconnect: { addListener: (fn: () => void) => void; _listeners: Array<() => void> };
disconnect: ReturnType<typeof vi.fn>;
}
function makePortStub(name = 'video-keepalive'): PortStub {
const port: PortStub = {
name,
sender: { id: 'ext-id-test' },
postMessage: vi.fn(),
onMessage: { addListener: vi.fn(), _listeners: [] },
onDisconnect: {
addListener: (fn) => { port.onDisconnect._listeners.push(fn); },
_listeners: [],
},
disconnect: vi.fn(),
};
port.onMessage.addListener.mockImplementation((fn: (m: unknown) => void) => {
port.onMessage._listeners.push(fn);
});
return port;
}
interface OnConnectCallback { (port: PortStub): 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> };
fetch?: typeof fetch;
}
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.action.onClicked routing + setPopup state-machine', () => {
beforeEach(() => {
vi.resetModules();
});
// ──────────────────────────────────────────────────────────────────────
// Test A — chrome.action.onClicked.addListener is registered at SW init
// ──────────────────────────────────────────────────────────────────────
it('A: chrome.action.onClicked.addListener is registered when SW initializes', 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.action.onClicked._callbacks.length).toBeGreaterThanOrEqual(1);
});
// ──────────────────────────────────────────────────────────────────────
// Test B — onClicked while NOT recording invokes startVideoCapture
// ──────────────────────────────────────────────────────────────────────
it('B: onClicked while not recording triggers startVideoCapture (sends 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');
// Synthetically connect an offscreen port + signal OFFSCREEN_READY so
// startVideoCapture's await offscreenReady resolves.
const port = makePortStub();
if (stub.runtime.onConnect._callbacks.length > 0) {
stub.runtime.onConnect._callbacks[0](port);
}
// Fire OFFSCREEN_READY via the onMessage callback so the readiness
// Promise resolves.
const onMsgHandler = stub.runtime.onMessage._callbacks[0];
onMsgHandler({ type: 'OFFSCREEN_READY' }, { id: 'ext-id-test' }, vi.fn());
const cb = stub.action.onClicked._callbacks[0];
cb();
// Drain microtasks so startVideoCapture's await chain reaches sendMessage.
for (let i = 0; i < 32; i++) await Promise.resolve();
// The handler should have triggered a START_RECORDING sendMessage.
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 C — onClicked twice without intermediate state-clear does not
// double-start (defensive guard test). After the first onClicked fires
// and isRecording flips true, a second onClicked must NOT trigger a
// second startVideoCapture.
// ──────────────────────────────────────────────────────────────────────
it('C: onClicked while isRecording is true does NOT double-start', 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 port = makePortStub();
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());
const cb = stub.action.onClicked._callbacks[0];
// First click — should fire START_RECORDING and flip isRecording.
cb();
for (let i = 0; i < 32; i++) await Promise.resolve();
// Second click while already recording — should be a no-op.
cb();
for (let i = 0; i < 32; i++) await Promise.resolve();
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';
},
);
// Exactly ONE START_RECORDING across both clicks.
expect(startCalls.length).toBe(1);
});
// ──────────────────────────────────────────────────────────────────────
// Test D — setPopup state-machine: empty string in OFF (idle) mode, the
// popup html path in REC mode. Verified via setPopup spy call args.
// ──────────────────────────────────────────────────────────────────────
it('D: setPopup is called with "" in OFF mode and with popup html path in REC mode', 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');
// After init the SW should have entered idle mode → setPopup('').
for (let i = 0; i < 8; i++) await Promise.resolve();
const offCalls = stub.action.setPopup.mock.calls.filter(
(args: unknown[]) => {
const opts = args[0] as { popup?: unknown };
return typeof opts === 'object' && opts !== null && opts.popup === '';
},
);
expect(offCalls.length).toBeGreaterThanOrEqual(1);
// Now trigger startVideoCapture via onClicked; this should set the
// popup back to the html path (REC mode).
const port = makePortStub();
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());
const cb = stub.action.onClicked._callbacks[0];
cb();
for (let i = 0; i < 32; i++) await Promise.resolve();
const recCalls = stub.action.setPopup.mock.calls.filter(
(args: unknown[]) => {
const opts = args[0] as { popup?: unknown };
return (
typeof opts === 'object' &&
opts !== null &&
typeof opts.popup === 'string' &&
(opts.popup as string).includes('popup/index.html')
);
},
);
expect(recCalls.length).toBeGreaterThanOrEqual(1);
});
});
// ────────────────────────────────────────────────────────────────────────
// Test E — popup-loads-when-idle race pin (W-02 fix).
//
// Node-env-friendly: jsdom is not installed; instead we manually stub
// the document/event surface the popup module touches at init time.
// The popup uses: getElementById('saveButton'/'statusMessage'),
// querySelector('.button-text'), addEventListener (button + document).
// ────────────────────────────────────────────────────────────────────────
interface ElementStub {
textContent: string;
className: string;
disabled?: boolean;
querySelector?: (sel: string) => ElementStub | null;
addEventListener?: (ev: string, cb: () => void) => void;
}
interface DocumentStub {
getElementById: (id: string) => ElementStub | null;
addEventListener: (ev: string, cb: () => void) => void;
_docListeners: Map<string, Array<() => void>>;
querySelector: (sel: string) => ElementStub | null;
querySelectorAll: (sel: string) => ElementStub[];
}
function buildDocumentStub(): {
doc: DocumentStub;
saveBtn: ElementStub;
statusMsg: ElementStub;
buttonText: ElementStub;
} {
const buttonText: ElementStub = {
textContent: '',
className: '',
};
const saveBtn: ElementStub = {
textContent: '',
className: '',
disabled: true,
querySelector: (sel: string) => (sel === '.button-text' ? buttonText : null),
addEventListener: vi.fn(),
};
const statusMsg: ElementStub = {
textContent: '',
className: '',
};
const docListeners = new Map<string, Array<() => void>>();
const doc: DocumentStub = {
getElementById: (id: string) => {
if (id === 'saveButton') return saveBtn;
if (id === 'statusMessage') return statusMsg;
return null;
},
addEventListener: (ev: string, cb: () => void) => {
const list = docListeners.get(ev) ?? [];
list.push(cb);
docListeners.set(ev, list);
},
_docListeners: docListeners,
querySelector: () => null,
// Plan 01-12 Wave 4: populateMksKeys() in src/popup/index.ts uses
// querySelectorAll('[data-mks-key]') to populate i18n copy on init.
// The toolbar-action test loads the popup without a real DOM, so
// returning an empty array safely is the right answer (no data-mks-key
// elements exist in the stubbed doc; populate-loop is a no-op).
querySelectorAll: () => [],
};
return { doc, saveBtn, statusMsg, buttonText };
}
describe('Plan 01-09 Task 3 Test E: popup SAVE-only contract (W-02 fix)', () => {
beforeEach(() => {
vi.resetModules();
});
it('E1+E2: popup init does NOT send REQUEST_PERMISSIONS AND saveButton is enabled', async () => {
const { doc, saveBtn } = buildDocumentStub();
(globalThis as unknown as { document?: unknown }).document = doc;
const sendMessageSpy = vi.fn().mockResolvedValue({ granted: false });
(globalThis as unknown as { chrome?: unknown }).chrome = {
runtime: {
id: 'ext-id-test',
sendMessage: sendMessageSpy,
onMessage: { addListener: vi.fn() },
},
};
await import('../../src/popup/index');
// Fire DOMContentLoaded via the captured doc listeners.
const cbs = doc._docListeners.get('DOMContentLoaded') ?? [];
cbs.forEach((cb) => cb());
// Drain microtasks so any async init paths complete.
for (let i = 0; i < 16; i++) await Promise.resolve();
// E1 — no REQUEST_PERMISSIONS at popup-open.
const reqPermCalls = sendMessageSpy.mock.calls.filter(
(args: unknown[]) => {
const msg = args[0] as { type?: unknown };
return typeof msg === 'object' && msg !== null && msg.type === 'REQUEST_PERMISSIONS';
},
);
expect(reqPermCalls.length).toBe(0);
// E2 — SAVE button enabled regardless of chrome round-trip result.
expect(saveBtn.disabled).toBe(false);
});
});