feat(01-11): wave-1 — gated test hooks for SW + offscreen, dist/ stays hook-free

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

Test hook surface:
- src/test-hooks/types.ts: canonical MokoshTestSurface — handlers
  (onClicked, onStartup, notificationOnClicked), notificationCount,
  lastNotificationOptions<true>, notificationIds, getCurrentStream,
  getSegmentCount. globalThis.__mokoshTest ambient declaration.
- src/test-hooks/sw-hooks.ts: SW-side hook. Monkey-patches addListener
  on chrome.action.onClicked / chrome.runtime.onStartup / chrome
  .notifications.onClicked to capture handler refs while chaining to
  the original. Wraps chrome.notifications.create across all four
  overload shapes (id+options+cb, options+cb, id+options→Promise,
  options→Promise) to increment notificationCount, save
  lastNotificationOptions, push resolved id into notificationIds.
- src/test-hooks/offscreen-hooks.ts: offscreen-side hook. Exports
  setCurrentStream + setSegmentCountGetter; the recorder calls both
  inside startRecording after the mediaStream + segments assignments.
  getCurrentStream getter closes over the cell so the harness reads
  the live MediaStream for displaySurface inspection + 'ended'
  dispatch (Bug B BLOCKER per RESEARCH §7).
- tests/uat/lib/test-hook-contract.d.ts: manual harness-side mirror of
  MokoshTestSurface (decoupled from src/ to keep tests/ import-clean
  per RESEARCH §11 resolution 5; drift risk documented inline).

Production-side wires (gated by __MOKOSH_UAT__ token):
- src/background/index.ts top-of-module: `if (__MOKOSH_UAT__) { await
  import('../test-hooks/sw-hooks'); }`. MUST run before any chrome.*
  addListener call below — top-of-module placement satisfies this.
- src/offscreen/recorder.ts top-of-module: symmetric gated dynamic
  import + module-scoped testHooks reference.
- src/offscreen/recorder.ts inside startRecording (after mediaStream
  assignment): `if (__MOKOSH_UAT__) { testHooks?.setCurrentStream(stream);
  testHooks?.setSegmentCountGetter(() => segments.length); }`
- src/offscreen/recorder.ts inside onUserStoppedSharing (after
  mediaStream = null): `if (__MOKOSH_UAT__) { testHooks?.setCurrentStream(null); }`
  — T-1-11-05 (Repudiation: stale stream ref) mitigation.

Build-time token wiring:
- vite.config.ts: declares `define: { __MOKOSH_UAT__: 'false' }` (prod
  default) + bumps `build.target: 'es2022'` so the top-level await in
  the gated dynamic imports compiles (MDN: Chrome 89 / Edge 89 /
  Firefox 89 / Safari 15 support TLA; MV3 floor Chrome 88 is
  effectively Chrome 89+ in field — comfortably inside the envelope).
- vite.test.config.ts: overrides `define: { __MOKOSH_UAT__: 'true' }`
  so the test bundle has the hooks active.
- vitest.config.ts: declares `define: { __MOKOSH_UAT__: 'false' }` for
  vitest's own source-loading runs. CRITICAL — without this, vitest
  would throw `ReferenceError: __MOKOSH_UAT__ is not defined` when
  loading src/background/index.ts; OR if we'd used `import.meta.env.MODE
  === 'test'` (RESEARCH §6's initial guidance), vitest's default
  MODE='test' would have ACTIVATED the hooks under unit tests +
  clobbered every existing vi.fn() chrome.notifications.create mock.
  The dedicated `__MOKOSH_UAT__` token sidesteps both failure modes
  cleanly — a refinement on RESEARCH §6 documented in the comment
  preambles of all three configs.
- globals.d.ts: declares `__MOKOSH_UAT__: boolean` ambient so
  `npx tsc --noEmit` passes without per-file annotations.
- tsconfig.json: include adds `globals.d.ts`.

Notification options generic refinement:
- chrome.notifications.NotificationOptions is declared with a
  `<true | false>` generic distinguishing "create" (all required —
  true) from "update" (all optional — false). Plan 01-11's production
  code always uses the create shape; types.ts + sw-hooks.ts pin to
  `NotificationOptions<true>` so the harness reads iconUrl etc. as
  definitely-present.

Verification:
- npx tsc --noEmit: exit 0
- npm run build: exit 0
- grep -rln '__mokoshTest\|simulateUserStop\|getSegmentCount\|setCurrentStream\|setSegmentCountGetter' dist/:
  ZERO matches (Tier-1 gate stays GREEN)
- npm run build:test: exit 0; dist-test/ emits separate sw-hooks-*.js
  + offscreen-hooks-*.js chunks (the gated dynamic imports survive
  tree-shaking when __MOKOSH_UAT__ === true)
- grep -rln '__mokoshTest' dist-test/: 2 matches
  (assets/sw-hooks-*.js + assets/offscreen-hooks-*.js)
- SKIP_BUILD=1 npx vitest run: 89/89 GREEN
  (83 baseline + 6 Tier-1 hook-leak surfaces)
- sw-bundle-import.test.ts: GREEN (the gated dynamic import does not
  break production module init — the `if (false)` branch is never
  reachable so the await + import are dead code in dist/)

In-flight bugs auto-fixed (Rule 1 + Rule 3):
- Rule 3: original RESEARCH §6 plan called for `import.meta.env.MODE
  === 'test'` as the gate; switched to `__MOKOSH_UAT__` define-token
  after observing vitest contamination (vitest defaults MODE='test'
  → hooks activated under unit tests → 8 existing tests broke with
  "Cannot read properties of undefined (reading 'calls')" because the
  hook wrapper replaced vi.fn() mocks). Documented in the comment
  preambles of all three configs as a refinement on RESEARCH §6.
- Rule 3: esbuild rejected TLA against the default ES2020 target;
  bumped to es2022 (Chrome 89+ supports TLA per MDN — inside MV3
  envelope). Recorded in vite.config.ts preamble.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 22:46:26 +02:00
parent 0cd50fde94
commit cb1a729962
11 changed files with 644 additions and 1 deletions

View File

@@ -18,6 +18,35 @@ import { OffscreenLogger } from '../shared/logger';
import { blobToBase64 } from '../shared/binary';
import type { Message, TransferredVideoSegment } from '../shared/types';
// ─── Plan 01-11: gated test-hook dynamic import ───────────────────────
// MUST run at top-of-module so `globalThis.__mokoshTest` is installed
// before any code that the harness queries (assertion 3 reads
// displaySurface; assertion 6 dispatches 'ended'; assertion 11 reads
// segment count). The `__MOKOSH_UAT__` token is text-replaced by Vite
// at build time — `false` in production (vite.config.ts), `true` only
// in the test bundle (vite.test.config.ts). The `if (false)` branch
// is a static dead branch and Rollup tree-shakes the entire `await
// import` + dependent statements away (Plan 01-11 RESEARCH §6, refined:
// we use a dedicated token rather than `import.meta.env.MODE === 'test'`
// because vitest also runs with MODE='test' and gating on MODE would
// activate the hooks during unit tests + clobber the vi.fn() mocks).
//
// Tier-1 grep gate `tests/background/no-test-hooks-in-prod-bundle.test.ts`
// enforces the resulting absence of `__mokoshTest` / `setCurrentStream`
// / `setSegmentCountGetter` in every file under `dist/` post-build
// (T-1-11-01 — Elevation of Privilege via leaked hook surface).
//
// Module-scoped reference captured here so wire-points later in the
// file (the setCurrentStream call after `mediaStream = stream`; the
// setSegmentCountGetter call once `segments` is in scope) do not need
// to re-import per call. The Vite/Rollup dead-branch elimination treats
// the entire if-block as unreachable in production, so the `let` itself
// vanishes alongside the imports.
let testHooks: typeof import('../test-hooks/offscreen-hooks') | null = null;
if (__MOKOSH_UAT__) {
testHooks = await import('../test-hooks/offscreen-hooks');
}
// ─── Константы (per CON-video-codec, CON-video-window, D-13) ────────────
// Длительность одного сегмента — 10 с (D-13, RESEARCH.md Pattern 3).
export const SEGMENT_DURATION_MS = 10_000;
@@ -245,6 +274,15 @@ async function startRecording(): Promise<void> {
video: { displaySurface: 'monitor'; cursor: 'always' };
});
mediaStream = stream;
// Plan 01-11 Task 2: wire the live MediaStream into the test hook
// surface so the harness can read displaySurface (assertion 3) and
// dispatch 'ended' on the track (assertion 6 — Bug B BLOCKER).
// Gated identically to the top-of-module import; tree-shaken in
// production by the `__MOKOSH_UAT__ === false` dead branch.
if (__MOKOSH_UAT__) {
testHooks?.setCurrentStream(stream);
testHooks?.setSegmentCountGetter(() => segments.length);
}
// Post-grant validation — the constraint-hint-vs-enforcement gap.
// getDisplayMedia's `displaySurface` is a HINT, not a hard
// constraint: the operator may pick a tab/window from the picker
@@ -468,6 +506,13 @@ function onUserStoppedSharing(): void {
mediaStream = null;
}
videoRecorder = null;
// Plan 01-11 Task 2: clear the test-hook stream reference so a
// subsequent harness `getCurrentStream()` returns null (the "not
// currently recording" signal documented in src/test-hooks/types.ts).
// Tree-shaken in production by the `__MOKOSH_UAT__ === false` dead branch.
if (__MOKOSH_UAT__) {
testHooks?.setCurrentStream(null);
}
chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: 'user-stopped-sharing' });
// Reset the guard so a future startRecording → onUserStoppedSharing
// cycle works correctly. Place AFTER the broadcast so a same-tick