chore(01-13): wave-0 — clean broken Approach-A artifacts per 01-11-SUMMARY
Restore a clean baseline before promoting thec647f61prototype to production paths (Wave 1) and building out Approach-B driver scaffolding (Wave 2). All deletions trace back to falsifications documented in 01-11-SUMMARY.md. Deleted — broken Approach-A files: - src/test-hooks/sw-hooks.ts MV3 SW blocks dynamic import (Chromium es_modules.md; w3c/webextensions#212). The gated `await import('../test-hooks/ sw-hooks')` from 01-11 Wave 1 never resolved → SW silently died → production listeners never registered. File was dead-on-arrival; no fix possible while MV3 SWs disallow dynamic import. Approach-B replaces SW-side instrumentation with the extension-internal harness page's chrome.action.* + chrome.notifications.* surface (full privilege; no monkey-patching needed). - tests/uat/lib/{launch,extension,sw,offscreen,assertions}.ts Popup-bridge architecture (01-11dbd977c) — falsification 2 + falsification 3 in 01-11-SUMMARY: `sw.evaluate` exposes only chrome.{loadTimes,csi}, NOT chrome.action.* / chrome.notifications.* / chrome.runtime.sendMessage; setPopup-juggling for extension-id resolution turned out to be unnecessary (browser.extensions() works directly per the prototype). These files will be reborn in Wave 2 around the extension-page architecture. Kept: tests/uat/lib/zip.ts (host-side JSZip work — architecture- agnostic; A12+A13 still use it) and tests/uat/lib/test-hook- contract.d.ts (type mirror — extended in Wave 3 but kept as-is here). - tests/uat/prototype/probe_{offscreen,sw,tabs,tabs2}.mjs Feasibility-research probes (01-11 spike) that empirically falsified the Approach-A hypotheses. The findings are encoded in 01-11- SUMMARY.md; the probes themselves are dead code. - tests/uat/harness.test.ts 01-11 Wave 2 popup-bridge orchestrator (dbd977c). Imports the now-deleted tests/uat/lib/{assertions,extension,sw,offscreen,launch} modules — would not typecheck after this commit. Reborn in Wave 3A as the Approach-B orchestrator (extension-internal page driver + A0 grep gate + 13 assertion drivers). Reverted — SW-side dynamic-import gate comment block: - src/background/index.ts lines 13-29 The existing comment block (post-spike) described the SW-side gated dynamic import that never landed. Rewritten to cite 01-13 Approach-B explicitly, link to 01-11-SUMMARY.md falsification, and clarify that the Tier-1 grep gate's enduring value is catching regressions in the offscreen chunk's __MOKOSH_UAT__ gate (the SW chunk is hook-free by construction). Updated — Tier-1 grep gate FORBIDDEN_HOOK_STRINGS inventory: - tests/background/no-test-hooks-in-prod-bundle.test.ts Removed: `simulateUserStop` (Approach-A naming; replaced by Approach-B `dispatchEndedOnTrack` which matches the W3C dispatchEvent semantics per RESEARCH §7 BLOCKER — track.stop() does NOT fire 'ended' per spec, so the simulation MUST use dispatchEvent). Added: `installFakeDisplayMedia`, `uninstallFakeDisplayMedia`, `dispatchEndedOnTrack`, `__mokoshOffscreenQuery`. Total inventory: 8 surface strings (was 5). Each MUST be absent from every file under dist/ post-build. Verification (all GREEN): - `npm run build` — exit 0; dist/ populated. - `grep -rln <forbidden> dist/` — 0 matches. - `npm run build:test` — exit 0; dist-test/ populated; offscreen-hooks chunk contains `installFakeDisplayMedia` (gate runs correctly against the test build's distinct artifact). - `npx tsc --noEmit` — exit 0 (root + tests/uat/tsconfig.json). - `npx vitest run` — 92/92 tests passing (was 89; the +3 new tests come from the FORBIDDEN_HOOK_STRINGS list expanding 5 → 8 — each forbidden string is one parametric `it(...)` block). Both prior-failing tests now GREEN: - tests/background/sw-bundle-import.test.ts (was missing dist/ → 92/92 requires the test run to have a current dist/; vitest gate test rebuilds via execFile when SKIP_BUILD≠1, otherwise relies on prior `npm run build`). - tests/background/no-test-hooks-in-prod-bundle.test.ts (was failing on stale dist; now GREEN against the freshly-rebuilt clean bundle). Wave 1 (next): promote tests/uat/prototype/{extension-page-harness.html, extension-page-harness.ts,a6.test.ts} to tests/uat/ via `git mv`; update vite.test.config.ts rollup input. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,311 +0,0 @@
|
||||
// src/test-hooks/sw-hooks.ts — Plan 01-11 Task 2 (SW-side test hook).
|
||||
//
|
||||
// Installs `globalThis.__mokoshTest` in the Service Worker isolate. The
|
||||
// harness reads this surface via `sw.evaluate(...)` to:
|
||||
// - re-fire captured chrome.* event handlers (assertion 8 — Bug A
|
||||
// onStartup notification path);
|
||||
// - count chrome.notifications.create invocations + snapshot the most
|
||||
// recent options + accumulate ids (assertions 7, 8 — recovery +
|
||||
// startup notifications).
|
||||
//
|
||||
// This module is imported ONLY from src/background/index.ts via a gated
|
||||
// dynamic import:
|
||||
// if (import.meta.env.MODE === 'test') {
|
||||
// await import('../test-hooks/sw-hooks');
|
||||
// }
|
||||
// Vite's mode replacement makes the literal-comparison branch dead code
|
||||
// in production mode (Plan 01-11 RESEARCH §6). The Tier-1 grep gate
|
||||
// `tests/background/no-test-hooks-in-prod-bundle.test.ts` verifies the
|
||||
// tree-shake landed by asserting `__mokoshTest` / `simulateUserStop` /
|
||||
// `getSegmentCount` etc. are absent from every file in `dist/`.
|
||||
//
|
||||
// Load-order contract (CRITICAL): the dynamic import in
|
||||
// src/background/index.ts MUST be at the TOP of the module — BEFORE any
|
||||
// `chrome.action.onClicked.addListener` / `chrome.runtime.onStartup
|
||||
// .addListener` / `chrome.notifications.onClicked.addListener` /
|
||||
// `chrome.notifications.create` call. The hook monkey-patches each of
|
||||
// those APIs; calls before the patch register lose handler capture +
|
||||
// miss notification observability. The production listener registrations
|
||||
// (src/background/index.ts:840+) run AFTER the top-of-module dynamic
|
||||
// import resolves, so this ordering is satisfied by placement.
|
||||
//
|
||||
// Implementation note (overload variance): chrome.notifications.create
|
||||
// is declared as a triple-overload (id+options+callback / options+callback /
|
||||
// id+options as Promise-returning / options as Promise-returning). The
|
||||
// monkey-patch wraps every shape by collecting all arguments via rest
|
||||
// spread + dispatching to the original create at the end. The reassignment
|
||||
// goes through `as unknown` (NOT `as any` per project style + CLAUDE.md
|
||||
// rule "no `@ts-ignore`") — `as unknown` is the project-style escape
|
||||
// hatch where typed overload variance is unavoidable.
|
||||
//
|
||||
// Reference for chrome.notifications.create overloads:
|
||||
// https://developer.chrome.com/docs/extensions/reference/api/notifications#method-create
|
||||
// Reference for monkey-patching event listeners safely (preserving `this`):
|
||||
// MDN: Function.prototype.bind
|
||||
// https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Function/bind
|
||||
|
||||
import type { MokoshTestSurface } from './types';
|
||||
|
||||
// Module-level mutable cells holding the hook surface state. The exported
|
||||
// `__mokoshTest` object closes over these so mutations land on the live
|
||||
// surface the harness reads.
|
||||
const handlers: MokoshTestSurface['handlers'] = {
|
||||
onClicked: null,
|
||||
onStartup: null,
|
||||
notificationOnClicked: null,
|
||||
};
|
||||
const notificationIds: string[] = [];
|
||||
let notificationCount = 0;
|
||||
let lastNotificationOptions: chrome.notifications.NotificationOptions<true> | null = null;
|
||||
|
||||
// ─── Monkey-patch chrome.action.onClicked.addListener ─────────────────
|
||||
// Capture the FIRST handler registration. Subsequent registrations
|
||||
// (which the production code does not currently do — only one
|
||||
// onClicked listener) would overwrite the capture; the harness
|
||||
// asserts behavior, not exclusivity, so overwrite semantics are
|
||||
// acceptable.
|
||||
//
|
||||
// We must bind() the original `addListener` so the production code's
|
||||
// invocation context (the onClicked event object) is preserved when
|
||||
// the patched function delegates back to it.
|
||||
const origActionAddListener = chrome.action.onClicked.addListener.bind(
|
||||
chrome.action.onClicked,
|
||||
);
|
||||
chrome.action.onClicked.addListener = ((callback: (tab: chrome.tabs.Tab) => void | Promise<void>): void => {
|
||||
handlers.onClicked = callback;
|
||||
origActionAddListener(callback);
|
||||
}) as typeof chrome.action.onClicked.addListener;
|
||||
|
||||
// ─── Monkey-patch chrome.runtime.onStartup.addListener ────────────────
|
||||
const origStartupAddListener = chrome.runtime.onStartup.addListener.bind(
|
||||
chrome.runtime.onStartup,
|
||||
);
|
||||
chrome.runtime.onStartup.addListener = ((callback: () => void | Promise<void>): void => {
|
||||
handlers.onStartup = callback;
|
||||
origStartupAddListener(callback);
|
||||
}) as typeof chrome.runtime.onStartup.addListener;
|
||||
|
||||
// ─── Monkey-patch chrome.notifications.onClicked.addListener ──────────
|
||||
const origNotifClickedAddListener = chrome.notifications.onClicked.addListener.bind(
|
||||
chrome.notifications.onClicked,
|
||||
);
|
||||
chrome.notifications.onClicked.addListener = ((callback: (notificationId: string) => void | Promise<void>): void => {
|
||||
handlers.notificationOnClicked = callback;
|
||||
origNotifClickedAddListener(callback);
|
||||
}) as typeof chrome.notifications.onClicked.addListener;
|
||||
|
||||
// ─── Monkey-patch chrome.notifications.create (overload-variant) ──────
|
||||
// The original create signature is overloaded — Chrome accepts:
|
||||
// create(id, options, callback?) → string
|
||||
// create(options, callback?) → string
|
||||
// create(id, options) → Promise<string> (Chrome 88+)
|
||||
// create(options) → Promise<string> (Chrome 88+)
|
||||
// To wrap every shape uniformly we collect args via rest, normalize the
|
||||
// (id, options) pair via type-guard on the first arg, then re-dispatch
|
||||
// to the original. Both callback + Promise return paths funnel through
|
||||
// the same id-capture path: callback-form via wrapping the user callback,
|
||||
// Promise-form via .then() on the returned thenable. The capture lands
|
||||
// in notificationIds regardless of which call shape the production code
|
||||
// used (src/background/index.ts uses the (id, options) shape — both
|
||||
// for startup and recovery notifications).
|
||||
const origNotifCreate = chrome.notifications.create.bind(chrome.notifications);
|
||||
|
||||
type NotifCreateArgs =
|
||||
| [chrome.notifications.NotificationOptions<true>]
|
||||
| [chrome.notifications.NotificationOptions<true>, (notificationId: string) => void]
|
||||
| [string, chrome.notifications.NotificationOptions<true>]
|
||||
| [string, chrome.notifications.NotificationOptions<true>, (notificationId: string) => void];
|
||||
|
||||
/**
|
||||
* Wrapping shim for chrome.notifications.create. Increments
|
||||
* notificationCount, captures lastNotificationOptions, and records
|
||||
* the resolved notification id in notificationIds — across BOTH the
|
||||
* callback-style and Promise-style return paths.
|
||||
*
|
||||
* @param args - The original create arguments, captured via rest.
|
||||
* @returns Whatever the original create returns (string id OR Promise<string>).
|
||||
*/
|
||||
function patchedNotifCreate(...args: NotifCreateArgs): string | Promise<string> {
|
||||
// Normalize (id, options, callback) | (options, callback) into a
|
||||
// unified (proposedId, options, userCallback) triple. The proposedId
|
||||
// is null when the caller omitted it; Chrome auto-generates one and
|
||||
// returns it via the callback / Promise.
|
||||
let proposedId: string | null;
|
||||
let options: chrome.notifications.NotificationOptions<true>;
|
||||
let userCallback: ((id: string) => void) | undefined;
|
||||
|
||||
if (typeof args[0] === 'string') {
|
||||
proposedId = args[0];
|
||||
options = args[1] as chrome.notifications.NotificationOptions<true>;
|
||||
userCallback = args[2] as ((id: string) => void) | undefined;
|
||||
} else {
|
||||
proposedId = null;
|
||||
options = args[0];
|
||||
userCallback = args[1] as ((id: string) => void) | undefined;
|
||||
}
|
||||
|
||||
notificationCount += 1;
|
||||
lastNotificationOptions = options;
|
||||
|
||||
/**
|
||||
* Record the resolved id (the one Chrome actually assigned — same as
|
||||
* proposedId when provided, otherwise auto-generated).
|
||||
* @param resolvedId - The id Chrome returned.
|
||||
*/
|
||||
const recordId = (resolvedId: string): void => {
|
||||
notificationIds.push(resolvedId);
|
||||
};
|
||||
|
||||
// Dispatch through to the original create with a wrapping callback
|
||||
// so the callback-form path captures the id. The wrapping callback
|
||||
// chains to the user's callback if any.
|
||||
if (userCallback !== undefined) {
|
||||
const wrappingCallback = (resolvedId: string): void => {
|
||||
recordId(resolvedId);
|
||||
userCallback!(resolvedId);
|
||||
};
|
||||
if (proposedId !== null) {
|
||||
return origNotifCreate(
|
||||
proposedId,
|
||||
options,
|
||||
wrappingCallback,
|
||||
) as unknown as string;
|
||||
}
|
||||
return origNotifCreate(
|
||||
options,
|
||||
wrappingCallback,
|
||||
) as unknown as string;
|
||||
}
|
||||
|
||||
// No user callback supplied — Chrome 88+ returns a Promise. Older
|
||||
// Chromes return undefined and require a callback for the id. Since
|
||||
// our manifest targets MV3 (Chrome 88+), Promise return is canonical.
|
||||
// Chain a .then so we still record the id.
|
||||
//
|
||||
// Cast through `unknown` because @types/chrome's declared return for
|
||||
// create-without-callback is `void` (the type maintainers haven't yet
|
||||
// modeled Chrome 88+'s Promise return) — but the runtime DOES return a
|
||||
// Promise<string>. We discriminate by runtime typeof / instanceof to
|
||||
// be robust to both possibilities.
|
||||
const ret: unknown = proposedId !== null
|
||||
? origNotifCreate(proposedId, options)
|
||||
: origNotifCreate(options);
|
||||
|
||||
// Some Chrome SW versions return string immediately; others return a
|
||||
// Promise. Discriminate by typeof to be safe.
|
||||
if (typeof ret === 'string') {
|
||||
recordId(ret);
|
||||
return ret;
|
||||
}
|
||||
if (ret instanceof Promise) {
|
||||
return (ret as Promise<string>).then((resolvedId) => {
|
||||
recordId(resolvedId);
|
||||
return resolvedId;
|
||||
});
|
||||
}
|
||||
// Defensive: neither string nor Promise — return as-is (caller can
|
||||
// still see the count + options snapshot).
|
||||
return ret as string;
|
||||
}
|
||||
|
||||
(chrome.notifications.create as unknown) = patchedNotifCreate;
|
||||
|
||||
// ─── Install the global surface ───────────────────────────────────────
|
||||
// Use Object.defineProperty for notificationIds to expose a defensive-
|
||||
// copy getter (so test consumers cannot mutate the underlying record
|
||||
// from inside an evaluate block).
|
||||
globalThis.__mokoshTest = {
|
||||
handlers,
|
||||
get notificationCount() {
|
||||
return notificationCount;
|
||||
},
|
||||
get lastNotificationOptions() {
|
||||
return lastNotificationOptions;
|
||||
},
|
||||
get notificationIds() {
|
||||
return notificationIds.slice();
|
||||
},
|
||||
} as MokoshTestSurface;
|
||||
|
||||
// ─── Harness message bridge ───────────────────────────────────────────
|
||||
// EMPIRICAL ARCHITECTURE NOTE: Puppeteer 25 + Chrome 148 + headless
|
||||
// cannot reliably evaluate against the SW directly. The harness
|
||||
// queries chrome.* state through the popup page (which has full
|
||||
// chrome.* API access) but cannot read the SW's globalThis.__mokoshTest
|
||||
// because the popup is a SEPARATE V8 isolate. So we bridge: the popup
|
||||
// sends chrome.runtime.sendMessage queries; this handler responds with
|
||||
// the queried state.
|
||||
//
|
||||
// Protocol — popup → SW message: { type: '__mokoshTestQuery', op: <string> }
|
||||
// Response shapes:
|
||||
// op='snapshot' → { count, lastOptions, ids }
|
||||
// op='fire-on-startup' → { ok: true } OR { ok: false, error: 'no-handler' }
|
||||
// op='handler-types' → { onClicked, onStartup, notificationOnClicked }
|
||||
// Unknown ops respond { ok: false, error: 'unknown-op' }.
|
||||
//
|
||||
// Returning `true` from the onMessage handler tells Chrome the
|
||||
// response is async; we keep sendResponse as a closed-over callback.
|
||||
// The bridge handler is registered AFTER the production listeners so
|
||||
// the hook never accidentally intercepts a production message —
|
||||
// __mokoshTest* messages are unambiguously test-only.
|
||||
chrome.runtime.onMessage.addListener((rawMessage, _sender, sendResponse) => {
|
||||
// Narrow the message — we accept ANY shape but only act on our type.
|
||||
if (rawMessage === null || typeof rawMessage !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const message = rawMessage as { type?: unknown; op?: unknown };
|
||||
if (message.type !== '__mokoshTestQuery') {
|
||||
// Not our message — production handler will take it.
|
||||
return false;
|
||||
}
|
||||
const op = String(message.op ?? '');
|
||||
if (op === 'snapshot') {
|
||||
sendResponse({
|
||||
count: notificationCount,
|
||||
lastOptions: lastNotificationOptions,
|
||||
ids: notificationIds.slice(),
|
||||
});
|
||||
return false; // Sync response — return false per Chrome onMessage contract.
|
||||
}
|
||||
if (op === 'fire-on-startup') {
|
||||
const h = handlers.onStartup;
|
||||
if (h === null) {
|
||||
sendResponse({ ok: false, error: 'no-handler' });
|
||||
return false;
|
||||
}
|
||||
// Fire-and-respond. The handler may be async; we don't await it
|
||||
// for the response, but if it throws synchronously the catch
|
||||
// surfaces in the response.
|
||||
try {
|
||||
// Schedule on microtask so the response goes out first; the
|
||||
// handler's side effects (notifications.create) happen right
|
||||
// after, before the next harness assertion polls.
|
||||
queueMicrotask(() => {
|
||||
Promise.resolve(h()).catch((err) => {
|
||||
// Swallow async errors — the assertion 8 check is on the
|
||||
// notification side effect, not the handler's return value.
|
||||
console.warn('[mokoshTest bridge] onStartup handler threw:', err);
|
||||
});
|
||||
});
|
||||
sendResponse({ ok: true });
|
||||
} catch (err) {
|
||||
sendResponse({
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (op === 'handler-types') {
|
||||
sendResponse({
|
||||
onClicked: typeof handlers.onClicked,
|
||||
onStartup: typeof handlers.onStartup,
|
||||
notificationOnClicked: typeof handlers.notificationOnClicked,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
sendResponse({ ok: false, error: 'unknown-op' });
|
||||
return false;
|
||||
});
|
||||
|
||||
export {};
|
||||
Reference in New Issue
Block a user