Milestone v1 (v2.0.0): Mokosh — Session Capture #1

Merged
strategy155 merged 297 commits from gsd/phase-04-harden-clean-up-optional into main 2026-05-31 15:34:17 +00:00
3 changed files with 460 additions and 22 deletions
Showing only changes of commit b665919c5f - Show all commits

View File

@@ -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<AssertionResult> {
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<true>,
): Promise<string> {
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<ReadonlyArray<string>> {
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<AssertionResult> {
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://<id>/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<true> = {
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 === '' ? '<empty (Bug A signature: imageUtil silent reject)>' : 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://<id>/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<AssertionResult> {
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<AssertionResult> {
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<string, string>` 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<string, string | undefined>;
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<string, string | undefined>)
: {};
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<AssertionResult>;
assertA6: () => Promise<AssertionResult>;
assertA7: () => Promise<AssertionResult>;
assertA8: () => Promise<AssertionResult>;
assertA9: () => Promise<AssertionResult>;
assertA10: () => Promise<AssertionResult>;
};
}
}
@@ -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 {};

View File

@@ -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<number> {
// 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');

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 ──────────────────────────────── */