// 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; onMessage: { addListener: ReturnType; removeListener: ReturnType; _listeners: Array<(msg: unknown) => void>; }; onDisconnect: { addListener: (fn: () => void) => void; _listeners: Array<() => void>; }; disconnect: ReturnType; } 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; } 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; onMessage: { addListener: ReturnType; _callbacks: OnMessageCallback[] }; onConnect: { addListener: (cb: OnConnectCallback) => void; _callbacks: OnConnectCallback[] }; onInstalled: { addListener: ReturnType }; onStartup: { addListener: (cb: () => void) => void; _callbacks: Array<() => void> }; }; offscreen: { hasDocument?: () => Promise; createDocument: ReturnType; Reason: { DISPLAY_MEDIA: 'DISPLAY_MEDIA' }; }; tabs: { query: ReturnType; sendMessage: ReturnType; captureVisibleTab: ReturnType; }; downloads: { download: ReturnType; onChanged: { addListener: ReturnType; _callbacks: DownloadsOnChangedCallback[]; }; }; action: { onClicked: { addListener: (cb: OnClickedCallback) => void; _callbacks: OnClickedCallback[] }; setPopup: ReturnType; setBadgeText: ReturnType; setBadgeBackgroundColor: ReturnType; setTitle: ReturnType; }; notifications: { create: ReturnType; clear: ReturnType; onClicked: { addListener: ReturnType; _callbacks: Array<(id: string) => void>; }; }; } interface GlobalWithBgChrome { chrome?: BgChromeStub; indexedDB?: { deleteDatabase: ReturnType }; 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 { 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 ''". 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((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 { 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((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((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(); } }); });