Files
mokosh/tests/offscreen/segment-rotation.test.ts

145 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
});
});