// 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 -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; });