Task 4 of Plan 01-11 attempted A1-A4 wiring. Empirical run reveals an
architectural blocker that needs orchestrator-level decision.
Current state after this commit (SKIP_PROD_REBUILD=1 npx tsx tests/uat/harness.test.ts):
- A0 [PASS]: production bundle hook-leak grep gate (17ms)
- A1 [FAIL]: SW bootstrap → setIdleMode — popup state never transitions
to '' despite keepalive ping + 3s waitFor. chrome.action.getPopup({})
from the popup page consistently returns the manifest default
(chrome-extension://<id>/src/popup/index.html), not the '' that
setIdleMode's chrome.action.setPopup({popup:''}) should produce.
- A2 [FAIL]: toolbar onClicked — badge never transitions to "REC" after
page.triggerExtensionAction(extension); 8s timeout. Either the
toolbar action isn't reaching the SW listener, OR getDisplayMedia's
picker isn't resolving in headless mode (despite the auto-select flag).
- A3 [FAIL]: offscreen target never appears (correlates with A2 — no
recording started, no offscreen document spawned).
- A4 [PASS]: trivially passes (offscreen count is 0 → 0, both before
+ after the click). Not a true assertion of behavior; would also pass
if the whole extension were broken.
- A5-A13: stubbed RED per plan.
Architectural blocker (Rule 4 — needs orchestrator decision):
- Puppeteer 25.0.2 + Chrome 148 + headless cannot reliably keep the MV3
SW alive long enough OR expose its real chrome.* state to a popup
page query. The popup-bridge architecture (Task 3 commit dbd977c)
works for synchronous bridge queries (snapshot, fire-on-startup)
but does NOT reliably reflect chrome.action.setPopup / setBadgeText
state changes initiated by the SW.
Three plausible paths forward (need orchestrator pick):
Option A — Content-script bridge: inject a content script that
bridges chrome.* queries to a webpage's window.* RPC surface;
harness uses page.evaluate against the content script instead of
popup.evaluate. Pros: content scripts have stable lifetime tied to
the page they're injected into. Cons: content scripts have
DIFFERENT chrome.* surface (no chrome.action API surface — they
can't read getBadgeText / getPopup at all). Likely DOESN'T solve
the underlying problem.
Option B — Headful with Xvfb on CI: relax the headless requirement;
accept Xvfb dependency. Per Plan 01-11 RESEARCH §3, RESEARCH
claimed headless works on Chrome 148 — empirical refutation here.
Pros: SW lifetime is more stable in headful mode; setPopup
propagation is reliable. Cons: introduces Xvfb dep that RESEARCH
explicitly said wasn't needed; CI complication.
Option C — Shrink harness scope to bridge-able assertions: A0 (grep
gate), A8 (Bug A onStartup via bridge), A9 (icon sizes via popup
fetch), A10 (manifest via popup), A13 (zip shape — operator runs
SAVE_ARCHIVE manually + drops zip to a known path; harness reads
it). Skip A1-A7, A11, A12 (the ones that require live SW state
observation through chrome.action API). Pros: ships the
bug-A-coverage portion of the harness today; keeps Plan 01-09's
Task 5 operator-checkpoint partly automated. Cons: doesn't retire
operator entirely; Plan 01-09 stays open on operator-empirical
A1-A7.
Option D — Switch to WebDriver BiDi (the Puppeteer 25 alternative
backend): Puppeteer 25 supports BiDi via {protocol: 'webDriverBiDi'}.
BiDi may handle extension SW evaluation differently (different
isolation model). Speculative — no empirical evidence either way.
What landed cleanly:
- Tier-1 hook-leak grep gate (T-1-11-01) GREEN: dist/ has zero
__mokoshTest / simulateUserStop / getSegmentCount / setCurrentStream
/ setSegmentCountGetter / __mokoshTestQuery / __mokoshKeepalive
occurrences after npm run build.
- Two-bundle infrastructure (dist/ vs dist-test/) operational.
- Bridge handler in sw-hooks.ts works for snapshot + fire-on-startup
+ handler-types ops (verified by no-hang on keepalivePing call).
- Existing 89-test vitest baseline preserved (no regression from any
Wave 0/1/2/3 work).
Verification:
- npx tsc --noEmit (src/): exit 0
- npx tsc --noEmit -p tests/uat: exit 0
- npm run build: exit 0; dist/ hook-free
- SKIP_BUILD=1 npx vitest run: 89/89 GREEN
- SKIP_PROD_REBUILD=1 npx tsx tests/uat/harness.test.ts:
2/14 passed (A0 + A4-trivially), 12 FAIL — non-zero exit as expected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
269 lines
9.8 KiB
TypeScript
269 lines
9.8 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 a __mokoshTestQuery 'snapshot' op as the
|
|
* cheapest round-trip with a guaranteed response — the bridge handler
|
|
* answers synchronously with the current notification state.
|
|
*
|
|
* @param popup - The extension popup page handle.
|
|
*/
|
|
export async function keepalivePing(popup: Page): Promise<void> {
|
|
await popup.evaluate(async () => {
|
|
const msg = { type: '__mokoshTestQuery', op: 'snapshot' };
|
|
return new Promise<void>((resolve) => {
|
|
chrome.runtime.sendMessage(msg, () => resolve());
|
|
// Cap at 2s — if the bridge is gone (SW reload race etc.) we
|
|
// still continue (the assertion may catch downstream issues).
|
|
setTimeout(() => resolve(), 2_000);
|
|
});
|
|
});
|
|
}
|
|
|
|
// 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 };
|