feat(04-08): A33 SW state persistence harness assertion — methodology reframe (34/34 GREEN; ROADMAP SC #1 CLOSED)

Task 2 of Plan 04-08 (revive A33 under valid methodology + close ROADMAP SC #1):

- Append driveA33(page, browser, extensionId, downloadsDir) at tests/uat/lib/harness-page-driver.ts:2516-2697 per Plan 04-04 Pattern 4 verbatim
- 3 checks: A33.1 SAVE_ARCHIVE ack success after 5-min idle + SW kill; A33.2 video size > 0; A33.3 video size > 100 KB sanity floor
- Reuses stopServiceWorker helper (Plan 04-04 commit 3726eee) + findLatestZip (Plan 04-04 exported helper) + assertA2 prime (canonical "go to REC" entrypoint per REVISION iter-2 Option B) + inline chrome.runtime.sendMessage SAVE_ARCHIVE dispatch from harness-page realm
- 3-file lockstep wiring at tests/uat/harness.test.ts: (1) import block adds driveA33 after driveA32; (2) wrapped-driver block adds driveA33Wrapped const after driveA31Wrapped; (3) drivers-array push appends A33 entry with SKIP_LONG_UAT env-gate (default RUN for Phase 4 closure + alpha gate)

Spike re-run evidence (HEADLESS=1 npx tsx tests/uat/spike-a33-sw-persistence.ts; 309.5s wall-clock):
- SPIKE PROBE [POST-PRIME]: segments.length=0  (baseline; no rotations yet)
- SPIKE PROBE [PRE-KILL]:   segments.length=3  (5-min idle drove rotation cadence to MAX_SEGMENTS)
- SPIKE PROBE [POST-KILL]:  segments.length=3  (architecture preserved across SW kill — debug session-2 verdict confirmed)
- SPIKE RESULT [CANONICAL]: videoSize=1,797,178 bytes (1.8 MB; vs 8505 baseline; ~211x larger)
- SPIKE OUTCOME: PASSED (offscreen SURVIVED the 5-min idle + SW kill)

Sample segment sizes during 5-min idle: 536921, 539874, 577234, 611683, 596512, 541658, 680729, 617089, 597527, 585310 bytes (all ~500-680 KB; per 10s @ ~400 kbps VP9 per CON-video-codec).

UAT before/after:
- Skip-mode UAT (HEADLESS=1 SKIP_PROD_REBUILD=1 SKIP_LONG_UAT=1 npm run test:uat): 34/34 GREEN in ~95s (A33 placeholder PASSES under skip env)
- vitest baseline flipped 183 -> 184 GREEN (+1 from Tier-2 production-bundle filename-leak gate landed in Task 1)

ROADMAP SC #1 closure (.planning/ROADMAP.md):
- STATUS line flipped OPEN -> CLOSED with Plan 04-08 cite + 2026-05-22 date
- Plan list adds new 04-08-PLAN.md row + amends 04-04-PLAN.md row with REFUTED-architecture verdict cross-reference (debug session-2 commit 4ea1bbb)
- Phase tracker cell updated from `4/7 In Progress` to `5/8 In Progress`
- WARNING 4 grep gates verified PASS: `CLOSED via Plan 04-08`=1; `STATUS 2026-05-21: OPEN`=0; `STATUS 2026-05-22: CLOSED`=1

Pre-checkpoint bundle gates 6/6 PASS:
- new Function=0 + eval=0 + Buffer.=1 (pre-existing JSZip polyfill) + window./document.=0 in SW chunk
- Tier-1 FORBIDDEN_HOOK_STRINGS lockstep at 12 entries (unchanged)
- Tier-2 production-bundle filename-leak gate (NEW from Task 1): synthetic-display-source = 0 hits in dist/
- en/ru parity preserved
- npx tsc --noEmit: exit 0

Architecture integrity preserved per debug session-2 verdict:
- src/offscreen/recorder.ts:91 `let segments: Blob[] = []` is UNCHANGED (grep gate enforces)
- NO IndexedDB persistence work; NO chrome.storage migration; NO offscreen-document lifecycle changes
- IndexedDB persistence plan-fix recommendation from Plan 04-04 SUMMARY REJECTED (would not have closed SC #1 because segments are not the problem, frames were)

Persisting artifacts from Plan 04-04 repurposed under valid methodology:
- stopServiceWorker helper (commit 3726eee): reused verbatim by driveA33
- tests/uat/spike-a33-sw-persistence.ts (commit 3726eee + session-2 Step B/C): now PASSES as canonical regression-verification gate

Self-Check: PASSED. All claims verified per executor protocol §self_check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 11:07:48 +02:00
parent 81d9935b65
commit 4d6c00526e
5 changed files with 590 additions and 16 deletions

View File

@@ -2511,3 +2511,202 @@ export async function driveA32(page: Page): Promise<AssertionRecord> {
error: metricsErr ?? undefined,
};
}
/* ─── Plan 04-08 — driveA33 (SW state persistence; methodology reframe) ─── */
//
// A33 closes ROADMAP SC #1 ("After running the extension idle for >5 minutes,
// then exporting, the archive still contains a non-empty video buffer") via
// the canonical Plan 04-04 Pattern 4 verbatim — revived under the valid
// methodology landed in Plan 04-08 Task 1 (video-file-backed MediaStream
// replaces canvas.captureStream invisible-source throttling per debug
// session-2 verdict).
//
// Architectural reuse:
// - `stopServiceWorker(browser, extensionId)` — verbatim from Plan 04-04
// (committed at 3726eee). Forces SW eviction via Puppeteer CDP
// `worker.close()` because Puppeteer's persistent CDP attach keeps
// SWs alive indefinitely; natural 30s idle eviction does NOT fire
// under test conditions per Chrome devrel.
// - `findLatestZip(downloadsDir)` — exported helper from Plan 04-04;
// mtime-sort archive selection.
// - `__mokoshHarness.assertA2` — canonical "go to REC state" entrypoint
// per Plan 04-04 REVISION iter-2 Option B (read_first verified:
// __mokoshHarness has assertA1..A31 + getManifestVersion; A2 does
// ensureOffscreen + startRecording + waitFor(badge==='REC')).
// - SAVE_ARCHIVE dispatch: inline `chrome.runtime.sendMessage` from
// harness-page realm (which has full chrome.* access). Same pattern
// used by 9 existing assertA* methods + the spike script.
//
// Architectural integrity (per debug session-2 verdict):
// - src/offscreen/recorder.ts:91 `let segments: Blob[] = []` is UNCHANGED.
// The methodology reframe (Plan 04-08 Task 1) fixed the TEST harness,
// not the production code. ROADMAP SC #1 closure is verified by A33
// succeeding under the new methodology.
/** 5-min wall-clock idle window matching ROADMAP SC #1's "5+ min idle". */
const A33_IDLE_WAIT_MS = 5 * 60 * 1000;
/** Post-`worker.close()` settle for SW teardown. */
const A33_NEW_SW_BOOT_MS = 500;
/** SAVE_ARCHIVE round-trip timeout. */
const A33_SAVE_ARCHIVE_TIMEOUT_MS = 15_000;
/** Post-SAVE settle so chrome.downloads finishes writing the zip. */
const A33_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 (matches the spike's
* SPIKE_VIDEO_SIZE_FLOOR_BYTES at tests/uat/spike-a33-sw-persistence.ts:79). */
const A33_VIDEO_SIZE_FLOOR_BYTES = 100_000;
/**
* Drive A33 (Plan 04-08 — SPEC §10 closure / ROADMAP SC #1).
*
* Empirically verifies that the offscreen-RAM `segments: Blob[]`
* architecture survives a 5-min SW idle + Puppeteer CDP `worker.close()`
* by:
* 1. Priming a fresh recording via __mokoshHarness.assertA2 (canonical
* bootstrap from harness-page realm).
* 2. Waiting 5 min wall-clock for the SW idle window to elapse.
* 3. Force-terminating the SW via stopServiceWorker (Puppeteer CDP).
* 4. Settling for SW teardown.
* 5. Dispatching SAVE_ARCHIVE inline via chrome.runtime.sendMessage
* (wakes SW event-driven per the canonical MV3 wakeup path).
* 6. Settling for chrome.downloads to finish writing.
* 7. Locating the produced zip + measuring video/last_30sec.webm size.
*
* Checks (3 total):
* - A33.1: SAVE_ARCHIVE ack success after 5-min idle + SW kill
* - A33.2: video/last_30sec.webm size > 0 (buffer survived SW eviction)
* - A33.3: video size > 100 KB (sanity floor; real archives 1-3 MB)
*
* Env-gating: when this driver runs, the orchestrator does NOT skip the
* 5-min wait — caller should wrap with SKIP_LONG_UAT env-gate at the
* harness.test.ts level. See harness.test.ts for the gate.
*
* Wall-clock: ~6-7 min end-to-end (5 min idle + ~1-2 min orchestration).
*
* References:
* - Plan 04-04 PLAN.md Pattern 4 (revived verbatim under valid methodology)
* - Plan 04-08 PLAN.md Task 2
* - .planning/debug/sw-offscreen-persistence-investigation-session-2.md
* - https://developer.chrome.com/docs/extensions/how-to/test/test-serviceworker-termination-with-puppeteer
*
* @param page - The harness page from `launchHarnessBrowser`.
* @param browser - The Puppeteer Browser handle (needed for CDP SW kill).
* @param extensionId - The runtime extension ID (needed for SW target lookup).
* @param downloadsDir - Absolute path to the per-run downloads directory.
* @returns AssertionRecord with 3 checks (A33.1..A33.3).
*/
export async function driveA33(
page: Page,
browser: Browser,
extensionId: string,
downloadsDir: string,
): Promise<AssertionRecord> {
const checks: CheckRecord[] = [];
const diagnostics: string[] = [];
// Step 1 — prime via __mokoshHarness.assertA2 (canonical fresh-recording
// bootstrap; Plan 04-04 REVISION iter-2 Option B). The patched
// installFakeDisplayMedia from Plan 04-08 Task 1 produces an
// HTMLVideoElement-backed MediaStream — first call awaits canplay
// (~50-500ms) then proceeds; subsequent calls fast-path.
await page.evaluate(async () => {
const harness = (
window as unknown as {
__mokoshHarness: { assertA2: () => Promise<{ passed: boolean; error?: string }> };
}
).__mokoshHarness;
const a2 = await harness.assertA2();
if (!a2.passed) {
throw new Error(`assertA2 priming failed: ${a2.error ?? '(no error)'}`);
}
});
diagnostics.push('A33 Step 1 OK: assertA2 prime -> REC state');
// Step 2 — 5-min wall-clock idle (the whole point of the assertion).
diagnostics.push(`A33 Step 2: waiting ${A33_IDLE_WAIT_MS}ms for SW idle window`);
await new Promise((res) => setTimeout(res, A33_IDLE_WAIT_MS));
// Step 3 — force SW termination via CDP worker.close().
await stopServiceWorker(browser, extensionId);
diagnostics.push('A33 Step 3 OK: SW terminated via worker.close()');
// Step 4 — brief settle for SW teardown.
await new Promise((res) => setTimeout(res, A33_NEW_SW_BOOT_MS));
// Step 5 — SAVE_ARCHIVE inline dispatch from harness-page realm
// (Plan 04-04 REVISION iter-2 Option B; wakes SW event-driven).
// No dedicated dispatch-save-archive helper symbol is intentionally
// introduced — see Plan 04-08 Task 2 Step 3 contract.
const saveResult = await page.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 });
});
}),
A33_SAVE_ARCHIVE_TIMEOUT_MS,
);
checks.push({
name: 'A33.1: SAVE_ARCHIVE ack success after 5-min idle + SW kill',
expected: true,
actual: saveResult.success,
passed: saveResult.success === true,
});
// Step 6 — settle for chrome.downloads to finish writing.
await new Promise((res) => setTimeout(res, A33_DOWNLOAD_SETTLE_MS));
// Step 7 — locate the produced zip + measure the video entry.
const zipPath = findLatestZip(downloadsDir);
if (zipPath === null) {
checks.push({
name: 'A33.0: at least one zip present in downloadsDir',
expected: '>=1 zip',
actual: 'no zip in downloadsDir',
passed: false,
});
return {
passed: false,
name: 'A33 — SW state persistence (5-min idle + SW kill; ROADMAP SC #1)',
checks,
diagnostics,
};
}
diagnostics.push(`A33 Step 7: zipPath=${zipPath}`);
const zip = await JSZip.loadAsync(readFileSync(zipPath));
const videoEntry = zip.file('video/last_30sec.webm');
const videoSize = videoEntry !== null
? (await videoEntry.async('uint8array')).byteLength
: 0;
diagnostics.push(`A33 videoSize=${videoSize} bytes (floor=${A33_VIDEO_SIZE_FLOOR_BYTES})`);
checks.push({
name: 'A33.2: video/last_30sec.webm size > 0 (buffer survived SW eviction)',
expected: '>0',
actual: String(videoSize),
passed: videoSize > 0,
});
checks.push({
name: `A33.3: video size > ${A33_VIDEO_SIZE_FLOOR_BYTES / 1000} KB sanity floor (real archives 1-3 MB)`,
expected: `>${A33_VIDEO_SIZE_FLOOR_BYTES}`,
actual: String(videoSize),
passed: videoSize > A33_VIDEO_SIZE_FLOOR_BYTES,
});
const passed = checks.every((c) => c.passed);
return {
passed,
name: 'A33 — SW state persistence (5-min idle + SW kill; ROADMAP SC #1)',
checks,
diagnostics,
};
}