630 lines
29 KiB
Markdown
630 lines
29 KiB
Markdown
---
|
|
phase: 01-stabilize-video-pipeline
|
|
plan: 02
|
|
type: execute
|
|
wave: 0
|
|
depends_on: ["01"]
|
|
files_modified:
|
|
- 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
|
|
autonomous: true
|
|
requirements:
|
|
- REQ-video-ring-buffer
|
|
requirements_addressed:
|
|
- REQ-video-ring-buffer
|
|
|
|
must_haves:
|
|
truths:
|
|
- "`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)"
|
|
artifacts:
|
|
- path: "package.json"
|
|
provides: "vitest devDep + npm test script + vitest-chrome mock"
|
|
contains: "\"vitest\""
|
|
- path: "vitest.config.ts"
|
|
provides: "node-env test config matching project tsconfig"
|
|
contains: "environment: 'node'"
|
|
- path: "tests/offscreen/ring-buffer.test.ts"
|
|
provides: "RED tests for header-pinning + 30 s trim (D-10/D-11)"
|
|
contains: "first chunk is header"
|
|
- path: "tests/offscreen/codec-check.test.ts"
|
|
provides: "RED test for codec strict-mode (D-20)"
|
|
contains: "vp9 unsupported"
|
|
- path: "tests/offscreen/handshake.test.ts"
|
|
provides: "RED test for OFFSCREEN_READY (Pattern 4)"
|
|
contains: "OFFSCREEN_READY"
|
|
- path: "tests/offscreen/port.test.ts"
|
|
provides: "RED test for port reconnect (Pattern 5 / Pitfall 4)"
|
|
contains: "reconnects"
|
|
- path: "tests/fixtures/.gitkeep"
|
|
provides: "marker so the fixtures directory survives clean checkouts"
|
|
contains: ""
|
|
key_links:
|
|
- from: "tests/offscreen/*.test.ts"
|
|
to: "src/offscreen/recorder.ts"
|
|
via: "import { ... } from '../../src/offscreen/recorder'"
|
|
pattern: "from '../../src/offscreen/recorder'"
|
|
- from: "package.json"
|
|
to: "vitest.config.ts"
|
|
via: "npm test script"
|
|
pattern: "\"test\": \"vitest run\""
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<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
|
|
</context>
|
|
|
|
<interfaces>
|
|
<!-- The recorder module that Plan 03 will create. These tests are written
|
|
against THIS interface. Plan 03 MUST export every symbol listed here. -->
|
|
|
|
Expected exports from `src/offscreen/recorder.ts` (created in Plan 03):
|
|
|
|
```typescript
|
|
// 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.
|
|
</interfaces>
|
|
|
|
<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>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Install Vitest, add npm test script, run npm install</name>
|
|
<files>package.json</files>
|
|
<read_first>
|
|
- 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)
|
|
</read_first>
|
|
<action>
|
|
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:
|
|
```bash
|
|
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:
|
|
```json
|
|
"scripts": {
|
|
"dev": "vite",
|
|
"build": "tsc && vite build",
|
|
"preview": "vite preview",
|
|
"test": "vitest run"
|
|
},
|
|
```
|
|
|
|
(3) Run `npm install` in the repo root:
|
|
```bash
|
|
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.
|
|
</action>
|
|
<verify>
|
|
<automated>test -d node_modules/vitest && grep -c "\"vitest\"" package.json && grep -c "\"test\": \"vitest run\"" package.json</automated>
|
|
</verify>
|
|
<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>
|
|
<done>Vitest available; `npm test` is wired but will fail because the 4 RED tests are about to be added in the next tasks.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Create vitest.config.ts</name>
|
|
<files>vitest.config.ts</files>
|
|
<read_first>
|
|
- 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)
|
|
</read_first>
|
|
<action>
|
|
Create `vitest.config.ts` at the repo root with VERBATIM content:
|
|
|
|
```typescript
|
|
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: false` — `tsc --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`).
|
|
</action>
|
|
<verify>
|
|
<automated>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</automated>
|
|
</verify>
|
|
<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>
|
|
<done>`vitest.config.ts` is committed; `npx vitest run` finds zero test files but exits cleanly (no config error).</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 3: Create tests/offscreen/ring-buffer.test.ts (RED — first chunk + trim 30s)</name>
|
|
<files>tests/offscreen/ring-buffer.test.ts, tests/fixtures/.gitkeep</files>
|
|
<read_first>
|
|
- .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)
|
|
</read_first>
|
|
<behavior>
|
|
- 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.
|
|
</behavior>
|
|
<action>
|
|
Create `tests/offscreen/ring-buffer.test.ts` with VERBATIM content:
|
|
|
|
```typescript
|
|
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).
|
|
</action>
|
|
<verify>
|
|
<automated>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")</automated>
|
|
</verify>
|
|
<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>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 4: Create tests/offscreen/codec-check.test.ts (RED — codec strict-mode)</name>
|
|
<files>tests/offscreen/codec-check.test.ts</files>
|
|
<read_first>
|
|
- .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"
|
|
</read_first>
|
|
<behavior>
|
|
- 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`.
|
|
</behavior>
|
|
<action>
|
|
Create `tests/offscreen/codec-check.test.ts` with VERBATIM content:
|
|
|
|
```typescript
|
|
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).
|
|
</action>
|
|
<verify>
|
|
<automated>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")</automated>
|
|
</verify>
|
|
<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>
|
|
<done>RED test for codec strict-mode exists. Plan 03 will export `assertCodecSupported` to flip it GREEN.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 5: Create tests/offscreen/handshake.test.ts and tests/offscreen/port.test.ts (RED)</name>
|
|
<files>tests/offscreen/handshake.test.ts, tests/offscreen/port.test.ts</files>
|
|
<read_first>
|
|
- .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)
|
|
</read_first>
|
|
<behavior>
|
|
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.
|
|
</behavior>
|
|
<action>
|
|
Create `tests/offscreen/handshake.test.ts` with VERBATIM content:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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);
|
|
});
|
|
});
|
|
```
|
|
</action>
|
|
<verify>
|
|
<automated>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")</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- 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)
|
|
</acceptance_criteria>
|
|
<done>Two more RED tests pin the handshake (Pattern 4) and port reconnect (Pattern 5 / Pitfall 4) contracts. Plan 04 will flip both to GREEN.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
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.
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
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)."
|
|
</output>
|