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>
263 lines
9.5 KiB
TypeScript
263 lines
9.5 KiB
TypeScript
// 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 };
|