Files
mokosh/tests/uat/lib/offscreen.ts
Mark dbd977c815 feat(01-11): wave-2 — Puppeteer harness scaffolding + A0 GREEN, popup-bridge architecture
Task 3 of Plan 01-11 (Puppeteer UAT harness).

Harness file tree (tests/uat/):
- harness.test.ts: tsx-runnable top-to-bottom harness entry point.
  Runs A0 inline (filesystem grep gate, abort-on-fail T-1-11-01),
  then launches Chrome + opens popup bridge + queries manifest, then
  iterates A1-A13 stubs. Each stub throws "NOT YET IMPLEMENTED —
  Plan 01-11 Task N wires this assertion". Exit code = 0 on full
  pass, 1 otherwise. Final line: "UAT harness: N/14 assertions passed".
- lib/launch.ts: launchHarnessBrowser() — wraps puppeteer.launch with
  enableExtensions:[dist-test/], headless default (HEADLESS=0
  override), --no-sandbox + --auto-select-desktop-capture-source flags.
  Polls browser.extensions() until the extension registers (empirically
  ~100ms but the first call right after launch returns Map(0)).
  Opens both a blank page (for triggerExtensionAction) AND the popup
  page (the bridge surface). Returns { browser, extension, extensionId,
  sw, downloadsDir, page, popup }.
- lib/extension.ts: waitForOffscreenTarget + attachToOffscreen +
  countOffscreenTargets. Offscreen attach uses target.type() ===
  'background_page' + .asPage() (NOT .page() — RESEARCH §4 Pitfall 1).
- lib/sw.ts: chrome.* state queries via the POPUP page handle (NOT
  the WebWorker handle — see architecture note below). getBadgeText,
  getPopup, getManifest, getIconSize, getIsRecording (side-channeled
  through badge text), fireOnStartup (via __mokoshTestQuery bridge),
  sendSyntheticRecordingError, getNotificationSnapshot (via bridge),
  keepalivePing (no-op message to wake SW for ~30s).
- lib/offscreen.ts: getDisplaySurface, simulateUserStop (the
  dispatchEvent('ended') path per RESEARCH §7 BLOCKER — DO NOT REFACTOR
  to track.stop()), getSegmentCount.
- lib/assertions.ts: runAssertion(idx, name, buffers, fn) wrapper —
  records pass/fail/duration; on failure dumps last 30 lines of SW
  + offscreen console buffers to stderr before rethrowing. assertEqual
  / assertMatch / assertTrue / assertGte / waitFor polling helper.
- lib/zip.ts: jszip-based assertArchiveShape + extractEntryToFile for
  assertions 12 + 13.
- README.md: runtime + local-debug + CI semantics + locale gotcha
  + dev-dep size note + assertion catalog table.
- tsconfig.json: per-tree type-check config (mirrors root tsconfig.json
  compiler options but includes the harness tree explicitly).

Architecture refinement (DEVIATION from RESEARCH §1 — Rule 1+3 inline fix):
- RESEARCH §1 sketched `sw.evaluate(() => chrome.action.getBadgeText({}))`
  as the chrome.* query path. Empirical probes during Task 3 execution
  against Puppeteer 25.0.2 + Chrome 148 + --headless=true revealed two
  blockers:
    1. Puppeteer's WebWorker.evaluate runs in an ISOLATED WORLD that
       carries SW globals (clients, registration, ...) but NOT the
       extension's full chrome.* API surface. Object.keys(chrome) inside
       sw.evaluate returns ["loadTimes","csi"] — the public webpage
       chrome, not the extension chrome.
    2. Chrome 148's headless mode aggressively suspends MV3 service
       workers; subsequent swTarget.worker() calls return
       "Protocol error: No target with given id found".
- WORKAROUND: open the popup page (chrome-extension://<id>/src/popup/
  index.html) as a separate Puppeteer Page. The popup has full
  chrome.* access (it's an extension context with same privileges as
  the SW) AND stable Puppeteer lifetime. For SW-globalThis state
  (__mokoshTest in the SW isolate, NOT in the popup), bridge via
  chrome.runtime.sendMessage. The popup sends
  { type: '__mokoshTestQuery', op: 'snapshot' | 'fire-on-startup' |
  'handler-types' }; the SW hook's onMessage handler responds.
- Bridge implementation added to src/test-hooks/sw-hooks.ts — registers
  AFTER the production listeners so it never intercepts production
  messages (__mokoshTest* type is unambiguously test-only). Tier-1
  grep gate (no-test-hooks-in-prod-bundle.test.ts) continues to enforce
  ZERO __mokoshTest occurrences in dist/ — the bridge handler is
  tree-shaken alongside the rest of the hook module via the
  __MOKOSH_UAT__ gate.

Other configuration changes:
- vitest.config.ts: exclude tests/uat/** from vitest discovery. The
  Puppeteer harness is invoked via `npm run test:uat` (not vitest);
  running it under vitest would try to launch real Chrome inside a
  vitest worker. The .test.ts suffix is retained for editor +
  naming-convention consistency with the rest of the tree.

Verification:
- npx tsc --noEmit (src/): exit 0
- npx tsc --noEmit -p tests/uat: exit 0
- npm run build: exit 0
- grep -rln '__mokoshTest|simulateUserStop|getSegmentCount|setCurrentStream|setSegmentCountGetter|__mokoshTestQuery|__mokoshKeepalive' dist/: ZERO matches
- npm run build:test: exit 0; dist-test/ populated with the new bridge code
- SKIP_BUILD=1 npx vitest run: 89/89 GREEN
- SKIP_PROD_REBUILD=1 npx tsx tests/uat/harness.test.ts:
  → A0 [PASS]: production bundle has no test-hook leaks (19ms)
  → Browser launches; popup opens; manifest read succeeds
  → A1-A13 [FAIL]: NOT YET IMPLEMENTED — Plan 01-11 Task N wires this
  → "UAT harness: 1/14 assertions passed, 13 failed (first failure: A1)"
  → Exit code: 1 (expected — 13 RED stubs intentional)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:14:58 +02:00

108 lines
3.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// tests/uat/lib/offscreen.ts — Plan 01-11 harness offscreen-context helpers.
//
// Each helper is a thin wrapper over `offPage.evaluate(() => ...)`.
// The Bug B BLOCKER (RESEARCH §7) lives in simulateUserStop —
// DO NOT REFACTOR to track.stop().
//
// References:
// - MediaStreamTrack 'ended' event:
// https://developer.mozilla.org/docs/Web/API/MediaStreamTrack/ended_event
// - MediaStreamTrack.stop spec note (stop does NOT fire 'ended' on the same track):
// https://www.w3.org/TR/mediacapture-streams/#dom-mediastreamtrack-stop
import type { Page } from 'puppeteer';
/// <reference path="./test-hook-contract.d.ts" />
/**
* Read the displaySurface from the active MediaStream's video track.
* Used by assertion 3 to verify monitor-only enforcement (the
* post-grant validation in src/offscreen/recorder.ts).
*
* Returns null when there is no active recording (the harness MUST
* start a recording before calling this).
*
* @param offPage - Offscreen Page handle.
* @returns 'monitor' on success, other strings on regression, null when no stream.
*/
export async function getDisplaySurface(offPage: Page): Promise<string | null> {
return await offPage.evaluate(() => {
const hook = globalThis.__mokoshTest;
if (hook === undefined || hook.getCurrentStream === undefined) {
return null;
}
const stream = hook.getCurrentStream();
if (stream === null) {
return null;
}
const track = stream.getVideoTracks()[0];
if (track === undefined) {
return null;
}
const ds = track.getSettings().displaySurface;
return typeof ds === 'string' ? ds : null;
});
}
/**
* Simulate the operator clicking Chrome's "Stop sharing" overlay.
*
* **BLOCKER (RESEARCH §7) — DO NOT REFACTOR to `track.stop()`.**
*
* `track.stop()` releases the capture but does NOT fire the 'ended'
* event on the same track per the W3C Screen Capture spec. The
* production `onUserStoppedSharing` handler (src/offscreen/recorder.ts:
* 451) is wired to 'ended' — using `track.stop()` would silently bypass
* the entire Bug B fix path that this assertion exists to verify.
*
* `track.dispatchEvent(new Event('ended'))` IS the only path that
* triggers our handler. After dispatch, the production handler calls
* `stream.getTracks().forEach(t => t.stop())` which DOES release the
* capture (just doesn't refire 'ended' on the same track — spec-correct).
*
* @param offPage - Offscreen Page handle.
* @throws If no active MediaStream OR no video track in the stream.
*/
export async function simulateUserStop(offPage: Page): Promise<void> {
await offPage.evaluate(() => {
const hook = globalThis.__mokoshTest;
if (hook === undefined || hook.getCurrentStream === undefined) {
throw new Error('simulateUserStop: __mokoshTest.getCurrentStream missing');
}
const stream = hook.getCurrentStream();
if (stream === null) {
throw new Error(
'simulateUserStop: no current MediaStream — recording must be active',
);
}
const track = stream.getVideoTracks()[0];
if (track === undefined) {
throw new Error('simulateUserStop: no video track in stream');
}
// CRITICAL: dispatchEvent, NOT track.stop(). See preamble for the
// BLOCKER analysis (RESEARCH §7).
track.dispatchEvent(new Event('ended'));
});
}
/**
* Read the current segment count from the offscreen recorder's ring
* buffer. Used by assertion 11 to verify the 30s window per D-13
* (3 × 10s segments expected after 35s of recording).
*
* Returns -1 when the hook is not installed (defensive — should
* never happen against a dist-test/ bundle).
*
* @param offPage - Offscreen Page handle.
* @returns Current segment count.
*/
export async function getSegmentCount(offPage: Page): Promise<number> {
return await offPage.evaluate(() => {
const hook = globalThis.__mokoshTest;
if (hook === undefined || hook.getSegmentCount === undefined) {
return -1;
}
return hook.getSegmentCount();
});
}