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 | 0 |
|
|
true |
|
|
|
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.tsExpected 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.addListenercalled at least oncechrome.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 callschrome.runtime.connectagain (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.
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 Blobwithout a real DOM. Confirmed in RESEARCH.md §"Validation Architecture" line 1104.include: ['tests/**/*.test.ts']— scoped strictly totests/; production code undersrc/is never picked up as a test.typecheck.enabled: false—tsc --noEmitruns separately as part ofnpm 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).
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.
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.runtimewith a minimal stub — novitest-chromedependency. - 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.tsexits 0grep -c "vp9 unsupported" tests/offscreen/codec-check.test.tsreturns at least 1grep -c "RECORDING_ERROR" tests/offscreen/codec-check.test.tsreturns at least 1npx vitest run tests/offscreen/codec-check.test.ts 2>&1exits NON-ZERO AND output contains "Cannot find module" or "Failed to resolve"- No
as anyand no@ts-ignorein the test file </acceptance_criteria> RED test for codec strict-mode exists. Plan 03 will exportassertCodecSupportedto flip it GREEN.
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 -d node_modules/vitest && test -f vitest.config.ts && test -f package-lock.json— exits 0.ls tests/offscreen/*.test.ts | wc -lreturns 4 (ring-buffer, codec-check, handshake, port).ls tests/fixtures/.gitkeepexits 0.npx vitest run 2>&1 | grep -cE "Failed to resolve|Cannot find module"returns at least 4 (one per test file — all RED becausesrc/offscreen/recorder.tsdoesn't exist yet).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 --versionworks) npm testscript exists and runsvitest 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-ignorein any test file - All five commits land cleanly </success_criteria>