docs(01): create phase 1 plans for video pipeline stabilization .planning/phases/01-stabilize-video-pipeline/01-01-PLAN.md .planning/phases/01-stabilize-video-pipeline/01-02-PLAN.md .planning/phases/01-stabilize-video-pipeline/01-03-PLAN.md .planning/phases/01-stabilize-video-pipeline/01-04-PLAN.md .planning/phases/01-stabilize-video-pipeline/01-05-PLAN.md .planning/phases/01-stabilize-video-pipeline/01-06-PLAN.md .planning/phases/01-stabilize-video-pipeline/01-07-PLAN.md .planning/ROADMAP.md
This commit is contained in:
@@ -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
|
||||
|
||||
434
.planning/phases/01-stabilize-video-pipeline/01-01-PLAN.md
Normal file
434
.planning/phases/01-stabilize-video-pipeline/01-01-PLAN.md
Normal file
@@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
<threat_model>
|
||||
## 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: ["<all_urls>"]` 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.)
|
||||
</threat_model>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Amend intel/decisions.md DEC-003 and DEC-010 (D-A1)</name>
|
||||
<files>.planning/intel/decisions.md</files>
|
||||
<read_first>
|
||||
- .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)
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -c "AMENDED-BY: Phase 01" .planning/intel/decisions.md</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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)
|
||||
</acceptance_criteria>
|
||||
<done>Both decisions carry an Amendment block; downstream phases that grep DEC-003 / DEC-010 now find the new contract alongside the original.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Amend intel/constraints.md — retire two, add one (D-A2)</name>
|
||||
<files>.planning/intel/constraints.md</files>
|
||||
<read_first>
|
||||
- .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)
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -c "RETIRED-BY: Phase 01" .planning/intel/constraints.md</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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
|
||||
</acceptance_criteria>
|
||||
<done>Two retired constraints carry RETIRED-BY markers; CON-display-capture-binding exists as the consolidated replacement constraint.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Amend PROJECT.md Key Decisions table and Constraints section (D-A3)</name>
|
||||
<files>.planning/PROJECT.md</files>
|
||||
<read_first>
|
||||
- .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)
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -c "AMENDED by Phase 01" .planning/PROJECT.md</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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
|
||||
</acceptance_criteria>
|
||||
<done>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/.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Amend REQUIREMENTS.md REQ-video-ring-buffer (D-A4)</name>
|
||||
<files>.planning/REQUIREMENTS.md</files>
|
||||
<read_first>
|
||||
- .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)
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -c "AMENDED in" .planning/REQUIREMENTS.md</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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
|
||||
</acceptance_criteria>
|
||||
<done>REQ-video-ring-buffer reads correctly for Phase 1 and binds the new constraint set.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: Amend ROADMAP.md Phase 1 description + Success Criterion #2 (D-A5)</name>
|
||||
<files>.planning/ROADMAP.md</files>
|
||||
<read_first>
|
||||
- .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)
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -c "AMENDED" .planning/ROADMAP.md</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 6: Manifest permission swap — tabCapture → desktopCapture, drop alarms (D-A6 / D-05)</name>
|
||||
<files>manifest.json</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<action>
|
||||
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).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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')"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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 `"<all_urls>"` (unchanged)
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
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.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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).
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
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)
|
||||
</output>
|
||||
629
.planning/phases/01-stabilize-video-pipeline/01-02-PLAN.md
Normal file
629
.planning/phases/01-stabilize-video-pipeline/01-02-PLAN.md
Normal file
@@ -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\""
|
||||
---
|
||||
|
||||
<objective>
|
||||
Wave 0 test infrastructure. Install Vitest, write `vitest.config.ts`, write
|
||||
the four FAILING test files that pin the contracts for Plans 03 (recorder)
|
||||
and 04 (handshake + port), and add the `npm test` script.
|
||||
|
||||
These tests are RED on purpose — they import from `src/offscreen/recorder`,
|
||||
which Plan 03 creates. Until Plan 03 GREEN-implements the module, every test
|
||||
in this batch fails with a module-resolution error. That is the Nyquist
|
||||
sampling signal Plans 03 / 04 must drive to PASS.
|
||||
|
||||
Purpose: This phase has `workflow.tdd_mode: true`. RED-first means the test
|
||||
file ships BEFORE the production code; Plans 03 and 04 then commit the
|
||||
minimum implementation to flip each test GREEN. Establishing the test
|
||||
fixtures here, in a single coherent plan, avoids a Plan-03 RED commit
|
||||
collision with a Plan-04 RED commit.
|
||||
|
||||
Output: Vitest installed, 4 RED test files, vitest.config.ts, npm test
|
||||
script, tests/fixtures/ directory placeholder.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md
|
||||
@.planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md
|
||||
@.planning/phases/01-stabilize-video-pipeline/01-VALIDATION.md
|
||||
@package.json
|
||||
@tsconfig.json
|
||||
@src/shared/types.ts
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
<!-- The recorder module that Plan 03 will create. These tests are written
|
||||
against THIS interface. Plan 03 MUST export every symbol listed here. -->
|
||||
|
||||
Expected exports from `src/offscreen/recorder.ts` (created in Plan 03):
|
||||
|
||||
```typescript
|
||||
// Ring-buffer (pure functions; testable in Node without a real Blob)
|
||||
export function addChunk(blob: { size: number }, timestamp: number): void;
|
||||
export function trimAged(now: number): void;
|
||||
export function getBuffer(): Array<{ data: { size: number }; timestamp: number; isFirst?: boolean }>;
|
||||
export function resetBuffer(): void;
|
||||
|
||||
// Codec strict-mode (D-20)
|
||||
export function assertCodecSupported(): void; // throws Error if vp9 unsupported
|
||||
|
||||
// Constants (so tests can reference the same window value Plan 03 uses)
|
||||
export const VIDEO_BUFFER_DURATION_MS: number; // = 30_000
|
||||
```
|
||||
|
||||
Expected side-effects of importing `src/offscreen/recorder.ts`:
|
||||
|
||||
- `chrome.runtime.onMessage.addListener` called at least once
|
||||
- `chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' })` called exactly once (Pattern 4)
|
||||
- `chrome.runtime.connect({ name: 'video-keepalive' })` called exactly once at module load (Pattern 5)
|
||||
- On the connected port firing `onDisconnect`, the module immediately calls `chrome.runtime.connect` again (Pitfall 4 reconnect)
|
||||
|
||||
These contracts are what the 4 RED tests below check.
|
||||
</interfaces>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| test runner → source modules | Vitest test files import from the source tree; a misconfigured `include` could leak production code-paths into the runtime |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-1-NEW-02-01 | Tampering / supply-chain | `package.json` Vitest install | mitigate | Pin Vitest at a major version (`^3` or whatever `npm view vitest version` shows on install day) and let `npm install` produce a deterministic `package-lock.json`. No `vitest-chrome` dep: lightweight inline stub in each test instead, so we don't widen the supply chain for a four-file test setup. |
|
||||
| T-1-NEW-02-02 | Information Disclosure | test output | accept | Tests run in Node; no captured user video can leak. Tests use mocked Blob (`{ size: N } as Blob`) — no real screen content involved. |
|
||||
|
||||
(Phase-wide T-1-01..T-1-04 are addressed in their respective implementation plans.)
|
||||
</threat_model>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Install Vitest, add npm test script, run npm install</name>
|
||||
<files>package.json</files>
|
||||
<read_first>
|
||||
- package.json (lines 1-21 — currently no Vitest, no test script)
|
||||
- .planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md §"Environment Availability" + §"Wave 0 Gaps" (lines 1058-1078 + 1147-1162)
|
||||
</read_first>
|
||||
<action>
|
||||
Three edits to `package.json`. Use the Edit tool, NOT a manual JSON rewrite (preserve formatting):
|
||||
|
||||
(1) Add `vitest` to `devDependencies`. Pin to the latest stable major at install time. Run this command first to discover the current version:
|
||||
```bash
|
||||
LATEST_VITEST=$(npm view vitest version)
|
||||
echo "Latest vitest is $LATEST_VITEST"
|
||||
```
|
||||
Then add to `devDependencies` in `package.json`: `"vitest": "^<MAJOR>"` where `<MAJOR>` is the major number returned (e.g., `^3` if `npm view vitest version` returns `3.1.4`). Do NOT pin a beta/RC; if the latest is non-stable, fall back to the previous stable major (verify with `npm view vitest versions --json | tail -20`).
|
||||
|
||||
(2) Add an `"test": "vitest run"` script to the `scripts` block. The full `scripts` block becomes VERBATIM:
|
||||
```json
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
```
|
||||
|
||||
(3) Run `npm install` in the repo root:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
This both installs Vitest AND brings the existing devDependencies up to date (the current repo has no `node_modules/` per RESEARCH.md Runtime State Inventory). Confirm `node_modules/vitest/` exists after.
|
||||
|
||||
If `npm install` produces a `package-lock.json` for the first time (it should — there is no committed lockfile today), this is expected; commit it together with the package.json change.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -d node_modules/vitest && grep -c "\"vitest\"" package.json && grep -c "\"test\": \"vitest run\"" package.json</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `test -d node_modules/vitest` exits 0
|
||||
- `grep -c "\"vitest\"" package.json` returns at least 1
|
||||
- `grep -c "\"test\": \"vitest run\"" package.json` returns 1
|
||||
- `node -e "require('./package.json')"` exits 0 (JSON valid)
|
||||
- `npx vitest --version` prints a version string (Vitest CLI reachable)
|
||||
- `package-lock.json` exists at repo root (committed)
|
||||
</acceptance_criteria>
|
||||
<done>Vitest available; `npm test` is wired but will fail because the 4 RED tests are about to be added in the next tasks.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create vitest.config.ts</name>
|
||||
<files>vitest.config.ts</files>
|
||||
<read_first>
|
||||
- tsconfig.json (lines 1-20 — `strict: true`, `target: ES2020`, `moduleResolution: bundler`)
|
||||
- .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §`vitest.config.ts` (lines 684-712)
|
||||
- vite.config.ts (lines 1-12 — `defineConfig` shape to mirror)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `vitest.config.ts` at the repo root with VERBATIM content:
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
reporters: 'dot',
|
||||
typecheck: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `environment: 'node'` — Vitest 3+ shims Blob via undici in Node mode, so our test code can construct `{ size: 1024 } as unknown as Blob` without a real DOM. Confirmed in RESEARCH.md §"Validation Architecture" line 1104.
|
||||
- `include: ['tests/**/*.test.ts']` — scoped strictly to `tests/`; production code under `src/` is never picked up as a test.
|
||||
- `typecheck.enabled: false` — `tsc --noEmit` runs separately as part of `npm run build`. Running typecheck via Vitest would duplicate work.
|
||||
|
||||
Do not add `globals: true` — we want explicit `import { describe, it, expect } from 'vitest'` in every test for clarity.
|
||||
|
||||
Do not add path aliases — `tsconfig.json` does not define any, and tests can use relative imports (`../../src/offscreen/recorder`).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>node -e "const c=require('./vitest.config.ts'.replace('.ts','.js'));" 2>/dev/null; test -f vitest.config.ts && grep -c "environment: 'node'" vitest.config.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `test -f vitest.config.ts` exits 0
|
||||
- `grep -c "environment: 'node'" vitest.config.ts` returns 1
|
||||
- `grep -c "include: \['tests/\*\*/\*.test.ts'\]" vitest.config.ts` returns 1
|
||||
- `grep -c "defineConfig" vitest.config.ts` returns 1
|
||||
- `npx vitest run --reporter=dot 2>&1` produces output (even if all tests fail with module-resolution errors — that's expected because tests don't exist yet at this task)
|
||||
</acceptance_criteria>
|
||||
<done>`vitest.config.ts` is committed; `npx vitest run` finds zero test files but exits cleanly (no config error).</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Create tests/offscreen/ring-buffer.test.ts (RED — first chunk + trim 30s)</name>
|
||||
<files>tests/offscreen/ring-buffer.test.ts, tests/fixtures/.gitkeep</files>
|
||||
<read_first>
|
||||
- .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §`tests/offscreen/ring-buffer.test.ts` (lines 534-568)
|
||||
- .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md (D-10, D-11)
|
||||
- .planning/intel/constraints.md §CON-video-window + §CON-webm-header-retention
|
||||
- src/background/index.ts (lines 26-75 — the ring-buffer logic that is being relocated to `src/offscreen/recorder.ts` in Plan 03; the test is written against the relocated module's export surface)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Test 1 (`first chunk is header`): When `resetBuffer()` is called then `addChunk({ size: 1024 } as Blob, 1_000)` is called, `getBuffer()[0].isFirst` MUST be `true`.
|
||||
- Test 2 (`second chunk is NOT header`): A second `addChunk` produces an entry with `isFirst: false` (or `undefined`).
|
||||
- Test 3 (`trim 30s — keeps header, evicts aged tail`): With one header chunk at t=0, one body chunk at t=10_000, one body chunk at t=35_000, and `trimAged(40_000)`, the resulting buffer MUST start with `isFirst: true` and MUST be length >= 2 (header + the t=35_000 chunk; the t=10_000 chunk has age 30_000 ms which is at the boundary — Plan 03 uses strict `<` so it gets trimmed at exactly 30_000 but the test only asserts ≥ 2 to be tolerant).
|
||||
- Test 4 (`trim with empty buffer`): `trimAged(0)` on an empty buffer does not throw.
|
||||
</behavior>
|
||||
<action>
|
||||
Create `tests/offscreen/ring-buffer.test.ts` with VERBATIM content:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { addChunk, trimAged, getBuffer, resetBuffer } from '../../src/offscreen/recorder';
|
||||
|
||||
describe('ring buffer', () => {
|
||||
beforeEach(() => resetBuffer());
|
||||
|
||||
it('first chunk is header', () => {
|
||||
addChunk({ size: 1024 } as unknown as Blob, 1_000);
|
||||
const buf = getBuffer();
|
||||
expect(buf.length).toBe(1);
|
||||
expect(buf[0].isFirst).toBe(true);
|
||||
});
|
||||
|
||||
it('second chunk is NOT header', () => {
|
||||
addChunk({ size: 1024 } as unknown as Blob, 1_000);
|
||||
addChunk({ size: 512 } as unknown as Blob, 2_000);
|
||||
const buf = getBuffer();
|
||||
expect(buf.length).toBe(2);
|
||||
expect(buf[0].isFirst).toBe(true);
|
||||
expect(buf[1].isFirst).toBeFalsy();
|
||||
});
|
||||
|
||||
it('trim 30s — keeps header, evicts aged tail', () => {
|
||||
addChunk({ size: 1024 } as unknown as Blob, 0); // header at t=0
|
||||
addChunk({ size: 512 } as unknown as Blob, 10_000); // t=10s
|
||||
addChunk({ size: 512 } as unknown as Blob, 35_000); // t=35s
|
||||
trimAged(40_000); // now=40s
|
||||
const buf = getBuffer();
|
||||
expect(buf[0].isFirst).toBe(true); // header survives unconditionally
|
||||
expect(buf.length).toBeGreaterThanOrEqual(2); // header + at least the t=35s chunk
|
||||
// The header chunk's age (40s) does NOT cause it to be trimmed.
|
||||
const headerStillThere = buf.some((c) => c.isFirst);
|
||||
expect(headerStillThere).toBe(true);
|
||||
});
|
||||
|
||||
it('trim with empty buffer does not throw', () => {
|
||||
expect(() => trimAged(0)).not.toThrow();
|
||||
expect(getBuffer()).toEqual([]);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Also create `tests/fixtures/.gitkeep` with empty content (Plan 07 will manually drop a known-good last_30sec.webm into this directory after the manual smoke test).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -f tests/offscreen/ring-buffer.test.ts && test -f tests/fixtures/.gitkeep && (npx vitest run tests/offscreen/ring-buffer.test.ts 2>&1 | grep -qE "Cannot find module|Failed to resolve")</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `test -f tests/offscreen/ring-buffer.test.ts` exits 0
|
||||
- `test -f tests/fixtures/.gitkeep` exits 0
|
||||
- The test file imports from `'../../src/offscreen/recorder'` (verify: `grep -c "from '../../src/offscreen/recorder'" tests/offscreen/ring-buffer.test.ts` returns 1)
|
||||
- `npx vitest run tests/offscreen/ring-buffer.test.ts 2>&1` exits NON-ZERO AND contains either "Cannot find module" or "Failed to resolve" — this is the RED gate (the source module doesn't exist yet; Plan 03 will create it)
|
||||
- No `as any` and no `@ts-ignore` in the test file (CLAUDE.md naming rules)
|
||||
</acceptance_criteria>
|
||||
<done>Four-test RED file for the ring buffer exists. Vitest can find it but fails to import the source module — exactly the RED gate Plan 03 will flip to GREEN.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: Create tests/offscreen/codec-check.test.ts (RED — codec strict-mode)</name>
|
||||
<files>tests/offscreen/codec-check.test.ts</files>
|
||||
<read_first>
|
||||
- .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §`tests/offscreen/codec-check.test.ts` (lines 572-603)
|
||||
- .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md §"Claude's Discretion" (codec strictness)
|
||||
- .planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md §"Pattern 6: Codec strict-mode" + §"Example E"
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Test 1 (`throws on unsupported vp9 and emits RECORDING_ERROR`): When `MediaRecorder.isTypeSupported` is mocked to return `false`, `assertCodecSupported()` MUST throw an Error whose message contains the literal substring `"vp9 unsupported"`. It MUST also call `chrome.runtime.sendMessage` with an object that has `type: 'RECORDING_ERROR'`.
|
||||
- Test 2 (`does not throw when vp9 IS supported`): When `MediaRecorder.isTypeSupported` returns `true`, `assertCodecSupported()` MUST NOT throw and MUST NOT call `chrome.runtime.sendMessage`.
|
||||
</behavior>
|
||||
<action>
|
||||
Create `tests/offscreen/codec-check.test.ts` with VERBATIM content:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
interface ChromeStub {
|
||||
runtime: { sendMessage: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
interface GlobalWithChrome {
|
||||
chrome?: ChromeStub;
|
||||
MediaRecorder?: { isTypeSupported: (mime: string) => boolean };
|
||||
}
|
||||
|
||||
describe('codec strict mode', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
(globalThis as unknown as GlobalWithChrome).chrome = {
|
||||
runtime: { sendMessage: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
it('throws on unsupported vp9 and emits RECORDING_ERROR', async () => {
|
||||
(globalThis as unknown as GlobalWithChrome).MediaRecorder = {
|
||||
isTypeSupported: vi.fn().mockReturnValue(false),
|
||||
};
|
||||
const mod = await import('../../src/offscreen/recorder');
|
||||
expect(() => mod.assertCodecSupported()).toThrow(/vp9 unsupported/);
|
||||
const stub = (globalThis as unknown as GlobalWithChrome).chrome!;
|
||||
expect(stub.runtime.sendMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'RECORDING_ERROR' })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw when vp9 IS supported', async () => {
|
||||
(globalThis as unknown as GlobalWithChrome).MediaRecorder = {
|
||||
isTypeSupported: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
const mod = await import('../../src/offscreen/recorder');
|
||||
expect(() => mod.assertCodecSupported()).not.toThrow();
|
||||
const stub = (globalThis as unknown as GlobalWithChrome).chrome!;
|
||||
expect(stub.runtime.sendMessage).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'RECORDING_ERROR' })
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `vi.resetModules()` between tests is critical: the module-import side-effects (Pattern 4 OFFSCREEN_READY, Pattern 5 port.connect) happen ONCE per module load; without reset, the test isolation breaks across the four test files.
|
||||
- Mock `chrome.runtime` with a minimal stub — no `vitest-chrome` dependency.
|
||||
- Type the mock with explicit interfaces (no `as any`, per CLAUDE.md).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -f tests/offscreen/codec-check.test.ts && (npx vitest run tests/offscreen/codec-check.test.ts 2>&1 | grep -qE "Cannot find module|Failed to resolve")</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `test -f tests/offscreen/codec-check.test.ts` exits 0
|
||||
- `grep -c "vp9 unsupported" tests/offscreen/codec-check.test.ts` returns at least 1
|
||||
- `grep -c "RECORDING_ERROR" tests/offscreen/codec-check.test.ts` returns at least 1
|
||||
- `npx vitest run tests/offscreen/codec-check.test.ts 2>&1` exits NON-ZERO AND output contains "Cannot find module" or "Failed to resolve"
|
||||
- No `as any` and no `@ts-ignore` in the test file
|
||||
</acceptance_criteria>
|
||||
<done>RED test for codec strict-mode exists. Plan 03 will export `assertCodecSupported` to flip it GREEN.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 5: Create tests/offscreen/handshake.test.ts and tests/offscreen/port.test.ts (RED)</name>
|
||||
<files>tests/offscreen/handshake.test.ts, tests/offscreen/port.test.ts</files>
|
||||
<read_first>
|
||||
- .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §`tests/offscreen/handshake.test.ts` + §`tests/offscreen/port.test.ts` (lines 607-680)
|
||||
- .planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md §"Pattern 4: OFFSCREEN_READY handshake" + §"Pattern 5: Long-lived port" + §"Pitfall 4"
|
||||
- src/shared/types.ts (line 18 — `OFFSCREEN_READY` declared but unused)
|
||||
</read_first>
|
||||
<behavior>
|
||||
handshake.test.ts:
|
||||
- Test 1 (`sends OFFSCREEN_READY after listener registration`): Importing `src/offscreen/recorder` MUST call `chrome.runtime.sendMessage` with an object whose `type` is `'OFFSCREEN_READY'` exactly once. The call MUST happen AFTER `chrome.runtime.onMessage.addListener` is called.
|
||||
|
||||
port.test.ts:
|
||||
- Test 1 (`connects on module load`): Importing `src/offscreen/recorder` MUST call `chrome.runtime.connect({ name: 'video-keepalive' })` exactly once.
|
||||
- Test 2 (`reconnects when port disconnects`): When the connected port fires its registered `onDisconnect` listener, the module MUST call `chrome.runtime.connect` a second time within the synchronous tick.
|
||||
</behavior>
|
||||
<action>
|
||||
Create `tests/offscreen/handshake.test.ts` with VERBATIM content:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
interface PortStub {
|
||||
name: string;
|
||||
postMessage: ReturnType<typeof vi.fn>;
|
||||
onMessage: { addListener: ReturnType<typeof vi.fn> };
|
||||
onDisconnect: { addListener: ReturnType<typeof vi.fn> };
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
interface ChromeStub {
|
||||
runtime: {
|
||||
id: string;
|
||||
sendMessage: (m: unknown) => void;
|
||||
onMessage: { addListener: ReturnType<typeof vi.fn> };
|
||||
connect: () => PortStub;
|
||||
};
|
||||
}
|
||||
|
||||
interface GlobalWithChrome {
|
||||
chrome?: ChromeStub;
|
||||
MediaRecorder?: { isTypeSupported: (mime: string) => boolean };
|
||||
}
|
||||
|
||||
function buildChromeStub(calls: unknown[]): ChromeStub {
|
||||
return {
|
||||
runtime: {
|
||||
id: 'ext-id-test',
|
||||
sendMessage: (m: unknown) => {
|
||||
calls.push(m);
|
||||
},
|
||||
onMessage: { addListener: vi.fn() },
|
||||
connect: () => ({
|
||||
name: 'video-keepalive',
|
||||
postMessage: vi.fn(),
|
||||
onMessage: { addListener: vi.fn() },
|
||||
onDisconnect: { addListener: vi.fn() },
|
||||
disconnect: vi.fn(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('OFFSCREEN_READY handshake', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
(globalThis as unknown as GlobalWithChrome).MediaRecorder = {
|
||||
isTypeSupported: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
it('sends OFFSCREEN_READY after listener registration', async () => {
|
||||
const calls: unknown[] = [];
|
||||
const stub = buildChromeStub(calls);
|
||||
(globalThis as unknown as GlobalWithChrome).chrome = stub;
|
||||
await import('../../src/offscreen/recorder');
|
||||
expect(stub.runtime.onMessage.addListener).toHaveBeenCalled();
|
||||
expect(calls).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ type: 'OFFSCREEN_READY' })])
|
||||
);
|
||||
const readyCount = calls.filter(
|
||||
(m): m is { type: string } =>
|
||||
typeof m === 'object' && m !== null && (m as { type?: unknown }).type === 'OFFSCREEN_READY'
|
||||
).length;
|
||||
expect(readyCount).toBe(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Create `tests/offscreen/port.test.ts` with VERBATIM content:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
interface PortStub {
|
||||
name: string;
|
||||
postMessage: ReturnType<typeof vi.fn>;
|
||||
onMessage: { addListener: ReturnType<typeof vi.fn> };
|
||||
onDisconnect: { addListener: (fn: () => void) => void };
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
interface ChromeStub {
|
||||
runtime: {
|
||||
id: string;
|
||||
sendMessage: ReturnType<typeof vi.fn>;
|
||||
onMessage: { addListener: ReturnType<typeof vi.fn> };
|
||||
connect: () => PortStub;
|
||||
};
|
||||
}
|
||||
|
||||
interface GlobalWithChrome {
|
||||
chrome?: ChromeStub;
|
||||
MediaRecorder?: { isTypeSupported: (mime: string) => boolean };
|
||||
}
|
||||
|
||||
describe('port reconnect', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
(globalThis as unknown as GlobalWithChrome).MediaRecorder = {
|
||||
isTypeSupported: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
it('connects on module load', async () => {
|
||||
let connectCount = 0;
|
||||
const disconnectListeners: Array<() => void> = [];
|
||||
const stub: ChromeStub = {
|
||||
runtime: {
|
||||
id: 'ext-id-test',
|
||||
sendMessage: vi.fn(),
|
||||
onMessage: { addListener: vi.fn() },
|
||||
connect: () => {
|
||||
connectCount++;
|
||||
return {
|
||||
name: 'video-keepalive',
|
||||
postMessage: vi.fn(),
|
||||
onMessage: { addListener: vi.fn() },
|
||||
onDisconnect: {
|
||||
addListener: (fn: () => void) => disconnectListeners.push(fn),
|
||||
},
|
||||
disconnect: vi.fn(),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
(globalThis as unknown as GlobalWithChrome).chrome = stub;
|
||||
await import('../../src/offscreen/recorder');
|
||||
expect(connectCount).toBe(1);
|
||||
});
|
||||
|
||||
it('reconnects when port disconnects', async () => {
|
||||
let connectCount = 0;
|
||||
const disconnectListeners: Array<() => void> = [];
|
||||
const stub: ChromeStub = {
|
||||
runtime: {
|
||||
id: 'ext-id-test',
|
||||
sendMessage: vi.fn(),
|
||||
onMessage: { addListener: vi.fn() },
|
||||
connect: () => {
|
||||
connectCount++;
|
||||
return {
|
||||
name: 'video-keepalive',
|
||||
postMessage: vi.fn(),
|
||||
onMessage: { addListener: vi.fn() },
|
||||
onDisconnect: {
|
||||
addListener: (fn: () => void) => disconnectListeners.push(fn),
|
||||
},
|
||||
disconnect: vi.fn(),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
(globalThis as unknown as GlobalWithChrome).chrome = stub;
|
||||
await import('../../src/offscreen/recorder');
|
||||
expect(connectCount).toBe(1);
|
||||
// Fire the disconnect — module should reconnect
|
||||
disconnectListeners.forEach((fn) => fn());
|
||||
expect(connectCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -f tests/offscreen/handshake.test.ts && test -f tests/offscreen/port.test.ts && (npx vitest run tests/offscreen/handshake.test.ts tests/offscreen/port.test.ts 2>&1 | grep -qE "Cannot find module|Failed to resolve")</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Both test files exist
|
||||
- `grep -c "OFFSCREEN_READY" tests/offscreen/handshake.test.ts` returns at least 1
|
||||
- `grep -c "video-keepalive" tests/offscreen/port.test.ts` returns at least 1
|
||||
- `grep -c "reconnects" tests/offscreen/port.test.ts` returns at least 1
|
||||
- `npx vitest run tests/offscreen/handshake.test.ts tests/offscreen/port.test.ts 2>&1` exits NON-ZERO AND output contains "Cannot find module" or "Failed to resolve"
|
||||
- No `as any` and no `@ts-ignore` in either test file (the cast pattern is `as unknown as X` which is acceptable per CLAUDE.md — narrows progressively without bypassing the type-checker)
|
||||
</acceptance_criteria>
|
||||
<done>Two more RED tests pin the handshake (Pattern 4) and port reconnect (Pattern 5 / Pitfall 4) contracts. Plan 04 will flip both to GREEN.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
After all five tasks land:
|
||||
|
||||
1. `test -d node_modules/vitest && test -f vitest.config.ts && test -f package-lock.json` — exits 0.
|
||||
2. `ls tests/offscreen/*.test.ts | wc -l` returns 4 (ring-buffer, codec-check, handshake, port).
|
||||
3. `ls tests/fixtures/.gitkeep` exits 0.
|
||||
4. `npx vitest run 2>&1 | grep -cE "Failed to resolve|Cannot find module"` returns at least 4 (one per test file — all RED because `src/offscreen/recorder.ts` doesn't exist yet).
|
||||
5. `npx tsc --noEmit 2>&1` — the test files reference a module that doesn't exist, so TypeScript will error. THIS IS EXPECTED. Plan 03 will resolve this. To make the RED gate clean: confirm the typescript errors are ONLY about the missing module (`Cannot find module '../../src/offscreen/recorder'`), not about test syntax mistakes.
|
||||
|
||||
Commit cadence: ONE commit per task (five atomic commits). Commit messages: `test(01-02): wave-0 setup — install vitest`, `test(01-02): add vitest.config.ts`, `test(01-02): add RED ring-buffer tests`, `test(01-02): add RED codec-check tests`, `test(01-02): add RED handshake + port tests`.
|
||||
|
||||
The "RED gate" is met because:
|
||||
- The 4 test files import from `'../../src/offscreen/recorder'`
|
||||
- That module does not yet exist (Plan 03 creates it)
|
||||
- Therefore every test fails at the IMPORT step (not at the assertion step)
|
||||
- This is exactly the Nyquist TDD signal: a contract pinned in tests, an implementation gap waiting to be filled.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Vitest installed and runnable (`npx vitest --version` works)
|
||||
- `npm test` script exists and runs `vitest run`
|
||||
- Four RED test files exist, each importing `src/offscreen/recorder` (which doesn't exist yet — this is INTENTIONAL)
|
||||
- `tests/fixtures/` directory committed (empty placeholder via .gitkeep)
|
||||
- No `as any` / `@ts-ignore` in any test file
|
||||
- All five commits land cleanly
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01-stabilize-video-pipeline/01-02-SUMMARY.md` with:
|
||||
- Vitest version that was actually installed
|
||||
- Exact list of new files (paths and line counts)
|
||||
- Output of `npx vitest run 2>&1` showing all 4 tests RED at the import step
|
||||
- Commit list (five commits)
|
||||
- Note: "Plan 03 must export {addChunk, trimAged, getBuffer, resetBuffer, assertCodecSupported, VIDEO_BUFFER_DURATION_MS} from src/offscreen/recorder.ts to flip these tests to GREEN. Plan 04 wires up the OFFSCREEN_READY send (handshake test) and the port.connect + reconnect-on-disconnect (port tests)."
|
||||
</output>
|
||||
676
.planning/phases/01-stabilize-video-pipeline/01-03-PLAN.md
Normal file
676
.planning/phases/01-stabilize-video-pipeline/01-03-PLAN.md
Normal file
@@ -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: "<script type=\"module\" src=\"./recorder.ts\">"
|
||||
pattern: "./recorder.ts"
|
||||
---
|
||||
|
||||
<objective>
|
||||
GREEN-implement the offscreen recorder module that owns:
|
||||
- `getDisplayMedia` capture (CONTEXT.md D-01..D-03)
|
||||
- single continuous `MediaRecorder` at timeslice 2000 ms (D-09)
|
||||
- in-memory ring buffer with header retention + 30 s age trim (D-10, D-11)
|
||||
- codec strict-mode (D-20 / RESEARCH.md Pattern 6)
|
||||
- `MediaStreamTrack.onended` cleanup (RESEARCH.md Example F)
|
||||
- restart-segments fallback pre-staged as a documented skeleton (D-13)
|
||||
|
||||
This plan flips the 2 ring-buffer + codec test files (created RED in Plan 02)
|
||||
to GREEN. It does NOT yet wire up the port-keepalive or the OFFSCREEN_READY
|
||||
handshake — that is Plan 04. To keep this plan inside its context budget, the
|
||||
port-side code in `recorder.ts` is left as a small import-time placeholder
|
||||
that Plan 04 fills in. The Plan-02 port + handshake tests therefore remain
|
||||
RED until Plan 04 lands, which is the intended choreography per the
|
||||
wave-1 dependency graph (Plans 03 and 04 run in parallel; Plan 02 wrote both
|
||||
sets of RED tests in advance).
|
||||
|
||||
Purpose: REQ-video-ring-buffer's load-bearing logic lives here. The ring
|
||||
buffer is a pure function — exactly the TDD sweet spot. The remaining
|
||||
non-pure side (getDisplayMedia + MediaRecorder construction + track.onended)
|
||||
is wired in the same module because the buffer is private state owned by it.
|
||||
Both test files attached to this plan run in pure Node and don't need a
|
||||
browser.
|
||||
|
||||
Output: `src/offscreen/recorder.ts` (the new authoritative source-of-truth),
|
||||
`src/offscreen/index.html` (crxjs entry point), `src/shared/logger.ts`
|
||||
extended with `OffscreenLogger`, `src/shared/types.ts` cleaned up.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/01-stabilize-video-pipeline/01-CONTEXT.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
|
||||
@tests/offscreen/ring-buffer.test.ts
|
||||
@tests/offscreen/codec-check.test.ts
|
||||
@src/background/index.ts
|
||||
@src/shared/types.ts
|
||||
@src/shared/logger.ts
|
||||
@offscreen/index.ts
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
<!-- Plan 02 already wrote tests that pin this module's contracts.
|
||||
This plan implements the module to satisfy those tests. -->
|
||||
|
||||
Required exports from `src/offscreen/recorder.ts` (verified against the RED tests):
|
||||
|
||||
```typescript
|
||||
export const VIDEO_BUFFER_DURATION_MS: number;
|
||||
export function addChunk(blob: Blob, timestamp: number): void;
|
||||
export function trimAged(now: number): void;
|
||||
export function getBuffer(): VideoChunk[];
|
||||
export function resetBuffer(): void;
|
||||
export function assertCodecSupported(): void; // throws Error on unsupported vp9
|
||||
```
|
||||
|
||||
Required side-effects of importing the module (pinned by Plan 02 tests):
|
||||
|
||||
- `chrome.runtime.onMessage.addListener(...)` called exactly once
|
||||
- `chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY', ... })` called exactly once
|
||||
- `chrome.runtime.connect({ name: 'video-keepalive' })` called exactly once
|
||||
|
||||
The OFFSCREEN_READY send AND the port connect are owned by Plan 04 — Plan 03
|
||||
adds a small bootstrap stub that calls them in the right order so that
|
||||
the Plan-02 ring-buffer + codec tests can stub `chrome.runtime` minimally
|
||||
without crashing on the bootstrap. The full port reconnect + ping logic
|
||||
lands in Plan 04.
|
||||
|
||||
VideoChunk type (from src/shared/types.ts:27-31, unchanged):
|
||||
```typescript
|
||||
export interface VideoChunk {
|
||||
data: Blob;
|
||||
timestamp: number;
|
||||
isFirst?: boolean;
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| browser getDisplayMedia picker → offscreen `MediaStream` | The operator selects what to share; offscreen receives a track they chose |
|
||||
| `MediaRecorder.isTypeSupported` → recorder.start path | A spoofed `MediaRecorder` could attempt to influence codec selection (only matters in test fixtures, but the strict-mode prevents production codec downgrade) |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-1-01 | Tampering — codec downgrade | `MediaRecorder` constructor | mitigate | `assertCodecSupported()` calls `MediaRecorder.isTypeSupported('video/webm;codecs=vp9')` BEFORE constructing the recorder; if it returns false, throws an Error and emits `RECORDING_ERROR` to SW. No vp8 / h264 / default fallback chain. The strict mode covers `MediaRecorder` itself being absent (the codec-check test mocks both cases). Grep gate: `grep -v '^#' src/offscreen/recorder.ts \| grep -cE 'codecs=(vp8\|h264)'` returns 0 (no fallback codec strings in the module). |
|
||||
| T-1-03 | Information Disclosure — captured stream contains other apps' content | `getDisplayMedia` stream | accept | This is the documented residual risk per CONTEXT.md D-04. The Chrome "Sharing your screen" indicator is the user-facing signal; the operator chose to share. No code-level mitigation is possible (the API is supposed to capture the screen). Documented as accepted risk in 01-RESEARCH.md §"Security Domain". |
|
||||
| T-1-NEW-03-01 | DoS — unbounded buffer growth on a stuck timestamp | `addChunk` + `trimAged` | mitigate | The trim function uses arrival timestamp; if a clock anomaly produces a negative or stuck `now`, the buffer is bounded above by SPEC §10 #9 (50 MB RAM ceiling) anyway. Defensive belt: the recorder also exposes `getBuffer().length` to SW so a healthchecker can observe growth. No active rate-limit needed for Phase 1. |
|
||||
</threat_model>
|
||||
|
||||
<feature>
|
||||
<name>Offscreen recorder module — ring buffer + getDisplayMedia + codec strict-mode + track-ended cleanup</name>
|
||||
<files>src/offscreen/recorder.ts (new), src/offscreen/index.html (new), src/shared/logger.ts (modify), src/shared/types.ts (modify)</files>
|
||||
<behavior>
|
||||
RED already exists (Plan 02). GREEN must satisfy:
|
||||
|
||||
Ring buffer (from `tests/offscreen/ring-buffer.test.ts`):
|
||||
- After `resetBuffer()`, `getBuffer()` returns `[]`
|
||||
- First `addChunk` produces an entry with `isFirst: true`; the first-chunk flag is sticky for the FIRST add only and survives subsequent trims
|
||||
- Second and later `addChunk` calls produce entries with `isFirst: false` (or undefined — `falsy`)
|
||||
- `trimAged(now)` removes every entry whose `timestamp < now - VIDEO_BUFFER_DURATION_MS` AND whose `isFirst` is not `true`
|
||||
- `VIDEO_BUFFER_DURATION_MS === 30_000`
|
||||
- `trimAged` on empty buffer does not throw
|
||||
|
||||
Codec strict-mode (from `tests/offscreen/codec-check.test.ts`):
|
||||
- `assertCodecSupported()` calls `MediaRecorder.isTypeSupported('video/webm;codecs=vp9')`
|
||||
- If the return is falsy: throw a new `Error` whose message starts with `'vp9 unsupported'` AND call `chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: <message> })` BEFORE throwing
|
||||
- If the return is truthy: do not throw and do not emit RECORDING_ERROR
|
||||
|
||||
Bootstrap side-effects (pinned by Plan 02 handshake + port tests but Plan 03 only needs to STUB them — Plan 04 fills in):
|
||||
- At module import time, register `chrome.runtime.onMessage.addListener(...)` BEFORE calling `chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' })` (handshake ordering — Plan 04 elaborates)
|
||||
- Call `chrome.runtime.connect({ name: 'video-keepalive' })` once (Plan 04 wires the ping loop and reconnect)
|
||||
|
||||
</behavior>
|
||||
<implementation>
|
||||
See task list — three tasks: RED-verify, GREEN-implement, then types + logger ergonomics.
|
||||
</implementation>
|
||||
</feature>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: RED-verify — confirm Plan 02 tests fail at module resolution</name>
|
||||
<files>(none modified)</files>
|
||||
<read_first>
|
||||
- tests/offscreen/ring-buffer.test.ts (Plan 02 output)
|
||||
- tests/offscreen/codec-check.test.ts (Plan 02 output)
|
||||
</read_first>
|
||||
<action>
|
||||
Run the two test files this plan owns and CAPTURE the failure mode:
|
||||
|
||||
```bash
|
||||
npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts 2>&1 | tee /tmp/01-03-red.log
|
||||
```
|
||||
|
||||
The output MUST contain "Cannot find module '../../src/offscreen/recorder'" (or "Failed to resolve" — Vitest's wording varies by version). The exit code MUST be non-zero. Both of these confirm the RED gate is in place from Plan 02.
|
||||
|
||||
If either condition is NOT met:
|
||||
- If the test passes: Plan 02 wrote a degenerate test — STOP and escalate (a passing test before the implementation exists means the test is wrong).
|
||||
- If the test fails for a different reason (e.g., syntax error in the test file): STOP and escalate to Plan 02 owner.
|
||||
|
||||
Otherwise, RED is confirmed; proceed to Task 2.
|
||||
|
||||
This is a verify-only step; nothing is committed.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -qE "Cannot find module|Failed to resolve" /tmp/01-03-red.log && ! npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts 2>&1 | tail -1 | grep -q "passed"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `/tmp/01-03-red.log` exists and is non-empty
|
||||
- Contains "Cannot find module" or "Failed to resolve"
|
||||
- Exit code of the vitest invocation was non-zero
|
||||
</acceptance_criteria>
|
||||
<done>RED gate confirmed. Plan 02 test files import a module that does not yet exist. Proceed to GREEN.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: GREEN — write src/offscreen/recorder.ts and src/offscreen/index.html</name>
|
||||
<files>src/offscreen/recorder.ts, src/offscreen/index.html</files>
|
||||
<read_first>
|
||||
- src/background/index.ts (lines 26-75 — ring-buffer logic being relocated)
|
||||
- src/shared/types.ts (the VideoChunk interface to import)
|
||||
- src/shared/logger.ts (Logger class shape; OffscreenLogger lands in Task 3)
|
||||
- .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md (lines 35-225 — composed pattern for the new module)
|
||||
- .planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md §"Example A" + §"Example E" + §"Example F" (lines 882-893, 949-971, 973-989)
|
||||
- tests/offscreen/ring-buffer.test.ts (the export surface to match)
|
||||
- tests/offscreen/codec-check.test.ts (the export surface to match)
|
||||
</read_first>
|
||||
<behavior>
|
||||
After Task 2 ships, the following MUST hold:
|
||||
|
||||
```bash
|
||||
npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts --reporter=dot
|
||||
# exit 0 — all 6 tests pass (4 in ring-buffer, 2 in codec-check)
|
||||
```
|
||||
</behavior>
|
||||
<action>
|
||||
Write the new `src/offscreen/recorder.ts`. Use the VERBATIM module below — line-by-line, comments and all (Russian section headers preserved per CONTEXT.md "Established patterns"):
|
||||
|
||||
```typescript
|
||||
// src/offscreen/recorder.ts — Phase 01 source of truth (replaces dead
|
||||
// offscreen/index.ts and the inline string in vite.config.ts:13-216).
|
||||
// Owns: getDisplayMedia capture, MediaRecorder lifecycle, in-memory ring
|
||||
// buffer with WebM-header retention + 30 s age trim, codec strict-mode,
|
||||
// track.onended cleanup. Port keepalive + OFFSCREEN_READY handshake are
|
||||
// wired by Plan 04 in the matching wave-1 lane.
|
||||
|
||||
import { OffscreenLogger } from '../shared/logger';
|
||||
import type { Message, VideoChunk } from '../shared/types';
|
||||
|
||||
// ─── Константы (per CON-video-codec, CON-video-window) ──────────────────
|
||||
export const VIDEO_BUFFER_DURATION_MS = 30_000; // 30 секунд
|
||||
const VIDEO_MIME = 'video/webm;codecs=vp9'; // D-20 strict — no fallback
|
||||
const VIDEO_BITRATE = 400_000; // CON-video-codec
|
||||
const TIMESLICE_MS = 2_000; // SPEC §4.1
|
||||
const PORT_NAME = 'video-keepalive'; // Plan 04 owns the ping loop
|
||||
|
||||
const logger = new OffscreenLogger('Recorder');
|
||||
|
||||
// ─── Состояние модуля (module-level, NOT inside startRecording — fixes audit P0 #1 shadow) ───
|
||||
let videoRecorder: MediaRecorder | null = null; // renamed from 'mediaRecorder' to prevent shadowing
|
||||
let mediaStream: MediaStream | null = null;
|
||||
let videoBuffer: VideoChunk[] = [];
|
||||
let firstChunkSaved = false;
|
||||
let keepalivePort: chrome.runtime.Port | null = null; // Plan 04 fills the lifecycle
|
||||
|
||||
// ─── Кольцевой буфер (pure functions — testable in Node) ────────────────
|
||||
|
||||
export function addChunk(blob: Blob, timestamp: number): void {
|
||||
const chunk: VideoChunk = {
|
||||
data: blob,
|
||||
timestamp,
|
||||
isFirst: !firstChunkSaved,
|
||||
};
|
||||
if (!firstChunkSaved) {
|
||||
firstChunkSaved = true;
|
||||
logger.log('First chunk (WebM header) pinned, size:', blob.size);
|
||||
}
|
||||
videoBuffer.push(chunk);
|
||||
trimAged(timestamp);
|
||||
}
|
||||
|
||||
export function trimAged(now: number): void {
|
||||
const cutoff = now - VIDEO_BUFFER_DURATION_MS;
|
||||
videoBuffer = videoBuffer.filter((chunk) => {
|
||||
if (chunk.isFirst) {
|
||||
return true;
|
||||
}
|
||||
return chunk.timestamp >= cutoff;
|
||||
});
|
||||
}
|
||||
|
||||
export function getBuffer(): VideoChunk[] {
|
||||
return videoBuffer;
|
||||
}
|
||||
|
||||
export function resetBuffer(): void {
|
||||
videoBuffer = [];
|
||||
firstChunkSaved = false;
|
||||
}
|
||||
|
||||
// ─── Проверка кодека (D-20 strict-mode — no fallback chain) ─────────────
|
||||
|
||||
export function assertCodecSupported(): void {
|
||||
const supported =
|
||||
typeof MediaRecorder !== 'undefined' &&
|
||||
typeof MediaRecorder.isTypeSupported === 'function' &&
|
||||
MediaRecorder.isTypeSupported(VIDEO_MIME);
|
||||
if (!supported) {
|
||||
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : '<unknown>';
|
||||
const errMessage = `vp9 unsupported. UA=${ua}`;
|
||||
chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: errMessage });
|
||||
throw new Error(errMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Захват экрана (getDisplayMedia inside offscreen — D-01) ────────────
|
||||
|
||||
async function startRecording(): Promise<void> {
|
||||
if (videoRecorder !== null && videoRecorder.state !== 'inactive') {
|
||||
logger.warn('Recording already active; ignoring duplicate START_RECORDING');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
assertCodecSupported(); // throws if vp9 missing — no fallback
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: true,
|
||||
audio: false, // SPEC §9 — Phase 2 / CAP-01 territory
|
||||
});
|
||||
mediaStream = stream;
|
||||
videoRecorder = new MediaRecorder(stream, {
|
||||
mimeType: VIDEO_MIME,
|
||||
videoBitsPerSecond: VIDEO_BITRATE,
|
||||
});
|
||||
videoRecorder.ondataavailable = onDataAvailable;
|
||||
videoRecorder.onerror = (event) => logger.error('MediaRecorder error:', event);
|
||||
// Track end detection — RESEARCH.md Example F. Attach to ALL tracks
|
||||
// (Pitfall 6) so an audio-track edge case won't silently desync.
|
||||
stream.getTracks().forEach((track) => {
|
||||
track.addEventListener('ended', onUserStoppedSharing, { once: true });
|
||||
});
|
||||
videoRecorder.start(TIMESLICE_MS);
|
||||
logger.log('Recording started, mime:', VIDEO_MIME, 'timeslice:', TIMESLICE_MS, 'ms');
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
logger.error('startRecording failed:', msg);
|
||||
chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: msg });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function onDataAvailable(event: BlobEvent): void {
|
||||
if (!event.data || event.data.size === 0) {
|
||||
return;
|
||||
}
|
||||
addChunk(event.data, Date.now());
|
||||
}
|
||||
|
||||
function onUserStoppedSharing(): void {
|
||||
logger.log('Operator stopped sharing — clearing buffer');
|
||||
resetBuffer();
|
||||
if (videoRecorder !== null && videoRecorder.state !== 'inactive') {
|
||||
videoRecorder.stop();
|
||||
}
|
||||
if (mediaStream !== null) {
|
||||
mediaStream.getTracks().forEach((t) => t.stop());
|
||||
mediaStream = null;
|
||||
}
|
||||
videoRecorder = null;
|
||||
chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: 'user-stopped-sharing' });
|
||||
}
|
||||
|
||||
function stopRecording(): void {
|
||||
if (videoRecorder !== null && videoRecorder.state !== 'inactive') {
|
||||
videoRecorder.stop();
|
||||
logger.log('Recording stopped manually');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Bootstrap (Plan 04 wires the full port + handshake) ────────────────
|
||||
// Plan 03 commits this minimal bootstrap so that Plan 02's ring-buffer +
|
||||
// codec tests can mock `chrome.runtime` without crashing on module import.
|
||||
// Plan 04 elaborates the OFFSCREEN_READY handshake and the port reconnect
|
||||
// loop without rewriting this module's exports.
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
// Plan 04 will replace this stub with the full reconnect + ping loop.
|
||||
keepalivePort = chrome.runtime.connect({ name: PORT_NAME });
|
||||
chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' });
|
||||
|
||||
// Touch the keepalive var so noUnusedLocals doesn't complain — Plan 04
|
||||
// uses it. Once Plan 04 lands, this line is removed in its refactor pass.
|
||||
void keepalivePort;
|
||||
|
||||
// ─── D-13 fallback: restart-segments skeleton (pre-staged per CONTEXT.md) ──
|
||||
// Activated only if the Phase 07 ffprobe gate fails on the simpler
|
||||
// continuous-recorder + age-trim approach. See RESEARCH.md Pattern 3.
|
||||
//
|
||||
// const SEGMENT_MS = 10_000;
|
||||
// const MAX_SEGMENTS = 3;
|
||||
// let segments: Blob[] = [];
|
||||
// let currentChunks: Blob[] = [];
|
||||
// function rotateSegment(): void { videoRecorder?.stop(); /* onstop assembles a segment */ }
|
||||
// function onSegmentStopped(): void {
|
||||
// segments.push(new Blob(currentChunks, { type: 'video/webm' }));
|
||||
// if (segments.length > MAX_SEGMENTS) segments.shift();
|
||||
// currentChunks = [];
|
||||
// if (mediaStream) {
|
||||
// videoRecorder = new MediaRecorder(mediaStream, { mimeType: VIDEO_MIME, videoBitsPerSecond: VIDEO_BITRATE });
|
||||
// videoRecorder.ondataavailable = (e) => { if (e.data.size > 0) currentChunks.push(e.data); };
|
||||
// videoRecorder.onstop = onSegmentStopped;
|
||||
// videoRecorder.start();
|
||||
// setTimeout(rotateSegment, SEGMENT_MS);
|
||||
// }
|
||||
// }
|
||||
```
|
||||
|
||||
Then write `src/offscreen/index.html` with VERBATIM content:
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Mokosh Recorder</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="./recorder.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts && npx tsc --noEmit</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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
|
||||
</acceptance_criteria>
|
||||
<done>Two tests files green; ring buffer and codec strict-mode behavior implemented; offscreen HTML entry exists; tsc clean.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Logger + types ergonomics — add OffscreenLogger, clean up shared/types.ts</name>
|
||||
<files>src/shared/logger.ts, src/shared/types.ts</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<action>
|
||||
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<T>` 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).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit && grep -c "OffscreenLogger" src/shared/logger.ts && grep -cE "VIDEO_CHUNK[^_S]|VIDEO_CHUNK_SAVED" src/shared/types.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: REFACTOR — optional cleanup pass (only if obvious improvements exist)</name>
|
||||
<files>src/offscreen/recorder.ts (only if changed)</files>
|
||||
<read_first>
|
||||
- src/offscreen/recorder.ts (Task 2 output)
|
||||
</read_first>
|
||||
<action>
|
||||
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`).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts && npx tsc --noEmit</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>REFACTOR phase complete (or explicitly skipped with rationale).</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
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.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `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 `<verification>` all return their expected values
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
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)
|
||||
</output>
|
||||
403
.planning/phases/01-stabilize-video-pipeline/01-04-PLAN.md
Normal file
403
.planning/phases/01-stabilize-video-pipeline/01-04-PLAN.md
Normal file
@@ -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: <getBuffer()> }`"
|
||||
- "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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/01-stabilize-video-pipeline/01-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
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
<!-- Pinned by Plan 02 tests. Plan 04 must satisfy these and ONLY these. -->
|
||||
|
||||
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).
|
||||
</interfaces>
|
||||
|
||||
<threat_model>
|
||||
## 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. |
|
||||
</threat_model>
|
||||
|
||||
<feature>
|
||||
<name>Port keepalive + OFFSCREEN_READY handshake + REQUEST_BUFFER handler</name>
|
||||
<files>src/offscreen/recorder.ts</files>
|
||||
<behavior>
|
||||
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)
|
||||
</behavior>
|
||||
<implementation>
|
||||
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).
|
||||
</implementation>
|
||||
</feature>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: RED-verify — confirm port + handshake tests fail on Plan 03's stub bootstrap</name>
|
||||
<files>(none modified)</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx vitest run tests/offscreen/port.test.ts -t "reconnects when port disconnects" 2>&1 | grep -qE "FAIL|✗"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `/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)
|
||||
</acceptance_criteria>
|
||||
<done>RED gate confirmed: the reconnect test is failing because Plan 03's stub bootstrap doesn't reconnect on disconnect. Proceed to GREEN.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: GREEN — replace stub bootstrap with full port lifecycle</name>
|
||||
<files>src/offscreen/recorder.ts</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<behavior>
|
||||
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)
|
||||
```
|
||||
</behavior>
|
||||
<action>
|
||||
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<typeof setInterval> | null = null;
|
||||
let preemptiveReconnectId: ReturnType<typeof setTimeout> | 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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx vitest run && npx tsc --noEmit</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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)
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: REFACTOR — optional cleanup or no-op</name>
|
||||
<files>src/offscreen/recorder.ts (only if changed)</files>
|
||||
<read_first>
|
||||
- src/offscreen/recorder.ts (Task 2 output)
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx vitest run && npx tsc --noEmit</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>REFACTOR phase complete (or explicitly skipped with rationale).</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
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.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
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)
|
||||
</output>
|
||||
454
.planning/phases/01-stabilize-video-pipeline/01-05-PLAN.md
Normal file
454
.planning/phases/01-stabilize-video-pipeline/01-05-PLAN.md
Normal file
@@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/01-stabilize-video-pipeline/01-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
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
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<VideoBufferResponse>` 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.
|
||||
</interfaces>
|
||||
|
||||
<threat_model>
|
||||
## 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. |
|
||||
</threat_model>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: DELETE — drop legacy buffer + alarms + IndexedDB + tabCapture paths from SW</name>
|
||||
<files>src/background/index.ts</files>
|
||||
<read_first>
|
||||
- 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"
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit && [ $(grep -cE "addVideoChunkFromBlob|cleanupVideoBuffer|setupKeepalive|loadChunkFromIndexedDB|openIndexedDB|getMediaStreamId|chrome\.alarms" src/background/index.ts) -eq 0 ]</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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)
|
||||
</acceptance_criteria>
|
||||
<done>SW shed every legacy path. tsc clean. File ~30% smaller. Ready for the port-host wiring in Task 2.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: ADD — wire SW-side port host, sender check, onInstalled IndexedDB cleanup, and port-based buffer fetch</name>
|
||||
<files>src/background/index.ts</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<action>
|
||||
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<void> = 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<VideoBufferResponse> {
|
||||
if (videoPort === null) {
|
||||
logger.warn('No offscreen port available; returning empty buffer');
|
||||
return { chunks: [] };
|
||||
}
|
||||
const port = videoPort;
|
||||
return new Promise<VideoBufferResponse>((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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit && npx vitest run && grep -c "video-keepalive" src/background/index.ts && grep -c "VideoRecorderDB" src/background/index.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
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`).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `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`
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
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
|
||||
</output>
|
||||
320
.planning/phases/01-stabilize-video-pipeline/01-06-PLAN.md
Normal file
320
.planning/phases/01-stabilize-video-pipeline/01-06-PLAN.md
Normal file
@@ -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("
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/01-stabilize-video-pipeline/01-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
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
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.
|
||||
</interfaces>
|
||||
|
||||
<threat_model>
|
||||
## 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: \`<JS-as-string>\` })` | 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. |
|
||||
</threat_model>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: DELETE — top-level offscreen/ directory and inline copy-offscreen plugin</name>
|
||||
<files>vite.config.ts, offscreen/index.ts, offscreen/index.html</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<action>
|
||||
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).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>[ ! -f offscreen/index.ts ] && [ ! -f offscreen/index.html ] && [ ! -d offscreen ] && [ "$(grep -c 'copy-offscreen' vite.config.ts)" -eq 0 ] && npx tsc --noEmit</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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)
|
||||
</acceptance_criteria>
|
||||
<done>Inline plugin gone. Root-level offscreen/ gone. vite.config.ts is ~22 lines.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: BUILD VERIFY — run npm run build and reconcile the offscreen path</name>
|
||||
<files>src/background/index.ts (only if path-adjustment needed)</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<action>
|
||||
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('<PATH>');
|
||||
```
|
||||
|
||||
If Outcome A: `<PATH>` = `'src/offscreen/index.html'`.
|
||||
|
||||
If Outcome B: `<PATH>` = `'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/<offscreen path>/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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm run build && ls dist/manifest.json && [ -f dist/src/offscreen/index.html ] || [ -f dist/offscreen/index.html ]</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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)
|
||||
</acceptance_criteria>
|
||||
<done>Build succeeds. SW URL string matches the dist layout. Plan 07 can load `dist/` into Chrome and the offscreen will resolve correctly.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
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).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
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)."
|
||||
</output>
|
||||
329
.planning/phases/01-stabilize-video-pipeline/01-07-PLAN.md
Normal file
329
.planning/phases/01-stabilize-video-pipeline/01-07-PLAN.md
Normal file
@@ -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: ""
|
||||
---
|
||||
|
||||
<objective>
|
||||
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).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/01-stabilize-video-pipeline/01-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
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
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
|
||||
</interfaces>
|
||||
|
||||
<threat_model>
|
||||
## 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. |
|
||||
</threat_model>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 1: HUMAN — load dist/ unpacked, capture a recording, export, save the archive</name>
|
||||
<files>tests/fixtures/last_30sec.webm</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<action>
|
||||
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 `<how-to-verify>` and `<what-built>` 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.
|
||||
</action>
|
||||
<what-built>
|
||||
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`
|
||||
</what-built>
|
||||
<verify>
|
||||
<automated>which ffprobe && ffprobe -v error -f matroska -i tests/fixtures/last_30sec.webm; test $? -eq 0</automated>
|
||||
</verify>
|
||||
<how-to-verify>
|
||||
**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.
|
||||
</how-to-verify>
|
||||
<resume-signal>
|
||||
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`.
|
||||
</resume-signal>
|
||||
<acceptance_criteria>
|
||||
- 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.
|
||||
</acceptance_criteria>
|
||||
<done>Phase 1 acceptance gate met. WebM ring buffer ships playable output.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: AUTOMATED — commit the fixture and update STATE.md</name>
|
||||
<files>tests/fixtures/last_30sec.webm, .planning/STATE.md</files>
|
||||
<read_first>
|
||||
- tests/fixtures/last_30sec.webm (verify it exists from Task 1)
|
||||
- .planning/STATE.md (current state — phase 1 progress)
|
||||
</read_first>
|
||||
<action>
|
||||
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).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>[ -f tests/fixtures/last_30sec.webm ] && grep -q "Phase 1 closure" .planning/STATE.md</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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
|
||||
</acceptance_criteria>
|
||||
<done>Phase 1 closure recorded in STATE.md; regression fixture committed; the orchestrator can transition to Phase 2 cleanly.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
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.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
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
|
||||
</output>
|
||||
859
.planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md
Normal file
859
.planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md
Normal file
@@ -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<void> {
|
||||
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
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Offscreen Page</title>
|
||||
</head>
|
||||
<body>
|
||||
<script src="./index.ts" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**Popup-side analog (same shape, references TS via crxjs)** — `src/popup/index.html:1-21`:
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI Call Recorder</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
...
|
||||
<script type="module" src="index.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**Pattern to copy** — RESEARCH.md Example A (crxjs discussion #919):
|
||||
```html
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="UTF-8"><title>Mokosh Recorder</title></head>
|
||||
<body>
|
||||
<script type="module" src="./recorder.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `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<T = unknown> {
|
||||
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
|
||||
Reference in New Issue
Block a user