feat(01-11): wave-2 — Puppeteer harness scaffolding + A0 GREEN, popup-bridge architecture

Task 3 of Plan 01-11 (Puppeteer UAT harness).

Harness file tree (tests/uat/):
- harness.test.ts: tsx-runnable top-to-bottom harness entry point.
  Runs A0 inline (filesystem grep gate, abort-on-fail T-1-11-01),
  then launches Chrome + opens popup bridge + queries manifest, then
  iterates A1-A13 stubs. Each stub throws "NOT YET IMPLEMENTED —
  Plan 01-11 Task N wires this assertion". Exit code = 0 on full
  pass, 1 otherwise. Final line: "UAT harness: N/14 assertions passed".
- lib/launch.ts: launchHarnessBrowser() — wraps puppeteer.launch with
  enableExtensions:[dist-test/], headless default (HEADLESS=0
  override), --no-sandbox + --auto-select-desktop-capture-source flags.
  Polls browser.extensions() until the extension registers (empirically
  ~100ms but the first call right after launch returns Map(0)).
  Opens both a blank page (for triggerExtensionAction) AND the popup
  page (the bridge surface). Returns { browser, extension, extensionId,
  sw, downloadsDir, page, popup }.
- lib/extension.ts: waitForOffscreenTarget + attachToOffscreen +
  countOffscreenTargets. Offscreen attach uses target.type() ===
  'background_page' + .asPage() (NOT .page() — RESEARCH §4 Pitfall 1).
- lib/sw.ts: chrome.* state queries via the POPUP page handle (NOT
  the WebWorker handle — see architecture note below). getBadgeText,
  getPopup, getManifest, getIconSize, getIsRecording (side-channeled
  through badge text), fireOnStartup (via __mokoshTestQuery bridge),
  sendSyntheticRecordingError, getNotificationSnapshot (via bridge),
  keepalivePing (no-op message to wake SW for ~30s).
- lib/offscreen.ts: getDisplaySurface, simulateUserStop (the
  dispatchEvent('ended') path per RESEARCH §7 BLOCKER — DO NOT REFACTOR
  to track.stop()), getSegmentCount.
- lib/assertions.ts: runAssertion(idx, name, buffers, fn) wrapper —
  records pass/fail/duration; on failure dumps last 30 lines of SW
  + offscreen console buffers to stderr before rethrowing. assertEqual
  / assertMatch / assertTrue / assertGte / waitFor polling helper.
- lib/zip.ts: jszip-based assertArchiveShape + extractEntryToFile for
  assertions 12 + 13.
- README.md: runtime + local-debug + CI semantics + locale gotcha
  + dev-dep size note + assertion catalog table.
- tsconfig.json: per-tree type-check config (mirrors root tsconfig.json
  compiler options but includes the harness tree explicitly).

Architecture refinement (DEVIATION from RESEARCH §1 — Rule 1+3 inline fix):
- RESEARCH §1 sketched `sw.evaluate(() => chrome.action.getBadgeText({}))`
  as the chrome.* query path. 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) inside
       sw.evaluate 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".
- WORKAROUND: open the popup page (chrome-extension://<id>/src/popup/
  index.html) as a separate Puppeteer Page. The popup has full
  chrome.* access (it's an extension context with same privileges as
  the SW) AND stable Puppeteer lifetime. For SW-globalThis state
  (__mokoshTest in the SW isolate, NOT in the popup), bridge via
  chrome.runtime.sendMessage. The popup sends
  { type: '__mokoshTestQuery', op: 'snapshot' | 'fire-on-startup' |
  'handler-types' }; the SW hook's onMessage handler responds.
- Bridge implementation added to src/test-hooks/sw-hooks.ts — registers
  AFTER the production listeners so it never intercepts production
  messages (__mokoshTest* type is unambiguously test-only). Tier-1
  grep gate (no-test-hooks-in-prod-bundle.test.ts) continues to enforce
  ZERO __mokoshTest occurrences in dist/ — the bridge handler is
  tree-shaken alongside the rest of the hook module via the
  __MOKOSH_UAT__ gate.

Other configuration changes:
- vitest.config.ts: exclude tests/uat/** from vitest discovery. The
  Puppeteer harness is invoked via `npm run test:uat` (not vitest);
  running it under vitest would try to launch real Chrome inside a
  vitest worker. The .test.ts suffix is retained for editor +
  naming-convention consistency with the rest of the tree.

Verification:
- npx tsc --noEmit (src/): exit 0
- npx tsc --noEmit -p tests/uat: exit 0
- npm run build: exit 0
- grep -rln '__mokoshTest|simulateUserStop|getSegmentCount|setCurrentStream|setSegmentCountGetter|__mokoshTestQuery|__mokoshKeepalive' dist/: ZERO matches
- npm run build:test: exit 0; dist-test/ populated with the new bridge code
- SKIP_BUILD=1 npx vitest run: 89/89 GREEN
- SKIP_PROD_REBUILD=1 npx tsx tests/uat/harness.test.ts:
  → A0 [PASS]: production bundle has no test-hook leaks (19ms)
  → Browser launches; popup opens; manifest read succeeds
  → A1-A13 [FAIL]: NOT YET IMPLEMENTED — Plan 01-11 Task N wires this
  → "UAT harness: 1/14 assertions passed, 13 failed (first failure: A1)"
  → Exit code: 1 (expected — 13 RED stubs intentional)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 09:14:58 +02:00
parent cb1a729962
commit dbd977c815
11 changed files with 1705 additions and 0 deletions

262
tests/uat/lib/sw.ts Normal file
View File

@@ -0,0 +1,262 @@
// 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://<id>/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';
/// <reference path="./test-hook-contract.d.ts" />
/**
* 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<true> | null;
readonly ids: ReadonlyArray<string>;
}
/**
* 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://<id>/src/popup/index.html).
* @returns Current badge text.
*/
export async function getBadgeText(popup: Page): Promise<string> {
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<string> {
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<chrome.runtime.Manifest> {
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<number> {
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<boolean> {
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<void> {
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<void> {
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<NotificationSnapshot> {
const response = await popup.evaluate(async () => {
const msg = { type: '__mokoshTestQuery', op: 'snapshot' };
return new Promise<{
count: number;
lastOptions: chrome.notifications.NotificationOptions<true> | null;
ids: string[];
}>((resolve) => {
chrome.runtime.sendMessage(msg, (r) => {
resolve(r as {
count: number;
lastOptions: chrome.notifications.NotificationOptions<true> | 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<void> {
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 };