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>
626 lines
26 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|