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>
374 lines
15 KiB
TypeScript
374 lines
15 KiB
TypeScript
// 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);
|
||
});
|
||
});
|