[per Plan 01-14; closes B-01-14-01 via Step 1b lockstep]
- src/offscreen/recorder.ts: add monitorTypeSurfaces:'include' as top-level
DisplayMediaStreamOptions sibling of video: (W3C Screen Capture spec §6.1;
Chrome >= 119; removes tab/window panes from the operator's picker per
Plan 01-10 RESEARCH §5 + §Pitfall-5 recommendation). Typed widening cast
extended in lockstep to keep the explicit-typing contract (no `as any`).
D-15 post-grant validation block at recorder.ts:294 UNCHANGED — belt
(picker narrowing) + suspenders (post-grant tear-down) chain preserved.
- tests/offscreen/display-surface-constraint.test.ts: lockstep update of
the strict-deep-equality assertion at lines 223-226 with the same key
ordering as the source change (video -> monitorTypeSurfaces -> audio).
toHaveBeenCalledWith contract preserved (NO expect.objectContaining —
the test author's "catches future drops of ANY field" discipline is
honored). This edit + the source change land in the SAME commit so the
98/98 baseline never crosses a commit boundary in RED state.
- src/test-hooks/offscreen-hooks.ts: capture last constraints object in
module-scoped `lastGetDisplayMediaConstraints` cell (was `_constraints`
received-but-unused; renamed to `constraints`); add `get-last-getDisplayMedia-constraints`
bridge op to the __mokoshOffscreenQuery dispatcher between
get-display-surface and get-segment-count. Defensive try/catch mirrors
the existing dispatcher pattern; the cell is module-internal so the
MokoshTestSurface cross-cast in types.ts requires NO change (decision
documented inline in offscreen-hooks.ts).
- tests/uat/extension-page-harness.ts: add `assertA23` mirroring `assertA3`
(bridge query → 2-check AssertionResult: non-null constraints + value).
Extend the `Window.__mokoshHarness` declaration + runtime export + status
bar text + console.log to reference A23.
- tests/uat/lib/harness-page-driver.ts: export `driveA23(page)` mirroring
the `driveA14` page.evaluate wrapper shape. Standard read-only driver.
- tests/uat/harness.test.ts: extend FORBIDDEN_HOOK_STRINGS (line 85) with
`lastGetDisplayMediaConstraints` and `get-last-getDisplayMedia-constraints`.
Import driveA23. Append `{ name: 'A23', drive: driveA23 }` to the drivers
array after the A14 entry. Update header comment + orchestrator stdout
to reflect A14 + A23 chain. The `Total = drivers.length + 1` arithmetic
adapts automatically: 14 + 1 = 15 → 15 + 1 = 16.
- tests/background/no-test-hooks-in-prod-bundle.test.ts: lockstep
extension of FORBIDDEN_HOOK_STRINGS (line 105) with the same 2 strings.
Header comment updated to "Total: 12 surface strings." (was 10).
Confirms production `dist/` has ZERO occurrences after `npm run build`
via the `__MOKOSH_UAT__` dead-branch tree-shake (T-01-14-04 mitigation).
D-01 (whole-desktop only via getDisplayMedia; reject window/tab surfaces) is
the design intent that monitorTypeSurfaces:'include' realizes at the picker-
UI level. D-15 post-grant validation (recorder.ts:294-307) remains the
actual enforcement against managed-policy/DevTools/older-Chrome overrides.
Verification chain (per Plan 01-14 §verify; clean post-commit):
- `npx tsc --noEmit` exit 0
- `npm run build` exit 0; dist/ produced, monitorTypeSurfaces ships in
the offscreen chunk as the operator-facing picker hint
- `npm run build:test` exit 0; dist-test/ produced with the harness
hooks intact (gated)
- `npm test` 100/100 GREEN (was 98/98; +2 via the 2 new FORBIDDEN_HOOK_STRINGS
parametrized tests — both PASS, production bundle hook-free)
- `npm run test:uat` 16/16 GREEN (15 → 16 via A23). A23 reads constraints
`{video: {...}, monitorTypeSurfaces: 'include', audio: false}` from the
fakeGetDisplayMedia capture cell — round-trips through the full call site.
- Production bundle spot-check:
`grep -rc 'lastGetDisplayMediaConstraints\|get-last-getDisplayMedia-constraints' dist/ | grep -v ':0$'`
→ empty (all `:0` filtered) → ZERO leakage.
References:
- W3C Screen Capture §6.1 DisplayMediaStreamOptions:
https://www.w3.org/TR/screen-capture/#dom-displaymediastreamoptions-monitortypesurfaces
- Chrome screen-sharing-controls (Chrome 119+):
https://developer.chrome.com/docs/web-platform/screen-sharing-controls
- Plan 01-10 RESEARCH §5 + §Pitfall-5 (recommendation provenance):
.planning/phases/01-stabilize-video-pipeline/01-10-RESEARCH.md
- Architectural-note (replaces retired AMENDMENT-A.md improvisation per
01-11-SUMMARY): canonical GSD ceremony — plan → checker (B-01-14-01)
→ executor → SUMMARY (this commit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
290 lines
12 KiB
TypeScript
290 lines
12 KiB
TypeScript
// tests/background/no-test-hooks-in-prod-bundle.test.ts
|
|
//
|
|
// Tier-1 hook-leak gate (Plan 01-11 Task 1) — sibling of
|
|
// `sw-bundle-import.test.ts`. Both gates inspect the BUILT `dist/`
|
|
// artifact for an invariant the SOURCE alone cannot prove.
|
|
//
|
|
// What this gate enforces — the security-critical invariant T-1-11-01:
|
|
//
|
|
// Plan 01-11 introduces test-only "hook" surfaces under `src/test-hooks/`
|
|
// that expose internal SW + offscreen state (captured chrome.* handler
|
|
// refs, MediaStream getter, simulated user-stopped-sharing trigger) to
|
|
// the Puppeteer harness via a global named `__mokoshTest`. The hooks
|
|
// ship in the TEST bundle (`dist-test/`) and MUST NOT ship in the
|
|
// PRODUCTION bundle (`dist/`) — leaking them would expose Bug B's
|
|
// `simulateUserStop` path + the captured `onStartup` handler ref to
|
|
// any page that can `eval` against the extension's SW.
|
|
//
|
|
// The leak is prevented by a Vite mode gate: each hook import in
|
|
// src/background/index.ts + src/offscreen/recorder.ts is wrapped in
|
|
// `if (import.meta.env.MODE === 'test') { await import('../test-hooks/...'); }`.
|
|
// Vite statically replaces `import.meta.env.MODE` at build time
|
|
// (production mode → `'production'`); the `'production' === 'test'`
|
|
// comparison is a static dead branch and Rollup tree-shakes the
|
|
// `await import` away entirely. That tree-shake is what THIS GATE
|
|
// verifies — by greping the built artifact tree for the hook surface
|
|
// strings and asserting they are absent.
|
|
//
|
|
// Why a unit-level gate IN ADDITION TO the harness's assertion 0:
|
|
// The harness's assertion 0 runs only when the harness runs (`npm run
|
|
// test:uat`), which requires a Chrome download + ~90s wall clock. The
|
|
// unit gate runs as part of the regular `npm test` pass — every
|
|
// developer's pre-push hook + every CI vitest job catches the leak in
|
|
// <15s. Belt + suspenders per Plan 01-11 RESEARCH §6 + the orchestrator-
|
|
// loaded `feedback-pre-checkpoint-bundle-gates.md` memory: any future
|
|
// plan executor whose work surfaces a SW build MUST keep this gate
|
|
// GREEN before any operator-empirical checkpoint.
|
|
//
|
|
// Polarity note: the gate is GREEN today (no hooks land until Plan 01-11
|
|
// Task 2) AND must STAY GREEN after Task 2 lands them. The test is
|
|
// committed BEFORE the hooks ship so the invariant is asserted from day
|
|
// one — eliminating any window-of-vulnerability where the production
|
|
// bundle could carry leaked hooks unnoticed.
|
|
//
|
|
// Surface inventory enforced (each MUST be absent from any file under
|
|
// dist/). Plan 01-13 Wave 0 updated this list for the Approach-B
|
|
// architecture (extension-internal harness page + offscreen-side
|
|
// synthetic stream + chrome.runtime.sendMessage bridge), replacing the
|
|
// 01-11 Approach-A SW-side instrumentation surface. The 01-11 entries
|
|
// `simulateUserStop` (renamed to `dispatchEndedOnTrack` to match the
|
|
// W3C dispatchEvent semantics per RESEARCH §7 BLOCKER) is dropped.
|
|
//
|
|
// - `__mokoshTest` — the global surface name itself
|
|
// - `setCurrentStream` — Plan 01-11 Task 2 offscreen wire (retained)
|
|
// - `setSegmentCountGetter` — Plan 01-11 Task 7 offscreen wire (retained)
|
|
// - `installFakeDisplayMedia` — 01-13 synthetic getDisplayMedia install
|
|
// - `uninstallFakeDisplayMedia` — 01-13 synthetic getDisplayMedia teardown
|
|
// - `dispatchEndedOnTrack` — 01-13 Bug B simulate via dispatchEvent
|
|
// (replaces Approach-A `simulateUserStop`)
|
|
// - `getSegmentCount` — Plan 01-11 Task 7 segments-count getter (retained)
|
|
// - `__mokoshOffscreenQuery` — 01-13 page→offscreen bridge message type
|
|
// - `get-display-surface` — 01-13 Wave 3A bridge op string (A3 contract)
|
|
// - `get-segment-count` — 01-13 Wave 3D bridge op string (A11 contract)
|
|
// - `lastGetDisplayMediaConstraints` — 01-14 A23 offscreen-side cell name
|
|
// - `get-last-getDisplayMedia-constraints` — 01-14 A23 bridge op string
|
|
//
|
|
// Total: 12 surface strings. Each MUST be absent from EVERY file under
|
|
// `dist/` post-build. The list is mirrored by the harness's A0
|
|
// assertion (tests/uat/harness.test.ts in Wave 3A; Plan 01-14 lockstep
|
|
// extension) so the same invariant is enforced at unit-test time
|
|
// (fast, every CI run) AND at UAT-harness time (belt+suspenders per
|
|
// the orchestrator-loaded `feedback-pre-checkpoint-bundle-gates.md`
|
|
// memory).
|
|
//
|
|
// Implementation mirrors `sw-bundle-import.test.ts`'s execFile pattern:
|
|
// - Spawn `npm run build` via execFile so the build is reproducible
|
|
// and the gate runs against a known-clean artifact.
|
|
// - Skip the build if `process.env.SKIP_BUILD === '1'` — developer
|
|
// escape hatch when iterating on the gate itself.
|
|
// - Recursively walk `dist/` reading files synchronously (the tree is
|
|
// small; ~10 chunks; ~500 KB total).
|
|
// - For each forbidden string, count total occurrences and report the
|
|
// offending file paths on failure.
|
|
//
|
|
// References:
|
|
// - Vite mode + `import.meta.env.MODE`:
|
|
// https://vite.dev/guide/env-and-mode.html
|
|
// - Rollup tree-shaking + dead-branch elimination:
|
|
// https://rollupjs.org/configuration-options/#treeshake
|
|
// - Node `child_process.execFile`:
|
|
// https://nodejs.org/api/child_process.html#child_processexecfilefile-args-options-callback
|
|
// - Node `fs.readdirSync` + `withFileTypes`:
|
|
// https://nodejs.org/api/fs.html#fsreaddirsyncpath-options
|
|
|
|
import { execFile } from 'node:child_process';
|
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
import { resolve as resolvePath } from 'node:path';
|
|
import { promisify } from 'node:util';
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
/**
|
|
* Surface strings the gate forbids in any file under `dist/`. Order is
|
|
* preserved in failure diagnostics so the report is stable across runs.
|
|
* Each entry's rationale lives in the file header above.
|
|
*/
|
|
const FORBIDDEN_HOOK_STRINGS: ReadonlyArray<string> = [
|
|
'__mokoshTest',
|
|
'setCurrentStream',
|
|
'setSegmentCountGetter',
|
|
'installFakeDisplayMedia',
|
|
'uninstallFakeDisplayMedia',
|
|
'dispatchEndedOnTrack',
|
|
'getSegmentCount',
|
|
'__mokoshOffscreenQuery',
|
|
'get-display-surface',
|
|
'get-segment-count',
|
|
// Plan 01-14 A23 surface — lockstep with UAT A0 inventory at
|
|
// tests/uat/harness.test.ts:85. Verifies the `__MOKOSH_UAT__` Vite
|
|
// define-token dead-branch tree-shake correctly elides the new
|
|
// `lastGetDisplayMediaConstraints` cell + the `get-last-getDisplayMedia-constraints`
|
|
// bridge op from production `dist/` (T-01-14-04 mitigation).
|
|
'lastGetDisplayMediaConstraints',
|
|
'get-last-getDisplayMedia-constraints',
|
|
];
|
|
|
|
/** How long the build child has to finish (`npm run build` is ~10s).
|
|
* Generous cap; if it blows past this something else is wrong. */
|
|
const BUILD_TIMEOUT_MS = 60_000;
|
|
|
|
/** Absolute path to the production output directory. */
|
|
const DIST_DIR = resolvePath(process.cwd(), 'dist');
|
|
|
|
/**
|
|
* One match in one file. Held in a flat array per forbidden string so
|
|
* the failure message can enumerate every (file, count) pair.
|
|
*/
|
|
interface ForbiddenMatch {
|
|
readonly filePath: string;
|
|
readonly count: number;
|
|
}
|
|
|
|
/**
|
|
* Recursively collect every regular file under `root`. Returns absolute
|
|
* paths. Skips symlinks defensively (none expected in the Vite output
|
|
* tree, but cheap to guard against).
|
|
*
|
|
* @param root - Absolute directory path to walk.
|
|
* @returns Sorted list of absolute file paths under `root`.
|
|
*/
|
|
function listAllFilesRecursive(root: string): ReadonlyArray<string> {
|
|
const accumulator: string[] = [];
|
|
const stack: string[] = [root];
|
|
while (stack.length > 0) {
|
|
const dir = stack.pop()!;
|
|
const entries = readdirSync(dir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const fullPath = resolvePath(dir, entry.name);
|
|
if (entry.isSymbolicLink()) {
|
|
continue;
|
|
}
|
|
if (entry.isDirectory()) {
|
|
stack.push(fullPath);
|
|
} else if (entry.isFile()) {
|
|
accumulator.push(fullPath);
|
|
}
|
|
}
|
|
}
|
|
return accumulator.sort();
|
|
}
|
|
|
|
/**
|
|
* Count occurrences of `needle` inside the given file's text content.
|
|
* Returns 0 when the file is binary-ish (no occurrences of a likely-text
|
|
* sentinel character class). Vite emits JS/CSS/HTML/JSON — all UTF-8 —
|
|
* plus copies of PNG icons. We skip files whose extensions clearly mark
|
|
* them as binary so readFileSync('utf8') does not return mojibake that
|
|
* could accidentally match `needle`.
|
|
*
|
|
* @param filePath - Absolute file path to scan.
|
|
* @param needle - Literal substring to count.
|
|
* @returns Total occurrences of `needle` in the file's text.
|
|
*/
|
|
function countOccurrencesInFile(filePath: string, needle: string): number {
|
|
const binaryExtensions = new Set(['.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp', '.woff', '.woff2', '.ttf', '.otf']);
|
|
const dotIdx = filePath.lastIndexOf('.');
|
|
const ext = dotIdx >= 0 ? filePath.substring(dotIdx).toLowerCase() : '';
|
|
if (binaryExtensions.has(ext)) {
|
|
return 0;
|
|
}
|
|
const stat = statSync(filePath);
|
|
if (stat.size === 0) {
|
|
return 0;
|
|
}
|
|
const text = readFileSync(filePath, 'utf8');
|
|
let count = 0;
|
|
let from = 0;
|
|
for (;;) {
|
|
const idx = text.indexOf(needle, from);
|
|
if (idx < 0) {
|
|
break;
|
|
}
|
|
count += 1;
|
|
from = idx + needle.length;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
/**
|
|
* Walk `dist/` and find every file containing `needle`. Returns an
|
|
* array of (file, count) pairs sorted by file path. Empty when the
|
|
* needle is absent — that is the GREEN-gate condition.
|
|
*
|
|
* @param needle - Literal substring to grep for.
|
|
* @returns List of matches; empty array on absence.
|
|
*/
|
|
function findMatchesInDist(needle: string): ReadonlyArray<ForbiddenMatch> {
|
|
const files = listAllFilesRecursive(DIST_DIR);
|
|
const matches: ForbiddenMatch[] = [];
|
|
for (const filePath of files) {
|
|
const count = countOccurrencesInFile(filePath, needle);
|
|
if (count > 0) {
|
|
matches.push({ filePath, count });
|
|
}
|
|
}
|
|
return matches;
|
|
}
|
|
|
|
/**
|
|
* Spawn `npm run build` in a child process; reject on non-zero exit OR
|
|
* on timeout. Inherits parent env (we want the same `NODE_OPTIONS` etc.
|
|
* the developer set), but suppresses Node experimental warnings to
|
|
* keep vitest's failure output readable.
|
|
*
|
|
* @returns void on success; throws with build stderr captured on failure.
|
|
*/
|
|
async function runProductionBuild(): Promise<void> {
|
|
await execFileAsync('npm', ['run', 'build'], {
|
|
timeout: BUILD_TIMEOUT_MS,
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
env: { ...process.env, NODE_NO_WARNINGS: '1' },
|
|
});
|
|
}
|
|
|
|
describe('production bundle has no test-hook leaks (Tier-1 gate — T-1-11-01)', () => {
|
|
it('npm run build completes and dist/ exists with at least one chunk', async () => {
|
|
if (process.env.SKIP_BUILD !== '1') {
|
|
await runProductionBuild();
|
|
}
|
|
expect(
|
|
existsSync(DIST_DIR),
|
|
`dist/ missing at ${DIST_DIR}. Either npm run build failed or SKIP_BUILD=1 was set ` +
|
|
`without a pre-existing build. The hook-leak gate cannot run without a built artifact.`,
|
|
).toBe(true);
|
|
const files = listAllFilesRecursive(DIST_DIR);
|
|
expect(
|
|
files.length,
|
|
`dist/ is empty after npm run build — the build produced no output, which is a different ` +
|
|
`regression class than a hook leak. Investigate before proceeding to the hook-leak assertion.`,
|
|
).toBeGreaterThan(0);
|
|
});
|
|
|
|
for (const needle of FORBIDDEN_HOOK_STRINGS) {
|
|
it(`production bundle does not contain '${needle}' (T-1-11-01 surface)`, () => {
|
|
// If the build did not run in the previous test (SKIP_BUILD=1) AND
|
|
// dist/ is missing, surface a clear diagnostic instead of letting
|
|
// the recursive walk throw an obscure ENOENT.
|
|
if (!existsSync(DIST_DIR)) {
|
|
throw new Error(
|
|
`dist/ missing — run \`npm run build\` first (SKIP_BUILD=1 is set but no prior build artifact exists).`,
|
|
);
|
|
}
|
|
const matches = findMatchesInDist(needle);
|
|
expect(
|
|
matches.length,
|
|
matches.length === 0
|
|
? 'unreachable'
|
|
: `Production bundle contains '${needle}' in ${matches.length} file(s) — this would leak ` +
|
|
`the Plan 01-11 test-hook surface to production. The Vite MODE-gate on the dynamic ` +
|
|
`import has regressed (verify the literal-comparison branch in src/background/index.ts ` +
|
|
`or src/offscreen/recorder.ts is still on the static-replacement path). Offending files:\n` +
|
|
matches
|
|
.map((m) => ` - ${m.filePath} (${m.count} occurrence${m.count === 1 ? '' : 's'})`)
|
|
.join('\n'),
|
|
).toBe(0);
|
|
});
|
|
}
|
|
});
|