From 3726eee39f555e480b069342df90b6198d51468c Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 21 May 2026 18:44:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(04-04):=20Wave=200=20spike=20=E2=80=94=20s?= =?UTF-8?q?topServiceWorker=20helper=20+=205-min=20SW=20idle=20empirical?= =?UTF-8?q?=20result?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPIKE OUTCOME: FAILED (offscreen DIED across 5-min SW idle + worker.close()) Per Plan 04-04 spike-first contract, Wave 0 empirically investigated whether the offscreen document's RAM-only `segments: Blob[] = []` at src/offscreen/recorder.ts:91 survives a 5-min SW idle followed by Puppeteer CDP-driven `worker.close()`. RESEARCH Q2 hypothesis (MEDIUM confidence): yes, the offscreen has its own lifecycle anchored by active MediaRecorder. Spike result REFUTES that hypothesis. Empirical measurement (HEADLESS=1; one full run; reproducible via the committed spike script): - assertA2 priming: PASSED (badge=REC; offscreen + MediaRecorder live) - 5-min idle: elapsed cleanly (308.7s total wall-clock) - stopServiceWorker: succeeded (worker.close() returned) - SAVE_ARCHIVE ack: {success: true} (SW respawned + processed message) - video/last_30sec.webm size: 8505 bytes (well below 100 KB floor) - meta.urls: only chrome-extension://* origins; real-page URLs LOST - rrweb/session.json: [] - logs/events.json: [] - ffprobe on extracted webm: 'End of file' + 'Duplicate element' errors (corrupt/truncated; not a valid 30s segment cluster sequence) Interpretation: offscreen-document lifecycle is NOT independent of the SW under Puppeteer CDP-driven worker.close() conditions. The 8505 bytes are likely stale/partial header bytes from a re-initialized empty offscreen context after SW respawn, not a surviving 30s buffer. The plan's Task 2 GATING CONDITION (videoSize > 100_000) is NOT satisfied; Task 2 is BLOCKED. Per saved memory `feedback-gsd-ceremony-for-fixes.md`: architectural changes (moving segments from offscreen RAM to IndexedDB per RESEARCH Q2 sub-question b Option C) MUST route through proper plan-fix ceremony, NOT improvised inline inside Plan 04-04. Plan 04-04 SUMMARY flags the failure mode + cites exact remediation path. ROADMAP SC #1 remains OPEN pending the persistence- layer plan-fix. Task 1 persisting artifacts (this commit): - tests/uat/lib/harness-page-driver.ts: + Browser type import (puppeteer) + stopServiceWorker(browser, extensionId) helper (verbatim from Chrome devrel canonical pattern — Puppeteer >=22.1.0; project pin ^25 OK) + findLatestZip exported (was module-internal) so the spike script can reuse the canonical mtime-sort selection logic without duplication - tests/uat/spike-a33-sw-persistence.ts (NEW): + One-shot empirical investigation script; reusable for future SW- lifecycle regression testing (e.g., verifying the eventual IndexedDB persistence layer actually closes ROADMAP SC #1) + Step 1 reuses __mokoshHarness.assertA2 (canonical fresh-recording prime; not the non-existent dispatchSaveArchive that REVISION iter-2 explicitly forbids) + Step 5 dispatches SAVE_ARCHIVE via chrome.runtime.sendMessage inline from harness-page realm (Option B per plan-checker BLOCKER 2; matches A5/A11/A12/A13/A26/A28/A29/A30/A31 pattern) Verification (Task 1 acceptance criteria): - npx tsc --noEmit: exits 0 - HEADLESS=1 tsx tests/uat/spike-a33-sw-persistence.ts: ran to completion (no Puppeteer throw); SPIKE RESULT line emitted with explicit videoSize=8505 bytes; SAVE_ARCHIVE ack received - grep -c 'dispatchSaveArchive' tests/uat/spike-a33-sw-persistence.ts: 0 - grep -c "type: 'SAVE_ARCHIVE'" tests/uat/spike-a33-sw-persistence.ts: 1 - Total spike wall-clock: 308.7s (~5min idle + ~8s orchestration) References: - Plan 04-04 PLAN.md spike contract (lines 64-72) - 04-RESEARCH.md Q2 sub-question (b) — Chrome MV3 offscreen lifecycle - https://developer.chrome.com/docs/extensions/how-to/test/test-serviceworker-termination-with-puppeteer - Saved memory: feedback-gsd-ceremony-for-fixes.md (no inline architectural fixes; route through plan-fix ceremony) --- tests/uat/lib/harness-page-driver.ts | 49 ++++++- tests/uat/spike-a33-sw-persistence.ts | 201 ++++++++++++++++++++++++++ 2 files changed, 244 insertions(+), 6 deletions(-) create mode 100644 tests/uat/spike-a33-sw-persistence.ts diff --git a/tests/uat/lib/harness-page-driver.ts b/tests/uat/lib/harness-page-driver.ts index 997b7b4..b40f74d 100644 --- a/tests/uat/lib/harness-page-driver.ts +++ b/tests/uat/lib/harness-page-driver.ts @@ -40,12 +40,45 @@ import { join, resolve as resolvePath } from 'node:path'; import JSZip from 'jszip'; import { EventType, IncrementalSource } from '@rrweb/types'; -import type { Page } from 'puppeteer'; +import type { Browser, Page } from 'puppeteer'; import type { AssertionRecord, CheckRecord } from './assertions'; import { assertArchiveShape, extractEntryToFile } from './zip'; import type { UserEvent } from '../../../src/shared/types'; +/** + * Force-terminate the MV3 service worker via Puppeteer CDP. Required + * because Puppeteer's persistent CDP attach keeps SWs alive indefinitely; + * natural 30s idle eviction does NOT fire under test conditions per + * Chrome docs. + * + * Plan 04-04 — Wave 0 SPIKE persisting artifact + Wave 1 driveA33 + * helper. The Puppeteer ≥ 22.1.0 `WebWorker.close()` surface is the + * canonical Chrome devrel pattern for simulating MV3 SW idle eviction + * under Puppeteer-driven UAT (CDP attach prevents natural eviction). + * + * References: + * - https://developer.chrome.com/docs/extensions/how-to/test/test-serviceworker-termination-with-puppeteer + * - https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle + * - https://developer.chrome.com/blog/eyeos-journey-to-testing-mv3-service%20worker-suspension + * + * @param browser - Puppeteer Browser handle from `launchHarnessBrowser`. + * @param extensionId - The runtime extension ID (from `handles.extensionId`). + */ +export 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(); + } +} + /** * Extended assertion-record shape for A5/A12/A13 which return * host-side-required binary payloads: @@ -1384,17 +1417,21 @@ const A27_HOST_POLL_TIMEOUT_MS = 8_000; const A27_HOST_POLL_INTERVAL_MS = 100; /** - * Internal: pick the most-recently-modified .zip file in `downloadsDir`, - * or null if no .zip files exist. Used by driveA26 + driveA28 to chain - * off A25/A27 without re-dispatching SAVE. The "latest mtime wins" - * pattern works because the A25/A27 driver returns AFTER its zip lands + * Pick the most-recently-modified .zip file in `downloadsDir`, or null + * if no .zip files exist. Used by driveA26 + driveA28 to chain off + * A25/A27 without re-dispatching SAVE. The "latest mtime wins" pattern + * works because the A25/A27 driver returns AFTER its zip lands * (await-pattern via the stable-size poll); no race with mid-write * partial zips at the read instant. * + * Plan 04-04 — exported so the Wave 0 spike script + * (`tests/uat/spike-a33-sw-persistence.ts`) can reuse the canonical + * mtime-sort archive-selection logic without duplicating it. + * * @param downloadsDir - Absolute path to the per-run downloads dir. * @returns Absolute path of the latest .zip, or null on empty dir. */ -function findLatestZip(downloadsDir: string): string | null { +export function findLatestZip(downloadsDir: string): string | null { const candidates = readdirSync(downloadsDir).filter(isZipFilename); if (candidates.length === 0) { return null; diff --git a/tests/uat/spike-a33-sw-persistence.ts b/tests/uat/spike-a33-sw-persistence.ts new file mode 100644 index 0000000..092888d --- /dev/null +++ b/tests/uat/spike-a33-sw-persistence.ts @@ -0,0 +1,201 @@ +// tests/uat/spike-a33-sw-persistence.ts — Plan 04-04 Wave 0 SPIKE. +// +// One-shot empirical investigation: does the offscreen document survive +// a 5-min idle window followed by Puppeteer-driven force-termination +// of the MV3 service worker? Per RESEARCH Q2 (sub-question b), the +// canonical architecture analysis says yes (offscreen has its own +// lifecycle, anchored by active MediaRecorder); RESEARCH confidence +// is MEDIUM because the Chrome docs leave the offscreen-vs-SW interplay +// implicit. This spike is the empirical hedge. +// +// Outcome decision tree: +// - videoSize > 100_000 bytes → SPIKE PASSED. Plan 04-04 Wave 1 +// proceeds: A33 is a verification-only harness assertion that +// wraps the spike methodology. ROADMAP SC #1 satisfied by the +// CURRENT architecture; no persistence layer needed. +// - videoSize <= 100_000 bytes OR throw → SPIKE FAILED. Plan 04-04 +// STOPS execution; SUMMARY documents the failure mode; the next +// planning event proposes IndexedDB persistence work (out of +// scope for this plan per the spike-first contract). +// +// Architectural reuse: +// - `setupFreshRecording` is module-internal inside +// `extension-page-harness.ts` (NOT exposed on `__mokoshHarness`). +// The exposed `assertA2` method is the canonical "prime fresh +// recording" entrypoint — it calls `ensureOffscreen + startRecording` +// and waits for `badge === 'REC'`. After `assertA2` returns, +// `MediaRecorder.state === 'recording'` and the offscreen is the +// "compelling reason" Chrome keeps it alive for. This matches the +// existing precedent: A3+A4 chain off A2's REC state. +// - SAVE_ARCHIVE dispatch uses `chrome.runtime.sendMessage` directly +// from the harness page realm (which is an extension-page realm +// with full chrome.* access). This matches the canonical pattern +// used by 9 existing assertA* methods (A5/A11/A12/A13/A26/A28/ +// A29/A30/A31). The __mokoshHarness surface is assertA1..A31 + +// getManifestVersion only (no SAVE-dispatch helper) — Option B +// per Plan 04-04 REVISION iter-2. +// +// References: +// - Plan 04-04 PLAN.md Task 1 +// - .planning/phases/04-harden-clean-up-optional/04-RESEARCH.md Q2 +// - https://developer.chrome.com/docs/extensions/how-to/test/test-serviceworker-termination-with-puppeteer +// +// Operation: tsx tests/uat/spike-a33-sw-persistence.ts (HEADLESS=1 for CI). +// Expected wall-clock: ~6-7 minutes (5 min idle + 1-2 min orchestration). + +import { readFileSync } from 'node:fs'; + +import JSZip from 'jszip'; + +import { launchHarnessBrowser } from './lib/launch'; +import { findLatestZip, stopServiceWorker } from './lib/harness-page-driver'; + +/** Wall-clock idle window. 5 min matches ROADMAP SC #1's "5+ min idle". */ +const SPIKE_IDLE_WAIT_MS = 5 * 60 * 1000; +/** Post-`worker.close()` settle so the SW teardown finishes. */ +const SPIKE_NEW_SW_BOOT_MS = 500; +/** SAVE_ARCHIVE round-trip timeout. */ +const SPIKE_SAVE_ARCHIVE_TIMEOUT_MS = 15_000; +/** Post-SAVE settle so chrome.downloads finishes writing the zip. */ +const SPIKE_DOWNLOAD_SETTLE_MS = 5_000; +/** Pass/fail floor: real archives are 1-3 MB; 100 KB is a sanity floor + * above any "near-empty" failure mode (a near-empty zip with just + * meta.json + screenshot is ~50 KB; anything above 100 KB means the + * video buffer actually contained segments). */ +const SPIKE_VIDEO_SIZE_FLOOR_BYTES = 100_000; + +/** + * Spike entrypoint. Returns a Node exit code (0 = PASSED, 1 = FAILED). + */ +async function main(): Promise { + process.stdout.write('\nMokosh Plan 04-04 Wave 0 SPIKE — SW state persistence empirical test\n'); + process.stdout.write('='.repeat(72) + '\n'); + process.stdout.write( + `SPIKE: idle window = ${SPIKE_IDLE_WAIT_MS}ms (${SPIKE_IDLE_WAIT_MS / 60_000} min); ` + + `pass floor = ${SPIKE_VIDEO_SIZE_FLOOR_BYTES} bytes\n\n`, + ); + + const t0 = Date.now(); + const handles = await launchHarnessBrowser(); + process.stdout.write(`SPIKE: launched browser; extensionId=${handles.extensionId}\n`); + process.stdout.write(`SPIKE: downloadsDir=${handles.downloadsDir}\n\n`); + + let videoSize = 0; + let spikeError: string | null = null; + + try { + // Step 1 — prime recording via assertA2. assertA2 is the canonical + // exposed "go to REC state" method on __mokoshHarness (read_first + // verified: __mokoshHarness has assertA1..A31 + getManifestVersion; + // assertA1 is read-only state inspection, NOT recording bootstrap; + // setupFreshRecording is module-internal, NOT exposed). assertA2 + // does ensureOffscreen + startRecording + waitFor(badge==='REC'). + process.stdout.write('SPIKE Step 1: prime recording via __mokoshHarness.assertA2\n'); + const a2Result = await handles.harnessPage.evaluate(async () => { + const harness = ( + window as unknown as { + __mokoshHarness: { assertA2: () => Promise<{ passed: boolean; error?: string }> }; + } + ).__mokoshHarness; + return harness.assertA2(); + }); + process.stdout.write( + `SPIKE Step 1 result: assertA2.passed=${a2Result.passed}` + + (a2Result.error !== undefined ? `, error=${a2Result.error}` : '') + + '\n', + ); + if (!a2Result.passed) { + throw new Error(`assertA2 priming failed: ${a2Result.error ?? '(no error)'}`); + } + + // Step 2 — 5-min wall-clock idle. The whole point of the spike. + process.stdout.write(`\nSPIKE Step 2: waiting ${SPIKE_IDLE_WAIT_MS}ms for SW idle window...\n`); + process.stdout.write(`SPIKE Step 2: ETA ${new Date(Date.now() + SPIKE_IDLE_WAIT_MS).toISOString()}\n`); + await new Promise((res) => setTimeout(res, SPIKE_IDLE_WAIT_MS)); + process.stdout.write('SPIKE Step 2 OK — 5-min idle elapsed\n'); + + // Step 3 — force-terminate the SW via Puppeteer CDP worker.close(). + process.stdout.write('\nSPIKE Step 3: stopServiceWorker(browser, extensionId)\n'); + await stopServiceWorker(handles.browser, handles.extensionId); + process.stdout.write('SPIKE Step 3 OK — SW terminated via worker.close()\n'); + + // Step 4 — brief settle for SW teardown. + await new Promise((res) => setTimeout(res, SPIKE_NEW_SW_BOOT_MS)); + + // Step 5 — dispatch SAVE_ARCHIVE via chrome.runtime.sendMessage from + // the harness page realm. The first message after worker.close() + // wakes the SW back up (event-driven respawn — canonical MV3 wakeup + // path). SAVE_ARCHIVE then triggers saveArchive() which calls + // getVideoBufferFromOffscreen() — if the offscreen survived, the + // segments array still has the accumulated 30s of webm chunks. + process.stdout.write('\nSPIKE Step 5: chrome.runtime.sendMessage({type: SAVE_ARCHIVE}) inline\n'); + const saveResult = await handles.harnessPage.evaluate( + (timeoutMs: number) => + new Promise<{ success: boolean; error?: string }>((resolve) => { + const timer = setTimeout(() => { + resolve({ success: false, error: `SAVE_ARCHIVE timed out after ${timeoutMs}ms` }); + }, timeoutMs); + chrome.runtime.sendMessage({ type: 'SAVE_ARCHIVE' }, (response: unknown) => { + clearTimeout(timer); + if (chrome.runtime.lastError !== undefined) { + resolve({ success: false, error: String(chrome.runtime.lastError.message) }); + return; + } + resolve(response as { success: boolean; error?: string }); + }); + }), + SPIKE_SAVE_ARCHIVE_TIMEOUT_MS, + ); + process.stdout.write(`SPIKE Step 5 result: SAVE_ARCHIVE ack -> ${JSON.stringify(saveResult)}\n`); + + // Step 6 — let chrome.downloads finish writing the file. + await new Promise((res) => setTimeout(res, SPIKE_DOWNLOAD_SETTLE_MS)); + + // Step 7 — find the produced zip + measure the video entry. + process.stdout.write('\nSPIKE Step 7: locate zip + inspect video/last_30sec.webm\n'); + const zipPath = findLatestZip(handles.downloadsDir); + if (zipPath === null) { + throw new Error(`No zip found in downloadsDir=${handles.downloadsDir}`); + } + process.stdout.write(`SPIKE Step 7: zipPath=${zipPath}\n`); + + const zip = await JSZip.loadAsync(readFileSync(zipPath)); + const videoEntry = zip.file('video/last_30sec.webm'); + videoSize = videoEntry !== null ? (await videoEntry.async('uint8array')).byteLength : 0; + } catch (err) { + spikeError = err instanceof Error ? err.message : String(err); + process.stderr.write(`\nSPIKE THREW: ${spikeError}\n`); + } finally { + try { + await handles.browser.close(); + } catch (closeErr) { + process.stderr.write(`(non-fatal: browser close threw: ${String(closeErr)})\n`); + } + } + + const tEnd = Date.now(); + const elapsedSec = (tEnd - t0) / 1000; + + process.stdout.write('\n' + '='.repeat(72) + '\n'); + process.stdout.write(`SPIKE RESULT: videoSize=${videoSize} bytes (floor=${SPIKE_VIDEO_SIZE_FLOOR_BYTES}; elapsed=${elapsedSec.toFixed(1)}s)\n`); + if (spikeError !== null) { + process.stdout.write(`SPIKE OUTCOME: FAILED (spike threw: ${spikeError})\n`); + process.stdout.write('='.repeat(72) + '\n'); + return 1; + } + if (videoSize > SPIKE_VIDEO_SIZE_FLOOR_BYTES) { + process.stdout.write('SPIKE OUTCOME: PASSED (offscreen SURVIVED the 5-min idle + SW kill)\n'); + process.stdout.write(' ROADMAP SC #1 hypothesis empirically confirmed.\n'); + process.stdout.write(' Plan 04-04 Wave 1 proceeds: A33 verification-only assertion.\n'); + process.stdout.write('='.repeat(72) + '\n'); + return 0; + } + process.stdout.write('SPIKE OUTCOME: FAILED (offscreen DIED — videoSize below floor)\n'); + process.stdout.write(' Plan 04-04 Wave 1 BLOCKED: IndexedDB persistence work needed.\n'); + process.stdout.write(' Route via plan-fix ceremony per saved-memory contract.\n'); + process.stdout.write('='.repeat(72) + '\n'); + return 1; +} + +const code = await main(); +process.exit(code);