# Phase 4: Harden + clean up (optional) - Research **Researched:** 2026-05-21 **Domain:** MV3 SW persistence + bundle hygiene (setimmediate polyfill) + cs-injection-world race-fix **Confidence:** HIGH ## Summary Scope-limited light research per `.plan-phase-preferences.md` (~10-20 min budget; 3 medium-novelty questions). All three research questions have actionable HIGH-confidence answers — and a fourth meaningful finding surfaced: one item the CONTEXT lists as "in-scope visual polish" (`getDisplayMedia` cursor visibility constraint) is ALREADY SHIPPED in production code, eliminating the implementation step and reducing it to a verification/SUMMARY-acknowledgment task. **Primary recommendation:** Plan 04 should adopt: 1. **setimmediate (Q1):** Option (a) — inline manual polyfill via `globalThis.setImmediate ||=` in SW entry + `exclude: ['setimmediate']` config (NOT yet attempted; vite-plugin-node-polyfills supports `exclude`). Drops `new Function` from SW chunk while preserving Buffer (the only legitimately-needed polyfill for JSZip). Verifiable by grep against built dist/. 2. **SW state persistence (Q2):** Use `worker.close()` via CDP (Puppeteer ≥ 22.1.0, already on ^25 — supported); persist segments to `chrome.storage.local` (NOT `chrome.storage.session` — in-memory only) OR rely on offscreen-document lifecycle. **Major finding:** current architecture stores segments only in offscreen-document RAM (src/offscreen/recorder.ts:91 `let segments: Blob[] = []`). Per Chrome docs, the offscreen IS the lifecycle anchor: as long as it survives, segments survive. The actual question becomes "does the offscreen survive a 5-min idle?" — recommend a smoke spike before committing to persistence work. 3. **A29 cs-injection-world fix (Q3):** Direct port of Plan 03-02 / 03-03 pattern to A29. The 1.5s tab-attach wait is the canonical settle interval (`A27_TAB_NAVIGATION_WAIT_MS = 1_500` used by all three current cs-injection callers). Use `https://example.com/` (already canonical probe URL across A27/A30/A31). ISOLATED world is correct (default; matches A30/A31). **Finding-4 (scope correction):** Cursor visibility item from CONTEXT D-P4-03 is already SHIPPED. `src/offscreen/recorder.ts:285` already contains `video: { displaySurface: 'monitor', cursor: 'always' }`. This was opportunistically added in Plan 01-09's D-15-display-surface work, NOT in a Phase 4 step. **Planner action:** convert "cursor visibility implementation" task into a "cursor visibility verification + SUMMARY-doc-correction" task that grep-confirms the line exists, captures the empirical evidence (a SAVE-then-decode test showing pointer visible in last_30sec.webm), and corrects the misleading "Phase 5 deferred" line in 01-07-SUMMARY.md (it landed in Plan 01-09 / amended Plan 01-14 timeframe). ## User Constraints (from CONTEXT.md) ### Locked Decisions - **D-P4-01 (Phase 4 scope):** Full Phase 4 — all 4 ROADMAP success criteria + meaningful subset of 12 deferred items from 03-VERIFICATION.md. Exclusions: rrweb v2 upgrade + programmatic SW-RAM measurement. Estimated 6-8 plans across 2-3 waves. - **D-P4-02 (Audit P1 polish):** Address all three audit P1 correctness items (#11 fetch + #14 nav URL + #15 rrweb timestamps) in a single dedicated plan or two cohesive plans. - **D-P4-03 (Visual polish):** Include BOTH cursor visibility constraint AND dark-surface logo contrast. - **D-P4-04 (Alpha tester feedback):** Phase 4 execution proceeds independently of alpha tester feedback. User explicitly: "no no, if something i'll tell you" 2026-05-20 — alpha findings are user-routed via separate channels. - **D-P4-05 (Docs hygiene):** Include ROADMAP backfill for Plans 01-08..01-13 (5 plans inline-tracked but not row-added per Plan 01-13 plan-checker flag #4). ### Claude's Discretion - **Plan organization:** Planner picks 6-8 plan split. Suggested grouping by CONTEXT (planner may consolidate): - 04-01: Bug / flake stabilization (A29 race + parallel-vitest race + 2 ffprobe flakes — 4 atomic tasks) - 04-02: Audit P1 polish (#11 fetch + #14 nav URL + #15 rrweb timestamps) - 04-03: SW state persistence (5-min idle test — ROADMAP SC #1; harness extension) - 04-04: fetch + XHR network_error harness extension (ROADMAP SC #2; extends driveA30 or new driveA33) - 04-05: generate-icons ESM/CJS + dead-code grep + setimmediate polyfill (ROADMAP SC #3+#4 + CSP hygiene) - 04-06: Visual polish — cursor visibility (verification-only per finding-4) + dark-logo contrast - 04-07: A31 extension (one-line rrweb session.json sentinel grep) — could fold into 04-02 - 04-08: VERIFICATION.md aggregator + ROADMAP backfill + alpha re-distribution + milestone v1 close prep Planner may consolidate (e.g., fold 04-07 into 04-02; merge 04-05 + 04-06 if cohesive) for 6-7 plans target. - **Wave structure:** Likely 3-4 waves; sequential where files_modified overlap (Phase 2 + Phase 3 lessons). - **Harness assertion numbering:** A33+ for new Phase 4 harness assertions (continues A29-A32 sequence from Phase 3). - **Pre-checkpoint bundle gates:** Same 6/6 inventory per saved memory `feedback-pre-checkpoint-bundle-gates.md`. - **Tier-1 FORBIDDEN_HOOK_STRINGS:** stays at 12 unless A33+ needs new `__MOKOSH_UAT__`-gated symbols. - **CSP-safety mitigation for setimmediate polyfill:** see Q1 below for recommended path. - **CSV / data file modifications:** none expected. ### Deferred Ideas (OUT OF SCOPE) - rrweb 2.0.0-alpha.4 → stable v2 upgrade (D-P3-03 + D-P4-01) - Programmatic SW-realm RAM measurement via chrome.devtools Memory API (D-P3-04 + D-P4-01) - REQ-password-confidentiality v2 candidate (full rrweb v2 maskInputFn + data-sensitive guards) — only revisit if charter reverses - Per-plan ROADMAP rows for Phase 2/3 plans beyond Plans 01-08..01-13 catch-up - Alpha-tester findings integration (routed via separate maintenance window) - All v2/SRV items (SRV-01..04, CAP-01) ## Phase Requirements Phase 4 has **NO new functional REQ-* entries**. It verifies ROADMAP success criteria + audit P1/P2 polish against ALREADY-shipped behavior. Mapping: | Item | Verifies | Research Support | |------|----------|------------------| | ROADMAP SC #1 — SW state persistence | REQ-video-ring-buffer (Phase 1) | Q2 below | | ROADMAP SC #2 — fetch + XHR network_error | REQ-user-event-log (Phase 3) | A30 pattern reuse (CONTEXT) | | ROADMAP SC #3 — generate-icons ESM/CJS | REQ-install-clean (Phase 1) | CONTEXT specifics (no new research) | | ROADMAP SC #4 — dead-code grep | REQ-manifest-permissions (Phase 1) | CONTEXT specifics (no new research) | | Audit P1 #11/#14/#15 | REQ-user-event-log (Phase 3) | CONTEXT specifics (no new research) | | A29 race fix | REQ-rrweb-dom-buffer (Phase 3) | Q3 below | | setimmediate polyfill | CSP hygiene (Phase 1 deferred-items.md) | Q1 below | | Cursor visibility | (operator perceptibility) | Finding-4 — ALREADY SHIPPED | | Dark-logo contrast | (brand polish) | UI-SPEC `currentColor` strategy locked | | ROADMAP backfill | (docs hygiene) | No research needed | The planner uses this mapping to validate that Phase 4's task set covers every CONTEXT-listed item without inventing new REQs. ## Architectural Responsibility Map | Capability | Primary Tier | Secondary Tier | Rationale | |------------|-------------|----------------|-----------| | Video segment buffer | Offscreen document RAM | — | D-13 restart-segments; src/offscreen/recorder.ts:91 `segments: Blob[]`; no persistence layer | | SW keepalive | SW + Offscreen long-lived port | — | D-17 pattern; offscreen pings every 25s; Chrome 114+ keeps SW alive on port messages | | Buffer marshalling | Offscreen → SW (base64 wire) | — | D-12; chrome.runtime.Port JSON-serializes; Blob → base64 → Blob on SW side | | Archive remux + zip | SW chunk | — | ts-ebml + webm-muxer + JSZip all run SW-side via webm-remux.ts | | URL.createObjectURL | Offscreen document | — | DEC-006; SW chunk lacks `URL.createObjectURL`; D-P2-01 CREATE_DOWNLOAD_URL bridge | | chrome.downloads.download | SW | — | Only context with chrome.downloads access | | Content-script event capture | Page-realm content script | — | rrweb + setupInputLogging + fetch/XHR wrappers on https:// pages only | | UAT harness probe-tab pattern | Puppeteer host + chrome.tabs.create + chrome.scripting.executeScript ISOLATED | — | Plan 03-02 established; Plan 03-03 reused; Phase 4 A29 fix should reuse | | Test-only hook surface | `__MOKOSH_UAT__`-gated dynamic imports in OFFSCREEN only | — | Tier-1 grep enforces 0 hits in dist/; SW chunk has NO hook gates (Plan 01-11/13 lesson) | **Why this matters for Phase 4:** every research question below maps onto an existing architectural tier. Q1 (setimmediate) is bundle-level — affects SW chunk. Q2 (SW persistence) is offscreen-tier — the SW IS stateless; the offscreen owns the buffer. Q3 (A29 cs-injection-world) is harness-tier — reuses the Plan 03-02 probe-tab pattern verbatim. ## Standard Stack Phase 4 ships ZERO new runtime dependencies. Stack is frozen at end-of-Phase-3 baseline; the only configuration change Phase 4 may introduce is to `vite-plugin-node-polyfills` (existing dep — bumped or excluded config flag). ### Core (UNCHANGED from Phase 3) | Library | Version (verified 2026-05-21) | Purpose | Why Standard | |---------|---------|---------|--------------| | jszip | 3.10.1 (latest) | Archive zip assembly | DEC; canonical SW-safe zip lib | | rrweb | 2.0.0-alpha.4 (pinned per D-P3-03) | DOM session replay | Pin holds through Phase 4; v2 stable upgrade deferred | | ts-ebml | 3.0.2 (latest) | WebM EBML parse for remux | Plan 01-08 single-EBML remux dep | | webm-muxer | 5.1.4 (latest) | WebM writer for remux output | Plan 01-08 dep | ### Build / Test (UNCHANGED except Q1 config) | Library | Version | Purpose | Notes | |---------|---------|---------|-------| | vite | ^5.4.2 (current) | Bundler | Stable | | vite-plugin-node-polyfills | ^0.27.0 (latest 0.28.0 available) | Buffer polyfill for SW | Q1: bump or exclude `setimmediate` | | puppeteer | ^25.0.2 | UAT harness driver | ≥22.1.0 supports `worker.close()` — Q2 verified | | vitest | ^4 | Unit-test runner | Stable | | tsx | ^4.22.1 | UAT harness invoker | Stable | ### Alternatives Considered (and rejected) | Instead of | Could Use | Why Rejected for v1 close | |------------|-----------|----------| | `vite-plugin-node-polyfills` Buffer | hand-rolled minimal Buffer shim | Per CONTEXT D-P4-05 + 01-12 deferred-items: switching could "drop the setimmediate polyfill entirely" but is a wider audit. Phase 4 keeps the dep, just excludes setimmediate via config — minimum-surface fix. | | `chrome.storage.session` for video segments | Persist Blobs across SW idle | Per Chrome docs: chrome.storage.session is in-memory and DOES NOT survive SW restart. Wrong tool. | | `chrome.storage.local` for video segments | Cross-restart persistence | Blob-serialization-to-IDBObjectStore is expensive (~5-10 MB per save; multiple writes per minute). Current architecture's offscreen-RAM-only design is already preserving segments across SW idle (offscreen has independent lifecycle). Verify-first, persist-only-if-broken. | ### Version verification ```bash $ npm view jszip version # 3.10.1 $ npm view rrweb version # 2.0.0-alpha.4 $ npm view ts-ebml version # 3.0.2 $ npm view webm-muxer version # 5.1.4 $ npm view vite-plugin-node-polyfills version # 0.28.0 (current installed: 0.27.x) ``` All training-data versions match the registry; rrweb stays on the alpha-pin per D-P3-03 (NOT bumping to 0.28.0 of the polyfill plugin is also acceptable — `exclude` is supported in 0.27 per the plugin's TypeScript types). ## Research Q1 — setimmediate polyfill replacement strategy ### Findings **Source of the polyfill** [VERIFIED: grep dist/assets/index.ts-8LkXuqac.js]: - The SW chunk dist/assets/index.ts-8LkXuqac.js (~370 KB) contains exactly **one** `new Function` reference at the form documented in Plan 01-12's `deferred-items.md`: `b.setImmediate=function(I){typeof I!="function"&&(I=new Function(""+I));...}` - This is the canonical `setimmediate` npm package shipped transitively by `vite-plugin-node-polyfills` (per Plan 01-12 disclosure). The plugin bundles `setimmediate` as part of its Buffer polyfill chain because the upstream `buffer` package depends on it for some legacy paths. **Which deps actually use setImmediate** [VERIFIED: grep node_modules]: - **JSZip**: YES — `node_modules/jszip/dist/jszip.min.js` references `setImmediate` (5 callsites in the minified bundle; classic pattern: `if(!s.setImmediate)` with a synchronous browser polyfill that uses postMessage/MessageChannel/setTimeout fallback). JSZip ships its own polyfill inline; if `globalThis.setImmediate` is already defined, JSZip uses it. - **ts-ebml**: Output truncated (95KB+ of unrelated grep hits). Spot-check confirms no direct `setImmediate(string)` calls — the API surface uses `setImmediate(function)` only. No `new Function` reachability through this dep. - **webm-muxer**: No `setImmediate` references found in spot-check. - **rrweb**: No `setImmediate` references found in spot-check. **Conclusion:** Only JSZip legitimately needs `setImmediate`, and it self-polyfills with a safe inline path. `vite-plugin-node-polyfills` adds a SECOND polyfill (via the `setimmediate` npm package) that includes the unsafe `new Function` fallback for `setImmediate(string)` — which JSZip never calls. The plugin's polyfill is redundant for our purposes. ### Three Options Evaluated **Option (a) — Inline manual polyfill + `exclude` config** [RECOMMENDED] - Add to vite.config.ts: ```ts nodePolyfills({ include: ['buffer'], exclude: ['setimmediate'], // NEW — explicit exclusion globals: { Buffer: true, global: false, process: false }, protocolImports: false, }) ``` - Add to SW entry (src/background/index.ts top-of-module): ```ts // Phase 4 hardening: replace vite-plugin-node-polyfills' setimmediate // polyfill (which includes a CSP-unsafe `new Function(string)` fallback // for string-form setImmediate calls that this codebase never uses). // JSZip falls back to its inline polyfill chain (MessageChannel / // postMessage / setTimeout) when globalThis.setImmediate is unset. // We provide the safest fast-path explicitly: if (typeof globalThis.setImmediate === 'undefined') { (globalThis as { setImmediate?: (fn: (...args: unknown[]) => void, ...args: unknown[]) => void }).setImmediate = (fn, ...args) => queueMicrotask(() => fn(...args)); } ``` - **Pros:** Drops `new Function` from SW chunk entirely (verifiable by grep); bundle size reduction ~5 KB (`setimmediate` polyfill is ~300 LOC pre-minification); MV3 CSP compliant; reversible by git revert. - **Cons:** Two-line config change AND a SW-entry-top inline polyfill — must land coherently in the same plan task. - **Verification gate:** post-build, run `grep -c 'new Function' dist/assets/index.ts-*.js` — must return 0 (was 1 before). Pre-checkpoint bundle gate per `feedback-pre-checkpoint-bundle-gates.md` already has the SW CSP-safety grep; this just flips one cell from 1 → 0. **Option (b) — Configure `vite-plugin-node-polyfills` to skip setimmediate via globals.setImmediate=false** [REJECTED] - The plugin's TypeScript types do NOT expose a `setImmediate` field under `globals` (only `Buffer`, `global`, `process`). Setting `globals.setImmediate = false` would be a no-op or a type error. - The `exclude: ['setimmediate']` config IS the canonical way per npmjs.com docs [VERIFIED via WebSearch 2026-05-21]: "Specific modules that should not be polyfilled." → array of module names → `setimmediate` is the module name on npm. - Option (b) collapses into option (a). **Option (c) — Document acceptance with explicit CSP-allow rationale** [REJECTED for v1 close] - Already done at `.planning/phases/01-stabilize-video-pipeline/deferred-items.md` (Plan 01-12 Wave 7 disclosure). - Per Plan 01-12 SUMMARY "Known Limitations": "Phase 5 hardening" was the explicit deferral target — and Phase 4 IS that phase (renumbered post-Phase-3 closure). - The CSP-allow rationale is technically valid (MV3 default CSP allows `new Function` in SW chunks — Chrome doesn't enforce script-src 'self' as strictly there), BUT (1) it's not future-proof (a tighter CSP override could break this), (2) it leaves a `new Function(string)` literal in production code, which is a static-analysis red flag for any security audit, (3) the option (a) fix costs 8 lines of code with zero behavior change. - Phase 4's charter is hardening; ship the fix. ### Recommendation **Adopt Option (a).** Single plan task in 04-05 (per CONTEXT's suggested plan grouping). Acceptance gates: 1. `grep -c 'new Function' dist/assets/index.ts-*.js` returns 0 (was 1). 2. UAT harness 33/33 GREEN preserved (JSZip falls back to its inline polyfill chain seamlessly). 3. vitest 171/171 GREEN preserved. 4. SW chunk size delta: -5 KB approximately (cosmetic; not a contract). 5. Update `.planning/phases/01-stabilize-video-pipeline/deferred-items.md` — flip "Plan 01-12 Wave 7" entry to "Resolved in Phase 4 Plan 04-05". **Sources:** - [vite-plugin-node-polyfills GitHub](https://github.com/davidmyersdev/vite-plugin-node-polyfills) — confirms `exclude` config option - [vite-plugin-node-polyfills npm](https://www.npmjs.com/package/vite-plugin-node-polyfills) — confirms array-of-module-names format - Local grep at dist/assets/index.ts-8LkXuqac.js + node_modules/jszip/dist/jszip.min.js [VERIFIED 2026-05-21] - `.planning/phases/01-stabilize-video-pipeline/deferred-items.md` (Plan 01-12 internal disclosure) **Confidence: HIGH** — verified through both upstream docs AND local code inspection of every relevant artifact. ## Research Q2 — SW state persistence 5-min idle test pattern under MV3 ### Sub-question (a): Forcing SW unload in Puppeteer **Canonical pattern** [VERIFIED: https://developer.chrome.com/docs/extensions/how-to/test/test-serviceworker-termination-with-puppeteer]: ```javascript async function stopServiceWorker(browser, extensionId) { const host = `chrome-extension://${extensionId}`; const target = await browser.waitForTarget( (t) => t.type() === 'service_worker' && t.url().startsWith(host) ); const worker = await target.worker(); await worker.close(); // CDP-based forced termination } ``` **Version requirement:** Puppeteer ≥ 22.1.0 for `WebWorker.close()`. Current project pin: `puppeteer: ^25.0.2` ⇒ **supported**. **Why NOT `chrome.runtime.reload()`:** - `chrome.runtime.reload()` reloads the ENTIRE extension (re-runs onInstalled, re-fires onStartup, re-bootstraps offscreen) — too aggressive for a "SW evicted but offscreen survives" test. - The whole point of the 5-min idle test is to verify behavior across SW idle eviction WHILE the offscreen document remains alive (since the offscreen is what owns the segment buffer). **Why NOT natural idle eviction (30s default):** - Per Chrome docs [VERIFIED: https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle]: "Service workers are never suspended if the developer tools are open or you are using a ChromeDriver based testing library." - Puppeteer is a CDP-based testing library, so its persistent CDP attach to the SW target keeps the SW alive indefinitely UNLESS we explicitly close it via `worker.close()`. - This is the well-known "tests can't reproduce SW idle eviction without CDP help" trap that the Chrome devrel blog post called out: [Chrome eyeo testing blog](https://developer.chrome.com/blog/eyeos-journey-to-testing-mv3-service%20worker-suspension). **Recommended pattern for A33+ harness assertion:** ```typescript // Host-side (driveA33), pseudo-code: await setupFreshRecording(); // 30s wall-clock min for segment to land await wait(SW_IDLE_THRESHOLD_MS + 5_000); // 35s — let SW age into eviction window await stopServiceWorker(browser, extensionId); // force-evict the SW via worker.close() await wait(NEW_SW_BOOT_MS); // ~500ms for fresh SW to bootstrap on next event await page.evaluate(() => harness.assertA33SaveAfterSwReload()); // dispatch SAVE_ARCHIVE — triggers SW respawn const zipPath = await findLatestZip(); const videoSize = (await readZipEntry(zipPath, 'video/last_30sec.webm')).byteLength; assert(videoSize > 0, 'video buffer survived SW eviction'); ``` The first SAVE_ARCHIVE message after `worker.close()` is what wakes the SW back up — this is the canonical MV3 wakeup path (event-driven respawn). ### Sub-question (b): Verifying buffer survives across reload **Current architecture analysis** [VERIFIED via Read on src/offscreen/recorder.ts + src/background/index.ts]: | State | Owner | Persistence | Survives SW eviction? | |-------|-------|-------------|----------------------| | `segments: Blob[]` (video buffer) | OFFSCREEN document RAM (src/offscreen/recorder.ts:91) | NONE | **Depends on offscreen lifecycle** | | `isRecording` flag | SW global var (src/background/index.ts:74) | NONE | NO — lost on SW restart | | `offscreenCreated` flag | SW global var (src/background/index.ts:75) | Re-derived via `chrome.offscreen.hasDocument()` on SW init (lines 1110-1133) | **YES — re-detected** | | `cachedScreenshot` | SW global Blob (src/background/index.ts:77) | NONE | NO — but only a 2s cache; meaningless | | Tab URL tracker | SW module-internal Set | NONE | NO — but onActivated/onUpdated re-fires; eventually re-accumulates | | `videoPort` reference | SW module-internal | NONE | NO — but offscreen's port keepalive auto-reconnects (recorder.ts:912) | **Critical finding:** The video buffer ONLY lives in offscreen-document RAM. The SW NEVER stores it. So: - If the offscreen document survives SW eviction → the buffer survives. - If the offscreen document dies → the buffer is lost regardless of any SW-side persistence. **Offscreen document lifecycle (Chrome docs):** - Offscreen documents have their own lifecycle, independent of the SW. - Per the Chrome MV3 offscreen API docs: "The offscreen document will be closed when there are no more compelling reasons to keep it open." (Where "compelling reasons" = active `DISPLAY_MEDIA` capture in our case.) - As long as `getDisplayMedia` is actively returning frames AND `MediaRecorder.state === 'recording'`, the offscreen stays alive — even across multiple SW evictions. - The Chrome docs page does NOT enumerate offscreen-document-specific idle eviction rules — they appear to use the same heuristics as the SW but with different inputs (the active capture pipeline keeps it alive). **Recommended verification plan for ROADMAP SC #1:** 1. **First — empirical spike (~30 min Plan 04-03 Wave 0):** Before committing to persistence work, run a smoke spike: - Start recording. - Wait 5 minutes (real wall-clock; can't be sped up because the offscreen needs to actually accumulate segments). - Force `worker.close()` on the SW. - Send SAVE_ARCHIVE from page.evaluate. - Check: does `last_30sec.webm` in the resulting zip have size > 0? 2. **If the spike PASSES** (likely outcome per architecture analysis above): SC #1 is satisfied by the CURRENT architecture. Plan 04-03 becomes a **verification-only plan** — add A33 harness assertion that drives the 5-min idle + SW kill + SAVE flow + zip size > 0 check. 3. **If the spike FAILS** (the offscreen dies along with the SW, contrary to docs): Plan 04-03 becomes an **implementation plan**: - Option A — Persist segments to `chrome.storage.local` (NOT chrome.storage.session — verified in-memory only via Chrome docs). - Option B — Increase offscreen-document keepalive aggressiveness (the port-PING interval is currently 25s; maybe add an "I'm still alive" message every 5s). - Option C — Use IndexedDB in the offscreen for segment persistence (Blobs serialize cleanly to IDB; structured-clone supports them natively). - Recommendation if persistence is needed: **Option C** (IndexedDB in offscreen) — Blobs round-trip without base64 cost; per-segment write is O(segment size) ~3 MB; ~3 writes per 30s window. **Confidence on the spike-first approach: HIGH.** The empirical-spike pattern was established by Plan 01-07 (D-12 + A3 pre-staged fallbacks) and is the canonical risk-management pattern documented in 01-07-SUMMARY.md's "Process Observation — Candidate for GSD Framework Retro" section. ### Sub-question (c): 5-min Puppeteer timeout considerations **Current vitest config** [VERIFIED via Read on vitest.config.ts]: - The vitest config explicitly EXCLUDES `tests/uat/**` from vitest discovery (line 28). The UAT harness runs via `tsx tests/uat/harness.test.ts` — a Node script, NOT a vitest run. - The harness has no per-assertion timeout aggregator; each assertion sets its own SAVE_ARCHIVE_TIMEOUT_MS (e.g., A29_SAVE_ARCHIVE_TIMEOUT_MS = 15_000; A30_SAVE_ARCHIVE_TIMEOUT_MS = 15_000). - Total harness wall-clock for 33 assertions currently runs ~95s under Puppeteer headless (per Plan 01-12 SUMMARY). **Recommendation for A33 5-min test:** - Define `A33_IDLE_WAIT_MS = 5 * 60 * 1000 = 300_000`. - Define `A33_OVERALL_TIMEOUT_MS = A33_IDLE_WAIT_MS + 60_000 = 360_000` (covers idle wait + SW kill + SAVE + zip dispatch). - Inside assertA33, gate with `Promise.race([assertion, timeoutPromise(A33_OVERALL_TIMEOUT_MS)])`. - **CI-lane consideration:** A33 alone adds ~5 min to the harness run (95s → ~395s = ~6.6 min). Acceptable for `npm run test:uat` as a single-shot validator. For per-commit CI lanes, consider an env-gated skip: `if (process.env.SKIP_LONG_UAT === '1') { return skipResult('A33 — set SKIP_LONG_UAT=0 to run 5min idle test'); }`. The planner can decide based on developer-velocity preference. - **The harness orchestrator (tests/uat/harness.test.ts) is a Node script with no global timeout — so A33's 5-min wait won't get pre-empted by an outer test framework.** This is a benefit of the tsx-script architecture; vitest's default per-test timeout (5s) would have been a blocker. **Sources for Q2:** - [Chrome — Test SW termination with Puppeteer](https://developer.chrome.com/docs/extensions/how-to/test/test-serviceworker-termination-with-puppeteer) - [Chrome — SW lifecycle reference](https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle) - [Chrome eyeo testing blog](https://developer.chrome.com/blog/eyeos-journey-to-testing-mv3-service%20worker-suspension) - [Chrome — Longer ESW lifetimes](https://developer.chrome.com/blog/longer-esw-lifetimes) - [Chrome — chrome.storage reference](https://developer.chrome.com/docs/extensions/reference/api/storage) - Local code analysis: src/background/index.ts:74-77 + src/offscreen/recorder.ts:89-94 [VERIFIED 2026-05-21] **Confidence: HIGH on (a) and (c); MEDIUM on (b)** — the architecture analysis is solid, but the offscreen-vs-SW lifecycle interplay is something the Chrome docs leave implicit. The empirical-spike-first recommendation hedges against the MEDIUM-confidence finding. ## Research Q3 — A29 cs-injection-world fix ### Findings **Established canonical pattern** [VERIFIED via Read on Plan 03-02-SUMMARY + 03-03-SUMMARY + tests/uat/extension-page-harness.ts:3129-3543]: A30 and A31 both use the identical 7-step pattern; the same skeleton fits A29's needs unchanged: ```typescript // Page-side assertA29 (rewritten for cs-injection-world): const A29_TAB_NAVIGATION_WAIT_MS = 1_500; // mirrors A27/A30/A31 const A29_PROBE_TAB_URL = 'https://example.com/'; // RFC 2606 reserved const A29_SEGMENT_SETTLE_MS = 11_000; // first segment rotation const A29_MUTATION_SETTLE_MS = 500; // rrweb IncrementalSnapshot enqueue const A29_SAVE_ARCHIVE_TIMEOUT_MS = 15_000; let probeTab: chrome.tabs.Tab | null = null; try { // Step 1: own the recording await setupFreshRecording(); // Step 2: open probe tab on https:// (content script attaches here) probeTab = await chrome.tabs.create({ url: A29_PROBE_TAB_URL, active: true }); // Step 3: wait for content script attach await new Promise((r) => setTimeout(r, A29_TAB_NAVIGATION_WAIT_MS)); // Step 4: first segment rotation await new Promise((r) => setTimeout(r, A29_SEGMENT_SETTLE_MS)); // Step 5: inject DOM mutation via chrome.scripting.executeScript ISOLATED world // (rrweb wires its MutationObserver in the content-script realm, // so DOM mutations in the SAME world ARE captured) await chrome.scripting.executeScript({ target: { tabId: probeTab.id! }, world: 'ISOLATED', func: () => { // Synthetic mutation to trigger rrweb IncrementalSnapshot const div = document.createElement('div'); div.id = 'a29-probe-mutation'; div.textContent = 'a29-mutation-sentinel'; document.body.appendChild(div); }, }); // Step 6: settle for mutation observer await new Promise((r) => setTimeout(r, A29_MUTATION_SETTLE_MS)); // Step 7: SAVE_ARCHIVE while probe tab is active const ack = await sendMessageWithTimeout({ type: 'SAVE_ARCHIVE' }, A29_SAVE_ARCHIVE_TIMEOUT_MS); result.checks.push({ name: 'A29.1', /* SAVE ack */ }); } finally { // T-02-04-04 silent-ignore cleanup if (probeTab?.id !== undefined) { try { await chrome.tabs.remove(probeTab.id); } catch {} } } ``` ```typescript // Host-side driveA29 (already mostly correct; one change needed): // Replace the host-side JSZip check that searches for ANY EventType.{2,3,4} hits // with a check that filters for events whose `data.source` field matches the // rrweb mutation source enum (rrweb v2 IncrementalSource.Mutation = 0) AND // `data.adds[*].node.textContent === 'a29-mutation-sentinel'` (or similar // guard that proves the mutation we INJECTED is what rrweb captured — NOT // arbitrary mutations from leftover iana.org). ``` ### Three Pitfalls Identified **Pitfall 1: tab-attach timing — already-handled.** The `A29_TAB_NAVIGATION_WAIT_MS = 1500ms` matches A27/A30/A31's empirically-validated wait. Per Plan 03-02 SUMMARY: this is sufficient for chrome.tabs.create → page load → content script attach on `https://example.com/`. **No tightening or new timing analysis needed.** **Pitfall 2: probe HTML location.** The Plan 03-02 SUMMARY rejects the "harness page" approach (chrome-extension://) because `` doesn't cover chrome-extension scheme. **Options for A29:** - (a) Use `https://example.com/` directly (matches A30/A31; canonical). - (b) Use a chrome-extension://-served HTML file packaged into the extension (would need to be added to manifest's web_accessible_resources). REJECTED — extra surface, no content script attaches there. - (c) Use a synthetic `data:text/html,...` URL injected via chrome.tabs.create. REJECTED — Chrome match-pattern spec excludes `data:` from `` content scripts, same root issue. - **Recommendation: (a).** Identical to A30/A31; zero surface delta. **Pitfall 3: distinguishing INJECTED mutation from incidental ones.** Per Plan 03-02 SUMMARY "Issues Encountered": A29's current "PASS" is reading iana.org leftover events from A27. The host-side check must validate that the captured rrweb events actually contain the A29-specific sentinel — NOT just generic EventType.{2,3,4} counts. **This is a non-trivial host-side change.** Two strategies for distinguishing: - **Strict (recommended):** filter rrweb events for `data.source === IncrementalSource.Mutation` (= 0 in rrweb v2) AND descend into `data.adds[*].node.textContent` to find the sentinel string `'a29-mutation-sentinel'`. This proves the mutation came from OUR injection. - **Loose:** just assert events.length > 0 AND first event is EventType.Meta. Easier but doesn't close the iana.org-leftover gap. The CONTEXT's `` notes the A31 extension (one-line rrweb session.json sentinel grep) — Plan 04-02's A31 extension can use the same strict approach. Re-targeting A29 should use the strict approach too. ### Recommendation **Adopt the verbatim Plan 03-02 / 03-03 pattern for A29.** Single task in Plan 04-01 (per CONTEXT's suggested plan grouping — "Bug / flake stabilization wave"). The fix is mechanical — port 03-02's assertA30 skeleton to assertA29, inject a rrweb-distinguishable mutation in the ISOLATED world, update driveA29 with the strict mutation-source check. Acceptance gates: 1. `npm run test:uat` exits 0 with 33/33 GREEN (or 34/34 if A33 SW persistence assertion lands first). 2. A29 PASS rate across 5 consecutive runs = 5/5 (vs. current ~2/3 pre-existing flake). 3. driveA29 host-side check explicitly validates the sentinel string `'a29-mutation-sentinel'` is present in at least one rrweb event's mutation payload. 4. Tier-1 FORBIDDEN_HOOK_STRINGS unchanged at 12 (the fix uses production chrome.tabs.create + chrome.scripting.executeScript exclusively). 5. vitest 171/171 GREEN preserved. **Sources:** - [chrome.scripting reference](https://developer.chrome.com/docs/extensions/reference/api/scripting) — confirms ISOLATED is default world; ISOLATED is correct choice - [Chrome — content scripts](https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts) — match-pattern spec - `.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-02-SUMMARY.md` Deviations + Issues Encountered [VERIFIED via Read] - `.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-03-SUMMARY.md` Decisions Made + Issues Encountered [VERIFIED via Read] - Local grep at tests/uat/extension-page-harness.ts:3129-3543 [VERIFIED 2026-05-21] **Confidence: HIGH.** Pattern is established and proven across 2 prior plan iterations (03-02 + 03-03); A29 is a third application of the same recipe. ## Finding 4 (out-of-charter spillover) — Cursor visibility ALREADY SHIPPED **Status:** `src/offscreen/recorder.ts:285` ALREADY contains: ```typescript const stream = await navigator.mediaDevices.getDisplayMedia({ video: { displaySurface: 'monitor', cursor: 'always' }, monitorTypeSurfaces: 'include', audio: false, } as ...); ``` The `cursor: 'always'` constraint was opportunistically added in Plan 01-09 (per the inline comment at recorder.ts:258-260: "Plan 01-09 D-15-display-surface ... `cursor: 'always'` opportunistically lifts the Phase 5 cursor-visibility refinement"). The CONTEXT `` line "Cursor visibility implementation: `getDisplayMedia({video: {cursor: 'always'}, audio: ...})` at the getDisplayMedia call site in src/offscreen/recorder.ts" describes work that is already done. **Implication for Plan 04-06 (visual polish):** - The plan task "cursor visibility implementation" can be downgraded to a **verification + acknowledgment task**: - Grep gate: `grep -c "cursor: 'always'" src/offscreen/recorder.ts` returns ≥ 1. - Empirical verification (optional — operator-perceptible): run a SAVE flow against a probe page, decode the `last_30sec.webm` from the resulting zip, scrub frame-by-frame to confirm the pointer is visible. - Doc correction: update `.planning/phases/01-stabilize-video-pipeline/01-07-SUMMARY.md` to flip the "deferred to Phase 5" line to "shipped Plan 01-09; verified Plan 04-06" (the SUMMARY currently says: "Cursor visibility refinement deferred to Phase 5, not back-patched into Phase 1." — that's stale). - The dark-logo contrast item (UI-SPEC `currentColor` strategy locked) remains the genuine implementation work for Plan 04-06. **Confidence: HIGH** — verified via Read at recorder.ts:285 [2026-05-21]. ## Don't Hand-Roll | Problem | Don't Build | Use Instead | Why | |---------|-------------|-------------|-----| | Force SW eviction in tests | Wait 30s + hope idle eviction fires | `worker.close()` via CDP (Puppeteer ≥22.1.0) | Chrome blocks idle eviction when CDP is attached; only `worker.close()` works | | Persist Blob across SW restart | Manual chrome.storage.session writes | Trust offscreen-document RAM (current arch) FIRST; if broken, use IndexedDB | chrome.storage.session is in-memory; IDB handles Blobs via structured clone natively | | setImmediate polyfill for SW | Custom Buffer audit + reimplement deps | `nodePolyfills({ exclude: ['setimmediate'] })` + 4-line inline polyfill | Surgical; one config flag + 4 LOC; reversible | | Probe-page for content-script-realm tests | Reinvent harness HTML to inject content scripts | chrome.tabs.create on https://example.com/ + chrome.scripting.executeScript ISOLATED | `` content_scripts EXCLUDES chrome-extension:// (Chrome match-pattern spec); the harness-page approach is empirically broken | | 5-min idle Puppeteer test runner | Pull in extra timeout library | Plain `new Promise((r) => setTimeout(r, 5*60*1000))` | The UAT harness is a tsx-runnable script (NOT vitest) — no outer test-framework timeout pre-emption to worry about | | rrweb event distinguishing | Just check events.length > 0 | Filter for `data.source === IncrementalSource.Mutation` + descend into `data.adds[*].node.textContent` for sentinel | The iana.org-leftover-flake (A29) is exactly what loose checks fail to catch | **Key insight:** Phase 4 is hardening work — every "don't hand-roll" recommendation here is about REPLACING ad-hoc patterns with established library/spec patterns, NOT introducing new dependencies. ## Runtime State Inventory > Per RESEARCH instructions: include for rename/refactor/migration phases. Phase 4 IS partially-refactor (cursor visibility / setimmediate polyfill / A29 race fix are all small refactors against shipped behavior). Brief inventory only. | Category | Items Found | Action Required | |----------|-------------|------------------| | Stored data | `chrome.storage.local` has `onboarding-completed: true` + `installed-at: ` set by openWelcomeIfFirstInstall (Plan 01-10). Neither key changes shape in Phase 4. | None | | Live service config | None — no n8n / Datadog / Tailscale / external services tied to this extension | None | | OS-registered state | None — Chrome's chrome.notifications API uses mokosh- namespace; no OS-level task-scheduler / launchd / systemd surfaces | None | | Secrets/env vars | `VITE_DEV` env var (set to `1` activates the __VITE_DEV__ define-token; absent = false). No secret keys. | None | | Build artifacts / installed packages | `dist/` and `dist-test/` directories regenerated by `npm run build` and `npm run build:test`. After the setimmediate fix lands, the `dist/assets/index.ts-.js` chunk's hash will change — but that's normal Vite output behavior, not a stale-artifact problem. | Phase 4 closure should rebuild + grep-verify dist/ for the post-fix invariants (0 `new Function`; SW chunk hash refreshed). | **Nothing else found.** Verified by spot-check on src/, scripts/, manifest.json [2026-05-21]. ## Common Pitfalls ### Pitfall 1: Trusting Plan 03-02's SUMMARY "A29 events.length=4" diagnostic as proof of correctness **What goes wrong:** Plan 03-02's verification trace shows `A29 events.length=4` and concludes A29 passes. But Plan 03-02 SUMMARY's "Issues Encountered" section explicitly says these 4 events came from iana.org leftover from A27, NOT from the probe HTML on the harness page. **Why it happens:** A29's host-side `findLatestZip` returned the same zip A28 had analyzed (mtime tiebreaker); the zip contained iana.org rrweb events; A29's loose-grep `EventType.{2,3,4}` checks passed. **How to avoid:** Implement strict sentinel-based check (see Q3 Pitfall 3). The mutation must contain a string we INJECTED; no other path produces that string. **Warning signs:** A29 flake rate ~1/3 across 3 consecutive runs (per Plan 03-03 SUMMARY); A29 PASS correlates with whichever A27/iana.org-tab-still-open race won. ### Pitfall 2: Adding chrome.storage.local persistence "just in case" for video buffer **What goes wrong:** Adding write-on-every-segment chrome.storage.local persistence costs ~3 MB per write, ~3 writes per 30s window = ~18 MB/min IDB-write rate. This dwarfs the entire 50 MB RAM budget (CON-ram-ceiling). It would also serialize Blobs via structured-clone (slow) AND occupy disk under the extension's storage quota (~5 MB default, larger via `unlimitedStorage` permission — which we DON'T have per DEC-011). **Why it happens:** The phrasing "SW state persistence" in ROADMAP SC #1 suggests "store the buffer somewhere persistent." The intuitive read is "use chrome.storage.local." **How to avoid:** Run the spike first (Q2 sub-question b). If the offscreen survives SW eviction, no persistence layer is needed — the current architecture already satisfies SC #1. **Warning signs:** If the planner allocates Plan 04-03 a Wave 0 RED test that requires chrome.storage.local writes from src/offscreen/recorder.ts, that's a red flag — the spike must come first. ### Pitfall 3: vite-plugin-node-polyfills `globals.setImmediate = false` (option b) **What goes wrong:** The plugin's TypeScript types DON'T expose a setImmediate field under globals. Setting it would be either a no-op or a type error. **Why it happens:** Looking at the plugin's docs surface, you see `globals: { Buffer, global, process }` — the natural extrapolation is "add setImmediate as a fourth global key." **How to avoid:** Use `exclude: ['setimmediate']` instead (npm module name, NOT global name). **Warning signs:** TypeScript compile error on the vite.config.ts edit, OR a no-op grep result (`new Function` still in dist/ chunk after the rebuild). ### Pitfall 4: Forgetting that Puppeteer prevents natural SW idle eviction **What goes wrong:** Writing a test that "waits 5 minutes and assumes SW is dead" — the SW will STILL be alive because Puppeteer's CDP attach keeps it alive. **Why it happens:** The 30s idle-eviction rule is well-publicized; the "tests can't reproduce this" caveat is less so. **How to avoid:** Always use `worker.close()` via CDP in tests. See Q2 sub-question (a). **Warning signs:** A33 returns "video buffer survived" on EVERY run — including runs where the underlying architecture would actually fail in production. The test is vacuously passing because the SW never died. ### Pitfall 5: A29 chrome.scripting.executeScript injecting MAIN world instead of ISOLATED **What goes wrong:** rrweb's MutationObserver lives in the ISOLATED world (content script's world). MAIN world DOM mutations DO cross over for DOM events but NOT for the rrweb wrapper's specific listeners. **Why it happens:** The default world is ISOLATED, so omitting `world:` is fine. But if someone explicitly sets `world: 'MAIN'` thinking "the page realm sees more"... it doesn't. **How to avoid:** Explicitly write `world: 'ISOLATED'` for documentation/grep value (matches A30/A31's explicit declaration). **Warning signs:** A29 PASS rate degrades after the cs-injection-world port if the world is set to MAIN. ## Code Examples Verified patterns from cited sources. ### Pattern 1: Force SW termination in Puppeteer test ```typescript // Source: https://developer.chrome.com/docs/extensions/how-to/test/test-serviceworker-termination-with-puppeteer async function stopServiceWorker(browser: Browser, extensionId: string): Promise { const host = `chrome-extension://${extensionId}`; const target = await browser.waitForTarget( (t) => t.type() === 'service_worker' && t.url().startsWith(host) ); const worker = await target.worker(); if (worker !== null) { await worker.close(); } } ``` ### Pattern 2: setimmediate polyfill replacement ```typescript // vite.config.ts (modified — Phase 4 Plan 04-05): nodePolyfills({ include: ['buffer'], exclude: ['setimmediate'], // NEW — CSP-hardening per Plan 04-05 globals: { Buffer: true, global: false, process: false }, protocolImports: false, }), // src/background/index.ts (top of module, before any other imports // that might transitively call setImmediate): if (typeof globalThis.setImmediate === 'undefined') { (globalThis as { setImmediate?: (fn: (...args: unknown[]) => void, ...args: unknown[]) => void }).setImmediate = (fn, ...args) => queueMicrotask(() => fn(...args)); } ``` ### Pattern 3: A29 cs-injection-world (verbatim port of A30 + sentinel guard) ```typescript // tests/uat/extension-page-harness.ts (assertA29 rewritten): // Source: derived from existing assertA30/assertA31; Plan 03-02 + 03-03 SUMMARY const A29_PROBE_TAB_URL = 'https://example.com/'; const A29_TAB_NAVIGATION_WAIT_MS = 1_500; const A29_SEGMENT_SETTLE_MS = 11_000; const A29_MUTATION_SETTLE_MS = 500; const A29_SAVE_ARCHIVE_TIMEOUT_MS = 15_000; const A29_MUTATION_SENTINEL = 'a29-mutation-sentinel'; const A29_PROBE_DIV_ID = 'a29-probe-mutation'; async function assertA29(): Promise { const result: AssertionResult = { name: 'A29', passed: false, checks: [], diagnostics: [] }; let probeTab: chrome.tabs.Tab | null = null; try { diag(result, 'Step 1: setupFreshRecording'); await setupFreshRecording(); diag(result, `Step 2: chrome.tabs.create(${A29_PROBE_TAB_URL})`); probeTab = await chrome.tabs.create({ url: A29_PROBE_TAB_URL, active: true }); diag(result, `Step 3: wait ${A29_TAB_NAVIGATION_WAIT_MS}ms for content script attach`); await new Promise((r) => setTimeout(r, A29_TAB_NAVIGATION_WAIT_MS)); diag(result, `Step 4: settle ${A29_SEGMENT_SETTLE_MS}ms for first segment rotation`); await new Promise((r) => setTimeout(r, A29_SEGMENT_SETTLE_MS)); diag(result, 'Step 5: chrome.scripting.executeScript ISOLATED world — inject DOM mutation'); await chrome.scripting.executeScript({ target: { tabId: probeTab.id! }, world: 'ISOLATED', func: (sentinel: string, divId: string) => { const div = document.createElement('div'); div.id = divId; div.textContent = sentinel; document.body.appendChild(div); }, args: [A29_MUTATION_SENTINEL, A29_PROBE_DIV_ID], }); diag(result, `Step 6: settle ${A29_MUTATION_SETTLE_MS}ms for rrweb mutation observer`); await new Promise((r) => setTimeout(r, A29_MUTATION_SETTLE_MS)); diag(result, 'Step 7: dispatch SAVE_ARCHIVE (probe tab is active)'); const ack = await sendMessageWithTimeout({ type: 'SAVE_ARCHIVE' }, A29_SAVE_ARCHIVE_TIMEOUT_MS); result.checks.push({ name: 'A29.1', passed: ack.success === true, expected: 'true', actual: String(ack.success) }); } finally { if (probeTab?.id !== undefined) { try { await chrome.tabs.remove(probeTab.id); } catch { /* T-02-04-04 silent-ignore */ } } } result.passed = result.checks.every((c) => c.passed); return result; } ``` ```typescript // tests/uat/lib/harness-page-driver.ts (driveA29 — host-side sentinel grep): // Source: derived from existing driveA30/driveA31 filter-pipeline pattern import { EventType, IncrementalSource } from '@rrweb/types'; // already imported const A29_MUTATION_SENTINEL = 'a29-mutation-sentinel'; // mirrors page-side export async function driveA29(...): Promise<...> { const r = await harness.assertA29(); // ... existing setup ... const zipPath = await findLatestZip(downloadsDir); const zip = await JSZip.loadAsync(fs.readFileSync(zipPath)); const sessionJson = await zip.file('rrweb/session.json')?.async('string') ?? '[]'; const events = JSON.parse(sessionJson) as Array<{ type: EventType; data?: any }>; // Strict A29.2: filter for rrweb v2 IncrementalSnapshot whose mutation // payload contains our injected sentinel — proves the mutation came // from OUR injection, NOT from leftover iana.org page activity. const mutationEvents = events.filter((e) => e.type === EventType.IncrementalSnapshot && e.data?.source === IncrementalSource.Mutation ); const sentinelEvents = mutationEvents.filter((e) => { const adds = e.data?.adds ?? []; return adds.some((a: any) => typeof a?.node?.textContent === 'string' && a.node.textContent.includes(A29_MUTATION_SENTINEL) ); }); r.checks.push({ name: 'A29.2', passed: sentinelEvents.length >= 1, expected: `>=1 mutation containing '${A29_MUTATION_SENTINEL}'`, actual: String(sentinelEvents.length), }); // ... existing A29.3+ checks for FullSnapshot / Meta / etc. ... } ``` ### Pattern 4: 5-min idle test skeleton (Plan 04-03) ```typescript // tests/uat/lib/harness-page-driver.ts (driveA33 — new): // Source: derived from Q2 research + Plan 03-02 cs-injection-world pattern const A33_IDLE_WAIT_MS = 5 * 60 * 1000; const A33_NEW_SW_BOOT_MS = 500; const A33_OVERALL_TIMEOUT_MS = A33_IDLE_WAIT_MS + 60_000; export async function driveA33( page: Page, browser: Browser, extensionId: string, downloadsDir: string, ): Promise { const r: AssertionRecord = { name: 'A33', passed: false, checks: [], diagnostics: [] }; // Step 1: prime recording on the probe tab await page.evaluate(() => harness.setupFreshRecordingForA33()); // Step 2: wait the 5-min idle interval (real wall-clock) r.diagnostics.push(`waiting ${A33_IDLE_WAIT_MS}ms for SW idle window`); await new Promise((r) => setTimeout(r, A33_IDLE_WAIT_MS)); // Step 3: force SW termination via CDP (Puppeteer ≥22.1.0) await stopServiceWorker(browser, extensionId); r.diagnostics.push('SW terminated via worker.close()'); // Step 4: brief wait for the SW to fully tear down await new Promise((r) => setTimeout(r, A33_NEW_SW_BOOT_MS)); // Step 5: dispatch SAVE_ARCHIVE — this wakes the SW back up as an event const saveResult = await page.evaluate(() => harness.dispatchSaveArchiveForA33()); r.checks.push({ name: 'A33.1', passed: saveResult.success === true, expected: 'true', actual: String(saveResult.success), }); // Step 6: verify the resulting zip contains a non-empty video buffer const zipPath = await findLatestZip(downloadsDir); const zip = await JSZip.loadAsync(fs.readFileSync(zipPath)); const videoEntry = zip.file('video/last_30sec.webm'); const videoSize = videoEntry !== null ? (await videoEntry.async('uint8array')).byteLength : 0; r.checks.push({ name: 'A33.2', passed: videoSize > 0, expected: '>0', actual: String(videoSize), }); r.checks.push({ name: 'A33.3', passed: videoSize > 100_000, // sanity: at least 100 KB (real archives are 1-3 MB) expected: '>100000', actual: String(videoSize), }); r.passed = r.checks.every((c) => c.passed); return r; } ``` ## State of the Art | Old Approach | Current Approach | When Changed | Impact | |--------------|------------------|--------------|--------| | Test SW eviction by waiting 30s idle | `worker.close()` via CDP | Puppeteer 22.1.0 (2024) | Tests can now actually reproduce SW eviction; before, CDP-attached SW never died | | chrome.alarms for SW keepalive | Long-lived chrome.runtime.Port message traffic | Chrome 110+ (2023) | Port message traffic resets SW idle timer; alarms callbacks DO NOT reset it (audit P1 #8) | | chrome.storage.sync only | + chrome.storage.session (in-memory) + chrome.storage.local + IndexedDB | Chrome 102+ chrome.storage.session shipped (2022) | Multiple persistence options; chrome.storage.session NOT cross-restart | | MAIN-world page.evaluate for content-script tests | chrome.scripting.executeScript ISOLATED world via chrome.tabs.create | Plan 03-02 (2026-05-20) | Direct access to ISOLATED-world wrapped APIs (e.g., src/content/index.ts:167 fetch wrapper) | | `chrome.runtime.reload()` to bounce SW state | `worker.close()` via CDP | Puppeteer 22.1.0+ (2024) | Doesn't re-trigger onInstalled/onStartup/offscreen-recreate; surgical for SW-only restart tests | **Deprecated/outdated** (carry-over from prior research; no Phase-4-relevant change): - chrome.tabCapture (replaced by chrome.offscreen + getDisplayMedia per D-01) - `await import(...)` in SW chunks (BLOCKED per Plan 01-11 spike; w3c/webextensions#212 still open as of 2026) - chrome.alarms as SW keepalive (replaced by port traffic per D-17) ## Assumptions Log | # | Claim | Section | Risk if Wrong | |---|-------|---------|---------------| | A1 | The setimmediate polyfill in dist/ comes from `vite-plugin-node-polyfills`'s `setimmediate` transitive dep, NOT from a different bundler-injected source | Q1 Findings | Wrong source → `exclude: ['setimmediate']` is a no-op → `new Function` stays in dist/. Plan 04-05 must verify post-fix grep returns 0. | | A2 | JSZip's inline `setImmediate` polyfill chain (MessageChannel / postMessage / setTimeout) works correctly in MV3 SW context when no `globalThis.setImmediate` is present | Q1 Findings | JSZip might break in production after `exclude: ['setimmediate']` lands. Pre-checkpoint UAT 33/33 GREEN gate would catch this. | | A3 | The offscreen document survives SW eviction as long as MediaRecorder is actively recording (per W3C / Chrome offscreen API spirit) | Q2 sub-question (b) | If wrong, the 5-min idle test fails; persistence work needed (Option C IndexedDB). Spike-first approach hedges. | | A4 | Puppeteer 25.x (our pin) exposes `worker.close()` consistently across the test runs | Q2 Pattern 1 | If `worker.close()` is unavailable on some Puppeteer 25.x patch versions, the test can't simulate SW eviction. WebFetch confirmed ≥22.1.0 supports it; 25.x is comfortably past. | | A5 | The rrweb v2 IncrementalSource.Mutation enum value AND the `data.adds[*].node.textContent` shape are stable across our pinned rrweb 2.0.0-alpha.4 version | Q3 Pattern 3 code | If wrong, A29's strict sentinel check might fail. Mitigation: add a fallback loose check that asserts events.length >= 1 + first event is Meta; flag the alpha-pin instability for v1.1 rrweb upgrade. | | A6 | The CONTEXT's listing of "cursor visibility implementation" was authored BEFORE Plan 01-09's opportunistic shipping of `cursor: 'always'` | Finding 4 | If wrong (the CONTEXT was authored with awareness that cursor is shipped), then finding-4 is just a doc-correction task as already framed. Confidence: HIGH that this is a CONTEXT framing oversight given the inline recorder.ts comment explicitly cites Plan 01-09. | **If this table is empty:** All claims in this research were verified or cited — no user confirmation needed. (Table is not empty; A1-A6 should be acknowledged by the planner. A1 + A2 in particular have post-fix grep + UAT-green verification gates that catch them quickly.) ## Open Questions 1. **Will the offscreen document actually survive 5 minutes of SW idle?** - What we know: Chrome docs say offscreen has its own lifecycle independent of SW; recorder.ts MediaRecorder is the "compelling reason" to keep offscreen alive. - What's unclear: empirical behavior in current Chrome (M132? newer?) — docs don't give a hard answer; behavior may differ if the offscreen is also idle (no segment rotation happening). - Recommendation: spike-first (Q2 sub-question b). Decide persistence work based on empirical result. 2. **Should A33 (5-min idle test) be in the main UAT loop or env-gated?** - What we know: A33 alone adds ~5 min to harness wall-clock (95s → ~395s ≈ 6.6 min). - What's unclear: developer-velocity preference. Some teams accept 6+ min UAT; others gate long tests behind `SKIP_LONG_UAT`. - Recommendation: env-gate with default-OFF for per-commit runs; default-ON for `npm run test:uat:full` / nightly CI. The planner picks based on user preference. 3. **Should the A29 cs-injection-world rewrite also re-target existing A30/A31 to use a uniform sentinel approach?** - What we know: A30/A31 already use cs-injection-world but with looser sentinel approaches (A30 uses 5-tuple presence count; A31 uses defense-in-depth absence + presence pair). - What's unclear: whether the planner wants to unify the sentinel approach across A29/A30/A31 (consistency wins) or keep each plan's existing pattern intact (minimum-surface wins). - Recommendation: keep them separate. A29's strict approach is special because of the iana.org-leftover flake; A30/A31 don't have that race because they verify their own freshly-typed sentinels. ## Environment Availability | Dependency | Required By | Available | Version | Fallback | |------------|------------|-----------|---------|----------| | Node.js | All build + test | ✓ | 25.x per package.json @types/node | — | | npm | All install + scripts | ✓ | (system) | — | | Chrome | Puppeteer UAT harness | ✓ | bundled by puppeteer ^25.0.2 | — | | vite | Bundler | ✓ | ^5.4.2 | — | | vite-plugin-node-polyfills | Buffer polyfill | ✓ | ^0.27.0 (latest 0.28.0) | — | | tsx | UAT harness runner | ✓ | ^4.22.1 | — | | TypeScript | Compile gate | ✓ | ^5.5.4 | — | | ffprobe / ffmpeg | NOT REQUIRED for Phase 4 plans | — | — | — | **Missing dependencies with no fallback:** None. **Missing dependencies with fallback:** None. Phase 4 is purely code/config/docs changes with no new external dependencies. All required tooling is already on the contributor machine per Phase 1's environment expectations. ## Validation Architecture Per `.planning/config.json` (assumed `workflow.nyquist_validation` is enabled — config not explicitly disabling). ### Test Framework | Property | Value | |----------|-------| | Framework | vitest ^4 (unit) + tsx-runnable harness (UAT) | | Config file | vitest.config.ts (unit) + tests/uat/harness.test.ts (UAT, NOT vitest-discovered) | | Quick run command | `npm test` (vitest dot reporter; ~10s) | | Full suite command | `npm test && npm run test:uat` (~110s + 5+ min if A33 lands) | ### Phase Requirements → Test Map Phase 4 has NO new functional REQs but verifies the following items: | Verification Item | Behavior | Test Type | Automated Command | File Exists? | |-------------------|----------|-----------|-------------------|-------------| | ROADMAP SC #1 (5-min idle) | A33 harness assertion: 5-min wait + SW kill + SAVE produces non-empty video | UAT | `npm run test:uat` (env: SKIP_LONG_UAT=0) | ❌ Wave 0 — Plan 04-03 creates assertA33+driveA33 | | ROADMAP SC #2 (fetch + XHR network_error) | A33+ or A30 extension: synthetic fetch + XHR with 404 produces 2 network_error entries | UAT | `npm run test:uat` | ❌ Wave 0 — Plan 04-04 extends driveA30 or creates driveA34 | | ROADMAP SC #3 (generate-icons ESM/CJS) | `npm run build && node generate-icons.cjs` both succeed | unit | `node generate-icons.cjs` smoke OR new test in tests/build/ | ❌ Wave 0 — Plan 04-05 | | ROADMAP SC #4 (dead-code grep) | `rg 'permissions\.request' src/` returns 0; `rg '' src/ vite.config.ts` returns 0 | unit | `npm test` + new tests/build/dead-code-grep.test.ts | ❌ Wave 0 — Plan 04-05 | | Audit P1 #11 (fetch URL extraction) | URL extraction correctly handles Request arg + string arg | unit | `pytest tests/content/fetch-interception.test.ts` (or vitest equivalent) | ❌ Wave 0 — Plan 04-02 | | Audit P1 #14 (navigation URL tracking) | previousUrl module-level tracking; nav events emit prior URL not "unknown" | unit | `npm test -- tests/content/navigation-tracking.test.ts` | ❌ Wave 0 — Plan 04-02 | | Audit P1 #15 (rrweb timestamps) | rrweb buffer cleanup emits Unix-epoch timestamps not page-load-relative | unit | `npm test -- tests/content/rrweb-timestamps.test.ts` | ❌ Wave 0 — Plan 04-02 | | A29 race fix | A29 strict sentinel check; PASS 5/5 across consecutive runs | UAT | `for i in {1..5}; do npm run test:uat; done` (manual stress; or one-time confirmation) | ✗ assertA29 + driveA29 EXIST but need rewrite | | Cursor visibility (verification-only) | grep src/offscreen/recorder.ts for `cursor: 'always'` returns ≥ 1 | unit | `npm test -- tests/build/cursor-visibility.test.ts` | ❌ Wave 0 — Plan 04-06 (optional; current grep gates don't pin this) | | setimmediate polyfill removal | `grep -c 'new Function' dist/assets/index.ts-*.js` returns 0 | build-gate | `npm run build && grep -c 'new Function' dist/assets/index.ts-*.js` | ❌ Wave 0 — Plan 04-05 extends tests/build/no-test-hooks-in-prod-bundle.test.ts OR adds new build gate | | Dark-logo currentColor strategy | mokosh-mark.svg stroke = 'currentColor'; welcome.ts uses ?raw + DOMParser inline injection | unit + UAT | `npm test -- tests/welcome/inline-svg.test.ts` + A17.8 update | ❌ Wave 0 — Plan 04-06 | | ROADMAP backfill (Plans 01-08..01-13) | ROADMAP.md contains plan rows for 01-08, 01-09, 01-10, 01-11, 01-12, 01-13 (5 plans) | docs-grep | `for p in 01-08 01-09 01-10 01-11 01-12 01-13; do grep -c "Plan $p" .planning/ROADMAP.md; done` | ✗ planner-discretion | ### Sampling Rate - **Per task commit:** `npm test` (vitest unit suite; ~10s) - **Per wave merge:** `npm test && npm run test:uat` (with SKIP_LONG_UAT=1 default for fast iteration; A33 skipped) - **Phase gate (Plan 04-08 closure):** `npm test && SKIP_LONG_UAT=0 npm run test:uat` (full A33 5-min idle test runs) ### Wave 0 Gaps - [ ] `tests/build/no-new-function-in-sw-chunk.test.ts` — covers Q1 setimmediate-polyfill-removal assertion - [ ] `tests/build/dead-code-grep.test.ts` — covers ROADMAP SC #4 - [ ] `tests/content/fetch-interception.test.ts` — covers audit P1 #11 - [ ] `tests/content/navigation-tracking.test.ts` — covers audit P1 #14 - [ ] `tests/content/rrweb-timestamps.test.ts` — covers audit P1 #15 - [ ] `tests/welcome/inline-svg.test.ts` — covers UI-SPEC inline-SVG injection contract - [ ] (Optional) `tests/build/cursor-visibility.test.ts` — pins the `cursor: 'always'` constant (defends against accidental future deletion) - Framework install: none needed (vitest + tsx already in place) ## Security Domain `security_enforcement` not explicitly set in config; defaulting to enabled per RESEARCH instructions. ### Applicable ASVS Categories | ASVS Category | Applies | Standard Control | |---------------|---------|-----------------| | V2 Authentication | no | Phase 4 doesn't touch auth surfaces; extension is local-only | | V3 Session Management | no | No server-side sessions; extension is local-only | | V4 Access Control | partial | T-1-04 sender-id check + T-1-NEW-05-01 mitigation already in place (SW + offscreen onMessage handlers check `sender.id === chrome.runtime.id`); Phase 4 changes do NOT touch these guards | | V5 Input Validation | yes | Audit P1 #11 (fetch URL extraction) is an input-validation fix — `args[0]?.toString()` becomes `args[0] instanceof Request ? args[0].url : String(args[0])`. This IS the V5 standard pattern (type-narrow before string-conversion). | | V6 Cryptography | no | No cryptographic surfaces | | V7 Error Handling | partial | Audit P1 #15 (rrweb timestamps) is error-data hygiene — wrong-unit timestamps in events.json would confuse downstream analysis. Phase 4 fixes this. | | V8 Data Protection | partial | REQ-password-confidentiality is OUT OF SCOPE per D-P3-02 charter. A31's existing line-82 filter remains the v1 minimum. Phase 4 does NOT introduce new data-protection surfaces. | | V14 Configuration | yes | Q1 setimmediate fix tightens MV3 CSP posture by removing the `new Function` static-analysis red flag in the SW chunk. | ### Known Threat Patterns for MV3 Chrome Extension stack | Pattern | STRIDE | Standard Mitigation | |---------|--------|---------------------| | Cross-extension message tampering | Spoofing | sender.id === chrome.runtime.id check (already in place) | | Port hijack from rogue extension | Spoofing + Elevation of Privilege | port.sender?.id === chrome.runtime.id check on every onConnect (already in place) | | `new Function(string)` in SW = static-analysis red flag for stricter future CSP | Elevation of Privilege | Q1 — replace with safe `queueMicrotask`-based polyfill | | Test-only hook surface leaking into production bundle | Information Disclosure | Tier-1 FORBIDDEN_HOOK_STRINGS grep gate (12 entries, 0 hits in dist/) (already enforced; Phase 4 preserves) | | URL.createObjectURL leaks via SW context | Information Disclosure + DoS (memory leak) | D-P2-01 offscreen-minted URL bridge + revoke-on-terminal-state (already in place); Phase 4 does NOT touch this | | iana.org tab leftover masking test failure | Repudiation (test misleadingly passes) | Q3 strict sentinel guard (Plan 04-01 A29 rewrite) | | Race between SW eviction and SAVE flow | Tampering (silent data loss) | Q2 — verify offscreen-survives-SW behavior; persist iff broken | ## Sources ### Primary (HIGH confidence) - [Chrome — extension service worker lifecycle](https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle) — fetched 2026-05-21 - [Chrome — Test SW termination with Puppeteer](https://developer.chrome.com/docs/extensions/how-to/test/test-serviceworker-termination-with-puppeteer) — fetched 2026-05-21 - [Chrome — chrome.storage API reference](https://developer.chrome.com/docs/extensions/reference/api/storage) - [Chrome — chrome.scripting reference](https://developer.chrome.com/docs/extensions/reference/api/scripting) - [Chrome — content scripts (match patterns)](https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts) - `.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-02-SUMMARY.md` (verbatim cs-injection-world pattern + A30 trace) - `.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-03-SUMMARY.md` (A31 + A29-flake-disclosure) - `.planning/phases/01-stabilize-video-pipeline/01-12-SUMMARY.md` (setimmediate polyfill discovery + deferred-items linkage) - `.planning/phases/01-stabilize-video-pipeline/deferred-items.md` (setimmediate Phase-5-hardening entry) - `.planning/phases/01-stabilize-video-pipeline/01-07-SUMMARY.md` (Plan 01-09 cursor visibility constraint — note "deferred to Phase 5" line is stale) - src/offscreen/recorder.ts (line 285 `cursor: 'always'` confirmed; line 91 `let segments: Blob[] = []` confirmed) [VERIFIED via Read 2026-05-21] - src/background/index.ts (SW state surface confirmed; lines 74-77 module globals; lines 1110-1133 hasDocument re-detect on init) [VERIFIED via Read 2026-05-21] - vite.config.ts (current nodePolyfills config — no `exclude` array currently) [VERIFIED via Read 2026-05-21] - dist/assets/index.ts-8LkXuqac.js (1 `new Function` reference confirmed; ~370 KB) [VERIFIED via grep 2026-05-21] ### Secondary (MEDIUM confidence) - [Chrome — longer ESW lifetimes blog](https://developer.chrome.com/blog/longer-esw-lifetimes) - [Chrome — eyeo testing journey blog](https://developer.chrome.com/blog/eyeos-journey-to-testing-mv3-service%20worker-suspension) - [vite-plugin-node-polyfills npm](https://www.npmjs.com/package/vite-plugin-node-polyfills) — confirms `exclude` config option - [vite-plugin-node-polyfills GitHub](https://github.com/davidmyersdev/vite-plugin-node-polyfills) — same - [SvelteKit issue #13937](https://github.com/sveltejs/kit/issues/13937) — known production-vs-dev SW polyfill divergence (cross-reference for our scenario) ### Tertiary (LOW confidence — flagged for re-verification if used) - None. ## Metadata **Confidence breakdown:** - Q1 setimmediate polyfill: HIGH — upstream docs + local grep + production-validated pattern - Q2 SW persistence (a) Puppeteer pattern: HIGH — canonical Chrome devrel pattern - Q2 SW persistence (b) buffer survives offscreen: MEDIUM — architecture inference; spike-first recommended - Q2 SW persistence (c) 5-min timeout: HIGH — verified via vitest config inspection - Q3 A29 cs-injection-world: HIGH — established 2-plan precedent (03-02 + 03-03) - Finding 4 cursor already shipped: HIGH — verified at src/offscreen/recorder.ts:285 **Research date:** 2026-05-21 **Valid until:** 2026-06-21 (30 days; stable surfaces — Chrome MV3 docs change slowly; vite-plugin-node-polyfills hasn't changed its `exclude` API in ~2 years) --- ## RESEARCH COMPLETE **Phase:** 04 - harden-clean-up-optional **Confidence:** HIGH ### Key Findings - **Q1 setimmediate:** Adopt option (a) — `exclude: ['setimmediate']` + 4-line `queueMicrotask` polyfill in SW entry. Verifiable by grep against built dist/. Drops `new Function` to 0. JSZip falls back to its inline polyfill chain cleanly. - **Q2 SW persistence:** Use `worker.close()` via Puppeteer CDP (supported on our ^25 pin). Architecture analysis shows segments only live in offscreen RAM — recommend SPIKE-FIRST to verify if the offscreen survives 5-min idle BEFORE committing to persistence work. If offscreen survives: Plan 04-03 is verification-only. If not: use IndexedDB in offscreen for Blob persistence (NOT chrome.storage.session, which is in-memory). - **Q3 A29 cs-injection-world:** Direct verbatim port of Plan 03-02 / 03-03 pattern. Use `https://example.com/`, ISOLATED world, 1.5s tab-attach wait. **Critical addition:** host-side `driveA29` must check for INJECTED-sentinel string in rrweb mutation payload (NOT just generic EventType counts) to close the iana.org-leftover-flake gap. - **Finding 4 (out-of-charter):** `cursor: 'always'` is ALREADY SHIPPED at src/offscreen/recorder.ts:285 (Plan 01-09 opportunistic). Plan 04-06 task downgrades from "implement" to "verify + correct stale SUMMARY". - **Test infrastructure:** 6 new test files needed at Wave 0 (no-new-function-in-sw-chunk, dead-code-grep, 3 content/*.test.ts files for P1 fixes, welcome/inline-svg.test.ts for UI-SPEC). ### File Created `.planning/phases/04-harden-clean-up-optional/04-RESEARCH.md` (absolute: /home/parf/projects/work/repremium/.planning/phases/04-harden-clean-up-optional/04-RESEARCH.md) ### Confidence Assessment | Area | Level | Reason | |------|-------|--------| | Q1 setimmediate polyfill | HIGH | Upstream docs + local grep verification; canonical fix pattern; reversible | | Q2 (a) Puppeteer SW kill pattern | HIGH | Canonical Chrome devrel doc; version-confirmed | | Q2 (b) offscreen lifecycle | MEDIUM | Architecture inference; Chrome docs leave offscreen-vs-SW interplay implicit; spike-first hedges | | Q2 (c) 5-min timeout integration | HIGH | Verified via vitest config inspection (UAT runs OUTSIDE vitest = no outer timeout) | | Q3 A29 cs-injection-world | HIGH | Established 2-plan precedent (03-02 + 03-03 both PASS); sentinel-guard refinement is new but obviously-correct | | Finding 4 cursor shipped | HIGH | Verified via grep at src/offscreen/recorder.ts:285 | | Audit P1 #11/#14/#15 | (deferred to planner per scope brief) | CONTEXT `` has exact diff snippets; planner works from those | | Dark-logo strategy | (deferred to UI-SPEC) | UI-SPEC `currentColor` strategy already locked + approved 5/6 PASS + 1 FLAG | ### Open Questions (carried forward) 1. Empirical: does the offscreen document survive 5-min SW idle? (Spike-first; informs Plan 04-03 scope.) 2. Should A33 be env-gated (`SKIP_LONG_UAT=0` to enable) or always-on? (Developer-velocity tradeoff; planner picks.) 3. Should A29/A30/A31 unify on the strict sentinel approach? (Recommend no — minimum-surface wins.) ### Ready for Planning Research complete. Planner can now create PLAN.md files for 04-01..04-08 (per CONTEXT suggested grouping, with finding-4 downgrading Plan 04-06's cursor task and Q2's spike-first recommendation shaping Plan 04-03's Wave 0 structure). --- *Phase: 04-harden-clean-up-optional* *Research date: 2026-05-21*