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:
@@ -294,6 +294,25 @@ async function openWelcomeIfFirstInstall(
|
||||
// cycles before the operator-visible error surfaces.
|
||||
const BUFFER_FETCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
// ─── D-P2-01 Blob URL mint/revoke lifecycle state (P0-6 fix) ──────────
|
||||
// pendingDownloadUrlResolvers maps a per-mint requestId to the resolver
|
||||
// of the in-flight downloadArchive's Promise. Mirrors the pendingBuffer
|
||||
// Requests pattern: the onConnect-level port message sink routes the
|
||||
// DOWNLOAD_URL response by id so port replacement mid-mint does not
|
||||
// drop the response.
|
||||
const pendingDownloadUrlResolvers: Map<string, (url: string) => void> = new Map();
|
||||
// pendingRevokes maps a chrome.downloads downloadId to the minted blob:URL
|
||||
// awaiting revocation. Populated when chrome.downloads.download resolves
|
||||
// with its downloadId; drained by the chrome.downloads.onChanged listener
|
||||
// when the corresponding state transitions to 'complete' or 'interrupted'.
|
||||
// The Map is bounded by O(saves-per-session) which is operationally <100
|
||||
// (T-02-02-02 threat-register entry); SW idle teardown clears it entirely.
|
||||
const pendingRevokes: Map<number, string> = new Map();
|
||||
// Outer-bound budget for the offscreen mint round-trip. The bridge is
|
||||
// purely local (no network), so 5 s is generous — the inner encode +
|
||||
// post round-trip is typically <100 ms for archives <10 MB.
|
||||
const BLOB_URL_MINT_TIMEOUT_MS = 5_000;
|
||||
|
||||
// Option C: in-flight REQUEST_BUFFER requests keyed by requestId. The
|
||||
// onConnect-level message sink routes BUFFER -> resolve by id, so port
|
||||
// replacement (videoPort changes mid-request) does NOT lose the
|
||||
@@ -438,6 +457,26 @@ chrome.runtime.onConnect.addListener((port) => {
|
||||
pending.resolve({ segments });
|
||||
return;
|
||||
}
|
||||
if (type === 'DOWNLOAD_URL') {
|
||||
// D-P2-01: route the offscreen-minted blob:URL back to the
|
||||
// in-flight downloadArchive Promise. Mirrors the BUFFER routing
|
||||
// above — keyed by requestId so concurrent mints (theoretically
|
||||
// possible across two SAVE flows) cannot cross-talk.
|
||||
const requestId = (msg as { requestId?: unknown }).requestId;
|
||||
if (typeof requestId !== 'string' || requestId.length === 0) {
|
||||
logger.warn('DOWNLOAD_URL without a valid requestId — dropping');
|
||||
return;
|
||||
}
|
||||
const resolver = pendingDownloadUrlResolvers.get(requestId);
|
||||
if (resolver === undefined) {
|
||||
// Stale DOWNLOAD_URL (mint already timed out). Silently drop.
|
||||
return;
|
||||
}
|
||||
pendingDownloadUrlResolvers.delete(requestId);
|
||||
const url = (msg as { url?: unknown }).url;
|
||||
resolver(typeof url === 'string' ? url : '');
|
||||
return;
|
||||
}
|
||||
// Unknown traffic — drop silently (T-1-04 defense-in-depth).
|
||||
});
|
||||
port.onDisconnect.addListener(() => {
|
||||
@@ -691,7 +730,24 @@ async function createArchive(
|
||||
return archiveBlob;
|
||||
}
|
||||
|
||||
// Скачивание архива
|
||||
// Скачивание архива (D-P2-01: offscreen-minted blob: URL pipeline; P0-6 fix)
|
||||
//
|
||||
// Architectural triangle: SW packages zip → SW asks offscreen to mint URL
|
||||
// (CREATE_DOWNLOAD_URL with base64 archive bytes) → offscreen mints via
|
||||
// URL.createObjectURL (SW lacks it per DEC-006) → offscreen replies
|
||||
// DOWNLOAD_URL{url} → SW calls chrome.downloads.download → onChanged
|
||||
// fires 'complete'/'interrupted' → SW asks offscreen to revoke
|
||||
// (REVOKE_DOWNLOAD_URL). The base64 wire-format reuses the D-12
|
||||
// precedent from src/shared/binary.ts.
|
||||
//
|
||||
// NO FALLBACK to the legacy data: URL pathway: real operator archives
|
||||
// (5-10 MB) exceed Chrome's ~2 MB data-URL cap and would silently fail
|
||||
// with a 'Network error' download (audit P0-6). The legacy encoding
|
||||
// chain (blobToBase64 + chrome.downloads.download(`data:...`)) is gone.
|
||||
// On any failure (mint timeout, empty url, port unavailable, non-blob:
|
||||
// prefix) we throw a typed Error that routes through saveArchive's
|
||||
// catch block into the RECORDING_ERROR channel — operator gets a
|
||||
// visible failure, not a silently corrupted archive.
|
||||
async function downloadArchive(archiveBlob: Blob) {
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().replace(/[:.]/g, '-').split('T')[0];
|
||||
@@ -701,20 +757,72 @@ async function downloadArchive(archiveBlob: Blob) {
|
||||
|
||||
logger.log(`Downloading archive: ${filename} (${archiveBlob.size} bytes)`);
|
||||
|
||||
// WR-08 fix: delegate to the shared `blobToBase64` helper instead of
|
||||
// re-implementing the same per-byte concat + btoa inline. Keeps the
|
||||
// wire-format encoding single-source-of-truth (also used by the
|
||||
// offscreen↔SW port; see src/shared/binary.ts) and ensures any future
|
||||
// performance work (chunked apply, etc.) propagates to both call sites.
|
||||
const base64 = await blobToBase64(archiveBlob);
|
||||
const url = `data:application/zip;base64,${base64}`;
|
||||
if (videoPort === null) {
|
||||
throw new Error('blob-url-mint-failed: offscreen port unavailable');
|
||||
}
|
||||
|
||||
await chrome.downloads.download({
|
||||
url: url,
|
||||
filename: filename,
|
||||
saveAs: false
|
||||
// Encode the archive bytes for the SW→offscreen wire (D-12 base64
|
||||
// precedent — chrome.runtime.Port JSON-serializes payloads and Blobs
|
||||
// arrive as empty objects without this transform).
|
||||
const dataBase64 = await blobToBase64(archiveBlob);
|
||||
const requestId = generateRequestId();
|
||||
const urlPromise = new Promise<string>((resolve) => {
|
||||
pendingDownloadUrlResolvers.set(requestId, resolve);
|
||||
});
|
||||
logger.log('Archive download started');
|
||||
try {
|
||||
videoPort.postMessage({
|
||||
type: 'CREATE_DOWNLOAD_URL',
|
||||
requestId,
|
||||
dataBase64,
|
||||
mimeType: 'application/zip',
|
||||
});
|
||||
} catch (err) {
|
||||
// Port disconnected synchronously between the null-check and post.
|
||||
// Clean up the resolver entry so it doesn't leak; surface a typed
|
||||
// error so saveArchive's catch routes it to RECORDING_ERROR.
|
||||
pendingDownloadUrlResolvers.delete(requestId);
|
||||
throw new Error(`blob-url-mint-failed: CREATE_DOWNLOAD_URL post threw: ${String(err)}`);
|
||||
}
|
||||
const timeoutPromise = new Promise<string>((_, reject) => {
|
||||
setTimeout(
|
||||
() => reject(new Error('blob-url-mint-timeout')),
|
||||
BLOB_URL_MINT_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
let url: string;
|
||||
try {
|
||||
url = await Promise.race([urlPromise, timeoutPromise]);
|
||||
} catch (err) {
|
||||
// Timeout fired before the offscreen responded. Drain the resolver
|
||||
// map entry; the late-arriving DOWNLOAD_URL will be silently dropped
|
||||
// by the onConnect sink (stale-id path).
|
||||
pendingDownloadUrlResolvers.delete(requestId);
|
||||
throw err;
|
||||
}
|
||||
if (url === '') {
|
||||
throw new Error('blob-url-mint-failed: offscreen returned empty url');
|
||||
}
|
||||
// T-02-02-03 mitigation (defense-in-depth): reject any URL that does
|
||||
// not have the blob: scheme. The offscreen is same-extension origin
|
||||
// (sender-id-checked) and the WHATWG URL spec guarantees
|
||||
// URL.createObjectURL emits blob: only — this guard catches future
|
||||
// regressions / hostile-peer-on-shared-port scenarios.
|
||||
if (!url.startsWith('blob:')) {
|
||||
throw new Error(`blob-url-mint-failed: offscreen returned non-blob: url '${url.substring(0, 40)}...'`);
|
||||
}
|
||||
|
||||
const downloadId = await chrome.downloads.download({
|
||||
url,
|
||||
filename,
|
||||
saveAs: false,
|
||||
});
|
||||
if (typeof downloadId === 'number') {
|
||||
// Track the (downloadId → url) pair so the chrome.downloads.onChanged
|
||||
// listener (registered below) can dispatch the revoke when the
|
||||
// download reaches a terminal state.
|
||||
pendingRevokes.set(downloadId, url);
|
||||
}
|
||||
logger.log(`Archive download started: id=${downloadId}, blob-url=${url.substring(0, 30)}...`);
|
||||
}
|
||||
|
||||
// Сохранение архива (полный процесс)
|
||||
@@ -1063,6 +1171,42 @@ try {
|
||||
logger.warn('chrome.notifications.onClicked.addListener failed:', e);
|
||||
}
|
||||
|
||||
// chrome.downloads.onChanged: D-P2-01 (P0-6 fix) — revoke-on-terminal-state.
|
||||
// Closes the URL.revokeObjectURL lifecycle by routing terminal download
|
||||
// state transitions (`complete` / `interrupted`) into a REVOKE_DOWNLOAD_URL
|
||||
// port message back to the offscreen document (which is the URL minting
|
||||
// origin per DEC-006). On benign races (offscreenPort null at revoke time,
|
||||
// e.g. SW respawn between download start and completion) the URL leaks in
|
||||
// offscreen until the document is torn down — bounded per-session,
|
||||
// acceptable per T-02-02-02 threat-register entry (chrome-extension://
|
||||
// scoped, never exposed to web pages).
|
||||
try {
|
||||
if (chrome.downloads?.onChanged?.addListener !== undefined) {
|
||||
chrome.downloads.onChanged.addListener((delta) => {
|
||||
if (delta.state === undefined) return;
|
||||
const newState = delta.state.current;
|
||||
if (newState !== 'complete' && newState !== 'interrupted') return;
|
||||
const url = pendingRevokes.get(delta.id);
|
||||
if (url === undefined) return;
|
||||
pendingRevokes.delete(delta.id);
|
||||
if (videoPort !== null) {
|
||||
try {
|
||||
videoPort.postMessage({ type: 'REVOKE_DOWNLOAD_URL', url });
|
||||
logger.log(`Dispatched REVOKE_DOWNLOAD_URL for downloadId=${delta.id}`);
|
||||
} catch (err) {
|
||||
logger.warn('REVOKE_DOWNLOAD_URL post failed:', err);
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
`videoPort null at revoke time; url ${url.substring(0, 30)}... leaks until offscreen teardown`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('chrome.downloads.onChanged.addListener failed:', e);
|
||||
}
|
||||
|
||||
// Запуск при установке
|
||||
chrome.runtime.onInstalled.addListener((details) => {
|
||||
logger.log('Extension installed/updated:', details.reason);
|
||||
|
||||
@@ -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' } }),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
// 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)) {
|
||||
// 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;
|
||||
}
|
||||
if (arg.url.startsWith(DATA_PREFIX)) {
|
||||
const b64 = arg.url.substring(DATA_PREFIX.length);
|
||||
const bytes = Buffer.from(b64, 'base64');
|
||||
const archiveBlob = new Blob([bytes], { type: 'application/zip' });
|
||||
archiveBytes = Buffer.from(b64, 'base64');
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user