// tests/background/webm-remux.test.ts // // Plan 01-08 Task 2: RED unit-level contract tests for the new // `remuxSegments()` helper that will live in // `src/background/webm-remux.ts` (created in Task 3). // // Pins the five invariants that the remux pipeline must honor in order // for SPEC §10 #7 (`last_30sec.webm plays back in a browser`) to hold: // // 1. Single-EBML-header output. The bytes returned by remuxSegments // contain EXACTLY ONE `1A 45 DF A3` (EBML) and EXACTLY ONE // `18 53 80 67` (Segment). That's the defining structural // property the D-14-remux amendment commits to. // // 2. Size sanity. The output blob's byte count lives within // [0.7×, 1.3×] of the summed input sizes — guards against // silently dropping content OR ballooning it. // // 3. ffprobe-reported `format=duration` >= 25 s. Mirrors the gate the // operator-facing test in tests/offscreen/webm-playback.test.ts // enforces but at the unit-helper level. // // 4. ffprobe `-count_frames` reports `[905, 912]` frames inclusive // (the per-segment sum is 301+300+311=912 frames per the d13 // byte-level probe; the muxer may legitimately drop at most one // partial frame per segment boundary → 3 boundaries × 1 frame = // ±3 absolute frames tolerance). I-01 fix during plan checker // pass: tightened from the prior ±20% band to ±1% / ±3 frames. // // 5. Empty input ⇒ empty Blob. `remuxSegments([])` returns a // `Promise` whose `.size === 0`. Defense-in-depth — the // saveArchive EmptyVideoBufferError throw guards this path // upstream, but the helper must be safe on its own. // // The fixture used here is `tests/fixtures/last_30sec.webm` sliced at // the byte offsets the d13 debug session documented (0 / 509038 / // 970967 — verified empirically against the committed fixture at the // top of this test file's authoring session). // // Skip discipline: ffprobe-dependent tests gate behind `ffprobeAvailable()` // matching the pattern in tests/offscreen/webm-playback.test.ts. The // byte-magic tests (1, 2, 5) do NOT skip — they run anywhere. // // This file is RED at land time (the `src/background/webm-remux.ts` // module does not yet exist). Task 3 implements remuxSegments and // flips all 5 to GREEN. import { describe, it, expect } from 'vitest'; import { readFileSync, existsSync, statSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { dirname, resolve, join } from 'node:path'; import { tmpdir } from 'node:os'; import type { VideoSegment } from '../../src/shared/types'; const here = dirname(fileURLToPath(import.meta.url)); const FIXTURE_PATH = resolve(here, '..', '..', 'tests', 'fixtures', 'last_30sec.webm'); const FFPROBE_BIN = '/usr/bin/ffprobe'; // Byte offsets of each EBML header in `tests/fixtures/last_30sec.webm`, // per the d13 debug session probe (Evidence/H4 byte-level EBML probe). // Verified at Plan 01-08 Task 2 land time against the committed // fixture: offsets [0, 509038, 970967]; total 1_633_459 bytes. const SEG1_START = 0; const SEG2_START = 509038; const SEG3_START = 970967; // Single-EBML invariant constants. const EBML_MAGIC = new Uint8Array([0x1a, 0x45, 0xdf, 0xa3]); const SEGMENT_MAGIC = new Uint8Array([0x18, 0x53, 0x80, 0x67]); // Per-segment frame counts from the d13 byte-level probe (segments // individually counted via ffprobe -count_frames): // seg1 = 301, seg2 = 300, seg3 = 311 → sum = 912. // I-01 tolerance: muxer may drop at most one partial frame per segment // boundary (3 boundaries → ±3 absolute frames). const EXPECTED_FRAME_COUNT_LOWER = 905; const EXPECTED_FRAME_COUNT_UPPER = 912; // Playable-duration floor — mirrors MIN_PLAYABLE_DURATION_MS in // tests/offscreen/webm-playback.test.ts. The recorder lifecycle holds // 3 × 10 s segments minus boundary slack; gate at 25 s. const MIN_DURATION_MS = 25_000; // Size sanity band (input-sum × [lower, upper]). Catches silent content // drop AND container-overhead explosion. const SIZE_LOWER_RATIO = 0.7; const SIZE_UPPER_RATIO = 1.3; /** * Predicate gating tests that shell out to ffprobe. Same shape as the * helper in tests/offscreen/webm-playback.test.ts (kept inline rather * than imported because the two test files live in distinct * directories and Vitest discourages cross-tree relative test * imports). Mirror updates between the two by hand. * * @returns true if /usr/bin/ffprobe exists and is a regular file. */ function ffprobeAvailable(): boolean { try { return existsSync(FFPROBE_BIN) && statSync(FFPROBE_BIN).isFile(); } catch { return false; } } /** * Count non-overlapping byte-window matches of `magic` inside `bytes`. * Pure 4-byte pattern scan; no false-positive risk at the fixture * scale (~1.6 MB) — EBML element IDs are structurally rare enough that * a naive scan is correct, but more importantly the assertion uses * EXACT-1 counts so any spurious match is a real bug surface. * * @param bytes - Byte array to scan. * @param magic - 4-byte EBML element identifier. * @returns Count of occurrences. */ function countMagic(bytes: Uint8Array, magic: Uint8Array): number { if (magic.length !== 4) { throw new Error(`countMagic expects 4-byte magic; got ${magic.length}`); } let count = 0; for (let i = 0; i + 4 <= bytes.length; i++) { if ( bytes[i] === magic[0] && bytes[i + 1] === magic[1] && bytes[i + 2] === magic[2] && bytes[i + 3] === magic[3] ) { count++; } } return count; } /** * Slice the canonical 3-segment fixture into 3 standalone WebM Blobs * at the byte offsets the d13 byte-level probe identified. Each * returned Blob is a complete, individually playable ~10 s WebM with * its own EBML header + Segment + Cluster tree. * * The `timestamp` field on each VideoSegment uses the synthetic * monotonic sequence `1_000_000 + i*10_000` (microsecond-style * placeholders matching the recorder-side rotation cadence). The * remux helper sorts by timestamp ascending so the relative ordering * is what matters, not the absolute value. * * @returns Three VideoSegment entries, one per fixture segment. */ function splitFixtureIntoSegments(): VideoSegment[] { const buf = readFileSync(FIXTURE_PATH); const total = buf.length; const seg1 = buf.subarray(SEG1_START, SEG2_START); const seg2 = buf.subarray(SEG2_START, SEG3_START); const seg3 = buf.subarray(SEG3_START, total); return [ { data: new Blob([new Uint8Array(seg1)], { type: 'video/webm' }), timestamp: 1_000_000 }, { data: new Blob([new Uint8Array(seg2)], { type: 'video/webm' }), timestamp: 1_010_000 }, { data: new Blob([new Uint8Array(seg3)], { type: 'video/webm' }), timestamp: 1_020_000 }, ]; } /** * Read container-level format duration via ffprobe. Mirrors the * helper in webm-playback.test.ts and returns NaN on parse failure. * * @param fixturePath - Absolute path to the WebM file. * @returns Duration in milliseconds, or NaN on parse failure. */ function probeContainerDurationMs(fixturePath: string): number { const proc = spawnSync( FFPROBE_BIN, [ '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', '-i', fixturePath, ], { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8', timeout: 30_000, maxBuffer: 1 * 1024 * 1024, }, ); if (proc.signal !== null) { throw new Error(`ffprobe killed by signal ${proc.signal}`); } const stdout = (proc.stdout ?? '').trim(); const seconds = parseFloat(stdout); return Number.isFinite(seconds) ? Math.round(seconds * 1000) : Number.NaN; } /** * Read decoded frame count via `ffprobe -count_frames`. Counts the * video stream's nb_read_frames property (the actual decode-side * count, not the container-claimed count which can lie under the * D-13 multi-EBML pathology). * * @param fixturePath - Absolute path to the WebM file. * @returns Frame count, or NaN on parse failure. */ function probeDecodedFrameCount(fixturePath: string): number { const proc = spawnSync( FFPROBE_BIN, [ '-v', 'error', '-count_frames', '-select_streams', 'v:0', '-show_entries', 'stream=nb_read_frames', '-of', 'default=noprint_wrappers=1:nokey=1', '-i', fixturePath, ], { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8', timeout: 30_000, maxBuffer: 1 * 1024 * 1024, }, ); if (proc.signal !== null) { throw new Error(`ffprobe killed by signal ${proc.signal}`); } const stdout = (proc.stdout ?? '').trim(); const n = parseInt(stdout, 10); return Number.isFinite(n) ? n : Number.NaN; } /** * Write a Blob to a fresh tmpfile and return the path. Caller is * responsible for cleanup via the returned dir handle. * * @param blob - Blob to materialize on disk. * @returns { path, dir } — `dir` to remove recursively after use. */ async function blobToTmpFile(blob: Blob): Promise<{ path: string; dir: string }> { const dir = mkdtempSync(join(tmpdir(), 'webm-remux-test-')); const path = join(dir, 'remuxed.webm'); const ab = await blob.arrayBuffer(); writeFileSync(path, Buffer.from(ab)); return { path, dir }; } describe('remuxSegments (Plan 01-08 Task 2 — RED until Task 3)', () => { it('emits exactly one EBML header and one Segment element', async () => { // Dynamic import wrapped in try/catch so RED before Task 3 lands // produces a precise message rather than a module-resolution // crash that hides the contract. let remux: typeof import('../../src/background/webm-remux'); try { remux = await import('../../src/background/webm-remux'); } catch (e) { expect.fail( `src/background/webm-remux.ts does not exist yet — this is the Task 3 GREEN gate. Error: ${String(e)}`, ); } const segments = splitFixtureIntoSegments(); const out = await remux.remuxSegments(segments); const bytes = new Uint8Array(await out.arrayBuffer()); const ebmlCount = countMagic(bytes, EBML_MAGIC); const segmentCount = countMagic(bytes, SEGMENT_MAGIC); expect( ebmlCount, `Expected exactly 1 EBML header magic (0x1A 0x45 0xDF 0xA3) in remux output, found ${ebmlCount}. ` + `The whole point of D-14-remux is to collapse 3 source EBML headers into 1.`, ).toBe(1); expect( segmentCount, `Expected exactly 1 Segment element magic (0x18 0x53 0x80 0x67) in remux output, found ${segmentCount}. ` + `Multiple Segment elements mean the merge collapsed into a multi-segment Matroska (broken under SPEC §10 #7).`, ).toBe(1); }); it('emits a blob whose size is within [0.7x, 1.3x] of the input total', async () => { let remux: typeof import('../../src/background/webm-remux'); try { remux = await import('../../src/background/webm-remux'); } catch (e) { expect.fail(`webm-remux module missing: ${String(e)}`); } const segments = splitFixtureIntoSegments(); const inputTotal = segments.reduce((sum, seg) => sum + seg.data.size, 0); const out = await remux.remuxSegments(segments); const lower = inputTotal * SIZE_LOWER_RATIO; const upper = inputTotal * SIZE_UPPER_RATIO; expect( out.size, `Output size ${out.size} bytes; input sum ${inputTotal}; expected band ` + `[${lower.toFixed(0)}, ${upper.toFixed(0)}]. Below band = silent content drop; ` + `above band = container overhead explosion (re-encode regression?).`, ).toBeGreaterThanOrEqual(lower); expect(out.size).toBeLessThanOrEqual(upper); }); it.skipIf(!ffprobeAvailable())( 'ffprobe format=duration on the remuxed output is at least 25 s', async () => { let remux: typeof import('../../src/background/webm-remux'); try { remux = await import('../../src/background/webm-remux'); } catch (e) { expect.fail(`webm-remux module missing: ${String(e)}`); } const segments = splitFixtureIntoSegments(); const out = await remux.remuxSegments(segments); const handle = await blobToTmpFile(out); try { const durationMs = probeContainerDurationMs(handle.path); expect( durationMs, `ffprobe reported container duration=${durationMs} ms for remux output. ` + `SPEC §10 #7 floor is ${MIN_DURATION_MS} ms. ` + `If <10 s, the multi-EBML pathology returned (Plan 01-08 regression). ` + `If 0/NaN, the muxer produced no Info.Duration EBML (check Muxer config).`, ).toBeGreaterThanOrEqual(MIN_DURATION_MS); } finally { rmSync(handle.dir, { recursive: true, force: true }); } }, ); it.skipIf(!ffprobeAvailable())( 'ffprobe -count_frames reports between 905 and 912 frames inclusive', async () => { let remux: typeof import('../../src/background/webm-remux'); try { remux = await import('../../src/background/webm-remux'); } catch (e) { expect.fail(`webm-remux module missing: ${String(e)}`); } const segments = splitFixtureIntoSegments(); const out = await remux.remuxSegments(segments); const handle = await blobToTmpFile(out); try { const frames = probeDecodedFrameCount(handle.path); expect( frames, `ffprobe -count_frames reported ${frames} frames in remux output. ` + `Expected band [${EXPECTED_FRAME_COUNT_LOWER}, ${EXPECTED_FRAME_COUNT_UPPER}] ` + `(per-segment sum 301+300+311=912 ± 3 boundary partial-frame drops). ` + `Below band = frame loss (silent SimpleBlock-skip bug); above band = ` + `duplicate-frame bug (re-emitting the same SimpleBlock from multiple sources).`, ).toBeGreaterThanOrEqual(EXPECTED_FRAME_COUNT_LOWER); expect(frames).toBeLessThanOrEqual(EXPECTED_FRAME_COUNT_UPPER); } finally { rmSync(handle.dir, { recursive: true, force: true }); } }, ); it('empty input returns an empty Blob', async () => { let remux: typeof import('../../src/background/webm-remux'); try { remux = await import('../../src/background/webm-remux'); } catch (e) { expect.fail(`webm-remux module missing: ${String(e)}`); } const out = await remux.remuxSegments([]); expect(out).toBeInstanceOf(Blob); expect( out.size, 'Empty input must produce an empty blob — saveArchive guards this path upstream but ' + 'remuxSegments must be safe on its own (defense-in-depth, T-1-08-01 + T-1-08-02).', ).toBe(0); }); });