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:
19
globals.d.ts
vendored
Normal file
19
globals.d.ts
vendored
Normal file
@@ -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;
|
||||||
@@ -10,6 +10,26 @@ import type {
|
|||||||
import { remuxSegments } from './webm-remux';
|
import { remuxSegments } from './webm-remux';
|
||||||
import JSZip from 'jszip';
|
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
|
// Default MIME applied when a wire chunk somehow lacks a type
|
||||||
// field (defense-in-depth: in normal operation the offscreen recorder
|
// field (defense-in-depth: in normal operation the offscreen recorder
|
||||||
// always populates it from chunk.data.type). Matches D-20 strict codec.
|
// always populates it from chunk.data.type). Matches D-20 strict codec.
|
||||||
|
|||||||
@@ -18,6 +18,35 @@ import { OffscreenLogger } from '../shared/logger';
|
|||||||
import { blobToBase64 } from '../shared/binary';
|
import { blobToBase64 } from '../shared/binary';
|
||||||
import type { Message, TransferredVideoSegment } from '../shared/types';
|
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) ────────────
|
// ─── Константы (per CON-video-codec, CON-video-window, D-13) ────────────
|
||||||
// Длительность одного сегмента — 10 с (D-13, RESEARCH.md Pattern 3).
|
// Длительность одного сегмента — 10 с (D-13, RESEARCH.md Pattern 3).
|
||||||
export const SEGMENT_DURATION_MS = 10_000;
|
export const SEGMENT_DURATION_MS = 10_000;
|
||||||
@@ -245,6 +274,15 @@ async function startRecording(): Promise<void> {
|
|||||||
video: { displaySurface: 'monitor'; cursor: 'always' };
|
video: { displaySurface: 'monitor'; cursor: 'always' };
|
||||||
});
|
});
|
||||||
mediaStream = stream;
|
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.
|
// Post-grant validation — the constraint-hint-vs-enforcement gap.
|
||||||
// getDisplayMedia's `displaySurface` is a HINT, not a hard
|
// getDisplayMedia's `displaySurface` is a HINT, not a hard
|
||||||
// constraint: the operator may pick a tab/window from the picker
|
// constraint: the operator may pick a tab/window from the picker
|
||||||
@@ -468,6 +506,13 @@ function onUserStoppedSharing(): void {
|
|||||||
mediaStream = null;
|
mediaStream = null;
|
||||||
}
|
}
|
||||||
videoRecorder = 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' });
|
chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: 'user-stopped-sharing' });
|
||||||
// Reset the guard so a future startRecording → onUserStoppedSharing
|
// Reset the guard so a future startRecording → onUserStoppedSharing
|
||||||
// cycle works correctly. Place AFTER the broadcast so a same-tick
|
// cycle works correctly. Place AFTER the broadcast so a same-tick
|
||||||
|
|||||||
91
src/test-hooks/offscreen-hooks.ts
Normal file
91
src/test-hooks/offscreen-hooks.ts
Normal file
@@ -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 {};
|
||||||
230
src/test-hooks/sw-hooks.ts
Normal file
230
src/test-hooks/sw-hooks.ts
Normal file
@@ -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<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;
|
||||||
|
|
||||||
|
export {};
|
||||||
143
src/test-hooks/types.ts
Normal file
143
src/test-hooks/types.ts
Normal file
@@ -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<void>) | null;
|
||||||
|
/** chrome.runtime.onStartup — fires once per browser session start. */
|
||||||
|
onStartup: (() => void | Promise<void>) | null;
|
||||||
|
/** chrome.notifications.onClicked — fires when operator clicks a notification. */
|
||||||
|
notificationOnClicked: ((notificationId: string) => void | Promise<void>) | 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 `<true | false>` 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<true>`
|
||||||
|
* so the harness can read iconUrl etc. as definitely-present.
|
||||||
|
*
|
||||||
|
* Null until the first notification fires.
|
||||||
|
*/
|
||||||
|
lastNotificationOptions: chrome.notifications.NotificationOptions<true> | 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<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {};
|
||||||
48
tests/uat/lib/test-hook-contract.d.ts
vendored
Normal file
48
tests/uat/lib/test-hook-contract.d.ts
vendored
Normal file
@@ -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<void>) | null;
|
||||||
|
onStartup: (() => void | Promise<void>) | null;
|
||||||
|
notificationOnClicked: ((notificationId: string) => void | Promise<void>) | null;
|
||||||
|
};
|
||||||
|
notificationCount: number;
|
||||||
|
lastNotificationOptions: chrome.notifications.NotificationOptions<true> | null;
|
||||||
|
readonly notificationIds: ReadonlyArray<string>;
|
||||||
|
getCurrentStream?: () => MediaStream | null;
|
||||||
|
getSegmentCount?: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line no-var
|
||||||
|
var __mokoshTest: MokoshTestSurfaceMirror | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -16,5 +16,5 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"types": ["chrome"]
|
"types": ["chrome"]
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src", "globals.d.ts"]
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,29 @@ export default defineConfig({
|
|||||||
ebml: 'ebml/lib/ebml.js',
|
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: {
|
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: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
offscreen: 'src/offscreen/index.html',
|
offscreen: 'src/offscreen/index.html',
|
||||||
|
|||||||
@@ -34,6 +34,18 @@ export default defineConfig(({ command, mode }) =>
|
|||||||
: baseAsExport,
|
: baseAsExport,
|
||||||
{
|
{
|
||||||
mode: 'test',
|
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: {
|
build: {
|
||||||
outDir: 'dist-test',
|
outDir: 'dist-test',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
export default defineConfig({
|
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: {
|
test: {
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['tests/**/*.test.ts'],
|
include: ['tests/**/*.test.ts'],
|
||||||
|
|||||||
Reference in New Issue
Block a user