From 553029227072030609ce52701f598328f3431d74 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 15 May 2026 20:59:01 +0200 Subject: [PATCH] feat(fix-a3): retire ring-buffer first-chunk pin tests, add segment-rotation contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/offscreen/ring-buffer.test.ts | 61 +++++------ tests/offscreen/segment-rotation.test.ts | 125 +++++++++++++++++++++++ 2 files changed, 150 insertions(+), 36 deletions(-) create mode 100644 tests/offscreen/segment-rotation.test.ts diff --git a/tests/offscreen/ring-buffer.test.ts b/tests/offscreen/ring-buffer.test.ts index 861db77..dcdfeb7 100644 --- a/tests/offscreen/ring-buffer.test.ts +++ b/tests/offscreen/ring-buffer.test.ts @@ -1,40 +1,29 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { addChunk, trimAged, getBuffer, resetBuffer } from '../../src/offscreen/recorder'; +// tests/offscreen/ring-buffer.test.ts — vestigial entry point. +// +// Under D-09..D-11 this file pinned the single-continuous-recorder ring +// buffer (first-chunk header pin + 30 s age-trim). Those semantics are +// retired in favour of the D-13 restart-segments architecture (debug +// session webm-playback-freeze): each segment is a self-contained ~10 s +// WebM with its own EBML header and seed keyframe, so a special +// `isFirst` flag is no longer meaningful. +// +// The equivalent invariants under D-13 are exercised in +// `tests/offscreen/segment-rotation.test.ts`. This file is kept as a +// breadcrumb so a future reader grepping for "ring buffer" lands on the +// correct successor — and so the retirement is auditable in `git log` +// (the original 4 tests were deleted in the same commit that added the +// segment-rotation suite). +// +// See: .planning/debug/webm-playback-freeze.md "Activation Plan" +// step 4 — the D-09..D-11 invariants are replaced, not weakened. -describe('ring buffer', () => { - beforeEach(() => resetBuffer()); +import { describe, it, expect } from 'vitest'; - it('first chunk is header', () => { - addChunk({ size: 1024 } as unknown as Blob, 1_000); - const buf = getBuffer(); - expect(buf.length).toBe(1); - expect(buf[0].isFirst).toBe(true); - }); - - it('second chunk is NOT header', () => { - addChunk({ size: 1024 } as unknown as Blob, 1_000); - addChunk({ size: 512 } as unknown as Blob, 2_000); - const buf = getBuffer(); - expect(buf.length).toBe(2); - expect(buf[0].isFirst).toBe(true); - expect(buf[1].isFirst).toBeFalsy(); - }); - - it('trim 30s — keeps header, evicts aged tail', () => { - addChunk({ size: 1024 } as unknown as Blob, 0); // header at t=0 - addChunk({ size: 512 } as unknown as Blob, 10_000); // t=10s - addChunk({ size: 512 } as unknown as Blob, 35_000); // t=35s - trimAged(40_000); // now=40s - const buf = getBuffer(); - expect(buf[0].isFirst).toBe(true); // header survives unconditionally - expect(buf.length).toBeGreaterThanOrEqual(2); // header + at least the t=35s chunk - // The header chunk's age (40s) does NOT cause it to be trimmed. - const headerStillThere = buf.some((c) => c.isFirst); - expect(headerStillThere).toBe(true); - }); - - it('trim with empty buffer does not throw', () => { - expect(() => trimAged(0)).not.toThrow(); - expect(getBuffer()).toEqual([]); +describe('ring buffer (retired — see segment-rotation.test.ts)', () => { + it('D-09..D-11 first-chunk pin is retired by D-13 restart-segments', () => { + // Structural assertion only: the test exists to make the retirement + // visible in the test report. Behavioural assertions live in + // segment-rotation.test.ts. + expect(true).toBe(true); }); }); diff --git a/tests/offscreen/segment-rotation.test.ts b/tests/offscreen/segment-rotation.test.ts new file mode 100644 index 0000000..8e44030 --- /dev/null +++ b/tests/offscreen/segment-rotation.test.ts @@ -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); + }); +});