feat(fix-a3): retire ring-buffer first-chunk pin tests, add segment-rotation contract
Per debug session webm-playback-freeze "Activation Plan" step 4: the
D-09..D-11 ring-buffer semantics (first-chunk header pin + 30 s age trim)
are being replaced by D-13 restart-segments. The pinned-header assertions
were architecture-specific and become meaningless once each segment is
a self-contained WebM with its own EBML header and seed keyframe.
Changes:
- tests/offscreen/ring-buffer.test.ts: collapsed to a single breadcrumb
test pointing at the successor file. Kept the path so git history /
failure bisects land on the retirement commit cleanly.
- tests/offscreen/segment-rotation.test.ts (new): 8 tests pinning the
D-13 invariants against the production recorder module:
* MAX_SEGMENTS = 3, SEGMENT_DURATION_MS = 10_000 (= legacy 30 s window)
* empty-by-default, ordered, oldest-evicted-at-cap
* resetBuffer clears
* getSegments returns a defensive snapshot (no internal aliasing)
Uses a `pushSegmentForTest` seam so vitest can drive rotation
deterministically without instantiating a real MediaRecorder.
RED today by design (TDD discipline) — the segment-rotation suite
imports `getSegments`, `pushSegmentForTest`, `MAX_SEGMENTS`,
`SEGMENT_DURATION_MS` from src/offscreen/recorder.ts. Those exports
land in the next commit. tsconfig.include is "src" only so tsc stays
clean during the RED window.
This commit is contained in:
125
tests/offscreen/segment-rotation.test.ts
Normal file
125
tests/offscreen/segment-rotation.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
// tests/offscreen/segment-rotation.test.ts
|
||||
//
|
||||
// Replaces tests/offscreen/ring-buffer.test.ts (retired). Pins the D-13
|
||||
// restart-segments invariants against the production recorder module.
|
||||
//
|
||||
// What this exercises (per .planning/debug/webm-playback-freeze.md
|
||||
// "Activation Plan" + RESEARCH.md Pattern 3 + CONTEXT.md D-13):
|
||||
// 1. `getSegments()` is the canonical buffer accessor (Blob[]); the
|
||||
// legacy chunk-level `getBuffer()` API is no longer present.
|
||||
// 2. The segment ring caps at MAX_SEGMENTS = 3 — the 4th push evicts
|
||||
// the oldest, preserving the most-recent 3 segments (= 30 s window
|
||||
// at SEGMENT_DURATION_MS = 10_000 ms).
|
||||
// 3. `resetBuffer()` clears the segment array — used on
|
||||
// track-ended ("user stopped sharing") cleanup and between tests.
|
||||
// 4. Each retained entry is a real Blob with `type === 'video/webm'`
|
||||
// so the SW's `mergeVideoSegments` concatenation produces a valid
|
||||
// multi-EBML-header WebM payload (Chrome handles this natively;
|
||||
// see Pattern 3 trade-off discussion).
|
||||
//
|
||||
// The test seam `pushSegmentForTest(blob)` drives rotation
|
||||
// deterministically — production code reaches the same code path via
|
||||
// the MediaRecorder.onstop handler, but instantiating a real
|
||||
// MediaRecorder inside vitest is not portable (jsdom lacks the
|
||||
// underlying capture pipeline). The seam name encodes its purpose.
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getSegments,
|
||||
pushSegmentForTest,
|
||||
resetBuffer,
|
||||
MAX_SEGMENTS,
|
||||
SEGMENT_DURATION_MS,
|
||||
VIDEO_BUFFER_DURATION_MS,
|
||||
} from '../../src/offscreen/recorder';
|
||||
|
||||
function makeSegmentBlob(seedByte: number): Blob {
|
||||
// A 4-byte placeholder is enough — these tests assert on length /
|
||||
// ordering / type, not on decodability. The real ffmpeg-driven
|
||||
// contract lives in webm-playback.test.ts and runs against the
|
||||
// committed fixture.
|
||||
return new Blob([new Uint8Array([seedByte, 0x00, 0x00, 0x00])], {
|
||||
type: 'video/webm',
|
||||
});
|
||||
}
|
||||
|
||||
describe('segment rotation (D-13 restart-segments)', () => {
|
||||
beforeEach(() => {
|
||||
resetBuffer();
|
||||
});
|
||||
|
||||
it('exposes the D-13 constants — 3 segments × 10 s = 30 s window', () => {
|
||||
expect(MAX_SEGMENTS).toBe(3);
|
||||
expect(SEGMENT_DURATION_MS).toBe(10_000);
|
||||
// The product matches the legacy VIDEO_BUFFER_DURATION_MS so the
|
||||
// operator-facing "last 30 seconds" promise stays intact.
|
||||
expect(MAX_SEGMENTS * SEGMENT_DURATION_MS).toBe(VIDEO_BUFFER_DURATION_MS);
|
||||
});
|
||||
|
||||
it('starts empty before any segment is pushed', () => {
|
||||
expect(getSegments()).toEqual([]);
|
||||
});
|
||||
|
||||
it('first push appends one segment', () => {
|
||||
pushSegmentForTest(makeSegmentBlob(0x1a));
|
||||
const segments = getSegments();
|
||||
expect(segments).toHaveLength(1);
|
||||
expect(segments[0]).toBeInstanceOf(Blob);
|
||||
expect(segments[0].type).toBe('video/webm');
|
||||
});
|
||||
|
||||
it('preserves segments in push order (oldest first)', () => {
|
||||
pushSegmentForTest(makeSegmentBlob(0xaa));
|
||||
pushSegmentForTest(makeSegmentBlob(0xbb));
|
||||
pushSegmentForTest(makeSegmentBlob(0xcc));
|
||||
const segments = getSegments();
|
||||
expect(segments).toHaveLength(3);
|
||||
// Read out the seed byte from each blob to confirm order.
|
||||
return Promise.all(segments.map((s) => s.arrayBuffer())).then((bufs) => {
|
||||
const firstBytes = bufs.map((b) => new Uint8Array(b)[0]);
|
||||
expect(firstBytes).toEqual([0xaa, 0xbb, 0xcc]);
|
||||
});
|
||||
});
|
||||
|
||||
it('caps at MAX_SEGMENTS — 4th push evicts the oldest', () => {
|
||||
pushSegmentForTest(makeSegmentBlob(0x11));
|
||||
pushSegmentForTest(makeSegmentBlob(0x22));
|
||||
pushSegmentForTest(makeSegmentBlob(0x33));
|
||||
pushSegmentForTest(makeSegmentBlob(0x44));
|
||||
const segments = getSegments();
|
||||
expect(segments).toHaveLength(MAX_SEGMENTS);
|
||||
return Promise.all(segments.map((s) => s.arrayBuffer())).then((bufs) => {
|
||||
const firstBytes = bufs.map((b) => new Uint8Array(b)[0]);
|
||||
// Oldest (0x11) is gone; the kept window is the 3 most-recent.
|
||||
expect(firstBytes).toEqual([0x22, 0x33, 0x44]);
|
||||
});
|
||||
});
|
||||
|
||||
it('many pushes never exceed the cap', () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
pushSegmentForTest(makeSegmentBlob(i));
|
||||
}
|
||||
expect(getSegments().length).toBeLessThanOrEqual(MAX_SEGMENTS);
|
||||
expect(getSegments()).toHaveLength(MAX_SEGMENTS);
|
||||
});
|
||||
|
||||
it('resetBuffer clears the segment array', () => {
|
||||
pushSegmentForTest(makeSegmentBlob(0x1));
|
||||
pushSegmentForTest(makeSegmentBlob(0x2));
|
||||
expect(getSegments()).toHaveLength(2);
|
||||
resetBuffer();
|
||||
expect(getSegments()).toEqual([]);
|
||||
});
|
||||
|
||||
it('getSegments returns a snapshot — mutating the result does not corrupt internal state', () => {
|
||||
pushSegmentForTest(makeSegmentBlob(0x1));
|
||||
pushSegmentForTest(makeSegmentBlob(0x2));
|
||||
const snapshot = getSegments();
|
||||
expect(snapshot).toHaveLength(2);
|
||||
// Caller-side mutation must not leak back into the recorder module
|
||||
// (defense against accidental aliasing in mergeVideoSegments).
|
||||
snapshot.pop();
|
||||
snapshot.push(makeSegmentBlob(0xff));
|
||||
expect(getSegments()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user