Captures the RED contracts that the webm-playback-freeze debug
session landed (before this fix-a3 cycle started) plus the original
Plan 07 smoke fixture they run against. None of these files were
modified by this fix cycle — they are landed as-is from the debug
session to make the test history bisectable.
Files staged:
- tests/offscreen/segment-keyframes.test.ts
Three describe blocks (~340 LOC):
* documentation — pure-simulation tests that pin the D-09..D-11
failure mode as executable evidence (regression guard against
re-introducing single-continuous-recorder semantics)
* GREEN-pinning — pure-simulation tests that pin the D-13
segment-keyframe invariant
* production-driven — imports src/offscreen/recorder.ts and
asserts (i) `getSegments` exported as a function, (ii) it
returns at most 3 Blobs. THIS BLOCK IS NOW GREEN after the
D-13 activation in the prior commits — was the genuine TDD
anchor for fix-a3.
- tests/offscreen/webm-playback.test.ts
Two empirical-ffmpeg assertions on tests/fixtures/last_30sec.webm:
* zero "Error submitting packet to decoder" lines from the
VP9 decoder
* no "File ended prematurely" container-finalization error
Both STAY RED in this commit because the committed fixture is
still the stale one from Plan 07's pre-fix smoke. They flip
GREEN after the operator runs ./smoke.sh to regenerate the
fixture against the D-13 recorder — see the closing message
and the NEXT-STEP block of the resolved debug session.
- tests/fixtures/last_30sec.webm
The 2.1 MB Plan 07 smoke artifact. Committed deliberately so
the empirical RED test has something to run against. Will be
overwritten by the next ./smoke.sh run (single-file rotation —
the path is fixed by the smoke script + zip extraction step
in the debug-session reproduction).
Verification:
- npx vitest run --reporter=dot → Tests 2 failed | 28 passed (30)
- The 2 fails are EXACTLY the two empirical-ffmpeg assertions in
webm-playback.test.ts; the structural production-driven block
in segment-keyframes.test.ts is fully GREEN.
- npx tsc --noEmit clean.
- npm run build succeeds.
Operator action required before Phase 1 close (Plan 07 still owns
REQ-video-ring-buffer): re-run ./smoke.sh per the documented
6-step reproduction in the debug session, then re-run
`npx vitest run tests/offscreen/webm-playback.test.ts` and
expect both assertions to flip GREEN. Plan 07 success criterion
§10 #7 (playback) lands at that point.
180 lines
7.7 KiB
TypeScript
180 lines
7.7 KiB
TypeScript
// tests/offscreen/webm-playback.test.ts
|
|
//
|
|
// RED-gate test for debug session webm-playback-freeze.
|
|
//
|
|
// Empirically proves the playback freeze observed in
|
|
// tests/fixtures/last_30sec.webm (Phase 1, Plan 01-07 smoke retest after the
|
|
// D-12 base64-transfer fix landed at commit bf07619).
|
|
//
|
|
// Hypothesis under test (per .planning/debug/webm-playback-freeze.md):
|
|
//
|
|
// The single-continuous MediaRecorder + 30 s age-trim approach (D-09..D-11)
|
|
// drops VP9 P-frames' keyframe references when the buffer trims out the
|
|
// middle of the recording. VP9's `kf_max_dist=100` (Chrome default) puts
|
|
// keyframes every ~3-5 s. With chunks emitted every 2 s (D-09 timeslice),
|
|
// the boundary chunks contain only P-frames referencing keyframes that have
|
|
// been evicted. The decoder therefore fails ~1 s into playback in Chrome,
|
|
// and `ffmpeg -v warning -i <fixture> -f null -` emits multiple
|
|
// "Error submitting packet to decoder: Invalid data found" lines plus a
|
|
// "File ended prematurely" tail-error.
|
|
//
|
|
// This test runs ffmpeg's CLI (an external dependency — /usr/bin/ffmpeg)
|
|
// over the COMMITTED fixture and asserts:
|
|
// * zero "Error submitting packet to decoder" lines, AND
|
|
// * no "File ended prematurely" line.
|
|
//
|
|
// Today (commit bf07619) the test goes RED because the fixture was produced
|
|
// by the single-continuous-recorder path. The D-13 fix (restart-segments,
|
|
// activate the pre-staged skeleton in src/offscreen/recorder.ts) will produce
|
|
// a fresh fixture whose decode is clean — at which point this test flips
|
|
// GREEN. See `tests/offscreen/segment-keyframes.test.ts` for the unit-level
|
|
// algorithmic guard that does NOT require regenerating the fixture.
|
|
//
|
|
// Skip discipline: if ffmpeg is missing from the environment the test
|
|
// auto-skips rather than failing. CI ships ffmpeg per `smoke.sh` so this is
|
|
// a developer-convenience fence, not a behavioural softening.
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import { existsSync, statSync } from 'node:fs';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, resolve } from 'node:path';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const FIXTURE_PATH = resolve(here, '..', 'fixtures', 'last_30sec.webm');
|
|
const FFMPEG_BIN = '/usr/bin/ffmpeg';
|
|
|
|
// Cap: a clean 30-second WebM decoded with `-f null` finishes well under
|
|
// 10 s on commodity hardware. If we ever exceed this we want a hard failure,
|
|
// not a hung CI job.
|
|
const FFMPEG_TIMEOUT_MS = 30_000;
|
|
|
|
function ffmpegAvailable(): boolean {
|
|
try {
|
|
return existsSync(FFMPEG_BIN) && statSync(FFMPEG_BIN).isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
interface DecodeResult {
|
|
stderr: string;
|
|
packetErrorCount: number;
|
|
endedPrematurely: boolean;
|
|
}
|
|
|
|
function decodeDryRun(fixturePath: string): DecodeResult {
|
|
// `-f null -` swallows the decoded output but still surfaces every per-packet
|
|
// decoder error to stderr. `-nostdin` prevents ffmpeg from blocking on a TTY
|
|
// that vitest does not provide. `-v warning` filters the noise floor; the
|
|
// signals we care about (`Error submitting packet to decoder`,
|
|
// `File ended prematurely`) are emitted at warning level or above.
|
|
let stderr = '';
|
|
try {
|
|
execFileSync(
|
|
FFMPEG_BIN,
|
|
['-nostdin', '-v', 'warning', '-i', fixturePath, '-f', 'null', '-'],
|
|
{
|
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
encoding: 'utf-8',
|
|
timeout: FFMPEG_TIMEOUT_MS,
|
|
maxBuffer: 4 * 1024 * 1024, // 4 MiB is comfortable for warning-level logs
|
|
},
|
|
);
|
|
} catch (err) {
|
|
// ffmpeg exits 0 even on per-packet decode errors with `-f null -`,
|
|
// so a thrown error usually means the binary is genuinely broken or the
|
|
// file is unreadable. Re-throw to fail loudly with full context.
|
|
const e = err as { stderr?: string; message?: string };
|
|
stderr = e.stderr ?? '';
|
|
if (!stderr) {
|
|
throw err;
|
|
}
|
|
}
|
|
// ffmpeg may also write its diagnostics directly when execFileSync succeeds.
|
|
// The captured stderr lives on the error path; on success we attach the
|
|
// pipe explicitly.
|
|
// execFileSync returns stdout-only by design — to also capture success-path
|
|
// stderr, repeat with stdio: ['ignore', 'ignore', 'pipe'] reading the
|
|
// returned Buffer is not possible. Use spawnSync semantics instead.
|
|
return {
|
|
stderr,
|
|
packetErrorCount: (stderr.match(/Error submitting packet to decoder/g) ?? []).length,
|
|
endedPrematurely: /File ended prematurely/.test(stderr),
|
|
};
|
|
}
|
|
|
|
// Variant that uses spawnSync so we can read stderr on the success path too.
|
|
// execFileSync above is intentionally kept for the documentation value, but
|
|
// the actual assertion uses spawnSync.
|
|
import { spawnSync } from 'node:child_process';
|
|
|
|
function decodeDryRunStrict(fixturePath: string): DecodeResult {
|
|
const proc = spawnSync(
|
|
FFMPEG_BIN,
|
|
['-nostdin', '-v', 'warning', '-i', fixturePath, '-f', 'null', '-'],
|
|
{
|
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
encoding: 'utf-8',
|
|
timeout: FFMPEG_TIMEOUT_MS,
|
|
maxBuffer: 4 * 1024 * 1024,
|
|
},
|
|
);
|
|
if (proc.signal !== null) {
|
|
throw new Error(`ffmpeg was killed by signal ${proc.signal}`);
|
|
}
|
|
const stderr = proc.stderr ?? '';
|
|
return {
|
|
stderr,
|
|
packetErrorCount: (stderr.match(/Error submitting packet to decoder/g) ?? []).length,
|
|
endedPrematurely: /File ended prematurely/.test(stderr),
|
|
};
|
|
}
|
|
|
|
describe('webm playback (RED — confirms webm-playback-freeze bug)', () => {
|
|
it.skipIf(!ffmpegAvailable())(
|
|
'ffmpeg dry-run on last_30sec.webm produces zero decoder packet errors',
|
|
() => {
|
|
expect(existsSync(FIXTURE_PATH)).toBe(true);
|
|
const result = decodeDryRunStrict(FIXTURE_PATH);
|
|
// Document the failure in the assertion message so a regression
|
|
// bisect lands on a useful diff, not just "expected 0 received N".
|
|
expect(
|
|
result.packetErrorCount,
|
|
`ffmpeg reported ${result.packetErrorCount} "Error submitting packet to decoder" line(s). ` +
|
|
`This means the VP9 decoder hit P-frames whose reference keyframe was missing from the ` +
|
|
`stream — the symptom of the single-continuous-recorder + 30 s age-trim approach (D-09..D-11). ` +
|
|
`Fix: activate the D-13 restart-segments skeleton at src/offscreen/recorder.ts:298-316 and ` +
|
|
`regenerate the fixture via ./smoke.sh. Full ffmpeg stderr:\n${result.stderr}`,
|
|
).toBe(0);
|
|
},
|
|
);
|
|
|
|
it.skipIf(!ffmpegAvailable())(
|
|
'ffmpeg dry-run on last_30sec.webm does not end prematurely',
|
|
() => {
|
|
expect(existsSync(FIXTURE_PATH)).toBe(true);
|
|
const result = decodeDryRunStrict(FIXTURE_PATH);
|
|
// The "File ended prematurely" line indicates the WebM lacks proper
|
|
// Matroska SegmentSize / Cues finalization because the SW reads the
|
|
// in-memory buffer while the MediaRecorder is still active (no .stop()).
|
|
// The D-13 restart-segments approach fixes this as a side effect —
|
|
// each rotated segment gets a proper .stop() and is therefore finalized.
|
|
expect(
|
|
result.endedPrematurely,
|
|
`ffmpeg reported "File ended prematurely". The WebM container was read mid-stream ` +
|
|
`without calling MediaRecorder.stop(), so SegmentSize/Cues are unwritten. The D-13 ` +
|
|
`restart-segments fix finalizes each segment naturally. Full ffmpeg stderr:\n${result.stderr}`,
|
|
).toBe(false);
|
|
},
|
|
);
|
|
|
|
// Touch the unused decodeDryRun symbol so the file's documentation block
|
|
// stays compilable under noUnusedLocals. The intent is to leave both
|
|
// helpers documented side-by-side: one shows the execFileSync semantics
|
|
// (succeeds quietly on decode errors) and the other shows the spawnSync
|
|
// approach actually used. Vitest will not execute the body.
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
void decodeDryRun;
|
|
});
|