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