Files
mokosh/tests/background/webm-remux.test.ts
Mark e40949d1d2 test(01-08): regenerate last_30sec.webm fixture + split remux input/output fixtures
Plan 01-08 Task 5 closeout. The post-B+ smoke run produced a working
single-EBML WebM (28.76s, 676 frames, 1.89 MB, monotonic 0→28.76s
timestamps). Operator-confirmed empirically (timer overlay in smoke
HTML showed the latest frames matched expectations).

Two-fixture split resolves a test-design conflict surfaced when
last_30sec.webm flipped from pre-remux input shape to post-remux
output shape:

- tests/fixtures/last_30sec.webm — POST-REMUX output (single EBML,
  41 ffmpeg dry-run lines). Validates webm-playback.test.ts'
  playable-duration + structural assertions.

- tests/fixtures/raw-3ebml-concat.webm — PRE-REMUX input (3-EBML
  concat, 299 ffmpeg dry-run lines = 3 segment boundaries).
  Preserved from the original 2026-05-15 Phase 1 closure fixture.
  Used by webm-remux.test.ts to test that remuxSegments correctly
  transforms 3-EBML input → single-EBML output.

tests/background/webm-remux.test.ts FIXTURE_PATH updated to point at
raw-3ebml-concat.webm; the hardcoded EBML byte offsets [0, 509038,
970967] and frame bounds [905, 912] remain valid against that
preserved input.

Result: 64/64 vitest GREEN (was 61/64). tsc clean. Build exit 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:44:50 +02:00

374 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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/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));
// Plan 01-08 Task 5 closeout note (2026-05-17): `tests/fixtures/last_30sec.webm`
// is now the post-remux OUTPUT (single-EBML, validated by webm-playback.test.ts).
// These unit tests need the pre-remux INPUT shape (3-EBML concat), so they
// read from the preserved raw fixture instead.
const FIXTURE_PATH = resolve(here, '..', '..', 'tests', 'fixtures', 'raw-3ebml-concat.webm');
const FFPROBE_BIN = '/usr/bin/ffprobe';
// Byte offsets of each EBML header in `tests/fixtures/raw-3ebml-concat.webm`,
// per the d13 debug session probe (Evidence/H4 byte-level EBML probe).
// Verified at Plan 01-08 Task 2 land time against the original committed
// fixture (now preserved as raw-3ebml-concat.webm): 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);
});
});