From 66e6c4af748119098a1dd1d5693ca46b3bbb39c1 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 17 May 2026 18:15:44 +0200 Subject: [PATCH] docs(01-11): create Puppeteer UAT harness plan (14 assertions, 9 tasks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retires operator-as-assertion-library role from Plan 01-09 Task 5. Bug A (notification icon API rejection) and Bug B (state-machine routing of user-stopped-sharing) both escaped vitest unit coverage and cost ~4-6h of operator UAT cycles in Phase 1. Plan 01-11 ships a Puppeteer-driven Node harness with CDP attach to SW + offscreen contexts; the 14 assertions cover the Plan 01-08/01-09 functional contract end-to-end. Locked from research (RESEARCH §1-§11): - Puppeteer 25.0.2 + tsx + node:assert/strict (no vitest browser mode) - Two-bundle separation via vite.test.config.ts (mode 'test' + dist-test/) - Hook gating: import.meta.env.MODE === 'test' + dynamic import (Vite tree-shakes from production) - Bug B trigger: track.dispatchEvent(new Event('ended')) — NOT track.stop() (W3C spec + empirical probe7 — track.stop does NOT fire 'ended') - Tier-1 grep gate (tests/background/no-test-hooks-in-prod-bundle.test.ts) enforces zero __mokoshTest in production dist/ - Single browser, serial assertions, bail-on-first-failure (open question 4) Wave structure (4 waves): - Wave 0 (Task 1): puppeteer+tsx install, vite.test.config, build:test + test:uat scripts, Tier-1 grep gate committed GREEN. - Wave 1 (Task 2): gated SW + offscreen hooks at src/test-hooks/; production bundle remains hook-free. - Wave 2 (Task 3): harness scaffolding — tests/uat/lib/* + harness.test.ts with assertion 0 wired GREEN + assertions 1-13 stubbed RED. - Wave 3 (Tasks 4-7): wire 13 assertions in 4 logically-grouped bundles (1-4 toolbar/displaySurface; 5-7 SAVE+BugB+ERROR; 8-10 BugA+icons+manifest; 11-13 buffer continuity + ffprobe + zip). - Wave 4 (Tasks 8-9): amend Plan 01-09 Task 5 to redirect functional steps to npm run test:uat; operator confirms brand/design. Bug A + Bug B each have RED-on-regression canonical demos required in the respective task's commit body — proves the harness CAN catch the regression, not just passes under current conditions. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../01-stabilize-video-pipeline/01-11-PLAN.md | 1069 +++++++++++++++++ 1 file changed, 1069 insertions(+) create mode 100644 .planning/phases/01-stabilize-video-pipeline/01-11-PLAN.md diff --git a/.planning/phases/01-stabilize-video-pipeline/01-11-PLAN.md b/.planning/phases/01-stabilize-video-pipeline/01-11-PLAN.md new file mode 100644 index 0000000..5769f8f --- /dev/null +++ b/.planning/phases/01-stabilize-video-pipeline/01-11-PLAN.md @@ -0,0 +1,1069 @@ +--- +phase: 01-stabilize-video-pipeline +plan: 11 +type: tdd +wave: 4 +depends_on: + - 01-08 + - 01-09 +files_modified: + - package.json + - package-lock.json + - vite.test.config.ts + - tsconfig.json + - src/background/index.ts + - src/offscreen/recorder.ts + - src/test-hooks/sw-hooks.ts + - src/test-hooks/offscreen-hooks.ts + - src/test-hooks/types.ts + - tests/uat/harness.test.ts + - tests/uat/lib/launch.ts + - tests/uat/lib/extension.ts + - tests/uat/lib/sw.ts + - tests/uat/lib/offscreen.ts + - tests/uat/lib/assertions.ts + - tests/uat/lib/zip.ts + - tests/uat/lib/test-hook-contract.d.ts + - tests/uat/README.md + - tests/background/no-test-hooks-in-prod-bundle.test.ts + - .planning/phases/01-stabilize-video-pipeline/01-09-PLAN.md +autonomous: false +requirements: + - REQ-uat-harness-puppeteer + - REQ-uat-bug-A-coverage + - REQ-uat-bug-B-coverage + - REQ-uat-two-bundle + - REQ-uat-ci-friendly + - REQ-uat-13-assertions + - REQ-video-ring-buffer +tags: + - puppeteer + - uat + - harness + - e2e + - mv3-extension + - getDisplayMedia + - bug-B + - bug-A + - tier-1 + - two-bundle + +must_haves: + truths: + - "`npm run build:test` produces `dist-test/` with `__mokoshTest` hook surfaces injected into SW + offscreen contexts; `npm run build` produces `dist/` with ZERO occurrences of `__mokoshTest` (grep-verifiable)." + - "`npm run test:uat` orchestrates `build:test` + the Puppeteer harness end-to-end; exits 0 only when ALL 14 assertions pass (13 from the brief + assertion 0 = production-bundle hook-leak grep gate)." + - "Bug B harness assertion (track.dispatchEvent('ended') → badge OFF + popup '' + isRecording=false + NO recovery notification) demonstrably catches a regression: rewinding the b9eeeeb conditional routing locally turns this assertion RED; reapplying turns it GREEN." + - "Bug A harness assertion (onStartup → chrome.notifications.create resolves cleanly with the manifest's icon48.png iconUrl) demonstrably catches a regression: stubbing the icon48 file to <100 bytes turns this assertion RED; restoring turns it GREEN." + - "Harness runs in `--headless=new` for CI portability; local-debug mode supported via `HEADLESS=0`; no Xvfb required (per RESEARCH §3 empirical probes against Chrome 148)." + - "Test hooks live ONLY behind `import.meta.env.MODE === 'test'` guarded dynamic imports; Vite tree-shakes them from the production bundle; the no-test-hooks-in-prod-bundle.test.ts unit gate enforces this in the existing vitest suite (Tier-1 alongside sw-bundle-import.test.ts)." + - "Existing 83 vitest tests remain GREEN after this plan lands (no regression to the unit test bed)." + - "Plan 01-09 functional contract closes by harness PASS: its Task 5 operator-checkpoint amendment redirects to `npm run test:uat` for steps 4-13 + 15; operator retains only step 1 (build) + step 14 (brand/design check)." + artifacts: + - path: "vite.test.config.ts" + provides: "Vite config extending the production config; sets `mode: 'test'`, `build.outDir: 'dist-test'`, `build.emptyOutDir: true`." + contains: "dist-test" + - path: "src/test-hooks/types.ts" + provides: "Shared TS type declaring `globalThis.__mokoshTest` shape (handlers, getCurrentStream, simulateUserStop, notificationCount, lastNotificationOptions). Single source of truth for SW + offscreen + harness." + contains: "__mokoshTest" + - path: "src/test-hooks/sw-hooks.ts" + provides: "SW-side test hook: captures chrome.action.onClicked / chrome.runtime.onStartup / chrome.notifications.onClicked handler refs; wraps chrome.notifications.create to record notificationCount + lastNotificationOptions. Imported dynamically from src/background/index.ts under `import.meta.env.MODE === 'test'` guard." + contains: "handlers" + - path: "src/test-hooks/offscreen-hooks.ts" + provides: "Offscreen-side test hook: exposes the current MediaStream via getter; provides simulateUserStop wrapping `track.dispatchEvent(new Event('ended'))` per RESEARCH §7. Imported dynamically from src/offscreen/recorder.ts under `import.meta.env.MODE === 'test'` guard." + contains: "simulateUserStop" + - path: "src/background/index.ts" + provides: "Adds a single `if (import.meta.env.MODE === 'test') { await import('../test-hooks/sw-hooks'); }` block at top-of-module so the hook registration runs BEFORE any production addListener calls (capturing every handler)." + contains: "import.meta.env.MODE" + - path: "src/offscreen/recorder.ts" + provides: "Adds an `if (import.meta.env.MODE === 'test') { __sharedRefs.setMediaStreamGetter(() => mediaStream); }` block (the import itself is gated; the getter wires the runtime mediaStream reference into the hook surface). Same guard pattern as SW." + contains: "import.meta.env.MODE" + - path: "tests/uat/harness.test.ts" + provides: "Single Node script (run under tsx) implementing all 14 assertions sequentially. ~400 LoC. Top-to-bottom narrative — launch, click, assert, simulate Bug B, simulate Bug A, etc. Returns exit 0 on full pass, non-zero on any failure with structured diagnostic dump." + min_lines: 350 + - path: "tests/uat/lib/launch.ts" + provides: "puppeteer.launch wrapper: builds args, sets enableExtensions to absolute dist-test path, chooses headless mode per CI env, configures downloads dir, exports a single launchHarnessBrowser() function." + - path: "tests/uat/lib/extension.ts" + provides: "Helpers to resolve the extension id, attach to the SW target, attach to the offscreen target (background_page type per RESEARCH §4 / Pitfall 1)." + - path: "tests/uat/lib/sw.ts" + provides: "SW context helpers: getBadgeText, getPopup, getManifestIcons, fireOnStartup (via captured handler ref), sendSyntheticRecordingError, keepalivePing." + - path: "tests/uat/lib/offscreen.ts" + provides: "Offscreen context helpers: waitForOffscreenTarget, getDisplaySurface, simulateUserStop (the dispatchEvent('ended') path per RESEARCH §7 BLOCKER finding)." + - path: "tests/uat/lib/assertions.ts" + provides: "Per-assertion helpers (assertEqual + structured diagnostic on failure); a runWithStartupDiagnostics wrapper that captures SW + offscreen console logs and dumps them on assertion failure for triage." + - path: "tests/uat/lib/zip.ts" + provides: "jszip-based archive shape assertions; reads downloaded `session_report_*.zip`, asserts `video/last_30sec.webm` present + `meta.json` carries `version === chrome.runtime.getManifest().version` (extension-side version read passed in)." + - path: "tests/uat/lib/test-hook-contract.d.ts" + provides: "Mirror of src/test-hooks/types.ts in TS-declaration form for the harness side; documents the wire contract between hook injector and harness consumer." + - path: "tests/uat/README.md" + provides: "How to run: `npm run test:uat`; local-debug headful mode via `HEADLESS=0`; CI semantics; troubleshooting (locale-specific picker string, Xvfb fallback if a future Chrome regresses headless, dev-dependency Chromium binary size note)." + - path: "tests/background/no-test-hooks-in-prod-bundle.test.ts" + provides: "Tier-1 unit-level grep gate (cousin of sw-bundle-import.test.ts): runs `npm run build` then asserts ZERO occurrences of `__mokoshTest` and ZERO occurrences of `simulateUserStop` in any file under `dist/`. RED today (the test runs before this plan lands its hook gating); GREEN after Task 1 verifies the gate AND the hook gating is correct." + - path: "package.json" + provides: "Adds `puppeteer` ^25.0.2 + `tsx` ^4 to devDependencies; adds two npm scripts: `build:test` (`tsc && vite build --mode test --config vite.test.config.ts`) and `test:uat` (`npm run build:test && tsx tests/uat/harness.test.ts`)." + contains: "test:uat" + - path: "tsconfig.json" + provides: "Includes `src/test-hooks/**/*` in compilation surface (so tsc validates the hook code). NO change to emit (vite handles bundling)." + - path: ".planning/phases/01-stabilize-video-pipeline/01-09-PLAN.md" + provides: "AMENDMENT block at the end of the file: redirects Plan 01-09 Task 5 operator-checkpoint steps 4-13 + 15 to `npm run test:uat` (this plan's harness). Operator retains step 1 (build) + step 14 (brand/design accept) only. Plan 01-09 closes when `npm run test:uat` exits 0 AND operator confirms brand/design step 14." + contains: "Plan 01-11 amendment" + key_links: + - from: "tests/uat/harness.test.ts" + to: "tests/uat/lib/launch.ts:launchHarnessBrowser" + via: "import" + pattern: "import.*from.*lib/launch" + - from: "tests/uat/lib/launch.ts" + to: "puppeteer.launch" + via: "enableExtensions + headless + autoSelect flag" + pattern: "enableExtensions" + - from: "src/background/index.ts" + to: "src/test-hooks/sw-hooks.ts" + via: "guarded dynamic import" + pattern: "import\\.meta\\.env\\.MODE === ['\"]test['\"]" + - from: "src/offscreen/recorder.ts" + to: "src/test-hooks/offscreen-hooks.ts" + via: "guarded dynamic import + setMediaStreamGetter wire" + pattern: "import\\.meta\\.env\\.MODE === ['\"]test['\"]" + - from: "tests/uat/lib/offscreen.ts:simulateUserStop" + to: "track.dispatchEvent(new Event('ended'))" + via: "evaluate-in-offscreen-page on __mokoshTest.getCurrentStream().getVideoTracks()[0]" + pattern: "dispatchEvent\\(new Event\\(['\"]ended['\"]" + - from: "tests/background/no-test-hooks-in-prod-bundle.test.ts" + to: "dist/ artifact tree" + via: "post-build grep for __mokoshTest + simulateUserStop" + pattern: "grep.*__mokoshTest.*dist" +--- + +## Scope Sanity Note + +**4 waves, 8 tasks, 18 file artifacts.** This sits at the upper end of the "split signal" threshold but consolidating is the right call: + +1. The test infrastructure (Wave 0), the hook gating (Wave 1), the harness scaffolding (Wave 2), and the 14 assertions (Wave 3) are tightly coupled at the contract level — splitting them into separate plans would force the harness contract (the `__mokoshTest` shape) to be re-derived in each plan's frontmatter `must_haves`, multiplying the duplication tax. +2. Per RESEARCH §6, the two-bundle gate (`__mokoshTest` ABSENT in production) is the security-critical mitigation for shipping test hooks. That gate MUST be wired in the same plan that adds the hooks; splitting would create a window where the hooks exist but the gate doesn't. +3. Wave 4 (closure) is a single checkpoint task — bundling it with Wave 3 wouldn't change context cost meaningfully, and separating it keeps the operator-checkpoint scope visible in the wave structure. +4. Context budget: Wave 0 + Wave 1 + Wave 2 ~30%; Wave 3 ~35%; Wave 4 ~5% (checkpoint). Total ~70%. Above the 50% target — but the 14 assertions are deterministic and template-shaped, so per-assertion authoring cost is sub-linear once Wave 2 lands. + +**If a future revision DOES force a split,** natural cut line: Plan 01-11A = Waves 0+1+2 (infrastructure + first 4 assertions as smoke); Plan 01-11B = Waves 3+4 (remaining 10 assertions + closure). This split incurs the contract-duplication tax and is NOT recommended absent a context-cost regression. + + +Build a Puppeteer-driven Node UAT harness that retires the operator-as-assertion-library role. Plan 01-09's Task 5 took 4-6 hours of operator empirical UAT cycles (Bug A icons + Bug B state routing both escaped vitest unit coverage); every "visual" check in that task has a CDP-callable equivalent. This plan automates them. + +Three coordinated changes: +1. **Two-bundle separation** via `vite.test.config.ts` extending the production config with `mode: 'test'` + `outDir: 'dist-test'`. Production builds stay hook-free. +2. **Test hooks** in `src/test-hooks/` consumed via guarded dynamic imports from SW + offscreen. The dynamic-import-inside-MODE-guard pattern (RESEARCH §6) lets Vite tree-shake the hook MODULES entirely from production, with a Tier-1 grep gate (`tests/background/no-test-hooks-in-prod-bundle.test.ts`) verifying the absence. +3. **Puppeteer harness** at `tests/uat/harness.test.ts` (plus a `lib/` helper split following MetaMask's POM shape per RESEARCH §5) implementing 14 assertions: assertion 0 (production-bundle hook-leak grep gate) + assertions 1-13 from the orchestrator brief. Bug B uses `track.dispatchEvent(new Event('ended'))` per RESEARCH §7 BLOCKER — NOT `track.stop()` which silently invalidates the assertion. + +Operator role retirement: Plan 01-09's Task 5 is amended to redirect steps 4-13 + 15 to `npm run test:uat`. Operator retains only step 1 (build verification) + step 14 (brand/design acceptance). All functional gates move to CI-callable harness. + +Output: +- `vite.test.config.ts` — production config extension with `mode: 'test'` + `outDir: 'dist-test'`. +- `src/test-hooks/{sw-hooks,offscreen-hooks,types}.ts` — gated hook modules. +- `src/background/index.ts` + `src/offscreen/recorder.ts` — gated dynamic import block (one line each + a `setMediaStreamGetter` wire in offscreen). +- `tests/uat/harness.test.ts` + `tests/uat/lib/*.ts` + `tests/uat/README.md` — harness + helpers. +- `tests/background/no-test-hooks-in-prod-bundle.test.ts` — Tier-1 unit-level hook-leak gate. +- `package.json` — `puppeteer`, `tsx` devDeps + `build:test`, `test:uat` scripts. +- `tsconfig.json` — includes `src/test-hooks/**/*` for type-checking. +- `.planning/phases/01-stabilize-video-pipeline/01-09-PLAN.md` — amendment block redirecting Task 5 functional steps to `npm run test:uat`. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/REQUIREMENTS.md +@.planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md +@.planning/phases/01-stabilize-video-pipeline/01-08-PLAN.md +@.planning/phases/01-stabilize-video-pipeline/01-08-SUMMARY.md +@.planning/phases/01-stabilize-video-pipeline/01-09-PLAN.md +@.planning/phases/01-stabilize-video-pipeline/01-09-SUMMARY.md +@.planning/phases/01-stabilize-video-pipeline/01-11-RESEARCH.md +@.planning/debug/resolved/01-09-recovery-flow.md +@src/background/index.ts +@src/offscreen/recorder.ts +@manifest.json +@vite.config.ts +@tsconfig.json +@package.json +@tests/background/sw-bundle-import.test.ts + + + + + +### Puppeteer 25.0.2 extension API surface (RESEARCH §1, empirically verified) + +```typescript +import puppeteer, { Browser, Extension, Page, Target } from 'puppeteer'; + +const browser: Browser = await puppeteer.launch({ + pipe: true, + enableExtensions: ['/abs/path/to/dist-test'], // string[] or true + headless: process.env.HEADLESS !== '0', // default headless=true; local debug HEADLESS=0 + args: [ + '--no-sandbox', + '--auto-select-desktop-capture-source=Entire screen', // RESEARCH §9 — locale-specific + // DO NOT add --use-fake-ui-for-media-stream (per RESEARCH §9 Pitfall, conflicts with auto-select) + ], +}); + +const extensions = await browser.extensions(); // Map +const [extId, ext] = [...extensions][0]; + +const swTarget = await browser.waitForTarget( + (t: Target) => t.type() === 'service_worker', + { timeout: 10_000 }, +); +const sw = await swTarget.worker(); // WebWorker — has .evaluate() + +const page = await browser.newPage(); +await page.goto('about:blank'); +await page.triggerExtensionAction(ext); // simulates toolbar click (NEEDS popup === '') + +// Offscreen page — RESEARCH §4 / Pitfall 1: target type 'background_page' NOT 'page' +const offTarget = browser.targets().find((t) => + t.type() === 'background_page' && t.url().includes('offscreen'), +); +const offPage = await offTarget.asPage(); // NOT .page() — only .asPage() works +``` + +### Chrome SW state surface (read via sw.evaluate) + +```typescript +// Read badge text +const badge = await sw.evaluate(() => chrome.action.getBadgeText({})); + +// Read popup +const popup = await sw.evaluate(() => chrome.action.getPopup({})); + +// Read manifest +const manifest = await sw.evaluate(() => chrome.runtime.getManifest()); +// manifest.icons === { '16': 'icons/icon16.png', '48': '...', '128': '...' } +// manifest.permissions includes 'notifications', etc. + +// Synthesize RECORDING_ERROR (no hook needed — goes through onMessage handler) +await sw.evaluate(() => + chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: 'codec-unsupported' }), +); + +// Invoke onStartup via captured handler ref (needs hook — see sw-hooks.ts) +await sw.evaluate(() => globalThis.__mokoshTest!.handlers.onStartup?.()); + +// Fetch an extension file and check size +const iconSize = await sw.evaluate(async () => { + const r = await fetch(chrome.runtime.getURL('icons/icon48.png')); + return r.ok ? Number(r.headers.get('content-length') ?? '0') : -1; +}); +``` + +### Offscreen surface (read via offPage.evaluate) + +```typescript +// Read displaySurface — RESEARCH §11 Req 3 +const ds = await offPage.evaluate(() => + globalThis.__mokoshTest!.getCurrentStream!()?.getVideoTracks()[0]?.getSettings().displaySurface ?? null, +); + +// Simulate user-stopped — RESEARCH §7 BLOCKER. MUST be dispatchEvent, NOT track.stop(). +await offPage.evaluate(() => { + const stream = globalThis.__mokoshTest!.getCurrentStream!(); + if (stream === null) throw new Error('no current stream — recording must be active'); + const track = stream.getVideoTracks()[0]; + track.dispatchEvent(new Event('ended')); + // Track still readyState 'live' after dispatch; production handler will + // call stream.getTracks().forEach(t => t.stop()) which DOES release the + // capture (just doesn't refire 'ended' on the same track — spec). +}); +``` + +### Test hook contract (NEW — src/test-hooks/types.ts) + +```typescript +// src/test-hooks/types.ts +// SINGLE SOURCE OF TRUTH for the __mokoshTest wire shape. +// Imported by sw-hooks.ts (registers), offscreen-hooks.ts (registers), +// and tests/uat/lib/test-hook-contract.d.ts (consumes — mirror). + +export interface MokoshTestSurface { + // SW handler refs (captured by sw-hooks.ts monkey-patching addListener) + handlers: { + onClicked: ((tab: chrome.tabs.Tab) => void | Promise) | null; + onStartup: (() => void | Promise) | null; + notificationOnClicked: ((notificationId: string) => void | Promise) | null; + }; + // SW notification observability + notificationCount: number; + lastNotificationOptions: chrome.notifications.NotificationOptions | null; + notificationIds: ReadonlyArray; + // Offscreen getCurrentStream — undefined in SW context; defined in offscreen. + // Always-present in the type to keep the harness side simple; runtime null is + // the "not currently recording" signal. + getCurrentStream?: () => MediaStream | null; +} + +declare global { + // eslint-disable-next-line no-var + var __mokoshTest: MokoshTestSurface | undefined; +} + +export {}; +``` + +### Production hook-gate pattern (src/background/index.ts top-of-module) + +```typescript +// AT THE VERY TOP of src/background/index.ts, BEFORE any addListener calls. +// import.meta.env.MODE is statically replaced at build time by Vite (RESEARCH §6); +// the entire `if` block + its dynamic import are tree-shaken from production bundles +// because the literal === comparison resolves to `false` and Rollup deletes the +// unreachable branch. +if (import.meta.env.MODE === 'test') { + await import('../test-hooks/sw-hooks'); +} +``` + +**CRITICAL ORDERING:** the hook import MUST run BEFORE any production `addListener` calls so the monkey-patches catch the handlers as they register. Top-of-module placement satisfies this. + +### Production hook-gate pattern (src/offscreen/recorder.ts) + +```typescript +// Top-of-module: register the hook. +if (import.meta.env.MODE === 'test') { + await import('../test-hooks/offscreen-hooks'); +} + +// Later, INSIDE startRecording after `mediaStream = stream;` (line ~247): +// Wire the runtime mediaStream reference into the hook. The hook's +// getCurrentStream getter reads through this wire. Gated identically so +// production bundle has zero hook reference at this site. +if (import.meta.env.MODE === 'test') { + globalThis.__mokoshTest?.getCurrentStream; // no-op read — actual wiring is in offscreen-hooks.ts setup + // The hook installs its own getter at registration time via a closure capture of + // a `currentStream` cell that we mutate here: + const hooks = await import('../test-hooks/offscreen-hooks'); + hooks.setCurrentStream(stream); +} +``` + +(Note: the executor may flatten this — the simpler shape is to expose a `setCurrentStream` function from offscreen-hooks.ts that the recorder calls after assignment. The hook-side closes over a mutable `currentStream` variable. See Task 2 step 5.) + +### Vite test config skeleton (vite.test.config.ts) + +```typescript +import { defineConfig, mergeConfig } from 'vite'; +import baseConfig from './vite.config'; + +export default defineConfig(() => + mergeConfig(baseConfig, { + mode: 'test', + build: { + outDir: 'dist-test', + emptyOutDir: true, + }, + }), +); +``` + +### npm scripts to add (package.json) + +```jsonc +{ + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "build:test": "tsc && vite build --mode test --config vite.test.config.ts", + "preview": "vite preview", + "test": "vitest run", + "test:uat": "npm run build:test && tsx tests/uat/harness.test.ts" + } +} +``` + +### Existing surfaces the executor must NOT alter (regression risk) + +- `src/background/index.ts` lines 725-778 (RECORDING_ERROR conditional routing) — Bug B fix landed at b9eeeeb; harness asserts this is intact. +- `src/offscreen/recorder.ts` lines 451-480 (`onUserStoppedSharing`) — Bug B handler; harness assertion 6 verifies the dispatchEvent path reaches it. +- `tests/background/sw-bundle-import.test.ts` — Tier-1 gate; the new `no-test-hooks-in-prod-bundle.test.ts` follows the same pattern but inspects the BUILT artifact for hook leaks. +- `manifest.json` — already declares `notifications` permission + all 3 icon sizes; harness assertions 8, 9, 10 read these as-is. +- ALL existing 83 vitest tests — must remain GREEN. + +### Resolved open questions from RESEARCH (5) + +| # | Question | Resolution | Rationale | +|---|----------|------------|-----------| +| 1 | Where does `simulateUserStop` shim live? | `src/test-hooks/offscreen-hooks.ts` exports a `setCurrentStream(stream: MediaStream)` setter the recorder calls after assignment. The hook's `__mokoshTest.getCurrentStream` is a getter over the captured cell. `simulateUserStop` is harness-side (in `tests/uat/lib/offscreen.ts`) calling `dispatchEvent` directly on the track returned by `getCurrentStream()` — the offscreen-hooks side just exposes the stream; the simulate function is harness-side. | Minimum surface in production tree; the dispatchEvent invocation is harness-side so it's never bundled. | +| 2 | Notification assertions: count vs set-membership? | **Count + set-membership combined.** notificationCount asserts on TOTAL count (e.g. assertion 8: exactly 1 startup notification). notificationIds asserts on prefix membership (e.g. "an id starting 'mokosh-startup-' was created"). lastNotificationOptions asserts on iconUrl shape. | Pure count is brittle (retries inflate); pure set-membership misses overcount regressions. Combined assertions catch both. | +| 3 | CI plumbing scope: include or defer? | **Defer to Phase 5 (P1/P2 hardening) or its own Plan 01-12.** This plan ships a CI-callable harness (`npm run test:uat` exits 0 on pass, non-zero on fail) but no GitHub Actions wiring. Rationale: no existing CI infrastructure in the repo (verified — no `.github/workflows/` directory); adding CI here would force a CI-tool decision (Actions vs self-hosted) that is out of scope for Phase 1 stabilization. | Lowest-friction shipping; CI tool selection deserves its own plan. | +| 4 | Failure isolation: single browser vs per-assertion restart? | **Single browser, serial assertions.** Restart between assertions = ~3-5 s × 14 = 60+ s overhead per run. Single browser keeps total runtime under 60 s. Mitigation: structured diagnostic dump on first failure (SW console logs + offscreen console logs + screenshot) + `--bail` semantics (abort remaining assertions to keep failure mode unambiguous). | RESEARCH §5 recommendation matches; cost of state bleed is much lower than cost of state isolation overhead for 14 deterministic checks. | +| 5 | Test-hook contract location? | **Both.** Production-side canonical: `src/test-hooks/types.ts` (the file that ships with the test bundle and is type-checked by tsc). Harness-side mirror: `tests/uat/lib/test-hook-contract.d.ts` (decoupled from the production tree so the harness has no `import` reaching into `src/`). The mirror file's preamble cites the production-side file as the canonical source. Drift detection: a Tier-1-style test could later snapshot-diff the two; out of scope here, but documented as a follow-up note. | Type duplication is a small price for keeping `tests/` and `src/` import-separable. The drift risk is low because the shape is small (4 fields). | + +### How to test Bug B without committing the revert + +Per orchestrator brief ("rewinding the b9eeeeb conditional routing locally turns this assertion RED"): + +1. Locally apply: `git apply <<'EOF' ... EOF` containing a temporary patch that reverts the `if (errorCode === 'user-stopped-sharing')` branch (so all errors route through `setErrorMode`). +2. Run `npm run test:uat`; assertion 6 (Bug B) MUST fail with a specific diagnostic (`expected badge text '' but got 'ERR'`). +3. Revert the local patch (`git checkout -- src/background/index.ts`). +4. Re-run `npm run test:uat`; assertion 6 MUST pass. + +This RED-on-known-broken / GREEN-on-known-good cycle is the TDD discipline for the harness ITSELF. Each assertion in Task 5/6/7 includes this self-verification step in its action block. + + + + + + + + Task 1 (Wave 0): Install Puppeteer + tsx; add `vite.test.config.ts`; add `build:test` + `test:uat` npm scripts; commit Tier-1 hook-leak grep gate as RED. + + - package.json (existing scripts + devDeps — confirm puppeteer + tsx absent) + - vite.config.ts (the base config the new test config will merge over) + - tests/background/sw-bundle-import.test.ts (Tier-1 gate pattern to mirror) + - tsconfig.json (confirm `include` covers `src/**/*` — needed for src/test-hooks/) + - .planning/phases/01-stabilize-video-pipeline/01-11-RESEARCH.md §10 (two-bundle build orchestration) + + package.json, package-lock.json, vite.test.config.ts, tsconfig.json, tests/background/no-test-hooks-in-prod-bundle.test.ts + + - `npm install --save-dev puppeteer@^25.0.2 tsx@^4` lands cleanly. Both publish to npm registry as MIT-licensed packages with active maintenance windows (puppeteer 25.0.2 published 2025; tsx 4.x current). Pin both with caret ranges per project convention. + - `vite.test.config.ts` exists, extends `./vite.config.ts` via `mergeConfig`, sets `mode: 'test'` + `build.outDir: 'dist-test'` + `build.emptyOutDir: true`. Running `npx vite build --config vite.test.config.ts --mode test` produces `dist-test/` (verifiable via `test -d dist-test`). + - `package.json` `scripts` block adds `build:test` and `test:uat` per the interfaces block. `npm run build:test` exits 0 and produces `dist-test/`. + - `tsconfig.json` `include` covers `src/test-hooks/**/*` (verify it does already via the `src/**/*` glob; no edit needed if `include` is already that wildcard — check first and only add if absent). + - `tests/background/no-test-hooks-in-prod-bundle.test.ts` exists with TWO `it` blocks: + (a) After `npm run build`, ZERO occurrences of `__mokoshTest` in any file under `dist/`. RED today because the gate test is committed BEFORE the hooks land — the gate is asserting on a not-yet-extant invariant. **CORRECTION:** RED-then-GREEN polarity here is inverted vs typical TDD: the gate ITSELF is GREEN today (no hooks → no leak), but the GATE must REMAIN GREEN after Task 2 lands the hooks. The test is committed in this task so the gate is operational BEFORE the hooks ship, eliminating the window-of-vulnerability where the production bundle could contain leaked hooks. Document this polarity in the test file preamble. + (b) After `npm run build`, ZERO occurrences of `simulateUserStop` in any file under `dist/`. Same polarity: GREEN today, must remain GREEN after hooks land. + - Both `it` blocks run a fresh `npm run build` as part of their setup (spawned via `child_process.execFile`, mirroring sw-bundle-import.test.ts's spawn pattern). They then `readdir`+`readFileSync` walk `dist/` and assert grep counts are zero. Skip the build spawn if `process.env.SKIP_BUILD === '1'` (developer escape hatch when running the test repeatedly during this task's iteration). + - The 83 baseline vitest tests + 2 new gate tests = 85 tests, ALL GREEN. (The Tier-1 gate is committed in a working state from day one.) + + + 1. Read `package.json` to confirm `puppeteer` + `tsx` absent. + 2. `npm install --save-dev puppeteer@^25.0.2 tsx@^4` — observe versions resolve correctly. Document the actually-resolved versions in the commit message body. + 3. Update `package.json` `scripts` block per the interfaces section — add `build:test` and `test:uat`. Leave existing scripts (`dev`, `build`, `preview`, `test`) untouched. + 4. Create `vite.test.config.ts` at repo root per the interfaces skeleton. + 5. Verify `tsconfig.json` `include` covers `src/test-hooks/**/*` — if `include` is `["src/**/*"]` or omits `exclude` that would block, no edit needed. Document the actual `tsconfig.json` shape in the commit message body so reviewers see the verification ran. + 6. Run `npm run build:test` → exit 0; `ls dist-test/` confirms emission. Run `npm run build` → exit 0; `ls dist/` confirms separate output. + 7. Create `tests/background/no-test-hooks-in-prod-bundle.test.ts` with the two `it` blocks per behavior (a) + (b). Preamble docstring per project style: extensive (Google Python style mandate carries over — keep mirroring sw-bundle-import.test.ts's docstring density). Cite that this is a Tier-1 gate per `feedback-pre-checkpoint-bundle-gates.md` (the auto-loaded memory item). + 8. Run `npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts` → both GREEN (no hooks landed yet, nothing leaks). + 9. Run `npx vitest run` (full suite) → 84 baseline + 2 new = 85 GREEN. Document the baseline + delta in the commit message body. + 10. Run `npx tsc --noEmit` → exit 0. + 11. Verify that NO `npm test` regression: rerun `npm test` → 85 GREEN. + Per project style: extensive docstrings; absolute imports; no `as any`; no `@ts-ignore`. The new test file is the first one to touch `child_process.execFile` since `sw-bundle-import.test.ts` — mirror that file's pattern verbatim (execFile + maxBuffer + timeout + stdout sentinel scheme). Do NOT introduce a new pattern. + + + npm run build:test && npm run build && test -d dist-test && test -d dist && npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts && npx tsc --noEmit + + + - `package.json` devDeps include `puppeteer` + `tsx` at the pinned versions; `scripts` block carries `build:test` + `test:uat`. + - `vite.test.config.ts` exists, extends base config, emits to `dist-test/`. + - `npm run build:test` exits 0; `dist-test/` populated. + - `npm run build` exits 0; `dist/` populated separately (no clobber). + - `tests/background/no-test-hooks-in-prod-bundle.test.ts` exists with 2 tests; both GREEN. + - Full vitest suite: 83 baseline + 2 new = 85 GREEN. + - `npx tsc --noEmit` exit 0. + + Two-bundle infrastructure landed; Tier-1 hook-leak gate operational (GREEN, will remain GREEN after Task 2 hooks land); npm scripts wired; baseline preserved. + + + + Task 2 (Wave 1): Add gated test hooks to SW + offscreen; verify production bundle remains hook-free (Tier-1 gate stays GREEN). + + - src/background/index.ts (top-of-module — where the import.meta.env.MODE guard lands; lines 1-50) + - src/offscreen/recorder.ts (top-of-module + line ~247 where mediaStream is assigned) + - tests/background/sw-bundle-import.test.ts (the Tier-1 SW-bundle-loadability gate — confirm it still passes after hooks land in test bundle) + - tests/background/no-test-hooks-in-prod-bundle.test.ts (the gate from Task 1) + - .planning/phases/01-stabilize-video-pipeline/01-11-RESEARCH.md §6 (Vite tree-shaking gotchas) + - vite.test.config.ts (from Task 1) + + src/test-hooks/types.ts, src/test-hooks/sw-hooks.ts, src/test-hooks/offscreen-hooks.ts, src/background/index.ts, src/offscreen/recorder.ts, tests/uat/lib/test-hook-contract.d.ts + + - `src/test-hooks/types.ts` exports `MokoshTestSurface` + declares `globalThis.__mokoshTest` per the interfaces block. + - `src/test-hooks/sw-hooks.ts` registers the SW-side hook at module-load: monkey-patches `chrome.action.onClicked.addListener`, `chrome.runtime.onStartup.addListener`, `chrome.notifications.onClicked.addListener` to capture handler refs while still calling the originals. Wraps `chrome.notifications.create` to increment `notificationCount`, push id to `notificationIds`, save `lastNotificationOptions`. Initializes `globalThis.__mokoshTest = { handlers: {...}, notificationCount: 0, lastNotificationOptions: null, notificationIds: [] }`. NO `getCurrentStream` in SW (the field is optional per type — undefined in SW context). + - `src/test-hooks/offscreen-hooks.ts` registers the offscreen-side hook: exposes a mutable `currentStream: MediaStream | null` cell + `setCurrentStream(s)` setter + `__mokoshTest.getCurrentStream = () => currentStream` getter. The recorder calls `setCurrentStream` after the `mediaStream = stream` assignment (gated by the same MODE check). + - `src/background/index.ts` top-of-module gets: + ```typescript + if (import.meta.env.MODE === 'test') { + await import('../test-hooks/sw-hooks'); + } + ``` + Placement: BEFORE any `addListener` calls in the file so the monkey-patches catch every handler. This is a top-level `await` — supported in SW context per crxjs/Vite's MV3 module emission. + - `src/offscreen/recorder.ts` top-of-module gets the symmetric gated import; the `setCurrentStream` call lands inside `startRecording` right after `mediaStream = stream;` (line 247), also gated. + - `tests/uat/lib/test-hook-contract.d.ts` mirrors `MokoshTestSurface` for harness-side consumption (it's a declaration file; not bundled, only used at type-check time on the harness). + - After all changes, `npm run build` exits 0 AND `tests/background/no-test-hooks-in-prod-bundle.test.ts` REMAINS GREEN (the literal `__mokoshTest` does NOT appear in any file under `dist/`). `npm run build:test` exits 0 AND ONE OR MORE files under `dist-test/` contain `__mokoshTest` (verifiable by `grep -l __mokoshTest dist-test/`). + - `tests/background/sw-bundle-import.test.ts` REMAINS GREEN (Layer 1 + Layer 2; the gated dynamic import does not break the production bundle's module init). + - Full vitest suite: 85 GREEN (no regression). + + + 1. Create `src/test-hooks/types.ts` per the interfaces block. Extensive JSDoc; cite this plan's Task 2 + RESEARCH §6 (gating mechanism) + RESEARCH §7 (Bug B BLOCKER context for getCurrentStream's role). + 2. Create `src/test-hooks/sw-hooks.ts`. Monkey-patch pattern follows RESEARCH §6 Pattern 1. Wrap `chrome.notifications.create` so all four shape fields update (count, last options, ids array, no-op chain to the original create). Use absolute Chrome types from `@types/chrome` — no `as any`. Initialization at module load: + ```typescript + const handlers: MokoshTestSurface['handlers'] = { + onClicked: null, onStartup: null, notificationOnClicked: null, + }; + const notificationIds: string[] = []; + + const origActionAdd = chrome.action.onClicked.addListener.bind(chrome.action.onClicked); + chrome.action.onClicked.addListener = (cb) => { + handlers.onClicked = cb; + return origActionAdd(cb); + }; + // ... similarly for onStartup, notifications.onClicked ... + + const origNotifCreate = chrome.notifications.create.bind(chrome.notifications); + (chrome.notifications.create as unknown) = (idOrOptions: string | chrome.notifications.NotificationOptions, optionsOrCb?: chrome.notifications.NotificationOptions | ((id: string) => void), maybeCb?: (id: string) => void) => { + // Handle both (id, options, cb) and (options, cb) overloads; + // surface the resolved id in notificationIds. + // Call origNotifCreate with the same args; wrap the callback to push id. + // Increment notificationCount; save lastNotificationOptions. + // Return the original return value (Chrome 88+ also Promise-returning). + }; + + globalThis.__mokoshTest = { + handlers, + notificationCount: 0, + lastNotificationOptions: null, + get notificationIds() { return notificationIds.slice(); }, + }; + ``` + The `as unknown` cast in the `create` reassignment is unavoidable because Chrome's `create` is typed as overloaded callable; document this explicitly with a comment citing the overload variance issue. NO `as any` — the `as unknown` + downstream typed body is the project-style escape hatch. + 3. Create `src/test-hooks/offscreen-hooks.ts`: + ```typescript + let currentStream: MediaStream | null = null; + export function setCurrentStream(stream: MediaStream | null): void { + currentStream = stream; + } + globalThis.__mokoshTest = { + // ...inherit SW's surface if it was set first; in offscreen context + // sw-hooks.ts did NOT run because this is a different document. + // So we initialize a fresh shape with only the offscreen-relevant fields: + handlers: { onClicked: null, onStartup: null, notificationOnClicked: null }, + notificationCount: 0, + lastNotificationOptions: null, + notificationIds: [], + getCurrentStream: () => currentStream, + }; + ``` + Note: the SW and offscreen are DIFFERENT JS isolates with DIFFERENT `globalThis`. The harness reads each surface via the appropriate `sw.evaluate` or `offPage.evaluate`. No cross-context shared state. + 4. Edit `src/background/index.ts` — add the gated dynamic import at the TOP of the file (after any necessary type imports but BEFORE the existing logger initialization + addListener calls). Document inline that the placement is load-order-critical: this MUST run before any addListener. + 5. Edit `src/offscreen/recorder.ts`: + (a) Top-of-module: gated dynamic import per the SW pattern. + (b) Inside `startRecording`, immediately after `mediaStream = stream;` (line ~247): gated `setCurrentStream(stream)` call. Use a top-level captured reference to the hooks module (set during the top-of-module import via a module-scoped `let hooks: typeof import('../test-hooks/offscreen-hooks') | null = null;` plus assignment in the import block). This avoids re-import per startRecording call. + 6. Create `tests/uat/lib/test-hook-contract.d.ts`. Mirror `MokoshTestSurface`. Add a preamble docstring citing `src/test-hooks/types.ts` as the canonical source AND noting the drift-risk (manual sync) + the rationale for decoupling (no `import` from `tests/` into `src/`). + 7. Run `npx tsc --noEmit` → exit 0 (all hook code typechecks). + 8. Run `npm run build` (production). Then check `grep -rln __mokoshTest dist/` → ZERO matches. The Tier-1 gate test `tests/background/no-test-hooks-in-prod-bundle.test.ts` MUST stay GREEN. + 9. Run `npm run build:test`. Then check `grep -rln __mokoshTest dist-test/` → ONE OR MORE matches (the hook code is bundled into the test build). + 10. Run `npx vitest run` (full suite). 85 GREEN. The SW-bundle-import test must also be GREEN — verifies the gated dynamic import does NOT break production module init. + 11. Sanity-check: open one of the production bundle's chunk files (the SW chunk via `dist/service-worker-loader.js` → its imported chunk) and confirm by eye that no `__mokoshTest` string is present. The grep gate is authoritative, but a manual eyeball ensures the gate isn't fooled by some bundler renaming. + DESIGN NOTE: the gated dynamic import IS the tree-shake trigger. If Vite ever fails to tree-shake a dynamic import behind a literal-comparison guard (which it shouldn't per RESEARCH §6 — the literal `'test'` !== `'production'` comparison is a static dead branch in production), the Tier-1 gate fails LOUDLY at CI time. The gate is THE mitigation for assumption A3 in RESEARCH §6. + + + npx tsc --noEmit && npm run build && test "$(grep -rln __mokoshTest dist/ | wc -l)" = "0" && npm run build:test && test "$(grep -rln __mokoshTest dist-test/ | wc -l)" -ge "1" && npx vitest run --reporter=dot + + + - `src/test-hooks/{types,sw-hooks,offscreen-hooks}.ts` exist with the contracts described. + - `src/background/index.ts` + `src/offscreen/recorder.ts` carry the gated dynamic import block; in offscreen, also the `setCurrentStream(stream)` call inside `startRecording`. + - `tests/uat/lib/test-hook-contract.d.ts` mirrors the type. + - `npm run build` exits 0; `grep -rln __mokoshTest dist/` → 0 matches. + - `npm run build:test` exits 0; `grep -rln __mokoshTest dist-test/` → ≥1 match. + - Tier-1 grep gate (`tests/background/no-test-hooks-in-prod-bundle.test.ts`) GREEN. + - Tier-1 SW-bundle-import gate (`tests/background/sw-bundle-import.test.ts`) GREEN. + - Full vitest suite: 85 GREEN. + - `npx tsc --noEmit` exit 0. + + Hook surfaces live in test bundle; absent in production bundle (Tier-1 grep gate verifies); SW + offscreen module init unchanged for production; baseline preserved. + + + + Task 3 (Wave 2): Build harness scaffolding — `tests/uat/lib/{launch,extension,sw,offscreen,assertions,zip}.ts` + `harness.test.ts` skeleton with all 14 assertions stubbed as failing. + + - .planning/phases/01-stabilize-video-pipeline/01-11-RESEARCH.md §1 (Puppeteer extension API patterns) + - .planning/phases/01-stabilize-video-pipeline/01-11-RESEARCH.md §4 (target type quirk for offscreen) + - .planning/phases/01-stabilize-video-pipeline/01-11-RESEARCH.md §7 (Bug B dispatchEvent contract — BLOCKER) + - .planning/phases/01-stabilize-video-pipeline/01-11-RESEARCH.md §11 (per-assertion implementation hints) + - src/test-hooks/types.ts (from Task 2) + - tests/uat/lib/test-hook-contract.d.ts (from Task 2) + - tests/background/sw-bundle-import.test.ts (execFile child-process pattern — only relevant for assertion 0 which uses fs.readdir directly, not a spawned child) + + tests/uat/lib/launch.ts, tests/uat/lib/extension.ts, tests/uat/lib/sw.ts, tests/uat/lib/offscreen.ts, tests/uat/lib/assertions.ts, tests/uat/lib/zip.ts, tests/uat/harness.test.ts, tests/uat/README.md + + - `tests/uat/lib/launch.ts` exports `launchHarnessBrowser(options?: HarnessOptions): Promise` returning `{ browser, sw, ext, page, downloadsDir }`. Reads `HEADLESS` env var (`'0'` = headful for debug, anything else = headless). Wires Chrome args per RESEARCH §1 + §9. + - `tests/uat/lib/extension.ts` exports `attachToSw`, `attachToOffscreen`, `waitForOffscreen` per the RESEARCH §4 patterns. The offscreen attach uses the `background_page` target type + `.asPage()` (Pitfall 1). + - `tests/uat/lib/sw.ts` exports `getBadgeText(sw)`, `getPopup(sw)`, `getManifest(sw)`, `getIconSize(sw, path)`, `fireOnStartup(sw)`, `sendSyntheticRecordingError(sw, errorCode)`, `keepalivePing(sw)`, `getNotificationSnapshot(sw)`. + - `tests/uat/lib/offscreen.ts` exports `getDisplaySurface(offPage)`, `simulateUserStop(offPage)` (the dispatchEvent path per RESEARCH §7 BLOCKER — with an inline comment block citing the BLOCKER reasoning so future readers don't refactor it to `track.stop()`). + - `tests/uat/lib/assertions.ts` exports `assertEqual(actual, expected, msg)` + `assertMatch(actual, regex, msg)` + `assertTrue(cond, msg)` + a structured `runAssertion(name, fn)` wrapper that runs a single assertion, captures any SW/offscreen console logs since the last assertion, and dumps them to stderr on failure. Uses `node:assert/strict` per RESEARCH §4. + - `tests/uat/lib/zip.ts` exports `assertArchiveShape(zipBuf, expectedVersion)` — opens with jszip, asserts `video/last_30sec.webm` present + `meta.json` carries `version === expectedVersion`. The meta.json shape is per Plan 01-07 (existing archive contract — read once at the start of the harness and pass through). + - `tests/uat/harness.test.ts` is the single Node script (tsx-runnable). Top-to-bottom narrative: + ``` + 0. Pre-flight grep gate (filesystem readdir on dist/) — assertion 0. + 1. launchHarnessBrowser → attachToSw → attachToOffscreen-when-ready. + 2. Assertion 1: SW bootstrap → setIdleMode (badge '', popup '', isRecording=false). + 3. Assertion 2: triggerExtensionAction → wait → badge 'REC' + popup === src/popup/index.html + isRecording=true. + 4. Assertion 3: offscreen track displaySurface === 'monitor'. + 5. Assertion 4: triggerExtensionAction (while recording) → popup opens, NO new offscreen target. + 6. Assertion 5: sendMessage SAVE_ARCHIVE → wait for download → check downloadsDir contains session_report_*.zip. + 7. Assertion 6 (BUG B): simulateUserStop → wait 300ms → badge '' + popup '' + isRecording=false + notificationCount delta = 0. + 8. Assertion 7 (ERROR path): sendSyntheticRecordingError('codec-unsupported') → badge 'ERR' + notificationCount delta = 1. + 9. Assertion 8 (BUG A + onStartup): fireOnStartup → notifications.create called once with iconUrl matching icons/icon48.png (or icon128.png — verify which one the production code uses; the badge_state_machine plan uses icon128, but the test asserts whichever the production code actually invokes per the lastNotificationOptions snapshot). + 10. Assertion 9: icon file sizes via sw.evaluate(fetch) ≥ floors (16: 200B, 48: 500B, 128: 1024B). + 11. Assertion 10: manifest has 'notifications' permission + icons.16 + icons.48 + icons.128 declared. + 12. Assertion 11 (35s record): start a fresh recording, wait 35s, query SW (via runtime message → offscreen → segments count) → segments.length >= 3. + 13. Assertion 12 (ffprobe gate): trigger SAVE_ARCHIVE, extract video/last_30sec.webm, spawn ffprobe → exit 0. + 14. Assertion 13 (zip shape): assertArchiveShape on the latest session_report_*.zip. + 15. Final summary: `console.log('UAT harness: 14/14 assertions passed')`; exit 0. + ``` + ALL 14 assertions stubbed today as `runAssertion('N: title', async () => { throw new Error('NOT YET IMPLEMENTED — Task 5+ wires this'); });` so the harness exits non-zero with a clear "N assertions failed" diagnostic. Assertion 0 (filesystem-only) is wired in this task; assertions 1-13 are stubbed. + - `tests/uat/README.md` documents: + - How to run: `npm run test:uat` (build + harness). + - Local-debug headful mode: `HEADLESS=0 npm run test:uat`. + - Skipping the build (developer iteration): `SKIP_BUILD=1 npx tsx tests/uat/harness.test.ts` (the build is the npm-script wrapper; the harness itself can run against an existing `dist-test/`). + - Locale gotcha: `--auto-select-desktop-capture-source="Entire screen"` works on en_US; other locales need the locale-equivalent string. Fallback to operator-pick + `KEEP_PROFILE=1` documented as the Plan 01-09 fallback. + - dev-dep size: puppeteer pulls ~150MB Chromium binary; CI must accept this. Production `npm install --omit=dev` skips it cleanly. + - Xvfb is NOT required (per RESEARCH §3 empirical probes on Chrome 148). + - Failure isolation choice: single browser, serial assertions, bail on first failure (RESEARCH §5 + open-question resolution 4). + - Running `npm run test:uat` exits NON-ZERO today (the 13 stubbed assertions all throw); the diagnostic clearly identifies which assertion failed AND why ("NOT YET IMPLEMENTED — Task 5+ wires this"). Assertion 0 (the grep gate) PASSES — confirming the harness scaffolding wires correctly and the only failures are intentional stubs. + + + 1. Create the `tests/uat/lib/` directory + all 6 helper files. Use absolute imports per project style. NO `as any`; type each helper's surface explicitly. Each helper file gets a top-of-file docstring per project style (extensive Google-style). + 2. `launch.ts`: implementation uses `puppeteer.launch({ enableExtensions: [absolutePath], headless: ..., args: [...] })`. The absolutePath is computed via `path.resolve(__dirname, '../../../dist-test')` (the harness lives at `tests/uat/harness.test.ts` so `../../../` lands at repo root). Use `fileURLToPath` + `import.meta.url` for the `__dirname` shim (the harness runs as ESM under tsx). + 3. `extension.ts`: implementation per RESEARCH §1 + §4 patterns. The offscreen attach uses `browser.waitForTarget(t => t.type() === 'background_page' && t.url().includes('offscreen'), { timeout: 5_000 })`. After getting the target, `.asPage()` returns the Page. + 4. `sw.ts`: each helper is one or two lines of `sw.evaluate(...)`. The `getNotificationSnapshot` helper returns a structured `{ count, lastOptions, ids }` to keep the harness's reasoning unified. + 5. `offscreen.ts` `simulateUserStop`: + ```typescript + export async function simulateUserStop(offPage: Page): Promise { + // RESEARCH §7 BLOCKER — DO NOT REFACTOR to track.stop(). + // track.stop() does NOT fire 'ended' per W3C spec (verified probe7); + // dispatchEvent IS the only path that triggers our production + // onUserStoppedSharing handler. A test that calls track.stop() would + // silently pass while production reality fails — exactly the trap + // Bug B fix (commit b9eeeeb) addresses. + await offPage.evaluate(() => { + const stream = globalThis.__mokoshTest?.getCurrentStream?.(); + if (!stream) throw new Error('no current MediaStream — recording must be active'); + const track = stream.getVideoTracks()[0]; + if (!track) throw new Error('no video track in stream'); + track.dispatchEvent(new Event('ended')); + }); + } + ``` + 6. `assertions.ts`: `runAssertion(name, fn)` captures `console.log`/`console.error` from the harness's own process; for SW + offscreen console logs, accept an optional `consoleSinks` parameter — the harness wires SW.on('console', ...) + offPage.on('console', ...) listeners at launch and passes their accumulating buffers to runAssertion. On assertion failure: dump buffers to stderr with structured "SW console (last N):" + "Offscreen console (last N):" preambles; rethrow. + 7. `zip.ts`: jszip-based reader. The `expectedVersion` comes from `chrome.runtime.getManifest().version` (queried once at the start of the harness via `sw.evaluate`). Assertion is exact equality. + 8. `harness.test.ts`: the top-to-bottom narrative. Wrap the whole thing in a top-level `try/finally`; the `finally` always calls `browser.close()`. The 14 assertion stubs all throw the "NOT YET IMPLEMENTED" Error. Assertion 0 is wired in this task: + ```typescript + await runAssertion('0: production bundle has no __mokoshTest leak', async () => { + // Filesystem-only — does not require the browser. + // We don't run `npm run build` here; that's the caller's responsibility + // (npm run test:uat does `npm run build:test` first; a separate `npm run build` + // confirmation could be added as a pre-flight, but the no-test-hooks-in-prod-bundle + // unit test already covers that and runs as part of `npm test`. Here we re-verify + // for E2E robustness against the case where the unit test passed against a stale dist/.) + const { execFileSync } = await import('node:child_process'); + execFileSync('npm', ['run', 'build'], { stdio: 'inherit' }); + const distDir = path.resolve(__dirname, '../../dist'); + const matches = await grepRecursive(distDir, '__mokoshTest'); + assertEqual(matches.length, 0, 'production dist/ must not contain __mokoshTest'); + }); + ``` + NOTE: assertion 0 spawns `npm run build` from inside the harness, which costs ~10s. The unit test (Task 1) makes this somewhat redundant — but the unit test runs in the vitest pass; the harness runs separately. Belt + suspenders. Alternative: skip the spawn if `process.env.SKIP_PROD_REBUILD === '1'` for developer iteration. + 9. `README.md`: per the behavior list. + 10. Run `npm run test:uat`. Expected output: + - `npm run build:test` runs first (succeeds; emits dist-test/). + - `tsx tests/uat/harness.test.ts` runs. + - Assertion 0 PASSES (filesystem grep gate). + - Assertions 1-13 all THROW "NOT YET IMPLEMENTED". + - Exit code: non-zero. + - Diagnostic line: "UAT harness: 1/14 assertions passed, 13 failed (first failure: Assertion 1)". + 11. Run `npx tsc --noEmit` → exit 0 (all harness code type-clean against `@types/chrome` + puppeteer types + `tests/uat/lib/test-hook-contract.d.ts`). + 12. Run `npx vitest run` (full suite) → 85 GREEN (no regression to unit tests; the harness lives outside vitest's discovery). + Per project style: extensive docstrings; absolute imports; no `as any`; no `@ts-ignore`; named callbacks (the runAssertion lambdas are short enough to be acceptable as inline arrows). Use if-else chains over early returns where the assertion logic has multi-arm branching; guard-clause early returns are fine for null-checks per established project exception. + + + npx tsc --noEmit && npm run test:uat; test $? -ne 0 && npx vitest run --reporter=dot + + + - All 7 helper files exist with the contracts described. + - `harness.test.ts` exists with assertion 0 wired (GREEN) + assertions 1-13 stubbed (RED). + - `README.md` documents the runtime + local-debug + CI semantics. + - `npm run test:uat` exits non-zero today; diagnostic clearly identifies assertion 0 as PASS + assertions 1-13 as "NOT YET IMPLEMENTED". + - `npx tsc --noEmit` exit 0 across both `src/` and `tests/` trees. + - Full vitest suite: 85 GREEN. + - No file under `src/` modified by this task (the harness is purely under `tests/`). + + Harness scaffolding live with assertion 0 wired GREEN; assertions 1-13 staged as RED stubs for Tasks 4-7; baseline preserved. + + + + Task 4 (Wave 3 — bundle 1/4): Wire assertions 1, 2, 3, 4 (SW bootstrap + toolbar onClicked + displaySurface + popup-during-recording). + + - tests/uat/harness.test.ts (skeleton from Task 3) + - tests/uat/lib/{launch,extension,sw,offscreen}.ts (helpers from Task 3) + - src/background/index.ts lines 75-108 (setIdleMode/setRecordingMode state machine — the production code these assertions verify) + - src/background/index.ts lines 411-415 (setRecordingMode call site inside startVideoCapture) + - src/background/index.ts lines 844-858 (chrome.action.onClicked listener registration) + - src/offscreen/recorder.ts lines 241-247 (getDisplayMedia call + mediaStream assignment) + - .planning/phases/01-stabilize-video-pipeline/01-11-RESEARCH.md §1 (triggerExtensionAction + the popup-vs-onClicked MV3 contract) + - .planning/phases/01-stabilize-video-pipeline/01-09-PLAN.md (the must-haves these assertions are verifying) + + tests/uat/harness.test.ts, tests/uat/lib/sw.ts + + - Assertion 1 (SW bootstrap): after `launchHarnessBrowser` + attach SW, query `getBadgeText` (empty), `getPopup` (empty), `getIsRecording` (false — exposed via a new helper that reads `globalThis.isRecording` from the SW context via `sw.evaluate`; the SW production code has `isRecording` as a module-level let, accessible from the SW global). PASSES today against current bundle. + - Assertion 2 (onClicked-idle): `page.triggerExtensionAction(ext)` → `await waitFor(() => getBadgeText() === 'REC', 5_000)` (poll up to 5s; the picker auto-selects the screen so getDisplayMedia resolves fast). Then assert popup === 'src/popup/index.html' + getIsRecording === true. PASSES today. + - Assertion 3 (displaySurface): after assertion 2 leaves recording active, attach to offscreen via `waitForOffscreen` + `attachToOffscreen`. Then `offsetPage.evaluate(() => __mokoshTest.getCurrentStream().getVideoTracks()[0].getSettings().displaySurface)` === 'monitor'. PASSES today (per Plan 01-09 D-15-display-surface; the post-grant validation in recorder.ts ensures monitor-only). + - Assertion 4 (click-during-recording): record the current offscreen target count, then `page.triggerExtensionAction(ext)` again. Assert: popup state unchanged (still 'src/popup/index.html'); NO new offscreen target spawned (count unchanged). The toolbar click with popup set opens the popup (which the harness can verify via `browser.targets().find(t => t.url().includes('popup/index.html'))` — the popup target appears as a `page` type briefly). PASSES today. + - All 4 assertions wired; each carries an inline RED-on-regression demonstration step in its action block: the executor must locally demonstrate the assertion CAN catch a regression before marking the assertion GREEN. + + + 1. Wire assertion 1: replace the "NOT YET IMPLEMENTED" stub with the real logic per behavior. Add a `getIsRecording(sw)` helper to `tests/uat/lib/sw.ts`: + ```typescript + export async function getIsRecording(sw: WebWorker): Promise { + return await sw.evaluate(() => (globalThis as any).isRecording as boolean); + } + ``` + NOTE: this is the ONE site where `as any` is unavoidable — the production code declares `isRecording` as a module-level `let` in `src/background/index.ts:36`, which is NOT exposed on globalThis directly. To read it, we need to evaluate in the SW context AS the SW (which has implicit globalThis access to module-top let-bindings — verify this is true in MV3 SW context; if not, expose `isRecording` via a getter on `__mokoshTest` in `sw-hooks.ts`). Document the choice + rationale inline. + (Per RESEARCH §6 contract verification: SW module-level `let` IS accessible as `globalThis.isRecording` in MV3 SW context — verified by probe2. If the executor sees `undefined` returned, fall back to exposing via `__mokoshTest.isRecording` getter from sw-hooks.ts and document the SW-isolation finding.) + 2. Wire assertion 2: implementation per behavior. After `triggerExtensionAction`, poll `getBadgeText` for up to 5 seconds — the badge transition is async (offscreen creation + getDisplayMedia + post-grant validation + setRecordingMode all happen in sequence). Use a polling helper from `assertions.ts` or inline: + ```typescript + async function waitFor(probe: () => Promise, predicate: (v: T) => boolean, timeoutMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const v = await probe(); + if (predicate(v)) return v; + await new Promise(r => setTimeout(r, 100)); + } + throw new Error(`waitFor timeout ${timeoutMs}ms`); + } + ``` + Use this in assertion 2 + 3 + 4. + 3. Wire assertion 3: per behavior. The `waitForOffscreen` helper already handles the target wait + asPage; attach once after assertion 2 sets recording=true, then offPage.evaluate the displaySurface read. + 4. Wire assertion 4: per behavior. Count `browser.targets()` filtered to offscreen-url-containing BEFORE the second click, then AFTER; assert equality. Also assert popup state unchanged. + 5. RED-on-regression demonstration: + - For assertion 2: locally insert `chrome.action.onClicked.addListener(async () => { return; })` BEFORE the production listener and re-build:test; assertion 2 should FAIL (badge stays empty). Revert the hack; assertion 2 PASSES. + - For assertion 3: locally alter `recorder.ts` to call `getDisplayMedia({ video: true, audio: false })` (without displaySurface constraint) and rebuild; assertion 3 should FAIL (displaySurface defaults to 'browser' OR is undefined depending on Chrome behavior). Revert; PASSES. + - The executor commits ONLY the working assertions; the RED demos are local-only verifications. Document each RED demo's outcome in the commit message body. + 6. Run `npm run test:uat`: assertions 0+1+2+3+4 PASS; assertions 5-13 still stubbed as RED. Exit non-zero. Diagnostic: "5/14 passed, 9 failed". + 7. Run `npx tsc --noEmit` → exit 0. + 8. Run full vitest suite → 85 GREEN. + + + npx tsc --noEmit && (set +e; npm run test:uat; test $? -ne 0) + + + - Assertions 0, 1, 2, 3, 4 all PASS in `npm run test:uat`. + - Assertions 5-13 still throw "NOT YET IMPLEMENTED". + - `npm run test:uat` exits non-zero (because 9 stubs remain). + - Diagnostic shows 5/14 passed. + - `npx tsc --noEmit` exit 0. + - Full vitest suite: 85 GREEN. + - Each wired assertion's commit message body cites the RED-demonstration outcome. + + First 4 functional assertions live and GREEN; harness proves it can verify toolbar + displaySurface + popup-state via CDP. + + + + Task 5 (Wave 3 — bundle 2/4): Wire assertions 5, 6, 7 (SAVE_ARCHIVE download + Bug B user-stopped routing + ERROR-path). + + - tests/uat/harness.test.ts (assertions 1-4 GREEN from Task 4) + - tests/uat/lib/{sw,offscreen,zip}.ts (helpers; especially simulateUserStop's BLOCKER-citing comment) + - src/background/index.ts lines 725-778 (RECORDING_ERROR handler — Bug B conditional routing) + - src/offscreen/recorder.ts lines 451-480 (onUserStoppedSharing — the handler simulateUserStop must trigger) + - .planning/debug/resolved/01-09-recovery-flow.md (Bug B debug record — the exact contract assertion 6 verifies) + - .planning/phases/01-stabilize-video-pipeline/01-11-RESEARCH.md §7 (BLOCKER analysis — track.dispatchEvent is the ONLY valid path) + + tests/uat/harness.test.ts, tests/uat/lib/sw.ts + + - Assertion 5 (SAVE_ARCHIVE download): with recording active from prior assertions, `sw.evaluate(() => chrome.runtime.sendMessage({type: 'SAVE_ARCHIVE'}))` triggers the save flow. The download lands in `downloadsDir` (configured at launch via `--user-data-dir` + per-page download behavior, OR via `page._client().send('Browser.setDownloadBehavior', ...)` — RESEARCH didn't deep-dive this; the executor researches the cleanest path). Poll for `*session_report*.zip` appearance in downloadsDir for up to 15s. PASSES today. + - Assertion 6 (BUG B): snapshot `notificationCount` via `getNotificationSnapshot(sw)`. Then `simulateUserStop(offPage)`. Wait 300ms (offscreen handler → runtime message → SW handler → state transition is async). Assert: badge text === '' (NOT 'ERR'); popup === '' (NOT 'src/popup/index.html'); isRecording === false; notificationCount delta === 0 (no recovery notification fired for deliberate stop). PASSES today against b9eeeeb. + - Assertion 7 (ERROR-path preserved): start a fresh recording (since assertion 6 stopped it). Snapshot notificationCount. Then `sw.evaluate(() => chrome.runtime.sendMessage({type: 'RECORDING_ERROR', error: 'codec-unsupported'}))`. Wait 200ms. Assert: badge text === 'ERR'; notificationCount delta === 1; last notification id starts with 'mokosh-recovery-'. PASSES today. + - Each assertion carries the RED-on-regression demonstration; assertion 6's RED demo is the canonical "rewinding b9eeeeb" cycle from the orchestrator brief. + + + 1. Wire assertion 5. Investigate Puppeteer's download path config: `browser.defaultBrowserContext().overridePermissions(...)` for downloads OR `CDP Browser.setDownloadBehavior` with `behavior: 'allow'` + `downloadPath: downloadsDir`. The harness creates `downloadsDir` in the launch helper (e.g. `os.tmpdir() + '/mokosh-uat-downloads-' + Date.now()`). After `sendMessage({type:'SAVE_ARCHIVE'})`, poll the dir for ~15s for any `session_report_*.zip`. Save the path for assertion 13. PASS = file appears + non-zero size. + 2. Wire assertion 6 per behavior. Use the existing `simulateUserStop` helper (with its BLOCKER comment intact). The 300ms wait is the propagation budget; if assertions intermittently flake here, bump to 500ms — the offscreen handler is synchronous-into-sendMessage, the SW handler is synchronous-into-setIdleMode, so 300ms is generous but not extravagant. + 3. Wire assertion 7 per behavior. Reads `lastNotificationOptions.title` or similar to verify "Mokosh stopped" recovery copy AND `notificationIds[notificationIds.length-1].startsWith('mokosh-recovery-')`. + 4. RED-on-regression demonstrations (recorded in commit body): + - **Assertion 6 RED demo (THE canonical Bug B regression check)**: locally `git diff HEAD~1 -- src/background/index.ts` to recover the pre-b9eeeeb shape of the RECORDING_ERROR handler (unconditional setErrorMode); APPLY the inverse patch locally (do NOT commit). Rebuild test bundle. Run `npm run test:uat`. Assertion 6 MUST FAIL with diagnostic: "expected badge text '' but got 'ERR'". Revert (`git checkout -- src/background/index.ts`). Rebuild. Re-run. Assertion 6 PASSES. This proves the harness assertion CAN catch a Bug B regression. **Document this end-to-end demo in the commit message body.** + - Assertion 5 RED demo: locally comment out the `chrome.downloads.download(...)` call in `src/background/index.ts:saveArchive` and rebuild; assertion 5 should FAIL (timeout waiting for zip). Revert; PASSES. + - Assertion 7 RED demo: locally short-circuit the RECORDING_ERROR case to return without calling setErrorMode for codec-unsupported (e.g. early-return on case entry); assertion 7 should FAIL. Revert; PASSES. + 5. Run `npm run test:uat`: 8/14 PASS, 6 stubs remain. Exit non-zero. + 6. Run `npx tsc --noEmit` → exit 0. Vitest 85 GREEN. + + + npx tsc --noEmit && (set +e; npm run test:uat; test $? -ne 0) + + + - Assertions 0-7 all PASS. + - Assertions 8-13 still stubbed RED. + - `npm run test:uat` exits non-zero; diagnostic 8/14 passed. + - Bug B RED-on-regression demo documented in commit body (mandatory). + - `npx tsc --noEmit` exit 0; vitest 85 GREEN. + + Bug B harness assertion live AND demonstrably catches regression; SAVE_ARCHIVE + ERROR-path coverage live; bug-class root cause (state-machine routing) now CI-callable. + + + + Task 6 (Wave 3 — bundle 3/4): Wire assertions 8, 9, 10 (Bug A onStartup notification + icon file sizes + manifest shape). + + - tests/uat/harness.test.ts (assertions 1-7 GREEN from Tasks 4-5) + - src/background/index.ts lines 860-881 (chrome.runtime.onStartup handler — the path Bug A's recovery notification was failing on before a881bf0) + - manifest.json (icons declared + notifications permission) + - .planning/phases/01-stabilize-video-pipeline/01-11-RESEARCH.md §11 (per-assertion implementation hints) + - icons/icon{16,48,128}.png (verify presence + size — the floors are 200/500/1024 bytes from the orchestrator brief) + + tests/uat/harness.test.ts + + - Assertion 8 (BUG A + onStartup): snapshot notificationCount. Then `sw.evaluate(() => globalThis.__mokoshTest!.handlers.onStartup?.())`. Wait 100ms (synchronous handler, but allow microtask drain). Assert: notificationCount delta === 1; `lastNotificationOptions.iconUrl` matches `/icons\/icon(?:128|48)\.png$/` (the production code uses NOTIFICATION_ICON_PATH = 'icons/icon128.png'); `lastNotificationOptions.title === 'Mokosh ready'`; `notificationIds[notificationIds.length-1].startsWith('mokosh-startup-')`. The PASS condition implies chrome.notifications.create's promise resolved cleanly — if Bug A regressed (icon below floor), Chrome's imageUtil throws and the create call REJECTS, so notificationCount would NOT increment. PASSES today against a881bf0. + - Assertion 9 (icon files present + sized): for each of (16, 200), (48, 500), (128, 1024), `sw.evaluate` a fetch of `chrome.runtime.getURL('icons/icon{N}.png')` and read `content-length`. Assert >= floor. PASSES today. + - Assertion 10 (manifest shape): `getManifest(sw)`. Assert: `permissions.includes('notifications')`; `icons['16']`, `icons['48']`, `icons['128']` all defined and equal to expected paths. PASSES today. + - Each assertion's RED-on-regression demo documented in commit body. + + + 1. Wire assertion 8 per behavior. The `onStartup` handler in production carries inline try/catch around the `chrome.notifications.create` call (per src/background/index.ts:868-877); the hook's notificationCount wrapper increments regardless of create's resolution path. To verify Bug A specifically, ALSO assert that the iconUrl in lastNotificationOptions points to a file that resolves to >= 1024 bytes (cross-check with assertion 9's floor). This catches the Bug A regression EVEN IF a future change wraps the create call in a swallowing try/catch. + 2. Wire assertion 9 per behavior. The fetch via sw.evaluate is the cleanest path — Chrome serves extension files from `chrome-extension:///...` and fetch with a `chrome-extension://` URL works in SW context. + 3. Wire assertion 10 per behavior. Direct `chrome.runtime.getManifest()` read. + 4. RED-on-regression demos (commit body): + - **Assertion 8 RED demo (Bug A canonical)**: locally `echo "" > icons/icon128.png` (truncate to 0 bytes). Rebuild test bundle. Run `npm run test:uat`. Assertion 8 should FAIL — Chrome's imageUtil rejects the create call (or the wrapper's lastNotificationOptions snapshot has wrong shape). Restore (`git checkout -- icons/icon128.png`). Rebuild. Re-run. Assertion 8 PASSES. **Document in commit body.** + - Assertion 9 RED demo: same truncate; rebuild; assertion 9 should FAIL with "content-length 0 < floor 1024". Restore; PASSES. + - Assertion 10 RED demo: locally remove "notifications" from manifest.json permissions and rebuild test bundle; assertion 10 should FAIL. Restore; PASSES. + 5. Run `npm run test:uat`: 11/14 PASS, 3 stubs remain (11, 12, 13). + 6. `npx tsc --noEmit` exit 0; vitest 85 GREEN. + + + npx tsc --noEmit && (set +e; npm run test:uat; test $? -ne 0) + + + - Assertions 0-10 all PASS. + - Assertions 11-13 still stubbed RED. + - `npm run test:uat` exits non-zero; diagnostic 11/14 passed. + - Bug A RED-on-regression demo documented in commit body (mandatory). + - `npx tsc --noEmit` exit 0; vitest 85 GREEN. + + Bug A harness assertion live AND demonstrably catches regression; icon + manifest coverage live; both Phase-1-escapee bug classes (Bug A + Bug B) now CI-callable. + + + + Task 7 (Wave 3 — bundle 4/4): Wire assertions 11, 12, 13 (35s buffer continuity + ffprobe gate + zip shape) — closes the 13-assertion charter. + + - tests/uat/harness.test.ts (assertions 1-10 GREEN from Tasks 4-6) + - tests/uat/lib/zip.ts (the jszip-based archive shape helper) + - tests/offscreen/webm-playback.test.ts (the existing ffprobe pattern — FFPROBE_BIN constant, skip-gate helper) + - src/background/webm-remux.ts (Plan 01-08's remux helper — what the harness's ffprobe gate validates) + - .planning/phases/01-stabilize-video-pipeline/01-11-RESEARCH.md §11 (per-assertion implementation hints for 11, 12, 13) + + tests/uat/harness.test.ts, tests/uat/lib/zip.ts + + - Assertion 11 (35s buffer continuity): start a fresh recording. Wait 35 seconds (with keepalive pings every 20s per RESEARCH §2). Query the offscreen segments count via offPage.evaluate (the offscreen recorder maintains a `segments` ring; expose it via a `__mokoshTest.getSegmentCount()` getter — ADD this to offscreen-hooks.ts in this task). Assert: segmentCount >= 3 (per D-13: 10s segments × MAX_SEGMENTS=3 = 30s window). PASSES today. + - Assertion 12 (ffprobe gate): trigger SAVE_ARCHIVE (reusing the assertion 5 helper). Extract `video/last_30sec.webm` from the produced zip via jszip. Write to a tmpfile. Spawn `ffprobe -v error -f matroska -i ` via execFileSync. Assert exit code 0. (Skip-gate this assertion with a clear "SKIPPED: ffprobe binary not available" diagnostic if `which ffprobe` fails — matches the existing webm-playback.test.ts pattern.) + - Assertion 13 (zip shape): jszip parse the same zip. Assert: `video/last_30sec.webm` entry exists + has non-zero size. Assert: `meta.json` entry exists + parsed JSON has `version === ` (read via sw.evaluate at the start of the harness or this assertion). + - The 35-second wait pushes the harness runtime past 60s. Add keepalive ping infrastructure (one ping every 20s during the wait) to avoid SW eviction per RESEARCH §2 / Pitfall 5. + + + 1. ADD a `__mokoshTest.getSegmentCount()` getter to `src/test-hooks/offscreen-hooks.ts`. The offscreen recorder has a module-level `segments` array (from D-13 restart-segments); expose a function-level setter alongside `setCurrentStream`: + ```typescript + // src/test-hooks/offscreen-hooks.ts + let currentStream: MediaStream | null = null; + let segmentCountGetter: () => number = () => 0; + export function setCurrentStream(s: MediaStream | null) { currentStream = s; } + export function setSegmentCountGetter(g: () => number) { segmentCountGetter = g; } + globalThis.__mokoshTest = { + // ... + getCurrentStream: () => currentStream, + getSegmentCount: () => segmentCountGetter(), + }; + ``` + Update `src/test-hooks/types.ts` to add `getSegmentCount?: () => number` to MokoshTestSurface. + In `src/offscreen/recorder.ts`, after the existing `setCurrentStream(stream)` call, add (gated): + ```typescript + if (import.meta.env.MODE === 'test') { + const hooks = await import('../test-hooks/offscreen-hooks'); + hooks.setSegmentCountGetter(() => segments.length); + } + ``` + (Where `segments` is the module-level array. If the variable name differs, adapt. Read the file to confirm; commonly named `videoSegments` or `segments`.) + 2. Wire assertion 11 per behavior. The 35s wait uses `await new Promise(r => setTimeout(r, 35_000))` with intermittent `await keepalivePing(sw)` every 20s. Use `setInterval` or a polling loop; document the keepalive purpose per RESEARCH §2. + 3. Wire assertion 12 per behavior. Reuse the `FFPROBE_BIN` constant pattern from `tests/offscreen/webm-playback.test.ts`. Skip-gate: `if (!existsSync(FFPROBE_BIN)) { console.warn('Assertion 12: ffprobe not available — SKIPPED'); return; }`. The skip-gate is acceptable for assertion 12 because the unit-level tests (Plan 01-08's `tests/background/webm-remux.test.ts`) also have ffprobe gates that cover the same contract — the harness's ffprobe assertion is end-to-end validation, not the primary gate. + 4. Wire assertion 13. Pass `expectedVersion = await sw.evaluate(() => chrome.runtime.getManifest().version)` into `assertArchiveShape`. + 5. Update Tier-1 grep gate test (`tests/background/no-test-hooks-in-prod-bundle.test.ts`) to ALSO assert ZERO `getSegmentCount` in dist/ (new hook surface added in this task — confirm gate stays GREEN). + 6. RED-on-regression demos (commit body): + - Assertion 11 RED demo: locally hack `SEGMENT_DURATION_MS = 30_000` in recorder.ts so 35s yields only 1 segment; rebuild; assertion 11 should FAIL. Revert; PASSES. + - Assertion 12 RED demo: locally inject a corrupted byte into the remux output (e.g. zero the EBML magic in webm-remux.ts before return); rebuild; assertion 12 should FAIL (ffprobe error). Revert; PASSES. + - Assertion 13 RED demo: locally drop `version` from the `meta.json` writer in saveArchive; rebuild; assertion 13 should FAIL. Revert; PASSES. + 7. Run `npm run test:uat`: ALL 14 assertions PASS. Exit 0. Diagnostic: "UAT harness: 14/14 assertions passed". + 8. `npx tsc --noEmit` → exit 0. `npx vitest run` → 85 GREEN. + 9. **Verify Tier-1 grep gate updates:** `npm run build && grep -rln 'getSegmentCount' dist/` → 0 matches. + + + npx tsc --noEmit && npm run test:uat && npx vitest run --reporter=dot && test "$(grep -rln getSegmentCount dist/ 2>/dev/null | wc -l)" = "0" + + + - All 14 assertions PASS in `npm run test:uat`; exit 0. + - `npm run test:uat` total runtime ~50-90s (dominated by the 35s assertion 11 wait + the harness setup ~10s + assertion 0's `npm run build` ~10s; skip with `SKIP_PROD_REBUILD=1` for ~70s). + - `npx tsc --noEmit` exit 0; vitest 85 GREEN. + - Production bundle (`npm run build`): `grep -rln __mokoshTest dist/` → 0; `grep -rln simulateUserStop dist/` → 0; `grep -rln getSegmentCount dist/` → 0. Tier-1 gate remains GREEN. + - Each new assertion's RED-on-regression demo documented in commit body. + + 13-assertion charter complete; harness exits 0 against current Plan 01-09 bundle; Phase 1 functional contract fully CI-callable. + + + + Task 8 (Wave 4): Amend Plan 01-09 Task 5 operator checkpoint to redirect functional steps to `npm run test:uat`; update STATE.md decisions; close Plan 01-09 via this plan's harness PASS. + + - .planning/phases/01-stabilize-video-pipeline/01-09-PLAN.md lines 519-549 (the operator checkpoint that gets amended) + - .planning/phases/01-stabilize-video-pipeline/01-09-SUMMARY.md (current closure state) + - .planning/STATE.md (Decisions section + Phase 1 Closure Notes) + - tests/uat/harness.test.ts (the harness that NOW closes the functional contract) + + .planning/phases/01-stabilize-video-pipeline/01-09-PLAN.md, .planning/STATE.md + + - `.planning/phases/01-stabilize-video-pipeline/01-09-PLAN.md` gets an AMENDMENT block at the END of the file (does NOT rewrite the original Task 5 — preserves provenance per project convention from D-A1..D-A6 cascade pattern): + ``` + --- + + ## Amendment (Phase 01-stabilize-video-pipeline, 2026-05-17) — Plan 01-11 harness retires operator functional steps + + Plan 01-11 (Puppeteer UAT harness) lands a CI-callable replacement for the + functional verification work in this plan's Task 5. The operator's role is + reduced to: + + - **Step 1 (build):** unchanged — `npm run build` must exit 0. + - **Steps 2-13:** REDIRECTED — replaced by `npm run test:uat` exit 0. The + Puppeteer harness implements 14 assertions (assertion 0 = production- + bundle hook-leak grep; assertions 1-13 = the original Task 5 + functional checks). + - **Step 14 (brand/design — implicit in steps 4, 5, 6 of original task):** + RETAINED for operator. The harness verifies displaySurface === 'monitor' + + notification fires; it does NOT verify the human-readable copy is + aesthetically correct OR that the badge color reads cleanly against the + operator's OS theme. Operator confirms. + - **Step 15 (genuine error UX):** REDIRECTED — assertion 7 verifies the + ERROR-path bandwidth. + + **New closure gate:** Plan 01-09 closes when `npm run test:uat` exits 0 + AND operator confirms step 14 (brand/design). The harness's 14/14 PASS + against current bundle (verified by this plan's Task 7) supplies the + first half today. + ``` + - `.planning/STATE.md` Decisions section gains a new entry (preserves the existing log; appends rather than rewriting): + ``` + - [Phase 01-11]: Operator role retirement landed via Puppeteer UAT harness. 14 assertions cover Plan 01-08/01-09 functional contract; operator retained only for brand/design step. `npm run test:uat` = the new CI gate for any Phase 1 SW/offscreen/manifest change. Tier-1 grep gate `tests/background/no-test-hooks-in-prod-bundle.test.ts` enforces zero `__mokoshTest` / `simulateUserStop` / `getSegmentCount` in production `dist/`. + ``` + - This task does NOT modify Plan 01-09's status fields, frontmatter, or original Task 5 body. The amendment is appended after the original `` block (mirroring the CONTEXT.md amendment-append pattern from 2026-05-16). + - Operator (in the closing checkpoint below) confirms brand/design step 14 manually and types "approved" — at which point Plan 01-09 + Plan 01-11 close together. + + + 1. Read `.planning/phases/01-stabilize-video-pipeline/01-09-PLAN.md` to confirm the file structure ends with the `` block (line ~596 based on the file's current shape). + 2. Append the amendment block per the behavior description, AFTER the closing `` tag. Use the same horizontal-rule + ## heading + AMENDED-BY metadata convention from CONTEXT.md amendments. Cite the harness path (`tests/uat/harness.test.ts`) and the npm script (`npm run test:uat`). + 3. Read `.planning/STATE.md` Decisions section (lines 72-109). + 4. Append the new entry to the Decisions list (after the most recent `[Phase 01-07-deferred-to-5]` entry per the convention). Do NOT modify any existing entry. + 5. Verify both edits are content-only (no frontmatter changes; no status flips — those happen in the closing checkpoint). + 6. Run `npx tsc --noEmit` → exit 0 (paranoia — neither edit touches TS, but baseline). + 7. Run `npm run test:uat` → exit 0 (final smoke before the closing checkpoint). + 8. Run `npx vitest run` → 85 GREEN. + + + npx tsc --noEmit && grep -q 'Plan 01-11 harness retires operator functional steps' .planning/phases/01-stabilize-video-pipeline/01-09-PLAN.md && grep -q 'Operator role retirement landed via Puppeteer UAT harness' .planning/STATE.md && npm run test:uat && npx vitest run --reporter=dot + + + - `01-09-PLAN.md` ends with the appended amendment block (no edits to the original Task 5 body). + - `STATE.md` Decisions section carries the new entry as the last item (no edits to prior entries). + - `npm run test:uat` exits 0 (14/14 GREEN). + - `npx tsc --noEmit` exit 0; vitest 85 GREEN. + + Plan 01-09 functional contract redirected to harness; STATE.md decisions log updated; ready for closing checkpoint. + + + + Task 9 (Wave 4): Operator confirms `npm run test:uat` exits 0 against current bundle AND confirms brand/design step 14 (Plan 01-09 Task 5 retained step) — closes Plan 01-09 + Plan 01-11. + (operator-driven; no files modified by this checkpoint) + See below — operator-driven empirical check. The executor must NOT bypass this checkpoint by stubbing harness output. + + echo "checkpoint:human-verify — see how-to-verify section; resume signal is the gate" + + Operator types "approved" after running the how-to-verify steps. See for the exact gate. + + Tasks 1-8 landed: Puppeteer + tsx installed, vite.test.config.ts produces dist-test/, gated test hooks in src/test-hooks/ ship in test bundle and NOT in production bundle (Tier-1 grep gate verifies), Puppeteer harness at tests/uat/harness.test.ts implements 14 assertions, all 14 GREEN against current Plan 01-09 bundle (b9eeeeb Bug B fix + a881bf0 Bug A fix both verified by Bug B + Bug A canonical RED-on-regression demos). Plan 01-09 Task 5 redirected to `npm run test:uat` for functional steps. This checkpoint validates the harness end-to-end against real Chrome AND captures operator's brand/design acceptance for Plan 01-09's retained step 14. + + + 1. **Pre-flight cleanliness:** run `git status` — confirm working tree clean. Any uncommitted local hacks (RED-demo reverts) MUST be reverted BEFORE this step. + 2. **Build production:** `npm run build` (must exit 0; this is Plan 01-09 Task 5 step 1). + 3. **Build test bundle:** `npm run build:test` (must exit 0). + 4. **Run harness:** `npm run test:uat` (must exit 0; runtime ~70-90s). Final output line MUST be exactly `UAT harness: 14/14 assertions passed`. If exit non-zero, paste the structured diagnostic + harness console dump + relevant SW/offscreen console logs; the plan iterates (likely a real bug surfaced). + 5. **Re-run for stability:** `npm run test:uat` a second time. Same outcome. (Eliminates first-run flakiness from cold Chrome / cold dist-test cache.) + 6. **Tier-1 hook-leak verification:** `grep -rln __mokoshTest dist/` must return 0 matches. Same for `simulateUserStop`, `getSegmentCount`, `setCurrentStream`, `setSegmentCountGetter`. If ANY match, the gate failed silently — STOP and triage. + 7. **Local-debug mode smoke:** `HEADLESS=0 npm run test:uat`. Watch the real Chrome window: see the toolbar icon, see the picker auto-accept, see the badge transitions. Same exit 0 outcome. (This is the operator's chance to spot any visual oddity the automated assertions miss.) + 8. **Brand/design acceptance (Plan 01-09 Task 5 step 14 — retained for operator):** + (a) Badge color readability against your OS theme: red OFF, green REC, yellow ERR should each contrast clearly with the toolbar background. If any is hard to see in light AND dark mode, document for Phase 5 hardening (do NOT block closure on this — file as a deferred item). + (b) Notification copy: "Mokosh ready — Click here to start recording your session." reads naturally in en_US. Russian operators may want a localized variant — document for Phase 5 (do NOT block closure on this). + (c) Picker UX: confirm Chrome's screen-share picker still surfaces (in headful mode) at the expected moment + with the correct monitor-only options. + 9. **If steps 4, 5, 6 all PASS:** Plan 01-09 + Plan 01-11 both close. Type "approved" with any brand/design notes appended. + 10. **If step 4 OR 5 FAIL:** paste the failure diagnostic. Likely culprits: locale-specific picker string mismatch (RESEARCH §9 — operator's Chrome may need a different `--auto-select-desktop-capture-source` value); race window in assertion 6 / 11 (try bumping the wait in the relevant assertion). + 11. **If step 6 FAILS:** STOP. The Tier-1 hook-leak gate failing means the production bundle contains test code — this is a security regression (T-1-11-01). Do NOT proceed to closure. Open a debug session. + 12. **If step 7 surfaces a real UX issue (not just a deferral):** document as a P1/P2 item in STATE.md or a phase-5 backlog file; closure can still proceed IF the issue is non-blocking. + + + Type "approved" after step 9 lands (all gates GREEN + brand/design accepted). If steps 10/11/12 hit, paste the failure mode + operator's Chrome version + locale + OS theme; the plan iterates on the failing piece (likely Task 4-7 for assertion-specific issues; Task 1-2 for hook-leak issues; a fresh debug session for novel failures). + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Puppeteer driver ↔ Chrome SW (via CDP) | The harness pipes CDP commands to the SW context via `sw.evaluate`. Trust boundary is unchanged at runtime (the SW only accepts the harness's commands because the harness runs inside the Puppeteer-launched Chrome process); but the harness CAN invoke any production SW code path via `sw.evaluate`, so a malicious or buggy harness could in principle exfiltrate buffered video. Mitigation: harness code is in-tree, code-reviewed via the same pipeline as production. | +| Test hook surface (`__mokoshTest`) in production bundle | NEW: if tree-shaking fails or the MODE guard is misconfigured, the hook surface ships to production — exposing simulateUserStop, getCurrentStream, captured handler refs to any page that can `eval` against the SW. THIS IS THE SECURITY-CRITICAL THREAT. Mitigation: Tier-1 grep gate (`tests/background/no-test-hooks-in-prod-bundle.test.ts`) enforces zero `__mokoshTest` in `dist/`; runs as part of `npm test` so any CI pipeline picks it up. | +| dev-dependency Chromium binary | NEW: Puppeteer downloads ~150 MB Chromium binary at `npm install` time. Supply-chain compromise of the Chrome download endpoint would inject malicious code into developer machines. Mitigation: `package-lock.json` integrity check (Puppeteer pins the Chromium download hash via its `@puppeteer/browsers` dependency). Out of scope: separate SCA for Puppeteer itself. | +| --auto-select-desktop-capture-source flag in CI | NEW: in a CI container, the flag auto-accepts the "Entire screen" source — which is whatever Xvfb (or modern headless surface) presents. If a CI runner is shared with sensitive workloads, the 35-second recording assertion captures whatever is on screen during that window. Mitigation: document that CI MUST run the harness in an isolated container with no concurrent workload; local-dev runs capture the operator's real screen for 35s during assertion 11, documented in README.md. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-1-11-01 | Elevation of Privilege | `__mokoshTest` surface leaking into production `dist/` would expose simulateUserStop, captured chrome.* handler refs, and stream getter to any code with access to the SW context | mitigate | Two layers: (a) gated dynamic import per RESEARCH §6 (the literal `'test' !== 'production'` comparison is a static dead branch that Vite/Rollup tree-shake); (b) Tier-1 unit gate `tests/background/no-test-hooks-in-prod-bundle.test.ts` greps the BUILT artifact for `__mokoshTest` / `simulateUserStop` / `getSegmentCount` / `setCurrentStream` / `setSegmentCountGetter` — ZERO matches required for GREEN. Belt + suspenders catches both tree-shake regression AND new hook-name additions. | +| T-1-11-02 | Information Disclosure | 35-second recording assertion captures whatever is on the operator's screen during local-dev runs | accept | Operator-facing — local-dev runs are by definition under operator control; the recording is consumed only by ffprobe + jszip inside the harness process and is deleted with the temp downloads dir at process exit. CI runs document the isolated-container requirement in README.md. | +| T-1-11-03 | Tampering | Puppeteer downloads Chromium binary at `npm install`; supply-chain compromise of the download endpoint | accept | `package-lock.json` pins resolved hashes via Puppeteer's `@puppeteer/browsers` machinery. Same risk surface as any npm dependency. Phase 5 SCA work (out of scope here) covers periodic re-verification. | +| T-1-11-04 | Denial of Service | A pathological assertion 11 (35s wait) ties up CI runner time; combined with 14 sequential assertions, total runtime ~90s ties up a runner slot | accept | 90s is well within typical CI per-job budgets. Local-dev runs use `SKIP_PROD_REBUILD=1` to drop assertion 0's `npm run build` cost (~10s). Out of scope: parallelizing assertions (would require multi-browser instances, defeating the failure-isolation choice). | +| T-1-11-05 | Repudiation | The harness asserts the absence of recovery notification (Bug B path), but the assertion is a count-delta check — a notification fired BEFORE the snapshot would be invisible | mitigate | Each assertion snapshots `notificationCount` IMMEDIATELY before the trigger event AND immediately after the propagation wait. The delta is checked, not the absolute count. The `notificationIds` array is also asserted on for ID-prefix membership — even if delta counting were fooled by some interleaving, the absence of a 'mokosh-recovery-' prefix in the post-snapshot ids array catches the same regression. | +| T-1-11-06 | Spoofing | Harness reads `__mokoshTest.handlers.onStartup` and invokes it; a hostile production change could swap in a no-op handler that registers AFTER the hook captures the real handler | mitigate | The hook monkey-patches `addListener` AT THE TOP OF THE MODULE (before any production addListener calls). Any later addListener invocation still goes through the patched function and would OVERWRITE handlers.onStartup, not bypass. A malicious bypass would require directly calling `chrome.runtime.onStartup.addListener.call(...)` via a saved bound reference — none exist in the production tree (verified by grep `addListener.call|.bind(chrome.runtime.onStartup)` returns 0). Defense in depth: the assertion verifies the captured handler actually fires the notification side-effect; a stub handler would fail assertion 8's notificationCount check. | + + + +- `npm run test:uat` exits 0 against the current Plan 01-09 bundle; final line is exactly `UAT harness: 14/14 assertions passed`. +- `npm run build` exit 0; `grep -rln __mokoshTest dist/` returns 0; `grep -rln simulateUserStop dist/` returns 0; `grep -rln getSegmentCount dist/` returns 0. +- `npm run build:test` exit 0; `dist-test/` populated; `grep -rln __mokoshTest dist-test/` returns ≥1. +- `npx vitest run` exit 0; 85 GREEN across all test files (83 baseline + 2 from Task 1's Tier-1 grep gate). +- `npx tsc --noEmit` exit 0 across `src/` + `tests/`. +- Tier-1 SW-bundle-import gate (`tests/background/sw-bundle-import.test.ts`) GREEN — verifies the gated dynamic import does not break production module init. +- Tier-1 hook-leak gate (`tests/background/no-test-hooks-in-prod-bundle.test.ts`) GREEN — verifies the production bundle is hook-free. +- Bug B canonical RED-on-regression demo documented in Task 5's commit body (locally reverting b9eeeeb makes assertion 6 RED; re-applying makes GREEN). +- Bug A canonical RED-on-regression demo documented in Task 6's commit body (locally truncating icons/icon128.png makes assertions 8 + 9 RED; restoring makes GREEN). +- Plan 01-09 Task 5 amended at the end of its PLAN.md (no rewrite of the original body); STATE.md Decisions log carries the new Plan 01-11 entry. +- Operator confirms brand/design step 14 + types "approved" in Task 9. + + + +Plan 01-11 is complete when: +1. **Two-bundle separation lives.** `npm run build` produces hook-free `dist/`; `npm run build:test` produces hook-enabled `dist-test/`. The Tier-1 grep gate enforces the production bundle's hook absence. +2. **All 14 harness assertions pass against the current Plan 01-09 bundle.** `npm run test:uat` exits 0; final line is `UAT harness: 14/14 assertions passed`. +3. **Both Phase-1-escapee bugs are now CI-callable.** Assertion 6 (Bug B state-machine routing) and Assertion 8 (Bug A icon-promoted notification) each have a RED-on-regression demo documented in their respective task's commit body, proving the harness assertion CAN catch a regression — not just pass under current conditions. +4. **Operator role retired for functional verification.** Plan 01-09 Task 5 steps 4-13 + 15 redirect to `npm run test:uat`; only step 1 (build) + step 14 (brand/design) retained. The amendment block in 01-09-PLAN.md preserves provenance (no rewrite of the original task). +5. **Existing 83 vitest tests remain GREEN.** Plus the 2 new Tier-1 gate tests in this plan = 85 total. No regression. +6. **`npx tsc --noEmit` exit 0.** All harness code + hook code type-clean. +7. **`npm run build` exit 0; `npm run build:test` exit 0.** Both production and test bundles emit cleanly. +8. **Operator confirms Task 9 brand/design acceptance + types "approved".** Plan 01-09 + Plan 01-11 close together. + + + +After completion, create `.planning/phases/01-stabilize-video-pipeline/01-11-SUMMARY.md` per the standard template. Cite: +- The 14 assertions landed GREEN (0: prod-bundle hook-leak grep gate; 1-13: functional contract from orchestrator brief). +- Both RED-on-regression canonical demos documented in commit bodies (Bug B for assertion 6; Bug A for assertion 8). +- The two-bundle separation (`dist/` vs `dist-test/`) verified by Tier-1 grep gate. +- npm script additions (`build:test` + `test:uat`); dev-dep additions (puppeteer + tsx) with resolved versions. +- Hook surface inventory (`__mokoshTest`: handlers, notification observables, getCurrentStream, getSegmentCount) + the gated dynamic import sites in `src/background/index.ts` + `src/offscreen/recorder.ts`. +- Plan 01-09 amendment block landed (Task 5 functional steps redirected; brand/design step retained). +- STATE.md decision log updated with the operator-retirement decision. +- Open questions resolved (5 from RESEARCH) + their resolutions; any new open questions surfaced during execution. +- Bundle-size delta (`dist/` before vs after; should be near-zero since gated dynamic imports tree-shake cleanly). +- Total harness runtime ranges observed (cold: ~90s including build steps; warm with SKIP_PROD_REBUILD=1: ~70s; the 35-second assertion 11 wait dominates). +