145 lines
5.9 KiB
TypeScript
145 lines
5.9 KiB
TypeScript
// 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('resetBuffer preserves segments do NOT survive (sweep #1 stop-race adjacent)', () => {
|
||
// Sweep #1 documented that stopRecording() previously raced
|
||
// onSegmentStopped: the timer was cleared but onSegmentStopped's
|
||
// mediaStream != null branch would still spawn a new segment. The
|
||
// fix nulls mediaStream FIRST. resetBuffer is the orthogonal
|
||
// mechanism for clearing the buffer (used by onUserStoppedSharing
|
||
// AND between tests). This test pins the contract that resetBuffer
|
||
// never leaves a partial segment behind.
|
||
pushSegmentForTest(makeSegmentBlob(0xa));
|
||
pushSegmentForTest(makeSegmentBlob(0xb));
|
||
pushSegmentForTest(makeSegmentBlob(0xc));
|
||
expect(getSegments()).toHaveLength(MAX_SEGMENTS);
|
||
resetBuffer();
|
||
expect(getSegments()).toHaveLength(0);
|
||
// Subsequent pushes start from an empty buffer — no eviction needed.
|
||
pushSegmentForTest(makeSegmentBlob(0xd));
|
||
expect(getSegments()).toHaveLength(1);
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|