Files
mokosh/tests/uat/lib/launch.ts
Mark 6a77967b6c feat(01-13): wave-3B — A5+A6+A7 GREEN + Bug B canonical regression rewind
Wave 3B lands the A5 (SAVE_ARCHIVE → zip on disk) and A7 (genuine
RECORDING_ERROR → ERR + recovery notification) assertions, completing
8/14 of the orchestrator's GREEN floor (A0+A1+A2+A3+A4+A5+A6+A7).
Bails at A8 (Wave 3C scope).

Changes per file:

  tests/uat/extension-page-harness.ts
    - assertA5: 11s settle (>= SEGMENT_DURATION_MS so first rotation
      lands a segment) + send SAVE_ARCHIVE + assert resp.success=true.
      Page-side only checks SW handler ack; host-side driver verifies
      disk-side outcome (zip presence + size floor).
    - assertA7: setupFreshRecording helper (A6 tears down; A7 needs
      REC state) → snapshot notif count → send RECORDING_ERROR with
      a non-Bug-B error code ('codec-unsupported') → 200ms settle →
      assert badge='ERR' + popup endsWith popup.html + notif delta=1
      + set-membership for 'mokosh-recovery-*' prefix.
    - setupFreshRecording: shared helper for A7 + future assertions
      that need a fresh REC state after a teardown.

  tests/uat/lib/harness-page-driver.ts
    - driveA5: page.evaluate(assertA5) THEN host-side fs polling for
      *.zip in handles.downloadsDir. The CDP Browser.setDownloadBehavior
      override renames the file to download.zip (data: URL filename
      gap), so we accept any *.zip suffix. Merges page-side check +
      host-side checks into a single AssertionRecord. Signature now
      takes downloadsDir as a second arg.
    - driveA7: standard page.evaluate wrapper (no host-side work).

  tests/uat/harness.test.ts
    - Wraps driveA5 in a closure that captures handles.downloadsDir.
    - Reordered: launchHarnessBrowser MUST run before driver list so
      the closure can read handles without a TDZ trap.

  tests/uat/lib/launch.ts
    - Victim page switched from about:blank to a file:// URL backed by
      a tmp HTML file in downloadsDir. About:blank breaks A5 because
      chrome.tabs.captureVisibleTab needs <all_urls> permission which
      matches http/https/file/ftp but NOT about: or data: URLs. The
      stub HTML satisfies <all_urls> + provides a real .url for the
      production saveArchive's chrome.tabs.query.

  src/test-hooks/offscreen-hooks.ts (test-only — tree-shaken from prod)
    - installFakeDisplayMedia: mintStream() helper called per
      fakeGetDisplayMedia invocation; each call mints a FRESH
      MediaStream from the persistent canvas. Real getDisplayMedia
      returns a new stream per call — fake now matches. Required for
      A7's setupFreshRecording where the previous recording's stream
      tracks were stopped by A6's onUserStoppedSharing teardown.
    - Added 33ms setInterval-driven drawFrame() alongside the
      existing requestAnimationFrame loop. RAF can throttle in
      headless Chrome on offscreen documents (page-visibility
      heuristics produce 0 fps), which yields zero-byte
      MediaRecorder segments that crash ts-ebml's VINT decode in
      webm-remux.extractFramesFromSegment with "Unrepresentable
      length: Infinity". The setInterval is redundant when RAF fires
      at full rate; it's a safety net for the headless-MV3 corner.

Bug B regression-catch demo (success_criteria #3 — MANDATORY per plan):

Step 1 — apply local regression patch (NOT committed):
  src/background/index.ts:792  setIdleMode() → setErrorMode()

Step 2 — npm run build:test && npm run test:uat RED snippet:

  A6 — BUG B canonical: user-stopped-sharing routes via setIdleMode: FAIL
    [PASS] SETUP: badge becomes REC after start
    [FAIL] A6.1: badge text is '' (NOT 'ERR') after user-stop
           expected: ""
           actual:   "ERR"
    [FAIL] A6.2: popup is '' (NOT manifest default) after user-stop
           expected: ""
           actual:   "chrome-extension://<id>/src/popup/index.html"
    [PASS] A6.3: NO recovery notification fired (count delta === 0)
    [PASS] A6.4: isRecording=false (via badge proxy)

  UAT harness: 6/14 assertions passed (bailed: A6 failed; see above)

Step 3 — revert local patch (git checkout -- src/background/index.ts).

Step 4 — npm run build:test && npm run test:uat GREEN snippet:

  A6 — BUG B canonical: user-stopped-sharing routes via setIdleMode: PASS
    [PASS] SETUP: badge becomes REC after start
    [PASS] A6.1: badge text is '' (NOT 'ERR') after user-stop
    [PASS] A6.2: popup is '' (NOT manifest default) after user-stop
    [PASS] A6.3: NO recovery notification fired (count delta === 0)
    [PASS] A6.4: isRecording=false (via badge proxy)

  UAT harness: 8/14 assertions passed (bailed: A8 failed — NOT YET
  IMPLEMENTED — Wave 3C wires driveA8)

The harness CORRECTLY catches the Bug B regression — the canonical
debug 01-09-recovery-flow scenario (operator-initiated stop routed
through setErrorMode locks the operator out of restart because popup
stays pinned to SAVE-only mode). Bug B is now CI-callable end-to-end.

vitest 93/93 GREEN throughout (unit-test layer unaffected). Tier-1
grep gate GREEN (9 forbidden hook strings: 0 occurrences in dist/).
npm run build exit 0; npx tsc --noEmit exit 0.

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

427 lines
17 KiB
TypeScript

// tests/uat/lib/launch.ts — Plan 01-13 Wave 2.
//
// Approach-B harness launch helper. Inherits the Puppeteer launch +
// victim-page-bringToFront + harness-page-open pattern from the proven
// `tests/uat/a6.test.ts` driver (originally landed as Plan 01-11
// prototype at commit c647f61; promoted to production paths by 01-13
// Wave 1). Refactored into a reusable helper so Wave 3's 13 assertion
// drivers share the same setup overhead — one Chrome launch + one
// harness page + one victim page per `npm run test:uat` run.
//
// Architectural commitments (per 01-11-SUMMARY.md, DO NOT REGRESS):
// - Drive Chrome FROM INSIDE: `harnessPage` runs at
// `chrome-extension://<id>/tests/uat/extension-page-harness.html`
// with full chrome.* API access (Approach B; sw.evaluate fallback
// was falsified per SUMMARY §2 — only chrome.{loadTimes,csi}
// surfaced through CDP).
// - `victimPage` is a brought-to-front about:blank tab so the
// production `chrome.tabs.query({active:true})` sees a real tab
// with a `.url` (Plan 01-13 retains the `tabs` permission gap as
// out-of-scope; A2 + similar tests send `START_RECORDING` directly
// to offscreen, bypassing the SW's `startVideoCapture` which needs
// the tabs permission to read `tab.url`). Workaround documented in
// the plan's resolved-open-questions table row 2.
// - Downloads land in a per-run tmp dir (`mkdtempSync`) so A5 polling
// does not collide with operator downloads. Configured via CDP
// `Browser.setDownloadBehavior` on the harness page's CDP session.
// - SW + offscreen consoles forwarded to `swConsole` / `offConsole`
// accumulating string buffers. Offscreen attach via
// `browser.on('targetcreated')` is OPPORTUNISTIC per the prototype
// pattern — offscreen targets appear asynchronously when
// `chrome.offscreen.createDocument` runs from inside the harness
// page; the harness must not block waiting for them.
// - NO `--auto-select-desktop-capture-source` flag: unreliable in
// `--headless=new` per 01-11-SUMMARY falsification 4. The synthetic
// `installFakeDisplayMedia` (offscreen-hooks.ts eager install)
// bypasses Chrome's picker entirely.
//
// References:
// - puppeteer.launch options:
// https://pptr.dev/api/puppeteer.launchoptions
// - puppeteer.Browser.extensions():
// https://pptr.dev/api/puppeteer.browser.extensions
// - CDP Browser.setDownloadBehavior (per-context download path):
// https://chromedevtools.github.io/devtools-protocol/tot/Browser/#method-setDownloadBehavior
// - puppeteer CDP session helper:
// https://pptr.dev/api/puppeteer.cdpsession
// - Node fs.mkdtempSync:
// https://nodejs.org/api/fs.html#fsmkdtempsyncprefix-options
import { existsSync, mkdtempSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve as resolvePath } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import puppeteer, { type Browser, type Page } from 'puppeteer';
/** Repo root resolved from this file's location (tests/uat/lib/launch.ts). */
const HARNESS_FILE_DIR = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolvePath(HARNESS_FILE_DIR, '..', '..', '..');
const DIST_TEST_DIR = resolvePath(REPO_ROOT, 'dist-test');
/** Time bounds for the various polling/attach steps. Keep in sync with
* the comments below — each value has a rationale, not a guess. */
const EXTENSION_ID_POLL_TIMEOUT_MS = 5_000;
const EXTENSION_ID_POLL_INTERVAL_MS = 100;
const HARNESS_BOOTSTRAP_TIMEOUT_MS = 5_000;
const HARNESS_GOTO_TIMEOUT_MS = 10_000;
const SW_TARGET_ATTACH_TIMEOUT_MS = 10_000;
/** Bumped from the puppeteer default 30s to give the assertions
* several sendMessage round-trips of CDP headroom on slow CI runners. */
const PROTOCOL_TIMEOUT_MS = 90_000;
/**
* Handles returned by `launchHarnessBrowser`. The caller owns the
* `browser` and is responsible for calling `browser.close()` in a
* `finally` block. `downloadsDir` is created by this function and is
* deliberately NOT cleaned up automatically — failing tests benefit
* from the operator inspecting the downloads dir post-mortem.
*/
export interface HarnessHandles {
readonly browser: Browser;
readonly extensionId: string;
readonly harnessPage: Page;
readonly victimPage: Page;
readonly downloadsDir: string;
/** Accumulating SW console log lines, format `[sw:<type>] <text>`. */
readonly swConsole: string[];
/** Accumulating offscreen console log lines, format `[off:<type>] <text>`. */
readonly offConsole: string[];
}
/**
* Options for `launchHarnessBrowser`. All fields optional; defaults
* apply (`headless: process.env.HEADLESS !== '0'`; `downloadsDir` ←
* fresh mkdtempSync).
*/
export interface LaunchOptions {
/** Override `--headless=new`; useful for visual debugging. */
readonly headless?: boolean;
/** Override the auto-created downloads dir; useful for cross-run debugging. */
readonly downloadsDir?: string;
}
/**
* Verify the test bundle is present at `dist-test/`; fail loudly with
* an actionable error if missing. The harness cannot launch without
* the bundle so failing early avoids confusing puppeteer errors.
*
* @throws If `dist-test/` is missing or not a directory.
*/
function assertBundlePresent(): void {
if (!existsSync(DIST_TEST_DIR)) {
throw new Error(
`dist-test/ missing at ${DIST_TEST_DIR} — run \`npm run build:test\` first.`,
);
}
if (!statSync(DIST_TEST_DIR).isDirectory()) {
throw new Error(`dist-test/ at ${DIST_TEST_DIR} is not a directory.`);
}
}
/**
* Poll `browser.extensions()` until at least one extension is loaded
* or the timeout elapses. Returns the first extension's id.
*
* @param browser - Puppeteer browser handle.
* @returns The resolved extension id string.
* @throws If no extension loads within `EXTENSION_ID_POLL_TIMEOUT_MS`.
*/
async function resolveExtensionIdWithPolling(browser: Browser): Promise<string> {
const pollStart = Date.now();
let extensionsMap = await browser.extensions();
while (
extensionsMap.size === 0 &&
Date.now() - pollStart < EXTENSION_ID_POLL_TIMEOUT_MS
) {
await new Promise((resolve) => setTimeout(resolve, EXTENSION_ID_POLL_INTERVAL_MS));
extensionsMap = await browser.extensions();
}
const entries = [...extensionsMap];
if (entries.length === 0) {
throw new Error(
`No extensions loaded after ${EXTENSION_ID_POLL_TIMEOUT_MS}ms — dist-test/ malformed?`,
);
}
const [extensionId] = entries[0];
return extensionId;
}
/**
* Attach a SW console listener that forwards every console event to
* the provided buffer (both for in-memory diagnostic capture AND for
* stderr streaming so the operator sees live logs during a hung
* assertion). Best-effort: if the SW target cannot be found inside
* `SW_TARGET_ATTACH_TIMEOUT_MS`, the failure is logged to stderr but
* the harness continues (the assertion may still pass — many
* assertions do not need SW console data).
*
* @param browser - Puppeteer browser handle.
* @param extensionId - The resolved extension id.
* @param swConsole - Accumulating string buffer to push log lines into.
*/
async function attachSwConsoleBestEffort(
browser: Browser,
extensionId: string,
swConsole: string[],
): Promise<void> {
try {
const swTarget = await browser.waitForTarget(
(t) => t.type() === 'service_worker' && t.url().includes(extensionId),
{ timeout: SW_TARGET_ATTACH_TIMEOUT_MS },
);
const sw = await swTarget.worker();
if (sw !== null) {
/**
* Named callback per project style — every chrome.* console event
* formatted with a leading `[sw:<type>]` tag for grep-ability.
*/
const onSwConsole = (msg: { type: () => string; text: () => string }): void => {
const line = `[sw:${msg.type()}] ${msg.text()}`;
swConsole.push(line);
process.stderr.write(line + '\n');
};
sw.on('console', onSwConsole);
}
} catch (swAttachErr) {
process.stderr.write(
`(launch: SW console attach skipped — ${String(swAttachErr)})\n`,
);
}
}
/**
* Register a `targetcreated` listener that lazily attaches the
* offscreen console once it appears. The offscreen target is created
* later (when the harness page calls `chrome.offscreen.createDocument`),
* so we cannot wait for it eagerly; instead we register the listener
* upfront and let it fire when the offscreen target spawns.
*
* Idempotent — only the first matching offscreen target is attached.
*
* @param browser - Puppeteer browser handle.
* @param extensionId - The resolved extension id.
* @param offConsole - Accumulating string buffer for offscreen log lines.
*/
function registerOffscreenConsoleAttach(
browser: Browser,
extensionId: string,
offConsole: string[],
): void {
let offscreenAttached = false;
/**
* Targetcreated handler — checks each new target for the offscreen
* extension URL pattern, attaches the console listener on the first
* match.
*/
const onTargetCreated = async (
target: { type: () => string; url: () => string; asPage: () => Promise<Page> },
): Promise<void> => {
if (offscreenAttached) {
return;
}
const url = target.url();
if (
target.type() === 'background_page' &&
url.includes(extensionId) &&
url.includes('offscreen')
) {
offscreenAttached = true;
try {
const offPage = await target.asPage();
/**
* Per-message callback — same tag format as the SW attach
* (`[off:<type>] <text>`).
*/
const onOffConsole = (msg: { type: () => string; text: () => string }): void => {
const line = `[off:${msg.type()}] ${msg.text()}`;
offConsole.push(line);
process.stderr.write(line + '\n');
};
offPage.on('console', onOffConsole);
} catch (offAttachErr) {
process.stderr.write(
`(launch: offscreen console attach skipped — ${String(offAttachErr)})\n`,
);
}
}
};
browser.on('targetcreated', onTargetCreated);
}
/**
* Configure the harness page's CDP session to use the per-run
* `downloadsDir` so A5 (SAVE_ARCHIVE → chrome.downloads.download) can
* poll a known directory without colliding with the operator's real
* downloads. Uses CDP `Browser.setDownloadBehavior` with
* `behavior: 'allow'` + the explicit path.
*
* @param harnessPage - The opened harness page handle.
* @param downloadsDir - Absolute path to the downloads directory.
*/
async function configureDownloadsDir(
harnessPage: Page,
downloadsDir: string,
): Promise<void> {
const session = await harnessPage.createCDPSession();
await session.send('Browser.setDownloadBehavior', {
behavior: 'allow',
downloadPath: downloadsDir,
});
}
/**
* Launch Chrome with the test bundle as an unpacked MV3 extension,
* open the extension-internal harness page + a victim about:blank
* page, configure downloads, attach SW + offscreen console listeners,
* and return the assembled handles.
*
* Caller MUST close the browser in a `finally` block:
* ```typescript
* const handles = await launchHarnessBrowser();
* try {
* // ... run assertions ...
* } finally {
* await handles.browser.close();
* }
* ```
*
* @param opts - Override headless / downloadsDir.
* @returns Assembled HarnessHandles.
*/
export async function launchHarnessBrowser(
opts: LaunchOptions = {},
): Promise<HarnessHandles> {
assertBundlePresent();
const headless = opts.headless ?? process.env.HEADLESS !== '0';
const downloadsDir = opts.downloadsDir ?? mkdtempSync(join(tmpdir(), 'mokosh-uat-'));
const browser = await puppeteer.launch({
enableExtensions: [DIST_TEST_DIR],
headless,
pipe: true,
protocolTimeout: PROTOCOL_TIMEOUT_MS,
args: [
'--no-sandbox',
// DO NOT add --auto-select-desktop-capture-source — unreliable
// in --headless=new per 01-11-SUMMARY falsification 4; the
// synthetic getDisplayMedia (offscreen-hooks.ts:installFake)
// bypasses Chrome's picker entirely.
],
});
const extensionId = await resolveExtensionIdWithPolling(browser);
// Accumulating console buffers — empty until SW + offscreen attach.
const swConsole: string[] = [];
const offConsole: string[] = [];
// Open the victim page FIRST so it's already in the tab list when
// the harness page opens. The victim URL must satisfy two constraints:
// 1. `chrome.tabs.query({active:true,currentWindow:true})` returns a
// tab with non-empty `.id` (so production `saveArchive` proceeds);
// every real tab does, so this is automatic.
// 2. `chrome.tabs.captureVisibleTab(...)` (used inside saveArchive's
// screenshot capture path) succeeds. captureVisibleTab needs
// EITHER `<all_urls>` host permission to match the active tab's
// URL OR `activeTab` granted (which only fires on a real user
// gesture, not a Puppeteer-scripted call). `<all_urls>` matches
// `http://`, `https://`, `file://`, `ftp://` — but NOT
// `about:blank` and NOT `data:` URLs (Chromium treats `data:`
// with an opaque origin and rejects the capture under
// "activeTab permission not in effect"). The canonical scheme
// that DOES satisfy `<all_urls>` without needing a localhost
// server is `file://`, so we write a stub HTML file inside the
// per-run `downloadsDir` (already mkdtempSync'd above) and load
// it as `file://<absPath>`.
//
// Plan 01-13 Wave 3B deviation (Rule 3 — blocking issue): the original
// `about:blank` victim page (carried over from the c647f61 prototype
// because A6 does not exercise captureVisibleTab) breaks A5's
// SAVE_ARCHIVE round-trip. The file:// victim is the simplest fix
// that does not touch production code or add network dependencies
// (an http://localhost server would also work but introduces port-
// allocation race conditions on CI).
const victimHtmlPath = resolvePath(downloadsDir, 'mokosh-uat-victim.html');
const victimHtml =
'<!doctype html><html><head><meta charset="utf-8">' +
'<title>Mokosh UAT Victim</title></head>' +
'<body><h1>UAT victim page</h1>' +
'<p>This tab exists so chrome.tabs.captureVisibleTab succeeds during A5 (SAVE_ARCHIVE).</p>' +
'</body></html>';
writeFileSync(victimHtmlPath, victimHtml, 'utf8');
const victimUrl = pathToFileURL(victimHtmlPath).toString();
const victimPage = await browser.newPage();
await victimPage.goto(victimUrl);
// Open the harness page; attach console + pageerror listeners
// BEFORE the goto so we don't miss bootstrap-time messages.
const harnessPage = await browser.newPage();
/**
* Named callback per project style — forwards all page-side console
* events to stderr with the `[page:<type>]` tag.
*/
const onPageConsole = (msg: { type: () => string; text: () => string }): void => {
const line = `[page:${msg.type()}] ${msg.text()}`;
process.stderr.write(line + '\n');
};
harnessPage.on('console', onPageConsole);
/**
* Named callback — page errors get an explicit `[page:ERROR]` tag
* separate from the console events so the operator can spot them in
* the stderr stream.
*/
const onPageError = (err: unknown): void => {
const msg = err instanceof Error ? err.message : String(err);
const line = `[page:ERROR] ${msg}`;
process.stderr.write(line + '\n');
};
harnessPage.on('pageerror', onPageError);
// Best-effort SW console attach — the SW target is usually ready
// by the time the extension finishes loading, but slow CI may need
// a brief poll (handled inside `attachSwConsoleBestEffort`).
await attachSwConsoleBestEffort(browser, extensionId, swConsole);
// Register the offscreen console attach BEFORE opening the harness
// page so the listener catches the offscreen target whenever it
// spawns (which happens later, when the page calls
// chrome.offscreen.createDocument from inside an assertion).
registerOffscreenConsoleAttach(browser, extensionId, offConsole);
// Configure downloads via CDP. This MUST happen on the harness
// page's CDP session (not the browser's default session) per
// puppeteer's per-page session model.
await configureDownloadsDir(harnessPage, downloadsDir);
// Bring the victim page to front so chrome.tabs.query({active:true})
// returns it (not the harness page) when production startVideoCapture
// runs. The harness page can still be evaluated against — Puppeteer's
// page handle doesn't care about active-tab state.
await victimPage.bringToFront();
// Open the harness page; wait for window.__mokoshHarness to install.
const harnessUrl = `chrome-extension://${extensionId}/tests/uat/extension-page-harness.html`;
await harnessPage.goto(harnessUrl, {
waitUntil: 'domcontentloaded',
timeout: HARNESS_GOTO_TIMEOUT_MS,
});
await harnessPage.waitForFunction(
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- waitForFunction runs in browser context where window types are loose.
() => (window as any).__mokoshHarness !== undefined,
{ timeout: HARNESS_BOOTSTRAP_TIMEOUT_MS },
);
return {
browser,
extensionId,
harnessPage,
victimPage,
downloadsDir,
swConsole,
offConsole,
};
}