test(01-08): RED unit tests for remuxSegments — single-EBML + monotonic + frame-count + size + empty

- 5 RED tests pinning the contract for src/background/webm-remux.ts
  (created in Task 3). All fail with "module missing" today — the
  Task 3 GREEN gate.
- Test 1: exactly 1 EBML header + 1 Segment magic in output.
- Test 2: output size within [0.7x, 1.3x] of input sum.
- Test 3: ffprobe format=duration >= 25_000 ms (skip-if-no-ffprobe).
- Test 4: ffprobe -count_frames in [905, 912] (per-seg sum 912 ± 3 boundary
  partial-frame drops, I-01 tightening).
- Test 5: empty input -> empty Blob (defense-in-depth).
- Fixture sliced at d13-confirmed byte offsets (0 / 509038 / 970967);
  verified against committed last_30sec.webm at Task 2 land time.
- Baseline counts: 13 files / 62 tests / 7 failed (2 webm-playback +
  5 new webm-remux) | 55 passed. tsc exit 0.
This commit is contained in:
2026-05-17 09:23:53 +02:00
parent 503531485c
commit 407e683e9b

View File

@@ -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<Blob>` 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);
});
});