diff --git a/tests/background/blob-url-download.test.ts b/tests/background/blob-url-download.test.ts new file mode 100644 index 0000000..171b4ff --- /dev/null +++ b/tests/background/blob-url-download.test.ts @@ -0,0 +1,625 @@ +// 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(); + } + }); +});