chore(01-13): wave-0 — clean broken Approach-A artifacts per 01-11-SUMMARY

Restore a clean baseline before promoting the c647f61 prototype to
production paths (Wave 1) and building out Approach-B driver
scaffolding (Wave 2). All deletions trace back to falsifications
documented in 01-11-SUMMARY.md.

Deleted — broken Approach-A files:
  - src/test-hooks/sw-hooks.ts
      MV3 SW blocks dynamic import (Chromium es_modules.md;
      w3c/webextensions#212). The gated `await import('../test-hooks/
      sw-hooks')` from 01-11 Wave 1 never resolved → SW silently died →
      production listeners never registered. File was dead-on-arrival;
      no fix possible while MV3 SWs disallow dynamic import. Approach-B
      replaces SW-side instrumentation with the extension-internal
      harness page's chrome.action.* + chrome.notifications.* surface
      (full privilege; no monkey-patching needed).

  - tests/uat/lib/{launch,extension,sw,offscreen,assertions}.ts
      Popup-bridge architecture (01-11 dbd977c) — falsification 2 +
      falsification 3 in 01-11-SUMMARY: `sw.evaluate` exposes only
      chrome.{loadTimes,csi}, NOT chrome.action.* / chrome.notifications.*
      / chrome.runtime.sendMessage; setPopup-juggling for extension-id
      resolution turned out to be unnecessary (browser.extensions()
      works directly per the prototype). These files will be reborn in
      Wave 2 around the extension-page architecture.

      Kept: tests/uat/lib/zip.ts (host-side JSZip work — architecture-
      agnostic; A12+A13 still use it) and tests/uat/lib/test-hook-
      contract.d.ts (type mirror — extended in Wave 3 but kept as-is here).

  - tests/uat/prototype/probe_{offscreen,sw,tabs,tabs2}.mjs
      Feasibility-research probes (01-11 spike) that empirically falsified
      the Approach-A hypotheses. The findings are encoded in 01-11-
      SUMMARY.md; the probes themselves are dead code.

  - tests/uat/harness.test.ts
      01-11 Wave 2 popup-bridge orchestrator (dbd977c). Imports the
      now-deleted tests/uat/lib/{assertions,extension,sw,offscreen,launch}
      modules — would not typecheck after this commit. Reborn in
      Wave 3A as the Approach-B orchestrator (extension-internal page
      driver + A0 grep gate + 13 assertion drivers).

Reverted — SW-side dynamic-import gate comment block:
  - src/background/index.ts lines 13-29
      The existing comment block (post-spike) described the SW-side
      gated dynamic import that never landed. Rewritten to cite 01-13
      Approach-B explicitly, link to 01-11-SUMMARY.md falsification,
      and clarify that the Tier-1 grep gate's enduring value is
      catching regressions in the offscreen chunk's __MOKOSH_UAT__
      gate (the SW chunk is hook-free by construction).

Updated — Tier-1 grep gate FORBIDDEN_HOOK_STRINGS inventory:
  - tests/background/no-test-hooks-in-prod-bundle.test.ts
      Removed: `simulateUserStop` (Approach-A naming; replaced by
      Approach-B `dispatchEndedOnTrack` which matches the W3C
      dispatchEvent semantics per RESEARCH §7 BLOCKER — track.stop()
      does NOT fire 'ended' per spec, so the simulation MUST use
      dispatchEvent).
      Added: `installFakeDisplayMedia`, `uninstallFakeDisplayMedia`,
      `dispatchEndedOnTrack`, `__mokoshOffscreenQuery`.
      Total inventory: 8 surface strings (was 5). Each MUST be absent
      from every file under dist/ post-build.

Verification (all GREEN):
  - `npm run build` — exit 0; dist/ populated.
  - `grep -rln <forbidden> dist/` — 0 matches.
  - `npm run build:test` — exit 0; dist-test/ populated; offscreen-hooks
    chunk contains `installFakeDisplayMedia` (gate runs correctly
    against the test build's distinct artifact).
  - `npx tsc --noEmit` — exit 0 (root + tests/uat/tsconfig.json).
  - `npx vitest run` — 92/92 tests passing (was 89; the +3 new tests
    come from the FORBIDDEN_HOOK_STRINGS list expanding 5 → 8 — each
    forbidden string is one parametric `it(...)` block).

Both prior-failing tests now GREEN:
  - tests/background/sw-bundle-import.test.ts (was missing dist/ → 92/92
    requires the test run to have a current dist/; vitest gate test
    rebuilds via execFile when SKIP_BUILD≠1, otherwise relies on prior
    `npm run build`).
  - tests/background/no-test-hooks-in-prod-bundle.test.ts (was failing
    on stale dist; now GREEN against the freshly-rebuilt clean bundle).

Wave 1 (next): promote tests/uat/prototype/{extension-page-harness.html,
extension-page-harness.ts,a6.test.ts} to tests/uat/ via `git mv`;
update vite.test.config.ts rollup input.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 14:54:41 +02:00
parent 70f4f4136a
commit a63066a289
13 changed files with 57 additions and 1991 deletions

View File

@@ -1,199 +0,0 @@
// tests/uat/lib/assertions.ts — Plan 01-11 harness assertion runner.
//
// Centralizes:
// - `assertEqual` / `assertMatch` / `assertTrue` — thin wrappers
// over `node:assert/strict` with explicit Plan 01-11 diagnostic
// framing (cite the bug-class on Bug A / Bug B assertions).
// - `runAssertion(name, fn)` — wraps each assertion in a try/catch
// so the harness can collect a per-assertion pass/fail map AND
// dump SW/offscreen console buffers on the FIRST failure (bail
// semantics per RESEARCH §5).
// - `waitFor(probe, predicate, timeoutMs)` — polling helper used by
// assertions that need to wait for async state transitions
// (badge changes, downloads, etc.).
//
// References:
// - node:assert/strict: https://nodejs.org/api/assert.html#strict-assertion-mode
import { strict as assert } from 'node:assert';
/**
* Per-assertion outcome record. Accumulated by runAssertion + flushed
* to the harness's final summary line.
*/
export interface AssertionRecord {
readonly index: number;
readonly name: string;
readonly passed: boolean;
readonly errorMessage: string;
readonly durationMs: number;
}
/**
* Console buffers captured from SW + offscreen contexts. The harness
* wires `sw.on('console', ...)` + `offPage.on('console', ...)` at
* launch + before each assertion-relevant phase; on failure these
* buffers are dumped to stderr for triage.
*/
export interface ConsoleBuffers {
swLines: string[];
offscreenLines: string[];
}
/**
* Run a single assertion, capturing its outcome + duration. On error,
* dump the per-context console buffers to stderr BEFORE rethrowing so
* the harness's top-level catch sees the diagnostic context.
*
* @param index - 0-13 (0 = grep gate, 1-13 = functional).
* @param name - Human-readable assertion title.
* @param buffers - Console buffers to dump on failure (may be empty).
* @param fn - Async assertion body.
* @returns Outcome record.
*/
export async function runAssertion(
index: number,
name: string,
buffers: ConsoleBuffers,
fn: () => Promise<void>,
): Promise<AssertionRecord> {
const start = Date.now();
try {
await fn();
const durationMs = Date.now() - start;
process.stdout.write(` [PASS] A${index}: ${name} (${durationMs}ms)\n`);
return {
index,
name,
passed: true,
errorMessage: '',
durationMs,
};
} catch (err) {
const durationMs = Date.now() - start;
const errorMessage =
err instanceof Error ? `${err.name}: ${err.message}` : String(err);
process.stderr.write(` [FAIL] A${index}: ${name} (${durationMs}ms)\n`);
process.stderr.write(` ${errorMessage}\n`);
dumpBuffers(buffers, index);
return {
index,
name,
passed: false,
errorMessage,
durationMs,
};
}
}
/**
* Dump SW + offscreen console buffers to stderr with structured framing.
* Cap at the last 30 lines per context to keep failure output readable.
*
* @param buffers - The accumulating buffers.
* @param assertionIndex - For framing the dump preamble.
*/
function dumpBuffers(buffers: ConsoleBuffers, assertionIndex: number): void {
const TAIL = 30;
const swTail = buffers.swLines.slice(-TAIL);
const offTail = buffers.offscreenLines.slice(-TAIL);
if (swTail.length > 0) {
process.stderr.write(
` --- SW console (last ${swTail.length} lines, assertion A${assertionIndex}) ---\n`,
);
for (const line of swTail) {
process.stderr.write(` ${line}\n`);
}
}
if (offTail.length > 0) {
process.stderr.write(
` --- Offscreen console (last ${offTail.length} lines, assertion A${assertionIndex}) ---\n`,
);
for (const line of offTail) {
process.stderr.write(` ${line}\n`);
}
}
}
/**
* Strict equality with a context-bearing message. Wraps
* `assert.strictEqual` so the failure surface is uniform across
* assertions.
*
* @param actual - Observed value.
* @param expected - Expected value.
* @param msg - Context for the failure diagnostic.
*/
export function assertEqual<T>(actual: T, expected: T, msg: string): void {
assert.strictEqual(actual, expected, msg);
}
/**
* Assert that `actual` matches `regex`. Wraps `assert.match`.
*
* @param actual - String to test.
* @param regex - Pattern.
* @param msg - Context for the failure diagnostic.
*/
export function assertMatch(actual: string, regex: RegExp, msg: string): void {
assert.match(actual, regex, msg);
}
/**
* Assert that `cond` is truthy. Wraps `assert.ok`.
*
* @param cond - Boolean expression.
* @param msg - Context for the failure diagnostic.
*/
export function assertTrue(cond: boolean, msg: string): void {
assert.ok(cond, msg);
}
/**
* Assert that the actual value is greater than or equal to expected.
* Used by assertion 9 (icon size floors) + assertion 11 (segment count).
*
* @param actual - Observed value.
* @param expected - Minimum acceptable value.
* @param msg - Context for the failure diagnostic.
*/
export function assertGte(actual: number, expected: number, msg: string): void {
assert.ok(
actual >= expected,
`${msg} — expected >= ${expected}, got ${actual}`,
);
}
/**
* Poll `probe` until `predicate(probe())` returns true OR timeoutMs
* elapses. Throws on timeout with a structured diagnostic.
*
* @param probe - Async function producing a value to test.
* @param predicate - Returns true when the value satisfies the wait.
* @param timeoutMs - Maximum wait time.
* @param description - Human-readable description for the diagnostic.
* @param pollIntervalMs - Interval between probe calls (default 100ms).
* @returns The last probed value that satisfied the predicate.
* @throws If timeoutMs elapses without predicate satisfaction.
*/
export async function waitFor<T>(
probe: () => Promise<T>,
predicate: (v: T) => boolean,
timeoutMs: number,
description: string,
pollIntervalMs: number = 100,
): Promise<T> {
const start = Date.now();
let lastValue: T | undefined;
while (Date.now() - start < timeoutMs) {
lastValue = await probe();
if (predicate(lastValue)) {
return lastValue;
}
await new Promise((r) => setTimeout(r, pollIntervalMs));
}
throw new Error(
`waitFor timeout ${timeoutMs}ms — ${description}; ` +
`last probed value: ${JSON.stringify(lastValue)}`,
);
}

View File

@@ -1,93 +0,0 @@
// tests/uat/lib/extension.ts — Plan 01-11 harness extension/offscreen helpers.
//
// The offscreen-document attach uses a CDP-level target type that
// Puppeteer 25 surfaces as `'background_page'` — NOT `'page'`. Per
// Plan 01-11 RESEARCH §4 / Pitfall 1, finding the offscreen via
// `t.type() === 'page'` returns no matches; `'background_page'` is
// the right discriminator. After getting the target, `.asPage()`
// returns a Page-like handle (NOT `.page()` — that returns undefined).
//
// References:
// - Puppeteer Target types:
// https://pptr.dev/api/puppeteer.targettype
// - Chrome offscreen document:
// https://developer.chrome.com/docs/extensions/reference/api/offscreen
import type { Browser, Page, Target } from 'puppeteer';
/** How long to wait for the offscreen document target to appear. */
const OFFSCREEN_TARGET_TIMEOUT_MS = 5_000;
/**
* Poll the browser's target list for the offscreen document. The
* offscreen is created lazily — only when the SW issues
* `chrome.offscreen.createDocument(...)`. Caller MUST invoke a flow
* that triggers offscreen creation (e.g. start a recording) BEFORE
* calling this helper.
*
* @param browser - Puppeteer Browser handle.
* @param extensionId - The extension's runtime id (for URL filtering).
* @returns Resolved Target whose URL contains 'offscreen'.
* @throws If no offscreen target appears within OFFSCREEN_TARGET_TIMEOUT_MS.
*/
export async function waitForOffscreenTarget(
browser: Browser,
extensionId: string,
): Promise<Target> {
const predicate = (t: Target): boolean => {
const url = t.url();
// Offscreen documents are loaded as chrome-extension://<id>/...
// with a path containing 'offscreen' (matches both 'src/offscreen/'
// and the bundled equivalents). Target type 'background_page' per
// RESEARCH §4 Pitfall 1.
return (
t.type() === 'background_page' &&
url.startsWith(`chrome-extension://${extensionId}`) &&
url.includes('offscreen')
);
};
return await browser.waitForTarget(predicate, {
timeout: OFFSCREEN_TARGET_TIMEOUT_MS,
});
}
/**
* Attach to the offscreen document as a Page-like handle. Uses
* `.asPage()` (NOT `.page()` — Puppeteer 25 returns null for
* `.page()` on background_page-type targets).
*
* @param target - The offscreen Target from waitForOffscreenTarget.
* @returns Page handle for evaluate/expose/etc.
*/
export async function attachToOffscreen(target: Target): Promise<Page> {
const page = await target.asPage();
return page;
}
/**
* Count the offscreen targets currently in the browser. Used by
* assertion 4 to verify that a toolbar click while recording does
* NOT spawn a second offscreen document.
*
* @param browser - Puppeteer Browser handle.
* @param extensionId - The extension's runtime id.
* @returns Integer count of offscreen targets.
*/
export function countOffscreenTargets(
browser: Browser,
extensionId: string,
): number {
const targets = browser.targets();
let count = 0;
for (const t of targets) {
if (
t.type() === 'background_page' &&
t.url().startsWith(`chrome-extension://${extensionId}`) &&
t.url().includes('offscreen')
) {
count += 1;
}
}
return count;
}

View File

@@ -1,314 +0,0 @@
// tests/uat/lib/launch.ts — Plan 01-11 harness launch helper.
//
// Wraps puppeteer.launch with the project's invariants:
// - enableExtensions points at the absolute path to dist-test/ (the
// test bundle that carries the gated test hooks per Plan 01-11
// Task 2). NOT dist/ — that would defeat the harness entirely.
// - headless defaults to true (CI-friendly); HEADLESS=0 env opens a
// real Chrome window for local debugging.
// - --auto-select-desktop-capture-source="Entire screen" auto-accepts
// the screen-share picker so getDisplayMedia resolves without
// operator interaction (RESEARCH §9). The literal string is
// en_US-locale-sensitive; document the fallback in tests/uat/README.md.
// - Downloads land in a fresh per-run temp dir so assertion 5
// (SAVE_ARCHIVE) can poll for session_report_*.zip without
// colliding with operator downloads.
//
// References:
// - puppeteer.launch options: https://pptr.dev/api/puppeteer.launchoptions
// - puppeteer extension API: https://pptr.dev/guides/extensions
// - Chrome --auto-select-desktop-capture-source:
// https://source.chromium.org/chromium/chromium/src/+/main:media/capture/video/chromeos/camera_app_device_provider.cc
// (search for the flag in chrome://flags or the Chromium source tree)
import { execSync } from 'node:child_process';
import { existsSync, mkdtempSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve as resolvePath } from 'node:path';
import { fileURLToPath } from 'node:url';
import puppeteer, {
type Browser,
type CDPSession,
type Extension,
type Page,
type WebWorker,
} from 'puppeteer';
/// <reference path="./test-hook-contract.d.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');
/**
* Handles returned from `launchHarnessBrowser`. All references are
* live for the lifetime of the browser; the caller MUST close the
* browser to release them.
*/
export interface HarnessHandles {
readonly browser: Browser;
readonly extension: Extension;
readonly extensionId: string;
/**
* Service worker handle (for completeness / future use). NOTE: per
* the architecture refinement documented in tests/uat/lib/sw.ts,
* the harness's chrome.* state queries go through the `popup` page
* (which has full extension chrome.* access AND a stable Puppeteer
* lifetime). Direct sw.evaluate is unreliable in Chrome 148 +
* headless + Puppeteer 25 (the SW suspends + worker() returns
* "Protocol error: No target with given id found"). The SW handle
* is kept here for harness wave-3 assertion 11 / 12 (where we may
* need a worker reference for diagnostics).
*/
readonly sw: WebWorker;
readonly downloadsDir: string;
/**
* A pre-opened blank page the harness can use to invoke
* `triggerExtensionAction` (Puppeteer requires a page in the active
* tab for the toolbar-click simulation).
*/
readonly page: Page;
/**
* The extension popup page, opened at
* chrome-extension://<extensionId>/src/popup/index.html. This page
* is the harness's primary chrome.* query surface (see
* tests/uat/lib/sw.ts file header for rationale).
*/
readonly popup: Page;
}
/**
* Optional launch overrides. Defaults are CI-friendly; HEADLESS=0
* environment variable flips to headful for local debugging.
*/
export interface LaunchOptions {
/** Override the dist-test directory (test isolation). */
readonly distTestDir?: string;
/** Override the downloads directory (default: fresh tempdir per call). */
readonly downloadsDir?: string;
/** Force headless / headful regardless of HEADLESS env. */
readonly headless?: boolean;
}
/**
* Create a per-run downloads directory under the OS tmpdir. Caller is
* responsible for cleanup (typically deferred to OS tmpdir GC).
*
* @returns Absolute path to the freshly-created downloads directory.
*/
function makeDownloadsDir(): string {
return mkdtempSync(join(tmpdir(), 'mokosh-uat-downloads-'));
}
/**
* Verify the dist-test directory exists and is a directory. Fails
* loudly with an actionable message — the caller likely forgot to
* run `npm run build:test` before invoking the harness.
*
* @param distTestDir - Absolute path to dist-test.
* @throws If the directory does not exist or is not a directory.
*/
function assertDistTestPresent(distTestDir: string): void {
if (!existsSync(distTestDir)) {
throw new Error(
`dist-test/ missing at ${distTestDir}. ` +
`Run \`npm run build:test\` before launching the harness ` +
`(or invoke via \`npm run test:uat\` which does it for you).`,
);
}
const stat = statSync(distTestDir);
if (!stat.isDirectory()) {
throw new Error(
`dist-test/ exists at ${distTestDir} but is not a directory.`,
);
}
}
/**
* Resolve whether to run headless. HEADLESS=0 forces headful;
* anything else (including undefined) is headless. Explicit
* `options.headless` overrides the env entirely.
*
* @param options - Optional launch overrides.
* @returns true for headless, false for headful.
*/
function resolveHeadless(options: LaunchOptions): boolean {
if (options.headless !== undefined) {
return options.headless;
}
return process.env.HEADLESS !== '0';
}
/**
* Locate the SW target via the extension ID. Polls puppeteer's target
* list because the SW is registered asynchronously after the extension
* loads. Times out at 10s — if the SW is missing after that, either
* dist-test/ is corrupted or the SW bundle threw at module init (which
* would be caught by sw-bundle-import.test.ts BEFORE the harness ever
* runs; but defensively, we surface a clear diagnostic here).
*
* @param browser - Puppeteer Browser handle.
* @param extensionId - The extension's runtime id.
* @returns The SW WebWorker handle.
* @throws If no SW target appears within 10s.
*/
async function waitForSwTarget(
browser: Browser,
extensionId: string,
): Promise<WebWorker> {
const target = await browser.waitForTarget(
(t) =>
t.type() === 'service_worker' &&
t.url().startsWith(`chrome-extension://${extensionId}`),
{ timeout: 10_000 },
);
const sw = await target.worker();
if (sw === null) {
throw new Error(
`Service worker target found for extension ${extensionId} but ` +
`its worker() returned null — the SW likely crashed at init.`,
);
}
return sw;
}
/**
* Configure the per-page download behavior via CDP so files land in
* our temp downloadsDir. Puppeteer 25's high-level downloads API is
* still in flux; the raw CDP call is stable across versions.
*
* @param page - Page whose downloads should be redirected.
* @param downloadsDir - Absolute path to capture downloads.
*/
async function setDownloadBehavior(
page: Page,
downloadsDir: string,
): Promise<void> {
const cdpClient: CDPSession = await page.target().createCDPSession();
await cdpClient.send('Browser.setDownloadBehavior', {
behavior: 'allow',
downloadPath: downloadsDir,
eventsEnabled: true,
});
}
/**
* Launch a Chrome instance with the test bundle loaded as an unpacked
* MV3 extension; wire downloads to a per-run temp dir; return all
* handles the harness needs. Caller MUST `await handles.browser.close()`.
*
* @param options - Optional overrides (mostly for isolation in tests).
* @returns Resolved handles to browser, extension, SW, page, downloadsDir.
* @throws If dist-test/ missing OR SW target never appears.
*/
export async function launchHarnessBrowser(
options: LaunchOptions = {},
): Promise<HarnessHandles> {
const distTestDir = options.distTestDir ?? DIST_TEST_DIR;
assertDistTestPresent(distTestDir);
const downloadsDir = options.downloadsDir ?? makeDownloadsDir();
const headless = resolveHeadless(options);
// Pre-flight: verify the operator's chrome binary supports the
// auto-select picker flag. The string is locale-specific; en_US
// uses "Entire screen". This pre-flight does NOT verify the locale
// matches — it only verifies Puppeteer can find a Chromium binary
// at all (a missing binary fails the launch with a confusing message
// otherwise).
// Suppress noisy `puppeteer --version` check; if it fails, the launch
// itself will surface the same diagnostic.
try {
execSync('node ./node_modules/puppeteer/lib/cjs/puppeteer/node/cli.js --help', {
stdio: 'ignore',
timeout: 5_000,
});
} catch {
// Best-effort. The actual launch will fail loudly if the binary is
// truly missing.
}
const browser = await puppeteer.launch({
enableExtensions: [distTestDir],
headless,
pipe: true,
args: [
'--no-sandbox',
// RESEARCH §9: auto-accept the screen-share picker so
// getDisplayMedia resolves without operator interaction. The
// literal string is en_US-locale-sensitive; tests/uat/README.md
// documents the fallback for other locales.
'--auto-select-desktop-capture-source=Entire screen',
// DO NOT add --use-fake-ui-for-media-stream (RESEARCH §9 Pitfall:
// conflicts with auto-select).
],
});
// Resolve the extension ID. Puppeteer 25's browser.extensions() returns
// a Map<id, Extension> with all enabled extensions — BUT the map is
// populated asynchronously after the extension's manifest loads.
// Empirically: extension appears within ~100ms on local hardware but
// the very first call right after launch returns Map(0). Poll until
// extension registers OR 5s elapses; surface a clear diagnostic on
// timeout (probably means dist-test/ is malformed).
let extensionsMap = await browser.extensions();
const POLL_TIMEOUT_MS = 5_000;
const POLL_INTERVAL_MS = 100;
const pollStart = Date.now();
while (extensionsMap.size === 0 && Date.now() - pollStart < POLL_TIMEOUT_MS) {
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
extensionsMap = await browser.extensions();
}
const entries = [...extensionsMap];
if (entries.length === 0) {
await browser.close();
throw new Error(
`Puppeteer launched Chrome but no extensions loaded after ${POLL_TIMEOUT_MS}ms — ` +
`verify enableExtensions path points at a valid unpacked extension: ${distTestDir}. ` +
`Common causes: dist-test/ missing the manifest.json, manifest version mismatch ` +
`(Chrome requires MV3 — verify "manifest_version": 3), or chrome binary ` +
`incompatible with the unpacked extension shape.`,
);
}
const [extensionId, extension] = entries[0];
// Wait for the SW target to appear + capture its worker handle.
const sw = await waitForSwTarget(browser, extensionId);
// Give the SW's module init a tick to complete. Empirically the
// service-worker-loader.js → assets/index-*.js dynamic import
// resolves quickly, but `chrome.action.onClicked.addListener` (and
// the gated test-hook addListener monkey-patches) all run inside
// the module body — a brief settle ensures the hook surface is
// installed BEFORE the harness's first `sw.evaluate(() =>
// globalThis.__mokoshTest...)` query.
await new Promise((r) => setTimeout(r, 500));
// Pre-open a blank page; configure downloads. The blank page is
// also the page the harness uses for triggerExtensionAction.
const page = await browser.newPage();
await page.goto('about:blank');
await setDownloadBehavior(page, downloadsDir);
// Open the extension popup as a separate Page. This is the harness's
// primary chrome.* query surface — see tests/uat/lib/sw.ts file
// header for the architecture rationale. The popup page has full
// extension chrome.* access AND a stable Puppeteer lifetime. Loading
// the URL also wakes the SW (chrome-extension:// page load IS a SW
// wake-up event in MV3).
const popup = await browser.newPage();
await popup.goto(
`chrome-extension://${extensionId}/src/popup/index.html`,
{ waitUntil: 'domcontentloaded', timeout: 10_000 },
);
return {
browser,
extension,
extensionId,
sw,
downloadsDir,
page,
popup,
};
}

View File

@@ -1,107 +0,0 @@
// 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();
});
}

View File

@@ -1,268 +0,0 @@
// tests/uat/lib/sw.ts — Plan 01-11 harness SW-state helpers.
//
// IMPLEMENTATION ARCHITECTURE (refined during Task 3 execution):
//
// The original Plan 01-11 RESEARCH §1 sketch assumed `sw.evaluate(() =>
// chrome.action.getBadgeText({}))` would work directly against the
// service worker via Puppeteer's WebWorker.evaluate. 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)`
// 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`.
//
// The popup page (chrome-extension://<id>/src/popup/index.html) has:
// - Full `chrome.*` API access (it's an extension context — same
// privileges as the SW for chrome.action, chrome.runtime,
// chrome.notifications, chrome.runtime.getManifest, etc.)
// - Stable lifetime (it's a regular Page; Puppeteer keeps it alive)
// - Natural SW wake-up via message passing (chrome.runtime
// .sendMessage from popup wakes the SW for 30s)
//
// So this module's helpers use a Puppeteer Page handle pointing at
// the popup URL — NOT a WebWorker handle. The harness opens the popup
// page during setup (tests/uat/lib/launch.ts) and passes it here.
//
// For SW-isolate-specific state (`globalThis.__mokoshTest` lives in
// the SW's globalThis, not the popup's), the SW hook exposes a
// `chrome.runtime.onMessage` bridge: the popup sends
// `{ type: '__mokoshTestQuery', op: '...' }` messages; the hook
// responds with the queried state. Bridge implementation is in
// src/test-hooks/sw-hooks.ts; this file invokes it via popup.evaluate
// wrapping `chrome.runtime.sendMessage`.
//
// References:
// - Chrome extension pages share chrome.* API:
// https://developer.chrome.com/docs/extensions/develop/concepts/popup
// - Puppeteer Page.evaluate: https://pptr.dev/api/puppeteer.page.evaluate
// - Service worker wake-up on chrome.runtime message:
// https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle
import type { Page } from 'puppeteer';
/// <reference path="./test-hook-contract.d.ts" />
/**
* Structured snapshot of the SW's notification observability state
* (Plan 01-11 Task 2 sw-hooks.ts surfaces). Used by assertions 7 + 8
* to verify count-deltas + last-options-shape + id-prefix membership.
*/
export interface NotificationSnapshot {
readonly count: number;
readonly lastOptions: chrome.notifications.NotificationOptions<true> | null;
readonly ids: ReadonlyArray<string>;
}
/**
* The SW hook's bridge message type. The popup sends one of these
* shapes via chrome.runtime.sendMessage; the SW's onMessage handler
* (extended by sw-hooks.ts) responds with the queried state. See
* src/test-hooks/sw-hooks.ts for the SW-side dispatch.
*/
interface BridgeQuery {
type: '__mokoshTestQuery';
op:
| 'snapshot'
| 'fire-on-startup'
| 'handler-types';
}
/**
* Get the toolbar badge text. Empty string means OFF or initial state;
* 'REC' means recording; 'ERR' means error per Plan 01-09 badge state
* machine.
*
* @param popup - The extension popup page handle (open against
* chrome-extension://<id>/src/popup/index.html).
* @returns Current badge text.
*/
export async function getBadgeText(popup: Page): Promise<string> {
return await popup.evaluate(async () => await chrome.action.getBadgeText({}));
}
/**
* Get the current popup URL. Empty string means popup is not set
* (toolbar click fires onClicked instead). The chrome-extension://
* URL means recording (popup hosts SAVE button).
*
* @param popup - The extension popup page handle.
* @returns Current popup URL (full chrome-extension:// form OR '').
*/
export async function getPopup(popup: Page): Promise<string> {
return await popup.evaluate(async () => await chrome.action.getPopup({}));
}
/**
* Read the runtime manifest. Used by assertion 10 to verify
* permissions + icons shape, and by assertion 13 to obtain the
* version string for archive shape matching.
*
* @param popup - The extension popup page handle.
* @returns The chrome.runtime.getManifest() result.
*/
export async function getManifest(popup: Page): Promise<chrome.runtime.Manifest> {
return await popup.evaluate(() => chrome.runtime.getManifest());
}
/**
* Fetch an extension-relative file via popup context and return its
* size in bytes. Used by assertion 9 to verify icon files meet the
* size floors that Chrome's imageUtil requires for notifications.create
* (Bug A regression class — too-small icon → create rejects).
*
* @param popup - The extension popup page handle.
* @param relativePath - Path under the extension root (e.g. 'icons/icon128.png').
* @returns Byte size on success, -1 on fetch failure.
*/
export async function getIconSize(
popup: Page,
relativePath: string,
): Promise<number> {
return await popup.evaluate(async (path: string) => {
const url = chrome.runtime.getURL(path);
const r = await fetch(url);
if (!r.ok) {
return -1;
}
const cl = r.headers.get('content-length');
if (cl !== null) {
const n = Number(cl);
if (Number.isFinite(n) && n > 0) {
return n;
}
}
const buf = await r.arrayBuffer();
return buf.byteLength;
}, relativePath);
}
/**
* Read whether the SW thinks a recording is active. Side-channeled
* through the badge text — 'REC' ↔ recording; '' ↔ idle; 'ERR' ↔
* error state — to avoid needing a dedicated hook field.
*
* @param popup - The extension popup page handle.
* @returns true when badge === 'REC'.
*/
export async function getIsRecording(popup: Page): Promise<boolean> {
const badge = await getBadgeText(popup);
return badge === 'REC';
}
/**
* Fire the captured chrome.runtime.onStartup handler via the test
* hook's chrome.runtime.sendMessage bridge. Used by assertion 8 to
* verify the Bug A path (icon-promoted notification fires cleanly).
*
* Bridge protocol: popup sends `{ type: '__mokoshTestQuery', op: 'fire-on-startup' }`;
* SW responds with `{ ok: true }` after invoking the handler, OR
* `{ ok: false, error: 'no-handler' }` if the production listener
* was never registered (means the SW module init failed — a
* different bug class).
*
* @param popup - The extension popup page handle.
* @throws If the bridge response indicates the handler is missing.
*/
export async function fireOnStartup(popup: Page): Promise<void> {
const response = await popup.evaluate(async () => {
const msg = {
type: '__mokoshTestQuery',
op: 'fire-on-startup',
};
return new Promise<{ ok: boolean; error?: string }>((resolve) => {
chrome.runtime.sendMessage(msg, (r) => {
resolve(r as { ok: boolean; error?: string });
});
});
});
if (!response.ok) {
throw new Error(
`fireOnStartup bridge returned ok=false: ${response.error ?? '(no error message)'}`,
);
}
}
/**
* Inject a synthetic RECORDING_ERROR message into the SW's
* chrome.runtime.onMessage handler. Used by assertion 7 to verify
* the error path is preserved (badge 'ERR' + recovery notification).
* Goes through the popup's chrome.runtime.sendMessage — a real
* production code path (sw onMessage handler).
*
* @param popup - The extension popup page handle.
* @param errorCode - The error code to inject (e.g. 'codec-unsupported').
*/
export async function sendSyntheticRecordingError(
popup: Page,
errorCode: string,
): Promise<void> {
await popup.evaluate(async (code: string) => {
await chrome.runtime.sendMessage({
type: 'RECORDING_ERROR',
error: code,
});
}, errorCode);
}
/**
* Snapshot the current notification observability state from the SW
* hook via the bridge.
*
* @param popup - The extension popup page handle.
* @returns Snapshot — count, last options, ids array.
*/
export async function getNotificationSnapshot(
popup: Page,
): Promise<NotificationSnapshot> {
const response = await popup.evaluate(async () => {
const msg = { type: '__mokoshTestQuery', op: 'snapshot' };
return new Promise<{
count: number;
lastOptions: chrome.notifications.NotificationOptions<true> | null;
ids: string[];
}>((resolve) => {
chrome.runtime.sendMessage(msg, (r) => {
resolve(r as {
count: number;
lastOptions: chrome.notifications.NotificationOptions<true> | null;
ids: string[];
});
});
});
});
return {
count: response.count,
lastOptions: response.lastOptions,
ids: response.ids,
};
}
/**
* Send a no-op keepalive ping to the SW so Chrome's ~30s idle timer
* does not evict the worker during long waits (assertion 11's 35s
* recording window). Uses a __mokoshTestQuery 'snapshot' op as the
* cheapest round-trip with a guaranteed response — the bridge handler
* answers synchronously with the current notification state.
*
* @param popup - The extension popup page handle.
*/
export async function keepalivePing(popup: Page): Promise<void> {
await popup.evaluate(async () => {
const msg = { type: '__mokoshTestQuery', op: 'snapshot' };
return new Promise<void>((resolve) => {
chrome.runtime.sendMessage(msg, () => resolve());
// Cap at 2s — if the bridge is gone (SW reload race etc.) we
// still continue (the assertion may catch downstream issues).
setTimeout(() => resolve(), 2_000);
});
});
}
// Re-export the BridgeQuery type for sw-hooks.ts side reference
// (the SW hook implements the message dispatch using the same shape).
export type { BridgeQuery };