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:
368
tests/background/webm-remux.test.ts
Normal file
368
tests/background/webm-remux.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user