Plan-checker iter-2 returned VERIFICATION PASSED with 3 cosmetic advisories: - Dim 11: RESEARCH.md "## Open Questions" missing "(RESOLVED)" suffix → fixed - Dim 12: PATTERNS.md:886 stale dispatchSaveArchiveForA33 example → added DEPRECATED banner citing Plan 04-04 REVISION iter-2 Option B canonical pattern - VALIDATION.md frontmatter "4 revised tasks" mismatched per-task map (5 rows) → fixed All 4 BLOCKER+WARNING issues from iter-1 verified resolved by iter-2 plan-checker (VERIFICATION PASSED). 3 cosmetic items now resolved as well. 2 advisory items left as-is per iter-1 (W2 scope-sanity at 04-06; W3 conservative 04-03 dep). Phase 4 plans cleared for execution: - 7 plans across 6 waves (Wave 1: 04-01+04-02 parallel; Waves 2-6 single-plan) - Plan-checker iter-2 VERIFICATION PASSED - Test baselines preserved: vitest 171/171 · UAT harness 33/33 · Tier-1 12 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
70 KiB
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:
- setimmediate (Q1): Option (a) — inline manual polyfill via
globalThis.setImmediate ||=in SW entry +exclude: ['setimmediate']config (NOT yet attempted; vite-plugin-node-polyfills supportsexclude). Dropsnew Functionfrom SW chunk while preserving Buffer (the only legitimately-needed polyfill for JSZip). Verifiable by grep against built dist/. - SW state persistence (Q2): Use
worker.close()via CDP (Puppeteer ≥ 22.1.0, already on ^25 — supported); persist segments tochrome.storage.local(NOTchrome.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:91let 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. - 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_500used by all three current cs-injection callers). Usehttps://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>
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)
</user_constraints>
<phase_requirements>
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.
</phase_requirements>
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
$ 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 Functionreference at the form documented in Plan 01-12'sdeferred-items.md:b.setImmediate=function(I){typeof I!="function"&&(I=new Function(""+I));...} - This is the canonical
setimmediatenpm package shipped transitively byvite-plugin-node-polyfills(per Plan 01-12 disclosure). The plugin bundlessetimmediateas part of its Buffer polyfill chain because the upstreambufferpackage depends on it for some legacy paths.
Which deps actually use setImmediate [VERIFIED: grep node_modules]:
- JSZip: YES —
node_modules/jszip/dist/jszip.min.jsreferencessetImmediate(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; ifglobalThis.setImmediateis 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 usessetImmediate(function)only. Nonew Functionreachability through this dep. - webm-muxer: No
setImmediatereferences found in spot-check. - rrweb: No
setImmediatereferences 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:
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):
// 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 Functionfrom SW chunk entirely (verifiable by grep); bundle size reduction ~5 KB (setimmediatepolyfill 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 perfeedback-pre-checkpoint-bundle-gates.mdalready 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
setImmediatefield underglobals(onlyBuffer,global,process). Settingglobals.setImmediate = falsewould 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 →setimmediateis 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 Functionin 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 anew 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:
grep -c 'new Function' dist/assets/index.ts-*.jsreturns 0 (was 1).- UAT harness 33/33 GREEN preserved (JSZip falls back to its inline polyfill chain seamlessly).
- vitest 171/171 GREEN preserved.
- SW chunk size delta: -5 KB approximately (cosmetic; not a contract).
- 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 — confirms
excludeconfig option - vite-plugin-node-polyfills npm — 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]:
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.
Recommended pattern for A33+ harness assertion:
// 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_MEDIAcapture in our case.) - As long as
getDisplayMediais actively returning frames ANDMediaRecorder.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:
-
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.webmin the resulting zip have size > 0?
-
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.
-
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.
- Option A — Persist segments to
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 viatsx 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:uatas 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
- Chrome — SW lifecycle reference
- Chrome eyeo testing blog
- Chrome — Longer ESW lifetimes
- Chrome — chrome.storage reference
- 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:
// 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 {}
}
}
// 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 <all_urls> 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 excludesdata:from<all_urls>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 intodata.adds[*].node.textContentto 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 <specifics> 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:
npm run test:uatexits 0 with 33/33 GREEN (or 34/34 if A33 SW persistence assertion lands first).- A29 PASS rate across 5 consecutive runs = 5/5 (vs. current ~2/3 pre-existing flake).
- driveA29 host-side check explicitly validates the sentinel string
'a29-mutation-sentinel'is present in at least one rrweb event's mutation payload. - Tier-1 FORBIDDEN_HOOK_STRINGS unchanged at 12 (the fix uses production chrome.tabs.create + chrome.scripting.executeScript exclusively).
- vitest 171/171 GREEN preserved.
Sources:
- chrome.scripting reference — confirms ISOLATED is default world; ISOLATED is correct choice
- Chrome — content scripts — match-pattern spec
.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-02-SUMMARY.mdDeviations + Issues Encountered [VERIFIED via Read].planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-03-SUMMARY.mdDecisions 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:
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 <specifics> 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.tsreturns ≥ 1. - Empirical verification (optional — operator-perceptible): run a SAVE flow against a probe page, decode the
last_30sec.webmfrom 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.mdto 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).
- Grep gate:
- The dark-logo contrast item (UI-SPEC
currentColorstrategy 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 | <all_urls> 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: <ts> 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-<hash>.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
// Source: https://developer.chrome.com/docs/extensions/how-to/test/test-serviceworker-termination-with-puppeteer
async function stopServiceWorker(browser: Browser, extensionId: string): Promise<void> {
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
// 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)
// 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<AssertionResult> {
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;
}
// 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)
// 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<AssertionRecord> {
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 (RESOLVED)
-
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.
-
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.
-
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 '<inline-offscreen-string>' 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 assertiontests/build/dead-code-grep.test.ts— covers ROADMAP SC #4tests/content/fetch-interception.test.ts— covers audit P1 #11tests/content/navigation-tracking.test.ts— covers audit P1 #14tests/content/rrweb-timestamps.test.ts— covers audit P1 #15tests/welcome/inline-svg.test.ts— covers UI-SPEC inline-SVG injection contract- (Optional)
tests/build/cursor-visibility.test.ts— pins thecursor: '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 — fetched 2026-05-21
- Chrome — Test SW termination with Puppeteer — fetched 2026-05-21
- Chrome — chrome.storage API reference
- Chrome — chrome.scripting reference
- Chrome — content scripts (match patterns)
.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 91let 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
excludearray currently) [VERIFIED via Read 2026-05-21] - dist/assets/index.ts-8LkXuqac.js (1
new Functionreference confirmed; ~370 KB) [VERIFIED via grep 2026-05-21]
Secondary (MEDIUM confidence)
- Chrome — longer ESW lifetimes blog
- Chrome — eyeo testing journey blog
- vite-plugin-node-polyfills npm — confirms
excludeconfig option - vite-plugin-node-polyfills GitHub — same
- SvelteKit issue #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-linequeueMicrotaskpolyfill in SW entry. Verifiable by grep against built dist/. Dropsnew Functionto 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-sidedriveA29must 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 <specifics> 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)
- Empirical: does the offscreen document survive 5-min SW idle? (Spike-first; informs Plan 04-03 scope.)
- Should A33 be env-gated (
SKIP_LONG_UAT=0to enable) or always-on? (Developer-velocity tradeoff; planner picks.) - 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