diff --git a/tests/uat/extension-page-harness.ts b/tests/uat/extension-page-harness.ts index 54ed542..96cb07d 100644 --- a/tests/uat/extension-page-harness.ts +++ b/tests/uat/extension-page-harness.ts @@ -84,6 +84,44 @@ // "most recent" check — `chrome.notifications.getAll` does // not guarantee key ordering (set membership is reliable; // ordering is not). +// +// Wave 3C surface — extends `window.__mokoshHarness` from 7 → 10 methods: +// - `assertA8()` — Bug A canonical regression rewind: invoke +// `chrome.notifications.create` from the page with the +// SAME options the production SW `onStartup` handler +// uses (iconUrl: chrome.runtime.getURL('icons/icon128.png'), +// title:'Mokosh ready', etc). Bug A was Chrome's +// `imageUtil` silently rejecting the create when the +// icon was too small / missing — exercising the same +// create code-path from the page covers the same +// icon-acceptance contract WITHOUT needing a SW-side +// hook (the SW onStartup handler invocation itself is +// covered by `tests/background/onstartup-notification.test.ts`). +// Asserts: create resolves with a non-empty id, the id +// has the 'mokosh-startup-' prefix, and the +// notification appears in `chrome.notifications.getAll` +// (count delta === 1, set-membership on the prefix). +// Plan 01-13 Task 6 §behavior + §implementation pin this +// approach explicitly (workaround documented at length +// there); architectural rationale + Bug A history at +// a881bf0 (the original fix commit). +// - `assertA9()` — icon files meet Chrome `imageUtil`'s silent-rejection +// floors per assets-spec.md / Plan 01-13 Task 6 +// behavior: 16→≥200 B, 48→≥500 B, 128→≥1024 B. Fetches +// `chrome.runtime.getURL('icons/icon{16,48,128}.png')` +// and reads `blob.size`. Regression target: a future +// icon swap that drops below the floor would silently +// break the onStartup / recovery notification flow +// (Bug A class). +// - `assertA10()` — manifest shape contract: reads +// `chrome.runtime.getManifest()` and asserts the three +// surfaces that A8 + the SW notification flow depend on: +// `permissions` includes 'notifications', `icons` has +// keys '16'/'48'/'128', `action.default_icon` has the +// same three keys. Regression target: a future manifest +// edit that drops `notifications` (notifications.create +// silently fails) or removes an icon entry (manifest +// parser rejects). /** * Result shape returned by harness assertions to Puppeteer. @@ -970,6 +1008,362 @@ async function assertA7(): Promise { return result; } +/* ─── Wave 3C — A8 + A9 + A10 ─────────────────────────────────────── */ + +/** Startup-notification id prefix — must stay in sync with + * `src/background/index.ts:NOTIFICATION_STARTUP_PREFIX`. A8 stamps a + * fresh id (prefix + Date.now()) when invoking + * chrome.notifications.create from the page; the assertion then + * checks that the same id (a) was returned by the create callback, + * and (b) is present in the post-create getAll() snapshot. */ +const STARTUP_NOTIF_PREFIX = 'mokosh-startup-'; + +/** Time in ms to wait after chrome.notifications.create resolves for + * the OS-level notification to land in `chrome.notifications.getAll`. + * The create callback fires AFTER Chrome's imageUtil validates the + * iconUrl + the OS surface displays the notification; getAll + * observability is effectively synchronous in practice but a small + * buffer (200ms) tolerates any future Chrome-internal scheduling. */ +const A8_GETALL_SETTLE_MS = 200; + +/** Per-icon size floors enforced by A9 — Chrome's `imageUtil` + * silent-rejection thresholds documented in assets-spec.md and Plan + * 01-13 Task 6 behavior. Values: 16→≥200 B, 48→≥500 B, 128→≥1024 B. + * Below these floors, Chrome's image decoder treats the PNG as + * invalid for chrome.notifications.create's iconUrl param — the + * create call silently fails (no error to the callback, no + * notification rendered). Bug A's original failure mode. */ +const A9_ICON_SPEC: ReadonlyArray<{ readonly size: 16 | 48 | 128; readonly floorBytes: number }> = [ + { size: 16, floorBytes: 200 }, + { size: 48, floorBytes: 500 }, + { size: 128, floorBytes: 1024 }, +]; + +/** Wrap chrome.notifications.create in a Promise. The create API uses + * a callback; we surface chrome.runtime.lastError as a rejection so + * the harness's try/catch picks it up. A silently-rejected create + * (Bug A class) resolves with an empty string id and NO lastError — + * the assertion handles that case by checking id !== '' downstream. + * + * @param id - Notification id (caller-supplied; we stamp prefix + Date.now()). + * @param options - chrome.notifications.create's NotificationOptions. + * @returns The id Chrome assigned (== input id on the happy path; '' on silent reject). + */ +async function createNotificationPromise( + id: string, + options: chrome.notifications.NotificationOptions, +): Promise { + return new Promise((resolve, reject) => { + chrome.notifications.create(id, options, (assignedId: string) => { + if (chrome.runtime.lastError !== undefined) { + reject(new Error(String(chrome.runtime.lastError.message))); + return; + } + resolve(assignedId); + }); + }); +} + +/** Read the set of active notification ids via chrome.notifications.getAll. + * Returns a sorted array for deterministic diagnostics; callers do + * set-membership checks (key ordering of getAll is NOT contractually + * stable per the API docs — set semantics are the reliable check). */ +async function getActiveNotificationIds(): Promise> { + return new Promise((resolve, reject) => { + chrome.notifications.getAll((notifications: Object) => { + if (chrome.runtime.lastError !== undefined) { + reject(new Error(String(chrome.runtime.lastError.message))); + return; + } + const ids = Object.keys(notifications ?? {}); + ids.sort(); + resolve(ids); + }); + }); +} + +/** + * A8 — Bug A canonical regression rewind. Exercises the same + * `chrome.notifications.create` code path the production SW + * `onStartup` handler runs (src/background/index.ts:894-912). Bug A + * was Chrome's `imageUtil` silently rejecting the create when + * `iconUrl` pointed at an undersized/missing PNG; the same + * imageUtil validation runs whether the caller is the SW or the + * harness page — calling from the page avoids the SW-hook problem + * (no SW-side dynamic import allowed per 01-11-SUMMARY) while + * covering the regression contract end-to-end. + * + * Workaround caveat (documented in Plan 01-13 Task 6 behavior + the + * threat-model row T-1-13-06): this verifies Chrome's imageUtil + * accepts the icon, NOT that the SW onStartup handler runs. The + * SW-handler-invocation gate is `tests/background/onstartup-notification.test.ts` + * (unit test). Together the two cover both halves of the Bug A + * regression contract (unit: handler is wired + dispatches; e2e: + * Chrome's imageUtil accepts the produced iconUrl + notification + * surfaces in getAll). + * + * Post-conditions verified: + * 1. create callback resolves with a non-empty id (silent rejection + * produces '' — the Bug A failure signature) + * 2. the returned id matches the input id (prefix + Date.now() stamp) + * 3. getAll() count delta === 1 (exactly one new notification) + * 4. one notification in getAll() has the 'mokosh-startup-' prefix + * (set-membership — getAll ordering is not contractually stable) + * + * @returns Structured result with 4 post-create checks. + */ +async function assertA8(): Promise { + const result: AssertionResult = { + passed: false, + name: 'A8 — BUG A canonical: chrome.notifications.create accepts startup-icon (imageUtil contract)', + checks: [], + diagnostics: [], + }; + + try { + diag(result, 'Step 1: snapshot notif count + ids BEFORE create'); + const idsBefore = await getActiveNotificationIds(); + diag(result, `Step 1 result: ${idsBefore.length} active; ids=${JSON.stringify(idsBefore)}`); + + // Mirror the production SW onStartup handler's options shape + // (src/background/index.ts:898-905). iconUrl resolved via + // chrome.runtime.getURL so the extension-scoped path becomes a + // chrome-extension:///icons/icon128.png URL that imageUtil + // can fetch + decode. The `priority: 1` matches production — + // not load-bearing for the imageUtil contract but kept for + // fidelity (so any future imageUtil refactor that varies behaviour + // by priority still covers the production call shape). + const inputId = STARTUP_NOTIF_PREFIX + Date.now(); + const options: chrome.notifications.NotificationOptions = { + type: 'basic', + iconUrl: chrome.runtime.getURL('icons/icon128.png'), + title: 'Mokosh ready', + message: 'Click here to start recording your session.', + priority: 1, + }; + diag(result, `Step 2: chrome.notifications.create(id='${inputId}', iconUrl='${options.iconUrl}')`); + let assignedId: string; + try { + assignedId = await createNotificationPromise(inputId, options); + } catch (createErr) { + const errMsg = createErr instanceof Error ? createErr.message : String(createErr); + throw new Error(`notifications.create rejected: ${errMsg}`); + } + diag(result, `Step 2 result: assignedId='${assignedId}'`); + + diag(result, `Step 3: settle ${A8_GETALL_SETTLE_MS}ms before getAll`); + await new Promise((r) => setTimeout(r, A8_GETALL_SETTLE_MS)); + + diag(result, 'Step 4: snapshot notif count + ids AFTER create'); + const idsAfter = await getActiveNotificationIds(); + diag(result, `Step 4 result: ${idsAfter.length} active; ids=${JSON.stringify(idsAfter)}`); + const delta = idsAfter.length - idsBefore.length; + const startupIdPresent = idsAfter.some((id) => id.startsWith(STARTUP_NOTIF_PREFIX)); + + result.checks.push({ + // The decisive imageUtil-acceptance check: a silent rejection (Bug A + // regression class) produces `assignedId === ''` per the Chrome + // notifications API contract — the callback still fires, but with + // an empty id and no lastError. Asserting non-empty AND matching + // the input id catches both classes (silent reject AND any future + // id-mangling regression). + name: 'A8.1: create callback resolves with non-empty assignedId (imageUtil acceptance)', + expected: 'non-empty string', + actual: assignedId === '' ? '' : assignedId, + passed: assignedId !== '', + }); + result.checks.push({ + name: 'A8.2: assignedId matches input id (chrome.notifications honors caller-supplied id)', + expected: inputId, + actual: assignedId, + passed: assignedId === inputId, + }); + result.checks.push({ + name: 'A8.3: notification count delta === 1 (exactly one new startup notification)', + expected: 1, + actual: delta, + passed: delta === 1, + }); + result.checks.push({ + name: `A8.4: at least one notification id startsWith '${STARTUP_NOTIF_PREFIX}' (set membership)`, + expected: true, + actual: startupIdPresent, + passed: startupIdPresent === true, + }); + + result.passed = result.checks.every((c) => c.passed); + } catch (err) { + result.error = err instanceof Error ? err.message : String(err); + diag(result, `THREW: ${result.error}`); + } + + return result; +} + +/** + * A9 — icon file sizes meet Chrome `imageUtil` silent-rejection floors. + * Fetches each icon via `chrome.runtime.getURL` (resolves to + * `chrome-extension:///icons/icon{N}.png`) and reads `blob.size`. + * Floors per Plan 01-13 Task 6 behavior (and the project-wide + * assets-spec): 16→≥200 B, 48→≥500 B, 128→≥1024 B. + * + * Regression target: a future icon swap (e.g. an over-aggressive + * SVG-to-PNG export that produces a 50-byte placeholder) would silently + * break the onStartup / recovery notification flow. A9 catches that + * BEFORE the SW even tries to create — same Bug A class, different + * tier of defense. + * + * @returns Structured result with one check per icon (3 checks total). + */ +async function assertA9(): Promise { + const result: AssertionResult = { + passed: false, + name: 'A9 — icon files meet imageUtil size floors (16≥200B, 48≥500B, 128≥1024B)', + checks: [], + diagnostics: [], + }; + + try { + for (const { size, floorBytes } of A9_ICON_SPEC) { + const url = chrome.runtime.getURL(`icons/icon${size}.png`); + diag(result, `Step: fetch ${url}`); + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `fetch ${url} returned HTTP ${response.status} ${response.statusText}`, + ); + } + const blob = await response.blob(); + const actualBytes = blob.size; + diag(result, `Step result: icon${size}.png size=${actualBytes}B (floor=${floorBytes}B)`); + result.checks.push({ + name: `A9.${size}: icons/icon${size}.png size >= ${floorBytes} bytes (imageUtil floor)`, + expected: `>= ${floorBytes}`, + actual: actualBytes, + passed: actualBytes >= floorBytes, + }); + } + + result.passed = result.checks.every((c) => c.passed); + } catch (err) { + result.error = err instanceof Error ? err.message : String(err); + diag(result, `THREW: ${result.error}`); + } + + return result; +} + +/** + * A10 — manifest shape. Reads `chrome.runtime.getManifest()` and + * asserts the three surfaces A8 + the SW notification flow depend on: + * 1. `permissions` array includes 'notifications' (without this + * permission, chrome.notifications.create is undefined or throws + * 'no permission' silently — Bug A precondition). + * 2. `icons` map has keys '16' / '48' / '128' (manifest parser + * requires at least one but the production flow uses all three). + * 3. `action.default_icon` map has keys '16' / '48' / '128' (toolbar + * icon rendering at all three densities). + * + * Regression target: a future manifest edit that drops `notifications` + * (would make A8 fail at create time) or removes an icon entry (would + * make A9 fail at fetch time + break manifest parsing on some Chrome + * channels). A10 is the cheap, deterministic guard for those classes. + * + * @returns Structured result with manifest-shape checks. + */ +async function assertA10(): Promise { + const result: AssertionResult = { + passed: false, + name: 'A10 — manifest shape: notifications permission + 16/48/128 icons + action.default_icon', + checks: [], + diagnostics: [], + }; + + try { + diag(result, 'Step 1: chrome.runtime.getManifest()'); + const manifest = chrome.runtime.getManifest(); + diag( + result, + `Step 1 result: manifest_version=${manifest.manifest_version} version=${manifest.version}`, + ); + + const permissions = manifest.permissions ?? []; + const hasNotifications = permissions.includes('notifications'); + diag(result, `Step 2: permissions=${JSON.stringify(permissions)}`); + + // The chrome.* typings model `icons` as `Record` keyed + // by stringified pixel sizes ('16', '48', etc.). Use bracket access + + // truthy check rather than .hasOwnProperty so a defined-but-empty-string + // value (a different regression class — manifest parser would normally + // reject it but defense in depth) also fails the contract. + const icons = (manifest.icons ?? {}) as Record; + const icon16Present = typeof icons['16'] === 'string' && icons['16']!.length > 0; + const icon48Present = typeof icons['48'] === 'string' && icons['48']!.length > 0; + const icon128Present = typeof icons['128'] === 'string' && icons['128']!.length > 0; + diag(result, `Step 3: icons=${JSON.stringify(icons)}`); + + const action = manifest.action ?? {}; + const defaultIcon = + typeof action.default_icon === 'object' && action.default_icon !== null + ? (action.default_icon as Record) + : {}; + const di16Present = typeof defaultIcon['16'] === 'string' && defaultIcon['16']!.length > 0; + const di48Present = typeof defaultIcon['48'] === 'string' && defaultIcon['48']!.length > 0; + const di128Present = typeof defaultIcon['128'] === 'string' && defaultIcon['128']!.length > 0; + diag(result, `Step 4: action.default_icon=${JSON.stringify(defaultIcon)}`); + + result.checks.push({ + name: "A10.1: permissions includes 'notifications' (chrome.notifications.create reachable)", + expected: true, + actual: hasNotifications, + passed: hasNotifications === true, + }); + result.checks.push({ + name: "A10.2a: icons['16'] defined + non-empty", + expected: true, + actual: icon16Present, + passed: icon16Present === true, + }); + result.checks.push({ + name: "A10.2b: icons['48'] defined + non-empty", + expected: true, + actual: icon48Present, + passed: icon48Present === true, + }); + result.checks.push({ + name: "A10.2c: icons['128'] defined + non-empty", + expected: true, + actual: icon128Present, + passed: icon128Present === true, + }); + result.checks.push({ + name: "A10.3a: action.default_icon['16'] defined + non-empty", + expected: true, + actual: di16Present, + passed: di16Present === true, + }); + result.checks.push({ + name: "A10.3b: action.default_icon['48'] defined + non-empty", + expected: true, + actual: di48Present, + passed: di48Present === true, + }); + result.checks.push({ + name: "A10.3c: action.default_icon['128'] defined + non-empty", + expected: true, + actual: di128Present, + passed: di128Present === true, + }); + + result.passed = result.checks.every((c) => c.passed); + } catch (err) { + result.error = err instanceof Error ? err.message : String(err); + diag(result, `THREW: ${result.error}`); + } + + return result; +} + // Install the global harness surface. declare global { interface Window { @@ -981,6 +1375,9 @@ declare global { assertA5: () => Promise; assertA6: () => Promise; assertA7: () => Promise; + assertA8: () => Promise; + assertA9: () => Promise; + assertA10: () => Promise; }; } } @@ -993,13 +1390,16 @@ window.__mokoshHarness = { assertA5, assertA6, assertA7, + assertA8, + assertA9, + assertA10, }; const statusEl = document.getElementById('status'); if (statusEl !== null) { - statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1, assertA2, assertA3, assertA4, assertA5, assertA6, assertA7} available.'; + statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1, assertA2, assertA3, assertA4, assertA5, assertA6, assertA7, assertA8, assertA9, assertA10} available.'; } -console.log('[harness-page] ready — window.__mokoshHarness installed (Wave 3B: A1+A2+A3+A4+A5+A6+A7)'); +console.log('[harness-page] ready — window.__mokoshHarness installed (Wave 3C: A1+A2+A3+A4+A5+A6+A7+A8+A9+A10)'); export {}; diff --git a/tests/uat/harness.test.ts b/tests/uat/harness.test.ts index 25f135f..502b9b9 100644 --- a/tests/uat/harness.test.ts +++ b/tests/uat/harness.test.ts @@ -10,10 +10,13 @@ // from `tests/uat/lib/harness-page-driver.ts`; the bail-on-first-failure // loop stops at the first such throw. // -// Wave 3B (this file's current state) wires A5 (SAVE_ARCHIVE → zip on -// disk) + A7 (genuine RECORDING_ERROR → ERR + recovery notification). -// Expected diagnostic: "8/14 GREEN: A0+A1+A2+A3+A4+A5+A6+A7; bail at A8". -// Wave 3C will wire A8+A9+A10; Wave 3D wires A11+A12+A13 for 14/14 GREEN. +// Wave 3B wires A5 (SAVE_ARCHIVE → zip on disk) + A7 (genuine +// RECORDING_ERROR → ERR + recovery notification). Wave 3C (this file's +// current state) wires A8 (Bug A canonical onStartup-notification +// regression rewind) + A9 (icon file sizes meet imageUtil floors) + +// A10 (manifest shape contract). Expected diagnostic: +// "11/14 GREEN: A0+A1+A2+A3+A4+A5+A6+A7+A8+A9+A10; bail at A11". +// Wave 3D wires A11+A12+A13 for 14/14 GREEN. // // The orchestrator structure is final from Wave 3A onward; future waves // only fill in the assertion-driver stubs. @@ -247,9 +250,9 @@ async function main(): Promise { // recording), then dispatch-ended. After A6 the recording is torn // down — A7+ would need to re-start or test post-stop state. // - // Wave 3B wires A5 + A7 in addition to A1..A4 + A6 — bail-on-first- - // failure stops at A8 (Wave 3C wires that). Expected diagnostic: - // "8/14 GREEN: A0+A1+A2+A3+A4+A5+A6+A7; A8..A13 NOT YET IMPLEMENTED". + // Wave 3C wires A8 + A9 + A10 in addition to A1..A7 — bail-on-first- + // failure stops at A11 (Wave 3D wires that). Expected diagnostic: + // "11/14 GREEN: A0+A1+A2+A3+A4+A5+A6+A7+A8+A9+A10; A11..A13 NOT YET IMPLEMENTED". // The standalone `npx tsx tests/uat/a6.test.ts` entry remains the // way to verify A6 in isolation for inner-loop iteration. process.stdout.write('Launching Chrome + opening harness page...\n'); diff --git a/tests/uat/lib/harness-page-driver.ts b/tests/uat/lib/harness-page-driver.ts index dead7b6..c8eab30 100644 --- a/tests/uat/lib/harness-page-driver.ts +++ b/tests/uat/lib/harness-page-driver.ts @@ -324,30 +324,65 @@ export async function driveA7(page: Page): Promise { }) as AssertionRecord; } -/* ─── Wave 3C — NOT YET IMPLEMENTED ──────────────────────────────── */ +/* ─── Wave 3C — WIRED ─────────────────────────────────────────────── */ /** - * Drive A8 (Bug A onStartup → notification creates). Wave 3C wires. - * @throws Always — replace stub when Wave 3C lands. + * Drive A8 (Bug A canonical regression rewind — onStartup notification + * creates). Standard page.evaluate wrapper — all orchestration + * (chrome.notifications.create dispatch + getAll snapshot + delta + + * set-membership check) happens page-side. The page calls + * chrome.notifications.create with the SAME options the SW onStartup + * handler uses (icon path, title, message), so the assertion exercises + * the same Chrome `imageUtil` validation that Bug A regressed against + * — without needing a SW-side hook (forbidden under Approach B per + * 01-11-SUMMARY: no dynamic import in MV3 SW). + * + * @param page - The harness page from `launchHarnessBrowser`. + * @returns Structured AssertionRecord with 4 checks (A8.1..A8.4). */ -export async function driveA8(_page: Page): Promise { - throw new Error(`${WAVE3_STUB_PREFIX} — Wave 3C wires driveA8`); +export async function driveA8(page: Page): Promise { + return await page.evaluate(async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose. + const harness = (window as any).__mokoshHarness; + const r: AssertionRecord = await harness.assertA8(); + return r; + }) as AssertionRecord; } /** - * Drive A9 (icon file sizes). Wave 3C wires. - * @throws Always — replace stub when Wave 3C lands. + * Drive A9 (icon file sizes meet `imageUtil` floors). Standard + * page.evaluate wrapper — the page fetches each icon via + * chrome.runtime.getURL + reads blob.size and verifies against the + * 200/500/1024-byte floors per assets-spec.md / Plan 01-13 Task 6. + * + * @param page - The harness page from `launchHarnessBrowser`. + * @returns Structured AssertionRecord with 3 checks (one per icon size). */ -export async function driveA9(_page: Page): Promise { - throw new Error(`${WAVE3_STUB_PREFIX} — Wave 3C wires driveA9`); +export async function driveA9(page: Page): Promise { + return await page.evaluate(async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose. + const harness = (window as any).__mokoshHarness; + const r: AssertionRecord = await harness.assertA9(); + return r; + }) as AssertionRecord; } /** - * Drive A10 (manifest shape). Wave 3C wires. - * @throws Always — replace stub when Wave 3C lands. + * Drive A10 (manifest shape contract). Standard page.evaluate wrapper — + * the page reads chrome.runtime.getManifest() and verifies the + * notifications permission + icons{16,48,128} + action.default_icon{16,48,128} + * surfaces that A8 + the SW notification flow depend on. + * + * @param page - The harness page from `launchHarnessBrowser`. + * @returns Structured AssertionRecord with 7 checks (1 permissions + 3 icons + 3 default_icon). */ -export async function driveA10(_page: Page): Promise { - throw new Error(`${WAVE3_STUB_PREFIX} — Wave 3C wires driveA10`); +export async function driveA10(page: Page): Promise { + return await page.evaluate(async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose. + const harness = (window as any).__mokoshHarness; + const r: AssertionRecord = await harness.assertA10(); + return r; + }) as AssertionRecord; } /* ─── Wave 3D — NOT YET IMPLEMENTED ──────────────────────────────── */