feat(04-04): Wave 0 spike — stopServiceWorker helper + 5-min SW idle empirical result
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)
This commit is contained in:
@@ -40,12 +40,45 @@ import { join, resolve as resolvePath } from 'node:path';
|
|||||||
|
|
||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import { EventType, IncrementalSource } from '@rrweb/types';
|
import { EventType, IncrementalSource } from '@rrweb/types';
|
||||||
import type { Page } from 'puppeteer';
|
import type { Browser, Page } from 'puppeteer';
|
||||||
|
|
||||||
import type { AssertionRecord, CheckRecord } from './assertions';
|
import type { AssertionRecord, CheckRecord } from './assertions';
|
||||||
import { assertArchiveShape, extractEntryToFile } from './zip';
|
import { assertArchiveShape, extractEntryToFile } from './zip';
|
||||||
import type { UserEvent } from '../../../src/shared/types';
|
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<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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extended assertion-record shape for A5/A12/A13 which return
|
* Extended assertion-record shape for A5/A12/A13 which return
|
||||||
* host-side-required binary payloads:
|
* host-side-required binary payloads:
|
||||||
@@ -1384,17 +1417,21 @@ const A27_HOST_POLL_TIMEOUT_MS = 8_000;
|
|||||||
const A27_HOST_POLL_INTERVAL_MS = 100;
|
const A27_HOST_POLL_INTERVAL_MS = 100;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal: pick the most-recently-modified .zip file in `downloadsDir`,
|
* Pick the most-recently-modified .zip file in `downloadsDir`, or null
|
||||||
* or null if no .zip files exist. Used by driveA26 + driveA28 to chain
|
* if no .zip files exist. Used by driveA26 + driveA28 to chain off
|
||||||
* off A25/A27 without re-dispatching SAVE. The "latest mtime wins"
|
* A25/A27 without re-dispatching SAVE. The "latest mtime wins" pattern
|
||||||
* pattern works because the A25/A27 driver returns AFTER its zip lands
|
* works because the A25/A27 driver returns AFTER its zip lands
|
||||||
* (await-pattern via the stable-size poll); no race with mid-write
|
* (await-pattern via the stable-size poll); no race with mid-write
|
||||||
* partial zips at the read instant.
|
* 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.
|
* @param downloadsDir - Absolute path to the per-run downloads dir.
|
||||||
* @returns Absolute path of the latest .zip, or null on empty 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);
|
const candidates = readdirSync(downloadsDir).filter(isZipFilename);
|
||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
201
tests/uat/spike-a33-sw-persistence.ts
Normal file
201
tests/uat/spike-a33-sw-persistence.ts
Normal file
@@ -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 <tasks> 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<number> {
|
||||||
|
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);
|
||||||
Reference in New Issue
Block a user