Files
mokosh/tests/offscreen/port-serialization.test.ts

237 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// tests/offscreen/port-serialization.test.ts
//
// RED-gate test for debug session d12-blob-port-transfer-fails.
//
// Empirically proves the failure mode behind the 75-byte "[object Object]"
// WebM payload observed in /home/parf/Downloads/session_report_2026-05-15_19-42-01.zip.
//
// Hypothesis under test (per .planning/debug/d12-blob-port-transfer-fails.md):
// 1. chrome.runtime.Port.postMessage serializes payloads as JSON across
// extension contexts (offscreen ↔ SW). JSON.stringify(blob) === "{}"
// because Blob has no enumerable own properties.
// 2. The current REQUEST_BUFFER handler in src/offscreen/recorder.ts:174-178
// sends `{ type: 'BUFFER', chunks: getBuffer() }` where each chunk has
// `.data: Blob`. After the JSON round-trip the SW receives chunks with
// `.data === {}`.
// 3. mergeVideoChunks in src/background/index.ts:204-222 then calls
// `new Blob([{}, {}, ...])`. The Blob constructor coerces each non-Blob
// member via String(member) → "[object Object]", concatenated with no
// separator → exactly 75 bytes for 5 chunks (5 × 15 = 75).
//
// Goal: make these tests RED first (proving the bug is real), then write the
// fix (Blob → base64 string transfer-format) to flip them GREEN.
import { describe, it, expect } from 'vitest';
// Simulates Chrome's documented chrome.runtime.Port serialization across
// extension contexts: JSON round-trip with no structured-clone fallback.
// Ref: https://developer.chrome.com/docs/extensions/develop/concepts/messaging
function chromeRuntimePortRoundTrip<T>(message: T): unknown {
return JSON.parse(JSON.stringify(message));
}
describe('port serialization (RED — confirms d12 bug)', () => {
it('JSON.stringify(blob) drops binary content (root cause part 1)', () => {
const blob = new Blob([new Uint8Array([0x1a, 0x45, 0xdf, 0xa3])], {
type: 'video/webm',
});
// EBML magic = 0x1A 0x45 0xDF 0xA3 — the bytes the real WebM should start with.
expect(blob.size).toBe(4);
const serialized = JSON.stringify(blob);
// The smoking gun: Blob has no enumerable own properties, so JSON drops it.
expect(serialized).toBe('{}');
});
it('Port round-trip strips Blob from chunks (reproduces the SW receive-side)', () => {
const blob = new Blob([new Uint8Array([0x1a, 0x45, 0xdf, 0xa3])], {
type: 'video/webm',
});
const portMessage = {
type: 'BUFFER',
chunks: [
{ data: blob, timestamp: 1, isFirst: true },
{ data: blob, timestamp: 2 },
],
};
const received = chromeRuntimePortRoundTrip(portMessage) as {
type: string;
chunks: Array<{ data: unknown; timestamp: number; isFirst?: boolean }>;
};
expect(received.type).toBe('BUFFER');
expect(received.chunks).toHaveLength(2);
// The bug: data is no longer a Blob — it's a plain empty object.
expect(received.chunks[0].data).not.toBeInstanceOf(Blob);
expect(received.chunks[0].data).toEqual({});
expect(received.chunks[1].data).toEqual({});
// Metadata survives because timestamp/isFirst are JSON-friendly.
expect(received.chunks[0].timestamp).toBe(1);
expect(received.chunks[0].isFirst).toBe(true);
});
it('new Blob([{}, {}, ...]) coerces each member to "[object Object]" (root cause part 2)', () => {
// This is the smoking gun for the merge step. Once the SW gets {} for each
// chunk.data, mergeVideoChunks does `new Blob(blobs, { type: 'video/webm' })`
// and the Blob ctor stringifies non-Blob members.
const merged = new Blob([{}, {}, {}, {}, {}], { type: 'video/webm' });
expect(merged.size).toBe(75); // 5 × 15 = 75 — matches the observed payload!
return merged.text().then((text) => {
expect(text).toBe('[object Object][object Object][object Object][object Object][object Object]');
});
});
it('end-to-end: simulate full failure path — Blob → port → merge → 75-byte garbage', async () => {
// Build a fake recorder buffer of 5 real WebM-ish blobs.
const realChunks = [0x1a, 0x45, 0xdf, 0xa3, 0xa3].map((firstByte, i) => ({
data: new Blob([new Uint8Array([firstByte, 0x00, 0x00, 0x00])], {
type: 'video/webm',
}),
timestamp: 1000 + i,
isFirst: i === 0,
}));
// Send through the simulated port.
const message = { type: 'BUFFER', chunks: realChunks };
const received = chromeRuntimePortRoundTrip(message) as {
chunks: Array<{ data: unknown; timestamp: number }>;
};
// SW-side: mergeVideoChunks does this exact operation.
const blobs = received.chunks
.sort((a, b) => a.timestamp - b.timestamp)
.map((c) => c.data as Blob);
const merged = new Blob(blobs, { type: 'video/webm' });
// The observed failure: 75 bytes of "[object Object]" repeated.
expect(merged.size).toBe(75);
const text = await merged.text();
expect(text).toBe(
'[object Object][object Object][object Object][object Object][object Object]'
);
// The original 5 × 4 = 20 bytes of WebM data are GONE.
expect(merged.size).not.toBe(20);
});
});
describe('port serialization (GREEN — pins the eventual fix contract)', () => {
// These tests are forward-pinning: they describe the wire-format the fix
// MUST implement. They will run today and PASS — they test pure helpers, not
// the (still-buggy) recorder.ts handler.
//
// The fix must:
// 1. Define a TransferredVideoChunk wire-format that is JSON-friendly:
// { dataBase64: string; type: string; timestamp: number; isFirst?: boolean }
// 2. Convert Blob → base64 in offscreen before postMessage.
// 3. Convert base64 → Blob in SW after receive.
//
// Until the fix lands, these helpers don't exist in src/. These tests live
// here as a contract the fix's reviewer can check against.
async function blobToBase64(blob: Blob): Promise<string> {
const buf = await blob.arrayBuffer();
const bytes = new Uint8Array(buf);
// Node + browser both support Buffer / btoa. Use a portable conversion.
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
// btoa exists in vitest's node env via globalThis polyfill in modern Node.
return typeof btoa === 'function'
? btoa(binary)
: Buffer.from(binary, 'binary').toString('base64');
}
function base64ToBlob(b64: string, type: string): Blob {
const binary =
typeof atob === 'function'
? atob(b64)
: Buffer.from(b64, 'base64').toString('binary');
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new Blob([bytes], { type });
}
it('base64 round-trip preserves binary content across JSON serialization', async () => {
const original = new Blob([new Uint8Array([0x1a, 0x45, 0xdf, 0xa3])], {
type: 'video/webm',
});
const b64 = await blobToBase64(original);
expect(b64).toBe('GkXfow=='); // base64 of EBML magic
// JSON round-trip preserves the string.
const wire = { dataBase64: b64, type: 'video/webm' };
const received = JSON.parse(JSON.stringify(wire)) as {
dataBase64: string;
type: string;
};
const restored = base64ToBlob(received.dataBase64, received.type);
expect(restored.size).toBe(4);
const text = await restored.text();
// EBML magic ≈ "Eߣ" — bytes preserved.
expect(new Uint8Array(await restored.arrayBuffer())).toEqual(
new Uint8Array([0x1a, 0x45, 0xdf, 0xa3])
);
});
it('merging base64-decoded chunks produces a real WebM-prefixed blob', async () => {
const original = new Blob([new Uint8Array([0x1a, 0x45, 0xdf, 0xa3])], {
type: 'video/webm',
});
const b64 = await blobToBase64(original);
const wireChunks = [
{ dataBase64: b64, type: 'video/webm', timestamp: 1, isFirst: true },
];
const received = JSON.parse(JSON.stringify({ chunks: wireChunks })) as {
chunks: Array<{ dataBase64: string; type: string; timestamp: number }>;
};
const blobs = received.chunks.map((c) => base64ToBlob(c.dataBase64, c.type));
const merged = new Blob(blobs, { type: 'video/webm' });
expect(merged.size).toBe(4);
expect(merged.size).not.toBe(75); // the bug is gone in the fixed form.
});
});
describe('binary.ts adversarial input (WR-07 + sweep target #6)', () => {
// These tests exercise the PRODUCTION helpers in src/shared/binary.ts,
// not local re-implementations. They pin the WR-07 + sweep-#6
// defensive contracts:
// 1. Empty-string input → zero-byte Blob (early-return shortcut,
// no atob('') round-trip).
// 2. Invalid base64 input → throws DOMException-like InvalidCharacterError,
// caller is expected to wrap in try/catch.
//
// The empty-segment filter in SW's getVideoBufferFromOffscreen relies
// on (1) so the multi-EBML-header concat output never includes a stray
// zero-byte segment. The error-classification step relies on (2) being
// a regular throw so the per-segment try/catch can drop a single bad
// entry without aborting the whole transfer.
it('base64ToBlob("") returns a zero-byte Blob with the requested MIME', async () => {
const { base64ToBlob } = await import('../../src/shared/binary');
const blob = base64ToBlob('', 'video/webm');
expect(blob).toBeInstanceOf(Blob);
expect(blob.size).toBe(0);
expect(blob.type).toBe('video/webm');
});
it('blobToBase64(emptyBlob) round-trips through base64ToBlob to zero bytes', async () => {
const { blobToBase64, base64ToBlob } = await import('../../src/shared/binary');
const original = new Blob([], { type: 'video/webm' });
const b64 = await blobToBase64(original);
expect(b64).toBe('');
const restored = base64ToBlob(b64, 'video/webm');
expect(restored.size).toBe(0);
});
it('base64ToBlob throws on invalid base64 alphabet (caller wraps with try/catch)', async () => {
const { base64ToBlob } = await import('../../src/shared/binary');
// Characters outside the base64 alphabet — atob throws InvalidCharacterError
// synchronously. The SW caller (getVideoBufferFromOffscreen) has a
// try/catch that drops a single bad segment without aborting the batch.
expect(() => base64ToBlob('!!!not-base64!!!', 'video/webm')).toThrow();
});
});