Files
mokosh/tests/background/blob-url-download.test.ts
Mark 748a81f100 test(02-01): RED — pin Blob URL download contract (D-P2-01)
Plan 02-01 Task 1 RED gate. Three failing tests pin D-P2-01
(offscreen-minted Blob URL pipeline) ahead of Plan 02-02 implementation:

  1. chrome.downloads.download is called with a blob: URL and NOT a
     data:application/zip;base64, URL (closes audit P0-6).
  2. A 6 MB archive completes through downloadArchive in under 5 s AND
     emits a blob: URL (REQ-archive-export-latency; vi-mocked
     remuxSegments short-circuits the muxer for the 6 MB stress path).
  3. URL.revokeObjectURL is scheduled with the minted URL after
     chrome.downloads.onChanged fires 'complete' (lifecycle hygiene).

RED evidence (vitest 4.1.6 against current HEAD):

  × Test 1: chrome.downloads.download was called with
    url='data:application/zip;base64,UEsDBAoAAAAAAL1qtFw...'
    — D-P2-01 forbids data:application/zip;base64, prefix.
  × Test 2: chrome.downloads.download was called with
    url='data:application/zip;base64,...' at the 6 MB scale —
    D-P2-01 requires blob: prefix.
  × Test 3: URL.revokeObjectURL was never called after
    chrome.downloads.onChanged 'complete' fired
    (chrome.downloads.onChanged._callbacks.length === 0 at probe time).

Implementation notes:
- vitest default env is 'node' (vitest.config.ts); Node 24 ships
  URL.createObjectURL + URL.revokeObjectURL + performance as globals,
  so no jsdom override is required.
- FileReader is NOT in Node 24; added a minimal FileReader polyfill
  (delegates to Blob.arrayBuffer()) so JSZip's Blob ingestion works.
- Test 2 mocks remuxSegments via vi.doMock to bypass muxer monotonic-
  timestamp constraints for the synthetic 6 MB payload.
- Tests 1 + 3 drive the SW with the canonical 3-slice raw-3ebml-concat
  fixture (same byte offsets as tests/background/webm-remux.test.ts).
- T-02-01-01 mitigation: grep -c '\.skip' returns 0.

Baseline: 155 GREEN preserved (no regressions); this plan adds 3 NEW
RED tests. Plan 02-02 flips them GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:24:16 +02:00

626 lines
26 KiB
TypeScript

// tests/background/blob-url-download.test.ts
//
// Plan 02-01 Task 1 (RED gate, Wave 0 of Phase 2).
// Pins decision D-P2-01 (offscreen-minted Blob URL download pipeline,
// closes audit P0-6: base64 data: URL → blob: URL migration).
//
// Contract pinned here (RED today; flips GREEN after Plan 02-02):
//
// 1. `downloadArchive()` invokes `chrome.downloads.download` with a `url`
// that starts with `blob:` and does NOT start with
// `data:application/zip;base64,`. The wire format is the cap-free
// Blob URL minted in the offscreen document via `URL.createObjectURL`
// (offscreen has the DOM globals; the SW does not — DEC-006 binding).
//
// 2. A 6 MB archive completes through `downloadArchive()` in under 5 s
// wall-clock AND emits a blob: URL. The current base64 path is
// bounded by Chrome's ~2 MB data-URL cap in the real runtime; the
// vi.fn() chrome.downloads.download stub can't model the rejection
// itself, so this test's gating signal is the WIRE-FORMAT prefix at
// 6 MB scale (latency check is necessary but not sufficient — the
// prefix guard converts a fast spurious pass into a wire-format
// failure under current HEAD).
//
// 3. `URL.revokeObjectURL(url)` is scheduled for the same URL passed to
// `chrome.downloads.download` once `chrome.downloads.onChanged`
// reports `state === 'complete'`. Lifecycle hygiene; without it the
// offscreen page leaks one URL handle per saved archive.
//
// Test architecture: load `src/background/index.ts`, dispatch a
// `SAVE_ARCHIVE` message, resolve the in-flight `REQUEST_BUFFER` with
// synthetic single/multi-segment BUFFERs so `createArchive()` produces a
// real archive Blob and `downloadArchive()` runs. The single
// chrome.downloads.download call's argv0 is then introspected. The
// chrome.downloads.onChanged.addListener stub captures the listener so
// Test 3 can fire the synthetic 'complete' event back at it.
//
// remuxSegments mocking (Test 2): the production muxer rejects
// monotonicity-breaking input shapes, which makes synthetic 6 MB stress
// tests fragile. Test 2 short-circuits remuxSegments via vi.mock to
// return a synthetic 6 MB Blob directly — preserving createArchive's
// post-remux JSZip + downloadArchive code path as the SUT.
//
// Environment: vitest default `node` env. Node 24's globals expose
// URL.createObjectURL + URL.revokeObjectURL + performance, so no jsdom
// override is required (verified at land time).
//
// Skip discipline: NONE — bare `it()` blocks, no skip modifiers.
// Plan 02-01 T-02-01-01 mitigation: this file contains zero
// describe-skip or it-skip method calls.
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve as resolvePath } from 'node:path';
// ──────────────────────────────────────────────────────────────────────
// Constants
// ──────────────────────────────────────────────────────────────────────
const LATENCY_BUDGET_MS = 5_000;
const SIX_MEGABYTES = 6 * 1024 * 1024;
const here = dirname(fileURLToPath(import.meta.url));
const FIXTURE_PATH = resolvePath(
here, '..', '..', 'tests', 'fixtures', 'raw-3ebml-concat.webm',
);
// Per-EBML offsets (Plan 01-08 Task 2 byte-level probe; mirrored from
// tests/background/webm-remux.test.ts so the 3-slice segmentation is
// muxer-safe — each slice is a self-contained ~10 s WebM with its own
// EBML + Segment + Cluster tree).
const SEG1_START = 0;
const SEG2_START = 509038;
const SEG3_START = 970967;
// ──────────────────────────────────────────────────────────────────────
// Chrome stub shape (extended from save-archive-does-not-stop-recording
// pattern with chrome.downloads.onChanged listener capture).
// ──────────────────────────────────────────────────────────────────────
interface PortStub {
name: string;
sender?: { id?: string };
postMessage: ReturnType<typeof vi.fn>;
onMessage: {
addListener: ReturnType<typeof vi.fn>;
removeListener: ReturnType<typeof vi.fn>;
_listeners: Array<(msg: unknown) => void>;
};
onDisconnect: {
addListener: (fn: () => void) => void;
_listeners: Array<() => void>;
};
disconnect: ReturnType<typeof vi.fn>;
}
function makePortStub(name = 'video-keepalive'): PortStub {
const port: PortStub = {
name,
sender: { id: 'ext-id-test' },
postMessage: vi.fn(),
onMessage: {
addListener: vi.fn(),
removeListener: vi.fn(),
_listeners: [],
},
onDisconnect: {
addListener: (fn: () => void) => {
port.onDisconnect._listeners.push(fn);
},
_listeners: [],
},
disconnect: vi.fn(),
};
port.onMessage.addListener.mockImplementation(
(fn: (msg: unknown) => void) => {
port.onMessage._listeners.push(fn);
},
);
port.onMessage.removeListener.mockImplementation(
(fn: (msg: unknown) => void) => {
const idx = port.onMessage._listeners.indexOf(fn);
if (idx >= 0) {
port.onMessage._listeners.splice(idx, 1);
}
},
);
return port;
}
interface OnConnectCallback { (port: PortStub): void; }
interface OnMessageCallback {
(msg: unknown, sender: { id?: string }, sendResponse: (r: unknown) => void): boolean;
}
interface OnClickedCallback { (info?: unknown): void | Promise<void>; }
interface DownloadDelta {
id: number;
state?: { current: 'in_progress' | 'complete' | 'interrupted' };
}
interface DownloadsOnChangedCallback { (delta: DownloadDelta): void; }
interface BgChromeStub {
runtime: {
id: string;
getURL: (p: string) => string;
getManifest: () => { version: string };
sendMessage: ReturnType<typeof vi.fn>;
onMessage: { addListener: ReturnType<typeof vi.fn>; _callbacks: OnMessageCallback[] };
onConnect: { addListener: (cb: OnConnectCallback) => void; _callbacks: OnConnectCallback[] };
onInstalled: { addListener: ReturnType<typeof vi.fn> };
onStartup: { addListener: (cb: () => void) => void; _callbacks: Array<() => void> };
};
offscreen: {
hasDocument?: () => Promise<boolean>;
createDocument: ReturnType<typeof vi.fn>;
Reason: { DISPLAY_MEDIA: 'DISPLAY_MEDIA' };
};
tabs: {
query: ReturnType<typeof vi.fn>;
sendMessage: ReturnType<typeof vi.fn>;
captureVisibleTab: ReturnType<typeof vi.fn>;
};
downloads: {
download: ReturnType<typeof vi.fn>;
onChanged: {
addListener: ReturnType<typeof vi.fn>;
_callbacks: DownloadsOnChangedCallback[];
};
};
action: {
onClicked: { addListener: (cb: OnClickedCallback) => void; _callbacks: OnClickedCallback[] };
setPopup: ReturnType<typeof vi.fn>;
setBadgeText: ReturnType<typeof vi.fn>;
setBadgeBackgroundColor: ReturnType<typeof vi.fn>;
setTitle: ReturnType<typeof vi.fn>;
};
notifications: {
create: ReturnType<typeof vi.fn>;
clear: ReturnType<typeof vi.fn>;
onClicked: {
addListener: ReturnType<typeof vi.fn>;
_callbacks: Array<(id: string) => void>;
};
};
}
interface GlobalWithBgChrome {
chrome?: BgChromeStub;
indexedDB?: { deleteDatabase: ReturnType<typeof vi.fn> };
fetch?: typeof fetch;
}
function buildBgStub(): BgChromeStub {
const stub: BgChromeStub = {
runtime: {
id: 'ext-id-test',
getURL: (p) => `chrome-extension://ext-id-test/${p}`,
getManifest: () => ({ version: '1.0.0' }),
sendMessage: vi.fn().mockResolvedValue(undefined),
onMessage: { addListener: vi.fn(), _callbacks: [] },
onConnect: {
addListener: (cb) => stub.runtime.onConnect._callbacks.push(cb),
_callbacks: [],
},
onInstalled: { addListener: vi.fn() },
onStartup: {
addListener: (cb) => stub.runtime.onStartup._callbacks.push(cb),
_callbacks: [],
},
},
offscreen: {
hasDocument: async () => false,
createDocument: vi.fn().mockResolvedValue(undefined),
Reason: { DISPLAY_MEDIA: 'DISPLAY_MEDIA' },
},
tabs: {
query: vi.fn().mockResolvedValue([
{ id: 1, url: 'https://example.com', windowId: 100 },
]),
sendMessage: vi.fn().mockResolvedValue({ events: [], userEvents: [] }),
captureVisibleTab: vi.fn().mockResolvedValue(
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==',
),
},
downloads: {
download: vi.fn().mockResolvedValue(42),
onChanged: {
addListener: vi.fn(),
_callbacks: [],
},
},
action: {
onClicked: {
addListener: (cb) => stub.action.onClicked._callbacks.push(cb),
_callbacks: [],
},
setPopup: vi.fn(),
setBadgeText: vi.fn(),
setBadgeBackgroundColor: vi.fn(),
setTitle: vi.fn(),
},
notifications: {
create: vi.fn(),
clear: vi.fn(),
onClicked: {
addListener: vi.fn(),
_callbacks: [],
},
},
};
stub.runtime.onMessage.addListener.mockImplementation((cb: OnMessageCallback) => {
stub.runtime.onMessage._callbacks.push(cb);
});
stub.downloads.onChanged.addListener.mockImplementation(
(cb: DownloadsOnChangedCallback) => {
stub.downloads.onChanged._callbacks.push(cb);
},
);
stub.notifications.onClicked.addListener.mockImplementation(
(cb: (id: string) => void) => {
stub.notifications.onClicked._callbacks.push(cb);
},
);
return stub;
}
async function stubFetch(_url: string): Promise<Response> {
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
return {
blob: async () => new Blob([png], { type: 'image/png' }),
} as unknown as Response;
}
// ──────────────────────────────────────────────────────────────────────
// Minimal FileReader polyfill — JSZip's Blob ingestion path calls
// `new FileReader().readAsArrayBuffer(blob)` to materialise the bytes.
// Node 24 ships native Blob (so JSZip's `support.blob` returns true) but
// does NOT ship FileReader, so the ingestion throws "Can't read the
// data of '<entry>'". The polyfill below delegates to Blob.arrayBuffer()
// (which IS available in Node 24) and dispatches onload synchronously
// on the microtask tick.
//
// This polyfill is scoped to the test process (set on globalThis); it
// does not leak into production builds because tests run under vitest
// in a separate Node process.
// ──────────────────────────────────────────────────────────────────────
class FileReaderPolyfill {
result: ArrayBuffer | string | null = null;
onload: ((ev: { target: FileReaderPolyfill }) => void) | null = null;
onerror: ((ev: { target: FileReaderPolyfill; error: unknown }) => void) | null = null;
readyState = 0;
readAsArrayBuffer(blob: Blob): void {
blob.arrayBuffer()
.then((ab) => {
this.result = ab;
this.readyState = 2;
if (this.onload) this.onload({ target: this });
})
.catch((err) => {
this.readyState = 2;
if (this.onerror) this.onerror({ target: this, error: err });
});
}
}
function installFileReaderPolyfill(): void {
if (typeof (globalThis as { FileReader?: unknown }).FileReader === 'undefined') {
(globalThis as { FileReader?: unknown }).FileReader = FileReaderPolyfill;
}
}
// ──────────────────────────────────────────────────────────────────────
// Three-slice fixture segments (each is a self-contained ~10 s WebM —
// muxer-safe). Mirrors splitFixtureIntoSegments() in webm-remux.test.ts.
// ──────────────────────────────────────────────────────────────────────
function buildThreeSliceSegmentsBase64(): Array<{ data: string; type: string; timestamp: number }> {
const buf = readFileSync(FIXTURE_PATH);
const slices = [
buf.subarray(SEG1_START, SEG2_START),
buf.subarray(SEG2_START, SEG3_START),
buf.subarray(SEG3_START, buf.length),
];
return slices.map((slice, i) => ({
data: Buffer.from(slice).toString('base64'),
type: 'video/webm;codecs=vp9',
timestamp: 1_000_000 + i * 10_000,
}));
}
/**
* Drive a SAVE_ARCHIVE flow to completion. Resolves the in-flight
* REQUEST_BUFFER with the three-slice fixture segments.
*
* @param stub - Wired chrome stub.
* @param port - Connected offscreen port.
* @returns The chrome.downloads.download arg0 payload, or undefined if
* no call landed within the drain window.
*/
async function runSaveAndCaptureDownloadArg(
stub: BgChromeStub,
port: PortStub,
): Promise<{ url: string; filename: string; saveAs?: boolean } | undefined> {
const segments = buildThreeSliceSegmentsBase64();
let buffered = false;
const tryFireBuffer = () => {
if (buffered) return;
const reqCalls = port.postMessage.mock.calls.filter(
(c: unknown[]) =>
typeof c[0] === 'object' &&
c[0] !== null &&
(c[0] as { type?: unknown }).type === 'REQUEST_BUFFER',
);
if (reqCalls.length === 0) return;
const reqMsg = reqCalls[0][0] as { requestId?: string };
buffered = true;
port.onMessage._listeners.forEach((fn) =>
fn({
type: 'BUFFER',
requestId: reqMsg.requestId,
segments,
}),
);
};
const onMsg = stub.runtime.onMessage._callbacks[0];
onMsg(
{ type: 'SAVE_ARCHIVE' },
{ id: 'ext-id-test' },
() => undefined,
);
const DRAIN_ITERATIONS = 1_500;
for (let i = 0; i < DRAIN_ITERATIONS; i++) {
tryFireBuffer();
if (stub.downloads.download.mock.calls.length > 0) break;
await new Promise<void>((resolve) => setTimeout(resolve, 5));
}
const calls = stub.downloads.download.mock.calls;
if (calls.length === 0) return undefined;
return calls[0][0] as { url: string; filename: string; saveAs?: boolean };
}
/**
* Load SW module, wire stubs, connect the offscreen port, and put the SW
* into REC state via toolbar onClicked.
*/
async function bootSwInRecState(stub: BgChromeStub): Promise<PortStub> {
installFileReaderPolyfill();
(globalThis as unknown as GlobalWithBgChrome).chrome = stub;
(globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn() };
(globalThis as unknown as GlobalWithBgChrome).fetch = stubFetch as unknown as typeof fetch;
await import('../../src/background/index');
for (let i = 0; i < 8; i++) await Promise.resolve();
const port = makePortStub();
if (stub.runtime.onConnect._callbacks.length > 0) {
stub.runtime.onConnect._callbacks[0](port);
}
const onMsg = stub.runtime.onMessage._callbacks[0];
onMsg({ type: 'OFFSCREEN_READY' }, { id: 'ext-id-test' }, () => undefined);
for (let i = 0; i < 16; i++) await Promise.resolve();
const onClicked = stub.action.onClicked._callbacks[0];
await onClicked();
for (let i = 0; i < 32; i++) await Promise.resolve();
return port;
}
// ──────────────────────────────────────────────────────────────────────
// Tests
// ──────────────────────────────────────────────────────────────────────
describe('Plan 02-01 Task 1 RED: downloadArchive uses blob: URL (D-P2-01)', () => {
beforeEach(() => {
vi.resetModules();
vi.restoreAllMocks();
delete (globalThis as unknown as GlobalWithBgChrome).chrome;
});
// ────────────────────────────────────────────────────────────────────
// Test 1 — WIRE-FORMAT pin. The single chrome.downloads.download call
// MUST carry a `url` starting with `blob:`. RED today because
// downloadArchive (src/background/index.ts:695-718) still mints
// `data:application/zip;base64,${base64}` via blobToBase64 +
// chrome.downloads.download. GREEN after Plan 02-02 swaps that for an
// offscreen-minted Blob URL.
// ────────────────────────────────────────────────────────────────────
it('calls chrome.downloads.download with a blob: URL and NOT a data:application/zip;base64, URL', async () => {
const stub = buildBgStub();
const port = await bootSwInRecState(stub);
const downloadArg = await runSaveAndCaptureDownloadArg(stub, port);
expect(
downloadArg,
'chrome.downloads.download was not called within the drain window. ' +
'Either SAVE_ARCHIVE never reached downloadArchive (BUFFER routing broken) or ' +
'the SW module structure changed — investigate before assuming this is the RED signal for D-P2-01.',
).toBeDefined();
const url = downloadArg!.url;
expect(
url.startsWith('data:application/zip;base64,'),
`chrome.downloads.download was called with url='${url.substring(0, 60)}...' ` +
`but D-P2-01 forbids data:application/zip;base64, prefix (Chrome's ~2 MB cap ` +
`weaponises P0-6). This polarity guard makes the RED→GREEN flip explicit.`,
).toBe(false);
expect(
url.startsWith('blob:'),
`chrome.downloads.download was called with url='${url.substring(0, 60)}...' ` +
`but D-P2-01 requires url.startsWith('blob:'). RED today because downloadArchive ` +
`still mints data:application/zip;base64,... — flips GREEN after Plan 02-02 ` +
`swaps to offscreen-minted Blob URL.`,
).toBe(true);
});
// ────────────────────────────────────────────────────────────────────
// Test 2 — LATENCY + WIRE-FORMAT at the 6 MB scale.
//
// The synthetic 6 MB archive is injected by mocking remuxSegments
// before the SW module loads. createArchive then assembles a zip
// containing one ~6 MB "video/last_30sec.webm" entry plus meta.json
// + rrweb + events + screenshot — the zip exceeds 6 MB. Under
// Chrome's real ~2 MB data-URL cap this would reject; the vi.fn()
// stub can't model rejection, so the test's load-bearing assertion is
// the wire-format prefix at 6 MB scale (latency check captures the
// base64-encoding regression vector but is necessary-not-sufficient).
//
// RED today (data: URL prefix + variable latency under current path).
// GREEN after Plan 02-02 (blob: URL prefix; no encoding bottleneck).
// ────────────────────────────────────────────────────────────────────
it('completes downloadArchive within 5000ms for a 6 MB archive AND emits a blob: URL', { timeout: 30_000 }, async () => {
// Pre-import: mock remuxSegments to short-circuit the muxer and
// return a synthetic 6 MB Blob directly. createArchive's downstream
// JSZip pack-up code + downloadArchive then run unchanged.
vi.doMock('../../src/background/webm-remux', () => ({
remuxSegments: vi.fn(async (_segments: unknown[]) => {
const sixMb = new Uint8Array(SIX_MEGABYTES);
return new Blob([sixMb], { type: 'video/webm' });
}),
}));
const stub = buildBgStub();
const port = await bootSwInRecState(stub);
// Any non-empty segments array passes the createArchive empty-buffer
// guard; the mocked remuxSegments ignores the input shape. The data
// payload is a 4-byte dummy ("AAAA" base64-decoded) so the SW-side
// filter in getVideoBufferFromOffscreen (which drops zero-size
// segments per src/shared/binary.ts WR-07 fix) keeps the segment in
// the array.
const segments = [{
data: 'AAAA',
type: 'video/webm;codecs=vp9',
timestamp: 1_000_000,
}];
const onMsg = stub.runtime.onMessage._callbacks[0];
onMsg(
{ type: 'SAVE_ARCHIVE' },
{ id: 'ext-id-test' },
() => undefined,
);
let buffered = false;
const t0 = performance.now();
const DRAIN_ITERATIONS = 1_500;
for (let i = 0; i < DRAIN_ITERATIONS; i++) {
if (!buffered) {
const reqCalls = port.postMessage.mock.calls.filter(
(c: unknown[]) =>
typeof c[0] === 'object' &&
c[0] !== null &&
(c[0] as { type?: unknown }).type === 'REQUEST_BUFFER',
);
if (reqCalls.length > 0) {
const reqMsg = reqCalls[0][0] as { requestId?: string };
buffered = true;
port.onMessage._listeners.forEach((fn) =>
fn({
type: 'BUFFER',
requestId: reqMsg.requestId,
segments,
}),
);
}
}
if (stub.downloads.download.mock.calls.length > 0) break;
await new Promise<void>((resolve) => setTimeout(resolve, 5));
}
const elapsed = performance.now() - t0;
expect(
stub.downloads.download.mock.calls.length,
`chrome.downloads.download was never called for the 6 MB archive after ${elapsed.toFixed(0)} ms ` +
`drain. Valid RED mode under current base64 path — the EmptyVideoBufferError throw or ` +
`the base64 encoding bottleneck can both gate the call.`,
).toBeGreaterThan(0);
expect(
elapsed,
`downloadArchive end-to-end took ${elapsed.toFixed(0)} ms for a 6 MB payload. ` +
`REQ-archive-export-latency floor is ${LATENCY_BUDGET_MS} ms. Above floor = ` +
`base64 encoding bottleneck (latency RED signal); below floor + wrong wire format ` +
`= polarity-guard RED signal.`,
).toBeLessThan(LATENCY_BUDGET_MS);
const downloadArg = stub.downloads.download.mock.calls[0][0] as { url: string };
expect(
downloadArg.url.startsWith('blob:'),
`chrome.downloads.download was called with url='${downloadArg.url.substring(0, 60)}...' ` +
`at the 6 MB scale — D-P2-01 requires blob: prefix. The wire-format pin holds ` +
`regardless of vi.fn() rejection modeling: current HEAD's data:application/zip;base64,... ` +
`path fails this assertion under any payload size.`,
).toBe(true);
vi.doUnmock('../../src/background/webm-remux');
});
// ────────────────────────────────────────────────────────────────────
// Test 3 — REVOKE LIFECYCLE. URL.revokeObjectURL is scheduled for the
// same url passed to chrome.downloads.download once
// chrome.downloads.onChanged fires with state.current === 'complete'.
//
// RED today: the current downloadArchive does NOT mint a Blob URL at
// all AND does NOT register an onChanged listener, so revokeObjectURL
// is never called regardless of whether the synthetic 'complete'
// event fires. Plan 02-02 wires both the mint AND the revoke-on-
// complete listener.
// ────────────────────────────────────────────────────────────────────
it('schedules URL.revokeObjectURL with the minted url after chrome.downloads.onChanged complete', async () => {
const stub = buildBgStub();
const port = await bootSwInRecState(stub);
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
try {
const downloadArg = await runSaveAndCaptureDownloadArg(stub, port);
expect(
downloadArg,
'chrome.downloads.download never called — cannot probe revoke lifecycle.',
).toBeDefined();
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.
stub.downloads.onChanged._callbacks.forEach((cb) =>
cb({ id: downloadId, state: { current: 'complete' } }),
);
for (let i = 0; i < 32; i++) await Promise.resolve();
await new Promise<void>((resolve) => setTimeout(resolve, 10));
expect(
revokeSpy.mock.calls.length,
`URL.revokeObjectURL was never called after chrome.downloads.onChanged 'complete' fired. ` +
`D-P2-01 lifecycle: SW (or offscreen) MUST revoke the minted blob: URL once the ` +
`download finishes to avoid handle leaks. RED expected because the current SW does ` +
`not register an onChanged listener (chrome.downloads.onChanged._callbacks.length === ` +
`${stub.downloads.onChanged._callbacks.length} at probe time).`,
).toBeGreaterThan(0);
expect(
revokeSpy.mock.calls.some(
(args: unknown[]) => args[0] === mintedUrl,
),
`URL.revokeObjectURL was called but with the wrong URL. Expected: '${mintedUrl}'. ` +
`Got: ${JSON.stringify(revokeSpy.mock.calls.map((c: unknown[]) => c[0]))}. ` +
`The mint/revoke pair must be id-coupled — revoking a different URL leaks ` +
`the current one and double-revokes a stale one.`,
).toBe(true);
} finally {
revokeSpy.mockRestore();
}
});
});