Milestone v1 (v2.0.0): Mokosh — Session Capture #1
625
tests/background/blob-url-download.test.ts
Normal file
625
tests/background/blob-url-download.test.ts
Normal file
@@ -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<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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user