From b665919c5f72b6f7ddb62c77cc40d6cc77c55da8 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 18 May 2026 20:07:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(01-13):=20wave-3C=20=E2=80=94=20A8+A9+A10?= =?UTF-8?q?=20GREEN=20+=20Bug=20A=20canonical=20regression=20rewind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 01-13 Task 6 (Wave 3C). Wires the final three Wave-3 assertions before A11+A12+A13 (Wave 3D — 35s segments / ffprobe / zip shape): - A8 (Bug A canonical regression rewind) — invokes chrome.notifications.create from the harness page with the SAME options the production SW onStartup handler uses (iconUrl resolved via chrome.runtime.getURL('icons/icon128.png')). Exercises Chrome's imageUtil icon validation — the exact code path Bug A regressed on (a881bf0). 4 checks: non-empty assignedId, id-honoring, getAll delta=1, prefix set-membership. The SW handler invocation itself remains covered by tests/background/onstartup-notification.test.ts (unit tier); A8 covers the end-to-end imageUtil-acceptance gate (e2e tier). Per T-1-13-06 threat-model row: unit + e2e are intentional defense in depth covering both halves of the Bug A contract. - A9 (icon file sizes meet imageUtil floors) — fetches icons/icon{16,48, 128}.png via chrome.runtime.getURL and asserts blob.size against the 200/500/1024-byte silent-rejection floors per assets-spec.md. Cheap pre-check for the Bug A class: a future icon swap that drops below the floor would silently break the notification flow; A9 catches it BEFORE the SW even tries to create. - A10 (manifest shape contract) — chrome.runtime.getManifest() asserts: permissions includes 'notifications' (without it, chrome.notifications.create is unreachable), icons['16/48/128'] defined + non-empty, action.default_icon['16/48/128'] same. 7 checks total. Catches manifest-edit regressions that would silently break A8. Bug A canonical RED-on-regression demo cycle ============================================ Regression trigger: head -c 50 /tmp/icon128.png.backup > icons/icon128.png (truncates the 2615-byte PNG to 50 bytes — preserves PNG magic so manifest loads, but Chrome's imageUtil silent-rejects the create). RED — A8 standalone driver with truncated icon128.png (50 bytes): A8 — BUG A canonical: chrome.notifications.create accepts startup-icon (imageUtil contract): FAIL Top-level error: notifications.create rejected: Unable to download all specified images. Diagnostics: - Step 1: snapshot notif count + ids BEFORE create - Step 1 result: 0 active; ids=[] - Step 2: chrome.notifications.create(id='mokosh-startup-1779124969677', iconUrl='chrome-extension:///icons/icon128.png') - THREW: notifications.create rejected: Unable to download all specified images. GREEN — A8 standalone driver after restoring icon128.png (2615 bytes): A8 — BUG A canonical: chrome.notifications.create accepts startup-icon (imageUtil contract): PASS Checks: [PASS] A8.1: create callback resolves with non-empty assignedId (imageUtil acceptance) expected: "non-empty string" actual: "mokosh-startup-1779124999809" [PASS] A8.2: assignedId matches input id (chrome.notifications honors caller-supplied id) expected: "mokosh-startup-1779124999809" actual: "mokosh-startup-1779124999809" [PASS] A8.3: notification count delta === 1 (exactly one new startup notification) expected: 1 actual: 1 [PASS] A8.4: at least one notification id startsWith 'mokosh-startup-' (set membership) expected: true actual: true The RED→GREEN cycle proves the harness empirically catches Bug A regression class (imageUtil silent rejection on undersized iconUrl PNG). The "Unable to download all specified images." rejection is Chrome's internal error surface for the same imageUtil validation that Bug A originally regressed on (fix at a881bf0). Note: under the full orchestrator order, the same truncation surfaces FIRST at A7 (recovery notification, which shares NOTIFICATION_ICON_PATH) — orchestrator bail-on-first-failure means A8 isn't reached in the full run. The isolated A8 demo above (via an ephemeral local driver script, NOT committed) confirmed A8 catches the same regression independently. Baseline preserved ================== - vitest: 93/93 GREEN (SKIP_BUILD=1 to dodge the pre-existing ~5s-default test timeout in no-test-hooks-in-prod-bundle.test.ts; with a fresh dist/ in place all 9 hook-string sub-tests PASS). - tsc: clean (no diagnostics). - npm run build: exit 0; production bundle unchanged (no SW/offscreen src edits — only tests/ + dist-test/). - npm run test:uat: 11/14 GREEN (A0+A1+A2+A3+A4+A5+A6+A7+A8+A9+A10); bails at A11 (Wave 3D wires that). Files touched ============= - tests/uat/extension-page-harness.ts: +assertA8 +assertA9 +assertA10 with 4 + 3 + 7 checks respectively; +createNotificationPromise + getActiveNotificationIds + STARTUP_NOTIF_PREFIX + A8_GETALL_SETTLE_MS + A9_ICON_SPEC helpers. window.__mokoshHarness extends 7 → 10 methods. - tests/uat/lib/harness-page-driver.ts: replaces driveA8/driveA9/driveA10 NYI stubs with page.evaluate wrappers. - tests/uat/harness.test.ts: updates Wave-3C-current comment block to reflect A8+A9+A10 wired (expected diagnostic 11/14, bail at A11). Approach rationale (per plan resolved-questions §A8) ==================================================== The plan resolved A8's "no SW-side handler-capture hook" challenge with an explicit SIMPLER WORKAROUND: invoke chrome.notifications.create DIRECTLY from the harness page with the same production options. This sidesteps the MV3-SW-dynamic-import block (01-11-SUMMARY) while still exercising Chrome's imageUtil validation — the exact code path Bug A broke. Approach considered but rejected per the plan: a SW-side static eager-import test hook + a __mokoshTriggerStartup message handler would have required adding a new production code path (even gated by __MOKOSH_UAT__) and a new FORBIDDEN_HOOK_STRINGS entry. The page-direct approach adds ZERO production surface and ZERO new forbidden strings — strictly better. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/uat/extension-page-harness.ts | 404 ++++++++++++++++++++++++++- tests/uat/harness.test.ts | 17 +- tests/uat/lib/harness-page-driver.ts | 61 +++- 3 files changed, 460 insertions(+), 22 deletions(-) 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 ──────────────────────────────── */