feat(01-13): wave-3C — A8+A9+A10 GREEN + Bug A canonical regression rewind

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://<ext-id>/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) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 20:07:47 +02:00
parent 6a77967b6c
commit b665919c5f
3 changed files with 460 additions and 22 deletions

View File

@@ -324,30 +324,65 @@ export async function driveA7(page: Page): Promise<AssertionRecord> {
}) 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<AssertionRecord> {
throw new Error(`${WAVE3_STUB_PREFIX} — Wave 3C wires driveA8`);
export async function driveA8(page: Page): Promise<AssertionRecord> {
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<AssertionRecord> {
throw new Error(`${WAVE3_STUB_PREFIX} — Wave 3C wires driveA9`);
export async function driveA9(page: Page): Promise<AssertionRecord> {
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<AssertionRecord> {
throw new Error(`${WAVE3_STUB_PREFIX} — Wave 3C wires driveA10`);
export async function driveA10(page: Page): Promise<AssertionRecord> {
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 ──────────────────────────────── */