Milestone v1 (v2.0.0): Mokosh — Session Capture #1

Merged
strategy155 merged 297 commits from gsd/phase-04-harden-clean-up-optional into main 2026-05-31 15:34:17 +00:00
2 changed files with 150 additions and 36 deletions
Showing only changes of commit 5530292270 - Show all commits

View File

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

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