Files
mokosh/tests/background/blob-url-download.test.ts
Mark 79964e62d2 feat(02-02): SW — downloadArchive via offscreen-minted Blob URL + revoke lifecycle (D-P2-01 closes P0-6)
Production changes (src/background/index.ts):
- pendingDownloadUrlResolvers Map<requestId, resolver> routes DOWNLOAD_URL
  responses back to the in-flight downloadArchive Promise; mirrors the
  pendingBufferRequests pattern from the BUFFER round-trip so port
  replacement mid-mint does not lose the response.
- pendingRevokes Map<downloadId, url> tracks (downloadId → minted blob:URL)
  for the chrome.downloads.onChanged revoke dispatch.
- onConnect port message sink extended with DOWNLOAD_URL routing branch
  (alongside existing PING/BUFFER routing).
- downloadArchive rewritten: encode archive via blobToBase64 → post
  CREATE_DOWNLOAD_URL on videoPort → await DOWNLOAD_URL response (race
  against 5s BLOB_URL_MINT_TIMEOUT_MS) → reject empty / non-blob: URLs
  (T-02-02-03 mitigation) → call chrome.downloads.download → register
  (downloadId, url) in pendingRevokes. NO data:URL fallback — typed
  errors route through saveArchive's catch to RECORDING_ERROR.
- chrome.downloads.onChanged listener registered at module init:
  on terminal state ('complete' / 'interrupted'), posts REVOKE_DOWNLOAD_URL
  to videoPort and clears the pendingRevokes entry.

Deviation (Rule 3 — auto-fix blocking issue):
- Plan 02-01's test helpers in blob-url-download.test.ts +
  meta-json-urls-schema.test.ts + strict-meta-json-validation.test.ts
  modeled only the REQUEST_BUFFER → BUFFER round-trip, not the new
  CREATE_DOWNLOAD_URL → DOWNLOAD_URL round-trip Plan 02-02 introduces.
  Without the test-side mint simulation, the SW's downloadArchive
  times out at the offscreen mint step → chrome.downloads.download
  never called → ALL existing meta.json tests timeout.
- Each helper extended with a tryFireDownloadUrl block that decodes
  the CREATE_DOWNLOAD_URL.dataBase64, mints a Node-native blob:URL via
  URL.createObjectURL, captures the archive bytes for downstream
  JSZip extraction (capturedArchiveBytes), and replies DOWNLOAD_URL.
  Test 3 (revoke lifecycle) additionally shims port.postMessage to
  call URL.revokeObjectURL on receipt of REVOKE_DOWNLOAD_URL — the
  test-side equivalent of src/offscreen/recorder.ts handleCreateDownloadUrl.
- Pre-existing Plan-02-02-era TODO comments in both test files
  explicitly anticipated this extension ("Plan 02-03 implementer will
  likely need a different helper, e.g. spy on URL.createObjectURL").

Verification (full §verification block from plan):
- npx tsc --noEmit: clean
- npm run build: clean
- npx vitest run tests/background/blob-url-download.test.ts: 3/3 GREEN (was 3 RED)
- npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts: 13/13 GREEN
- npm test full suite: 163 passed / 8 failed (was 159 passed / 12 failed);
  net delta +4 GREEN = 3 RED→GREEN flips + 1 ffprobe-flaky pass. 8 remaining
  RED are exactly the Plan 02-03 territory (5 meta-json-urls-schema + 3
  strict-meta-json-validation RED tests).
- grep -c "data:application/zip;base64," src/background/index.ts: 0 (gone)
- grep -c "blob:" src/background/index.ts: 8 (new pipeline)
- grep -c "chrome.downloads.onChanged" src/background/index.ts: 5 (listener wired)
- dist/ post-build: 0 "data:application/zip;base64," matches; 1 file with
  "chrome.downloads.onChanged" (the SW chunk).
2026-05-20 15:54:28 +02:00

731 lines
30 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,
}),
);
};
// Plan 02-02 (D-P2-01): simulate the offscreen-side CREATE_DOWNLOAD_URL
// handler. The SW posts CREATE_DOWNLOAD_URL with the archive bytes as
// base64; the real offscreen mints a blob:URL via URL.createObjectURL
// (src/offscreen/recorder.ts:handleCreateDownloadUrl). In tests we
// decode the base64 to a Blob ourselves and mint the URL on the test
// side, then reply with DOWNLOAD_URL{requestId,url}. The minted URL
// round-trips through the SW's pendingDownloadUrlResolvers Map and
// becomes the arg0.url passed to chrome.downloads.download. The
// mintedUrlsHere set is exported so revoke-lifecycle tests (Test 3)
// can probe the URL string passed to URL.revokeObjectURL.
const mintedUrlsHere = new Set<string>();
const tryFireDownloadUrl = () => {
const mintCalls = port.postMessage.mock.calls.filter(
(c: unknown[]) =>
typeof c[0] === 'object' &&
c[0] !== null &&
(c[0] as { type?: unknown }).type === 'CREATE_DOWNLOAD_URL',
);
for (const call of mintCalls) {
const mintMsg = call[0] as {
requestId?: string;
dataBase64?: string;
mimeType?: string;
};
const requestId = mintMsg.requestId;
if (typeof requestId !== 'string' || requestId.length === 0) continue;
if (mintedUrlsHere.has(requestId)) continue;
mintedUrlsHere.add(requestId);
const dataBase64 = mintMsg.dataBase64 ?? '';
const mimeType = mintMsg.mimeType ?? 'application/zip';
let url = '';
try {
// Node 24 ships URL.createObjectURL + Blob as globals. The
// resulting URL has the `blob:nodedata:<uuid>` shape, which
// satisfies the `url.startsWith('blob:')` polarity guard.
const binary = atob(dataBase64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
const blob = new Blob([bytes], { type: mimeType });
url = URL.createObjectURL(blob);
} catch {
url = '';
}
port.onMessage._listeners.forEach((fn) =>
fn({ type: 'DOWNLOAD_URL', requestId, url }),
);
}
};
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();
tryFireDownloadUrl();
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;
// Plan 02-02 (D-P2-01): mirror runSaveAndCaptureDownloadArg's
// offscreen-side CREATE_DOWNLOAD_URL handler so the SW's
// downloadArchive can complete the bridge round-trip. The 6 MB
// archive's base64 payload is sizeable (~8 MB after encoding); the
// local URL.createObjectURL mint is still essentially free.
const mintedRequestIds = new Set<string>();
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,
}),
);
}
}
const mintCalls = port.postMessage.mock.calls.filter(
(c: unknown[]) =>
typeof c[0] === 'object' &&
c[0] !== null &&
(c[0] as { type?: unknown }).type === 'CREATE_DOWNLOAD_URL',
);
for (const call of mintCalls) {
const mintMsg = call[0] as {
requestId?: string;
dataBase64?: string;
mimeType?: string;
};
const requestId = mintMsg.requestId;
if (typeof requestId !== 'string' || requestId.length === 0) continue;
if (mintedRequestIds.has(requestId)) continue;
mintedRequestIds.add(requestId);
let url = '';
try {
const binary = atob(mintMsg.dataBase64 ?? '');
const bytes = new Uint8Array(binary.length);
for (let k = 0; k < binary.length; k++) bytes[k] = binary.charCodeAt(k);
const blob = new Blob([bytes], { type: mintMsg.mimeType ?? 'application/zip' });
url = URL.createObjectURL(blob);
} catch {
url = '';
}
port.onMessage._listeners.forEach((fn) =>
fn({ type: 'DOWNLOAD_URL', requestId, url }),
);
}
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(() => {});
// Plan 02-02 (D-P2-01): the real offscreen handler in
// src/offscreen/recorder.ts calls URL.revokeObjectURL on receipt of
// REVOKE_DOWNLOAD_URL. The test stub does not load recorder.ts, so
// we route the SW-posted REVOKE_DOWNLOAD_URL through revokeSpy here
// to model the offscreen-side behaviour faithfully.
port.postMessage.mockImplementation((msg: unknown) => {
if (
typeof msg === 'object' &&
msg !== null &&
(msg as { type?: unknown }).type === 'REVOKE_DOWNLOAD_URL'
) {
const url = (msg as { url?: unknown }).url;
if (typeof url === 'string' && url.length > 0) {
URL.revokeObjectURL(url);
}
}
});
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;
// GREEN under Plan 02-02: downloadArchive registers a chrome.downloads
// .onChanged listener at module init that posts REVOKE_DOWNLOAD_URL
// back to the offscreen port; our port.postMessage shim above mirrors
// that into revokeSpy.
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();
}
});
});