feat(02-04): harness A24 — empirical Blob URL download verification (D-P2-01 closes P0-6)
Wire A24 into the Plan 01-13 Approach B UAT harness as the binding empirical gate for D-P2-01. A24 verifies end-to-end that SAVE_ARCHIVE → chrome.downloads. download receives a `blob:` URL prefix (NOT `data:application/zip;base64,`), closing audit P0-6 functionally. The Plan 02-02 unit tests pin the wire-format at the SW↔offscreen boundary; A24 pins it at the chrome.downloads platform boundary through a real Chrome instance. Strategy: chrome.downloads.onCreated listener captures the URL cross-realm. The plan's <action> block proposed a chrome.downloads.download monkey-patch installed in the harness page realm — but that intercepts only same-realm calls, missing the SW's call. The canonical cross-realm capture pattern is chrome.downloads.onCreated (fires for any download initiated by any extension realm, with the full DownloadItem including .url). Documented as a deviation from the plan's pseudo-code in SUMMARY.md (Rule 1 — bug fix vs the pseudo-code strategy; same A24 contract verified, correct mechanism). Files modified: - tests/uat/extension-page-harness.ts (+~150 lines): assertA24 + A24_* constants - tests/uat/lib/harness-page-driver.ts (+~30 lines): driveA24 page.evaluate wrapper - tests/uat/harness.test.ts (+~10 lines): import driveA24, append to drivers list Verification: - HEADLESS=1 npm run test:uat → 25/25 GREEN (24 baseline + A24) - capturedUrl observed: blob:chrome-extension://lpgnfoop.../... - npx vitest run → 171/171 GREEN (no regression) - Tier-1 FORBIDDEN_HOOK_STRINGS gate → 13/13 GREEN (12 strings preserved) - npx tsc --noEmit → clean Plan 02-04 scope: 1/3 tasks landed (A24); Tasks 2-3 add A25+A26+A27+A28 (latency, meta.json shape, multi-tab strict, REQ-archive-layout strict).
This commit is contained in:
@@ -2789,6 +2789,178 @@ async function assertA22(): Promise<AssertionResult> {
|
||||
return result;
|
||||
}
|
||||
|
||||
/* ─── Plan 02-04 Task 1 — A24 (Blob URL empirical) ─────────────────────
|
||||
*
|
||||
* A24 — D-P2-01 empirical: SAVE_ARCHIVE → chrome.downloads.download is
|
||||
* invoked with a `blob:` URL prefix (NOT `data:application/zip;base64,`).
|
||||
* Closes audit P0-6 functionally (Plan 02-02 unit-tested it at the
|
||||
* wire boundary; A24 verifies end-to-end through a real Chrome
|
||||
* instance + the offscreen mint round-trip + chrome.downloads
|
||||
* platform call).
|
||||
*
|
||||
* FORBIDDEN_HOOK_STRINGS impact: NONE. A24 uses chrome.downloads.onCreated
|
||||
* (production cross-realm API; canonical capture pattern) + chrome.runtime
|
||||
* .sendMessage(SAVE_ARCHIVE). The Tier-1 inventory stays at 12.
|
||||
*
|
||||
* Future Plan 02-04 Tasks 2-3 will add A25+A26+A27+A28 (latency, meta.json
|
||||
* shape, multi-tab urls strict, REQ-archive-layout strict zip-layout).
|
||||
*/
|
||||
|
||||
/** Timeout for SAVE_ARCHIVE message dispatch — matches A5_SAVE_ARCHIVE_TIMEOUT_MS
|
||||
* (the SW does the same screenshot + content-script + JSZip work). 15s is
|
||||
* generously above the typical ~2-3s observed in A5. */
|
||||
const A24_SAVE_ARCHIVE_TIMEOUT_MS = 15_000;
|
||||
/** Max time to poll for the chrome.downloads.download Proxy to fire after
|
||||
* the SAVE_ARCHIVE ack returns. The platform call is async-resolved post
|
||||
* the offscreen → SW → chrome.downloads round-trip; 5s is comfortable
|
||||
* headroom over the typical sub-second delay. */
|
||||
const A24_DOWNLOAD_SPY_POLL_TIMEOUT_MS = 5_000;
|
||||
/** Polling cadence while waiting for the chrome.downloads.download spy
|
||||
* to capture its args. 100 ms matches the global POLL_INTERVAL_MS. */
|
||||
const A24_DOWNLOAD_SPY_POLL_INTERVAL_MS = 100;
|
||||
/** Pre-SAVE segment-settle window — mirrors A5_SEGMENT_SETTLE_MS (11s
|
||||
* = 10s rotation + 1s slack) so getVideoBufferFromOffscreen returns a
|
||||
* non-empty segment buffer before A24 dispatches SAVE_ARCHIVE. */
|
||||
const A24_SEGMENT_SETTLE_MS = 11_000;
|
||||
|
||||
/**
|
||||
* A24 — D-P2-01 empirical: SAVE_ARCHIVE invokes chrome.downloads.download
|
||||
* with a `blob:` URL (NOT `data:application/zip;base64,`).
|
||||
*
|
||||
* Strategy: chrome.downloads.onCreated listener captures the URL cross-realm.
|
||||
*
|
||||
* The plan's `<action>` block proposed a chrome.downloads.download
|
||||
* monkey-patch installed in the harness page realm. That approach
|
||||
* intercepts only calls dispatched FROM the harness page realm — the
|
||||
* SW's downloadArchive call lives in a different realm and bypasses
|
||||
* the patch. The canonical cross-realm capture pattern is
|
||||
* chrome.downloads.onCreated (fires for any download initiated by any
|
||||
* extension realm, with the full DownloadItem including .url).
|
||||
*
|
||||
* Per the plan + saved memory feedback-no-unilateral-scope-reduction:
|
||||
* NO new test-hook surface. chrome.downloads.onCreated is a production
|
||||
* chrome.* API available to extension contexts under the existing
|
||||
* `downloads` permission. Tier-1 FORBIDDEN_HOOK_STRINGS stays at 12.
|
||||
*
|
||||
* Chaining: A24 does its OWN setupFreshRecording + SAVE because the
|
||||
* onCreated listener MUST be installed BEFORE the SAVE — chaining off
|
||||
* A5/A11/A12/A13's already-completed saves misses the listener window.
|
||||
*
|
||||
* @returns Structured result with 4 checks (A24.1..A24.4).
|
||||
*/
|
||||
async function assertA24(): Promise<AssertionResult> {
|
||||
const result: AssertionResult = {
|
||||
passed: false,
|
||||
name: 'A24 — D-P2-01 empirical: chrome.downloads.download receives blob: URL (closes P0-6)',
|
||||
checks: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
// Capture stored in closure so the onCreated listener can populate it
|
||||
// across the async SAVE_ARCHIVE dispatch + post-ack poll.
|
||||
let capturedUrl: string | null = null;
|
||||
let listenerInstalled = false;
|
||||
const onCreatedListener = (item: chrome.downloads.DownloadItem): void => {
|
||||
// First-wins — A24's SAVE_ARCHIVE produces exactly one download; any
|
||||
// subsequent download in the same test run (e.g. from a stray prior
|
||||
// SAVE that landed late) would NOT overwrite the captured value.
|
||||
if (capturedUrl === null) {
|
||||
capturedUrl = item.url;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
diag(result, 'Step 1: setupFreshRecording (A24 owns its recording — onCreated listener installed pre-SAVE)');
|
||||
const setupResp = await setupFreshRecording();
|
||||
if (!setupResp.ok) {
|
||||
throw new Error(
|
||||
`setupFreshRecording failed: ${setupResp.error ?? '(no error)'}`,
|
||||
);
|
||||
}
|
||||
diag(result, 'Step 1 OK — REC state established');
|
||||
|
||||
diag(result, `Step 2: settle ${A24_SEGMENT_SETTLE_MS}ms for first segment rotation`);
|
||||
await new Promise((r) => setTimeout(r, A24_SEGMENT_SETTLE_MS));
|
||||
|
||||
diag(result, 'Step 3: install chrome.downloads.onCreated listener (cross-realm capture)');
|
||||
chrome.downloads.onCreated.addListener(onCreatedListener);
|
||||
listenerInstalled = true;
|
||||
|
||||
diag(result, 'Step 4: dispatch SAVE_ARCHIVE');
|
||||
const ack = await sendMessageWithTimeout<{ success: boolean; error?: string }>(
|
||||
{ type: 'SAVE_ARCHIVE' },
|
||||
A24_SAVE_ARCHIVE_TIMEOUT_MS,
|
||||
'SAVE_ARCHIVE (A24)',
|
||||
);
|
||||
diag(result, `Step 4 result: ${JSON.stringify(ack)}`);
|
||||
|
||||
result.checks.push({
|
||||
name: 'A24.1: SAVE_ARCHIVE ack received with success=true',
|
||||
expected: true,
|
||||
actual: ack.success,
|
||||
passed: ack.success === true,
|
||||
});
|
||||
|
||||
diag(result, `Step 5: poll up to ${A24_DOWNLOAD_SPY_POLL_TIMEOUT_MS}ms for chrome.downloads.onCreated to fire`);
|
||||
const pollStart = Date.now();
|
||||
while (capturedUrl === null && Date.now() - pollStart < A24_DOWNLOAD_SPY_POLL_TIMEOUT_MS) {
|
||||
await new Promise((r) => setTimeout(r, A24_DOWNLOAD_SPY_POLL_INTERVAL_MS));
|
||||
}
|
||||
diag(
|
||||
result,
|
||||
`Step 5 result: capturedUrl=${capturedUrl === null ? '<null>' : (capturedUrl as string).substring(0, 60) + '...'}`,
|
||||
);
|
||||
|
||||
result.checks.push({
|
||||
name: 'A24.2: chrome.downloads.onCreated fired (download initiated by SW)',
|
||||
expected: true,
|
||||
actual: capturedUrl !== null,
|
||||
passed: capturedUrl !== null,
|
||||
});
|
||||
|
||||
const urlIsBlob =
|
||||
capturedUrl !== null && (capturedUrl as string).startsWith('blob:');
|
||||
const urlIsDataBase64 =
|
||||
capturedUrl !== null &&
|
||||
(capturedUrl as string).startsWith('data:application/zip;base64,');
|
||||
result.checks.push({
|
||||
name: 'A24.3: download URL starts with "blob:" (D-P2-01 Plan 02-02 closure of P0-6)',
|
||||
expected: true,
|
||||
actual: urlIsBlob,
|
||||
passed: urlIsBlob,
|
||||
});
|
||||
result.checks.push({
|
||||
name: 'A24.4: download URL does NOT start with "data:application/zip;base64," (legacy path retired)',
|
||||
expected: true,
|
||||
actual: !urlIsDataBase64,
|
||||
passed: !urlIsDataBase64,
|
||||
});
|
||||
diag(
|
||||
result,
|
||||
`capturedUrl prefix: ${capturedUrl === null ? '<null>' : (capturedUrl as string).substring(0, 40) + '...'}`,
|
||||
);
|
||||
|
||||
result.passed = result.checks.every((c) => c.passed);
|
||||
} catch (err) {
|
||||
result.error = err instanceof Error ? err.message : String(err);
|
||||
diag(result, `THREW: ${result.error}`);
|
||||
} finally {
|
||||
// T-02-04-01 mitigation: always remove the listener so it does not
|
||||
// accumulate across re-runs of the harness within a single session.
|
||||
// removeListener on a never-added listener is a benign no-op per
|
||||
// chrome.events spec; the guard is purely diagnostic.
|
||||
if (listenerInstalled) {
|
||||
try {
|
||||
chrome.downloads.onCreated.removeListener(onCreatedListener);
|
||||
} catch (rmErr) {
|
||||
diag(result, `(listener cleanup ignored: ${String(rmErr)})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read `chrome.runtime.getManifest().version`. Used by the host-side
|
||||
* orchestrator at startup to capture the expected version for A13's
|
||||
@@ -2832,6 +3004,8 @@ declare global {
|
||||
assertA22: () => Promise<AssertionResult>;
|
||||
// Plan 01-14 — picker narrowing
|
||||
assertA23: () => Promise<AssertionResult>;
|
||||
// Plan 02-04 Task 1 — Phase 2 closure (A24 first)
|
||||
assertA24: () => Promise<AssertionResult>;
|
||||
getManifestVersion: () => Promise<string>;
|
||||
};
|
||||
}
|
||||
@@ -2861,14 +3035,15 @@ window.__mokoshHarness = {
|
||||
assertA21,
|
||||
assertA22,
|
||||
assertA23,
|
||||
assertA24,
|
||||
getManifestVersion,
|
||||
};
|
||||
|
||||
const statusEl = document.getElementById('status');
|
||||
if (statusEl !== null) {
|
||||
statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1..assertA17, assertA18..A22, assertA23, getManifestVersion} available.';
|
||||
statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1..A28, getManifestVersion} available.';
|
||||
}
|
||||
|
||||
console.log('[harness-page] ready — window.__mokoshHarness installed (Plan 01-13 Task 9: A1..A14 + Plan 01-10 Wave 3: A15..A17 + Plan 01-12 Wave 6: A18..A22 + Plan 01-14: A23 + getManifestVersion)');
|
||||
console.log('[harness-page] ready — window.__mokoshHarness installed (Plan 01-13 Task 9: A1..A14 + Plan 01-10 Wave 3: A15..A17 + Plan 01-12 Wave 6: A18..A22 + Plan 01-14: A23 + Plan 02-04 Task 1: A24 + getManifestVersion)');
|
||||
|
||||
export {};
|
||||
|
||||
Reference in New Issue
Block a user