diff --git a/tests/background/webm-remux.test.ts b/tests/background/webm-remux.test.ts new file mode 100644 index 0000000..5be8130 --- /dev/null +++ b/tests/background/webm-remux.test.ts @@ -0,0 +1,368 @@ +// 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); + }); +});