Files
mokosh/.planning/phases/01-stabilize-video-pipeline/01-02-PLAN.md
Mark c24fcda818 docs(01): revise plan 02 wave per checker iteration 1
Plan 02 depends_on: ["01"], so wave must be max(wave(01)=0)+1 = 1, not 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:49:59 +02:00

29 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, requirements_addressed, must_haves
phase plan type wave depends_on files_modified autonomous requirements requirements_addressed must_haves
01-stabilize-video-pipeline 02 execute 1
01
package.json
vitest.config.ts
tests/offscreen/ring-buffer.test.ts
tests/offscreen/codec-check.test.ts
tests/offscreen/handshake.test.ts
tests/offscreen/port.test.ts
tests/fixtures/.gitkeep
true
REQ-video-ring-buffer
REQ-video-ring-buffer
truths artifacts key_links
`node_modules/` exists and Vitest is installed under devDependencies
`vitest.config.ts` exists at repo root and runs Node-environment tests under tests/**/*.test.ts
`npm test` runs `vitest run` and exits with a non-zero code (because the 4 RED tests fail — they import modules that do not exist yet)
The four RED test files exist and EACH attempts to import from `src/offscreen/recorder` — the four tests pin the contracts Plans 03 and 04 must satisfy
`tests/fixtures/` directory exists and is committed (empty for now; Plan 07 will produce a real WebM into it manually)
path provides contains
package.json vitest devDep + npm test script + vitest-chrome mock "vitest"
path provides contains
vitest.config.ts node-env test config matching project tsconfig environment: 'node'
path provides contains
tests/offscreen/ring-buffer.test.ts RED tests for header-pinning + 30 s trim (D-10/D-11) first chunk is header
path provides contains
tests/offscreen/codec-check.test.ts RED test for codec strict-mode (D-20) vp9 unsupported
path provides contains
tests/offscreen/handshake.test.ts RED test for OFFSCREEN_READY (Pattern 4) OFFSCREEN_READY
path provides contains
tests/offscreen/port.test.ts RED test for port reconnect (Pattern 5 / Pitfall 4) reconnects
path provides contains
tests/fixtures/.gitkeep marker so the fixtures directory survives clean checkouts
from to via pattern
tests/offscreen/*.test.ts src/offscreen/recorder.ts import { ... } from '../../src/offscreen/recorder' from '../../src/offscreen/recorder'
from to via pattern
package.json vitest.config.ts npm test script "test": "vitest run"
Wave 0 test infrastructure. Install Vitest, write `vitest.config.ts`, write the four FAILING test files that pin the contracts for Plans 03 (recorder) and 04 (handshake + port), and add the `npm test` script.

These tests are RED on purpose — they import from src/offscreen/recorder, which Plan 03 creates. Until Plan 03 GREEN-implements the module, every test in this batch fails with a module-resolution error. That is the Nyquist sampling signal Plans 03 / 04 must drive to PASS.

Purpose: This phase has workflow.tdd_mode: true. RED-first means the test file ships BEFORE the production code; Plans 03 and 04 then commit the minimum implementation to flip each test GREEN. Establishing the test fixtures here, in a single coherent plan, avoids a Plan-03 RED commit collision with a Plan-04 RED commit.

Output: Vitest installed, 4 RED test files, vitest.config.ts, npm test script, tests/fixtures/ directory placeholder.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md @.planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md @.planning/phases/01-stabilize-video-pipeline/01-VALIDATION.md @package.json @tsconfig.json @src/shared/types.ts

Expected exports from src/offscreen/recorder.ts (created in Plan 03):

// Ring-buffer (pure functions; testable in Node without a real Blob)
export function addChunk(blob: { size: number }, timestamp: number): void;
export function trimAged(now: number): void;
export function getBuffer(): Array<{ data: { size: number }; timestamp: number; isFirst?: boolean }>;
export function resetBuffer(): void;

// Codec strict-mode (D-20)
export function assertCodecSupported(): void;          // throws Error if vp9 unsupported

// Constants (so tests can reference the same window value Plan 03 uses)
export const VIDEO_BUFFER_DURATION_MS: number;         // = 30_000

Expected side-effects of importing src/offscreen/recorder.ts:

  • chrome.runtime.onMessage.addListener called at least once
  • chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' }) called exactly once (Pattern 4)
  • chrome.runtime.connect({ name: 'video-keepalive' }) called exactly once at module load (Pattern 5)
  • On the connected port firing onDisconnect, the module immediately calls chrome.runtime.connect again (Pitfall 4 reconnect)

These contracts are what the 4 RED tests below check.

<threat_model>

Trust Boundaries

Boundary Description
test runner → source modules Vitest test files import from the source tree; a misconfigured include could leak production code-paths into the runtime

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-1-NEW-02-01 Tampering / supply-chain package.json Vitest install mitigate Pin Vitest at a major version (^3 or whatever npm view vitest version shows on install day) and let npm install produce a deterministic package-lock.json. No vitest-chrome dep: lightweight inline stub in each test instead, so we don't widen the supply chain for a four-file test setup.
T-1-NEW-02-02 Information Disclosure test output accept Tests run in Node; no captured user video can leak. Tests use mocked Blob ({ size: N } as Blob) — no real screen content involved.

(Phase-wide T-1-01..T-1-04 are addressed in their respective implementation plans.) </threat_model>

Task 1: Install Vitest, add npm test script, run npm install package.json - package.json (lines 1-21 — currently no Vitest, no test script) - .planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md §"Environment Availability" + §"Wave 0 Gaps" (lines 1058-1078 + 1147-1162) Three edits to `package.json`. Use the Edit tool, NOT a manual JSON rewrite (preserve formatting):

(1) Add vitest to devDependencies. Pin to the latest stable major at install time. Run this command first to discover the current version:

LATEST_VITEST=$(npm view vitest version)
echo "Latest vitest is $LATEST_VITEST"

Then add to devDependencies in package.json: "vitest": "^<MAJOR>" where <MAJOR> is the major number returned (e.g., ^3 if npm view vitest version returns 3.1.4). Do NOT pin a beta/RC; if the latest is non-stable, fall back to the previous stable major (verify with npm view vitest versions --json | tail -20).

(2) Add an "test": "vitest run" script to the scripts block. The full scripts block becomes VERBATIM:

  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview",
    "test": "vitest run"
  },

(3) Run npm install in the repo root:

npm install

This both installs Vitest AND brings the existing devDependencies up to date (the current repo has no node_modules/ per RESEARCH.md Runtime State Inventory). Confirm node_modules/vitest/ exists after.

If npm install produces a package-lock.json for the first time (it should — there is no committed lockfile today), this is expected; commit it together with the package.json change. test -d node_modules/vitest && grep -c ""vitest"" package.json && grep -c ""test": "vitest run"" package.json <acceptance_criteria> - test -d node_modules/vitest exits 0 - grep -c "\"vitest\"" package.json returns at least 1 - grep -c "\"test\": \"vitest run\"" package.json returns 1 - node -e "require('./package.json')" exits 0 (JSON valid) - npx vitest --version prints a version string (Vitest CLI reachable) - package-lock.json exists at repo root (committed) </acceptance_criteria> Vitest available; npm test is wired but will fail because the 4 RED tests are about to be added in the next tasks.

Task 2: Create vitest.config.ts vitest.config.ts - tsconfig.json (lines 1-20 — `strict: true`, `target: ES2020`, `moduleResolution: bundler`) - .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §`vitest.config.ts` (lines 684-712) - vite.config.ts (lines 1-12 — `defineConfig` shape to mirror) Create `vitest.config.ts` at the repo root with VERBATIM content:
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'node',
    include: ['tests/**/*.test.ts'],
    reporters: 'dot',
    typecheck: {
      enabled: false,
    },
  },
});

Notes:

  • environment: 'node' — Vitest 3+ shims Blob via undici in Node mode, so our test code can construct { size: 1024 } as unknown as Blob without a real DOM. Confirmed in RESEARCH.md §"Validation Architecture" line 1104.
  • include: ['tests/**/*.test.ts'] — scoped strictly to tests/; production code under src/ is never picked up as a test.
  • typecheck.enabled: falsetsc --noEmit runs separately as part of npm run build. Running typecheck via Vitest would duplicate work.

Do not add globals: true — we want explicit import { describe, it, expect } from 'vitest' in every test for clarity.

Do not add path aliases — tsconfig.json does not define any, and tests can use relative imports (../../src/offscreen/recorder). node -e "const c=require('./vitest.config.ts'.replace('.ts','.js'));" 2>/dev/null; test -f vitest.config.ts && grep -c "environment: 'node'" vitest.config.ts <acceptance_criteria> - test -f vitest.config.ts exits 0 - grep -c "environment: 'node'" vitest.config.ts returns 1 - grep -c "include: \['tests/\*\*/\*.test.ts'\]" vitest.config.ts returns 1 - grep -c "defineConfig" vitest.config.ts returns 1 - npx vitest run --reporter=dot 2>&1 produces output (even if all tests fail with module-resolution errors — that's expected because tests don't exist yet at this task) </acceptance_criteria> vitest.config.ts is committed; npx vitest run finds zero test files but exits cleanly (no config error).

Task 3: Create tests/offscreen/ring-buffer.test.ts (RED — first chunk + trim 30s) tests/offscreen/ring-buffer.test.ts, tests/fixtures/.gitkeep - .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §`tests/offscreen/ring-buffer.test.ts` (lines 534-568) - .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md (D-10, D-11) - .planning/intel/constraints.md §CON-video-window + §CON-webm-header-retention - src/background/index.ts (lines 26-75 — the ring-buffer logic that is being relocated to `src/offscreen/recorder.ts` in Plan 03; the test is written against the relocated module's export surface) - Test 1 (`first chunk is header`): When `resetBuffer()` is called then `addChunk({ size: 1024 } as Blob, 1_000)` is called, `getBuffer()[0].isFirst` MUST be `true`. - Test 2 (`second chunk is NOT header`): A second `addChunk` produces an entry with `isFirst: false` (or `undefined`). - Test 3 (`trim 30s — keeps header, evicts aged tail`): With one header chunk at t=0, one body chunk at t=10_000, one body chunk at t=35_000, and `trimAged(40_000)`, the resulting buffer MUST start with `isFirst: true` and MUST be length >= 2 (header + the t=35_000 chunk; the t=10_000 chunk has age 30_000 ms which is at the boundary — Plan 03 uses strict `<` so it gets trimmed at exactly 30_000 but the test only asserts ≥ 2 to be tolerant). - Test 4 (`trim with empty buffer`): `trimAged(0)` on an empty buffer does not throw. Create `tests/offscreen/ring-buffer.test.ts` with VERBATIM content:
import { describe, it, expect, beforeEach } from 'vitest';
import { addChunk, trimAged, getBuffer, resetBuffer } from '../../src/offscreen/recorder';

describe('ring buffer', () => {
  beforeEach(() => resetBuffer());

  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([]);
  });
});

Also create tests/fixtures/.gitkeep with empty content (Plan 07 will manually drop a known-good last_30sec.webm into this directory after the manual smoke test). test -f tests/offscreen/ring-buffer.test.ts && test -f tests/fixtures/.gitkeep && (npx vitest run tests/offscreen/ring-buffer.test.ts 2>&1 | grep -qE "Cannot find module|Failed to resolve") <acceptance_criteria> - test -f tests/offscreen/ring-buffer.test.ts exits 0 - test -f tests/fixtures/.gitkeep exits 0 - The test file imports from '../../src/offscreen/recorder' (verify: grep -c "from '../../src/offscreen/recorder'" tests/offscreen/ring-buffer.test.ts returns 1) - npx vitest run tests/offscreen/ring-buffer.test.ts 2>&1 exits NON-ZERO AND contains either "Cannot find module" or "Failed to resolve" — this is the RED gate (the source module doesn't exist yet; Plan 03 will create it) - No as any and no @ts-ignore in the test file (CLAUDE.md naming rules) </acceptance_criteria> Four-test RED file for the ring buffer exists. Vitest can find it but fails to import the source module — exactly the RED gate Plan 03 will flip to GREEN.

Task 4: Create tests/offscreen/codec-check.test.ts (RED — codec strict-mode) tests/offscreen/codec-check.test.ts - .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §`tests/offscreen/codec-check.test.ts` (lines 572-603) - .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md §"Claude's Discretion" (codec strictness) - .planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md §"Pattern 6: Codec strict-mode" + §"Example E" - Test 1 (`throws on unsupported vp9 and emits RECORDING_ERROR`): When `MediaRecorder.isTypeSupported` is mocked to return `false`, `assertCodecSupported()` MUST throw an Error whose message contains the literal substring `"vp9 unsupported"`. It MUST also call `chrome.runtime.sendMessage` with an object that has `type: 'RECORDING_ERROR'`. - Test 2 (`does not throw when vp9 IS supported`): When `MediaRecorder.isTypeSupported` returns `true`, `assertCodecSupported()` MUST NOT throw and MUST NOT call `chrome.runtime.sendMessage`. Create `tests/offscreen/codec-check.test.ts` with VERBATIM content:
import { describe, it, expect, vi, beforeEach } from 'vitest';

interface ChromeStub {
  runtime: { sendMessage: ReturnType<typeof vi.fn> };
}

interface GlobalWithChrome {
  chrome?: ChromeStub;
  MediaRecorder?: { isTypeSupported: (mime: string) => boolean };
}

describe('codec strict mode', () => {
  beforeEach(() => {
    vi.resetModules();
    (globalThis as unknown as GlobalWithChrome).chrome = {
      runtime: { sendMessage: vi.fn() },
    };
  });

  it('throws on unsupported vp9 and emits RECORDING_ERROR', async () => {
    (globalThis as unknown as GlobalWithChrome).MediaRecorder = {
      isTypeSupported: vi.fn().mockReturnValue(false),
    };
    const mod = await import('../../src/offscreen/recorder');
    expect(() => mod.assertCodecSupported()).toThrow(/vp9 unsupported/);
    const stub = (globalThis as unknown as GlobalWithChrome).chrome!;
    expect(stub.runtime.sendMessage).toHaveBeenCalledWith(
      expect.objectContaining({ type: 'RECORDING_ERROR' })
    );
  });

  it('does not throw when vp9 IS supported', async () => {
    (globalThis as unknown as GlobalWithChrome).MediaRecorder = {
      isTypeSupported: vi.fn().mockReturnValue(true),
    };
    const mod = await import('../../src/offscreen/recorder');
    expect(() => mod.assertCodecSupported()).not.toThrow();
    const stub = (globalThis as unknown as GlobalWithChrome).chrome!;
    expect(stub.runtime.sendMessage).not.toHaveBeenCalledWith(
      expect.objectContaining({ type: 'RECORDING_ERROR' })
    );
  });
});

Notes:

  • vi.resetModules() between tests is critical: the module-import side-effects (Pattern 4 OFFSCREEN_READY, Pattern 5 port.connect) happen ONCE per module load; without reset, the test isolation breaks across the four test files.
  • Mock chrome.runtime with a minimal stub — no vitest-chrome dependency.
  • Type the mock with explicit interfaces (no as any, per CLAUDE.md). test -f tests/offscreen/codec-check.test.ts && (npx vitest run tests/offscreen/codec-check.test.ts 2>&1 | grep -qE "Cannot find module|Failed to resolve") <acceptance_criteria>
    • test -f tests/offscreen/codec-check.test.ts exits 0
    • grep -c "vp9 unsupported" tests/offscreen/codec-check.test.ts returns at least 1
    • grep -c "RECORDING_ERROR" tests/offscreen/codec-check.test.ts returns at least 1
    • npx vitest run tests/offscreen/codec-check.test.ts 2>&1 exits NON-ZERO AND output contains "Cannot find module" or "Failed to resolve"
    • No as any and no @ts-ignore in the test file </acceptance_criteria> RED test for codec strict-mode exists. Plan 03 will export assertCodecSupported to flip it GREEN.
Task 5: Create tests/offscreen/handshake.test.ts and tests/offscreen/port.test.ts (RED) tests/offscreen/handshake.test.ts, tests/offscreen/port.test.ts - .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §`tests/offscreen/handshake.test.ts` + §`tests/offscreen/port.test.ts` (lines 607-680) - .planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md §"Pattern 4: OFFSCREEN_READY handshake" + §"Pattern 5: Long-lived port" + §"Pitfall 4" - src/shared/types.ts (line 18 — `OFFSCREEN_READY` declared but unused) handshake.test.ts: - Test 1 (`sends OFFSCREEN_READY after listener registration`): Importing `src/offscreen/recorder` MUST call `chrome.runtime.sendMessage` with an object whose `type` is `'OFFSCREEN_READY'` exactly once. The call MUST happen AFTER `chrome.runtime.onMessage.addListener` is called.
port.test.ts:
- Test 1 (`connects on module load`): Importing `src/offscreen/recorder` MUST call `chrome.runtime.connect({ name: 'video-keepalive' })` exactly once.
- Test 2 (`reconnects when port disconnects`): When the connected port fires its registered `onDisconnect` listener, the module MUST call `chrome.runtime.connect` a second time within the synchronous tick.
Create `tests/offscreen/handshake.test.ts` with VERBATIM content:
import { describe, it, expect, vi, beforeEach } from 'vitest';

interface PortStub {
  name: string;
  postMessage: ReturnType<typeof vi.fn>;
  onMessage: { addListener: ReturnType<typeof vi.fn> };
  onDisconnect: { addListener: ReturnType<typeof vi.fn> };
  disconnect: ReturnType<typeof vi.fn>;
}

interface ChromeStub {
  runtime: {
    id: string;
    sendMessage: (m: unknown) => void;
    onMessage: { addListener: ReturnType<typeof vi.fn> };
    connect: () => PortStub;
  };
}

interface GlobalWithChrome {
  chrome?: ChromeStub;
  MediaRecorder?: { isTypeSupported: (mime: string) => boolean };
}

function buildChromeStub(calls: unknown[]): ChromeStub {
  return {
    runtime: {
      id: 'ext-id-test',
      sendMessage: (m: unknown) => {
        calls.push(m);
      },
      onMessage: { addListener: vi.fn() },
      connect: () => ({
        name: 'video-keepalive',
        postMessage: vi.fn(),
        onMessage: { addListener: vi.fn() },
        onDisconnect: { addListener: vi.fn() },
        disconnect: vi.fn(),
      }),
    },
  };
}

describe('OFFSCREEN_READY handshake', () => {
  beforeEach(() => {
    vi.resetModules();
    (globalThis as unknown as GlobalWithChrome).MediaRecorder = {
      isTypeSupported: vi.fn().mockReturnValue(true),
    };
  });

  it('sends OFFSCREEN_READY after listener registration', async () => {
    const calls: unknown[] = [];
    const stub = buildChromeStub(calls);
    (globalThis as unknown as GlobalWithChrome).chrome = stub;
    await import('../../src/offscreen/recorder');
    expect(stub.runtime.onMessage.addListener).toHaveBeenCalled();
    expect(calls).toEqual(
      expect.arrayContaining([expect.objectContaining({ type: 'OFFSCREEN_READY' })])
    );
    const readyCount = calls.filter(
      (m): m is { type: string } =>
        typeof m === 'object' && m !== null && (m as { type?: unknown }).type === 'OFFSCREEN_READY'
    ).length;
    expect(readyCount).toBe(1);
  });
});

Create tests/offscreen/port.test.ts with VERBATIM content:

import { describe, it, expect, vi, beforeEach } from 'vitest';

interface PortStub {
  name: string;
  postMessage: ReturnType<typeof vi.fn>;
  onMessage: { addListener: ReturnType<typeof vi.fn> };
  onDisconnect: { addListener: (fn: () => void) => void };
  disconnect: ReturnType<typeof vi.fn>;
}

interface ChromeStub {
  runtime: {
    id: string;
    sendMessage: ReturnType<typeof vi.fn>;
    onMessage: { addListener: ReturnType<typeof vi.fn> };
    connect: () => PortStub;
  };
}

interface GlobalWithChrome {
  chrome?: ChromeStub;
  MediaRecorder?: { isTypeSupported: (mime: string) => boolean };
}

describe('port reconnect', () => {
  beforeEach(() => {
    vi.resetModules();
    (globalThis as unknown as GlobalWithChrome).MediaRecorder = {
      isTypeSupported: vi.fn().mockReturnValue(true),
    };
  });

  it('connects on module load', async () => {
    let connectCount = 0;
    const disconnectListeners: Array<() => void> = [];
    const stub: ChromeStub = {
      runtime: {
        id: 'ext-id-test',
        sendMessage: vi.fn(),
        onMessage: { addListener: vi.fn() },
        connect: () => {
          connectCount++;
          return {
            name: 'video-keepalive',
            postMessage: vi.fn(),
            onMessage: { addListener: vi.fn() },
            onDisconnect: {
              addListener: (fn: () => void) => disconnectListeners.push(fn),
            },
            disconnect: vi.fn(),
          };
        },
      },
    };
    (globalThis as unknown as GlobalWithChrome).chrome = stub;
    await import('../../src/offscreen/recorder');
    expect(connectCount).toBe(1);
  });

  it('reconnects when port disconnects', async () => {
    let connectCount = 0;
    const disconnectListeners: Array<() => void> = [];
    const stub: ChromeStub = {
      runtime: {
        id: 'ext-id-test',
        sendMessage: vi.fn(),
        onMessage: { addListener: vi.fn() },
        connect: () => {
          connectCount++;
          return {
            name: 'video-keepalive',
            postMessage: vi.fn(),
            onMessage: { addListener: vi.fn() },
            onDisconnect: {
              addListener: (fn: () => void) => disconnectListeners.push(fn),
            },
            disconnect: vi.fn(),
          };
        },
      },
    };
    (globalThis as unknown as GlobalWithChrome).chrome = stub;
    await import('../../src/offscreen/recorder');
    expect(connectCount).toBe(1);
    // Fire the disconnect — module should reconnect
    disconnectListeners.forEach((fn) => fn());
    expect(connectCount).toBeGreaterThanOrEqual(2);
  });
});
test -f tests/offscreen/handshake.test.ts && test -f tests/offscreen/port.test.ts && (npx vitest run tests/offscreen/handshake.test.ts tests/offscreen/port.test.ts 2>&1 | grep -qE "Cannot find module|Failed to resolve") - Both test files exist - `grep -c "OFFSCREEN_READY" tests/offscreen/handshake.test.ts` returns at least 1 - `grep -c "video-keepalive" tests/offscreen/port.test.ts` returns at least 1 - `grep -c "reconnects" tests/offscreen/port.test.ts` returns at least 1 - `npx vitest run tests/offscreen/handshake.test.ts tests/offscreen/port.test.ts 2>&1` exits NON-ZERO AND output contains "Cannot find module" or "Failed to resolve" - No `as any` and no `@ts-ignore` in either test file (the cast pattern is `as unknown as X` which is acceptable per CLAUDE.md — narrows progressively without bypassing the type-checker) Two more RED tests pin the handshake (Pattern 4) and port reconnect (Pattern 5 / Pitfall 4) contracts. Plan 04 will flip both to GREEN. After all five tasks land:
  1. test -d node_modules/vitest && test -f vitest.config.ts && test -f package-lock.json — exits 0.
  2. ls tests/offscreen/*.test.ts | wc -l returns 4 (ring-buffer, codec-check, handshake, port).
  3. ls tests/fixtures/.gitkeep exits 0.
  4. npx vitest run 2>&1 | grep -cE "Failed to resolve|Cannot find module" returns at least 4 (one per test file — all RED because src/offscreen/recorder.ts doesn't exist yet).
  5. npx tsc --noEmit 2>&1 — the test files reference a module that doesn't exist, so TypeScript will error. THIS IS EXPECTED. Plan 03 will resolve this. To make the RED gate clean: confirm the typescript errors are ONLY about the missing module (Cannot find module '../../src/offscreen/recorder'), not about test syntax mistakes.

Commit cadence: ONE commit per task (five atomic commits). Commit messages: test(01-02): wave-0 setup — install vitest, test(01-02): add vitest.config.ts, test(01-02): add RED ring-buffer tests, test(01-02): add RED codec-check tests, test(01-02): add RED handshake + port tests.

The "RED gate" is met because:

  • The 4 test files import from '../../src/offscreen/recorder'
  • That module does not yet exist (Plan 03 creates it)
  • Therefore every test fails at the IMPORT step (not at the assertion step)
  • This is exactly the Nyquist TDD signal: a contract pinned in tests, an implementation gap waiting to be filled.

<success_criteria>

  • Vitest installed and runnable (npx vitest --version works)
  • npm test script exists and runs vitest run
  • Four RED test files exist, each importing src/offscreen/recorder (which doesn't exist yet — this is INTENTIONAL)
  • tests/fixtures/ directory committed (empty placeholder via .gitkeep)
  • No as any / @ts-ignore in any test file
  • All five commits land cleanly </success_criteria>
After completion, create `.planning/phases/01-stabilize-video-pipeline/01-02-SUMMARY.md` with: - Vitest version that was actually installed - Exact list of new files (paths and line counts) - Output of `npx vitest run 2>&1` showing all 4 tests RED at the import step - Commit list (five commits) - Note: "Plan 03 must export {addChunk, trimAged, getBuffer, resetBuffer, assertCodecSupported, VIDEO_BUFFER_DURATION_MS} from src/offscreen/recorder.ts to flip these tests to GREEN. Plan 04 wires up the OFFSCREEN_READY send (handshake test) and the port.connect + reconnect-on-disconnect (port tests)."