# Phase 1: Stabilize Video Pipeline — Pattern Map **Mapped:** 2026-05-15 **Files analyzed:** 12 (4 NEW, 4 MODIFIED, 4 DELETED) **Analogs found:** 11 / 12 > NOTE on line numbers cited in CONTEXT.md: The CONTEXT.md narrative > references some "decimal-style" approximate line ranges that did not > match the live file when I read it. The numbers used **below** in > Pattern Assignments are the verified line numbers from the files as > they exist on disk on 2026-05-15. ## File Classification | New / Modified / Deleted File | Role | Data Flow | Closest Analog | Match Quality | |-------------------------------|------|-----------|----------------|---------------| | `src/offscreen/recorder.ts` (NEW) | offscreen recorder module | streaming + port + event-driven | `offscreen/index.ts` (dead) + inline string at `vite.config.ts:35-213` + ring-buffer structure at `src/background/index.ts:26-75` | role-match (compose 3 analogs) | | `src/offscreen/index.html` (NEW) | bundle entry HTML | static asset | `offscreen/index.html` (current) + `src/popup/index.html` (script tag) | exact | | `src/background/index.ts` (MODIFIED, heavy shrink) | service worker coordinator | request-response + port-host + event-driven | itself (current file) — refactor in place | exact | | `vite.config.ts` (MODIFIED, heavy reduction) | build config | config | crxjs discussion #919 pattern (cited in RESEARCH.md Example B) | role-match | | `manifest.json` (MODIFIED, permission swap) | manifest | config | itself | exact | | `src/shared/types.ts` (MODIFIED, light) | shared types | type-only | itself (current file) — add/verify message types | exact | | `tests/offscreen/ring-buffer.test.ts` (NEW) | unit test | request-response | none in codebase — Vitest default; structural cousin: `src/background/index.ts:47-75` `cleanupVideoBuffer` (subject under test) | role-only | | `tests/offscreen/codec-check.test.ts` (NEW) | unit test | request-response | none | role-only | | `tests/offscreen/handshake.test.ts` (NEW) | unit test | event-driven | none | role-only | | `tests/offscreen/port.test.ts` (NEW) | unit test | port/event-driven | none | role-only | | `vitest.config.ts` (NEW) | test runner config | config | `vite.config.ts` (defineConfig + plugin shape) + `tsconfig.json` (path/aliases) | role-only | | `offscreen/index.ts` (DELETED) | — | — | — | — | | `offscreen/index.html` (DELETED) | — | — | — | — | | `vite.config.ts:11-217` inline plugin (DELETED) | — | — | — | — | | `src/background/index.ts:156-165, 128, 457-473, 482-520` (DELETED in-place) | — | — | — | — | ## Pattern Assignments ### `src/offscreen/recorder.ts` (NEW — offscreen recorder; streaming + port + event-driven) **Analogs (composed):** - `offscreen/index.ts` (lines 1-60) — the dead-but-correctly-shaped skeleton: module-level `let mediaRecorder`, `chrome.runtime.onMessage.addListener` with a `switch (message.type)`, `MediaRecorder.ondataavailable` push pattern. Phase 1 KEEPS the shape and REWRITES the body. - Inline JS string at `vite.config.ts:35-213` — the live offscreen at runtime. This is the source-of-truth for the *current behavior* but is the explicit DELETE target. Use it ONLY as a reference for what NOT to copy (the codec fallback chain at lines 151-172, the IndexedDB plumbing at lines 43-107, the `let mediaRecorder` shadow at line 158). - `src/background/index.ts:25-75` — ring-buffer structural pattern (currently SW-side). MOVE this to offscreen. - `src/shared/logger.ts:1-25` — `Logger` class shape. Phase 1 adds an `OffscreenLogger` mirroring this (`[OS:...]` prefix) OR reuses `Logger` with prefix `Offscreen:Main`. **Skeleton pattern (KEEP shape; rewrite body)** — from `offscreen/index.ts:1-60`: ```typescript // offscreen/index.ts:1-2 — module-level state declaration (KEEP shape; rename // `mediaRecorder` → `videoRecorder` per RESEARCH.md anti-pattern fix) let mediaRecorder: MediaRecorder | null = null; let videoChunks: Blob[] = []; // offscreen/index.ts:45-59 — message-listener-with-switch shape (KEEP) chrome.runtime.onMessage.addListener((message) => { switch (message.type) { case 'START_RECORDING': startRecording(message.streamId); break; case 'STOP_RECORDING': stopRecording(); break; case 'GET_CHUNKS': chrome.runtime.sendMessage({ type: 'CHUNKS_RESPONSE', chunks: getChunks() }); break; } }); ``` **dataavailable pattern (KEEP behavior; chunk Blob is now pushed to ring buffer with timestamp+isHeader, NOT forwarded via `chrome.runtime.sendMessage`)** — from `offscreen/index.ts:18-27`: ```typescript mediaRecorder.ondataavailable = (event) => { if (event.data && event.data.size > 0) { videoChunks.push(event.data); chrome.runtime.sendMessage({ // ← REMOVE: this is the broken-Blob-over-sendMessage path type: 'VIDEO_CHUNK', data: event.data, timestamp: Date.now() }); } }; ``` **Ring-buffer pattern (MOVE from SW to offscreen, verbatim)** — from `src/background/index.ts:47-75`: ```typescript function cleanupVideoBuffer() { const now = Date.now(); const beforeCount = videoBuffer.length; logger.log(`Cleaning up buffer, current size: ${beforeCount}`); // Всегда сохраняем первый чанк (WebM заголовок, помечен как isFirst) // Остальные чанки фильтруем по времени (старше 30 секунд удаляем) videoBuffer = videoBuffer.filter(chunk => { // Всегда оставляем первый чанк (заголовок) if (chunk.isFirst) { return true; } // Остальные - только если моложе 30 секунд const age = now - chunk.timestamp; const keep = age < VIDEO_BUFFER_DURATION_MS; if (!keep) { logger.log(`Removing chunk, age: ${age}ms, limit: ${VIDEO_BUFFER_DURATION_MS}ms`); } return keep; }); const removed = beforeCount - videoBuffer.length; if (removed > 0) { logger.log(`Removed ${removed} old video chunks, buffer: ${videoBuffer.length}`); } else { logger.log(`No chunks removed, buffer: ${videoBuffer.length}`); } } ``` **Header-pin add pattern (MOVE from SW to offscreen)** — from `src/background/index.ts:26-45`: ```typescript function addVideoChunkFromBlob(blob: Blob) { logger.log(`Processing video chunk from Blob, size: ${blob.size} bytes, type: ${blob.type}`); const chunk: VideoChunk = { data: blob, timestamp: Date.now(), isFirst: !firstChunkSaved // Первый чанк помечаем как isFirst }; if (!firstChunkSaved) { firstChunkSaved = true; logger.log(`This is the FIRST video chunk (WebM header), size: ${blob.size} bytes`); } videoBuffer.push(chunk); cleanupVideoBuffer(); } ``` **Constants pattern** — from `src/background/index.ts:12-13`: ```typescript // Константы const VIDEO_BUFFER_DURATION_MS = 30 * 1000; // 30 секунд ``` Add for the new module: ```typescript const TIMESLICE_MS = 2000; // CON-video-codec / SPEC §4.1 (was 200ms) const VIDEO_MIME = 'video/webm;codecs=vp9'; const VIDEO_BITRATE = 400_000; // CON-video-codec const PORT_NAME = 'video-keepalive'; const PORT_PING_MS = 25_000; // < 30s SW idle threshold const PORT_RECONNECT_MS = 290_000; // pre-empt the ~5min port cap ``` **getDisplayMedia call pattern** — from RESEARCH.md Example A (canonical): ```typescript async function startRecording(): Promise { const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false, // SPEC §9 — Phase 2/CAP-01 territory }); // Codec strict-mode — RESEARCH.md Example E if (!MediaRecorder.isTypeSupported(VIDEO_MIME)) { const err = `vp9 unsupported. UA=${navigator.userAgent}`; chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: err }); throw new Error(err); } videoRecorder = new MediaRecorder(stream, { mimeType: VIDEO_MIME, videoBitsPerSecond: VIDEO_BITRATE, }); videoRecorder.ondataavailable = onDataAvailable; videoRecorder.start(TIMESLICE_MS); // RESEARCH.md Example F — track.ended for "Stop sharing" recovery stream.getTracks().forEach((track) => { track.addEventListener('ended', onUserStoppedSharing, { once: true }); }); } ``` **OFFSCREEN_READY handshake (Pattern 4)** — from RESEARCH.md (matches `src/shared/types.ts:18` declared but unused message type): ```typescript // at the bottom of recorder.ts, after the onMessage listener is registered: chrome.runtime.onMessage.addListener((msg) => { /* ... */ }); chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' }); ``` **Port keepalive pattern (Pattern 5)** — from RESEARCH.md lines 600-628: ```typescript const port = chrome.runtime.connect({ name: PORT_NAME }); setInterval(() => port.postMessage({ type: 'PING' }), PORT_PING_MS); port.onMessage.addListener((msg) => { if (msg.type === 'REQUEST_BUFFER') { port.postMessage({ type: 'BUFFER', chunks: videoBuffer }); } }); port.onDisconnect.addListener(() => { // Pitfall 4 — 5-minute port cap, reconnect reconnectPort(); }); ``` **Russian-comment style (KEEP)** — observed throughout `src/background/index.ts:12, 25, 47, 52-54, 156`: ```typescript // Константы // Кольцевой буфер видео // Очистка старых событий // Keepalive для предотвращения выгрузки Service Worker ``` New code in `src/offscreen/recorder.ts` SHOULD include Russian section headers (matches project provenance from CONTEXT.md "Established patterns"). **Imports pattern (KEEP project shape)** — from `src/background/index.ts:1-8`: ```typescript import { Logger } from '../shared/logger'; import type { Message, VideoChunk, SessionMetadata, VideoBufferResponse } from '../shared/types'; import JSZip from 'jszip'; ``` For the new offscreen recorder: ```typescript import { Logger } from '../shared/logger'; import type { Message, VideoChunk } from '../shared/types'; ``` --- ### `src/offscreen/index.html` (NEW — bundle entry HTML, static asset) **Analog:** `offscreen/index.html` (current) — DELETE and recreate. **Current pattern** — `offscreen/index.html:1-10`: ```html Offscreen Page ``` **Popup-side analog (same shape, references TS via crxjs)** — `src/popup/index.html:1-21`: ```html AI Call Recorder ... ``` **Pattern to copy** — RESEARCH.md Example A (crxjs discussion #919): ```html Mokosh Recorder ``` --- ### `src/background/index.ts` (MODIFIED — service worker coordinator; shrinks substantially) **Analog:** itself. Refactor in place. **DELETE blocks (verified line numbers as of 2026-05-15):** | Block | Current Lines | Reason | |-------|---------------|--------| | `addVideoChunkFromBlob` | 26-45 | MOVE to offscreen (D-16) | | `cleanupVideoBuffer` | 47-75 | MOVE to offscreen (D-16) | | `getMediaStreamId` call inside `startVideoCapture` | 126-131 | Replaced by `getDisplayMedia` in offscreen (D-01) | | `setupKeepalive` function | 156-165 | Replaced by port keepalive (D-18) | | `VIDEO_CHUNK` case in onMessage | 457-466 | Buffer no longer travels via sendMessage (D-19) | | `VIDEO_CHUNK_SAVED` case in onMessage | 468-473 | IndexedDB path is dead (D-19) | | `loadChunkFromIndexedDB` function | 482-505 | IndexedDB path is dead (D-19) | | `openIndexedDB` function | 507-520 | IndexedDB path is dead (D-19) | | `setupKeepalive()` call inside `initialize` | 525 | Paired with deletion of `setupKeepalive` | **Auth/Guard pattern (sender.id validation — RESEARCH.md security recommendation)** — currently MISSING: The SW's onMessage listener at `src/background/index.ts:427-479` does NOT validate `sender.id`. Phase 1 SHOULD add `if (_sender.id !== chrome.runtime.id) return false;` at the top of the new port and onMessage handlers (low-effort hardening; per RESEARCH.md security domain table). Rename `_sender` → `sender` since it is now used. **onMessage handler pattern (KEEP shape, modify cases)** — from `src/background/index.ts:427-479`: ```typescript chrome.runtime.onMessage.addListener((message: Message, _sender, sendResponse) => { logger.log('Received message:', message.type, message); switch (message.type) { case 'REQUEST_PERMISSIONS': checkPermissions().then(async (hasPermissions) => { if (hasPermissions) { if (!isRecording) { await startVideoCapture(); } sendResponse({ granted: true }); } else { requestPermissions().then(granted => { sendResponse({ granted }); }); } }); return true; // ← async response convention case 'GET_VIDEO_BUFFER': sendResponse(getVideoBuffer()); return false; // ← sync response convention case 'SAVE_ARCHIVE': saveArchive().then(result => { sendResponse(result); }); return true; default: logger.warn('Unknown message type:', message.type); return false; } }); ``` Phase 1 modifications: - `REQUEST_PERMISSIONS`: replace `startVideoCapture` body with new `ensureOffscreenAndStart()` (offscreen + DISPLAY_MEDIA reason). - `GET_VIDEO_BUFFER`: replace `getVideoBuffer()` with an over-port request to offscreen (return `true`, resolve via port message handler). - `SAVE_ARCHIVE`: same — `getVideoBuffer()` inside `saveArchive()` (line 332) becomes a port-request to offscreen. - Add a new `case 'OFFSCREEN_READY'` to resolve the handshake Promise (Pattern 4). **ensureOffscreen pattern (REPLACE reason)** — from `src/background/index.ts:78-104`: ```typescript async function ensureOffscreen() { if (offscreenCreated) { logger.log('Offscreen already created'); return; } try { const url = chrome.runtime.getURL('offscreen/index.html'); // ← path may change to 'src/offscreen/index.html'; verify after first build logger.log('Creating offscreen document at:', url); await chrome.offscreen.createDocument({ url: url, reasons: ['USER_MEDIA'] as any, // ← REPLACE: ['DISPLAY_MEDIA']; drop the `as any` once @types/chrome is bumped justification: 'Need to record video from tab for error reporting' // ← OPTIONAL: update copy to match RESEARCH.md Example C }); offscreenCreated = true; logger.log('Offscreen document created successfully'); } catch (error) { if ((error as any).message?.includes('already exists')) { offscreenCreated = true; logger.log('Offscreen document already exists'); } else { logger.error('Failed to create offscreen document:', error); throw error; } } } ``` Anti-pattern note: the `as any` on line 90 maps to audit P1 #13 and the CLAUDE.md "no @ts-ignore / no as any" rule. The fix is `chrome.offscreen.Reason.DISPLAY_MEDIA` after bumping `@types/chrome` from `^0.0.268` to `^0.1.42` (RESEARCH.md Standard Stack). The bump is OPTIONAL in this phase but recommended. **ADD: onConnect handler for video-keepalive port (NEW)** — RESEARCH.md Pattern 5, lines 609-617: ```typescript let videoPort: chrome.runtime.Port | null = null; chrome.runtime.onConnect.addListener((p) => { if (p.name !== 'video-keepalive') return; videoPort = p; p.onMessage.addListener((msg) => { if (msg.type === 'BUFFER') { /* resolve pending export */ } }); p.onDisconnect.addListener(() => { videoPort = null; }); }); ``` **ADD: one-shot deletion of orphaned IndexedDB on install** — RESEARCH.md Runtime State Inventory: The `chrome.runtime.onInstalled.addListener` at `src/background/index.ts:530-533` is the natural site to call `indexedDB.deleteDatabase('VideoRecorderDB')` to clean up old browser profiles. Add inside that listener. **`saveArchive` pattern (MODIFY to fetch buffer via port)** — `src/background/index.ts:314-387`: ```typescript async function saveArchive() { // ... unchanged up to line 332 ... const videoBuffer = getVideoBuffer(); // ← REPLACE: await getVideoBufferFromOffscreen() // ... unchanged thereafter ... } ``` --- ### `vite.config.ts` (MODIFIED — heavy reduction) **Analog:** crxjs discussion #919 working pattern (cited in RESEARCH.md Example B). **DELETE the entire inline plugin block** — `vite.config.ts:13-216` (the object literal starting `{ name: 'copy-offscreen', generateBundle() { ... } }`). **Final shape (REPLACE existing file body)** — RESEARCH.md Example B: ```typescript import { defineConfig } from 'vite'; import { crx } from '@crxjs/vite-plugin'; import manifest from './manifest.json'; export default defineConfig({ plugins: [ crx({ manifest, contentScripts: { injectCss: false } }), ], build: { rollupOptions: { input: { offscreen: 'src/offscreen/index.html', }, }, }, }); ``` **Current `crx` invocation pattern (KEEP)** — `vite.config.ts:5-12`: ```typescript crx({ manifest, contentScripts: { injectCss: false, }, }), ``` This invocation form is correct and is retained verbatim; only the second plugin entry (the inline `copy-offscreen`) is deleted, and `rollupOptions.input` for the offscreen HTML entry is added per RESEARCH.md Pitfall 5. **Pitfall 5 note (per RESEARCH.md):** Inspect `dist/` after the first `npm run build` to verify the emitted path matches what `src/background/index.ts:85` passes to `createDocument`. If crxjs strips the `src/` prefix, change the SW's `chrome.runtime.getURL` arg accordingly. --- ### `manifest.json` (MODIFIED — permission swap) **Analog:** itself. **Current permissions block** — `manifest.json:6-14`: ```json "permissions": [ "tabCapture", "activeTab", "downloads", "scripting", "storage", "alarms", "offscreen" ], ``` **Patch per D-A6:** - Replace `"tabCapture"` with `"desktopCapture"`. - KEEP `"activeTab"` (screenshot path: `chrome.tabs.captureVisibleTab`, see `src/background/index.ts:190`). - Drop `"alarms"` (no longer used after `setupKeepalive` deletion). [OPTIONAL but consistent with D-18.] - KEEP `"downloads"` (used at `src/background/index.ts:305`). - KEEP `"storage"` (used by rrweb? — not used in current code; can drop, but out of scope for this phase). - KEEP `"scripting"` (content script). - KEEP `"offscreen"` (required to call `chrome.offscreen.createDocument`). Final shape: ```json "permissions": [ "desktopCapture", "activeTab", "downloads", "scripting", "storage", "offscreen" ], ``` --- ### `src/shared/types.ts` (MODIFIED — light; verify message types) **Analog:** itself. **Status check** — `src/shared/types.ts:3-18`: ```typescript export type MessageType = | 'REQUEST_PERMISSIONS' | 'PERMISSIONS_GRANTED' | 'PERMISSIONS_DENIED' | 'GET_VIDEO_BUFFER' | 'VIDEO_BUFFER_RESPONSE' | 'GET_RRWEB_EVENTS' | 'RRWEB_EVENTS_RESPONSE' | 'SAVE_ARCHIVE' | 'ARCHIVE_SAVED' | 'START_RECORDING' | 'STOP_RECORDING' | 'RECORDING_ERROR' | 'VIDEO_CHUNK' | 'VIDEO_CHUNK_SAVED' | 'OFFSCREEN_READY'; ``` - `OFFSCREEN_READY` is present (line 18). ✓ - `RECORDING_ERROR` is present (line 15). ✓ - `VIDEO_CHUNK` and `VIDEO_CHUNK_SAVED` should be **REMOVED** (lines 16-17) since their handlers are deleted in Phase 1. **Phase 1 additions:** - Add port-message string types if planner chooses to type the port traffic: ```typescript // Port message types (offscreen ↔ SW over 'video-keepalive' port) export type PortMessageType = | 'PING' | 'REQUEST_BUFFER' | 'BUFFER'; export interface PortMessage { type: PortMessageType; chunks?: VideoChunk[]; payload?: T; } ``` **`VideoChunk` interface (KEEP as-is)** — `src/shared/types.ts:27-31`: ```typescript export interface VideoChunk { data: Blob; timestamp: number; isFirst?: boolean; } ``` This shape is exactly what the offscreen ring buffer needs. Reuse unchanged. --- ### `tests/offscreen/ring-buffer.test.ts` (NEW — unit test) **Analog:** none in codebase (no tests directory exists yet). **Subject under test** — the relocated `addVideoChunkFromBlob` + `cleanupVideoBuffer` from `src/background/index.ts:26-75`. Tests assert: 1. First chunk is pinned with `isFirst: true`. 2. Subsequent chunks have `isFirst: false`. 3. After 30+ seconds, non-header chunks are evicted but the header survives. 4. Buffer length stays ≤ N for N chunks added within 30 s. **Skeleton (standard Vitest)** — pattern inferred from RESEARCH.md Validation Architecture: ```typescript import { describe, it, expect, beforeEach } from 'vitest'; import { addChunk, trimAged, getBuffer, resetBuffer } from '../../src/offscreen/recorder'; // NB: planner must export these pure functions from recorder.ts to make them testable. describe('ring buffer', () => { beforeEach(() => resetBuffer()); it('first chunk is header', () => { addChunk({ size: 1024 } as Blob, 1_000); expect(getBuffer()[0].isFirst).toBe(true); }); it('trim 30s — keeps header, evicts aged tail', () => { addChunk({ size: 1024 } as Blob, 0); // header at t=0 addChunk({ size: 512 } as Blob, 10_000); // t=10s addChunk({ size: 512 } as Blob, 35_000); // t=35s — header now 35s old, tail 25s trimAged(/* now= */ 40_000); // header age 40s; first chunk 30s; last chunk 5s const buf = getBuffer(); expect(buf[0].isFirst).toBe(true); // header survives unconditionally expect(buf.length).toBeGreaterThanOrEqual(2); // header + at least the t=35s chunk }); }); ``` --- ### `tests/offscreen/codec-check.test.ts` (NEW — unit test) **Analog:** none. Subject under test = the codec strict-mode error path (RESEARCH.md Pattern 6 / Example E). **Skeleton:** ```typescript import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; describe('codec strict mode', () => { let originalIsTypeSupported: typeof MediaRecorder.isTypeSupported; beforeEach(() => { originalIsTypeSupported = (globalThis as any).MediaRecorder?.isTypeSupported; (globalThis as any).MediaRecorder = { isTypeSupported: vi.fn().mockReturnValue(false), }; (globalThis as any).chrome = { runtime: { sendMessage: vi.fn() } }; }); afterEach(() => { if (originalIsTypeSupported) { (globalThis as any).MediaRecorder = { isTypeSupported: originalIsTypeSupported }; } }); it('throws on unsupported vp9 and emits RECORDING_ERROR', async () => { const { assertCodecSupported } = await import('../../src/offscreen/recorder'); expect(() => assertCodecSupported()).toThrow(/vp9 unsupported/); expect((globalThis as any).chrome.runtime.sendMessage).toHaveBeenCalledWith( expect.objectContaining({ type: 'RECORDING_ERROR' }) ); }); }); ``` --- ### `tests/offscreen/handshake.test.ts` (NEW — unit test) **Analog:** none. Subject under test = `OFFSCREEN_READY` Promise resolution (RESEARCH.md Pattern 4). **Skeleton:** ```typescript import { describe, it, expect, vi } from 'vitest'; describe('OFFSCREEN_READY handshake', () => { it('sends OFFSCREEN_READY after listener registration', async () => { const calls: any[] = []; (globalThis as any).chrome = { runtime: { sendMessage: (m: unknown) => { calls.push(m); }, onMessage: { addListener: vi.fn() }, connect: () => ({ name: 'video-keepalive', postMessage: vi.fn(), onMessage: { addListener: vi.fn() }, onDisconnect: { addListener: vi.fn() }, disconnect: vi.fn(), }), }, }; await import('../../src/offscreen/recorder'); expect(calls).toEqual(expect.arrayContaining([ expect.objectContaining({ type: 'OFFSCREEN_READY' }), ])); }); }); ``` --- ### `tests/offscreen/port.test.ts` (NEW — unit test) **Analog:** none. Subject under test = port reconnect on disconnect (RESEARCH.md Pitfall 4). **Skeleton:** ```typescript import { describe, it, expect, vi } from 'vitest'; describe('port reconnect', () => { it('reconnects when port disconnects', () => { const disconnectListeners: Array<() => void> = []; let connectCount = 0; (globalThis as any).chrome = { runtime: { sendMessage: vi.fn(), onMessage: { addListener: vi.fn() }, connect: () => { connectCount++; return { name: 'video-keepalive', postMessage: vi.fn(), onMessage: { addListener: vi.fn() }, onDisconnect: { addListener: (fn: () => void) => disconnectListeners.push(fn), }, disconnect: vi.fn(), }; }, }, }; // After import, the module connects exactly once return import('../../src/offscreen/recorder').then(() => { expect(connectCount).toBe(1); // Fire the disconnect — module should reconnect disconnectListeners.forEach((fn) => fn()); expect(connectCount).toBeGreaterThanOrEqual(2); }); }); }); ``` --- ### `vitest.config.ts` (NEW — test runner config) **Analogs:** `vite.config.ts` (defineConfig shape) + `tsconfig.json` (compiler-options alignment). **Pattern to copy** — RESEARCH.md Wave 0 Gaps + Vitest standard: ```typescript import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', // node-only; Blob is shimmed by Vitest 3+ via undici include: ['tests/**/*.test.ts'], reporters: 'dot', typecheck: { enabled: false, // tsc --noEmit runs separately in `npm run build` }, }, }); ``` **`package.json` script ADD** — companion to vitest.config.ts: ```json "scripts": { "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", "test": "vitest run" // ← ADD } ``` --- ## Shared Patterns ### Logging **Source:** `src/shared/logger.ts:1-25` (Logger class) and `src/shared/logger.ts:28-51` (ContentLogger class). **Apply to:** All new and modified source files. ```typescript // src/shared/logger.ts:1-25 export class Logger { private context: string; constructor(context: string) { this.context = context; } private logWithLevel(level: 'log' | 'warn' | 'error', ...args: any[]) { const timestamp = new Date().toISOString(); console[level](`[SW:${this.context}] ${timestamp}`, ...args); } log(...args: any[]) { this.logWithLevel('log', ...args); } warn(...args: any[]) { this.logWithLevel('warn', ...args); } error(...args: any[]) { this.logWithLevel('error', ...args); } } ``` **For the new offscreen file:** Either (a) reuse `Logger` with constructor argument `'Offscreen:Main'`, producing `[SW:Offscreen:Main]` (slightly misleading since the runtime is the offscreen, not the SW), or (b) ADD an `OffscreenLogger` class mirroring `ContentLogger` with prefix `[OS:${context}]`. Planner picks; CONTEXT.md "Reusable assets" section permits either. Recommendation: add `OffscreenLogger` for consistency with the existing `Logger` / `ContentLogger` split. ~25 lines, matches the shape exactly: ```typescript // New OffscreenLogger class to add to src/shared/logger.ts export class OffscreenLogger { private context: string; constructor(context: string) { this.context = context; } private logWithLevel(level: 'log' | 'warn' | 'error', ...args: any[]) { const timestamp = new Date().toISOString(); console[level](`[OS:${this.context}] ${timestamp}`, ...args); } log(...args: any[]) { this.logWithLevel('log', ...args); } warn(...args: any[]) { this.logWithLevel('warn', ...args); } error(...args: any[]) { this.logWithLevel('error', ...args); } } ``` ### Message handling (sender validation) **Source:** RESEARCH.md Security Domain table (currently MISSING in codebase). **Apply to:** New `chrome.runtime.onMessage` and `chrome.runtime.onConnect` handlers in both `src/background/index.ts` and `src/offscreen/recorder.ts`. Current code does NOT validate `sender.id`. Phase 1 SHOULD add: ```typescript chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (sender.id !== chrome.runtime.id) return false; // ... existing dispatch ... }); chrome.runtime.onConnect.addListener((port) => { if (port.sender?.id !== chrome.runtime.id) { port.disconnect(); return; } // ... existing handling ... }); ``` ### Async response convention **Source:** `src/background/index.ts:427-479`. **Apply to:** All `onMessage` handlers that perform `await`/`then` before `sendResponse`. The convention in the existing code is consistent and correct: - `return true;` — when the handler will call `sendResponse` asynchronously (after a Promise resolves). - `return false;` — when the handler returns synchronously or does not respond. Preserve this convention verbatim in the modified background and the new offscreen recorder. ### Russian inline comments **Source:** `src/background/index.ts:12, 25, 47, 156` and `src/content/index.ts:14-18, 20-23, 27`. **Apply to:** New `src/offscreen/recorder.ts`. Project convention: Russian comments are kept as section markers / explanations for business logic. Example: ```typescript // Константы const TIMESLICE_MS = 2000; // Кольцевой буфер видео let videoBuffer: VideoChunk[] = []; // Keepalive порт (заменяет chrome.alarms) let keepalivePort: chrome.runtime.Port | null = null; ``` TypeScript identifiers stay English (per CLAUDE.md naming rules); only comments are Russian. ### Module-level state pattern **Source:** `src/background/index.ts:15-22` and `src/content/index.ts:20-24`. **Apply to:** New `src/offscreen/recorder.ts`. Single-instance modules use top-level `let` state declarations (not classes / singletons): ```typescript // src/background/index.ts:15-22 let videoBuffer: VideoChunk[] = []; let firstChunkSaved = false; let isRecording = false; let offscreenCreated = false; let lastScreenshotTime = 0; let cachedScreenshot: Blob | null = null; ``` For `src/offscreen/recorder.ts`, mirror this shape: ```typescript let videoRecorder: MediaRecorder | null = null; // RENAMED from 'mediaRecorder' to break the audit P0 #1 shadow let videoBuffer: VideoChunk[] = []; let firstChunkSaved = false; let mediaStream: MediaStream | null = null; let keepalivePort: chrome.runtime.Port | null = null; let pingIntervalId: number | null = null; ``` ## No Analog Found | File | Role | Data Flow | Reason | |------|------|-----------|--------| | `tests/offscreen/*.test.ts` (4 files) | unit test | request-response | No test directory exists in the project yet. Pattern comes from Vitest defaults + RESEARCH.md Validation Architecture. | `vitest.config.ts` has a partial analog in `vite.config.ts` (defineConfig shape) and is listed under Pattern Assignments rather than here. ## Metadata **Analog search scope:** - `/home/parf/projects/work/repremium/src/` (all subdirectories) - `/home/parf/projects/work/repremium/offscreen/` - `/home/parf/projects/work/repremium/vite.config.ts` - `/home/parf/projects/work/repremium/manifest.json` - `/home/parf/projects/work/repremium/tsconfig.json` - `/home/parf/projects/work/repremium/package.json` **Files scanned:** 10 source files; 4 config files. **Verified line numbers** (read on 2026-05-15): - `src/background/index.ts` — 536 lines total. Verified delete-target ranges: 26-45 (addVideoChunkFromBlob), 47-75 (cleanupVideoBuffer), 126-131 (getMediaStreamId), 156-165 (setupKeepalive), 457-473 (VIDEO_CHUNK + VIDEO_CHUNK_SAVED cases), 482-520 (loadChunkFromIndexedDB + openIndexedDB). - `vite.config.ts` — 227 lines total. Verified inline plugin range: 13-216 (the `copy-offscreen` object starts at line 13 with `name:` on line 14 inside a plugins array; the embedded JS string template ends at line 213; the trailing build/rollupOptions block at 218-226 also collapses per RESEARCH.md Example B). - `offscreen/index.ts` — 60 lines, intact. DELETE target. - `offscreen/index.html` — 10 lines, intact. DELETE target. - `manifest.json:7` — `"tabCapture"` token to replace. - `src/shared/types.ts:18` — `'OFFSCREEN_READY'` declared, ready to wire up. **Pattern extraction date:** 2026-05-15