From a63066a2898274be8b60ea9f8875a019246f3bda Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 18 May 2026 14:54:41 +0200 Subject: [PATCH] =?UTF-8?q?chore(01-13):=20wave-0=20=E2=80=94=20clean=20br?= =?UTF-8?q?oken=20Approach-A=20artifacts=20per=2001-11-SUMMARY?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- src/background/index.ts | 44 +- src/test-hooks/sw-hooks.ts | 311 ----------- .../no-test-hooks-in-prod-bundle.test.ts | 36 +- tests/uat/harness.test.ts | 499 ------------------ tests/uat/lib/assertions.ts | 199 ------- tests/uat/lib/extension.ts | 93 ---- tests/uat/lib/launch.ts | 314 ----------- tests/uat/lib/offscreen.ts | 107 ---- tests/uat/lib/sw.ts | 268 ---------- tests/uat/prototype/probe_offscreen.mjs | 39 -- tests/uat/prototype/probe_sw.mjs | 80 --- tests/uat/prototype/probe_tabs.mjs | 25 - tests/uat/prototype/probe_tabs2.mjs | 33 -- 13 files changed, 57 insertions(+), 1991 deletions(-) delete mode 100644 src/test-hooks/sw-hooks.ts delete mode 100644 tests/uat/harness.test.ts delete mode 100644 tests/uat/lib/assertions.ts delete mode 100644 tests/uat/lib/extension.ts delete mode 100644 tests/uat/lib/launch.ts delete mode 100644 tests/uat/lib/offscreen.ts delete mode 100644 tests/uat/lib/sw.ts delete mode 100644 tests/uat/prototype/probe_offscreen.mjs delete mode 100644 tests/uat/prototype/probe_sw.mjs delete mode 100644 tests/uat/prototype/probe_tabs.mjs delete mode 100644 tests/uat/prototype/probe_tabs2.mjs diff --git a/src/background/index.ts b/src/background/index.ts index 697278d..0e606d0 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -10,22 +10,36 @@ import type { import { remuxSegments } from './webm-remux'; 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). +// ─── Plan 01-13: NO SW-side test hook gate (Approach B) ────────────── +// Plan 01-11 originally planned a gated `await import('../test-hooks/sw-hooks')` +// here to instrument chrome.* handler registrations from the SW side. +// EMPIRICAL FALSIFICATION (01-11 spike, see 01-11-SUMMARY.md): MV3 +// service workers BLOCK dynamic import (Chromium es_modules.md: +// "Dynamic import is currently blocked in Service Workers, but it +// will change in the future."; w3c/webextensions#212 still open as of +// May 2026). The `await import(...)` never resolved → the SW silently +// died at top-of-module init → production chrome.* listeners never +// registered → the entire service worker was non-functional. // -// 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). +// Plan 01-13 (Approach B) DROPS the SW-side hook entirely: +// - The OFFSCREEN-side test hook (src/test-hooks/offscreen-hooks.ts) +// still works because offscreen IS a DOM document where dynamic +// import is supported. See src/offscreen/recorder.ts top-of-module +// for the surviving `if (__MOKOSH_UAT__) { await import(...) }`. +// - SW-side state (badge text, popup, isRecording proxy) is queried +// by the extension-internal harness page via chrome.action.* +// directly (the page has full chrome.* privilege, unlike the +// restricted CDP `sw.evaluate` surface that 01-11 tried first). +// - Architectural rationale + full falsification table: +// .planning/phases/01-stabilize-video-pipeline/01-11-SUMMARY.md +// +// SECURITY INVARIANT (T-1-11-01, retained): production bundle MUST +// contain NO test-hook surface strings. The Tier-1 grep gate +// `tests/background/no-test-hooks-in-prod-bundle.test.ts` enforces +// post-build. In Approach B this invariant is trivially satisfied for +// the SW chunk (no hook imports here at all); the gate's enduring +// value is catching regressions in the offscreen chunk's +// `__MOKOSH_UAT__` gate. // Default MIME applied when a wire chunk somehow lacks a type // field (defense-in-depth: in normal operation the offscreen recorder diff --git a/src/test-hooks/sw-hooks.ts b/src/test-hooks/sw-hooks.ts deleted file mode 100644 index 41d6883..0000000 --- a/src/test-hooks/sw-hooks.ts +++ /dev/null @@ -1,311 +0,0 @@ -// 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 | 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 => { - 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 => { - 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 => { - 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 (Chrome 88+) -// create(options) → Promise (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] - | [chrome.notifications.NotificationOptions, (notificationId: string) => void] - | [string, chrome.notifications.NotificationOptions] - | [string, chrome.notifications.NotificationOptions, (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). - */ -function patchedNotifCreate(...args: NotifCreateArgs): string | Promise { - // 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; - let userCallback: ((id: string) => void) | undefined; - - if (typeof args[0] === 'string') { - proposedId = args[0]; - options = args[1] as chrome.notifications.NotificationOptions; - 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. 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).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; - -// ─── Harness message bridge ─────────────────────────────────────────── -// EMPIRICAL ARCHITECTURE NOTE: Puppeteer 25 + Chrome 148 + headless -// cannot reliably evaluate against the SW directly. The harness -// queries chrome.* state through the popup page (which has full -// chrome.* API access) but cannot read the SW's globalThis.__mokoshTest -// because the popup is a SEPARATE V8 isolate. So we bridge: the popup -// sends chrome.runtime.sendMessage queries; this handler responds with -// the queried state. -// -// Protocol — popup → SW message: { type: '__mokoshTestQuery', op: } -// Response shapes: -// op='snapshot' → { count, lastOptions, ids } -// op='fire-on-startup' → { ok: true } OR { ok: false, error: 'no-handler' } -// op='handler-types' → { onClicked, onStartup, notificationOnClicked } -// Unknown ops respond { ok: false, error: 'unknown-op' }. -// -// Returning `true` from the onMessage handler tells Chrome the -// response is async; we keep sendResponse as a closed-over callback. -// The bridge handler is registered AFTER the production listeners so -// the hook never accidentally intercepts a production message — -// __mokoshTest* messages are unambiguously test-only. -chrome.runtime.onMessage.addListener((rawMessage, _sender, sendResponse) => { - // Narrow the message — we accept ANY shape but only act on our type. - if (rawMessage === null || typeof rawMessage !== 'object') { - return false; - } - const message = rawMessage as { type?: unknown; op?: unknown }; - if (message.type !== '__mokoshTestQuery') { - // Not our message — production handler will take it. - return false; - } - const op = String(message.op ?? ''); - if (op === 'snapshot') { - sendResponse({ - count: notificationCount, - lastOptions: lastNotificationOptions, - ids: notificationIds.slice(), - }); - return false; // Sync response — return false per Chrome onMessage contract. - } - if (op === 'fire-on-startup') { - const h = handlers.onStartup; - if (h === null) { - sendResponse({ ok: false, error: 'no-handler' }); - return false; - } - // Fire-and-respond. The handler may be async; we don't await it - // for the response, but if it throws synchronously the catch - // surfaces in the response. - try { - // Schedule on microtask so the response goes out first; the - // handler's side effects (notifications.create) happen right - // after, before the next harness assertion polls. - queueMicrotask(() => { - Promise.resolve(h()).catch((err) => { - // Swallow async errors — the assertion 8 check is on the - // notification side effect, not the handler's return value. - console.warn('[mokoshTest bridge] onStartup handler threw:', err); - }); - }); - sendResponse({ ok: true }); - } catch (err) { - sendResponse({ - ok: false, - error: err instanceof Error ? err.message : String(err), - }); - } - return false; - } - if (op === 'handler-types') { - sendResponse({ - onClicked: typeof handlers.onClicked, - onStartup: typeof handlers.onStartup, - notificationOnClicked: typeof handlers.notificationOnClicked, - }); - return false; - } - sendResponse({ ok: false, error: 'unknown-op' }); - return false; -}); - -export {}; diff --git a/tests/background/no-test-hooks-in-prod-bundle.test.ts b/tests/background/no-test-hooks-in-prod-bundle.test.ts index f2a6cc4..4eb41ee 100644 --- a/tests/background/no-test-hooks-in-prod-bundle.test.ts +++ b/tests/background/no-test-hooks-in-prod-bundle.test.ts @@ -42,12 +42,29 @@ // bundle could carry leaked hooks unnoticed. // // Surface inventory enforced (each MUST be absent from any file under -// dist/ once builds + future plans add to the hook tree): -// - `__mokoshTest` — the global surface name itself -// - `simulateUserStop` — Bug B simulate function (Plan 01-11 §3) -// - `getSegmentCount` — Plan 01-11 Task 7 segments-count getter -// - `setCurrentStream` — Plan 01-11 Task 2 offscreen wire -// - `setSegmentCountGetter` — Plan 01-11 Task 7 offscreen wire +// 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 +// +// Total: 8 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) 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 @@ -85,10 +102,13 @@ const execFileAsync = promisify(execFile); */ const FORBIDDEN_HOOK_STRINGS: ReadonlyArray = [ '__mokoshTest', - 'simulateUserStop', - 'getSegmentCount', 'setCurrentStream', 'setSegmentCountGetter', + 'installFakeDisplayMedia', + 'uninstallFakeDisplayMedia', + 'dispatchEndedOnTrack', + 'getSegmentCount', + '__mokoshOffscreenQuery', ]; /** How long the build child has to finish (`npm run build` is ~10s). diff --git a/tests/uat/harness.test.ts b/tests/uat/harness.test.ts deleted file mode 100644 index fea57e1..0000000 --- a/tests/uat/harness.test.ts +++ /dev/null @@ -1,499 +0,0 @@ -// tests/uat/harness.test.ts — Plan 01-11 Puppeteer UAT harness entry point. -// -// Runs end-to-end via `npm run test:uat` (build:test + tsx tests/uat/harness.test.ts). -// Top-to-bottom narrative: launch Chrome with dist-test loaded as -// MV3 extension, attach to SW + offscreen, run 14 assertions -// sequentially with bail-on-first-fail semantics + structured -// diagnostic dump on failure (RESEARCH §5 + open-question resolution 4). -// -// Exit code: -// 0 — all 14 assertions passed -// 1 — at least one assertion failed -// -// Local-debug mode: `HEADLESS=0 npm run test:uat` (opens real Chrome) -// Skip prod rebuild: `SKIP_PROD_REBUILD=1` (assertion 0 still verifies -// the EXISTING dist/ rather than spawning npm run build). -// -// Assertion catalog (14 total): -// 0 — Production bundle grep gate (filesystem-only; pre-flight). -// 1 — SW bootstrap → setIdleMode (badge '', popup '', isRecording=false). -// 2 — Toolbar onClicked-idle → badge 'REC' + popup popup.html + isRecording=true. -// 3 — Offscreen displaySurface === 'monitor' (post-grant validation). -// 4 — Toolbar onClicked while recording → popup, NO new offscreen. -// 5 — SAVE_ARCHIVE → download fires + session_report_*.zip appears. -// 6 — BUG B (canonical): simulateUserStop → badge '' + popup '' + NO recovery notif. -// 7 — RECORDING_ERROR codec-unsupported → badge 'ERR' + recovery notif. -// 8 — BUG A (canonical): onStartup → mokosh-startup- notification creates cleanly. -// 9 — Icon file sizes meet floors (16→200, 48→500, 128→1024). -// 10 — Manifest has notifications permission + all three icons declared. -// 11 — 35s recording yields >= 3 segments per D-13. -// 12 — ffprobe -v error -f matroska on extracted webm exits 0. -// 13 — Archive shape (video/last_30sec.webm + meta.json with version match). - -import { execFileSync, execSync } from 'node:child_process'; -import { existsSync, readdirSync, readFileSync, statSync, mkdtempSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join, resolve as resolvePath } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import type { Page } from 'puppeteer'; - -import { - type AssertionRecord, - type ConsoleBuffers, - assertEqual, - assertGte, - assertMatch, - assertTrue, - runAssertion, - waitFor, -} from './lib/assertions'; -import { - attachToOffscreen, - countOffscreenTargets, - waitForOffscreenTarget, -} from './lib/extension'; -import { - getDisplaySurface, - getSegmentCount, - simulateUserStop, -} from './lib/offscreen'; -import { - fireOnStartup, - getBadgeText, - getIconSize, - getIsRecording, - getManifest, - getNotificationSnapshot, - getPopup, - keepalivePing, - sendSyntheticRecordingError, -} from './lib/sw'; -import { assertArchiveShape, extractEntryToFile } from './lib/zip'; -import { launchHarnessBrowser, type HarnessHandles } from './lib/launch'; - -const HARNESS_FILE_DIR = dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = resolvePath(HARNESS_FILE_DIR, '..', '..'); -const DIST_DIR = resolvePath(REPO_ROOT, 'dist'); -const FFPROBE_BIN = '/usr/bin/ffprobe'; -const TOTAL_ASSERTIONS = 14; - -/** - * Forbidden hook surface strings — assertion 0 verifies absence - * in production dist/. Mirrors the Tier-1 unit gate's surface list - * (tests/background/no-test-hooks-in-prod-bundle.test.ts) but runs - * against the SAME dist/ as the live harness for E2E parity. - */ -const FORBIDDEN_HOOK_STRINGS: ReadonlyArray = [ - '__mokoshTest', - 'simulateUserStop', - 'getSegmentCount', - 'setCurrentStream', - 'setSegmentCountGetter', -]; - -/** Icon-size floors per assertion 9 (per orchestrator brief). */ -const ICON_SIZE_FLOORS: ReadonlyArray = [ - ['icons/icon16.png', 200], - ['icons/icon48.png', 500], - ['icons/icon128.png', 1024], -]; - -/** - * Recursively list all files under a root directory (sync). Used by - * assertion 0 to walk dist/. Symlinks are skipped defensively. - * - * @param root - Absolute directory path. - * @returns Sorted list of absolute file paths. - */ -function listAllFilesRecursive(root: string): ReadonlyArray { - const acc: 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()) { - acc.push(fullPath); - } - } - } - return acc.sort(); -} - -/** - * Grep `needle` across every text-like file under `root`. Returns - * file paths that contain at least one occurrence. - * - * @param root - Absolute directory path. - * @param needle - Literal substring to find. - * @returns Paths containing `needle`. - */ -function grepRecursive(root: string, needle: string): ReadonlyArray { - const binaryExt = new Set(['.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp', '.woff', '.woff2', '.ttf']); - const out: string[] = []; - for (const filePath of listAllFilesRecursive(root)) { - const dotIdx = filePath.lastIndexOf('.'); - const ext = dotIdx >= 0 ? filePath.substring(dotIdx).toLowerCase() : ''; - if (binaryExt.has(ext)) continue; - if (statSync(filePath).size === 0) continue; - const text = readFileSync(filePath, 'utf8'); - if (text.includes(needle)) { - out.push(filePath); - } - } - return out; -} - -/** - * Poll `downloadsDir` for any *session_report*.zip file. Returns the - * absolute path of the first match. Used by assertion 5. - * - * @param downloadsDir - Absolute downloads directory path. - * @param timeoutMs - Maximum wait time. - * @returns Absolute path to the matched .zip. - * @throws On timeout. - */ -async function waitForDownloadedZip( - downloadsDir: string, - timeoutMs: number, -): Promise { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - const entries = readdirSync(downloadsDir); - for (const name of entries) { - if (name.includes('session_report') && name.endsWith('.zip')) { - const full = join(downloadsDir, name); - // Make sure write completed (size stabilized). - const size1 = statSync(full).size; - await new Promise((r) => setTimeout(r, 200)); - const size2 = statSync(full).size; - if (size1 === size2 && size1 > 0) { - return full; - } - } - } - await new Promise((r) => setTimeout(r, 200)); - } - throw new Error( - `waitForDownloadedZip: no session_report_*.zip appeared in ${downloadsDir} within ${timeoutMs}ms`, - ); -} - -/** - * Run a production build of dist/ unless SKIP_PROD_REBUILD=1. - * Assertion 0 reads dist/, so this guarantees the gate runs against - * a fresh artifact. - */ -function ensureProductionBuild(): void { - if (process.env.SKIP_PROD_REBUILD === '1') { - process.stdout.write(' (SKIP_PROD_REBUILD=1 — using existing dist/)\n'); - return; - } - process.stdout.write(' Running `npm run build` (assertion 0 pre-flight)...\n'); - execFileSync('npm', ['run', 'build'], { - stdio: 'inherit', - cwd: REPO_ROOT, - }); -} - -/** - * Stub placeholder for assertions Task 4+ wires. Each stub throws so - * the harness exits non-zero today; the diagnostic clearly identifies - * the assertion as un-implemented vs failing-in-production. - * - * @param taskNumber - The plan task number that will wire this assertion. - * @returns A function that always throws. - */ -function notYetImplemented(taskNumber: number): () => Promise { - return async () => { - throw new Error( - `NOT YET IMPLEMENTED — Plan 01-11 Task ${taskNumber} wires this assertion`, - ); - }; -} - -/** - * Main harness entry point. Runs all 14 assertions sequentially with - * bail-on-first-fail semantics for the SETUP-dependent assertions - * (we still record every assertion's outcome — bail only stops - * subsequent FUNCTIONAL assertions from running). - */ -async function main(): Promise { - const results: AssertionRecord[] = []; - const buffers: ConsoleBuffers = { swLines: [], offscreenLines: [] }; - let handles: HarnessHandles | null = null; - - process.stdout.write('\nMokosh UAT harness — Plan 01-11 Puppeteer-driven 14-assertion suite\n'); - process.stdout.write('='.repeat(72) + '\n\n'); - - try { - // ─── Assertion 0: Pre-flight grep gate ────────────────────────── - process.stdout.write('Assertion 0 (pre-flight, filesystem-only):\n'); - ensureProductionBuild(); - const a0 = await runAssertion( - 0, - 'production bundle has no test-hook leaks (T-1-11-01)', - buffers, - async () => { - for (const needle of FORBIDDEN_HOOK_STRINGS) { - const matches = grepRecursive(DIST_DIR, needle); - assertEqual( - matches.length, - 0, - `production dist/ contains '${needle}' in: ${JSON.stringify(matches)}`, - ); - } - }, - ); - results.push(a0); - if (!a0.passed) { - // Hook leak is security-critical (T-1-11-01) — abort immediately. - process.stderr.write( - '\n*** ABORT: assertion 0 (hook leak gate) FAILED — refusing to ' + - 'continue with potentially-leaky production bundle. ***\n', - ); - return 1; - } - - // ─── Setup: launch browser, attach to SW + open popup bridge ─── - process.stdout.write('\nLaunching Chrome + opening popup bridge...\n'); - handles = await launchHarnessBrowser(); - const { browser, sw, page, popup, extension, extensionId, downloadsDir } = handles; - process.stdout.write(` extensionId: ${extensionId}\n`); - process.stdout.write(` downloadsDir: ${downloadsDir}\n`); - process.stdout.write(` popup: chrome-extension://${extensionId}/src/popup/index.html\n\n`); - - // Wire console buffers. The popup carries the chrome.* queries; - // the SW handle is kept for diagnostic console capture (when the - // SW is alive). Both feed buffers for failure dumps. - const popupPage: Page = popup; - popupPage.on('console', (msg) => { - buffers.swLines.push(`[Popup:${msg.type()}] ${msg.text()}`); - }); - sw.on('console', (msg) => { - buffers.swLines.push(`[SW:${msg.type()}] ${msg.text()}`); - }); - - // Read the manifest version once for assertion 13. - const manifest = await getManifest(popupPage); - const expectedVersion = manifest.version; - - // ─── Assertion 1: SW bootstrap → setIdleMode ─────────────────── - const a1 = await runAssertion( - 1, - 'SW bootstrap → setIdleMode (badge="" + popup="" + isRecording=false)', - buffers, - async () => { - // Wake the SW via the keepalive ping first — this ensures the - // SW's initialize() has been observed before we sample state. - // In MV3, the SW may have suspended between launch and now; - // sendMessage wakes it for 30s. - await keepalivePing(popupPage); - // After SW init (production initialize() calls setIdleMode()), - // badge becomes empty + popup becomes empty + isRecording is false. - // Poll for the popup transition because setIdleMode's setPopup - // call is async-propagated through the action API; the popup - // page may briefly observe the manifest default before the - // override lands. - const popupUrl = await waitFor( - () => getPopup(popupPage), - (v) => v === '', - 3_000, - 'popup should become empty after SW initialize() runs setIdleMode', - 100, - ); - assertEqual(popupUrl, '', 'popup expected empty in idle mode'); - const badge = await getBadgeText(popupPage); - assertEqual(badge, '', 'badge expected empty after SW bootstrap'); - const recording = await getIsRecording(popupPage); - assertEqual(recording, false, 'isRecording expected false at bootstrap'); - }, - ); - results.push(a1); - - // ─── Assertion 2: Toolbar onClicked-idle → REC + popup ───────── - const a2 = await runAssertion( - 2, - 'toolbar onClicked-idle → badge "REC" + popup set + isRecording=true', - buffers, - async () => { - // Trigger the toolbar action — the production code's - // chrome.action.onClicked listener calls startVideoCapture(), - // which creates the offscreen + starts recording + transitions - // the badge to REC + sets the popup to popup/index.html. - await page.bringToFront(); - await page.triggerExtensionAction(extension); - // Badge transition is async (offscreen create + handshake + - // getDisplayMedia picker + post-grant validation + setBadgeText). - // Poll up to 8s; the auto-select picker shaves most of the time. - const badge = await waitFor( - () => getBadgeText(popupPage), - (v) => v === 'REC', - 8_000, - 'badge should become REC after toolbar click', - 200, - ); - assertEqual(badge, 'REC', 'badge after toolbar click'); - const popupUrl = await getPopup(popupPage); - assertMatch( - popupUrl, - /src\/popup\/index\.html$/, - 'popup should be set to popup/index.html during recording', - ); - const recording = await getIsRecording(popupPage); - assertEqual(recording, true, 'isRecording expected true after click'); - }, - ); - results.push(a2); - - // ─── Assertion 3: offscreen displaySurface === 'monitor' ─────── - const a3 = await runAssertion( - 3, - 'offscreen getCurrentStream displaySurface === "monitor"', - buffers, - async () => { - // A2 left recording active. The offscreen document was created - // by startVideoCapture; wait for the target + attach. - const offTarget = await waitForOffscreenTarget(browser, extensionId); - const offPage = await attachToOffscreen(offTarget); - offPage.on('console', (msg) => { - buffers.offscreenLines.push(`[OS:${msg.type()}] ${msg.text()}`); - }); - const ds = await getDisplaySurface(offPage); - assertEqual( - ds, - 'monitor', - 'displaySurface expected "monitor" per post-grant validation (D-15)', - ); - }, - ); - results.push(a3); - - // ─── Assertion 4: onClicked-recording → popup, no new offscreen - const a4 = await runAssertion( - 4, - 'toolbar onClicked while recording → popup opens, no new offscreen target', - buffers, - async () => { - const offscreenCountBefore = countOffscreenTargets(browser, extensionId); - // Triggering the action during recording opens the popup - // (production code keeps popup set in REC mode); the click - // does NOT spawn a second offscreen. - await page.bringToFront(); - await page.triggerExtensionAction(extension); - // Give the popup a moment to open + Chrome to settle. - await new Promise((r) => setTimeout(r, 500)); - const offscreenCountAfter = countOffscreenTargets(browser, extensionId); - assertEqual( - offscreenCountAfter, - offscreenCountBefore, - 'offscreen target count must not increase on second click during recording', - ); - // popup must still be set (recording is ongoing). - const popupUrl = await getPopup(popupPage); - assertMatch( - popupUrl, - /src\/popup\/index\.html$/, - 'popup must remain set while recording', - ); - }, - ); - results.push(a4); - - // ─── Wave 3 STUBBED assertions (Tasks 5-7 will wire these) ───── - const stubs: Array<{ - index: number; - name: string; - taskNumber: number; - }> = [ - { index: 5, name: 'SAVE_ARCHIVE → download fires + zip appears', taskNumber: 5 }, - { index: 6, name: 'BUG B canonical: simulateUserStop → badge OFF + no recovery notif', taskNumber: 5 }, - { index: 7, name: 'RECORDING_ERROR codec-unsupported → badge ERR + recovery notif', taskNumber: 5 }, - { index: 8, name: 'BUG A canonical: onStartup → notification creates cleanly', taskNumber: 6 }, - { index: 9, name: 'icon file sizes meet floors', taskNumber: 6 }, - { index: 10, name: 'manifest has notifications + 3 icons', taskNumber: 6 }, - { index: 11, name: '35s recording → segments.length >= 3', taskNumber: 7 }, - { index: 12, name: 'ffprobe on extracted webm exits 0', taskNumber: 7 }, - { index: 13, name: 'archive shape — video + meta.json version match', taskNumber: 7 }, - ]; - - for (const s of stubs) { - const rec = await runAssertion( - s.index, - s.name, - buffers, - notYetImplemented(s.taskNumber), - ); - results.push(rec); - } - - // Suppress unused-warning placeholders for Tasks 5-7 helpers. - void expectedVersion; - void getIconSize; - void fireOnStartup; - void sendSyntheticRecordingError; - void getNotificationSnapshot; - void keepalivePing; - void simulateUserStop; - void getSegmentCount; - void assertArchiveShape; - void extractEntryToFile; - void assertTrue; - void assertGte; - void waitForDownloadedZip; - void mkdtempSync; - void existsSync; - void execSync; - void tmpdir; - void FFPROBE_BIN; - void ICON_SIZE_FLOORS; - - return finalize(results); - } catch (setupErr) { - process.stderr.write(`\n*** Harness setup error: ${String(setupErr)}\n`); - return finalize(results); - } finally { - if (handles !== null) { - try { - await handles.browser.close(); - } catch (closeErr) { - process.stderr.write(`(non-fatal: browser close threw: ${String(closeErr)})\n`); - } - } - } -} - -/** - * Print the final summary line + return the exit code. - * - * @param results - All assertion records collected during the run. - * @returns 0 if all 14 passed, 1 otherwise. - */ -function finalize(results: ReadonlyArray): number { - const passCount = results.filter((r) => r.passed).length; - const failCount = results.length - passCount; - process.stdout.write('\n' + '='.repeat(72) + '\n'); - if (passCount === TOTAL_ASSERTIONS) { - process.stdout.write(`UAT harness: ${passCount}/${TOTAL_ASSERTIONS} assertions passed\n`); - return 0; - } - const firstFail = results.find((r) => !r.passed); - process.stdout.write( - `UAT harness: ${passCount}/${TOTAL_ASSERTIONS} assertions passed, ${failCount} failed`, - ); - if (firstFail !== undefined) { - process.stdout.write(` (first failure: A${firstFail.index} ${firstFail.name})`); - } - process.stdout.write('\n'); - return 1; -} - -// Run + exit. Top-level await + explicit exit code so tsx returns -// the right status without leaving unhandled-promise spew on stderr. -const exitCode = await main(); -process.exit(exitCode); diff --git a/tests/uat/lib/assertions.ts b/tests/uat/lib/assertions.ts deleted file mode 100644 index 3d15df6..0000000 --- a/tests/uat/lib/assertions.ts +++ /dev/null @@ -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, -): Promise { - 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(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( - probe: () => Promise, - predicate: (v: T) => boolean, - timeoutMs: number, - description: string, - pollIntervalMs: number = 100, -): Promise { - 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)}`, - ); -} diff --git a/tests/uat/lib/extension.ts b/tests/uat/lib/extension.ts deleted file mode 100644 index 3d22013..0000000 --- a/tests/uat/lib/extension.ts +++ /dev/null @@ -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 { - const predicate = (t: Target): boolean => { - const url = t.url(); - // Offscreen documents are loaded as chrome-extension:///... - // 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 { - 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; -} diff --git a/tests/uat/lib/launch.ts b/tests/uat/lib/launch.ts deleted file mode 100644 index 3e0a93c..0000000 --- a/tests/uat/lib/launch.ts +++ /dev/null @@ -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'; - -/// - -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:///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 { - 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 { - 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 { - 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 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, - }; -} diff --git a/tests/uat/lib/offscreen.ts b/tests/uat/lib/offscreen.ts deleted file mode 100644 index bf3ce70..0000000 --- a/tests/uat/lib/offscreen.ts +++ /dev/null @@ -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'; - -/// - -/** - * 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 { - 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 { - 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 { - return await offPage.evaluate(() => { - const hook = globalThis.__mokoshTest; - if (hook === undefined || hook.getSegmentCount === undefined) { - return -1; - } - return hook.getSegmentCount(); - }); -} diff --git a/tests/uat/lib/sw.ts b/tests/uat/lib/sw.ts deleted file mode 100644 index bebe4ca..0000000 --- a/tests/uat/lib/sw.ts +++ /dev/null @@ -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:///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'; - -/// - -/** - * 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 | null; - readonly ids: ReadonlyArray; -} - -/** - * 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:///src/popup/index.html). - * @returns Current badge text. - */ -export async function getBadgeText(popup: Page): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - const response = await popup.evaluate(async () => { - const msg = { type: '__mokoshTestQuery', op: 'snapshot' }; - return new Promise<{ - count: number; - lastOptions: chrome.notifications.NotificationOptions | null; - ids: string[]; - }>((resolve) => { - chrome.runtime.sendMessage(msg, (r) => { - resolve(r as { - count: number; - lastOptions: chrome.notifications.NotificationOptions | 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 { - await popup.evaluate(async () => { - const msg = { type: '__mokoshTestQuery', op: 'snapshot' }; - return new Promise((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 }; diff --git a/tests/uat/prototype/probe_offscreen.mjs b/tests/uat/prototype/probe_offscreen.mjs deleted file mode 100644 index eec1e5e..0000000 --- a/tests/uat/prototype/probe_offscreen.mjs +++ /dev/null @@ -1,39 +0,0 @@ -// Probe — can extension page call chrome.offscreen.createDocument? -import puppeteer from 'puppeteer'; -const browser = await puppeteer.launch({ - enableExtensions: ['/home/parf/projects/work/repremium/dist-test'], - headless: true, - pipe: true, - protocolTimeout: 90_000, - args: ['--no-sandbox'], -}); -try { - let exts = await browser.extensions(); - while (exts.size === 0) { await new Promise(r=>setTimeout(r,100)); exts = await browser.extensions(); } - const [extId] = [...exts][0]; - console.log('extId:', extId); - - const page = await browser.newPage(); - page.on('console', msg => console.log('[PAGE]', msg.text())); - await page.goto(`chrome-extension://${extId}/tests/uat/prototype/extension-page-harness.html`, { waitUntil: 'domcontentloaded' }); - - await new Promise(r => setTimeout(r, 500)); - - const r = await page.evaluate(async () => { - try { - const offscreenAvailable = typeof chrome.offscreen?.createDocument; - const url = chrome.runtime.getURL('src/offscreen/index.html'); - await chrome.offscreen.createDocument({ - url, - reasons: [chrome.offscreen.Reason.DISPLAY_MEDIA], - justification: 'page-test', - }); - return { ok: true, available: offscreenAvailable, url }; - } catch (e) { - return { ok: false, error: String(e) }; - } - }); - console.log('result:', JSON.stringify(r)); -} finally { - await browser.close(); -} diff --git a/tests/uat/prototype/probe_sw.mjs b/tests/uat/prototype/probe_sw.mjs deleted file mode 100644 index 27d3292..0000000 --- a/tests/uat/prototype/probe_sw.mjs +++ /dev/null @@ -1,80 +0,0 @@ -// Probe v11 — attach to SW after waiting, check for sentinel logs -import puppeteer from 'puppeteer'; -process.on('unhandledRejection', (r) => { console.error('UNHANDLED REJECTION:', r); process.exit(2); }); -process.on('uncaughtException', (e) => { console.error('UNCAUGHT EXCEPTION:', e); process.exit(2); }); - -const distPath = process.argv[2] === 'prod' ? '/home/parf/projects/work/repremium/dist' : '/home/parf/projects/work/repremium/dist-test'; -console.log('Using bundle:', distPath); - -const browser = await puppeteer.launch({ - enableExtensions: [distPath], - headless: true, - pipe: true, - protocolTimeout: 90_000, - args: ['--no-sandbox'], -}); -console.log('Launched'); -try { - let exts = await browser.extensions(); - let start = Date.now(); - while (exts.size === 0 && Date.now() - start < 5_000) { - await new Promise(r => setTimeout(r, 100)); - exts = await browser.extensions(); - } - const [extId] = [...exts][0] ?? []; - console.log('extension id:', extId); - - // Subscribe to SW console BEFORE the SW chunk runs - browser.on('targetcreated', async (t) => { - if (t.type() === 'service_worker' && t.url().includes(extId)) { - console.log('TARGETCREATED service_worker — attaching console'); - try { - const w = await t.worker(); - if (w) { - w.on('console', msg => process.stdout.write(`[SW ${msg.type()}] ${msg.text()}\n`)); - w.on('error', err => process.stdout.write(`[SW ERROR] ${err}\n`)); - } - } catch (e) { - console.log('worker() failed:', e.message); - } - } - }); - - // Wait for the SW target via waitForTarget (which also calls worker()) - try { - const swTarget = await browser.waitForTarget( - (t) => t.type() === 'service_worker' && t.url().includes(extId), - { timeout: 15_000 }, - ); - console.log('SW found:', swTarget.url()); - } catch (e) { - console.log('SW wait timed out:', e.message); - } - - // Wait a bit - await new Promise(r => setTimeout(r, 3_000)); - - // Open popup to trigger sendMessage - const page = await browser.newPage(); - page.on('console', msg => process.stdout.write(`[PAGE ${msg.type()}] ${msg.text()}\n`)); - await page.goto(`chrome-extension://${extId}/src/popup/index.html`, { waitUntil: 'domcontentloaded' }); - console.log('popup opened'); - - await new Promise(r => setTimeout(r, 2_000)); - - // sendMessage - const r = await page.evaluate(() => new Promise((resolve) => { - const t = setTimeout(() => resolve({ error: 'timeout' }), 5_000); - chrome.runtime.sendMessage({ type: 'GET_VIDEO_BUFFER' }, (resp) => { - clearTimeout(t); - resolve({ resp, lastError: chrome.runtime.lastError?.message }); - }); - })); - console.log('sendMessage result:', JSON.stringify(r)); - - await new Promise(r => setTimeout(r, 1_000)); -} finally { - console.log('closing...'); - await browser.close(); - console.log('done.'); -} diff --git a/tests/uat/prototype/probe_tabs.mjs b/tests/uat/prototype/probe_tabs.mjs deleted file mode 100644 index e8afec0..0000000 --- a/tests/uat/prototype/probe_tabs.mjs +++ /dev/null @@ -1,25 +0,0 @@ -import puppeteer from 'puppeteer'; -const b = await puppeteer.launch({ - enableExtensions: ['/home/parf/projects/work/repremium/dist-test'], - headless: true, pipe: true, protocolTimeout: 90_000, - args: ['--no-sandbox'], -}); -try { - let exts = await b.extensions(); - while (exts.size === 0) { await new Promise(r=>setTimeout(r,100)); exts = await b.extensions(); } - const [extId] = [...exts][0]; - const page = await b.newPage(); - await page.goto(`chrome-extension://${extId}/tests/uat/prototype/extension-page-harness.html`, {waitUntil:'domcontentloaded'}); - console.log('opened harness page'); - - // Query tabs from the harness page - const r = await page.evaluate(async () => { - const tabs = await chrome.tabs.query({}); - const active = await chrome.tabs.query({active: true, currentWindow: true}); - return { - allTabs: tabs.map(t => ({ id: t.id, url: t.url, active: t.active })), - activeCurrent: active.map(t => ({ id: t.id, url: t.url, active: t.active })), - }; - }); - console.log('tabs:', JSON.stringify(r, null, 2)); -} finally { await b.close(); } diff --git a/tests/uat/prototype/probe_tabs2.mjs b/tests/uat/prototype/probe_tabs2.mjs deleted file mode 100644 index 3c39ddf..0000000 --- a/tests/uat/prototype/probe_tabs2.mjs +++ /dev/null @@ -1,33 +0,0 @@ -import puppeteer from 'puppeteer'; -const b = await puppeteer.launch({ - enableExtensions: ['/home/parf/projects/work/repremium/dist-test'], - headless: true, pipe: true, protocolTimeout: 90_000, - args: ['--no-sandbox'], -}); -try { - let exts = await b.extensions(); - while (exts.size === 0) { await new Promise(r=>setTimeout(r,100)); exts = await b.extensions(); } - const [extId] = [...exts][0]; - - // Open multiple pages: data URL, harness page, then test query - const dataPage = await b.newPage(); - await dataPage.goto('data:text/html,victim', {waitUntil:'domcontentloaded'}); - console.log('opened data: page'); - - const harness = await b.newPage(); - await harness.goto(`chrome-extension://${extId}/tests/uat/prototype/extension-page-harness.html`, {waitUntil:'domcontentloaded'}); - console.log('opened harness'); - - await dataPage.bringToFront(); - console.log('victim brought to front'); - - const r = await harness.evaluate(async () => { - const all = await chrome.tabs.query({}); - const active = await chrome.tabs.query({active: true, currentWindow: true}); - return { - all: all.map(t => ({ id: t.id, url: t.url, active: t.active })), - active: active.map(t => ({ id: t.id, url: t.url, active: t.active })), - }; - }); - console.log('after bringToFront:', JSON.stringify(r, null, 2)); -} finally { await b.close(); }