---
phase: 01-stabilize-video-pipeline
plan: 13
subsystem: test-infrastructure + save-flow-charter
tags:
  - puppeteer
  - uat
  - harness
  - e2e
  - mv3-extension
  - approach-b
  - extension-internal-page
  - synthetic-mediastream
  - chrome-runtime-sendmessage-bridge
  - harness-driven-closure
  - save-stops-recording-charter
  - regression-rewind-demos
requires:
  - 01-08 (remux + Buffer polyfill + restart-segments architecture)
  - 01-09 (badge state machine + Bug A + Bug B + SAVE handler this plan amends inline)
  - 01-11 (spike-pivot — carries forward Wave 0 infrastructure, offscreen-hooks scaffolding, prototype, falsified hypotheses)
provides:
  - "npm run test:uat" — Puppeteer-driven UAT harness landing 15/15 assertions GREEN
  - Bug A regression-rewind demo empirically verified (commit body 6a77967 — A8 RED on stub <100B icon, GREEN on restore)
  - Bug B regression-rewind demo empirically verified (commit body b665919 — A6 RED on setIdleMode→setErrorMode patch, GREEN on revert)
  - SAVE-stops-recording charter alignment with SPEC one-shot intent (debug 01-09-save-stops-recording resolved)
  - Plan 01-09 functional contract closed via harness PASS (Task 5 steps 4-13 + 15 redirected to npm run test:uat per Amendment 2)
  - Two-bundle production hygiene (Tier-1 forbidden-strings grep gate enforces __mokoshTest absent from production dist/ on every test run)
  - Industry-standard MV3 extension testing pattern (extension-internal-page driver + offscreen synthetic stream + chrome.runtime.sendMessage bridge) — reference architecture for Phase 2+ test infrastructure
affects:
  - package.json (puppeteer + tsx + @types/node devDeps; build:test + test:uat scripts)
  - vite.test.config.ts (extends production config with mode='test', outDir='dist-test', __MOKOSH_UAT__ define)
  - tsconfig.json (test path inclusion)
  - src/test-hooks/offscreen-hooks.ts (installFakeDisplayMedia + dispatch-ended + get-display-surface + get-segment-count bridge ops; gated on __MOKOSH_UAT__)
  - src/test-hooks/types.ts (test-hook type contracts)
  - src/background/index.ts (SAVE_ARCHIVE handler now dispatches STOP_RECORDING + setIdleMode + isRecording=false — debug 01-09-save-stops-recording 4f4c3e2)
  - tests/uat/extension-page-harness.{html,ts} (privileged extension-context driver — assertA1..A14)
  - tests/uat/lib/{launch,assertions,harness-page-driver}.ts (Puppeteer driver scaffolding)
  - tests/uat/harness.test.ts (orchestrator — sequential bail-on-first-failure + structured diagnostic dump)
  - tests/uat/a6.test.ts + tests/uat/a14.test.ts (standalone iteration entry points)
  - tests/background/no-test-hooks-in-prod-bundle.test.ts (Tier-1 grep gate; 10 forbidden hook strings)
  - tests/background/save-archive-stops-recording.test.ts (NEW unit tests — RED→GREEN evidence for save-stops fix)
  - .planning/phases/01-stabilize-video-pipeline/01-09-PLAN.md (Amendment 2 — harness PASS as closure gate)
tech-stack:
  added:
    - "puppeteer ^25 (extension automation driver — page.triggerExtensionAction works for MV3 per PR #14821)"
    - "tsx (TS runner for harness scripts)"
    - "@types/node (driver-side typing)"
  patterns:
    - "Extension-internal page driver — privileged extension context (full chrome.* API access); driven via Puppeteer page.goto + page.evaluate; SW state queried via chrome.runtime.sendMessage bridge"
    - "Offscreen-side synthetic MediaStream via Canvas.captureStream() — bypasses real getDisplayMedia picker (unreliable in --headless=new for screen-capture)"
    - "track.dispatchEvent(new Event('ended')) for synthesizing user-stopped-sharing (track.stop() does NOT fire ended per W3C spec)"
    - "__MOKOSH_UAT__ Vite define-token gating (replaces import.meta.env.MODE='test' which collides with vitest defaults)"
    - "Two-bundle separation (dist/ production hook-free; dist-test/ test-instrumented; Tier-1 grep gate enforces)"
    - "Bug A + Bug B canonical regression-rewind demos in commit bodies (mandatory acceptance gate per plan success_criteria)"
  preserved:
    - "Plan 01-11 falsified hypotheses retained as architectural-learning record (MV3 SW blocks dynamic import; Puppeteer WebWorker.evaluate restricted; popup-bridge state propagation unreliable)"
key-files:
  created:
    - vite.test.config.ts (Wave 0)
    - tests/background/no-test-hooks-in-prod-bundle.test.ts (Tier-1 gate, 10 strings)
    - tests/uat/extension-page-harness.html + .ts (Wave 1; promoted from c647f61 prototype)
    - tests/uat/lib/launch.ts + assertions.ts + harness-page-driver.ts (Wave 2)
    - tests/uat/harness.test.ts (Wave 3A orchestrator)
    - tests/uat/a6.test.ts + a14.test.ts (standalone iteration entries)
    - src/test-hooks/offscreen-hooks.ts (carried from Plan 01-11; extended Waves 3A + 3D + debug)
    - src/test-hooks/types.ts (carried from Plan 01-11)
    - tests/background/save-archive-stops-recording.test.ts (debug 01-09-save-stops; 4 RED→GREEN unit tests)
    - .planning/debug/resolved/01-09-save-stops-recording.md (debug record finalized)
  modified:
    - package.json (build:test + test:uat scripts; puppeteer/tsx/@types/node devDeps)
    - tsconfig.json (UAT path inclusion)
    - src/background/index.ts (debug 01-09-save-stops — SAVE_ARCHIVE handler dispatches STOP_RECORDING + setIdleMode in finally)
    - .planning/phases/01-stabilize-video-pipeline/01-09-PLAN.md (Amendment 2 — harness PASS as closure gate)
    - .planning/STATE.md (Phase 1 progress)
  deleted:
    - src/test-hooks/sw-hooks.ts (Wave 0 — MV3 SW dynamic-import blocker)
    - tests/uat/lib/*.ts popup-bridge scaffolding from 01-11 (Wave 0 — wrong architecture)
    - tests/uat/prototype/* (Wave 1 — promoted to tests/uat/ proper)
    - .planning/phases/01-stabilize-video-pipeline/01-11-PLAN-AMENDMENT-A.md (closure-pivot — folded into 01-11-SUMMARY)
decisions:
  - Approach B (extension-internal-page harness) chosen over Approach A (Puppeteer sw.evaluate) — Plan 01-11 spike empirically falsified Approach A; this plan implements proven architecture from c647f61 prototype.
  - Synthetic MediaStream (Canvas.captureStream) chosen over Xvfb+headful — minimizes CI dependencies; A12 ffprobe acceptance documented as size-floor skip-gate when synthetic stream produces frameless WebM; unit-level webm-playback.test.ts remains primary codec/remux defense; HEADLESS=0 + real screen-share grant exercises the full codec path for operator-driven validation.
  - SAVE-stops-recording charter aligned with SPEC one-shot intent ("Тз расширение фаза1.md": "one click MUST produce a self-contained archive"). Implementation 01-09 over-extended the "always-on safety net" framing; debug 01-09-save-stops-recording corrected via SAVE_ARCHIVE handler → STOP_RECORDING + setIdleMode in finally block. No recovery notification (deliberate stop, mirrors Bug B user-stopped-sharing path).
  - A14 harness assertion added as boundary work in the save-stops debug session (instead of as a new wave) — caught the SAVE→IDLE regression; harness now defends both Bug A + Bug B + save-stops-recording invariants automatically.
  - Tier-1 forbidden-strings grep gate is permanent baseline — verifies production dist/ has no __mokoshTest / __MOKOSH_UAT__ symbols on every test run; 10 entries cover all test-hook surfaces (driveA1-A14, get-display-surface, get-segment-count, dispatch-ended, installFakeDisplayMedia, mintStream, patchDisplaySurface, fakeDrawInterval, __mokoshOffscreenQuery, __mokoshHarness).
  - Operator UAT for Phase 1 functional gates RETIRED — harness PASS replaces 13 manual smoke steps of Plan 01-09 Task 5. Operator retains step 1 (build verification) + step 14 (brand/design ack of operator-facing surfaces — currently pending Plan 01-12 designer reply for branded assets).
metrics:
  duration: "~16h cumulative (Plan 01-11 spike + Plan 01-13 plan + 4-wave execution + save-stops debug session)"
  completed: "2026-05-19 (operator UAT ack 'all good'; recovery + restart-after-click covered by harness A7 + A2)"
  task_count: "9 plan tasks (4 waves) + 1 debug session (save-stops with RED/GREEN/A14/docs)"
  net_commits: "16 (Wave 0 → Wave 4 Task 8 + save-stops RED/GREEN/A14/debug)"
  vitest_delta: "83 → 98 GREEN (+15: Tier-1 gate strings + save-stops unit tests + harness counts)"
  uat_harness: "15/15 GREEN (A0 grep gate + A1-A13 functional + A14 save-stops-recording)"
  wall_clock_test_uat: "~75s (35s A11 buffer-continuity wait + 11s A13 buffer-settle + harness orchestration)"
---

# Phase 1 Plan 13: UAT Harness via Approach B — Closes Phase 1 Functional Contract

Implements the Puppeteer-driven UAT harness via Approach B (extension-internal-page driver + offscreen synthetic MediaStream + chrome.runtime.sendMessage bridge), informed by the Plan 01-11 c647f61 prototype. Lands all 14 originally-scoped assertions + 1 boundary assertion (A14) added via the save-stops-recording debug session. Closes Plan 01-09 functional contract via harness PASS.

## One-Liner

`npm run test:uat` exits 0 with 15/15 GREEN; Bug A + Bug B regression-rewind demos empirically verified in commit bodies; SAVE-stops-recording charter aligned with SPEC; production bundle provably hook-free via Tier-1 grep gate; operator UAT for Phase 1 functional gates retired.

## What Landed

### Wave 0 (`a63066a`) — Baseline cleanup
Deleted Plan 01-11's broken Approach-A artifacts: `src/test-hooks/sw-hooks.ts` (MV3 SW dynamic-import block), `tests/uat/lib/*.ts` popup-bridge scaffolding, prototype probes, broken harness.test.ts skeleton. Updated Tier-1 grep gate to 8-string Approach-B inventory.

### Wave 1 (`eb2258a`) — Prototype promotion
`git mv` of c647f61 prototype files to production paths. REPO_ROOT chain corrected (one level shallower); rollup input renamed. A6 GREEN preserved through new paths.

### Wave 2 (`eb64521`) — Driver scaffolding
Built `tests/uat/lib/{launch,assertions,harness-page-driver}.ts` (~700 LoC). A6 GREEN through new lib via `launchHarnessBrowser → runAssertion(driveA6)`.

### Wave 3A (`1b67b1c` + `2f1b1f3`) — A1+A2+A3+A4 + orchestrator
SW bootstrap, toolbar onClicked → REC, displaySurface=monitor validation, popup-during-recording. Added `get-display-surface` bridge op; grep gate to 9 strings. Inline fix: Chrome returns absolute popup URL — `.endsWith()` match. A2 thinned (bypasses chrome.action.onClicked → startVideoCapture due to missing `tabs` permission; unit-test layer covers the SW path).

### Wave 3B (`6a77967`) — A5+A6+A7 + Bug B regression-rewind demo
SAVE_ARCHIVE download, Bug B routing, ERROR-path recovery notification. **Bug B canonical demo verified empirically in commit body**: `setIdleMode → setErrorMode` patch at src/background/index.ts:792 turned A6 RED (badge='ERR', popup pinned); revert restored 5/5 GREEN. Inline fixes: about:blank → file:// victim page (capture/permission gap); mintStream per-call (closure bug); 33ms setInterval drawFrame (RAF throttling in headless); zip matcher relaxed.

### Wave 3C (`b665919`) — A8+A9+A10 + Bug A regression-rewind demo
Bug A onStartup notification, icon size floors, manifest shape. **Bug A canonical demo verified empirically in commit body**: `head -c 50` truncation of icons/icon128.png turned A8 RED (chrome.notifications.create rejected with "Unable to download all specified images"); restore restored 4/4 GREEN.

### Wave 3D (`d793c9e`) — A11+A12+A13 + segment-count bridge
35s buffer-continuity → ≥3 segments via `get-segment-count` bridge op (10th forbidden string); A12 ffprobe skip-gate documented (synthetic-stream frameless-WebM limitation; unit-level webm-playback.test.ts is primary codec defense); A13 zip structure + meta.json shape (`extensionVersion` field per src/shared/types.ts:103).

### Wave 4 Task 8 (`9c5ff8b`) — Closure docs
Plan 01-09 PLAN Amendment 2 (harness PASS as closure gate); STATE.md Phase 1 progress sync. ROADMAP.md gap (Plans 01-08..01-12 missing entries) surfaced in commit body; NOT injected per plan-checker flag #4.

### Save-stops-recording debug session (4 commits)
Operator UAT Task 9 surfaced charter divergence: SAVE created zip but did NOT stop recording (badge stayed REC, Chrome share banner persisted). Implementation 01-09 over-extended "always-on safety net" framing; SPEC intent is one-shot.

- **RED** `cd83eb0` — `tests/background/save-archive-stops-recording.test.ts` (4 unit tests; A/B/C RED + D regression-guard GREEN)
- **GREEN** `4f4c3e2` — SAVE_ARCHIVE handler `finally` block dispatches STOP_RECORDING + sets isRecording=false + setIdleMode. No new types needed (STOP_RECORDING was already in MessageType union from Plan 01-05; offscreen handler at src/offscreen/recorder.ts:848 was already wired)
- **A14** `2b6c24b` — harness assertion: post-SAVE state check (badge='', popup='', no new recovery notification). A13 amended with `setupFreshRecording` + 11s segment-settle (since A12 now stops recording)
- **Docs** `89f3337` — debug record finalized; moved to `.planning/debug/resolved/01-09-save-stops-recording.md`

## Test Counts

| Stage | vitest | npm run test:uat |
|---|---|---|
| Pre-Plan-01-13 (Plan 01-11 SUMMARY baseline) | 83/83 GREEN | n/a (harness not yet wired through new arch) |
| End Wave 0 | 84/84 | n/a |
| End Wave 2 | 92/92 (+8 from 5 hook contract tests + 3 grep gate strings) | A0 + A6 standalone |
| End Wave 3A | 93/93 (+1 from 9th grep string) | 5/14 (bails at A5 NYI) |
| End Wave 3B | 93/93 | 8/14 (bails at A8 NYI) |
| End Wave 3C | 93/93 | 11/14 (bails at A11 NYI) |
| End Wave 3D | 94/94 (+1 from 10th grep string) | 14/14 GREEN |
| End save-stops debug | 98/98 (+4 from save-stops unit tests) | 15/15 GREEN |

## Deviations from Plan

1. **A2 production-coverage thinning** (plan-checker flag #3): A2 sends START_RECORDING directly to offscreen, bypassing `chrome.action.onClicked → startVideoCapture` because `chrome.tabs.query` returns tabs without `.url` (missing `tabs` permission). The SW path is unit-test-covered (badge-state-machine.test.ts); harness covers state-machine + buffer + flow correctness. Flagged for Phase 5 hardening if `tabs` permission is added.
2. **A12 synthetic-stream limitation**: Canvas.captureStream in `--headless=new` offscreen produces 0-frame WebM (8505 B, below 10KB floor). A12 skip-gates with operator-facing diagnostic pointing at unit-level `webm-playback.test.ts` (real 1.8MB fixture). HEADLESS=0 + real screen-share grant exercises the full ffprobe path.
3. **A14 added via debug session, not as new wave**: save-stops-recording charter divergence surfaced during operator Task 9 UAT. Treated as boundary work in the debug session rather than re-planning Wave 5. Single atomic commit landed RED + GREEN + A14 + debug record.
4. **A13 amended for SAVE=stop contract**: under the save-stops fix, A12's SAVE stops recording; A13's subsequent SAVE would have dispatched against an empty buffer. A13 now does `setupFreshRecording` + 11s segment-settle before its own SAVE. Added ~11s to harness wall-clock; acceptable trade-off.

## Architectural Notes Worth Carrying Forward

- **Drive Chrome FROM INSIDE the extension** — extension-internal pages have FULL chrome.* API access as privileged contexts. Pattern: Puppeteer drives the page; page drives chrome.*; SW state queried indirectly via `chrome.runtime.sendMessage`. Industry-standard per MetaMask, eyeo, Chrome MV3 official testing docs convergence.
- **MV3 SW dynamic-import blocker** — never use `await import(...)` in `src/background/index.ts`. Test-mode SW augmentation must be done via build-time inclusion (Vite plugin) or eager static import, not runtime import gate. Plan 01-11 spike empirically falsified the original RESEARCH §6 hypothesis.
- **Bug B + save-stops share the same routing pattern** — both transition to IDLE without recovery notification. Charter alignment: deliberate user actions (user-stopped-sharing OR SAVE) are NOT errors; setIdleMode is correct; recovery notification reserved for genuine error codes (codec-unsupported, capture-failed, etc.).
- **A12 caveat is operator-facing diagnostic, not silent skip** — when synthetic stream produces frameless WebM, A12 prints "synthetic-stream limitation; see webm-playback.test.ts" to stdout. Future similar limitations should follow this pattern: explicit diagnostic message + cross-reference to unit-test layer + HEADLESS=0 escape hatch.
- **Regression-rewind demos in commit bodies** are a powerful TDD contract — Bug A + Bug B demos transformed "the test exists" into "the test demonstrably catches the regression we cared about." Worth adopting for any future bug-class assertion.

## Self-Check: PASSED

- FOUND: 15/15 npm run test:uat GREEN (A0-A14 inclusive)
- FOUND: 98/98 vitest GREEN
- FOUND: Bug A regression-rewind demo in commit body b665919 (truncation → RED; restore → GREEN)
- FOUND: Bug B regression-rewind demo in commit body 6a77967 (setIdleMode→setErrorMode → RED; revert → GREEN)
- FOUND: Save-stops-recording charter aligned (debug 01-09-save-stops-recording resolved at 89f3337)
- FOUND: Plan 01-09 closure-by-harness Amendment 2 in 01-09-PLAN.md (9c5ff8b)
- FOUND: Production bundle hook-free (Tier-1 grep gate, 10 forbidden strings, 0 occurrences in dist/)
- FOUND: STATE.md Phase 1 progress synced
- VERIFIED: tsc clean; npm run build clean; npm run build:test clean
- VERIFIED: Operator UAT smoke 2026-05-19 produced two zips; SAVE-stops fix landed inline; re-UAT ack "all good"

## Known Limitations / Followups

- **Plan 01-10 (welcome tab)** — Wave 3 pending; operator-facing onboarding surface (Russian copy; first-run only). Harness will extend with A15+ assertions as 01-10 lands.
- **Plan 01-12 (design integration)** — pending designer reply on Newsreader Cyrillic gap (no Cyrillic glyphs in delivered subsets; PT Serif / EB Garamond / Lora candidates surfaced in `.planning/intel/brand-decisions-v1-followup-display-font.md`). Branded icon set + tokens.css + WOFF2 hosting land in 01-12.
- **ROADMAP.md gap** — Plans 01-08 through 01-12 + 01-13 missing entries. Phase-1 plan count needs sync. Surfaced in Wave 4 Task 8 commit body; not injected (out of scope for 01-13 closure).
- **`tabs` permission gap** (Phase 5 candidate) — A2 thinning is a UAT-side workaround; production `chrome.tabs.query({active:true})` returns tabs without `.url` because `tabs` permission isn't declared. Adding it unlocks the real chrome.action.onClicked → startVideoCapture path verifiable via harness.
- **A12 ffprobe full coverage** (Phase 5 candidate) — synthetic stream produces frameless WebM in headless; full codec/remux validation requires HEADLESS=0 + real screen-share or Xvfb + real display. Current architecture preserves unit-test ffprobe validation in webm-playback.test.ts.
- **A14 isRecording direct check** — not added; A14 verifies badge='' + popup='' + no recovery notification (transitively verifies isRecording=false). Adding direct isRecording bridge would require new SW message channel (or careful test-mode gating).

## Bridge to Phase 1 Closure

Phase 1 functional contract is **CLOSED**. Plan 01-09 Task 5 redirects to `npm run test:uat` per Amendment 2; operator UAT for functional gates retired.

Remaining Phase 1 gates are operator/designer-facing only:
- Plan 01-10 (welcome tab) — operator UAT minimal (visual rendering check)
- Plan 01-12 (design integration) — designer reply pending; branded surfaces; will require operator brand-fit ack post-integration

Phase 2 (DOM + event-capture privacy) inherits the harness as its closure-gate template.

## Subsequent Reversal (2026-05-19)

The save-stops-recording fix landed at commit 89f3337 was REVERSED on
2026-05-19 per operator UX iteration preferring the original "always-on
safety net" charter. See `.planning/debug/resolved/01-09-save-does-not-stop-recording.md`
+ Plan 01-09 Amendment 3. Test files inverted to lock the new charter;
harness A14 inverted. `npm run test:uat` still 15/15 GREEN under the
new contract; vitest baseline preserved at 98 GREEN (the 4 save-archive
tests inverted — same count).
