// tests/uat/lib/sw.ts — Plan 01-11 harness SW-state helpers. // // IMPLEMENTATION ARCHITECTURE (refined during Task 3 execution): // // The original Plan 01-11 RESEARCH §1 sketch assumed `sw.evaluate(() => // chrome.action.getBadgeText({}))` would work directly against the // service worker via Puppeteer's WebWorker.evaluate. Empirical probes // during Task 3 execution against Puppeteer 25.0.2 + Chrome 148 + // --headless=true revealed two blockers: // 1. Puppeteer's `WebWorker.evaluate` runs in an ISOLATED WORLD that // carries SW globals (clients, registration, ...) but NOT the // extension's full `chrome.*` API surface. `Object.keys(chrome)` // returns `["loadTimes", "csi"]` — the public webpage chrome, // not the extension chrome. // 2. Chrome 148's headless mode aggressively suspends MV3 service // workers; subsequent `swTarget.worker()` calls return // `Protocol error: No target with given id found`. // // The popup page (chrome-extension:///src/popup/index.html) has: // - Full `chrome.*` API access (it's an extension context — same // privileges as the SW for chrome.action, chrome.runtime, // chrome.notifications, chrome.runtime.getManifest, etc.) // - Stable lifetime (it's a regular Page; Puppeteer keeps it alive) // - Natural SW wake-up via message passing (chrome.runtime // .sendMessage from popup wakes the SW for 30s) // // So this module's helpers use a Puppeteer Page handle pointing at // the popup URL — NOT a WebWorker handle. The harness opens the popup // page during setup (tests/uat/lib/launch.ts) and passes it here. // // For SW-isolate-specific state (`globalThis.__mokoshTest` lives in // the SW's globalThis, not the popup's), the SW hook exposes a // `chrome.runtime.onMessage` bridge: the popup sends // `{ type: '__mokoshTestQuery', op: '...' }` messages; the hook // responds with the queried state. Bridge implementation is in // src/test-hooks/sw-hooks.ts; this file invokes it via popup.evaluate // wrapping `chrome.runtime.sendMessage`. // // References: // - Chrome extension pages share chrome.* API: // https://developer.chrome.com/docs/extensions/develop/concepts/popup // - Puppeteer Page.evaluate: https://pptr.dev/api/puppeteer.page.evaluate // - Service worker wake-up on chrome.runtime message: // https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle import type { Page } from 'puppeteer'; /// /** * Structured snapshot of the SW's notification observability state * (Plan 01-11 Task 2 sw-hooks.ts surfaces). Used by assertions 7 + 8 * to verify count-deltas + last-options-shape + id-prefix membership. */ export interface NotificationSnapshot { readonly count: number; readonly lastOptions: chrome.notifications.NotificationOptions | null; readonly ids: ReadonlyArray; } /** * The SW hook's bridge message type. The popup sends one of these * shapes via chrome.runtime.sendMessage; the SW's onMessage handler * (extended by sw-hooks.ts) responds with the queried state. See * src/test-hooks/sw-hooks.ts for the SW-side dispatch. */ interface BridgeQuery { type: '__mokoshTestQuery'; op: | 'snapshot' | 'fire-on-startup' | 'handler-types'; } /** * Get the toolbar badge text. Empty string means OFF or initial state; * 'REC' means recording; 'ERR' means error per Plan 01-09 badge state * machine. * * @param popup - The extension popup page handle (open against * chrome-extension:///src/popup/index.html). * @returns Current badge text. */ export async function getBadgeText(popup: Page): Promise { return await popup.evaluate(async () => await chrome.action.getBadgeText({})); } /** * Get the current popup URL. Empty string means popup is not set * (toolbar click fires onClicked instead). The chrome-extension:// * URL means recording (popup hosts SAVE button). * * @param popup - The extension popup page handle. * @returns Current popup URL (full chrome-extension:// form OR ''). */ export async function getPopup(popup: Page): Promise { return await popup.evaluate(async () => await chrome.action.getPopup({})); } /** * Read the runtime manifest. Used by assertion 10 to verify * permissions + icons shape, and by assertion 13 to obtain the * version string for archive shape matching. * * @param popup - The extension popup page handle. * @returns The chrome.runtime.getManifest() result. */ export async function getManifest(popup: Page): Promise { return await popup.evaluate(() => chrome.runtime.getManifest()); } /** * Fetch an extension-relative file via popup context and return its * size in bytes. Used by assertion 9 to verify icon files meet the * size floors that Chrome's imageUtil requires for notifications.create * (Bug A regression class — too-small icon → create rejects). * * @param popup - The extension popup page handle. * @param relativePath - Path under the extension root (e.g. 'icons/icon128.png'). * @returns Byte size on success, -1 on fetch failure. */ export async function getIconSize( popup: Page, relativePath: string, ): Promise { return await popup.evaluate(async (path: string) => { const url = chrome.runtime.getURL(path); const r = await fetch(url); if (!r.ok) { return -1; } const cl = r.headers.get('content-length'); if (cl !== null) { const n = Number(cl); if (Number.isFinite(n) && n > 0) { return n; } } const buf = await r.arrayBuffer(); return buf.byteLength; }, relativePath); } /** * Read whether the SW thinks a recording is active. Side-channeled * through the badge text — 'REC' ↔ recording; '' ↔ idle; 'ERR' ↔ * error state — to avoid needing a dedicated hook field. * * @param popup - The extension popup page handle. * @returns true when badge === 'REC'. */ export async function getIsRecording(popup: Page): Promise { const badge = await getBadgeText(popup); return badge === 'REC'; } /** * Fire the captured chrome.runtime.onStartup handler via the test * hook's chrome.runtime.sendMessage bridge. Used by assertion 8 to * verify the Bug A path (icon-promoted notification fires cleanly). * * Bridge protocol: popup sends `{ type: '__mokoshTestQuery', op: 'fire-on-startup' }`; * SW responds with `{ ok: true }` after invoking the handler, OR * `{ ok: false, error: 'no-handler' }` if the production listener * was never registered (means the SW module init failed — a * different bug class). * * @param popup - The extension popup page handle. * @throws If the bridge response indicates the handler is missing. */ export async function fireOnStartup(popup: Page): Promise { const response = await popup.evaluate(async () => { const msg = { type: '__mokoshTestQuery', op: 'fire-on-startup', }; return new Promise<{ ok: boolean; error?: string }>((resolve) => { chrome.runtime.sendMessage(msg, (r) => { resolve(r as { ok: boolean; error?: string }); }); }); }); if (!response.ok) { throw new Error( `fireOnStartup bridge returned ok=false: ${response.error ?? '(no error message)'}`, ); } } /** * Inject a synthetic RECORDING_ERROR message into the SW's * chrome.runtime.onMessage handler. Used by assertion 7 to verify * the error path is preserved (badge 'ERR' + recovery notification). * Goes through the popup's chrome.runtime.sendMessage — a real * production code path (sw onMessage handler). * * @param popup - The extension popup page handle. * @param errorCode - The error code to inject (e.g. 'codec-unsupported'). */ export async function sendSyntheticRecordingError( popup: Page, errorCode: string, ): Promise { await popup.evaluate(async (code: string) => { await chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: code, }); }, errorCode); } /** * Snapshot the current notification observability state from the SW * hook via the bridge. * * @param popup - The extension popup page handle. * @returns Snapshot — count, last options, ids array. */ export async function getNotificationSnapshot( popup: Page, ): Promise { const response = await popup.evaluate(async () => { const msg = { type: '__mokoshTestQuery', op: 'snapshot' }; return new Promise<{ count: number; lastOptions: chrome.notifications.NotificationOptions | null; ids: string[]; }>((resolve) => { chrome.runtime.sendMessage(msg, (r) => { resolve(r as { count: number; lastOptions: chrome.notifications.NotificationOptions | null; ids: string[]; }); }); }); }); return { count: response.count, lastOptions: response.lastOptions, ids: response.ids, }; } /** * Send a no-op keepalive ping to the SW so Chrome's ~30s idle timer * does not evict the worker during long waits (assertion 11's 35s * recording window). Uses chrome.runtime.sendMessage as the cheapest * wake-up signal; the SW's onMessage handler treats unknown messages * as a warning-log no-op. * * @param popup - The extension popup page handle. */ export async function keepalivePing(popup: Page): Promise { await popup.evaluate(async () => { await chrome.runtime.sendMessage({ type: '__mokoshKeepalive' }); }); } // Re-export the BridgeQuery type for sw-hooks.ts side reference // (the SW hook implements the message dispatch using the same shape). export type { BridgeQuery };