// tests/uat/spike-a33-sw-persistence.ts — Plan 04-04 Wave 0 SPIKE + // Plan-04-04 debug session-2 disambiguation probes. // // 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. // // Session-2 disambiguation (committed alongside debug // session-2 note .planning/debug/sw-offscreen-persistence-investigation-session-2.md): // - get-segment-count probes at 3 checkpoints (post-prime, pre-kill, // post-kill-pre-SAVE) — distinguishes "segments empty pre-kill // (canvas-captureStream throttled)" from "segments existed pre-kill // but lost post-kill (architectural)". Probes ride the production // __mokoshOffscreenQuery bridge op (already in FORBIDDEN_HOOK_STRINGS // inventory); no new test-only symbols introduced. // - SPIKE_SKIP_SW_KILL=1 env-var skips the worker.close() call — // distinguishes "SW kill via CDP causes the failure (CDP artifact)" // from "5-min idle alone causes the failure (architectural OR // canvas-throttling-without-SW-involvement)". // // 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; /** Session-2: probe op timeout (5s; matches the harness's offscreenQuery default). */ const SPIKE_PROBE_TIMEOUT_MS = 5_000; /** * Session-2 disambiguation probe — query the offscreen recorder's live * `segments.length` via the production `__mokoshOffscreenQuery` * `get-segment-count` bridge op (src/test-hooks/offscreen-hooks.ts:493). * * Returns the segment count (number of fully-rotated 10s WebM segments * in the in-memory ring buffer), or `-1` on bridge error. * * The bridge op rides chrome.runtime.sendMessage from the harness page * realm → offscreen-hooks's onMessage listener responds synchronously * via sendResponse({count: number}). No new test-hook symbol; the * `get-segment-count` op is already in the 12-entry FORBIDDEN_HOOK_STRINGS * inventory (Plan 01-13 Wave 3D A11 contract). * * Used at 3 checkpoints in the spike: * 1. post-prime: confirms baseline segment-count ≈ 0 (no rotations yet) * — sanity check; if NON-zero here, the prime did unexpected work. * 2. pre-kill (just BEFORE stopServiceWorker): the critical probe — * if zero here, hypothesis B (canvas-throttling) confirmed; * if non-zero here but archive empty post-SAVE, hypothesis A * (architectural RAM loss across SW termination) confirmed. * 3. post-kill-pre-SAVE: confirms offscreen still respawns + can * respond — if the offscreen target itself died with the SW kill, * this probe would throw or time out; that result distinguishes * "offscreen survives kill but loses state" from "offscreen dies * with the SW kill (collateral teardown per Puppeteer #9995)". * * @param page - The harness page (has chrome.runtime access). * @returns The live segment count, or `-1` on probe error. */ async function probeSegmentCount( page: import('puppeteer').Page, label: string, ): Promise { try { const result = await page.evaluate( (timeoutMs: number) => new Promise<{ count?: number; error?: string }>((resolve) => { const timer = setTimeout(() => { resolve({ error: `get-segment-count timed out after ${timeoutMs}ms` }); }, timeoutMs); chrome.runtime.sendMessage( { type: '__mokoshOffscreenQuery', op: 'get-segment-count' }, (response: unknown) => { clearTimeout(timer); if (chrome.runtime.lastError !== undefined) { resolve({ error: String(chrome.runtime.lastError.message) }); return; } resolve(response as { count?: number; error?: string }); }, ); }), SPIKE_PROBE_TIMEOUT_MS, ); if (typeof result.count === 'number') { process.stdout.write( `SPIKE PROBE [${label}]: segments.length=${result.count}\n`, ); return result.count; } process.stdout.write( `SPIKE PROBE [${label}]: ERROR ${result.error ?? '(unknown)'}\n`, ); return -1; } catch (err) { process.stdout.write( `SPIKE PROBE [${label}]: THREW ${err instanceof Error ? err.message : String(err)}\n`, ); return -1; } } /** * Spike entrypoint. Returns a Node exit code (0 = PASSED, 1 = FAILED). */ async function main(): Promise { // Session-2 mode switch: SPIKE_SKIP_SW_KILL=1 = run Step C variant // (5-min idle + SAVE, NO worker.close()). Distinguishes "SW kill is // the cause" from "5-min idle alone is the cause". const skipSwKill = process.env.SPIKE_SKIP_SW_KILL === '1'; 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`, ); process.stdout.write( `SPIKE: mode = ${skipSwKill ? 'STEP-C (skip-sw-kill: 5-min idle + SAVE, no worker.close)' : 'CANONICAL (5-min idle + worker.close + SAVE)'}\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)'}`); } // Session-2 PROBE 1: post-prime baseline. Expected: 0 (no segment // rotation has happened yet; A2's prime takes <1s + first rotation // is at +10s). await probeSegmentCount(handles.harnessPage, 'POST-PRIME'); // 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'); // Session-2 PROBE 2: pre-kill — THE critical disambiguation point. // If count==0 here: hypothesis B (canvas-captureStream throttled in // headless idle; segments never accumulated). Architecture NOT broken. // If count>=3 here: hypothesis A (architectural RAM loss across SW // termination); the test is correct; segments existed pre-kill. // Then check post-SAVE archive: if also empty, IDB persistence needed. // If count==-1 (probe failed): offscreen is unresponsive — either // collateral-killed already (unlikely; no worker.close yet) or // SW had to broker and SW is also unresponsive. const segCountPreKill = await probeSegmentCount(handles.harnessPage, 'PRE-KILL'); if (skipSwKill) { // Step C variant — skip the SW kill entirely. Use the same 500ms // settle as the canonical path so the timing between SAVE dispatch // and zip mtime stays consistent across runs. process.stdout.write( '\nSPIKE Step 3: SKIPPED (SPIKE_SKIP_SW_KILL=1) — testing 5-min idle alone\n', ); } else { // 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)); // Session-2 PROBE 3: post-kill (or post-skip-kill) — confirms // offscreen is still responsive after worker.close. If count drops // from pre-kill value, the SW kill collaterally destroyed offscreen // state. If count is same (or grows by 1 due to a rotation between // probes), offscreen survived. const segCountPostKill = await probeSegmentCount(handles.harnessPage, skipSwKill ? 'POST-SKIP-KILL' : 'POST-KILL'); process.stdout.write( `SPIKE: segment-count transition: pre=${segCountPreKill}, post=${segCountPostKill}\n`, ); // 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 [${skipSwKill ? 'STEP-C-NO-KILL' : 'CANONICAL'}]: ` + `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);