feat(02-02): SW — downloadArchive via offscreen-minted Blob URL + revoke lifecycle (D-P2-01 closes P0-6)

Production changes (src/background/index.ts):
- pendingDownloadUrlResolvers Map<requestId, resolver> routes DOWNLOAD_URL
  responses back to the in-flight downloadArchive Promise; mirrors the
  pendingBufferRequests pattern from the BUFFER round-trip so port
  replacement mid-mint does not lose the response.
- pendingRevokes Map<downloadId, url> tracks (downloadId → minted blob:URL)
  for the chrome.downloads.onChanged revoke dispatch.
- onConnect port message sink extended with DOWNLOAD_URL routing branch
  (alongside existing PING/BUFFER routing).
- downloadArchive rewritten: encode archive via blobToBase64 → post
  CREATE_DOWNLOAD_URL on videoPort → await DOWNLOAD_URL response (race
  against 5s BLOB_URL_MINT_TIMEOUT_MS) → reject empty / non-blob: URLs
  (T-02-02-03 mitigation) → call chrome.downloads.download → register
  (downloadId, url) in pendingRevokes. NO data:URL fallback — typed
  errors route through saveArchive's catch to RECORDING_ERROR.
- chrome.downloads.onChanged listener registered at module init:
  on terminal state ('complete' / 'interrupted'), posts REVOKE_DOWNLOAD_URL
  to videoPort and clears the pendingRevokes entry.

Deviation (Rule 3 — auto-fix blocking issue):
- Plan 02-01's test helpers in blob-url-download.test.ts +
  meta-json-urls-schema.test.ts + strict-meta-json-validation.test.ts
  modeled only the REQUEST_BUFFER → BUFFER round-trip, not the new
  CREATE_DOWNLOAD_URL → DOWNLOAD_URL round-trip Plan 02-02 introduces.
  Without the test-side mint simulation, the SW's downloadArchive
  times out at the offscreen mint step → chrome.downloads.download
  never called → ALL existing meta.json tests timeout.
- Each helper extended with a tryFireDownloadUrl block that decodes
  the CREATE_DOWNLOAD_URL.dataBase64, mints a Node-native blob:URL via
  URL.createObjectURL, captures the archive bytes for downstream
  JSZip extraction (capturedArchiveBytes), and replies DOWNLOAD_URL.
  Test 3 (revoke lifecycle) additionally shims port.postMessage to
  call URL.revokeObjectURL on receipt of REVOKE_DOWNLOAD_URL — the
  test-side equivalent of src/offscreen/recorder.ts handleCreateDownloadUrl.
- Pre-existing Plan-02-02-era TODO comments in both test files
  explicitly anticipated this extension ("Plan 02-03 implementer will
  likely need a different helper, e.g. spy on URL.createObjectURL").

Verification (full §verification block from plan):
- npx tsc --noEmit: clean
- npm run build: clean
- npx vitest run tests/background/blob-url-download.test.ts: 3/3 GREEN (was 3 RED)
- npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts: 13/13 GREEN
- npm test full suite: 163 passed / 8 failed (was 159 passed / 12 failed);
  net delta +4 GREEN = 3 RED→GREEN flips + 1 ffprobe-flaky pass. 8 remaining
  RED are exactly the Plan 02-03 territory (5 meta-json-urls-schema + 3
  strict-meta-json-validation RED tests).
- grep -c "data:application/zip;base64," src/background/index.ts: 0 (gone)
- grep -c "blob:" src/background/index.ts: 8 (new pipeline)
- grep -c "chrome.downloads.onChanged" src/background/index.ts: 5 (listener wired)
- dist/ post-build: 0 "data:application/zip;base64," matches; 1 file with
  "chrome.downloads.onChanged" (the SW chunk).
This commit is contained in:
2026-05-20 15:54:28 +02:00
parent f0b95f4a83
commit 79964e62d2
4 changed files with 379 additions and 32 deletions

View File

@@ -362,6 +362,55 @@ async function runSaveAndCaptureDownloadArg(
);
};
// Plan 02-02 (D-P2-01): simulate the offscreen-side CREATE_DOWNLOAD_URL
// handler. The SW posts CREATE_DOWNLOAD_URL with the archive bytes as
// base64; the real offscreen mints a blob:URL via URL.createObjectURL
// (src/offscreen/recorder.ts:handleCreateDownloadUrl). In tests we
// decode the base64 to a Blob ourselves and mint the URL on the test
// side, then reply with DOWNLOAD_URL{requestId,url}. The minted URL
// round-trips through the SW's pendingDownloadUrlResolvers Map and
// becomes the arg0.url passed to chrome.downloads.download. The
// mintedUrlsHere set is exported so revoke-lifecycle tests (Test 3)
// can probe the URL string passed to URL.revokeObjectURL.
const mintedUrlsHere = new Set<string>();
const tryFireDownloadUrl = () => {
const mintCalls = port.postMessage.mock.calls.filter(
(c: unknown[]) =>
typeof c[0] === 'object' &&
c[0] !== null &&
(c[0] as { type?: unknown }).type === 'CREATE_DOWNLOAD_URL',
);
for (const call of mintCalls) {
const mintMsg = call[0] as {
requestId?: string;
dataBase64?: string;
mimeType?: string;
};
const requestId = mintMsg.requestId;
if (typeof requestId !== 'string' || requestId.length === 0) continue;
if (mintedUrlsHere.has(requestId)) continue;
mintedUrlsHere.add(requestId);
const dataBase64 = mintMsg.dataBase64 ?? '';
const mimeType = mintMsg.mimeType ?? 'application/zip';
let url = '';
try {
// Node 24 ships URL.createObjectURL + Blob as globals. The
// resulting URL has the `blob:nodedata:<uuid>` shape, which
// satisfies the `url.startsWith('blob:')` polarity guard.
const binary = atob(dataBase64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
const blob = new Blob([bytes], { type: mimeType });
url = URL.createObjectURL(blob);
} catch {
url = '';
}
port.onMessage._listeners.forEach((fn) =>
fn({ type: 'DOWNLOAD_URL', requestId, url }),
);
}
};
const onMsg = stub.runtime.onMessage._callbacks[0];
onMsg(
{ type: 'SAVE_ARCHIVE' },
@@ -372,6 +421,7 @@ async function runSaveAndCaptureDownloadArg(
const DRAIN_ITERATIONS = 1_500;
for (let i = 0; i < DRAIN_ITERATIONS; i++) {
tryFireBuffer();
tryFireDownloadUrl();
if (stub.downloads.download.mock.calls.length > 0) break;
await new Promise<void>((resolve) => setTimeout(resolve, 5));
}
@@ -509,6 +559,12 @@ describe('Plan 02-01 Task 1 RED: downloadArchive uses blob: URL (D-P2-01)', () =
);
let buffered = false;
// Plan 02-02 (D-P2-01): mirror runSaveAndCaptureDownloadArg's
// offscreen-side CREATE_DOWNLOAD_URL handler so the SW's
// downloadArchive can complete the bridge round-trip. The 6 MB
// archive's base64 payload is sizeable (~8 MB after encoding); the
// local URL.createObjectURL mint is still essentially free.
const mintedRequestIds = new Set<string>();
const t0 = performance.now();
const DRAIN_ITERATIONS = 1_500;
for (let i = 0; i < DRAIN_ITERATIONS; i++) {
@@ -531,6 +587,36 @@ describe('Plan 02-01 Task 1 RED: downloadArchive uses blob: URL (D-P2-01)', () =
);
}
}
const mintCalls = port.postMessage.mock.calls.filter(
(c: unknown[]) =>
typeof c[0] === 'object' &&
c[0] !== null &&
(c[0] as { type?: unknown }).type === 'CREATE_DOWNLOAD_URL',
);
for (const call of mintCalls) {
const mintMsg = call[0] as {
requestId?: string;
dataBase64?: string;
mimeType?: string;
};
const requestId = mintMsg.requestId;
if (typeof requestId !== 'string' || requestId.length === 0) continue;
if (mintedRequestIds.has(requestId)) continue;
mintedRequestIds.add(requestId);
let url = '';
try {
const binary = atob(mintMsg.dataBase64 ?? '');
const bytes = new Uint8Array(binary.length);
for (let k = 0; k < binary.length; k++) bytes[k] = binary.charCodeAt(k);
const blob = new Blob([bytes], { type: mintMsg.mimeType ?? 'application/zip' });
url = URL.createObjectURL(blob);
} catch {
url = '';
}
port.onMessage._listeners.forEach((fn) =>
fn({ type: 'DOWNLOAD_URL', requestId, url }),
);
}
if (stub.downloads.download.mock.calls.length > 0) break;
await new Promise<void>((resolve) => setTimeout(resolve, 5));
}
@@ -580,6 +666,24 @@ describe('Plan 02-01 Task 1 RED: downloadArchive uses blob: URL (D-P2-01)', () =
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
// Plan 02-02 (D-P2-01): the real offscreen handler in
// src/offscreen/recorder.ts calls URL.revokeObjectURL on receipt of
// REVOKE_DOWNLOAD_URL. The test stub does not load recorder.ts, so
// we route the SW-posted REVOKE_DOWNLOAD_URL through revokeSpy here
// to model the offscreen-side behaviour faithfully.
port.postMessage.mockImplementation((msg: unknown) => {
if (
typeof msg === 'object' &&
msg !== null &&
(msg as { type?: unknown }).type === 'REVOKE_DOWNLOAD_URL'
) {
const url = (msg as { url?: unknown }).url;
if (typeof url === 'string' && url.length > 0) {
URL.revokeObjectURL(url);
}
}
});
try {
const downloadArg = await runSaveAndCaptureDownloadArg(stub, port);
expect(
@@ -590,9 +694,10 @@ describe('Plan 02-01 Task 1 RED: downloadArchive uses blob: URL (D-P2-01)', () =
const mintedUrl = downloadArg!.url;
const downloadId = (await stub.downloads.download.mock.results[0].value) as number;
// RED today: chrome.downloads.onChanged._callbacks is empty (no
// listener registered by current downloadArchive); the forEach
// is a no-op; revokeSpy stays uncalled; assertion below fails.
// GREEN under Plan 02-02: downloadArchive registers a chrome.downloads
// .onChanged listener at module init that posts REVOKE_DOWNLOAD_URL
// back to the offscreen port; our port.postMessage shim above mirrors
// that into revokeSpy.
stub.downloads.onChanged._callbacks.forEach((cb) =>
cb({ id: downloadId, state: { current: 'complete' } }),
);

View File

@@ -366,6 +366,53 @@ async function runSaveAndCaptureArchiveBlob(
);
};
// Plan 02-02 (D-P2-01): simulate the offscreen-side CREATE_DOWNLOAD_URL
// handler. The SW posts CREATE_DOWNLOAD_URL with the archive bytes as
// base64; we mint a Node-native blob: URL via URL.createObjectURL and
// reply DOWNLOAD_URL{requestId,url}. The archive bytes are captured
// here BEFORE minting so meta.json extraction works regardless of
// whether the SW ever calls chrome.downloads.download — this is the
// canonical test-side equivalent of the offscreen path described in
// src/offscreen/recorder.ts handleCreateDownloadUrl.
const mintedRequestIds = new Set<string>();
let capturedArchiveBytes: Uint8Array | null = null;
const tryFireDownloadUrl = () => {
const mintCalls = port.postMessage.mock.calls.filter(
(c: unknown[]) =>
typeof c[0] === 'object' &&
c[0] !== null &&
(c[0] as { type?: unknown }).type === 'CREATE_DOWNLOAD_URL',
);
for (const call of mintCalls) {
const mintMsg = call[0] as {
requestId?: string;
dataBase64?: string;
mimeType?: string;
};
const requestId = mintMsg.requestId;
if (typeof requestId !== 'string' || requestId.length === 0) continue;
if (mintedRequestIds.has(requestId)) continue;
mintedRequestIds.add(requestId);
let url = '';
try {
const binary = atob(mintMsg.dataBase64 ?? '');
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
// Capture the archive bytes for downstream meta.json extraction.
if (capturedArchiveBytes === null) {
capturedArchiveBytes = bytes;
}
const blob = new Blob([bytes], { type: mintMsg.mimeType ?? 'application/zip' });
url = URL.createObjectURL(blob);
} catch {
url = '';
}
port.onMessage._listeners.forEach((fn) =>
fn({ type: 'DOWNLOAD_URL', requestId, url }),
);
}
};
const onMsg = stub.runtime.onMessage._callbacks[0];
onMsg(
{ type: 'SAVE_ARCHIVE' },
@@ -376,24 +423,27 @@ async function runSaveAndCaptureArchiveBlob(
const DRAIN_ITERATIONS = 1_500;
for (let i = 0; i < DRAIN_ITERATIONS; i++) {
tryFireBuffer();
tryFireDownloadUrl();
if (stub.downloads.download.mock.calls.length > 0) break;
await new Promise<void>((resolve) => setTimeout(resolve, 5));
}
if (stub.downloads.download.mock.calls.length === 0) return undefined;
const arg = stub.downloads.download.mock.calls[0][0] as { url: string };
// Current HEAD: data:application/zip;base64,<base64>. Base64-decode.
// Plan 02-02 era: blob:URL — the archive bytes are not recoverable
// from the URL string itself. We captured the base64 payload during
// the CREATE_DOWNLOAD_URL round-trip above; decode and return.
if (arg.url.startsWith('blob:') && capturedArchiveBytes !== null) {
return new Blob([capturedArchiveBytes], { type: 'application/zip' });
}
// Pre-Plan-02-02 path (kept for forward-compat; current HEAD on the
// post-Plan-02-02 branch never takes this branch).
const DATA_PREFIX = 'data:application/zip;base64,';
if (arg.url.startsWith(DATA_PREFIX)) {
const b64 = arg.url.substring(DATA_PREFIX.length);
const bytes = Buffer.from(b64, 'base64');
return new Blob([bytes], { type: 'application/zip' });
}
// Plan 02-02 era: blob: URL. The blob bytes are not directly
// recoverable from the blob: URL string in Node; this RED test
// doesn't reach that branch under current HEAD. The Plan 02-03 GREEN
// implementer will likely need a different helper (e.g. spy on
// URL.createObjectURL to capture the underlying Blob reference).
return undefined;
}

View File

@@ -375,6 +375,50 @@ async function runAndParseMetaJson(
);
};
// Plan 02-02 (D-P2-01): simulate the offscreen-side CREATE_DOWNLOAD_URL
// handler and capture the archive bytes from the port message itself
// (the SW posts the base64-encoded payload before the URL is minted).
// This is the canonical Plan-02-02-era equivalent of the prior
// data:URL base64-decode pathway — we read the same bytes from a
// different surface (port message vs. download URL).
const mintedRequestIds = new Set<string>();
let capturedArchiveBytes: Uint8Array | null = null;
const tryFireDownloadUrl = () => {
const mintCalls = port.postMessage.mock.calls.filter(
(c: unknown[]) =>
typeof c[0] === 'object' &&
c[0] !== null &&
(c[0] as { type?: unknown }).type === 'CREATE_DOWNLOAD_URL',
);
for (const call of mintCalls) {
const mintMsg = call[0] as {
requestId?: string;
dataBase64?: string;
mimeType?: string;
};
const requestId = mintMsg.requestId;
if (typeof requestId !== 'string' || requestId.length === 0) continue;
if (mintedRequestIds.has(requestId)) continue;
mintedRequestIds.add(requestId);
let url = '';
try {
const binary = atob(mintMsg.dataBase64 ?? '');
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
if (capturedArchiveBytes === null) {
capturedArchiveBytes = bytes;
}
const blob = new Blob([bytes], { type: mintMsg.mimeType ?? 'application/zip' });
url = URL.createObjectURL(blob);
} catch {
url = '';
}
port.onMessage._listeners.forEach((fn) =>
fn({ type: 'DOWNLOAD_URL', requestId, url }),
);
}
};
const onMsg = stub.runtime.onMessage._callbacks[0];
onMsg(
{ type: 'SAVE_ARCHIVE' },
@@ -385,23 +429,27 @@ async function runAndParseMetaJson(
const DRAIN_ITERATIONS = 1_500;
for (let i = 0; i < DRAIN_ITERATIONS; i++) {
tryFireBuffer();
tryFireDownloadUrl();
if (stub.downloads.download.mock.calls.length > 0) break;
await new Promise<void>((resolve) => setTimeout(resolve, 5));
}
if (stub.downloads.download.mock.calls.length === 0) return undefined;
const arg = stub.downloads.download.mock.calls[0][0] as { url: string };
const DATA_PREFIX = 'data:application/zip;base64,';
if (!arg.url.startsWith(DATA_PREFIX)) {
// Plan 02-02 era: blob: URL. Plan 02-03 implementer will need a
// different harness (e.g. spy on URL.createObjectURL to capture
// the underlying Blob reference). This RED test runs against the
// current data: URL path.
return undefined;
// Plan 02-02 era: extract archive bytes from the captured port
// payload, not from the blob: URL string (which is opaque in Node).
let archiveBytes: Uint8Array | null = null;
if (arg.url.startsWith('blob:') && capturedArchiveBytes !== null) {
archiveBytes = capturedArchiveBytes;
} else {
const DATA_PREFIX = 'data:application/zip;base64,';
if (arg.url.startsWith(DATA_PREFIX)) {
const b64 = arg.url.substring(DATA_PREFIX.length);
archiveBytes = Buffer.from(b64, 'base64');
}
}
const b64 = arg.url.substring(DATA_PREFIX.length);
const bytes = Buffer.from(b64, 'base64');
const archiveBlob = new Blob([bytes], { type: 'application/zip' });
if (archiveBytes === null) return undefined;
const archiveBlob = new Blob([archiveBytes], { type: 'application/zip' });
const zip = await JSZip.loadAsync(archiveBlob);
const metaEntry = zip.file('meta.json');
if (!metaEntry) return undefined;