diff --git a/globals.d.ts b/globals.d.ts new file mode 100644 index 0000000..c53f3b2 --- /dev/null +++ b/globals.d.ts @@ -0,0 +1,19 @@ +// globals.d.ts — Plan 01-11 ambient declarations for Vite `define` +// text-replacement tokens. +// +// `__MOKOSH_UAT__` is a build-time boolean token replaced by Vite's +// `define` config (vite.config.ts sets it `false`, vite.test.config.ts +// overrides it to `true`). The token gates the test-hook dynamic +// imports in src/background/index.ts + src/offscreen/recorder.ts; +// production builds tree-shake the entire `if (__MOKOSH_UAT__)` block +// because Rollup recognizes `if (false)` as a static dead branch. +// +// Declaring the symbol here keeps the src/ tree type-clean under +// `npx tsc --noEmit` without spreading per-file ambient declarations. +// +// References: +// - Vite define: https://vite.dev/config/shared-options.html#define +// - TypeScript ambient declarations: +// https://www.typescriptlang.org/docs/handbook/declaration-files/templates/global-modifying-module-d-ts.html + +declare const __MOKOSH_UAT__: boolean; diff --git a/src/background/index.ts b/src/background/index.ts index 2c9ea8e..119e163 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -10,6 +10,26 @@ import type { import { remuxSegments } from './webm-remux'; import JSZip from 'jszip'; +// ─── Plan 01-11: gated test-hook dynamic import ─────────────────────── +// MUST run BEFORE any chrome.* addListener call below so the SW-side +// test hook's addListener monkey-patches catch every handler +// registration. 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` +// 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 +// inside unit tests + clobber their vi.fn() chrome.* mocks). +// +// The Tier-1 grep gate `tests/background/no-test-hooks-in-prod-bundle +// .test.ts` enforces that `__mokoshTest` is absent from every file +// under `dist/` post-build (T-1-11-01 — Elevation of Privilege via +// leaked hook surface). +if (__MOKOSH_UAT__) { + await import('../test-hooks/sw-hooks'); +} + // Default MIME applied when a wire chunk somehow lacks a type // field (defense-in-depth: in normal operation the offscreen recorder // always populates it from chunk.data.type). Matches D-20 strict codec. diff --git a/src/offscreen/recorder.ts b/src/offscreen/recorder.ts index 0568a5b..6b6bc64 100644 --- a/src/offscreen/recorder.ts +++ b/src/offscreen/recorder.ts @@ -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 { 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 diff --git a/src/test-hooks/offscreen-hooks.ts b/src/test-hooks/offscreen-hooks.ts new file mode 100644 index 0000000..6d47362 --- /dev/null +++ b/src/test-hooks/offscreen-hooks.ts @@ -0,0 +1,91 @@ +// src/test-hooks/offscreen-hooks.ts — Plan 01-11 Task 2 (offscreen-side test hook). +// +// Installs `globalThis.__mokoshTest` in the offscreen document's isolate. +// The harness reads this surface via `offPage.evaluate(...)` to: +// - read `getCurrentStream().getVideoTracks()[0].getSettings().displaySurface` +// (assertion 3 — verifies monitor-only enforcement); +// - dispatch `new Event('ended')` on the video track (assertion 6 — +// the Bug B simulation per RESEARCH §7 BLOCKER. `track.stop()` does +// NOT fire 'ended' per W3C spec, so the production +// onUserStoppedSharing handler would never run — that is the trap +// this hook exists to expose); +// - read `getSegmentCount()` (assertion 11 — verifies 30s ring buffer +// per D-13). +// +// The offscreen recorder wires the runtime references via the two +// setters exported below. These imports are gated by the same +// `import.meta.env.MODE === 'test'` literal-comparison guard in +// src/offscreen/recorder.ts as the SW-side hook; production builds +// tree-shake the entire module away (Tier-1 grep gate verifies). +// +// Cross-isolate note: SW and offscreen are SEPARATE isolates with +// SEPARATE `globalThis`. The SW-side sw-hooks.ts installs handler +// captures + notification observability on the SW's globalThis; +// this file installs stream + segment-count observability on the +// offscreen's globalThis. The harness queries the appropriate isolate +// per assertion. handlers / notificationCount / notificationIds / +// lastNotificationOptions in this offscreen surface are present-but- +// inert (initialized to empty values) to keep the type uniform across +// isolates — the harness never reads them off the offscreen surface. +// +// Reference for MediaStreamTrack 'ended' event: +// https://developer.mozilla.org/docs/Web/API/MediaStreamTrack/ended_event +// Reference for offscreen document isolation in MV3: +// https://developer.chrome.com/docs/extensions/reference/api/offscreen + +import type { MokoshTestSurface } from './types'; + +// Module-level mutable cells holding the runtime references. The +// recorder calls the setters; the surface getters close over the cells. +let currentStream: MediaStream | null = null; +let segmentCountGetter: () => number = () => 0; + +/** + * Wire the current MediaStream into the test surface. Called by + * src/offscreen/recorder.ts:startRecording immediately after the + * `mediaStream = stream` assignment AND on stream teardown (with + * null) so the surface tracks the live recording lifecycle. + * + * Idempotent — calling with the same value is a no-op equivalent. + * + * @param stream - The active MediaStream, or null when teardown. + */ +export function setCurrentStream(stream: MediaStream | null): void { + currentStream = stream; +} + +/** + * Wire the segment-count getter into the test surface. Called once + * by src/offscreen/recorder.ts when the test bundle is active. The + * recorder holds a `let segments: Blob[]` module-local; the getter + * captures it by closure so the harness reads the live count without + * needing to import the recorder's source from the harness side. + * + * @param getter - Closure returning the current segments.length. + */ +export function setSegmentCountGetter(getter: () => number): void { + segmentCountGetter = getter; +} + +// ─── Install the global surface ─────────────────────────────────────── +// Note: the offscreen isolate's globalThis is FRESH per offscreen +// document creation (each createDocument restart resets it). The +// gated dynamic import in recorder.ts top-of-module runs once per +// offscreen lifetime, so each new offscreen document gets a fresh +// surface install — there is no cross-lifetime contamination. +globalThis.__mokoshTest = { + handlers: { + onClicked: null, + onStartup: null, + notificationOnClicked: null, + }, + notificationCount: 0, + lastNotificationOptions: null, + get notificationIds() { + return []; + }, + getCurrentStream: () => currentStream, + getSegmentCount: () => segmentCountGetter(), +} as MokoshTestSurface; + +export {}; diff --git a/src/test-hooks/sw-hooks.ts b/src/test-hooks/sw-hooks.ts new file mode 100644 index 0000000..b724a48 --- /dev/null +++ b/src/test-hooks/sw-hooks.ts @@ -0,0 +1,230 @@ +// 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 | 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 => { + 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 => { + 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 => { + 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 (Chrome 88+) +// create(options) → Promise (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] + | [chrome.notifications.NotificationOptions, (notificationId: string) => void] + | [string, chrome.notifications.NotificationOptions] + | [string, chrome.notifications.NotificationOptions, (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). + */ +function patchedNotifCreate(...args: NotifCreateArgs): string | Promise { + // 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; + let userCallback: ((id: string) => void) | undefined; + + if (typeof args[0] === 'string') { + proposedId = args[0]; + options = args[1] as chrome.notifications.NotificationOptions; + 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. 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).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; + +export {}; diff --git a/src/test-hooks/types.ts b/src/test-hooks/types.ts new file mode 100644 index 0000000..b8ddb3c --- /dev/null +++ b/src/test-hooks/types.ts @@ -0,0 +1,143 @@ +// src/test-hooks/types.ts — Plan 01-11 Task 2. +// +// SINGLE SOURCE OF TRUTH for the `globalThis.__mokoshTest` wire shape +// the Puppeteer UAT harness reads via `sw.evaluate` + `offPage.evaluate`. +// +// This type is imported by: +// - src/test-hooks/sw-hooks.ts (registers in SW isolate) +// - src/test-hooks/offscreen-hooks.ts (registers in offscreen isolate) +// - tests/uat/lib/test-hook-contract.d.ts (manual mirror — keeps the +// harness's import surface clean of any `import` reaching into `src/`; +// drift risk documented in that file's preamble) +// +// Production-bundle invariant (T-1-11-01): this file's content is +// reachable from src/background/index.ts + src/offscreen/recorder.ts +// ONLY via a `if (import.meta.env.MODE === 'test') { await import(...) }` +// gated dynamic import. Vite statically replaces `import.meta.env.MODE` +// at build time; in production mode the literal-comparison branch is dead +// code and Rollup tree-shakes the entire `await import` away — so this +// file's content never lands in `dist/`. The Tier-1 grep gate +// `tests/background/no-test-hooks-in-prod-bundle.test.ts` enforces the +// absence of `__mokoshTest` (and the other hook surface strings) in +// every file under `dist/` after `npm run build`. See Plan 01-11 +// RESEARCH §6 (Vite tree-shaking dynamic-import-behind-literal-guard). +// +// Cross-isolate note: SW and offscreen are SEPARATE JavaScript isolates +// with SEPARATE `globalThis` objects. The harness queries each surface +// via the appropriate `sw.evaluate(...)` or `offPage.evaluate(...)`. +// `__mokoshTest.getCurrentStream` is undefined in the SW isolate; +// `__mokoshTest.handlers` is populated only in the SW isolate. The +// offscreen-side init reuses the same type to keep the harness's +// reasoning unified — runtime null/undefined are the "not in this +// context" signals. +// +// References: +// - Vite `import.meta.env.MODE`: +// https://vite.dev/guide/env-and-mode.html +// - W3C Screen Capture MediaStream: https://www.w3.org/TR/screen-capture/ + +/** + * Wire shape of `globalThis.__mokoshTest` exposed by the Plan 01-11 test + * hooks. Each field is described inline; null/undefined values carry + * load-order or cross-isolate semantics documented per field. + */ +export interface MokoshTestSurface { + /** + * Captured chrome.* event-listener handler references. The SW-side + * hook (sw-hooks.ts) monkey-patches `addListener` so the FIRST call + * for each event captures the handler ref while still chaining + * through to the production registration. The harness re-fires each + * handler via `sw.evaluate(() => globalThis.__mokoshTest!.handlers.onStartup!())`. + * + * Null values indicate the corresponding addListener never ran in + * this isolate (e.g. notificationOnClicked may be null until the + * production listener registration block executes). + * + * In the offscreen isolate, all three are null (offscreen does not + * register these chrome.* listeners — they live in the SW). + */ + handlers: { + /** chrome.action.onClicked — fires on toolbar click when popup === ''. */ + onClicked: ((tab: chrome.tabs.Tab) => void | Promise) | null; + /** chrome.runtime.onStartup — fires once per browser session start. */ + onStartup: (() => void | Promise) | null; + /** chrome.notifications.onClicked — fires when operator clicks a notification. */ + notificationOnClicked: ((notificationId: string) => void | Promise) | null; + }; + + /** + * Total count of `chrome.notifications.create(...)` invocations + * since the hook installed. Monotonic; the harness snapshots before + * a trigger event and after the propagation wait, asserting on the + * DELTA (not the absolute count) so retries do not invalidate + * assertions. See Plan 01-11 RESEARCH §11 open-question resolution 2. + * + * Always 0 in the offscreen isolate (notifications.create is + * SW-only in this extension). + */ + notificationCount: number; + + /** + * The most recent `options` argument passed to + * `chrome.notifications.create(...)`. The harness asserts on + * `iconUrl` shape (matches /icons\/icon(?:128|48)\.png$/) and on + * `title` strings (e.g. 'Mokosh ready'). + * + * Generic argument: chrome.notifications.NotificationOptions is + * declared with a `` generic distinguishing the + * "create" shape (all fields required — true) from the "update" + * shape (all fields optional — false). Our production code always + * uses the create shape; we widen here to `NotificationOptions` + * so the harness can read iconUrl etc. as definitely-present. + * + * Null until the first notification fires. + */ + lastNotificationOptions: chrome.notifications.NotificationOptions | null; + + /** + * IDs of every notification created since hook install, in invocation + * order. The harness asserts ID-prefix membership (e.g. an id + * starting 'mokosh-startup-' MUST appear after fireOnStartup; an id + * starting 'mokosh-recovery-' MUST appear after RECORDING_ERROR + * other than 'user-stopped-sharing'). Defense in depth alongside + * notificationCount per RESEARCH §11 resolution 2. + * + * Returned as a defensive copy from the getter — mutating the + * returned array does not affect the underlying record. + */ + readonly notificationIds: ReadonlyArray; + + /** + * Read the currently-active MediaStream in the offscreen isolate, if + * any. Returns null when not recording. Used by the harness to read + * `getVideoTracks()[0].getSettings().displaySurface` (assertion 3) + * and to dispatch the 'ended' event on the track (assertion 6 — the + * Bug B simulation per RESEARCH §7 BLOCKER). + * + * Always undefined in the SW isolate; runtime null in the offscreen + * isolate signals "not currently recording" (the harness MUST start + * a recording before invoking this — see assertion 6 ordering). + */ + getCurrentStream?: () => MediaStream | null; + + /** + * Read the current count of segments in the offscreen recorder's + * ring buffer (the `segments: Blob[]` module-level array in + * src/offscreen/recorder.ts:62). Used by assertion 11 to verify + * the 30s window per D-13 (3 × 10s segments). + * + * Always undefined in the SW isolate. + */ + getSegmentCount?: () => number; +} + +declare global { + // The eslint disable directive mirrors the pattern in @types/chrome's + // ambient declarations; `var` is the only TS syntax that augments + // globalThis cleanly without a side-effecting `Window` augmentation + // (which would not apply in SW/offscreen contexts). + // eslint-disable-next-line no-var + var __mokoshTest: MokoshTestSurface | undefined; +} + +export {}; diff --git a/tests/uat/lib/test-hook-contract.d.ts b/tests/uat/lib/test-hook-contract.d.ts new file mode 100644 index 0000000..b9f87c6 --- /dev/null +++ b/tests/uat/lib/test-hook-contract.d.ts @@ -0,0 +1,48 @@ +// tests/uat/lib/test-hook-contract.d.ts — Plan 01-11 harness mirror. +// +// CANONICAL SOURCE: src/test-hooks/types.ts (production-side definition +// that ships with the test bundle and is type-checked by tsc as part of +// the `src/**/*` include). +// +// This file is a MANUAL MIRROR. Rationale (per Plan 01-11 RESEARCH §11 +// resolution 5): keeping tests/ and src/ import-separable means the +// Puppeteer harness has no `import` reaching into `src/`. The type +// duplication is small (3 fields + nested handlers shape) and any drift +// surfaces as a TypeScript error in the harness — the wire shape +// inspections (`evaluate(() => globalThis.__mokoshTest!.foo)`) are +// statically checked against this declaration. +// +// Drift detection: a Tier-1-style test could snapshot-diff this file +// against src/test-hooks/types.ts; out of scope for Plan 01-11 (small +// surface; reviewer-spottable). If the surface grows beyond ~6 fields, +// promote the diff check to a CI gate. +// +// References: +// - TypeScript ambient declaration files (`.d.ts`): +// https://www.typescriptlang.org/docs/handbook/declaration-files/templates.html +// - The canonical source: src/test-hooks/types.ts + +/** + * Mirror of `src/test-hooks/types.ts:MokoshTestSurface`. See that file + * for full field-by-field semantics; keep this declaration in sync + * whenever the canonical surface changes. + */ +interface MokoshTestSurfaceMirror { + handlers: { + onClicked: ((tab: chrome.tabs.Tab) => void | Promise) | null; + onStartup: (() => void | Promise) | null; + notificationOnClicked: ((notificationId: string) => void | Promise) | null; + }; + notificationCount: number; + lastNotificationOptions: chrome.notifications.NotificationOptions | null; + readonly notificationIds: ReadonlyArray; + getCurrentStream?: () => MediaStream | null; + getSegmentCount?: () => number; +} + +declare global { + // eslint-disable-next-line no-var + var __mokoshTest: MokoshTestSurfaceMirror | undefined; +} + +export {}; diff --git a/tsconfig.json b/tsconfig.json index daf96e8..5c6387a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,5 +16,5 @@ "noFallthroughCasesInSwitch": true, "types": ["chrome"] }, - "include": ["src"] + "include": ["src", "globals.d.ts"] } \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index 03a66b9..d99ab19 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -26,7 +26,29 @@ export default defineConfig({ ebml: 'ebml/lib/ebml.js', }, }, + // `define` text-replaces token symbols at bundle time. We declare + // __MOKOSH_UAT__ as `false` in the production config so the gated + // hook-import branches in src/background/index.ts + src/offscreen/ + // recorder.ts are static dead branches that Rollup tree-shakes. The + // test-only build (vite.test.config.ts) overrides this to `true`. We + // chose a dedicated token rather than gating on import.meta.env.MODE + // because vitest also uses MODE='test' by default — gating on MODE + // would activate the hooks during unit tests and overwrite the + // vi.fn() chrome.* mocks the existing 83-test baseline relies on. + // Reference: https://vite.dev/config/shared-options.html#define + define: { + __MOKOSH_UAT__: 'false', + }, build: { + // Plan 01-11: bump from default ES2020 to ES2022 so gated top-level + // await (`if (__MOKOSH_UAT__) { await import(...); }` in + // src/background/index.ts + src/offscreen/recorder.ts) compiles. + // The extension targets MV3 (Chrome ≥88); top-level await landed in + // Chrome 89 / Edge 89 / Firefox 89 / Safari 15 per MDN — comfortably + // inside the MV3 compatibility envelope. + // Reference: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Operators/await#browser_compatibility + // Reference: https://vite.dev/config/build-options.html#build-target + target: 'es2022', rollupOptions: { input: { offscreen: 'src/offscreen/index.html', diff --git a/vite.test.config.ts b/vite.test.config.ts index f867e6b..4e42dfc 100644 --- a/vite.test.config.ts +++ b/vite.test.config.ts @@ -34,6 +34,18 @@ export default defineConfig(({ command, mode }) => : baseAsExport, { mode: 'test', + // `define` performs a static text replacement at build time. We use a + // dedicated `__MOKOSH_UAT__` token (NOT `import.meta.env.MODE`) for the + // hook-import gate because vitest defaults its mode to 'test' too — + // gating on MODE would activate the hooks during unit tests and + // overwrite their vi.fn() mocks for chrome.notifications.create etc. + // The dedicated token is `false` everywhere except in this test bundle + // (Plan 01-11 RESEARCH §6 augmented — keeps Vite tree-shake semantics + // intact while sidestepping the vitest cross-contamination). + // Reference: https://vite.dev/config/shared-options.html#define + define: { + __MOKOSH_UAT__: 'true', + }, build: { outDir: 'dist-test', emptyOutDir: true, diff --git a/vitest.config.ts b/vitest.config.ts index ac49635..38e7872 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,19 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ + // Plan 01-11: declare `__MOKOSH_UAT__` as `false` for vitest's own + // SOURCE-loading test runs. Vitest's load pipeline goes through Vite, + // so `define` text-replaces the token in any source the test files + // import — keeping the gated test-hook dynamic imports (in + // src/background/index.ts + src/offscreen/recorder.ts) as static + // dead branches under unit tests. Without this, vitest would throw + // `ReferenceError: __MOKOSH_UAT__ is not defined` when loading those + // sources, OR would activate the hooks and clobber the existing + // vi.fn() chrome.* mocks the 83-test baseline relies on. + // Reference: https://vite.dev/config/shared-options.html#define + define: { + __MOKOSH_UAT__: 'false', + }, test: { environment: 'node', include: ['tests/**/*.test.ts'],