diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 1e1d381..1bdd7e0 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -64,7 +64,14 @@ directory + `vite.config.ts` inline string + `src/background/`. concatenating header + buffered chunks yields a byte sequence a browser would play. -**Plans**: TBD +**Plans**: 7 plans +- [ ] 01-01-PLAN.md — Doc cascade: amend DEC-003 / DEC-010 / RETIRE constraints / swap manifest permissions (D-A1..D-A6) +- [ ] 01-02-PLAN.md — Wave-0 test infrastructure: Vitest install + 4 RED test files + fixtures placeholder +- [ ] 01-03-PLAN.md — Offscreen recorder TDD: ring buffer + codec strict-mode + getDisplayMedia + track-ended cleanup; D-13 fallback skeleton pre-staged +- [ ] 01-04-PLAN.md — Port keepalive + OFFSCREEN_READY handshake (TDD): replaces alarms keepalive on offscreen side +- [ ] 01-05-PLAN.md — SW shrink: delete legacy buffer + alarms + IndexedDB + tabCapture paths; wire SW-side onConnect host +- [ ] 01-06-PLAN.md — Build pipeline collapse: delete vite.config.ts inline plugin + top-level offscreen/ dir; declare rollupOptions.input +- [ ] 01-07-PLAN.md — Manual smoke + ffprobe D-12 acceptance gate; commit regression fixture ### Phase 2: Stabilize DOM + event capture privacy **Goal**: rrweb captures DOM events on typical pages and the user-event log diff --git a/.planning/phases/01-stabilize-video-pipeline/01-01-PLAN.md b/.planning/phases/01-stabilize-video-pipeline/01-01-PLAN.md new file mode 100644 index 0000000..82378a7 --- /dev/null +++ b/.planning/phases/01-stabilize-video-pipeline/01-01-PLAN.md @@ -0,0 +1,434 @@ +--- +phase: 01-stabilize-video-pipeline +plan: 01 +type: execute +wave: 0 +depends_on: [] +files_modified: + - .planning/PROJECT.md + - .planning/REQUIREMENTS.md + - .planning/ROADMAP.md + - .planning/intel/decisions.md + - .planning/intel/constraints.md + - manifest.json +autonomous: true +requirements: + - REQ-video-ring-buffer +requirements_addressed: + - REQ-video-ring-buffer + +must_haves: + truths: + - "PROJECT.md DEC-003, DEC-010 reflect the getDisplayMedia + port-keepalive amendments" + - "REQUIREMENTS.md REQ-video-ring-buffer no longer says 'active-tab' and binds to getDisplayMedia" + - "ROADMAP.md Phase 1 Success Criterion #2 no longer references tab re-attach" + - "intel/decisions.md DEC-003 and DEC-010 carry an Amendment block" + - "intel/constraints.md CON-tab-capture-binding and CON-service-worker-keepalive are RETIRED with a CON-display-capture-binding added" + - "manifest.json permissions list contains desktopCapture (not tabCapture) and drops the now-unused alarms entry" + - "Every code-touching plan (02-07) sees a consistent doc baseline before it runs" + artifacts: + - path: ".planning/PROJECT.md" + provides: "Amended DEC-003 / DEC-010 rows and amended Constraints section" + contains: "getDisplayMedia" + - path: ".planning/REQUIREMENTS.md" + provides: "REQ-video-ring-buffer wording without 'active-tab' and bound to getDisplayMedia" + contains: "getDisplayMedia" + - path: ".planning/ROADMAP.md" + provides: "Phase 1 Success Criterion #2 with tab-reattach clause removed" + contains: "MediaRecorder" + - path: ".planning/intel/decisions.md" + provides: "DEC-003 and DEC-010 with Amendment blocks" + contains: "## Amendment" + - path: ".planning/intel/constraints.md" + provides: "RETIRED markers + new CON-display-capture-binding" + contains: "CON-display-capture-binding" + - path: "manifest.json" + provides: "Permission swap" + contains: "desktopCapture" + key_links: + - from: "manifest.json" + to: "intel/constraints.md" + via: "lockstep permission set" + pattern: "desktopCapture" + - from: "intel/decisions.md" + to: "PROJECT.md" + via: "Amendment block referenced from Key Decisions table" + pattern: "AMENDED" +--- + + +Doc-cascade for D-A1..D-A6 + manifest permission swap (D-05/D-A6). This is +Wave 0 work that MUST precede every code-touching plan in this phase so +downstream agents see a consistent baseline (DEC-003 amended to +`getDisplayMedia`, DEC-010 amended to port keepalive, manifest permissions +swapped to `desktopCapture`). Plan 01 makes zero code-runtime changes — only +text edits to planning docs and a single permissions edit in `manifest.json`. + +Purpose: Phase 2 and later phases will read PROJECT.md / REQUIREMENTS.md / +ROADMAP.md to understand what was decided. Leaving them stale would carry the +old `chrome.tabCapture` contract forward and cause downstream agents to +silently re-introduce active-tab assumptions. + +Output: Six amended doc files + the manifest permissions block in its final +Phase-1 shape. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/REQUIREMENTS.md +@.planning/ROADMAP.md +@.planning/intel/decisions.md +@.planning/intel/constraints.md +@.planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md +@manifest.json + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| extension manifest → Chrome runtime | Permissions block declares the API surface Chrome will grant; minimising this is the policy boundary | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-1-02 | Information Disclosure / over-privileged | `manifest.json` permissions array | mitigate | Drop `tabCapture` and `alarms` (no longer used); add `desktopCapture` (matches CONTEXT.md D-05 / D-A6). `host_permissions: [""]` and `activeTab` retained — already justified by Phase 3 screenshot path. Grep gate: `grep -v '^#' manifest.json \| grep -c '"tabCapture"' == 0 && grep -v '^#' manifest.json \| grep -c '"desktopCapture"' == 1` | + +(T-1-01 codec downgrade and T-1-04 port-hijack are addressed in Plans 03 and 04. T-1-03 stream-content-leakage is an accepted residual risk per CONTEXT.md D-04 — recorded in Plan 03's threat model.) + + + + + + Task 1: Amend intel/decisions.md DEC-003 and DEC-010 (D-A1) + .planning/intel/decisions.md + + - .planning/intel/decisions.md (lines 48-60 DEC-003, lines 145-153 DEC-010) + - .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md §"Doc Amendments (precede code)" (D-A1) + + +Append an Amendment block to DEC-003 immediately AFTER line 59 (the `Confirming source` line). The amendment block reads VERBATIM: + +```markdown + +## Amendment (Phase 01-stabilize-video-pipeline, 2026-05-15) + +- AMENDED-BY: Phase 01 CONTEXT.md D-01..D-05 +- Replace `chrome.tabCapture.capture()` with `navigator.mediaDevices.getDisplayMedia()` called from the offscreen document. +- Offscreen document is created with `chrome.offscreen.Reason.DISPLAY_MEDIA` (replaces `USER_MEDIA`). +- Codec/bitrate/timeslice binding unchanged: `video/webm; codecs=vp9` @ 400 000 bps, MediaRecorder timeslice 2000 ms. +- Trade-off accepted: SPEC §1 "silent operation" is given up — Chrome's permanent "Sharing your screen" indicator is shown while recording. Phase 1 accepts this in exchange for broader capture coverage and elimination of `tabCapture` user-gesture juggling. +- Tab-switch re-attachment clause is REMOVED — `getDisplayMedia` captures a screen/window, not a tab. There is nothing to re-attach. +- Manifest permission `tabCapture` is REPLACED with `desktopCapture` (the latter is harmless: `getDisplayMedia` is a web standard API and does NOT actually require `desktopCapture`, but we declare it for clarity per CONTEXT.md D-05). +``` + +Then append an Amendment block to DEC-010 immediately AFTER line 152 (the `chrome.alarms` decision line). The amendment block reads VERBATIM: + +```markdown + +## Amendment (Phase 01-stabilize-video-pipeline, 2026-05-15) + +- AMENDED-BY: Phase 01 CONTEXT.md D-17..D-18 +- Replace `chrome.alarms`-driven 20 s keepalive with a long-lived `chrome.runtime.connect` port opened from the offscreen document to the Service Worker. The port emits a `PING` message every 25 s; both directions of traffic reset the SW's 30 s idle timer per Chrome 110+ semantics (developer.chrome.com/blog/longer-esw-lifetimes). +- The `alarms` permission is removed from `manifest.json` (it is no longer used by Phase 1; Phase 2 / 3 may re-add if needed). +- Port lifetime cap (~5 minutes per Chromium-extensions community gist sunnyguan/f94058f66fab89e59e75b1ac1bf1a06e) is mitigated by reconnecting on `onDisconnect` and pre-emptively at ~290 s. +- See `.planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md` Pattern 5 for the canonical implementation. +``` + +Do not modify any other text in decisions.md. + + + grep -c "AMENDED-BY: Phase 01" .planning/intel/decisions.md + + + - `grep -c "AMENDED-BY: Phase 01" .planning/intel/decisions.md` returns 2 + - `grep -c "getDisplayMedia" .planning/intel/decisions.md` returns at least 1 + - `grep -c "port" .planning/intel/decisions.md` returns at least 1 + - DEC-003 still has its original text intact (the amendment is APPENDED, not replacing) + - DEC-010 still has its original text intact (the amendment is APPENDED, not replacing) + + Both decisions carry an Amendment block; downstream phases that grep DEC-003 / DEC-010 now find the new contract alongside the original. + + + + Task 2: Amend intel/constraints.md — retire two, add one (D-A2) + .planning/intel/constraints.md + + - .planning/intel/constraints.md (lines 95-111 CON-tab-capture-binding and CON-service-worker-keepalive) + - .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md §"Doc Amendments (precede code)" (D-A2) + + +Two edits, both in `.planning/intel/constraints.md`: + +(1) Append a RETIRED block immediately AFTER line 102 (the end of CON-tab-capture-binding). VERBATIM text: + +```markdown + +### RETIRED (Phase 01-stabilize-video-pipeline, 2026-05-15) + +- RETIRED-BY: Phase 01 CONTEXT.md D-01 / D-A2 +- Reason: This phase replaces `chrome.tabCapture` with `navigator.mediaDevices.getDisplayMedia()`. The new API is not active-tab-bound; the recorder captures a screen / window selected once via Chrome's native picker and continues across tab switches. +- Replacement: CON-display-capture-binding (below). +``` + +(2) Append a RETIRED block immediately AFTER line 111 (the end of CON-service-worker-keepalive). VERBATIM text: + +```markdown + +### RETIRED (Phase 01-stabilize-video-pipeline, 2026-05-15) + +- RETIRED-BY: Phase 01 CONTEXT.md D-17 / D-A2 +- Reason: This phase replaces alarms-driven keepalive with a long-lived `chrome.runtime.connect` port between offscreen and Service Worker. Port-message traffic resets the SW idle timer per Chrome 110+ semantics. +- Replacement: CON-display-capture-binding (binds the port-keepalive expectations alongside the new capture API). +``` + +(3) Append a brand-new constraint immediately AFTER the existing `CON-buffer-storage` block (after line 196). VERBATIM text: + +```markdown + +--- + +## CON-display-capture-binding + +- Source: Phase 01 CONTEXT.md D-01..D-17, RESEARCH.md Patterns 1 & 5 +- Type: api-contract +- Constraint: Video capture uses `navigator.mediaDevices.getDisplayMedia()` invoked once per session from the offscreen document with `chrome.offscreen.Reason.DISPLAY_MEDIA`. The Service Worker is kept alive by a long-lived `chrome.runtime.connect({ name: 'video-keepalive' })` port opened by the offscreen, with traffic in both directions at a minimum cadence of 25 s and pre-emptive reconnect at 290 s. +- Replaces: CON-tab-capture-binding (RETIRED), CON-service-worker-keepalive (RETIRED). +- UX trade-off: Chrome's permanent "Sharing your screen" indicator is shown while recording. SPEC §1 silent-operation property is intentionally relaxed. +``` + +Do not modify any other text in constraints.md. + + + grep -c "RETIRED-BY: Phase 01" .planning/intel/constraints.md + + + - `grep -c "RETIRED-BY: Phase 01" .planning/intel/constraints.md` returns 2 + - `grep -c "## CON-display-capture-binding" .planning/intel/constraints.md` returns 1 + - The original `## CON-tab-capture-binding` and `## CON-service-worker-keepalive` headings still exist (RETIRED is APPENDED below them, not replacing them) + - `grep -c "video-keepalive" .planning/intel/constraints.md` returns at least 1 + + Two retired constraints carry RETIRED-BY markers; CON-display-capture-binding exists as the consolidated replacement constraint. + + + + Task 3: Amend PROJECT.md Key Decisions table and Constraints section (D-A3) + .planning/PROJECT.md + + - .planning/PROJECT.md (lines 79-110 Constraints section, lines 113-134 Key Decisions table) + - .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md §"Doc Amendments (precede code)" (D-A3) + + +Three edits in `.planning/PROJECT.md`: + +(1) DEC-003 row in the Key Decisions table — find line 124 starting `| **DEC-003**:` and replace the entire row with VERBATIM: + +```markdown +| **DEC-003**: Active video via `getDisplayMedia()` (vp9 / 400 kbps / 2000 ms) | AMENDED by Phase 01: SPEC §2/§4.1/§7 originally specified `chrome.tabCapture`; Phase 01 swaps to `getDisplayMedia` invoked in the offscreen document with `chrome.offscreen.Reason.DISPLAY_MEDIA`. Codec/bitrate/timeslice binding unchanged. See `.planning/intel/decisions.md` DEC-003 Amendment. | — Pending | locked (Phase 1, post-Amendment) | +``` + +(2) DEC-010 row — find line 131 starting `| **DEC-010**:` and replace the entire row with VERBATIM: + +```markdown +| **DEC-010**: Service Worker keepalive via long-lived port | AMENDED by Phase 01: SPEC §8 originally specified `chrome.alarms` at 20 s; Phase 01 swaps to a `chrome.runtime.connect` port between offscreen and SW with 25 s ping cadence and 290 s pre-emptive reconnect. See `.planning/intel/decisions.md` DEC-010 Amendment. | — Pending | locked (Phase 1, post-Amendment) | +``` + +(3) Constraints section — find line 100-101 (the `chrome.alarms` keepalive bullet) and replace with VERBATIM: + +```markdown +- **Service Worker lifecycle**: MV3 SW unloads after ~30 s idle; a long-lived + `chrome.runtime.connect` port from offscreen to SW emits a PING every 25 s + to keep the SW alive (CON-display-capture-binding, AMENDED from + CON-service-worker-keepalive). +- **Tab capture binding**: REMOVED (CON-tab-capture-binding RETIRED). The new + `getDisplayMedia` binding (CON-display-capture-binding) is screen/window- + scoped, not tab-scoped, and survives tab switches without re-attach. +``` + +(this replaces the two bullets currently at lines 97-102 — the existing SW-keepalive bullet AND the existing tab-capture-binding bullet — with the two replacement bullets above. The bullet ordering and the rest of the Constraints section are unchanged.) + +Do not modify any other text in PROJECT.md. + + + grep -c "AMENDED by Phase 01" .planning/PROJECT.md + + + - `grep -c "AMENDED by Phase 01" .planning/PROJECT.md` returns 2 (DEC-003 + DEC-010 rows) + - `grep -c "getDisplayMedia" .planning/PROJECT.md` returns at least 1 + - `grep -c "long-lived port" .planning/PROJECT.md` returns at least 1 + - `grep -c "RETIRED" .planning/PROJECT.md` returns at least 1 + - The `## Key Decisions` heading and the `## Constraints` heading still exist + - All other rows of the Key Decisions table are untouched + + PROJECT.md's Key Decisions table and Constraints section reflect the new contract; downstream readers of PROJECT.md see the amendments without needing to drill into intel/. + + + + Task 4: Amend REQUIREMENTS.md REQ-video-ring-buffer (D-A4) + .planning/REQUIREMENTS.md + + - .planning/REQUIREMENTS.md (lines 19-27 REQ-video-ring-buffer block) + - .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md §"Doc Amendments (precede code)" (D-A4) + + +Replace the entire REQ-video-ring-buffer entry (currently lines 19-27 starting `- [ ] **REQ-video-ring-buffer**`) with VERBATIM: + +```markdown +- [ ] **REQ-video-ring-buffer**: The extension maintains an in-memory ring + buffer containing the most recent 30 seconds of captured video. AMENDED in + Phase 01: video is acquired via `navigator.mediaDevices.getDisplayMedia()` + invoked from the offscreen document (with `chrome.offscreen.Reason.DISPLAY_MEDIA`), + NOT `chrome.tabCapture` as originally specified. The captured stream is + screen-or-window-scoped per the operator's one-time selection in Chrome's + native picker, and continues unchanged across tab switches. Encoding is + unchanged: `video/webm; codecs=vp9` @ 400 000 bps with a `MediaRecorder` + timeslice of 2000 ms; a single continuous recorder runs for the whole + session. The first emitted chunk (WebM header) is retained indefinitely; + subsequent chunks rotate out by the 30-second TTL rule. Bindings: DEC-003 + (AMENDED), DEC-009, CON-video-window, CON-video-codec, + CON-webm-header-retention, CON-display-capture-binding (replaces RETIRED + CON-tab-capture-binding). + - SPEC §10 acceptance criteria: #2, #3, #7. +``` + +Do not modify any other text in REQUIREMENTS.md. + + + grep -c "AMENDED in" .planning/REQUIREMENTS.md + + + - `grep -c "AMENDED in" .planning/REQUIREMENTS.md` returns at least 1 + - `grep -c "getDisplayMedia" .planning/REQUIREMENTS.md` returns at least 1 + - `grep -c "active-tab video" .planning/REQUIREMENTS.md` returns 0 (the old "active-tab" wording is removed) + - `grep -c "REQ-video-ring-buffer" .planning/REQUIREMENTS.md` returns at least 2 (the bullet + the Traceability table row) + - The Traceability table row for REQ-video-ring-buffer remains intact + + REQ-video-ring-buffer reads correctly for Phase 1 and binds the new constraint set. + + + + Task 5: Amend ROADMAP.md Phase 1 description + Success Criterion #2 (D-A5) + .planning/ROADMAP.md + + - .planning/ROADMAP.md (lines 25, 33-66 Phase 1 block) + - .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md §"Doc Amendments (precede code)" (D-A5) + + +Two edits in `.planning/ROADMAP.md`: + +(1) Replace line 25 VERBATIM with: + +```markdown +- [ ] **Phase 1: Stabilize video pipeline** — Collapse offscreen duality, fix MediaRecorder shadow, fix WebM ring buffer playability, replace `chrome.tabCapture` with offscreen `getDisplayMedia` (AMENDED from original DEC-003) +``` + +(2) Replace Success Criterion #2 (currently lines 58-61 in the `**Success Criteria** (what must be TRUE):` block — "With the extension loaded and a tab open, a single continuous `MediaRecorder` ... without losing the WebM container header.") with VERBATIM: + +```markdown + 2. With the extension loaded and an operator session active, a single + continuous `MediaRecorder` is running on the operator-selected + screen/window source with timeslice 2000 ms; the recorder continues + unchanged across tab switches (no tab re-attach logic; AMENDED from the + original wording). The WebM container header is retained in the ring + buffer indefinitely. +``` + +Do not modify any other text in ROADMAP.md. + + + grep -c "AMENDED" .planning/ROADMAP.md + + + - `grep -c "AMENDED" .planning/ROADMAP.md` returns at least 2 + - `grep -c "tab re-attach" .planning/ROADMAP.md` returns 0 (the old phrase is gone) + - `grep -c "getDisplayMedia" .planning/ROADMAP.md` returns at least 1 + - Phases 2-5 sections remain untouched + + ROADMAP.md Phase 1 description and Success Criterion #2 reflect the new contract; readers of ROADMAP.md see the amendment without needing to drill into CONTEXT.md. + + + + Task 6: Manifest permission swap — tabCapture → desktopCapture, drop alarms (D-A6 / D-05) + manifest.json + + - manifest.json (lines 6-14 permissions block) + - .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md §"Doc Amendments (precede code)" (D-A6) + - .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §`manifest.json` (lines 444-476) + + +Replace the `"permissions": [...]` array (currently lines 6-14) VERBATIM with: + +```json + "permissions": [ + "desktopCapture", + "activeTab", + "downloads", + "scripting", + "storage", + "offscreen" + ], +``` + +Specifically: +- Replace `"tabCapture"` → `"desktopCapture"` (per D-A6). +- Remove `"alarms"` (the alarms keepalive is deleted in Plan 05; declaring an unused permission expands attack surface, mitigating T-1-02). +- Keep `"activeTab"` (needed for `chrome.tabs.captureVisibleTab` in Phase 3 screenshot path — current code uses it at src/background/index.ts:190). +- Keep `"downloads"` (used at src/background/index.ts:305). +- Keep `"scripting"` (content script injection). +- Keep `"storage"` (currently unused by code but retained per SPEC §7; do not remove in this phase — that is Phase 5 hardening territory). +- Keep `"offscreen"` (required to call `chrome.offscreen.createDocument`). + +Do not modify any other field in `manifest.json` (manifest_version, name, version, description, host_permissions, background, content_scripts, action, icons all remain untouched). + + + node -e "const m=require('./manifest.json'); const p=m.permissions; if(p.includes('tabCapture')||p.includes('alarms')){process.exit(1)}; if(!p.includes('desktopCapture')||!p.includes('offscreen')){process.exit(2)}; console.log('ok')" + + + - `grep -c '"tabCapture"' manifest.json` returns 0 + - `grep -c '"desktopCapture"' manifest.json` returns 1 + - `grep -c '"alarms"' manifest.json` returns 0 + - `grep -c '"offscreen"' manifest.json` returns 1 + - `grep -c '"activeTab"' manifest.json` returns 1 + - `node -e "require('./manifest.json')"` exits 0 (JSON valid) + - `host_permissions` array still contains `""` (unchanged) + + manifest.json carries the final Phase-1 permissions set; downstream code-touching plans (02-07) operate against a manifest that matches the new API contract. + + + + + +After all six tasks land: + +1. `grep -c "AMENDED" .planning/PROJECT.md .planning/REQUIREMENTS.md .planning/ROADMAP.md` returns at least 4 lines (each file has at least one amendment marker; the precise totals are checked in each task's acceptance_criteria). +2. `grep -c "RETIRED-BY: Phase 01" .planning/intel/constraints.md` returns 2. +3. `grep -c "AMENDED-BY: Phase 01" .planning/intel/decisions.md` returns 2. +4. `grep -c '"tabCapture"' manifest.json` returns 0 and `grep -c '"desktopCapture"' manifest.json` returns 1. +5. `node -e "require('./manifest.json')"` exits 0 (JSON valid). +6. `node -e "require('./.planning/config.json')"` exits 0 (we did not touch config.json). +7. Every code-touching plan in Waves 0..2 can grep the doc baseline (manifest.json, PROJECT.md, REQUIREMENTS.md, ROADMAP.md, decisions.md, constraints.md) and find the amendments — no plan needs to back-patch a doc. + +Commit cadence: ONE git commit per task (six atomic commits). Each commit message follows `docs(01-01): amend {file} per D-{ID}` style. The orchestrator handles commit creation. + + + +- All six tasks complete and verified. +- No file in the dependency closure carries the pre-amendment wording. +- manifest.json `permissions` array exactly matches the list specified in Task 6's action block. +- Plans 02..07 can run against a self-consistent doc baseline (no late doc patches required mid-phase). + + + +After completion, create `.planning/phases/01-stabilize-video-pipeline/01-01-SUMMARY.md` with: +- Which 6 files were amended and at which line ranges +- The exact text of the new CON-display-capture-binding block (so downstream plans can quote it) +- Confirmation of all six acceptance grep checks +- Six commit SHAs (one per task) + diff --git a/.planning/phases/01-stabilize-video-pipeline/01-02-PLAN.md b/.planning/phases/01-stabilize-video-pipeline/01-02-PLAN.md new file mode 100644 index 0000000..7606a3c --- /dev/null +++ b/.planning/phases/01-stabilize-video-pipeline/01-02-PLAN.md @@ -0,0 +1,629 @@ +--- +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\"" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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): + +```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. + + + +## 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.) + + + + + + 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: +```bash +LATEST_VITEST=$(npm view vitest version) +echo "Latest vitest is $LATEST_VITEST" +``` +Then add to `devDependencies` in `package.json`: `"vitest": "^"` where `` 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. + + + test -d node_modules/vitest && grep -c "\"vitest\"" package.json && grep -c "\"test\": \"vitest run\"" package.json + + + - `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) + + 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: + +```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`). + + + 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 + + + - `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) + + `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: + +```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). + + + 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") + + + - `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) + + 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: + +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +interface ChromeStub { + runtime: { sendMessage: ReturnType }; +} + +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") + + + - `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 + + 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: + +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +interface PortStub { + name: string; + postMessage: ReturnType; + onMessage: { addListener: ReturnType }; + onDisconnect: { addListener: ReturnType }; + disconnect: ReturnType; +} + +interface ChromeStub { + runtime: { + id: string; + sendMessage: (m: unknown) => void; + onMessage: { addListener: ReturnType }; + 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; + onMessage: { addListener: ReturnType }; + onDisconnect: { addListener: (fn: () => void) => void }; + disconnect: ReturnType; +} + +interface ChromeStub { + runtime: { + id: string; + sendMessage: ReturnType; + onMessage: { addListener: ReturnType }; + 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. + + + +- 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 + + + +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)." + diff --git a/.planning/phases/01-stabilize-video-pipeline/01-03-PLAN.md b/.planning/phases/01-stabilize-video-pipeline/01-03-PLAN.md new file mode 100644 index 0000000..bf30979 --- /dev/null +++ b/.planning/phases/01-stabilize-video-pipeline/01-03-PLAN.md @@ -0,0 +1,676 @@ +--- +phase: 01-stabilize-video-pipeline +plan: 03 +type: tdd +wave: 1 +depends_on: ["02"] +files_modified: + - src/offscreen/recorder.ts + - src/offscreen/index.html + - src/shared/logger.ts + - src/shared/types.ts +autonomous: true +requirements: + - REQ-video-ring-buffer +requirements_addressed: + - REQ-video-ring-buffer + +must_haves: + truths: + - "`src/offscreen/recorder.ts` exists and exports the symbols Plan 02 tests against: addChunk, trimAged, getBuffer, resetBuffer, assertCodecSupported, VIDEO_BUFFER_DURATION_MS" + - "Running `npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts` exits 0 with all tests green" + - "Buffer holds at most: 1 header chunk + every chunk with arrival timestamp newer than now-30_000ms" + - "Codec strictly bound to `video/webm;codecs=vp9` at 400 000 bps with `MediaRecorder.isTypeSupported` gate; no fallback chain (D-20)" + - "`MediaRecorder.start(2000)` is called on session start (timeslice = 2000 ms per SPEC §4.1)" + - "On `MediaStreamTrack.onended`, the buffer is cleared and a `RECORDING_ERROR` of `'user-stopped-sharing'` is emitted to SW" + - "Restart-segments fallback (D-13) is pre-staged as a commented-out skeleton at the bottom of recorder.ts so Plan 07's fallback path doesn't require a re-plan" + - "`src/offscreen/index.html` exists at the source path and references `./recorder.ts`" + - "`src/shared/logger.ts` has an `OffscreenLogger` class with `[OS:...]` prefix" + - "`src/shared/types.ts` carries the new `PortMessageType` and `PortMessage` types; `VIDEO_CHUNK` and `VIDEO_CHUNK_SAVED` removed" + artifacts: + - path: "src/offscreen/recorder.ts" + provides: "Ring buffer + getDisplayMedia + MediaRecorder + codec strict-mode + track-ended handler" + min_lines: 150 + exports: ["addChunk", "trimAged", "getBuffer", "resetBuffer", "assertCodecSupported", "VIDEO_BUFFER_DURATION_MS"] + - path: "src/offscreen/index.html" + provides: "crxjs-managed offscreen entry point" + contains: "./recorder.ts" + - path: "src/shared/logger.ts" + provides: "OffscreenLogger added alongside existing Logger / ContentLogger" + contains: "export class OffscreenLogger" + - path: "src/shared/types.ts" + provides: "Port message types added; deleted VIDEO_CHUNK / VIDEO_CHUNK_SAVED variants" + contains: "PortMessage" + key_links: + - from: "src/offscreen/recorder.ts" + to: "src/shared/types.ts" + via: "import type { Message, VideoChunk, PortMessage } from '../shared/types'" + pattern: "from '../shared/types'" + - from: "src/offscreen/recorder.ts" + to: "src/shared/logger.ts" + via: "import { OffscreenLogger } from '../shared/logger'" + pattern: "from '../shared/logger'" + - from: "src/offscreen/index.html" + to: "src/offscreen/recorder.ts" + via: " + + +``` + +Notes: +- The exported pure functions (`addChunk`, `trimAged`, `getBuffer`, `resetBuffer`, `assertCodecSupported`) are deliberately decoupled from the impure `getDisplayMedia` path so they can be exercised in Node — exactly what Plan 02's ring-buffer + codec tests do. +- The handshake `OFFSCREEN_READY` send + the `chrome.runtime.connect` call are minimal stubs that satisfy the import-time test side-effects. Plan 04 elaborates them (adds ping loop, adds onDisconnect reconnect, adds REQUEST_BUFFER handler) — the changes Plan 04 makes are ADDITIVE within the same module, so they do NOT break Plan 03's tests. +- The `as any` count in this module is zero, the `@ts-ignore` count is zero (per CLAUDE.md naming rule + CONTEXT.md "Established patterns"). + +After writing both files, run: +```bash +npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts --reporter=dot +npx tsc --noEmit +``` + +Both MUST exit 0. + + + npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts && npx tsc --noEmit + + + - `test -f src/offscreen/recorder.ts && test -f src/offscreen/index.html` exits 0 + - `npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts` exits 0 with 6 passing tests + - `npx tsc --noEmit` exits 0 + - `grep -c "export function addChunk" src/offscreen/recorder.ts` returns 1 + - `grep -c "export function trimAged" src/offscreen/recorder.ts` returns 1 + - `grep -c "export function getBuffer" src/offscreen/recorder.ts` returns 1 + - `grep -c "export function resetBuffer" src/offscreen/recorder.ts` returns 1 + - `grep -c "export function assertCodecSupported" src/offscreen/recorder.ts` returns 1 + - `grep -c "VIDEO_BUFFER_DURATION_MS = 30_000" src/offscreen/recorder.ts` returns 1 + - `grep -v '^#' src/offscreen/recorder.ts | grep -cE "codecs=(vp8|h264)"` returns 0 (T-1-01 mitigation — no fallback codec) + - `grep -c "as any" src/offscreen/recorder.ts` returns 0 (CLAUDE.md rule) + - `grep -c "@ts-ignore" src/offscreen/recorder.ts` returns 0 + - `grep -c "start(2000)" src/offscreen/recorder.ts` returns 0 BUT `grep -c "TIMESLICE_MS" src/offscreen/recorder.ts` returns at least 2 (start uses the constant) + - `grep -c "./recorder.ts" src/offscreen/index.html` returns 1 + + Two tests files green; ring buffer and codec strict-mode behavior implemented; offscreen HTML entry exists; tsc clean. + + + + Task 3: Logger + types ergonomics — add OffscreenLogger, clean up shared/types.ts + src/shared/logger.ts, src/shared/types.ts + + - src/shared/logger.ts (lines 1-51 — Logger and ContentLogger shapes) + - src/shared/types.ts (lines 1-68 — full current state) + - .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §"Shared Patterns / Logging" (lines 716-759) + - .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §`src/shared/types.ts` (lines 480-530) + + +Two edits. + +(1) `src/shared/logger.ts` — append a new `OffscreenLogger` class at the END of the file, mirroring the shape of `ContentLogger`: + +```typescript + +// Логгер для Offscreen Document +export class OffscreenLogger { + private context: string; + + constructor(context: string) { + this.context = context; + } + + private logWithLevel(level: 'log' | 'warn' | 'error', ...args: any[]) { + const timestamp = new Date().toISOString(); + console[level](`[OS:${this.context}] ${timestamp}`, ...args); + } + + log(...args: any[]) { + this.logWithLevel('log', ...args); + } + + warn(...args: any[]) { + this.logWithLevel('warn', ...args); + } + + error(...args: any[]) { + this.logWithLevel('error', ...args); + } +} +``` + +Do not modify the existing `Logger` or `ContentLogger` classes. + +(2) `src/shared/types.ts` — three edits: + +(a) Remove `'VIDEO_CHUNK'` and `'VIDEO_CHUNK_SAVED'` from the `MessageType` union. The `MessageType` becomes: + +```typescript +export type MessageType = + | 'REQUEST_PERMISSIONS' + | 'PERMISSIONS_GRANTED' + | 'PERMISSIONS_DENIED' + | 'GET_VIDEO_BUFFER' + | 'VIDEO_BUFFER_RESPONSE' + | 'GET_RRWEB_EVENTS' + | 'RRWEB_EVENTS_RESPONSE' + | 'SAVE_ARCHIVE' + | 'ARCHIVE_SAVED' + | 'START_RECORDING' + | 'STOP_RECORDING' + | 'RECORDING_ERROR' + | 'OFFSCREEN_READY'; +``` + +(b) After the existing `Message` interface (after line 24), add the port message types VERBATIM: + +```typescript + +// Типы сообщений в long-lived port (offscreen ↔ SW; D-17 / Plan 04) +export type PortMessageType = + | 'PING' + | 'REQUEST_BUFFER' + | 'BUFFER'; + +export interface PortMessage { + type: PortMessageType; + chunks?: VideoChunk[]; +} +``` + +(c) Leave the `VideoChunk`, `UserEvent`, `SessionMetadata`, `PopupState`, `VideoBufferResponse`, `RrwebEventsResponse` interfaces unchanged. + +After both edits, run: +```bash +npx tsc --noEmit +npx vitest run +``` + +Both MUST exit 0 (the 2 ring-buffer + codec tests should still pass; the 2 handshake + port tests remain RED — they get GREEN in Plan 04). + + + npx tsc --noEmit && grep -c "OffscreenLogger" src/shared/logger.ts && grep -cE "VIDEO_CHUNK[^_S]|VIDEO_CHUNK_SAVED" src/shared/types.ts + + + - `npx tsc --noEmit` exits 0 + - `grep -c "export class OffscreenLogger" src/shared/logger.ts` returns 1 + - `grep -c "'VIDEO_CHUNK'" src/shared/types.ts` returns 0 (removed) + - `grep -c "'VIDEO_CHUNK_SAVED'" src/shared/types.ts` returns 0 (removed) + - `grep -c "PortMessage" src/shared/types.ts` returns at least 2 (one for the type alias, one for the interface) + - `grep -c "OFFSCREEN_READY" src/shared/types.ts` returns 1 (still present) + - `npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts` exits 0 + + OffscreenLogger available; types cleaned up; ring-buffer + codec tests still green; the deleted message types prevent Plan 05 from accidentally re-introducing the broken sendMessage-Blob path. + + + + Task 4: REFACTOR — optional cleanup pass (only if obvious improvements exist) + src/offscreen/recorder.ts (only if changed) + + - src/offscreen/recorder.ts (Task 2 output) + + +Per the TDD REFACTOR phase rules in `$HOME/.claude/get-shit-done/references/tdd.md`: + +1. Re-read `src/offscreen/recorder.ts` (from Task 2). +2. Look ONLY for obvious improvements: + - Constant duplication + - Unused imports + - Mis-placed comments +3. If no obvious improvements: SKIP THIS TASK (do not commit). Note in the summary: "Refactor: no changes needed." +4. If improvements found: make them; run `npx vitest run && npx tsc --noEmit`; commit ONLY if both exit 0. + +Do NOT use this task to add features. Do NOT rewrite the bootstrap section (that's Plan 04's territory). Do NOT extract the ring buffer into a separate file in this phase (audit-driven decision: one source of truth in `src/offscreen/recorder.ts`). + + + npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts && npx tsc --noEmit + + + - All Plan-03-owned tests still pass after the refactor pass (or after the no-op decision) + - `npx tsc --noEmit` still exits 0 + - The SUMMARY.md documents whether refactor happened or was skipped + + REFACTOR phase complete (or explicitly skipped with rationale). + + + + + +After all four tasks land: + +1. `npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts` — exits 0, 6 tests passing. +2. `npx vitest run tests/offscreen/handshake.test.ts tests/offscreen/port.test.ts` — exits NON-ZERO (these stay RED until Plan 04). The failure mode is now NOT a module-resolution error (since the module exists), but rather an assertion mismatch — the stub bootstrap calls `chrome.runtime.sendMessage({type: 'OFFSCREEN_READY'})` exactly once and `chrome.runtime.connect({name: 'video-keepalive'})` exactly once, so the handshake test MAY actually pass after Plan 03 lands. **This is acceptable**: the reconnect test is what definitively pins Plan 04. +3. `npx tsc --noEmit` — exits 0. +4. `grep -c "as any\|@ts-ignore" src/offscreen/recorder.ts src/offscreen/index.html` — returns 0. +5. `grep -v '^#' src/offscreen/recorder.ts | grep -cE "codecs=(vp8|h264)"` — returns 0 (T-1-01 mitigation grep gate). +6. `wc -l src/offscreen/recorder.ts` — at least 150 lines. + +Commit cadence: per the TDD reference, this is the GREEN phase of a TDD plan: +- Task 1: no commit (verify-only). +- Task 2: ONE commit (`feat(01-03): implement offscreen recorder ring buffer and codec strict-mode`). +- Task 3: ONE commit (`feat(01-03): add OffscreenLogger and clean up shared types`). +- Task 4: zero or one commit (`refactor(01-03): {what was cleaned}` only if changes were made). + +Total: 2-3 commits. + + + +- `src/offscreen/recorder.ts` exists, exports all 6 symbols Plan 02 tests expect +- 6 ring-buffer + codec tests passing (Plan 02 → Plan 03 GREEN handoff complete for the ring-buffer + codec slice) +- `src/offscreen/index.html` exists at source path, references `./recorder.ts` +- `OffscreenLogger` class exists in `src/shared/logger.ts` +- `PortMessage` type exists in `src/shared/types.ts`; `VIDEO_CHUNK` / `VIDEO_CHUNK_SAVED` removed +- D-13 fallback skeleton present as comment block in recorder.ts (no re-plan needed if Plan 07 ffprobe fails) +- `npx tsc --noEmit` clean; no `as any`, no `@ts-ignore` +- The grep gates in `` all return their expected values + + + +After completion, create `.planning/phases/01-stabilize-video-pipeline/01-03-SUMMARY.md` with: +- RED: log excerpt from Task 1 showing the module-resolution failure +- GREEN: list of 6 tests now passing, with the test names +- REFACTOR: what changed (if anything) in Task 4 +- Final list of files modified (4 files) and line counts +- The exact export surface of recorder.ts (so Plan 04 / 05 can grep against it without re-reading) +- Note: "Plan 04 needs to: (a) replace the import-time stub `chrome.runtime.connect({name:'video-keepalive'})` with the full ping-loop + reconnect-on-disconnect from RESEARCH.md Pattern 5; (b) wire the `REQUEST_BUFFER` handler so the SW can pull chunks on export; (c) confirm the existing `OFFSCREEN_READY` send is still emitted exactly once. Plan 05 needs to update SW side accordingly." +- Commit list (2-3 commits) + diff --git a/.planning/phases/01-stabilize-video-pipeline/01-04-PLAN.md b/.planning/phases/01-stabilize-video-pipeline/01-04-PLAN.md new file mode 100644 index 0000000..2b151d2 --- /dev/null +++ b/.planning/phases/01-stabilize-video-pipeline/01-04-PLAN.md @@ -0,0 +1,403 @@ +--- +phase: 01-stabilize-video-pipeline +plan: 04 +type: tdd +wave: 1 +depends_on: ["02", "03"] +files_modified: + - src/offscreen/recorder.ts +autonomous: true +requirements: + - REQ-video-ring-buffer +requirements_addressed: + - REQ-video-ring-buffer + +must_haves: + truths: + - "On module import, the offscreen opens exactly one port via `chrome.runtime.connect({ name: 'video-keepalive' })` AND emits exactly one `OFFSCREEN_READY` message via `chrome.runtime.sendMessage`" + - "Both the port-connect and the OFFSCREEN_READY send happen AFTER `chrome.runtime.onMessage.addListener` registration (Pattern 4 ordering)" + - "When the open port fires its registered `onDisconnect` listener, the module synchronously reconnects (Pitfall 4 mitigation)" + - "The port emits a `{ type: 'PING' }` postMessage on a ≤ 25 s interval (RESEARCH.md Pattern 5)" + - "Pre-emptive reconnect runs every 290 s (belt-and-braces against the ~5 min port-lifetime cap)" + - "The port handles incoming `REQUEST_BUFFER` messages by responding with `{ type: 'BUFFER', chunks: }`" + - "Running `npx vitest run tests/offscreen/handshake.test.ts tests/offscreen/port.test.ts` exits 0 (Plan 02 RED → GREEN)" + artifacts: + - path: "src/offscreen/recorder.ts" + provides: "Full port keepalive + handshake + REQUEST_BUFFER handler" + contains: "video-keepalive" + key_links: + - from: "src/offscreen/recorder.ts (port.onDisconnect)" + to: "src/offscreen/recorder.ts (connectPort)" + via: "synchronous reconnect call" + pattern: "onDisconnect" + - from: "src/offscreen/recorder.ts (REQUEST_BUFFER handler)" + to: "getBuffer()" + via: "port.postMessage({ type: 'BUFFER', chunks: getBuffer() })" + pattern: "REQUEST_BUFFER" +--- + + +Replace the import-time STUB bootstrap in Plan 03's `src/offscreen/recorder.ts` +with the full long-lived-port keepalive + OFFSCREEN_READY handshake + +REQUEST_BUFFER handler. This flips the two remaining RED tests from Plan 02 +(`tests/offscreen/handshake.test.ts` and `tests/offscreen/port.test.ts`) to +GREEN. + +Purpose: REQ-video-ring-buffer also covers the SW-keepalive contract per +D-17 (long-lived port replaces `chrome.alarms`) and the OFFSCREEN_READY +handshake per RESEARCH.md Pattern 4. Both behaviors are observable from a +pure-Node test that mocks `chrome.runtime` — exactly the TDD sweet spot for +a contract that survives SW unloads, port 5-min cap, and offscreen +bootstrap races. + +Output: Updated `src/offscreen/recorder.ts` — only this single file is +touched. Plan 04 is an isolated unit-of-work; Plan 05 picks up the +SW-side `onConnect` handler in parallel (Plan 04 runs alongside Plan 05 in +Wave 1 with NO file overlap — Plan 04 owns offscreen-side, Plan 05 owns +SW-side). + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md +@.planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md +@.planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md +@tests/offscreen/handshake.test.ts +@tests/offscreen/port.test.ts +@src/offscreen/recorder.ts + + + + + +Module-import side-effects (from tests/offscreen/handshake.test.ts): +- `chrome.runtime.onMessage.addListener` is called at least once +- `chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' })` is called exactly once + +Module-import side-effects (from tests/offscreen/port.test.ts): +- `chrome.runtime.connect({ name: 'video-keepalive' })` is called exactly once (first test) +- Firing the registered onDisconnect listener causes `chrome.runtime.connect` to be called a second time within the synchronous tick (second test) + +In-port message contract (per RESEARCH.md Pattern 5): +- Outbound: `{ type: 'PING' }` every 25 s (configurable constant) +- Outbound on REQUEST_BUFFER: `{ type: 'BUFFER', chunks: VideoChunk[] }` (the chunks are `getBuffer()`) +- Inbound listener: handles `{ type: 'REQUEST_BUFFER' }` from SW side + +Existing exports unchanged: `addChunk`, `trimAged`, `getBuffer`, `resetBuffer`, `assertCodecSupported`, `VIDEO_BUFFER_DURATION_MS` (Plan 03's tests must continue to pass). + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| port connection origin | Any context inside the extension can call `chrome.runtime.connect`; non-extension contexts cannot (Chrome enforces). The offscreen-side connect is initiated BY the offscreen, so the offscreen is the trusting party (it knows where the port goes). The SW-side `onConnect` (in Plan 05) is the listening party — that side validates `sender.id === chrome.runtime.id`. | +| inbound port `onMessage` payload | The offscreen receives `REQUEST_BUFFER` over the port; the SW is the only caller (sender validated on SW side); inbound message validation here is defense-in-depth | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-1-04 | Spoofing — port hijack | Offscreen-side `port.onMessage` listener | mitigate | The port name `'video-keepalive'` is paired with the SW-side `onConnect` filter `p.name === 'video-keepalive' && p.sender?.id === chrome.runtime.id` (added in Plan 05). On the offscreen side: the port is opened BY the offscreen, so the offscreen is the trusting party and the SW is the only legitimate counterparty. As defense in depth, the offscreen-side `port.onMessage` handler explicitly switches on `msg.type` and ignores any unknown shape. Grep gate (Plan 05 owns the sender check, Plan 04 owns the type-switch on inbound port traffic): `grep -v '^#' src/offscreen/recorder.ts \| grep -cE "REQUEST_BUFFER" ` returns at least 1, AND any branch outside the known PortMessageType union is treated as no-op. | +| T-1-NEW-04-01 | DoS — port reconnect storm | offscreen `connectPort` → port.onDisconnect | mitigate | The reconnect path is idempotent: when `onDisconnect` fires, the existing port reference is cleared, a fresh port is opened, and the new port's listeners are attached. If reconnect throws (e.g., SW gone), the failure is caught and logged; the next pre-emptive 290 s reconnect cycle will retry. Grep gate: `grep -v '^#' src/offscreen/recorder.ts \| grep -cE "function connectPort"` returns 1. | + + + + Port keepalive + OFFSCREEN_READY handshake + REQUEST_BUFFER handler + src/offscreen/recorder.ts + +RED already exists (Plan 02 handshake + port tests). GREEN must: + +Handshake (from tests/offscreen/handshake.test.ts): +- On module import, register `chrome.runtime.onMessage.addListener` BEFORE calling `chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' })` +- `OFFSCREEN_READY` is sent EXACTLY ONCE during module bootstrap (not on every subsequent message) + +Port (from tests/offscreen/port.test.ts): +- On module import, call `chrome.runtime.connect({ name: 'video-keepalive' })` exactly once +- Register `onDisconnect` listener on the connected port +- When `onDisconnect` fires, immediately call `chrome.runtime.connect({ name: 'video-keepalive' })` again (synchronous reconnect) +- The reconnect attaches the same set of listeners (onMessage, onDisconnect, etc.) to the fresh port + +Production behavior (not directly tested in Plan 02, but verifiable): +- Every 25 s while a port is open: `port.postMessage({ type: 'PING' })` (keepalive against SW idle) +- Every 290 s: pre-emptively close + reopen the port (Pitfall 4 — beat the ~5 min lifetime cap) +- On inbound port message `{ type: 'REQUEST_BUFFER' }`: respond with `{ type: 'BUFFER', chunks: getBuffer() }` (this is the SW's export-time data-pull path; Plan 05 wires the SW caller) + + +See task list — three tasks: RED-verify the two port + handshake test files, GREEN refactor the bootstrap section of recorder.ts to add the port lifecycle, then REFACTOR (skipped or minimal). + + + + + + + Task 1: RED-verify — confirm port + handshake tests fail on Plan 03's stub bootstrap + (none modified) + + - tests/offscreen/handshake.test.ts (Plan 02 output) + - tests/offscreen/port.test.ts (Plan 02 output) + - src/offscreen/recorder.ts (Plan 03 stub bootstrap — lines 144-155 of the Task-2 verbatim module) + + +Run the two test files this plan owns: + +```bash +npx vitest run tests/offscreen/handshake.test.ts tests/offscreen/port.test.ts 2>&1 | tee /tmp/01-04-red.log +``` + +Analyse the failure mode: + +- **Handshake test:** Plan 03's stub bootstrap already calls `chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' })` once after the listener registration, so this test MAY ALREADY PASS as of Plan 03's GREEN landing. If it does — that is acceptable. The handshake contract is sufficiently met by Plan 03's stub. +- **Port test (`connects on module load`):** Plan 03's stub calls `chrome.runtime.connect({ name: PORT_NAME })` once — this test MAY ALREADY PASS too. +- **Port test (`reconnects when port disconnects`):** Plan 03's stub does NOT register `onDisconnect`, so when the test fires the registered listeners, `connectCount` stays at 1 — this test MUST FAIL. + +The intended RED gate for Plan 04 is the reconnect test. Confirm: + +```bash +grep -q "reconnects when port disconnects" /tmp/01-04-red.log && grep -q "FAIL" /tmp/01-04-red.log +``` + +If the reconnect test passes spuriously (e.g., because Plan 03 already added reconnect): STOP — the plan ordering may have shifted; reconcile with Plan 03 before continuing. + +If the handshake test or the `connects on module load` test fail: STOP — Plan 03 broke its own contract; revisit Plan 03 instead. + +Otherwise, proceed to Task 2. + +No commits in this task. + + + npx vitest run tests/offscreen/port.test.ts -t "reconnects when port disconnects" 2>&1 | grep -qE "FAIL|✗" + + + - `/tmp/01-04-red.log` exists and is non-empty + - The reconnect test is in a FAIL state + - The handshake test and the `connects on module load` port test outcomes are recorded in /tmp/01-04-red.log (whether they pass or fail — we don't gate on them here) + + RED gate confirmed: the reconnect test is failing because Plan 03's stub bootstrap doesn't reconnect on disconnect. Proceed to GREEN. + + + + Task 2: GREEN — replace stub bootstrap with full port lifecycle + src/offscreen/recorder.ts + + - src/offscreen/recorder.ts (Plan 03 output — the bootstrap section starting at the `// ─── Bootstrap` comment through the `void keepalivePort;` line) + - .planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md §"Pattern 5: Long-lived port" (lines 590-648) and §"Pitfall 4" (lines 820-836) + - .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §`src/offscreen/recorder.ts` — port keepalive snippet (lines 185-198) + - tests/offscreen/handshake.test.ts (the test that pins OFFSCREEN_READY ordering) + - tests/offscreen/port.test.ts (the test that pins reconnect-on-disconnect) + + +After Task 2 lands: + +```bash +npx vitest run tests/offscreen/handshake.test.ts tests/offscreen/port.test.ts +# exit 0 — all 3 tests pass (1 in handshake, 2 in port) + +npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts +# exit 0 — ring-buffer and codec tests still green (NO regression from Plan 03) +``` + + +Edit `src/offscreen/recorder.ts` to REPLACE the entire bootstrap section (currently starting at the `// ─── Bootstrap (Plan 04 wires the full port + handshake)` comment through the `void keepalivePort;` line) with the VERBATIM expanded bootstrap below. The pure-function exports above the bootstrap section and the D-13 fallback comment block below it are UNCHANGED. + +Also: at the top of the file, ADD a new constant block (after the existing `const PORT_NAME = ...` line and before the `const logger = new OffscreenLogger(...)` line): + +```typescript +const PORT_PING_MS = 25_000; // < 30 s SW idle threshold +const PORT_RECONNECT_MS = 290_000; // pre-empt the ~5 min port cap (Pitfall 4) +``` + +The bootstrap section becomes VERBATIM: + +```typescript +// ─── Bootstrap: handshake + port lifecycle (D-17, RESEARCH.md Patterns 4 & 5) ── + +function isFromOwnExtension(sender: chrome.runtime.MessageSender | undefined): boolean { + return sender?.id === chrome.runtime.id; +} + +chrome.runtime.onMessage.addListener((message: Message, sender, sendResponse) => { + if (!isFromOwnExtension(sender)) { + return false; + } + switch (message.type) { + case 'START_RECORDING': + startRecording().then(() => sendResponse({ ok: true })).catch((err) => sendResponse({ ok: false, error: String(err) })); + return true; + case 'STOP_RECORDING': + stopRecording(); + sendResponse({ ok: true }); + return false; + default: + return false; + } +}); + +// Stable handles for the ping interval and the pre-emptive reconnect timer, +// so we can clear them on disconnect / re-init. +let pingIntervalId: ReturnType | null = null; +let preemptiveReconnectId: ReturnType | null = null; + +function teardownPortTimers(): void { + if (pingIntervalId !== null) { + clearInterval(pingIntervalId); + pingIntervalId = null; + } + if (preemptiveReconnectId !== null) { + clearTimeout(preemptiveReconnectId); + preemptiveReconnectId = null; + } +} + +function onPortMessage(message: unknown): void { + // Defense-in-depth: explicit shape check before destructuring + if (typeof message !== 'object' || message === null) { + return; + } + const type = (message as { type?: unknown }).type; + if (type === 'REQUEST_BUFFER') { + if (keepalivePort !== null) { + keepalivePort.postMessage({ type: 'BUFFER', chunks: getBuffer() }); + } + } + // Any unknown port message type is silently dropped (T-1-04 defense-in-depth). +} + +function connectPort(): void { + teardownPortTimers(); + try { + keepalivePort = chrome.runtime.connect({ name: PORT_NAME }); + } catch (err) { + logger.error('port connect failed:', err); + keepalivePort = null; + return; + } + keepalivePort.onMessage.addListener(onPortMessage); + keepalivePort.onDisconnect.addListener(() => { + logger.warn('port disconnected — reconnecting'); + teardownPortTimers(); + keepalivePort = null; + // Synchronous reconnect — tests/offscreen/port.test.ts pins this + connectPort(); + }); + pingIntervalId = setInterval(() => { + keepalivePort?.postMessage({ type: 'PING' }); + }, PORT_PING_MS); + preemptiveReconnectId = setTimeout(() => { + logger.log('pre-emptive port reconnect (290 s cap)'); + keepalivePort?.disconnect(); + // onDisconnect handler above triggers a fresh connectPort() call + }, PORT_RECONNECT_MS); +} + +// Open the first port and emit OFFSCREEN_READY. Order matters per Pattern 4: +// the onMessage listener registration above already happened, so the SW can +// safely send START_RECORDING the moment it sees OFFSCREEN_READY. +connectPort(); +chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' }); +``` + +After this replacement, the previous `let keepalivePort: chrome.runtime.Port | null = null;` declaration in the module-level state block is UNCHANGED — it stays. The previous `void keepalivePort;` line is REMOVED (the variable is now used by `onPortMessage` and `connectPort`, so the no-unused-locals workaround is no longer needed). + +Run: +```bash +npx vitest run +npx tsc --noEmit +``` + +Both MUST exit 0. + +Notes: +- `setInterval` / `setTimeout` in offscreen documents: offscreen runs in a real DOM context (NOT in the SW), so timers are reliable — unlike `setTimeout` inside a SW. RESEARCH.md "Don't Hand-Roll" table confirms this. +- The `disconnect()` call inside the pre-emptive reconnect uses optional chaining; if the port is somehow already null, the call is a no-op. The `onDisconnect` listener fires synchronously in Chrome's port-disconnect path, which transitions us back through `connectPort()` cleanly. +- `PortMessage` shape: outbound traffic uses the `PortMessage` type added to `src/shared/types.ts` in Plan 03. No `as any`. Inbound messages over the port are typed `unknown` and shape-checked. + + + npx vitest run && npx tsc --noEmit + + + - `npx vitest run` exits 0 with ALL test files in tests/offscreen/ passing (8 tests total across 4 files: 4 ring-buffer + 2 codec-check + 1 handshake + 2 port) + - `npx tsc --noEmit` exits 0 + - `grep -c "function connectPort" src/offscreen/recorder.ts` returns 1 + - `grep -c "PORT_PING_MS = 25_000" src/offscreen/recorder.ts` returns 1 + - `grep -c "PORT_RECONNECT_MS = 290_000" src/offscreen/recorder.ts` returns 1 + - `grep -c "REQUEST_BUFFER" src/offscreen/recorder.ts` returns at least 1 (the inbound handler) + - `grep -c "'BUFFER'" src/offscreen/recorder.ts` returns at least 1 (the outbound response shape) + - `grep -c "isFromOwnExtension" src/offscreen/recorder.ts` returns at least 1 (sender check on onMessage — T-1-04 defense-in-depth) + - `grep -c "as any" src/offscreen/recorder.ts` returns 0 + - `grep -c "@ts-ignore" src/offscreen/recorder.ts` returns 0 + - `grep -c "void keepalivePort" src/offscreen/recorder.ts` returns 0 (the no-unused-locals workaround is removed) + + All 4 test files from Plan 02 are now GREEN. The offscreen module owns the full port lifecycle. Plan 05 can wire the SW-side `onConnect` handler against the same port name with confidence. + + + + Task 3: REFACTOR — optional cleanup or no-op + src/offscreen/recorder.ts (only if changed) + + - src/offscreen/recorder.ts (Task 2 output) + + +Per the TDD REFACTOR phase rules: + +1. Re-read `src/offscreen/recorder.ts` (the full module now that Plan 04 has landed). +2. Look ONLY for obvious improvements: + - Constants that should be hoisted into a shared block + - Type assertions that could be replaced with proper type narrowing + - Comments that became stale after the bootstrap refactor +3. If no obvious improvements: SKIP THIS TASK (do not commit). Note in summary: "Refactor: no changes needed." +4. If improvements found: make them; run `npx vitest run && npx tsc --noEmit`; commit ONLY if both exit 0. + +Do NOT use this task to add features. Plan 05 picks up the SW-side wiring — do not anticipate it in this module. + + + npx vitest run && npx tsc --noEmit + + + - All 4 test files still pass after the refactor pass (or after the no-op decision) + - `npx tsc --noEmit` still exits 0 + - The SUMMARY.md documents whether refactor happened or was skipped + + REFACTOR phase complete (or explicitly skipped with rationale). + + + + + +After all three tasks land: + +1. `npx vitest run` — exits 0 with ALL 8 tests passing across the 4 test files in tests/offscreen/. +2. `npx tsc --noEmit` — exits 0. +3. `grep -c "as any\|@ts-ignore" src/offscreen/recorder.ts` returns 0. +4. The grep gates listed in Task 2's `acceptance_criteria` all return their expected values. +5. The module's line count grew modestly (Task 2 adds ~40 lines net to the bootstrap section); the file remains well under 250 lines total. + +Commit cadence: +- Task 1: no commit. +- Task 2: ONE commit (`feat(01-04): wire offscreen port keepalive and OFFSCREEN_READY handshake`). +- Task 3: zero or one commit (`refactor(01-04): {what was cleaned}`). + +Total: 1-2 commits. + + + +- All 4 Plan-02 test files green (ring-buffer, codec-check, handshake, port) — 8 tests total +- `src/offscreen/recorder.ts` contains a single `connectPort()` function that handles connect, reconnect, REQUEST_BUFFER response, and pre-emptive reconnect +- T-1-04 mitigations in place: sender-id check on onMessage; type-switch on inbound port messages +- No `as any`, no `@ts-ignore` +- D-17 contract (long-lived port replacing alarms) is implemented at the offscreen side; SW side lands in Plan 05 + + + +After completion, create `.planning/phases/01-stabilize-video-pipeline/01-04-SUMMARY.md` with: +- RED: log excerpt from Task 1 showing the reconnect test failing +- GREEN: full vitest output showing all 8 tests passing +- REFACTOR: what changed (if anything) in Task 3 +- Final line count for src/offscreen/recorder.ts +- Note: "Plan 05 must add a corresponding `chrome.runtime.onConnect.addListener` in `src/background/index.ts` that (a) filters by `port.name === 'video-keepalive'`, (b) validates `port.sender?.id === chrome.runtime.id` (T-1-04), (c) sends `{ type: 'REQUEST_BUFFER' }` over the port when SAVE_ARCHIVE arrives, and (d) handles the incoming `BUFFER` response by resolving the saveArchive Promise." +- Commit list (1-2 commits) + diff --git a/.planning/phases/01-stabilize-video-pipeline/01-05-PLAN.md b/.planning/phases/01-stabilize-video-pipeline/01-05-PLAN.md new file mode 100644 index 0000000..efad68e --- /dev/null +++ b/.planning/phases/01-stabilize-video-pipeline/01-05-PLAN.md @@ -0,0 +1,454 @@ +--- +phase: 01-stabilize-video-pipeline +plan: 05 +type: execute +wave: 2 +depends_on: ["03", "04"] +files_modified: + - src/background/index.ts +autonomous: true +requirements: + - REQ-video-ring-buffer +requirements_addressed: + - REQ-video-ring-buffer + +must_haves: + truths: + - "`src/background/index.ts` no longer contains `addVideoChunkFromBlob`, `cleanupVideoBuffer`, `setupKeepalive`, `loadChunkFromIndexedDB`, `openIndexedDB`, or any `chrome.alarms` reference (buffer ownership moves to offscreen per D-16/D-19)" + - "`src/background/index.ts` no longer calls `chrome.tabCapture.getMediaStreamId` (D-01 amendment)" + - "`src/background/index.ts` no longer handles `VIDEO_CHUNK` or `VIDEO_CHUNK_SAVED` (deleted Message types in Plan 03)" + - "SW has an `onConnect` listener that filters `port.name === 'video-keepalive'` and validates `port.sender?.id === chrome.runtime.id` (T-1-04 mitigation)" + - "SW has an `onMessage` `OFFSCREEN_READY` case that resolves a pending readiness Promise (Pattern 4 SW side)" + - "SW's `SAVE_ARCHIVE` and `GET_VIDEO_BUFFER` handlers fetch the buffer via the port (`REQUEST_BUFFER` → wait for `BUFFER`) instead of holding their own `videoBuffer` array" + - "SW's `ensureOffscreen` uses `chrome.offscreen.Reason.DISPLAY_MEDIA` (not `USER_MEDIA`)" + - "SW's `onInstalled` listener calls `indexedDB.deleteDatabase('VideoRecorderDB')` once as a cleanup pass (RESEARCH.md Runtime State Inventory)" + - "`npx tsc --noEmit` exits 0" + artifacts: + - path: "src/background/index.ts" + provides: "Shrunk SW coordinator: lifecycle + port host + export buffer-fetch only; no buffer state, no alarms, no IndexedDB" + contains: "video-keepalive" + key_links: + - from: "src/background/index.ts (onConnect)" + to: "src/offscreen/recorder.ts (connectPort)" + via: "shared port name 'video-keepalive'" + pattern: "video-keepalive" + - from: "src/background/index.ts (SAVE_ARCHIVE handler)" + to: "src/background/index.ts (getVideoBufferFromOffscreen)" + via: "port REQUEST_BUFFER round-trip" + pattern: "REQUEST_BUFFER" + - from: "src/background/index.ts (ensureOffscreen)" + to: "src/offscreen/index.html" + via: "chrome.offscreen.createDocument url" + pattern: "src/offscreen/index.html" +--- + + +Shrink `src/background/index.ts` to its new responsibilities: offscreen +lifecycle, port host, and export-time buffer fetch. DELETE the SW-side +ring-buffer state and helpers (now owned by offscreen per D-16), DELETE the +chrome.alarms keepalive (D-18), DELETE the IndexedDB code path (D-19), +DELETE the `chrome.tabCapture.getMediaStreamId` call (D-01 amendment), +DELETE the `VIDEO_CHUNK` / `VIDEO_CHUNK_SAVED` message handlers (their +message types were removed in Plan 03), and WIRE the SW-side `onConnect` +handler against the `'video-keepalive'` port that Plan 04 opens from the +offscreen. + +Purpose: REQ-video-ring-buffer's data flow on export is `popup → +SAVE_ARCHIVE → SW → REQUEST_BUFFER (via port) → offscreen → BUFFER (via +port) → SW → assemble ZIP → download`. The SW NEVER holds a chunk locally — +which is the only way the buffer survives SW idle unloads (D-16). + +Output: A heavily-shrunk `src/background/index.ts` that compiles cleanly, +holds zero video-buffer state, talks to the offscreen exclusively via +runtime messaging + the long-lived port, and tolerates the IndexedDB +remnant by deleting it on first install. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md +@.planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md +@.planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md +@src/background/index.ts +@src/shared/types.ts +@src/offscreen/recorder.ts + + + +Port contract (already implemented in offscreen by Plan 04): +- Port name: `'video-keepalive'` +- Offscreen → SW outbound: `{ type: 'PING' }` every 25 s (informational; SW just receives it as keepalive traffic) +- Offscreen → SW outbound on REQUEST_BUFFER: `{ type: 'BUFFER', chunks: VideoChunk[] }` +- SW → offscreen inbound: `{ type: 'REQUEST_BUFFER' }` + +SW side must: +1. Register `chrome.runtime.onConnect.addListener` that filters by port name AND validates sender ID +2. Store the connected port in a module-level `let videoPort: chrome.runtime.Port | null` +3. Clear the reference on disconnect (offscreen will reconnect; SW gets a new onConnect call) +4. Expose `getVideoBufferFromOffscreen(): Promise` that: + - Returns `{chunks: []}` early if no port is connected + - Otherwise sends `{type: 'REQUEST_BUFFER'}` over the port + - Resolves when a `{type: 'BUFFER', chunks: ...}` reply arrives + - Times out at 2 s with `{chunks: []}` + +Existing SW behaviors to PRESERVE: +- `mergeVideoChunks(chunks: VideoChunk[]): Blob` (unchanged) +- `createArchive(...)` (unchanged signature; calls `mergeVideoChunks` and zips with JSZip) +- `downloadArchive(blob)` (unchanged) +- `captureScreenshot()` (unchanged — Phase 3 owns popup-side rework) +- `chrome.runtime.onInstalled` listener (existing, gets an indexedDB cleanup line added) +- `onMessage` cases: `REQUEST_PERMISSIONS`, `GET_VIDEO_BUFFER`, `SAVE_ARCHIVE` — KEPT but their bodies change to talk to offscreen via port + +Removed message types (from Plan 03's edit to src/shared/types.ts): +- `'VIDEO_CHUNK'` — handler block deleted +- `'VIDEO_CHUNK_SAVED'` — handler block deleted + +`chrome.offscreen.Reason.DISPLAY_MEDIA`: try `reasons: [chrome.offscreen.Reason.DISPLAY_MEDIA]` first. If the current `@types/chrome` (0.0.268) does NOT expose `DISPLAY_MEDIA` and `tsc --noEmit` fails, fall back to a narrowing cast (no `as any`): `reasons: ['DISPLAY_MEDIA' as chrome.offscreen.Reason]`. The executor verifies which form compiles by running `npx tsc --noEmit` after the edit. + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| extension contexts → SW `onConnect` | Any extension context can attempt to open a port to the SW; only the offscreen has a legitimate reason for the `'video-keepalive'` port | +| popup / content script → SW `onMessage` | Existing trust boundary; this plan adds T-1-04 sender-id check on the SW-side onMessage too | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-1-04 | Spoofing — port-hijack on SW side | `chrome.runtime.onConnect` handler | mitigate | The SW `onConnect` listener filters `port.name === 'video-keepalive'` AND `port.sender?.id === chrome.runtime.id`. A non-extension caller cannot open a runtime port at all (Chrome enforces); the sender-id check is defense-in-depth for the within-extension case. Grep gate: `grep -v '^#' src/background/index.ts \| grep -c "port.sender?.id !== chrome.runtime.id"` returns at least 1. | +| T-1-NEW-05-01 | Information Disclosure — buffer pulled by unauthorized message | `onMessage` handlers (`SAVE_ARCHIVE`, `GET_VIDEO_BUFFER`) | mitigate | The SW-side `onMessage` listener already exists; this plan adds a `sender.id === chrome.runtime.id` guard at the top of the listener (per RESEARCH.md Security Domain table). | +| T-1-NEW-05-02 | Tampering — stale IndexedDB orphan | `indexedDB.deleteDatabase('VideoRecorderDB')` in `onInstalled` | mitigate | After this phase deletes the inline plugin's IndexedDB code, browser profiles that previously ran the old build still have a `VideoRecorderDB` database. The SW `onInstalled` listener calls `indexedDB.deleteDatabase('VideoRecorderDB')` idempotently to clear it. The call is harmless if the DB never existed. Grep gate: `grep -c "indexedDB.deleteDatabase('VideoRecorderDB')" src/background/index.ts` returns 1. | + + + + + + Task 1: DELETE — drop legacy buffer + alarms + IndexedDB + tabCapture paths from SW + src/background/index.ts + + - src/background/index.ts (the full current file: 536 lines) + - .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §`src/background/index.ts` (lines 276-396) — the verified delete-target table + - .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md §"Files to DELETE in this phase" + + +The current `src/background/index.ts` has been verified at 536 lines. Apply the following deletions using the Edit tool. Re-verify each line range with `grep -n` BEFORE editing (line numbers below are from the on-disk state captured 2026-05-15; do NOT trust them blindly — re-run grep first): + +(1) Delete `addVideoChunkFromBlob` function — currently lines 26-45 inclusive (the entire `// Кольцевой буфер видео` block through the closing `}`). Also remove the preceding `// Кольцевой буфер видео` comment. + +(2) Delete `cleanupVideoBuffer` function — currently lines 47-75 inclusive. + +(3) Delete `firstChunkSaved` from module state — line 17 (`let firstChunkSaved = false; // Флаг что первый чанк уже сохранен`). + +(4) Delete `videoBuffer` from module state — line 16 (`let videoBuffer: VideoChunk[] = [];`). + +(5) Replace the `chrome.tabCapture` call inside `startVideoCapture` — currently lines 126-144. The replacement is VERBATIM: + +```typescript + // Создаём offscreen документ (с reason DISPLAY_MEDIA per D-02) + await ensureOffscreen(); + + // Просим offscreen запустить запись — getDisplayMedia вызывается там + // (D-01: больше нет chrome.tabCapture.getMediaStreamId). + logger.log('Sending START_RECORDING to offscreen...'); + try { + await chrome.runtime.sendMessage({ + type: 'START_RECORDING' + }); + logger.log('START_RECORDING sent successfully'); + } catch (msgError) { + logger.error('Failed to send START_RECORDING:', msgError); + throw msgError; + } +``` + +The existing `const [tab] = ...` line at the top of `startVideoCapture` and the `if (!tab.id || !tab.url) throw ...` check can be retained — they don't harm anything. Phase 1 keeps them. + +(6) Replace `ensureOffscreen` reason — currently line 90 reads `reasons: ['USER_MEDIA'] as any,`. Replace VERBATIM: + +```typescript + reasons: [chrome.offscreen.Reason.DISPLAY_MEDIA], +``` + +If `chrome.offscreen.Reason.DISPLAY_MEDIA` is NOT in the current `@types/chrome` (0.0.268) and `tsc --noEmit` fails, fall back to (no `as any`): + +```typescript + reasons: ['DISPLAY_MEDIA' as chrome.offscreen.Reason], +``` + +(7) Update the justification text on line 91 to match RESEARCH.md Example C: + +```typescript + justification: 'Continuous screen recording for operator session diagnostics' +``` + +(8) Delete `setupKeepalive` function — currently lines 156-165. + +(9) Delete the `setupKeepalive()` call inside `initialize` — currently line 525. + +(10) Delete the `VIDEO_CHUNK` case from the onMessage switch — currently lines 457-466. + +(11) Delete the `VIDEO_CHUNK_SAVED` case — currently lines 468-473. + +(12) Delete `loadChunkFromIndexedDB` function — currently lines 482-505. + +(13) Delete `openIndexedDB` function — currently lines 507-520. + +After ALL these deletions, run `npx tsc --noEmit`. It MUST exit 0. If `VideoChunk` is reported as unused after the deletes, that indicates a function that needs it was inadvertently lost; STOP and audit. + + + npx tsc --noEmit && [ $(grep -cE "addVideoChunkFromBlob|cleanupVideoBuffer|setupKeepalive|loadChunkFromIndexedDB|openIndexedDB|getMediaStreamId|chrome\.alarms" src/background/index.ts) -eq 0 ] + + + - `npx tsc --noEmit` exits 0 + - `grep -v '^#' src/background/index.ts | grep -c "addVideoChunkFromBlob"` returns 0 + - `grep -v '^#' src/background/index.ts | grep -c "cleanupVideoBuffer"` returns 0 + - `grep -v '^#' src/background/index.ts | grep -c "setupKeepalive"` returns 0 + - `grep -v '^#' src/background/index.ts | grep -c "loadChunkFromIndexedDB"` returns 0 + - `grep -v '^#' src/background/index.ts | grep -c "openIndexedDB"` returns 0 + - `grep -v '^#' src/background/index.ts | grep -c "getMediaStreamId"` returns 0 + - `grep -v '^#' src/background/index.ts | grep -c "VIDEO_CHUNK_SAVED"` returns 0 + - `grep -v '^#' src/background/index.ts | grep -c "chrome.alarms"` returns 0 + - `grep -c "DISPLAY_MEDIA" src/background/index.ts` returns 1 + - `grep -c "as any" src/background/index.ts` returns 0 (CLAUDE.md rule) + - File line count reduced from 536 to roughly 380-400 lines (allow ±40) + + SW shed every legacy path. tsc clean. File ~30% smaller. Ready for the port-host wiring in Task 2. + + + + Task 2: ADD — wire SW-side port host, sender check, onInstalled IndexedDB cleanup, and port-based buffer fetch + src/background/index.ts + + - src/background/index.ts (post-Task-1 state) + - src/offscreen/recorder.ts (read-only reference for the offscreen-side port contract) + - .planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md §"Pattern 5: SW-side port handling" (lines 590-628) + - .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §`src/background/index.ts` ADD blocks (lines 372-396) + + +Seven targeted additions using the Edit tool (NOT a full rewrite). + +(1) **Module-level state additions** — after the existing `let cachedScreenshot: Blob | null = null;` line, ADD VERBATIM: + +```typescript +// Port from offscreen (D-17). Re-assigned on every (re)connect. +let videoPort: chrome.runtime.Port | null = null; +// Offscreen readiness Promise — set up at module load, resolved on first +// OFFSCREEN_READY message (Pattern 4). startVideoCapture awaits this before +// sending START_RECORDING, so we never lose the popup's transient activation +// to a race with the offscreen bootstrap. +let offscreenReadyResolve: (() => void) | null = null; +const offscreenReady: Promise = new Promise((res) => { + offscreenReadyResolve = res; +}); +``` + +(2) **onConnect handler** — add VERBATIM AFTER the `ensureOffscreen` function block. Place it BEFORE `startVideoCapture` so the listener is registered before any code might trigger a port open: + +```typescript +// SW-side port host (D-17, RESEARCH.md Pattern 5). The offscreen opens this +// port on bootstrap and reconnects on disconnect. We use it for: (a) +// keepalive traffic (PING) — Chrome 110+ resets the SW idle timer on every +// port message; (b) on-demand REQUEST_BUFFER round-trip during SAVE_ARCHIVE. +chrome.runtime.onConnect.addListener((port) => { + // T-1-04 mitigation: only accept ports from this extension + if (port.name !== 'video-keepalive') { + return; + } + if (port.sender?.id !== chrome.runtime.id) { + logger.warn('Rejecting port with mismatched sender:', port.sender?.id); + port.disconnect(); + return; + } + logger.log('Offscreen port connected'); + videoPort = port; + port.onDisconnect.addListener(() => { + logger.log('Offscreen port disconnected; offscreen will reconnect'); + videoPort = null; + }); + // Inbound traffic is mostly PING (ignored) and BUFFER (handled by the + // per-request listener installed in getVideoBufferFromOffscreen). +}); +``` + +(3) **Buffer-fetch function** — add VERBATIM AFTER the onConnect block above: + +```typescript +const BUFFER_FETCH_TIMEOUT_MS = 2_000; + +async function getVideoBufferFromOffscreen(): Promise { + if (videoPort === null) { + logger.warn('No offscreen port available; returning empty buffer'); + return { chunks: [] }; + } + const port = videoPort; + return new Promise((resolve) => { + const timer = setTimeout(() => { + port.onMessage.removeListener(handler); + logger.warn(`Buffer fetch timed out after ${BUFFER_FETCH_TIMEOUT_MS} ms`); + resolve({ chunks: [] }); + }, BUFFER_FETCH_TIMEOUT_MS); + const handler = (msg: unknown) => { + if ( + typeof msg === 'object' && + msg !== null && + (msg as { type?: unknown }).type === 'BUFFER' + ) { + clearTimeout(timer); + port.onMessage.removeListener(handler); + const chunks = (msg as { chunks?: VideoChunk[] }).chunks ?? []; + resolve({ chunks }); + } + }; + port.onMessage.addListener(handler); + port.postMessage({ type: 'REQUEST_BUFFER' }); + }); +} +``` + +(4) **Delete `getVideoBuffer` (the synchronous version that returned the local array)** — find and delete the function currently around lines ~168-172 post-deletes (`function getVideoBuffer(): VideoBufferResponse { return { chunks: videoBuffer }; }`). It no longer compiles after Task 1 deletes `videoBuffer`. + +(5) **Replace the `GET_VIDEO_BUFFER` case** — in the onMessage switch, find `case 'GET_VIDEO_BUFFER':` and replace its body VERBATIM: + +```typescript + case 'GET_VIDEO_BUFFER': + getVideoBufferFromOffscreen().then((resp) => sendResponse(resp)); + return true; +``` + +(6) **Update `saveArchive` to use the port-based fetch** — find the `const videoBuffer = getVideoBuffer();` line (currently around line 332 in the post-Task-1 file). Replace VERBATIM: + +```typescript + const videoBufferResp = await getVideoBufferFromOffscreen(); + logger.log(`Video buffer: ${videoBufferResp.chunks.length} chunks`); +``` + +And update the subsequent `createArchive(videoBuffer, ...)` call to use `videoBufferResp` instead. Specifically: + +```typescript + const archiveBlob = await createArchive( + videoBufferResp, + rrwebEvents, + userEvents, + screenshot + ); +``` + +(7) **Add OFFSCREEN_READY case + await in startVideoCapture** — two sub-edits: + +(7a) Add a new case to the onMessage switch, placed AFTER `case 'SAVE_ARCHIVE':` and BEFORE the `default:` block. VERBATIM: + +```typescript + case 'OFFSCREEN_READY': + logger.log('OFFSCREEN_READY received'); + offscreenReadyResolve?.(); + offscreenReadyResolve = null; + return false; +``` + +(7b) In `startVideoCapture`, ADD `await offscreenReady;` AFTER `await ensureOffscreen();`. The relevant excerpt becomes: + +```typescript + await ensureOffscreen(); + await offscreenReady; + logger.log('Sending START_RECORDING to offscreen...'); + // ... rest unchanged +``` + +(8) **Sender check on onMessage** — at the very top of the existing `chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {` callback, rename `_sender` to `sender` and add a guard. Replace the listener header VERBATIM: + +```typescript +chrome.runtime.onMessage.addListener((message: Message, sender, sendResponse) => { + if (sender.id !== chrome.runtime.id) { + logger.warn('Rejecting message with mismatched sender:', sender.id); + return false; + } + logger.log('Received message:', message.type, message); +``` + +(9) **onInstalled IndexedDB cleanup** — find the existing `chrome.runtime.onInstalled.addListener` block and REPLACE entirely VERBATIM: + +```typescript +chrome.runtime.onInstalled.addListener((details) => { + logger.log('Extension installed/updated:', details.reason); + // RESEARCH.md Runtime State Inventory — clean up orphaned IndexedDB from + // pre-Phase-01 builds. Idempotent: no-op if DB never existed. + try { + indexedDB.deleteDatabase('VideoRecorderDB'); + logger.log('Cleaned up orphaned VideoRecorderDB (if present)'); + } catch (e) { + logger.warn('indexedDB.deleteDatabase failed:', e); + } + initialize(); +}); +``` + +After ALL these edits, run: + +```bash +npx tsc --noEmit +npx vitest run +``` + +Both MUST exit 0. + + + npx tsc --noEmit && npx vitest run && grep -c "video-keepalive" src/background/index.ts && grep -c "VideoRecorderDB" src/background/index.ts + + + - `npx tsc --noEmit` exits 0 + - `npx vitest run` exits 0 (all 8 offscreen tests still pass — Plan 05 only touches SW, but the offscreen tests should not regress) + - `grep -c "chrome.runtime.onConnect.addListener" src/background/index.ts` returns 1 + - `grep -c "'video-keepalive'" src/background/index.ts` returns at least 1 + - `grep -c "port.sender?.id !== chrome.runtime.id" src/background/index.ts` returns 1 (T-1-04 mitigation) + - `grep -c "sender.id !== chrome.runtime.id" src/background/index.ts` returns 1 (onMessage sender check) + - `grep -c "indexedDB.deleteDatabase('VideoRecorderDB')" src/background/index.ts` returns 1 + - `grep -c "function getVideoBufferFromOffscreen" src/background/index.ts` returns 1 + - `grep -c "REQUEST_BUFFER" src/background/index.ts` returns at least 1 + - `grep -c "offscreenReady" src/background/index.ts` returns at least 2 (the Promise variable + the await) + - `grep -c "OFFSCREEN_READY" src/background/index.ts` returns at least 1 (the case label) + - `grep -c "as any" src/background/index.ts` returns 0 + - `grep -c "@ts-ignore" src/background/index.ts` returns 0 + + SW side fully wired against the offscreen's port. Sender checks in place. IndexedDB cleanup landed on onInstalled. The SW is now a pure coordinator — it holds no buffer state of its own. + + + + + +After both tasks land: + +1. `npx tsc --noEmit` — exits 0. +2. `npx vitest run` — exits 0 (8 tests passing across 4 files in tests/offscreen/). +3. The grep gates listed in Task 2's `acceptance_criteria` all return their expected values. +4. `wc -l src/background/index.ts` — line count between 380 and 440 (the file shrunk from 536 by ~100-150 lines as the legacy paths went away and ~30-40 lines were added for the port host). + +Commit cadence: TWO commits. +- Task 1: ONE commit (`refactor(01-05): delete legacy SW buffer + alarms + IndexedDB + tabCapture paths`). +- Task 2: ONE commit (`feat(01-05): wire SW-side port host and port-based buffer fetch`). + + + +- `src/background/index.ts` carries no buffer state, no alarms, no IndexedDB plumbing, no `tabCapture` calls +- SW has `onConnect` handler matching the offscreen's port (Plan 04 counterparty) +- SW has `OFFSCREEN_READY` handshake handler resolving a readiness Promise +- T-1-04 mitigations in place on BOTH onConnect (sender + port name) and onMessage (sender) +- IndexedDB orphan cleanup runs on onInstalled +- `tsc --noEmit` clean; no `as any`, no `@ts-ignore` + + + +After completion, create `.planning/phases/01-stabilize-video-pipeline/01-05-SUMMARY.md` with: +- Before/after line count for src/background/index.ts (was 536, now N) +- List of every deleted symbol from Task 1 (so future audits can grep) +- The final shape of the onMessage switch (which cases survived) +- A snippet showing the exact `chrome.offscreen.createDocument` call as committed (so Plan 06 / 07 can grep against it) +- Confirmation that `npx vitest run` shows all 8 tests passing (port + handshake stay green because Plan 04's offscreen-side stayed unchanged) +- Two commit SHAs + diff --git a/.planning/phases/01-stabilize-video-pipeline/01-06-PLAN.md b/.planning/phases/01-stabilize-video-pipeline/01-06-PLAN.md new file mode 100644 index 0000000..fea082b --- /dev/null +++ b/.planning/phases/01-stabilize-video-pipeline/01-06-PLAN.md @@ -0,0 +1,320 @@ +--- +phase: 01-stabilize-video-pipeline +plan: 06 +type: execute +wave: 2 +depends_on: ["03"] +files_modified: + - vite.config.ts + - offscreen/index.ts + - offscreen/index.html +autonomous: true +requirements: + - REQ-video-ring-buffer +requirements_addressed: + - REQ-video-ring-buffer + +must_haves: + truths: + - "`vite.config.ts` no longer contains the `copy-offscreen` inline plugin (the 200+-line block including IndexedDB plumbing, codec fallback chain, and `mediaRecorder` shadow is GONE)" + - "`vite.config.ts` declares the offscreen entry via `rollupOptions.input` per RESEARCH.md Example B" + - "Top-level `offscreen/index.ts` is DELETED (dead code per audit P2 #18)" + - "Top-level `offscreen/index.html` is DELETED (replaced by the crxjs-managed `src/offscreen/index.html` from Plan 03)" + - "`npm run build` exits 0; `dist/` contains a bundled offscreen HTML at the path the SW's `chrome.runtime.getURL` argument expects" + - "No mention of `VideoRecorderDB`, `openIndexedDB`, `chromeMediaSource`, or `copy-offscreen` remains in `vite.config.ts`" + artifacts: + - path: "vite.config.ts" + provides: "Minimal vite config with crxjs and offscreen entry" + min_lines: 15 + contains: "rollupOptions" + - path: "dist/manifest.json" + provides: "Build output (artifact of npm run build)" + contains: "manifest_version" + - path: "dist/src/offscreen/index.html" + provides: "Bundled offscreen HTML at the path SW.ensureOffscreen expects (or wherever crxjs emits it — actual path verified at build time)" + contains: "" + key_links: + - from: "vite.config.ts (rollupOptions.input.offscreen)" + to: "src/offscreen/index.html" + via: "rollup input declaration" + pattern: "src/offscreen/index.html" + - from: "vite.config.ts" + to: "@crxjs/vite-plugin" + via: "crx({ manifest, contentScripts: { injectCss: false } })" + pattern: "crx(" +--- + + +Collapse the build pipeline. DELETE the 200+-line `copy-offscreen` inline +Vite plugin block in `vite.config.ts` and the dead `offscreen/index.ts` + +`offscreen/index.html` at the repo root. The crxjs plugin already handles +bundling; we declare the new offscreen entry via `rollupOptions.input` +pointing at the `src/offscreen/index.html` Plan 03 created. + +Purpose: D-08 says delete the inline plugin (which is the audit's P0 #1 root +cause). D-07 says crxjs picks up the new TS entry through the HTML +reference. The execution path is "minimal vite.config.ts + the existing +crx() invocation + a single rollupOptions.input line — that's it." + +The plan also includes a build-time pathing verification — after the first +`npm run build`, the executor inspects `dist/` and confirms the bundled +offscreen HTML lands at the SAME path the SW's +`chrome.runtime.getURL(...)` argument in Plan 05 expects. If crxjs strips +the `src/` prefix (which it sometimes does), the SW URL gets adjusted by a +follow-up Edit. RESEARCH.md Pitfall 5 calls this out explicitly. + +Output: A ~25-line `vite.config.ts`, no top-level `offscreen/` directory, +and a clean `npm run build` whose output passes the path-matching check. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md +@.planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md +@.planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md +@vite.config.ts +@offscreen/index.ts +@offscreen/index.html +@src/offscreen/index.html +@manifest.json + + + +crxjs entry contract (per RESEARCH.md §"Pitfall 5" and discussion #919): +- `rollupOptions.input` declares an HTML entry; crxjs bundles the HTML and emits its referenced TS module into `dist/assets/`. +- The runtime URL that the SW passes to `chrome.offscreen.createDocument` MUST match the bundled HTML path. If `input: { offscreen: 'src/offscreen/index.html' }`, the runtime URL is typically `chrome.runtime.getURL('src/offscreen/index.html')` (crxjs preserves the input key as the output path), but real-world projects have reported the `src/` prefix being stripped. The executor MUST verify after the first `npm run build` and adjust Plan 05's `ensureOffscreen` call if needed. + +Plan 05's `ensureOffscreen` currently calls: +```typescript +const url = chrome.runtime.getURL('offscreen/index.html'); +``` +(this is leftover from the pre-amendment manifest entry that has the old root-level `offscreen/` dir). Plan 06 verifies the post-build emit path and updates this string to match. If the emit path is `src/offscreen/index.html`, the call becomes `getURL('src/offscreen/index.html')`. Plan 05 wrote the path AS-IS — Plan 06 is the right place to do the binding verification because it owns the build pipeline. + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| build-time vite plugin → bundled output | A malicious build plugin could inject code; we're DELETING our inline plugin (the audit's P0 #1) which is itself an attack-surface reduction | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-1-NEW-06-01 | Tampering — string-injected code via inline plugin | `vite.config.ts:13-216` `copy-offscreen` plugin's `this.emitFile({ source: \`\` })` | mitigate | DELETE the entire inline plugin. The replacement is a `src/offscreen/recorder.ts` real module + a `src/offscreen/index.html` declared in `rollupOptions.input`. No more long template-literal JS in `vite.config.ts`. Grep gate: `grep -v '^#' vite.config.ts \| grep -c "this.emitFile"` returns 0. | +| T-1-NEW-06-02 | Tampering — orphaned root-level offscreen | `offscreen/index.ts` + `offscreen/index.html` (dead code) | mitigate | DELETE both files. After this plan, a `find offscreen/ -type f` produces no output. Grep gate: `[ ! -d offscreen ]` exits 0. | + + + + + + Task 1: DELETE — top-level offscreen/ directory and inline copy-offscreen plugin + vite.config.ts, offscreen/index.ts, offscreen/index.html + + - vite.config.ts (lines 1-227 — full file; the inline plugin spans lines 13-216) + - offscreen/index.ts (60 lines — dead code) + - offscreen/index.html (10 lines — replaced by src/offscreen/index.html from Plan 03) + - .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §`vite.config.ts` (lines 399-436) + - .planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md §"Example B" (lines 895-915) + + +Three deletions and one rewrite. + +(1) Delete `offscreen/index.ts` (the orphan dead-code file): + +```bash +rm offscreen/index.ts +``` + +(2) Delete `offscreen/index.html` (it is REPLACED by the new `src/offscreen/index.html` that Plan 03 created — the path was deliberately moved into the source tree): + +```bash +rm offscreen/index.html +``` + +(3) Remove the now-empty `offscreen/` directory (verify it is empty first; if anything else lives there, STOP and surface it): + +```bash +[ -d offscreen ] && rmdir offscreen +``` + +(4) REWRITE `vite.config.ts` to be the minimal RESEARCH.md Example B form. Use the Write tool (NOT Edit — the file is being replaced). VERBATIM content: + +```typescript +import { defineConfig } from 'vite'; +import { crx } from '@crxjs/vite-plugin'; +import manifest from './manifest.json'; + +export default defineConfig({ + plugins: [ + crx({ + manifest, + contentScripts: { + injectCss: false, + }, + }), + ], + build: { + rollupOptions: { + input: { + offscreen: 'src/offscreen/index.html', + }, + }, + }, +}); +``` + +The deleted content (the entire `copy-offscreen` plugin block at lines 13-216 plus the surrounding `build.rollupOptions.output.manualChunks` shape at lines 218-226) does not survive any portion in the rewrite. + +After these edits, run: + +```bash +npx tsc --noEmit +``` + +It MUST exit 0 (the previous TS errors about `(error as any).message?.includes` etc. were inside `src/background/index.ts`; Plan 05 already cleared those. `vite.config.ts` is type-checked via Vite, not `tsc --noEmit`; the `npx tsc --noEmit` check here is a regression guard for the rest of the codebase). + + + [ ! -f offscreen/index.ts ] && [ ! -f offscreen/index.html ] && [ ! -d offscreen ] && [ "$(grep -c 'copy-offscreen' vite.config.ts)" -eq 0 ] && npx tsc --noEmit + + + - `test ! -f offscreen/index.ts` exits 0 (file deleted) + - `test ! -f offscreen/index.html` exits 0 (file deleted) + - `test ! -d offscreen` exits 0 (directory removed) + - `grep -c "copy-offscreen" vite.config.ts` returns 0 + - `grep -c "this.emitFile" vite.config.ts` returns 0 (T-1-NEW-06-01 grep gate) + - `grep -c "VideoRecorderDB" vite.config.ts` returns 0 + - `grep -c "openIndexedDB" vite.config.ts` returns 0 + - `grep -c "chromeMediaSource" vite.config.ts` returns 0 + - `grep -c "rollupOptions" vite.config.ts` returns 1 + - `grep -c "src/offscreen/index.html" vite.config.ts` returns 1 + - `wc -l vite.config.ts` returns ≤ 30 + - `npx tsc --noEmit` exits 0 + - `npx vitest run` exits 0 (regression guard) + + Inline plugin gone. Root-level offscreen/ gone. vite.config.ts is ~22 lines. + + + + Task 2: BUILD VERIFY — run npm run build and reconcile the offscreen path + src/background/index.ts (only if path-adjustment needed) + + - vite.config.ts (post-Task-1 state) + - src/offscreen/index.html (from Plan 03) + - src/background/index.ts (line 85 area — the `chrome.runtime.getURL('offscreen/index.html')` call inherited from before Plan 05; verify with `grep -n "getURL" src/background/index.ts`) + - .planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md §"Pitfall 5" (lines 838-856) + + +Run the build: + +```bash +rm -rf dist +npm run build 2>&1 | tee /tmp/01-06-build.log +``` + +The build MUST exit 0. If it doesn't: +- Check the log for syntax/type errors and STOP — do not proceed to the rest of this task. +- Common failure modes: `chrome.offscreen.Reason.DISPLAY_MEDIA` not in current `@types/chrome` (fall back to the cast Plan 05 documented); the manifest reference to `desktopCapture` not yet landed (Plan 01 should have committed it — re-check); rollupOptions.input pointing at a non-existent file. + +If the build succeeds, verify the dist layout: + +```bash +ls -la dist/ +ls -la dist/src/offscreen/ 2>/dev/null +ls -la dist/offscreen/ 2>/dev/null +``` + +There will be ONE of two outcomes: + +**Outcome A — crxjs preserved `src/`**: `dist/src/offscreen/index.html` exists. In this case, the SW URL needs to be `chrome.runtime.getURL('src/offscreen/index.html')`. + +**Outcome B — crxjs stripped `src/`**: `dist/offscreen/index.html` exists. In this case, the SW URL needs to be `chrome.runtime.getURL('offscreen/index.html')` (which is what the current Plan-05 file already has by accident — leftover from the pre-amendment code). + +Identify which outcome obtains. Then edit `src/background/index.ts` line ~85 (inside `ensureOffscreen`): + +```typescript + const url = chrome.runtime.getURL(''); +``` + +If Outcome A: `` = `'src/offscreen/index.html'`. + +If Outcome B: `` = `'offscreen/index.html'` (leave unchanged). + +Either way, after the edit (or no-edit), re-run: + +```bash +npx tsc --noEmit +npm run build +ls -la dist/manifest.json +node -e "const m=require('./dist/manifest.json'); console.log('permissions:', m.permissions.join(','))" +``` + +The last line MUST print a comma-separated permission list that includes `desktopCapture` and does NOT include `tabCapture` (Plan 01 should already have ensured this in `manifest.json`; the `dist/manifest.json` is the crxjs-bundled output that propagates the manifest as-is). + +Final layout verification: + +```bash +find dist -type f -name "*.html" -o -name "*.js" | sort +``` + +The output should contain (in some order): +- `dist/manifest.json` +- `dist/src/popup/index.html` (or `dist/popup/index.html` — same Outcome-A/B rule) +- `dist//index.html` (whichever outcome) +- One or more `dist/assets/*.js` files (bundled SW, content script, offscreen TS, popup TS) + +If the manifest.json's `background.service_worker` field doesn't resolve to an emitted file, STOP and audit. crxjs handles this automatically — there's only a problem if the manifest entry was hand-broken. + + + npm run build && ls dist/manifest.json && [ -f dist/src/offscreen/index.html ] || [ -f dist/offscreen/index.html ] + + + - `npm run build` exits 0 (with NO TypeScript errors in /tmp/01-06-build.log) + - `dist/manifest.json` exists + - Either `dist/src/offscreen/index.html` OR `dist/offscreen/index.html` exists + - `dist/manifest.json` permissions list contains `"desktopCapture"` and does NOT contain `"tabCapture"` + - `src/background/index.ts` `chrome.runtime.getURL(...)` argument matches whichever Outcome (A or B) the build produced + - `dist/assets/` contains at least one `.js` file (the bundled SW / content / popup / offscreen scripts) + + Build succeeds. SW URL string matches the dist layout. Plan 07 can load `dist/` into Chrome and the offscreen will resolve correctly. + + + + + +After both tasks land: + +1. `npm run build` — exits 0, clean output. +2. `dist/` contains a loadable extension (manifest.json + at least one HTML offscreen page + at least one JS bundle in assets/). +3. `npx tsc --noEmit && npx vitest run` — both exit 0 (regression guard for the rest of the codebase). +4. `wc -l vite.config.ts` — ≤ 30 lines. +5. No top-level `offscreen/` directory. +6. The SW URL string in `src/background/index.ts` matches the actual `dist/` emit path (Outcome A or B). + +Commit cadence: TWO commits. +- Task 1: ONE commit (`refactor(01-06): delete inline copy-offscreen plugin and orphan offscreen/ directory`). +- Task 2: ONE commit if path adjustment was needed (`fix(01-06): align ensureOffscreen URL with crxjs emit path`); ZERO commits if no path adjustment was needed (note in SUMMARY which Outcome obtained). + + + +- `vite.config.ts` is ~22 lines, contains crx() and rollupOptions only +- `offscreen/` top-level directory is GONE +- `dist/manifest.json` carries the post-Plan-01 amended permissions (desktopCapture, no tabCapture, no alarms) +- The SW URL string and the bundled HTML path match +- `npm run build` clean + + + +After completion, create `.planning/phases/01-stabilize-video-pipeline/01-06-SUMMARY.md` with: +- Pre / post line count for vite.config.ts (was 227, now ~22) +- Confirmation that `offscreen/` directory is gone (output of `find offscreen/ 2>&1 || echo absent`) +- The outcome (A or B) for crxjs's emit path, and the exact SW URL string committed +- Output of `find dist -type f | sort` after the post-Task-2 build, so Plan 07's manual smoke test knows what to load +- Two commit SHAs (or one if Task 2 needed no path adjustment) +- Note: "Plan 07 loads `dist/` unpacked into Chrome ≥ 116 and runs the ffprobe acceptance gate (D-12)." + diff --git a/.planning/phases/01-stabilize-video-pipeline/01-07-PLAN.md b/.planning/phases/01-stabilize-video-pipeline/01-07-PLAN.md new file mode 100644 index 0000000..41ad51a --- /dev/null +++ b/.planning/phases/01-stabilize-video-pipeline/01-07-PLAN.md @@ -0,0 +1,329 @@ +--- +phase: 01-stabilize-video-pipeline +plan: 07 +type: execute +wave: 3 +depends_on: ["05", "06"] +files_modified: + - tests/fixtures/last_30sec.webm +autonomous: false +requirements: + - REQ-video-ring-buffer +requirements_addressed: + - REQ-video-ring-buffer + +must_haves: + truths: + - "`dist/` loads unpacked into Chrome ≥ 116 with no errors at chrome://extensions" + - "On extension load, the offscreen prompts the operator with Chrome's native screen-share picker" + - "After selecting a screen/window, Chrome's permanent 'Sharing your screen' indicator is shown (D-04 accepted residual risk)" + - "After ≥ 30 s of recording, clicking SAVE_ARCHIVE produces a `session_report_*.zip` in the user's Downloads folder containing `video/last_30sec.webm`" + - "Extracting that webm and running `ffprobe -v error -f matroska -i last_30sec.webm` exits 0 with EMPTY stderr (D-12 acceptance gate)" + - "The captured webm is preserved at `tests/fixtures/last_30sec.webm` as a known-good sample for regression" + - "If the ffprobe gate FAILS, the developer is presented with the D-13 fallback skeleton (already pre-staged in src/offscreen/recorder.ts) and re-plans Phase 1 closure" + artifacts: + - path: "tests/fixtures/last_30sec.webm" + provides: "Captured + verified ffprobe-clean WebM sample" + contains: "" + key_links: + - from: "dist/manifest.json" + to: "dist/src/offscreen/index.html (or dist/offscreen/index.html — whichever Plan 06 produced)" + via: "chrome.offscreen.createDocument at runtime" + pattern: "DISPLAY_MEDIA" + - from: "ffprobe -v error" + to: "last_30sec.webm playability" + via: "exit code 0 + empty stderr" + pattern: "" +--- + + +Manual smoke test against a real Chrome instance + ffprobe verification of +the assembled `last_30sec.webm`. This is the D-12 acceptance gate that +ROADMAP.md Phase 1 Success Criterion #3 names verbatim ("concatenating +header + buffered chunks yields a byte sequence a browser would play"). + +Purpose: Code-level verification (vitest, tsc) covers correctness of pure +logic and types. Playability of an actual WebM file requires running the +extension against a real browser, recording, and exporting. Phase 1 is not +"done" until ffprobe accepts the output — that is the criterion the +plan-checker is bound to enforce. + +This plan is **`autonomous: false`** — it has a `checkpoint:human-verify` +because driving a Chrome instance unpacked-load + screen-picker click + +manual save click is not a CLI operation. ffprobe IS a CLI tool; the +ffprobe step is automated AFTER the human steps complete. + +The plan also handles the BRANCH where ffprobe fails — in that case, the +developer SHALL switch to the D-13 fallback (already pre-staged as a +commented skeleton in `src/offscreen/recorder.ts`) and the orchestrator +re-plans Phase 1 closure as a follow-up. + +Output: A committed `tests/fixtures/last_30sec.webm` known-good sample (if +ffprobe passes) OR a clear escalation to D-13 (if ffprobe fails). + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md +@.planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md +@.planning/phases/01-stabilize-video-pipeline/01-VALIDATION.md +@.planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md +@dist/manifest.json +@src/offscreen/recorder.ts + + + +This plan runs against a built `dist/` from Plan 06. No new code interfaces +are introduced. The plan exercises the contracts already implemented: + +- SW `ensureOffscreen` → `chrome.offscreen.createDocument({reasons: [DISPLAY_MEDIA]})` +- Offscreen on START_RECORDING: `getDisplayMedia` → MediaRecorder.start(2000) +- Offscreen ondataavailable: addChunk(blob, Date.now()) +- Offscreen track.onended: clear buffer + RECORDING_ERROR to SW +- SW on SAVE_ARCHIVE: getVideoBufferFromOffscreen() via port → REQUEST_BUFFER → BUFFER → mergeVideoChunks → JSZip → chrome.downloads.download + +Manual interactions: +1. `chrome://extensions` → "Load unpacked" → select `dist/` +2. (Whatever currently triggers `startVideoCapture` — the existing flow in `src/popup/index.ts` opens the popup, which probably calls `REQUEST_PERMISSIONS`. The exact UX wire is Phase 3 territory, so Plan 07 accepts that the trigger may be clunky — the goal is to get a recording running and exporting.) +3. Click through Chrome's native screen picker +4. Wait ≥ 35 seconds (so the buffer has rotated at least once and the trim path executed) +5. Click whatever in the popup triggers SAVE_ARCHIVE (likely "Сохранить отчёт об ошибке") +6. Verify a `session_report_*.zip` lands in Downloads +7. Extract and run ffprobe + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| developer's local Chrome ↔ unpacked extension | The developer is running the extension on their own machine; loading unpacked is the recommended Phase 1 deployment per audit P2 #22 (no CI). No new trust boundary introduced by this plan. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-1-NEW-07-01 | Information Disclosure — committed test fixture | `tests/fixtures/last_30sec.webm` | mitigate | The developer chooses what to share with Chrome's picker when running the smoke test. Recommended share: a fresh test browser window with no sensitive content (e.g., the README rendered in Markdown preview). The committed fixture MUST NOT contain identifying information. Reviewer guidance documented in SUMMARY. | + + + + + + Task 1: HUMAN — load dist/ unpacked, capture a recording, export, save the archive + tests/fixtures/last_30sec.webm + + - dist/manifest.json (verify the build is loadable) + - src/popup/index.html and src/popup/index.ts (understand the existing UX trigger — Phase 3 reworks this; for now, click whatever button exists) + + +This is a `checkpoint:human-verify` task. The "action" is the manual sequence below — Claude prepares the environment (pre-flight checks), the human drives Chrome, then Claude resumes for the ffprobe gate. Full details in `` and `` below. + +Step summary: +1. Run pre-flight automation (manifest check + ffprobe presence check). +2. Pause and hand off to the human: load dist/ unpacked, click through the popup, accept the screen-share picker, wait ≥ 35 s, click save. +3. Resume automation: extract the saved archive, run `ffprobe -v error -f matroska -i last_30sec.webm`, capture the exit code + stderr. +4. If ffprobe exit 0 with no stderr lines: commit the captured WebM as `tests/fixtures/last_30sec.webm` (regression fixture for future phases). +5. If ffprobe fails: escalate to the orchestrator with the D-13 fallback signal. + + +The full Phase-1 Mokosh build: +- `dist/manifest.json` declares `desktopCapture` (not `tabCapture`) +- The offscreen document calls `getDisplayMedia` once per session +- Captured stream is recorded by a single `MediaRecorder` at vp9 / 400 kbps / 2 s timeslice +- Buffer holds first chunk (WebM header) + every chunk newer than now-30s +- SW receives the buffer over the `'video-keepalive'` port on SAVE_ARCHIVE +- Archive is delivered via `chrome.downloads` + + + which ffprobe && ffprobe -v error -f matroska -i tests/fixtures/last_30sec.webm; test $? -eq 0 + + +**Pre-flight (automated, do these in a bash shell before opening Chrome):** + +```bash +# Confirm the build is fresh +ls -la dist/manifest.json dist/assets/*.js +node -e "const m=require('./dist/manifest.json'); if(!m.permissions.includes('desktopCapture')) process.exit(1); if(m.permissions.includes('tabCapture')) process.exit(2); console.log('manifest OK')" +# Confirm ffprobe is installed +which ffprobe || { echo "ffprobe missing — install with: sudo apt install ffmpeg"; exit 1; } +ffprobe -version | head -1 +``` + +**Manual smoke (in Chrome):** + +1. Open `chrome://extensions` in a Chromium-based browser (Chrome / Chromium ≥ 116). +2. Enable "Developer mode" (top right). +3. Click "Load unpacked". +4. Select the project's `dist/` directory. +5. Confirm the extension card shows the name "AI Call Recorder" (from `manifest.json`) with NO errors or warnings (red text in the card == FAIL). +6. Open the extension's popup (click the toolbar icon). +7. Click whatever button triggers permission/recording. The popup is from before this phase — Phase 3 reworks it; for Phase 1 the button may say "Сохранить отчёт" or "Запросить разрешения". +8. Chrome SHOULD show its native screen-share picker. **If it doesn't**: open chrome://extensions, click "service worker" on the extension card, inspect the SW console for the `[SW:Main]` log line that should say "Sending START_RECORDING to offscreen..." — that tells us where the flow stalled. +9. Pick "Entire screen" or a specific window (your choice — recommend an innocuous one like a code editor showing this PLAN.md). +10. Confirm Chrome's permanent "Sharing your screen" indicator appears at the top of the screen. (This is the accepted D-04 trade-off.) +11. **Wait at least 35 seconds.** Move the mouse around or scroll a page so the captured stream has actual visual change (helps vp9 produce useful frames). +12. Open the popup again and click the SAVE_ARCHIVE button (likely "Сохранить отчёт об ошибке"). +13. Within ~5 seconds, a `session_report_YYYY-MM-DD_HH-MM-SS.zip` SHOULD appear in your Downloads folder. + +**Verification of the exported archive:** + +```bash +# Find the latest archive +LATEST=$(ls -t ~/Downloads/session_report_*.zip 2>/dev/null | head -1) +echo "Latest archive: $LATEST" +[ -z "$LATEST" ] && { echo "FAIL — no session_report archive found in Downloads"; exit 1; } +# Extract the video +unzip -p "$LATEST" video/last_30sec.webm > /tmp/last_30sec.webm +ls -la /tmp/last_30sec.webm + +# Run the D-12 acceptance gate +ffprobe -v error -f matroska -i /tmp/last_30sec.webm +GATE=$? +echo "ffprobe exit: $GATE" +ffprobe -v error -show_format -show_streams /tmp/last_30sec.webm 2>&1 | head -30 +``` + +**The acceptance criterion is `GATE == 0` AND zero lines of stderr from the `ffprobe -v error` invocation above.** If both hold: +- Copy the verified webm into the project as a regression fixture: `cp /tmp/last_30sec.webm tests/fixtures/last_30sec.webm` +- Report success. + +**If `GATE != 0`** (the ffprobe gate fails): +- Capture the stderr output and the structural dump (`ffprobe -v error -show_packets -i /tmp/last_30sec.webm 2>&1 | head -50`). +- This is the D-13 escalation: the simple continuous-recorder + age-trim approach didn't survive vp9's keyframe cadence (RESEARCH.md Pitfall 1). +- DO NOT delete the existing recorder code. The D-13 fallback skeleton is already pre-staged in `src/offscreen/recorder.ts` as a commented block. Surface the failure to the orchestrator with a precise summary so a new plan (08?) can be drafted to activate the fallback. + + +Report ONE of: +- **`approved` + paste the ffprobe exit code (must be 0) + paste any single stderr line if present + confirm `tests/fixtures/last_30sec.webm` is committed.** +- **`ffprobe-failed` + paste the stderr + paste the first 20 lines of `ffprobe -v error -show_packets`. Orchestrator escalates to D-13 fallback re-plan.** +- **`load-failed` + paste the chrome://extensions error text or the SW console error. Orchestrator escalates to a previous-plan revision (likely Plan 05 or 06).** +- **`picker-rejected` + paste the offscreen console line containing `RECORDING_ERROR`. Surface to user as a one-off; retry by reloading the extension card and clicking the popup again.** + +Phase 1 is **DONE** when this checkpoint returns `approved`. + + + - The build at dist/ loads unpacked with NO red error text on the extension card. + - getDisplayMedia picker shows on a popup-triggered start path. + - Chrome's "Sharing your screen" indicator appears. + - After ≥ 35 s of recording, SAVE_ARCHIVE produces a session_report_*.zip in Downloads within 5 seconds. + - ffprobe -v error -f matroska -i last_30sec.webm exits 0 with NO stderr lines. + - tests/fixtures/last_30sec.webm is committed as the regression-fixture. + + Phase 1 acceptance gate met. WebM ring buffer ships playable output. + + + + Task 2: AUTOMATED — commit the fixture and update STATE.md + tests/fixtures/last_30sec.webm, .planning/STATE.md + + - tests/fixtures/last_30sec.webm (verify it exists from Task 1) + - .planning/STATE.md (current state — phase 1 progress) + + +This task ONLY runs if Task 1 returned `approved`. If Task 1 returned `ffprobe-failed`, `load-failed`, or `picker-rejected`, SKIP this task — the orchestrator handles the escalation. + +Two edits: + +(1) Ensure `tests/fixtures/last_30sec.webm` is committed to git. Check its size: + +```bash +ls -lh tests/fixtures/last_30sec.webm +# Expected: between ~300 KB and ~2 MB for a 30-second vp9 capture at 400 kbps +``` + +If the file is suspiciously small (< 100 KB) or large (> 5 MB), STOP and audit — the file may be empty (only the header chunk) or include way more than 30 s of content. The expected bitrate is ~400 kbps × 30 s = ~1.5 MB. + +Also verify it's tracked by git: + +```bash +git status tests/fixtures/last_30sec.webm +``` + +(2) Update `.planning/STATE.md` to mark Phase 1 as complete. Use the Edit tool to update the relevant fields: + +(2a) `stopped_at:` — replace `"Phase 1 context gathered"` with `"Phase 1 closure: ffprobe acceptance gate passed; tests/fixtures/last_30sec.webm committed"`. + +(2b) `last_activity:` — replace the existing line with `2026-05-15 — Phase 1 closure: D-12 ffprobe gate green; ready for Phase 2`. + +(2c) `## Current Position` block: +- `Phase: 1 of 5 (Stabilize video pipeline)` → `Phase: 2 of 5 (next: Stabilize DOM + event capture privacy)` +- `Plan: 0 of TBD in current phase` → `Plan: 7 of 7 complete (Phase 1 closed)` +- `Status: Ready to plan` → `Status: Phase 1 complete; ready to plan Phase 2` + +(2d) Update the `## Performance Metrics` Phase 1 row (currently `| 1. Stabilize video pipeline | 0 | — | — |`) to reflect 7 plans completed. + +(2e) Append a `## Phase 1 Closure Notes` block at the bottom of STATE.md with: +- ffprobe exit code (must be 0) +- Size of the committed fixture +- "Phase 1 outcome: SPEC §10 acceptance criteria #2, #3, #7 are functionally green pending Phase 4 end-to-end smoke verification." +- Note: "Phase 2 owns the DOM/event-capture privacy slice; Phase 3 owns the popup state machine + base64-URL replacement; Phase 4 runs the full SPEC §10 smoke pass." + +Do NOT touch the `## Deferred Items` or `## Accumulated Context > ## Decisions` sections — those are owned by the orchestrator across phases. + +After both edits, run: + +```bash +git status +``` + +The output should show `tests/fixtures/last_30sec.webm` (new) and `.planning/STATE.md` (modified). + + + [ -f tests/fixtures/last_30sec.webm ] && grep -q "Phase 1 closure" .planning/STATE.md + + + - `tests/fixtures/last_30sec.webm` exists, size between 100 KB and 5 MB + - `grep -c "Phase 1 closure" .planning/STATE.md` returns at least 1 + - `grep -c "ffprobe acceptance gate passed" .planning/STATE.md` returns at least 1 + - `git status .planning/STATE.md tests/fixtures/last_30sec.webm` shows both files staged or modified + + Phase 1 closure recorded in STATE.md; regression fixture committed; the orchestrator can transition to Phase 2 cleanly. + + + + + +After Task 1 returns `approved` AND Task 2 lands: + +1. `git log --oneline -5` shows the Phase 1 commits (Plan 01 through Plan 07). +2. `tests/fixtures/last_30sec.webm` is in the repo, size sensible (~1-2 MB). +3. `ffprobe -v error -f matroska -i tests/fixtures/last_30sec.webm; echo $?` prints `0` with no stderr. +4. `.planning/STATE.md` reflects Phase 1 closure. +5. `npx vitest run` exits 0 (8 tests passing). +6. `npx tsc --noEmit` exits 0. +7. `npm run build` exits 0. +8. `grep -RnE "tabCapture|chrome\.alarms|VideoRecorderDB|copy-offscreen|openIndexedDB" src/ vite.config.ts manifest.json | grep -v '^#'` returns nothing. + +If ANY of (1)-(8) fail, Phase 1 is NOT closed; orchestrator escalates. + +Commit cadence: ONE commit at the end of Task 2 (`feat(01-07): close phase 1 — ffprobe gate green, fixture committed`). The fixture and STATE.md are committed together. + +If Task 1 escalates (D-13 fallback or earlier-plan rework needed), this verification block is SKIPPED — the orchestrator carries the escalation. + + + +- Manual smoke + ffprobe gate green +- `tests/fixtures/last_30sec.webm` committed as regression-fixture +- `.planning/STATE.md` records Phase 1 closure +- All grep gates from Plans 01..06 still pass +- Build clean; vitest green; tsc clean +- Phase 2 can begin (the next milestone) + + + +After completion, create `.planning/phases/01-stabilize-video-pipeline/01-07-SUMMARY.md` with: +- The exact `chrome://extensions` card state observed (errors? warnings?) +- The exact stderr captured from `ffprobe -v error -f matroska -i /tmp/last_30sec.webm` (should be empty if green) +- The size of `tests/fixtures/last_30sec.webm` +- The ffprobe stream/format dump (so future regressions have a reference) +- Whether the D-13 fallback was activated (and if so, the exact escalation path) +- ONE commit SHA (the Task-2 commit) +- A "What Phase 2 needs to know" section: + - The offscreen now owns capture; Phase 2 (DOM + event-capture privacy) plugs into the existing content-script architecture and does NOT touch the offscreen + - The port `'video-keepalive'` keeps the SW alive; Phase 2's content-script work should NOT add competing keepalives + - The SW's `onMessage` listener now validates `sender.id === chrome.runtime.id`; Phase 2's content-script messages already pass this check (content scripts have the same extension ID), but if Phase 2 adds a new sender path (e.g., from an injected page-world script), Phase 2 must respect the guard + diff --git a/.planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md b/.planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md new file mode 100644 index 0000000..830feb3 --- /dev/null +++ b/.planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md @@ -0,0 +1,859 @@ +# Phase 1: Stabilize Video Pipeline — Pattern Map + +**Mapped:** 2026-05-15 +**Files analyzed:** 12 (4 NEW, 4 MODIFIED, 4 DELETED) +**Analogs found:** 11 / 12 + +> NOTE on line numbers cited in CONTEXT.md: The CONTEXT.md narrative +> references some "decimal-style" approximate line ranges that did not +> match the live file when I read it. The numbers used **below** in +> Pattern Assignments are the verified line numbers from the files as +> they exist on disk on 2026-05-15. + +## File Classification + +| New / Modified / Deleted File | Role | Data Flow | Closest Analog | Match Quality | +|-------------------------------|------|-----------|----------------|---------------| +| `src/offscreen/recorder.ts` (NEW) | offscreen recorder module | streaming + port + event-driven | `offscreen/index.ts` (dead) + inline string at `vite.config.ts:35-213` + ring-buffer structure at `src/background/index.ts:26-75` | role-match (compose 3 analogs) | +| `src/offscreen/index.html` (NEW) | bundle entry HTML | static asset | `offscreen/index.html` (current) + `src/popup/index.html` (script tag) | exact | +| `src/background/index.ts` (MODIFIED, heavy shrink) | service worker coordinator | request-response + port-host + event-driven | itself (current file) — refactor in place | exact | +| `vite.config.ts` (MODIFIED, heavy reduction) | build config | config | crxjs discussion #919 pattern (cited in RESEARCH.md Example B) | role-match | +| `manifest.json` (MODIFIED, permission swap) | manifest | config | itself | exact | +| `src/shared/types.ts` (MODIFIED, light) | shared types | type-only | itself (current file) — add/verify message types | exact | +| `tests/offscreen/ring-buffer.test.ts` (NEW) | unit test | request-response | none in codebase — Vitest default; structural cousin: `src/background/index.ts:47-75` `cleanupVideoBuffer` (subject under test) | role-only | +| `tests/offscreen/codec-check.test.ts` (NEW) | unit test | request-response | none | role-only | +| `tests/offscreen/handshake.test.ts` (NEW) | unit test | event-driven | none | role-only | +| `tests/offscreen/port.test.ts` (NEW) | unit test | port/event-driven | none | role-only | +| `vitest.config.ts` (NEW) | test runner config | config | `vite.config.ts` (defineConfig + plugin shape) + `tsconfig.json` (path/aliases) | role-only | +| `offscreen/index.ts` (DELETED) | — | — | — | — | +| `offscreen/index.html` (DELETED) | — | — | — | — | +| `vite.config.ts:11-217` inline plugin (DELETED) | — | — | — | — | +| `src/background/index.ts:156-165, 128, 457-473, 482-520` (DELETED in-place) | — | — | — | — | + +## Pattern Assignments + +### `src/offscreen/recorder.ts` (NEW — offscreen recorder; streaming + port + event-driven) + +**Analogs (composed):** +- `offscreen/index.ts` (lines 1-60) — the dead-but-correctly-shaped skeleton: module-level `let mediaRecorder`, `chrome.runtime.onMessage.addListener` with a `switch (message.type)`, `MediaRecorder.ondataavailable` push pattern. Phase 1 KEEPS the shape and REWRITES the body. +- Inline JS string at `vite.config.ts:35-213` — the live offscreen at runtime. This is the source-of-truth for the *current behavior* but is the explicit DELETE target. Use it ONLY as a reference for what NOT to copy (the codec fallback chain at lines 151-172, the IndexedDB plumbing at lines 43-107, the `let mediaRecorder` shadow at line 158). +- `src/background/index.ts:25-75` — ring-buffer structural pattern (currently SW-side). MOVE this to offscreen. +- `src/shared/logger.ts:1-25` — `Logger` class shape. Phase 1 adds an `OffscreenLogger` mirroring this (`[OS:...]` prefix) OR reuses `Logger` with prefix `Offscreen:Main`. + +**Skeleton pattern (KEEP shape; rewrite body)** — from `offscreen/index.ts:1-60`: +```typescript +// offscreen/index.ts:1-2 — module-level state declaration (KEEP shape; rename +// `mediaRecorder` → `videoRecorder` per RESEARCH.md anti-pattern fix) +let mediaRecorder: MediaRecorder | null = null; +let videoChunks: Blob[] = []; + +// offscreen/index.ts:45-59 — message-listener-with-switch shape (KEEP) +chrome.runtime.onMessage.addListener((message) => { + switch (message.type) { + case 'START_RECORDING': + startRecording(message.streamId); + break; + case 'STOP_RECORDING': + stopRecording(); + break; + case 'GET_CHUNKS': + chrome.runtime.sendMessage({ + type: 'CHUNKS_RESPONSE', + chunks: getChunks() + }); + break; + } +}); +``` + +**dataavailable pattern (KEEP behavior; chunk Blob is now pushed to ring buffer with timestamp+isHeader, NOT forwarded via `chrome.runtime.sendMessage`)** — from `offscreen/index.ts:18-27`: +```typescript +mediaRecorder.ondataavailable = (event) => { + if (event.data && event.data.size > 0) { + videoChunks.push(event.data); + chrome.runtime.sendMessage({ // ← REMOVE: this is the broken-Blob-over-sendMessage path + type: 'VIDEO_CHUNK', + data: event.data, + timestamp: Date.now() + }); + } +}; +``` + +**Ring-buffer pattern (MOVE from SW to offscreen, verbatim)** — from `src/background/index.ts:47-75`: +```typescript +function cleanupVideoBuffer() { + const now = Date.now(); + const beforeCount = videoBuffer.length; + + logger.log(`Cleaning up buffer, current size: ${beforeCount}`); + + // Всегда сохраняем первый чанк (WebM заголовок, помечен как isFirst) + // Остальные чанки фильтруем по времени (старше 30 секунд удаляем) + videoBuffer = videoBuffer.filter(chunk => { + // Всегда оставляем первый чанк (заголовок) + if (chunk.isFirst) { + return true; + } + // Остальные - только если моложе 30 секунд + const age = now - chunk.timestamp; + const keep = age < VIDEO_BUFFER_DURATION_MS; + if (!keep) { + logger.log(`Removing chunk, age: ${age}ms, limit: ${VIDEO_BUFFER_DURATION_MS}ms`); + } + return keep; + }); + + const removed = beforeCount - videoBuffer.length; + if (removed > 0) { + logger.log(`Removed ${removed} old video chunks, buffer: ${videoBuffer.length}`); + } else { + logger.log(`No chunks removed, buffer: ${videoBuffer.length}`); + } +} +``` + +**Header-pin add pattern (MOVE from SW to offscreen)** — from `src/background/index.ts:26-45`: +```typescript +function addVideoChunkFromBlob(blob: Blob) { + logger.log(`Processing video chunk from Blob, size: ${blob.size} bytes, type: ${blob.type}`); + + const chunk: VideoChunk = { + data: blob, + timestamp: Date.now(), + isFirst: !firstChunkSaved // Первый чанк помечаем как isFirst + }; + + if (!firstChunkSaved) { + firstChunkSaved = true; + logger.log(`This is the FIRST video chunk (WebM header), size: ${blob.size} bytes`); + } + + videoBuffer.push(chunk); + cleanupVideoBuffer(); +} +``` + +**Constants pattern** — from `src/background/index.ts:12-13`: +```typescript +// Константы +const VIDEO_BUFFER_DURATION_MS = 30 * 1000; // 30 секунд +``` +Add for the new module: +```typescript +const TIMESLICE_MS = 2000; // CON-video-codec / SPEC §4.1 (was 200ms) +const VIDEO_MIME = 'video/webm;codecs=vp9'; +const VIDEO_BITRATE = 400_000; // CON-video-codec +const PORT_NAME = 'video-keepalive'; +const PORT_PING_MS = 25_000; // < 30s SW idle threshold +const PORT_RECONNECT_MS = 290_000; // pre-empt the ~5min port cap +``` + +**getDisplayMedia call pattern** — from RESEARCH.md Example A (canonical): +```typescript +async function startRecording(): Promise { + const stream = await navigator.mediaDevices.getDisplayMedia({ + video: true, + audio: false, // SPEC §9 — Phase 2/CAP-01 territory + }); + // Codec strict-mode — RESEARCH.md Example E + if (!MediaRecorder.isTypeSupported(VIDEO_MIME)) { + const err = `vp9 unsupported. UA=${navigator.userAgent}`; + chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: err }); + throw new Error(err); + } + videoRecorder = new MediaRecorder(stream, { + mimeType: VIDEO_MIME, + videoBitsPerSecond: VIDEO_BITRATE, + }); + videoRecorder.ondataavailable = onDataAvailable; + videoRecorder.start(TIMESLICE_MS); + // RESEARCH.md Example F — track.ended for "Stop sharing" recovery + stream.getTracks().forEach((track) => { + track.addEventListener('ended', onUserStoppedSharing, { once: true }); + }); +} +``` + +**OFFSCREEN_READY handshake (Pattern 4)** — from RESEARCH.md (matches `src/shared/types.ts:18` declared but unused message type): +```typescript +// at the bottom of recorder.ts, after the onMessage listener is registered: +chrome.runtime.onMessage.addListener((msg) => { /* ... */ }); +chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' }); +``` + +**Port keepalive pattern (Pattern 5)** — from RESEARCH.md lines 600-628: +```typescript +const port = chrome.runtime.connect({ name: PORT_NAME }); +setInterval(() => port.postMessage({ type: 'PING' }), PORT_PING_MS); +port.onMessage.addListener((msg) => { + if (msg.type === 'REQUEST_BUFFER') { + port.postMessage({ type: 'BUFFER', chunks: videoBuffer }); + } +}); +port.onDisconnect.addListener(() => { + // Pitfall 4 — 5-minute port cap, reconnect + reconnectPort(); +}); +``` + +**Russian-comment style (KEEP)** — observed throughout `src/background/index.ts:12, 25, 47, 52-54, 156`: +```typescript +// Константы +// Кольцевой буфер видео +// Очистка старых событий +// Keepalive для предотвращения выгрузки Service Worker +``` +New code in `src/offscreen/recorder.ts` SHOULD include Russian section headers (matches project provenance from CONTEXT.md "Established patterns"). + +**Imports pattern (KEEP project shape)** — from `src/background/index.ts:1-8`: +```typescript +import { Logger } from '../shared/logger'; +import type { + Message, + VideoChunk, + SessionMetadata, + VideoBufferResponse +} from '../shared/types'; +import JSZip from 'jszip'; +``` +For the new offscreen recorder: +```typescript +import { Logger } from '../shared/logger'; +import type { Message, VideoChunk } from '../shared/types'; +``` + +--- + +### `src/offscreen/index.html` (NEW — bundle entry HTML, static asset) + +**Analog:** `offscreen/index.html` (current) — DELETE and recreate. + +**Current pattern** — `offscreen/index.html:1-10`: +```html + + + + + Offscreen Page + + + + + +``` + +**Popup-side analog (same shape, references TS via crxjs)** — `src/popup/index.html:1-21`: +```html + + + + + + AI Call Recorder + + + + ... + + + +``` + +**Pattern to copy** — RESEARCH.md Example A (crxjs discussion #919): +```html + + +Mokosh Recorder + + + + +``` + +--- + +### `src/background/index.ts` (MODIFIED — service worker coordinator; shrinks substantially) + +**Analog:** itself. Refactor in place. + +**DELETE blocks (verified line numbers as of 2026-05-15):** + +| Block | Current Lines | Reason | +|-------|---------------|--------| +| `addVideoChunkFromBlob` | 26-45 | MOVE to offscreen (D-16) | +| `cleanupVideoBuffer` | 47-75 | MOVE to offscreen (D-16) | +| `getMediaStreamId` call inside `startVideoCapture` | 126-131 | Replaced by `getDisplayMedia` in offscreen (D-01) | +| `setupKeepalive` function | 156-165 | Replaced by port keepalive (D-18) | +| `VIDEO_CHUNK` case in onMessage | 457-466 | Buffer no longer travels via sendMessage (D-19) | +| `VIDEO_CHUNK_SAVED` case in onMessage | 468-473 | IndexedDB path is dead (D-19) | +| `loadChunkFromIndexedDB` function | 482-505 | IndexedDB path is dead (D-19) | +| `openIndexedDB` function | 507-520 | IndexedDB path is dead (D-19) | +| `setupKeepalive()` call inside `initialize` | 525 | Paired with deletion of `setupKeepalive` | + +**Auth/Guard pattern (sender.id validation — RESEARCH.md security recommendation)** — currently MISSING: +The SW's onMessage listener at `src/background/index.ts:427-479` does NOT validate `sender.id`. Phase 1 SHOULD add `if (_sender.id !== chrome.runtime.id) return false;` at the top of the new port and onMessage handlers (low-effort hardening; per RESEARCH.md security domain table). Rename `_sender` → `sender` since it is now used. + +**onMessage handler pattern (KEEP shape, modify cases)** — from `src/background/index.ts:427-479`: +```typescript +chrome.runtime.onMessage.addListener((message: Message, _sender, sendResponse) => { + logger.log('Received message:', message.type, message); + + switch (message.type) { + case 'REQUEST_PERMISSIONS': + checkPermissions().then(async (hasPermissions) => { + if (hasPermissions) { + if (!isRecording) { + await startVideoCapture(); + } + sendResponse({ granted: true }); + } else { + requestPermissions().then(granted => { + sendResponse({ granted }); + }); + } + }); + return true; // ← async response convention + + case 'GET_VIDEO_BUFFER': + sendResponse(getVideoBuffer()); + return false; // ← sync response convention + + case 'SAVE_ARCHIVE': + saveArchive().then(result => { + sendResponse(result); + }); + return true; + + default: + logger.warn('Unknown message type:', message.type); + return false; + } +}); +``` +Phase 1 modifications: +- `REQUEST_PERMISSIONS`: replace `startVideoCapture` body with new `ensureOffscreenAndStart()` (offscreen + DISPLAY_MEDIA reason). +- `GET_VIDEO_BUFFER`: replace `getVideoBuffer()` with an over-port request to offscreen (return `true`, resolve via port message handler). +- `SAVE_ARCHIVE`: same — `getVideoBuffer()` inside `saveArchive()` (line 332) becomes a port-request to offscreen. +- Add a new `case 'OFFSCREEN_READY'` to resolve the handshake Promise (Pattern 4). + +**ensureOffscreen pattern (REPLACE reason)** — from `src/background/index.ts:78-104`: +```typescript +async function ensureOffscreen() { + if (offscreenCreated) { + logger.log('Offscreen already created'); + return; + } + + try { + const url = chrome.runtime.getURL('offscreen/index.html'); // ← path may change to 'src/offscreen/index.html'; verify after first build + logger.log('Creating offscreen document at:', url); + + await chrome.offscreen.createDocument({ + url: url, + reasons: ['USER_MEDIA'] as any, // ← REPLACE: ['DISPLAY_MEDIA']; drop the `as any` once @types/chrome is bumped + justification: 'Need to record video from tab for error reporting' // ← OPTIONAL: update copy to match RESEARCH.md Example C + }); + offscreenCreated = true; + logger.log('Offscreen document created successfully'); + } catch (error) { + if ((error as any).message?.includes('already exists')) { + offscreenCreated = true; + logger.log('Offscreen document already exists'); + } else { + logger.error('Failed to create offscreen document:', error); + throw error; + } + } +} +``` +Anti-pattern note: the `as any` on line 90 maps to audit P1 #13 and the CLAUDE.md "no @ts-ignore / no as any" rule. The fix is `chrome.offscreen.Reason.DISPLAY_MEDIA` after bumping `@types/chrome` from `^0.0.268` to `^0.1.42` (RESEARCH.md Standard Stack). The bump is OPTIONAL in this phase but recommended. + +**ADD: onConnect handler for video-keepalive port (NEW)** — RESEARCH.md Pattern 5, lines 609-617: +```typescript +let videoPort: chrome.runtime.Port | null = null; +chrome.runtime.onConnect.addListener((p) => { + if (p.name !== 'video-keepalive') return; + videoPort = p; + p.onMessage.addListener((msg) => { + if (msg.type === 'BUFFER') { /* resolve pending export */ } + }); + p.onDisconnect.addListener(() => { videoPort = null; }); +}); +``` + +**ADD: one-shot deletion of orphaned IndexedDB on install** — RESEARCH.md Runtime State Inventory: +The `chrome.runtime.onInstalled.addListener` at `src/background/index.ts:530-533` is the natural site to call `indexedDB.deleteDatabase('VideoRecorderDB')` to clean up old browser profiles. Add inside that listener. + +**`saveArchive` pattern (MODIFY to fetch buffer via port)** — `src/background/index.ts:314-387`: +```typescript +async function saveArchive() { + // ... unchanged up to line 332 ... + const videoBuffer = getVideoBuffer(); // ← REPLACE: await getVideoBufferFromOffscreen() + // ... unchanged thereafter ... +} +``` + +--- + +### `vite.config.ts` (MODIFIED — heavy reduction) + +**Analog:** crxjs discussion #919 working pattern (cited in RESEARCH.md Example B). + +**DELETE the entire inline plugin block** — `vite.config.ts:13-216` (the object literal starting `{ name: 'copy-offscreen', generateBundle() { ... } }`). + +**Final shape (REPLACE existing file body)** — RESEARCH.md Example B: +```typescript +import { defineConfig } from 'vite'; +import { crx } from '@crxjs/vite-plugin'; +import manifest from './manifest.json'; + +export default defineConfig({ + plugins: [ + crx({ manifest, contentScripts: { injectCss: false } }), + ], + build: { + rollupOptions: { + input: { + offscreen: 'src/offscreen/index.html', + }, + }, + }, +}); +``` + +**Current `crx` invocation pattern (KEEP)** — `vite.config.ts:5-12`: +```typescript +crx({ + manifest, + contentScripts: { + injectCss: false, + }, +}), +``` +This invocation form is correct and is retained verbatim; only the second plugin entry (the inline `copy-offscreen`) is deleted, and `rollupOptions.input` for the offscreen HTML entry is added per RESEARCH.md Pitfall 5. + +**Pitfall 5 note (per RESEARCH.md):** Inspect `dist/` after the first `npm run build` to verify the emitted path matches what `src/background/index.ts:85` passes to `createDocument`. If crxjs strips the `src/` prefix, change the SW's `chrome.runtime.getURL` arg accordingly. + +--- + +### `manifest.json` (MODIFIED — permission swap) + +**Analog:** itself. + +**Current permissions block** — `manifest.json:6-14`: +```json +"permissions": [ + "tabCapture", + "activeTab", + "downloads", + "scripting", + "storage", + "alarms", + "offscreen" +], +``` + +**Patch per D-A6:** +- Replace `"tabCapture"` with `"desktopCapture"`. +- KEEP `"activeTab"` (screenshot path: `chrome.tabs.captureVisibleTab`, see `src/background/index.ts:190`). +- Drop `"alarms"` (no longer used after `setupKeepalive` deletion). [OPTIONAL but consistent with D-18.] +- KEEP `"downloads"` (used at `src/background/index.ts:305`). +- KEEP `"storage"` (used by rrweb? — not used in current code; can drop, but out of scope for this phase). +- KEEP `"scripting"` (content script). +- KEEP `"offscreen"` (required to call `chrome.offscreen.createDocument`). + +Final shape: +```json +"permissions": [ + "desktopCapture", + "activeTab", + "downloads", + "scripting", + "storage", + "offscreen" +], +``` + +--- + +### `src/shared/types.ts` (MODIFIED — light; verify message types) + +**Analog:** itself. + +**Status check** — `src/shared/types.ts:3-18`: +```typescript +export type MessageType = + | 'REQUEST_PERMISSIONS' + | 'PERMISSIONS_GRANTED' + | 'PERMISSIONS_DENIED' + | 'GET_VIDEO_BUFFER' + | 'VIDEO_BUFFER_RESPONSE' + | 'GET_RRWEB_EVENTS' + | 'RRWEB_EVENTS_RESPONSE' + | 'SAVE_ARCHIVE' + | 'ARCHIVE_SAVED' + | 'START_RECORDING' + | 'STOP_RECORDING' + | 'RECORDING_ERROR' + | 'VIDEO_CHUNK' + | 'VIDEO_CHUNK_SAVED' + | 'OFFSCREEN_READY'; +``` +- `OFFSCREEN_READY` is present (line 18). ✓ +- `RECORDING_ERROR` is present (line 15). ✓ +- `VIDEO_CHUNK` and `VIDEO_CHUNK_SAVED` should be **REMOVED** (lines 16-17) since their handlers are deleted in Phase 1. + +**Phase 1 additions:** +- Add port-message string types if planner chooses to type the port traffic: + ```typescript + // Port message types (offscreen ↔ SW over 'video-keepalive' port) + export type PortMessageType = + | 'PING' + | 'REQUEST_BUFFER' + | 'BUFFER'; + export interface PortMessage { + type: PortMessageType; + chunks?: VideoChunk[]; + payload?: T; + } + ``` + +**`VideoChunk` interface (KEEP as-is)** — `src/shared/types.ts:27-31`: +```typescript +export interface VideoChunk { + data: Blob; + timestamp: number; + isFirst?: boolean; +} +``` +This shape is exactly what the offscreen ring buffer needs. Reuse unchanged. + +--- + +### `tests/offscreen/ring-buffer.test.ts` (NEW — unit test) + +**Analog:** none in codebase (no tests directory exists yet). + +**Subject under test** — the relocated `addVideoChunkFromBlob` + `cleanupVideoBuffer` from `src/background/index.ts:26-75`. Tests assert: +1. First chunk is pinned with `isFirst: true`. +2. Subsequent chunks have `isFirst: false`. +3. After 30+ seconds, non-header chunks are evicted but the header survives. +4. Buffer length stays ≤ N for N chunks added within 30 s. + +**Skeleton (standard Vitest)** — pattern inferred from RESEARCH.md Validation Architecture: +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; +import { addChunk, trimAged, getBuffer, resetBuffer } from '../../src/offscreen/recorder'; +// NB: planner must export these pure functions from recorder.ts to make them testable. + +describe('ring buffer', () => { + beforeEach(() => resetBuffer()); + + it('first chunk is header', () => { + addChunk({ size: 1024 } as Blob, 1_000); + expect(getBuffer()[0].isFirst).toBe(true); + }); + + it('trim 30s — keeps header, evicts aged tail', () => { + addChunk({ size: 1024 } as Blob, 0); // header at t=0 + addChunk({ size: 512 } as Blob, 10_000); // t=10s + addChunk({ size: 512 } as Blob, 35_000); // t=35s — header now 35s old, tail 25s + trimAged(/* now= */ 40_000); // header age 40s; first chunk 30s; last chunk 5s + const buf = getBuffer(); + expect(buf[0].isFirst).toBe(true); // header survives unconditionally + expect(buf.length).toBeGreaterThanOrEqual(2); // header + at least the t=35s chunk + }); +}); +``` + +--- + +### `tests/offscreen/codec-check.test.ts` (NEW — unit test) + +**Analog:** none. Subject under test = the codec strict-mode error path (RESEARCH.md Pattern 6 / Example E). + +**Skeleton:** +```typescript +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +describe('codec strict mode', () => { + let originalIsTypeSupported: typeof MediaRecorder.isTypeSupported; + beforeEach(() => { + originalIsTypeSupported = (globalThis as any).MediaRecorder?.isTypeSupported; + (globalThis as any).MediaRecorder = { + isTypeSupported: vi.fn().mockReturnValue(false), + }; + (globalThis as any).chrome = { runtime: { sendMessage: vi.fn() } }; + }); + afterEach(() => { + if (originalIsTypeSupported) { + (globalThis as any).MediaRecorder = { isTypeSupported: originalIsTypeSupported }; + } + }); + + it('throws on unsupported vp9 and emits RECORDING_ERROR', async () => { + const { assertCodecSupported } = await import('../../src/offscreen/recorder'); + expect(() => assertCodecSupported()).toThrow(/vp9 unsupported/); + expect((globalThis as any).chrome.runtime.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: 'RECORDING_ERROR' }) + ); + }); +}); +``` + +--- + +### `tests/offscreen/handshake.test.ts` (NEW — unit test) + +**Analog:** none. Subject under test = `OFFSCREEN_READY` Promise resolution (RESEARCH.md Pattern 4). + +**Skeleton:** +```typescript +import { describe, it, expect, vi } from 'vitest'; + +describe('OFFSCREEN_READY handshake', () => { + it('sends OFFSCREEN_READY after listener registration', async () => { + const calls: any[] = []; + (globalThis as any).chrome = { + runtime: { + 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(), + }), + }, + }; + await import('../../src/offscreen/recorder'); + expect(calls).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'OFFSCREEN_READY' }), + ])); + }); +}); +``` + +--- + +### `tests/offscreen/port.test.ts` (NEW — unit test) + +**Analog:** none. Subject under test = port reconnect on disconnect (RESEARCH.md Pitfall 4). + +**Skeleton:** +```typescript +import { describe, it, expect, vi } from 'vitest'; + +describe('port reconnect', () => { + it('reconnects when port disconnects', () => { + const disconnectListeners: Array<() => void> = []; + let connectCount = 0; + (globalThis as any).chrome = { + runtime: { + 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(), + }; + }, + }, + }; + // After import, the module connects exactly once + return import('../../src/offscreen/recorder').then(() => { + expect(connectCount).toBe(1); + // Fire the disconnect — module should reconnect + disconnectListeners.forEach((fn) => fn()); + expect(connectCount).toBeGreaterThanOrEqual(2); + }); + }); +}); +``` + +--- + +### `vitest.config.ts` (NEW — test runner config) + +**Analogs:** `vite.config.ts` (defineConfig shape) + `tsconfig.json` (compiler-options alignment). + +**Pattern to copy** — RESEARCH.md Wave 0 Gaps + Vitest standard: +```typescript +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', // node-only; Blob is shimmed by Vitest 3+ via undici + include: ['tests/**/*.test.ts'], + reporters: 'dot', + typecheck: { + enabled: false, // tsc --noEmit runs separately in `npm run build` + }, + }, +}); +``` + +**`package.json` script ADD** — companion to vitest.config.ts: +```json +"scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "test": "vitest run" // ← ADD +} +``` + +--- + +## Shared Patterns + +### Logging +**Source:** `src/shared/logger.ts:1-25` (Logger class) and `src/shared/logger.ts:28-51` (ContentLogger class). + +**Apply to:** All new and modified source files. + +```typescript +// src/shared/logger.ts:1-25 +export class Logger { + private context: string; + + constructor(context: string) { + this.context = context; + } + + private logWithLevel(level: 'log' | 'warn' | 'error', ...args: any[]) { + const timestamp = new Date().toISOString(); + console[level](`[SW:${this.context}] ${timestamp}`, ...args); + } + + log(...args: any[]) { this.logWithLevel('log', ...args); } + warn(...args: any[]) { this.logWithLevel('warn', ...args); } + error(...args: any[]) { this.logWithLevel('error', ...args); } +} +``` + +**For the new offscreen file:** Either (a) reuse `Logger` with constructor argument `'Offscreen:Main'`, producing `[SW:Offscreen:Main]` (slightly misleading since the runtime is the offscreen, not the SW), or (b) ADD an `OffscreenLogger` class mirroring `ContentLogger` with prefix `[OS:${context}]`. Planner picks; CONTEXT.md "Reusable assets" section permits either. + +Recommendation: add `OffscreenLogger` for consistency with the existing `Logger` / `ContentLogger` split. ~25 lines, matches the shape exactly: +```typescript +// New OffscreenLogger class to add to src/shared/logger.ts +export class OffscreenLogger { + private context: string; + constructor(context: string) { this.context = context; } + private logWithLevel(level: 'log' | 'warn' | 'error', ...args: any[]) { + const timestamp = new Date().toISOString(); + console[level](`[OS:${this.context}] ${timestamp}`, ...args); + } + log(...args: any[]) { this.logWithLevel('log', ...args); } + warn(...args: any[]) { this.logWithLevel('warn', ...args); } + error(...args: any[]) { this.logWithLevel('error', ...args); } +} +``` + +### Message handling (sender validation) +**Source:** RESEARCH.md Security Domain table (currently MISSING in codebase). +**Apply to:** New `chrome.runtime.onMessage` and `chrome.runtime.onConnect` handlers in both `src/background/index.ts` and `src/offscreen/recorder.ts`. + +Current code does NOT validate `sender.id`. Phase 1 SHOULD add: +```typescript +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (sender.id !== chrome.runtime.id) return false; + // ... existing dispatch ... +}); + +chrome.runtime.onConnect.addListener((port) => { + if (port.sender?.id !== chrome.runtime.id) { + port.disconnect(); + return; + } + // ... existing handling ... +}); +``` + +### Async response convention +**Source:** `src/background/index.ts:427-479`. +**Apply to:** All `onMessage` handlers that perform `await`/`then` before `sendResponse`. + +The convention in the existing code is consistent and correct: +- `return true;` — when the handler will call `sendResponse` asynchronously (after a Promise resolves). +- `return false;` — when the handler returns synchronously or does not respond. + +Preserve this convention verbatim in the modified background and the new offscreen recorder. + +### Russian inline comments +**Source:** `src/background/index.ts:12, 25, 47, 156` and `src/content/index.ts:14-18, 20-23, 27`. +**Apply to:** New `src/offscreen/recorder.ts`. + +Project convention: Russian comments are kept as section markers / explanations for business logic. +Example: +```typescript +// Константы +const TIMESLICE_MS = 2000; +// Кольцевой буфер видео +let videoBuffer: VideoChunk[] = []; +// Keepalive порт (заменяет chrome.alarms) +let keepalivePort: chrome.runtime.Port | null = null; +``` +TypeScript identifiers stay English (per CLAUDE.md naming rules); only comments are Russian. + +### Module-level state pattern +**Source:** `src/background/index.ts:15-22` and `src/content/index.ts:20-24`. +**Apply to:** New `src/offscreen/recorder.ts`. + +Single-instance modules use top-level `let` state declarations (not classes / singletons): +```typescript +// src/background/index.ts:15-22 +let videoBuffer: VideoChunk[] = []; +let firstChunkSaved = false; +let isRecording = false; +let offscreenCreated = false; +let lastScreenshotTime = 0; +let cachedScreenshot: Blob | null = null; +``` +For `src/offscreen/recorder.ts`, mirror this shape: +```typescript +let videoRecorder: MediaRecorder | null = null; // RENAMED from 'mediaRecorder' to break the audit P0 #1 shadow +let videoBuffer: VideoChunk[] = []; +let firstChunkSaved = false; +let mediaStream: MediaStream | null = null; +let keepalivePort: chrome.runtime.Port | null = null; +let pingIntervalId: number | null = null; +``` + +## No Analog Found + +| File | Role | Data Flow | Reason | +|------|------|-----------|--------| +| `tests/offscreen/*.test.ts` (4 files) | unit test | request-response | No test directory exists in the project yet. Pattern comes from Vitest defaults + RESEARCH.md Validation Architecture. | + +`vitest.config.ts` has a partial analog in `vite.config.ts` (defineConfig shape) and is listed under Pattern Assignments rather than here. + +## Metadata + +**Analog search scope:** +- `/home/parf/projects/work/repremium/src/` (all subdirectories) +- `/home/parf/projects/work/repremium/offscreen/` +- `/home/parf/projects/work/repremium/vite.config.ts` +- `/home/parf/projects/work/repremium/manifest.json` +- `/home/parf/projects/work/repremium/tsconfig.json` +- `/home/parf/projects/work/repremium/package.json` + +**Files scanned:** 10 source files; 4 config files. + +**Verified line numbers** (read on 2026-05-15): +- `src/background/index.ts` — 536 lines total. Verified delete-target ranges: 26-45 (addVideoChunkFromBlob), 47-75 (cleanupVideoBuffer), 126-131 (getMediaStreamId), 156-165 (setupKeepalive), 457-473 (VIDEO_CHUNK + VIDEO_CHUNK_SAVED cases), 482-520 (loadChunkFromIndexedDB + openIndexedDB). +- `vite.config.ts` — 227 lines total. Verified inline plugin range: 13-216 (the `copy-offscreen` object starts at line 13 with `name:` on line 14 inside a plugins array; the embedded JS string template ends at line 213; the trailing build/rollupOptions block at 218-226 also collapses per RESEARCH.md Example B). +- `offscreen/index.ts` — 60 lines, intact. DELETE target. +- `offscreen/index.html` — 10 lines, intact. DELETE target. +- `manifest.json:7` — `"tabCapture"` token to replace. +- `src/shared/types.ts:18` — `'OFFSCREEN_READY'` declared, ready to wire up. + +**Pattern extraction date:** 2026-05-15