diff --git a/.planning/phases/02-stabilize-export-pipeline/02-01-SUMMARY.md b/.planning/phases/02-stabilize-export-pipeline/02-01-SUMMARY.md new file mode 100644 index 0000000..6e1e87e --- /dev/null +++ b/.planning/phases/02-stabilize-export-pipeline/02-01-SUMMARY.md @@ -0,0 +1,223 @@ +--- +phase: 02-stabilize-export-pipeline +plan: 01 +subsystem: testing +tags: + - tdd + - red-tests + - blob-url-migration + - meta-json-urls-array + - schema-validation + - p0-6 + - p1-10 + - wave-0 + - export-pipeline + +# Dependency graph +requires: + - phase: 01-stabilize-video-pipeline + provides: createArchive + remuxSegments + chrome stub patterns + raw-3ebml-concat.webm fixture (Plan 01-08 Wave 0 RED precedent) +provides: + - "3 failing test files (16 it() blocks total) pinning D-P2-01 + D-P2-02 + D-P2-03 + F2 contracts ahead of Plans 02-02 and 02-03 implementation" + - "Blob URL download wire-format contract (chrome.downloads.download arg0.url starts with blob:, not data:application/zip;base64,)" + - "URL.revokeObjectURL lifecycle contract (revoke fires on chrome.downloads.onChanged 'complete')" + - "SessionMetadata.urls: string[] type-shape contract via source-text scan" + - "createArchive meta.json shape contract (urls Array, no url field)" + - "tab-url-tracker module seam (src/background/tab-url-tracker.ts with getTabUrlsSeen(): string[])" + - "URL filter contract (include https + chrome-extension://; exclude chrome:// + about:)" + - "Empty-tracker F2 contract (meta.urls === [] for whole-desktop-no-tab sessions)" + - "Strict 8-field meta.json schema with EXPECTED_KEYS pin including planner-suggested schemaVersion 8th field" + - "FileReader polyfill pattern for vitest Node-env Blob ingestion through JSZip" +affects: + - Plan 02-02 (offscreen Blob URL pipeline implementation) + - Plan 02-03 (meta.json urls[] migration + tab-url-tracker + schema validation) + - Plan 02-04 (UAT harness extensions A24+ — pre-checkpoint bundle gates) + - All future phases that consume meta.json (downstream UAT harness, server-upload candidates) + +# Tech tracking +tech-stack: + added: + - "FileReader polyfill (Node 24 lacks FileReader; JSZip needs it for Blob ingestion)" + - "vi.doMock for remuxSegments short-circuit (6 MB synthetic stress test)" + - "Source-text scan for type-shape pinning (workaround for vitest.config.ts typecheck:{enabled:false})" + patterns: + - "RED-gate triple-test pattern: WIRE-FORMAT polarity guard + LATENCY budget + LIFECYCLE pin (Test 2's necessary-not-sufficient latency + wire-format combo)" + - "Missing-module RED via dynamic-import-in-try/catch + expect.fail (mirrors Plan 01-08 webm-remux.test.ts precedent)" + - "Three-slice fixture pattern (SEG1/SEG2/SEG3 byte offsets from raw-3ebml-concat.webm) for muxer-safe segmentation" + - "Per-test SAVE_ARCHIVE drain helper: dispatch SAVE_ARCHIVE → resolve REQUEST_BUFFER with synthetic segments → loop until chrome.downloads.download called" + +key-files: + created: + - "tests/background/blob-url-download.test.ts (3 RED tests, ~625 lines; pins D-P2-01)" + - "tests/background/meta-json-urls-schema.test.ts (5 RED tests, ~671 lines; pins D-P2-02 + F2)" + - "tests/build/strict-meta-json-validation.test.ts (3 RED + 5 GREEN regression-guard tests, ~621 lines; pins D-P2-03 + F2)" + modified: [] + +key-decisions: + - "Use vitest default `node` env instead of jsdom: Node 24 ships URL.createObjectURL + URL.revokeObjectURL + performance + Blob as globals. Plan's suggestion to use jsdom was incorrect (vitest.config.ts explicitly sets environment: 'node' and jsdom is not in devDependencies)." + - "FileReader polyfill inline in each test file: Node 24 lacks FileReader; JSZip's Blob ingestion path needs it. Minimal polyfill (delegates to Blob.arrayBuffer()) installed at bootSwInRecState time." + - "vi.doMock('../../src/background/webm-remux') for Test 2's 6 MB scale: production muxer rejects monotonicity-breaking input shapes, so a synthetic 6 MB Blob via vi.doMock is the cleanest way to stress the download wire-format under the data-URL-cap-exceeding scale." + - "Test 1 (SessionMetadata shape pin) uses source-text scan instead of tsc compile failure: vitest.config.ts has typecheck:{enabled:false}, so a tsc-failure-based pin would be a no-op. Source-text regex against src/shared/types.ts is the RED gate that survives the typecheck-disabled config." + - "EXPECTED_KEYS 8th field tentatively `schemaVersion`: marks D-P2-02 url→urls breaking-change cutover. Plan 02-03's implementer commits to adding `schemaVersion: '2'` as a constant. Test file is canonical place to amend if a stronger 8th-field argument surfaces." + - "Tests 2, 4, 5, 6, 7 of Task 3 stay GREEN-today as regression guards: plan's 'ALL 8 fail' claim wasn't achievable (timestamp/semver/totalEvents/videoBufferSeconds/logDurationMinutes already match the current 7-field shape). Documented honestly; the GREEN-today tests pin the post-migration schema for regression-guard duty." + - "tab-url-tracker module seam: src/background/tab-url-tracker.ts with export `getTabUrlsSeen(): string[]`. Tests 3+4+5 of Task 2 use dynamic-import-with-expect.fail to surface the missing module as the RED signal. Plan 02-03 implements the module." + +patterns-established: + - "RED-gate triple-pin pattern: (1) precondition (download was called), (2) polarity guard (forbidden prefix absent), (3) target contract (required prefix present). Test 1 of blob-url-download.test.ts exemplifies." + - "Wire-format-at-scale pattern: synthetic-payload stress test + wire-format guard. The latency check is necessary but not sufficient — the wire-format guard converts a sub-budget spurious pass into a meaningful RED. Test 2 of blob-url-download.test.ts." + - "Source-text type-shape pin: when vitest typecheck is disabled, fall back to a regex scan of the source file body. Captures both presence (new field exists) and absence (old field removed) in one test." + - "Per-test SAVE_ARCHIVE driver helper: factored out runSaveAndCaptureArchiveBlob / runSaveAndCaptureDownloadArg / runAndParseMetaJson with a shared try-fire-REQUEST_BUFFER-loop. Future Phase 2 tests inherit this pattern." + - "GREEN-today regression-guard tests bundled with RED-today migration tests: same describe block, same setup, but the assertions are scoped to pin different layers (migration RED + stability GREEN). Future TDD plans can mix these explicitly." + +requirements-completed: [] +# Per plan instructions: requirements (REQ-archive-export-latency, +# REQ-meta-json-schema) are NOT completed by this plan — they're +# pinned by RED tests here and IMPLEMENTED + GREEN-flipped by Plans +# 02-02 + 02-03. Marking them complete now would be incorrect per +# CLAUDE.md "no premature requirement marking" lesson from Plan 01-01 +# (see STATE.md "[Phase ?]: Reverted premature REQ-video-ring-buffer +# Complete marking left by Plan 01-01"). + +# Metrics +duration: ~20min +completed: 2026-05-20 +--- + +# Phase 02 Plan 01: Wave-0 RED tests for export pipeline migration + +**Three failing test files (16 it() blocks) pin D-P2-01 Blob URL download contract + D-P2-02 meta.json urls[] migration + D-P2-03 strict 8-field schema validation + F2 empty-tracker resolution, ahead of Plan 02-02 + 02-03 implementation.** + +## Performance + +- **Duration:** ~20 min +- **Started:** 2026-05-20T13:13:59Z (baseline test capture) +- **Completed:** 2026-05-20T13:33:38Z (final commit + SUMMARY) +- **Tasks:** 3/3 completed +- **Files created:** 3 test files (1 917 lines total) +- **Files modified:** 0 production-code files (this is the RED gate ONLY) + +## Accomplishments + +### Task 1: tests/background/blob-url-download.test.ts (3 RED) + +Pins D-P2-01 (offscreen-minted Blob URL pipeline; closes audit P0-6). + +- **Test 1 (wire-format polarity guard):** `chrome.downloads.download` is called with a `url` that starts with `blob:` and does NOT start with `data:application/zip;base64,`. RED today; concrete assertion error: `chrome.downloads.download was called with url='data:application/zip;base64,UEsDBAoAAAAAAL1qtFw...' — D-P2-01 forbids data:application/zip;base64, prefix`. +- **Test 2 (latency + wire-format at 6 MB scale):** A 6 MB archive completes in under 5 000 ms AND emits a `blob:` URL. The vi.doMock on remuxSegments short-circuits the muxer to inject a synthetic 6 MB Blob. RED today via the wire-format guard (data: URL prefix); GREEN after Plan 02-02. Elapsed ~1 266 ms (well under budget). +- **Test 3 (revoke lifecycle):** `URL.revokeObjectURL` is scheduled with the same URL passed to `chrome.downloads.download` once `chrome.downloads.onChanged` reports `state.current === 'complete'`. RED today; concrete error: `chrome.downloads.onChanged._callbacks.length === 0 at probe time` (current downloadArchive does not register an onChanged listener). + +**Commit:** [748a81f](#) `test(02-01): RED — pin Blob URL download contract (D-P2-01)` + +### Task 2: tests/background/meta-json-urls-schema.test.ts (5 RED) + +Pins D-P2-02 (meta.json url→urls migration) + F2 empty-tracker resolution. + +- **Test 1 (SessionMetadata type-shape via source-text scan):** `src/shared/types.ts` MUST contain `urls: string[]` AND MUST NOT contain `url:` (singular) inside the SessionMetadata interface body. Workaround for vitest typecheck:{enabled:false}. RED today; concrete error: `'urls: string[]' field not present`. +- **Test 2 (createArchive meta.json shape):** Real createArchive output has `Array.isArray(meta.urls) === true` AND `meta.url === undefined`. RED today; concrete error: `meta.urls is not an Array. Got: undefined`. +- **Tests 3+4+5 (tab-url-tracker module seam):** Each test dynamically imports `src/background/tab-url-tracker` (the planner-suggested module name from Plan 02-03) and `expect.fail`s with a precise GREEN-gate contract message when the module doesn't exist. RED today; module missing. + - Test 3 pins dedup + first-seen-first order: ['A', 'B', 'A'] → ['A', 'B']. + - Test 4 pins URL filter: include https + chrome-extension://; exclude chrome:// + about:. + - Test 5 pins F2 empty-tracker contract: no observations → `getTabUrlsSeen() === []` (NOT undefined, NOT [], NOT null). + +**Commit:** [9e45d33](#) `test(02-01): RED — pin meta.json urls[] schema + dedup/filter + empty-tracker (D-P2-02 + F2)` + +### Task 3: tests/build/strict-meta-json-validation.test.ts (3 RED + 5 GREEN regression guards) + +Pins D-P2-03 strict 8-field meta.json schema validation. + +- **Test 1 (RED): exactly 8 fields.** Current shape has 7. Concrete error: `meta.json has 7 fields; D-P2-03 requires exactly 8. Current keys: [timestamp, url, userAgent, extensionVersion, videoBufferSeconds, logDurationMinutes, totalEvents]`. +- **Test 2 (GREEN regression guard): ISO-8601 Z-suffix timestamp.** Current `new Date().toISOString()` already matches; catches future locale-formatter regressions. +- **Test 3 (RED): urls Array of valid URLs (empty permitted per F2).** Concrete error: `meta.urls is not an Array. Got: undefined`. +- **Test 4 (GREEN regression guard): semver extensionVersion.** Current manifest.version '1.0.0' matches; catches build-hash regressions. +- **Test 5 (GREEN regression guard): non-negative integer totalEvents.** Current sum-of-event-counts matches; catches negative/float regressions. +- **Test 6 (GREEN regression guard): videoBufferSeconds === 30 literal.** CON-video-window pin. +- **Test 7 (GREEN regression guard): logDurationMinutes === 10 literal.** CON-event-log-window pin. +- **Test 8 (RED): no extra fields beyond EXPECTED_KEYS.** Concrete error: `meta.json contains extra (unexpected) fields: ["url"]`. EXPECTED_KEYS = [timestamp, urls, userAgent, extensionVersion, videoBufferSeconds, logDurationMinutes, totalEvents, schemaVersion]. + +**Commit:** [94e0346](#) `test(02-01): RED — pin strict 8-field meta.json schema validation (D-P2-03)` + +## Test count delta + +- **Before:** 155 GREEN (baseline including sw-bundle-import.test.ts after npm run build). +- **After:** 159 GREEN + 11 RED (11 new RED from this plan + the 5 Task-3 GREEN-today regression guards count as +4 net new GREEN since Test 5 was added per F2; see "Plan vs reality" below). +- **Net delta:** +11 RED, +4 GREEN tests (was 155, now 170 vitest-discovered + 1 ffprobe-flaky out-of-scope; see Deferred Issues). + +Vitest reporter final state: +``` +Test Files 4 failed | 27 passed (31) + Tests 12 failed | 159 passed (171) +``` +(12 failed = 3 [Task 1] + 5 [Task 2] + 3 [Task 3 RED] + 1 [pre-existing ffprobe-flaky webm-remux test — out of scope; see Deferred Issues].) + +## Tier-1 grep gate + +`tests/background/no-test-hooks-in-prod-bundle.test.ts` continues to pass with 13/13 GREEN (FORBIDDEN_HOOK_STRINGS at the existing inventory; this plan touches no production code). + +## Plan-vs-reality reconciliation + +### Plan claim: "ALL 8 [strict-validation] tests MUST fail under current HEAD" + +**Reality:** 3 of 8 are RED-today (Tests 1, 3, 8 — the migration-driven assertions). The other 5 are GREEN-today regression guards (timestamp regex, semver, totalEvents, videoBufferSeconds, logDurationMinutes — all of which already match the current 7-field shape). + +**Resolution:** Honored the plan's spirit (8 strict-validation tests pinning the post-migration schema) while being honest about the test states. The 5 GREEN-today tests are not test pollution — they're regression guards that prevent Plan 02-03 from accidentally regressing the unchanged fields when adding `urls` + `schemaVersion`. + +### Plan suggestion: "Use jsdom env (default in vitest.config.ts)" + +**Reality:** vitest.config.ts explicitly sets `environment: 'node'` and jsdom is not in devDependencies. Node 24 ships URL.createObjectURL + URL.revokeObjectURL + performance + Blob as globals (verified at land time), so jsdom is not required. FileReader is the only missing global; a minimal polyfill inline in each test file delegates to Blob.arrayBuffer(). + +### Plan suggestion: "Test 1 fails at the TypeScript-compile layer" + +**Reality:** vitest.config.ts has `typecheck: { enabled: false }`. A tsc-failure-based pin would be a no-op. Replaced with a source-text regex scan of `src/shared/types.ts` that asserts both presence of `urls: string[]` AND absence of `url:` inside the SessionMetadata interface body. + +## Planner-resolved tensions (carried forward into Plans 02-02 + 02-03) + +- **D-P2-03 "non-empty urls[]" vs CONTEXT.md `` permissive empty-array clause:** resolved in favor of the permissive clause (F2 — empty IS PERMITTED for whole-desktop-no-tab sessions). Test 3 of Task 3 enforces the relaxed contract. +- **8th field name `schemaVersion`:** TENTATIVE PLANNER PICK to mark the D-P2-02 url→urls breaking-change cutover. EXPECTED_KEYS constant in `tests/build/strict-meta-json-validation.test.ts` pins this choice; Plan 02-03's implementer COMMITS to adding `schemaVersion: '2'` as a constant in `src/background/index.ts`. If a stronger argument for a different 8th-field name surfaces, this test file is the canonical place to amend. +- **tab-url-tracker module name `src/background/tab-url-tracker.ts`:** PLANNER-SUGGESTED. Plan 02-03's implementer commits to this name; if renaming is desired, all three Task 2 dynamic-import strings need to update lockstep. +- **`_resetForTesting` / `_observeForTesting` ergonomic hooks on tab-url-tracker:** OPTIONAL contract the test file documents. Plan 02-03 implementer SHOULD expose these for Tests 3+4+5 ergonomic wiring; if absent, the tests need to wire chrome.tabs.onUpdated callbacks directly. + +## Forward links + +- **Plan 02-02 (Wave 1 GREEN):** flips Task 1's 3 RED tests by minting the Blob URL in the offscreen document via `URL.createObjectURL`, transferring it to the SW via a new port-bridge message, and registering the `chrome.downloads.onChanged` listener for `URL.revokeObjectURL` lifecycle. +- **Plan 02-03 (Wave 1 GREEN):** flips Task 2's 5 RED tests + Task 3's 3 RED tests by (a) amending `src/shared/types.ts` to swap `url: string` for `urls: string[]`, (b) implementing `src/background/tab-url-tracker.ts` with `getTabUrlsSeen()` fed by chrome.tabs.onUpdated + chrome.tabs.onActivated listeners (DEC-011 Amendment 1 `tabs` permission), (c) amending `createArchive` in `src/background/index.ts` to call `snapshotOpenTabs() + getTabUrlsSeen()` and write `urls` + `schemaVersion: '2'` to meta.json (removing the old `url:` field). +- **Plan 02-04 (Wave 2 UAT extensions):** harness A24+ assertions read the GREEN-flipped meta.json. A28 (added per F1) pins REQ-archive-layout strict zip-layout (5 entries exact). + +## Deferred Issues + +### Pre-existing flaky test in tests/background/webm-remux.test.ts + +The `ffprobe -count_frames reports between 905 and 912 frames inclusive` test (Plan 01-08 Task 2) fails when run as part of the full-suite parallel runner but passes when run in isolation. The failure surface is intermittent; the input fixture (`raw-3ebml-concat.webm`) and `remuxSegments` output are deterministic. Suspected cause: ffprobe spawnSync timing interaction with parallel vitest workers, or transient tmpdir contention. + +- **Out of scope** for this plan per Plan 02-01 §verification: this plan touches no production code and no Phase 1 test file. +- **Scope boundary**: not directly caused by current plan changes (verified by running the test in isolation post-commit). +- **Recommended owner:** Phase 5 hardening OR a Phase 2 closure debug session if the flake recurs during Plans 02-02 / 02-03 execution. If the flake blocks Phase 2 plan-checker or operator empirical UAT, escalate to a /gsd-debug session. + +## Self-Check: PASSED + +### Files created (verified via stat at SUMMARY commit time) + +- FOUND: tests/background/blob-url-download.test.ts (~625 lines) +- FOUND: tests/background/meta-json-urls-schema.test.ts (~671 lines) +- FOUND: tests/build/strict-meta-json-validation.test.ts (~621 lines) + +### Commits exist (verified via git log) + +- FOUND: 748a81f test(02-01): RED — pin Blob URL download contract (D-P2-01) +- FOUND: 9e45d33 test(02-01): RED — pin meta.json urls[] schema + dedup/filter + empty-tracker (D-P2-02 + F2) +- FOUND: 94e0346 test(02-01): RED — pin strict 8-field meta.json schema validation (D-P2-03) + +### Plan §verification gates + +- Task 1: `npx vitest run tests/background/blob-url-download.test.ts` → 3 failed (3) ✓ +- Task 2: `npx vitest run tests/background/meta-json-urls-schema.test.ts` → 5 failed (5) ✓ +- Task 3: `npx vitest run tests/build/strict-meta-json-validation.test.ts` → 3 failed | 5 passed (8) ✓ (5 GREEN = regression guards per plan-vs-reality reconciliation above) +- Skip discipline: `grep -cE "\.skip\(|\.skip$|\.skip[, ]"` returns 0 for all three files ✓ +- Tier-1 grep gate: `npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts` → 13/13 GREEN preserved ✓ + +### Success criteria reconciliation + +1. [x] Three RED test files exist with concrete failure messages under current HEAD (3 + 5 + 3 = 11 RED). +2. [x] `meta-json-urls-schema.test.ts` Test 1 fails (source-text scan; the planner's tsc-compile-failure path was a no-op due to typecheck:{enabled:false}, but the source-text scan is a stronger structural pin that survives the disabled-typecheck config). +3. [x] Tier-1 grep gate (FORBIDDEN_HOOK_STRINGS inventory) remains GREEN (13/13). +4. [x] UAT harness baseline UNCHANGED (test-only changes; this plan touches no production-source code). +5. [x] Commits land with `test(02-01): RED — ...` prefix matching Plan 01-02 / 01-13 RED-test precedent. diff --git a/tests/background/blob-url-download.test.ts b/tests/background/blob-url-download.test.ts new file mode 100644 index 0000000..171b4ff --- /dev/null +++ b/tests/background/blob-url-download.test.ts @@ -0,0 +1,625 @@ +// tests/background/blob-url-download.test.ts +// +// Plan 02-01 Task 1 (RED gate, Wave 0 of Phase 2). +// Pins decision D-P2-01 (offscreen-minted Blob URL download pipeline, +// closes audit P0-6: base64 data: URL → blob: URL migration). +// +// Contract pinned here (RED today; flips GREEN after Plan 02-02): +// +// 1. `downloadArchive()` invokes `chrome.downloads.download` with a `url` +// that starts with `blob:` and does NOT start with +// `data:application/zip;base64,`. The wire format is the cap-free +// Blob URL minted in the offscreen document via `URL.createObjectURL` +// (offscreen has the DOM globals; the SW does not — DEC-006 binding). +// +// 2. A 6 MB archive completes through `downloadArchive()` in under 5 s +// wall-clock AND emits a blob: URL. The current base64 path is +// bounded by Chrome's ~2 MB data-URL cap in the real runtime; the +// vi.fn() chrome.downloads.download stub can't model the rejection +// itself, so this test's gating signal is the WIRE-FORMAT prefix at +// 6 MB scale (latency check is necessary but not sufficient — the +// prefix guard converts a fast spurious pass into a wire-format +// failure under current HEAD). +// +// 3. `URL.revokeObjectURL(url)` is scheduled for the same URL passed to +// `chrome.downloads.download` once `chrome.downloads.onChanged` +// reports `state === 'complete'`. Lifecycle hygiene; without it the +// offscreen page leaks one URL handle per saved archive. +// +// Test architecture: load `src/background/index.ts`, dispatch a +// `SAVE_ARCHIVE` message, resolve the in-flight `REQUEST_BUFFER` with +// synthetic single/multi-segment BUFFERs so `createArchive()` produces a +// real archive Blob and `downloadArchive()` runs. The single +// chrome.downloads.download call's argv0 is then introspected. The +// chrome.downloads.onChanged.addListener stub captures the listener so +// Test 3 can fire the synthetic 'complete' event back at it. +// +// remuxSegments mocking (Test 2): the production muxer rejects +// monotonicity-breaking input shapes, which makes synthetic 6 MB stress +// tests fragile. Test 2 short-circuits remuxSegments via vi.mock to +// return a synthetic 6 MB Blob directly — preserving createArchive's +// post-remux JSZip + downloadArchive code path as the SUT. +// +// Environment: vitest default `node` env. Node 24's globals expose +// URL.createObjectURL + URL.revokeObjectURL + performance, so no jsdom +// override is required (verified at land time). +// +// Skip discipline: NONE — bare `it()` blocks, no skip modifiers. +// Plan 02-01 T-02-01-01 mitigation: this file contains zero +// describe-skip or it-skip method calls. + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve as resolvePath } from 'node:path'; + +// ────────────────────────────────────────────────────────────────────── +// Constants +// ────────────────────────────────────────────────────────────────────── +const LATENCY_BUDGET_MS = 5_000; +const SIX_MEGABYTES = 6 * 1024 * 1024; + +const here = dirname(fileURLToPath(import.meta.url)); +const FIXTURE_PATH = resolvePath( + here, '..', '..', 'tests', 'fixtures', 'raw-3ebml-concat.webm', +); +// Per-EBML offsets (Plan 01-08 Task 2 byte-level probe; mirrored from +// tests/background/webm-remux.test.ts so the 3-slice segmentation is +// muxer-safe — each slice is a self-contained ~10 s WebM with its own +// EBML + Segment + Cluster tree). +const SEG1_START = 0; +const SEG2_START = 509038; +const SEG3_START = 970967; + +// ────────────────────────────────────────────────────────────────────── +// Chrome stub shape (extended from save-archive-does-not-stop-recording +// pattern with chrome.downloads.onChanged listener capture). +// ────────────────────────────────────────────────────────────────────── + +interface PortStub { + name: string; + sender?: { id?: string }; + postMessage: ReturnType; + onMessage: { + addListener: ReturnType; + removeListener: ReturnType; + _listeners: Array<(msg: unknown) => void>; + }; + onDisconnect: { + addListener: (fn: () => void) => void; + _listeners: Array<() => void>; + }; + disconnect: ReturnType; +} + +function makePortStub(name = 'video-keepalive'): PortStub { + const port: PortStub = { + name, + sender: { id: 'ext-id-test' }, + postMessage: vi.fn(), + onMessage: { + addListener: vi.fn(), + removeListener: vi.fn(), + _listeners: [], + }, + onDisconnect: { + addListener: (fn: () => void) => { + port.onDisconnect._listeners.push(fn); + }, + _listeners: [], + }, + disconnect: vi.fn(), + }; + port.onMessage.addListener.mockImplementation( + (fn: (msg: unknown) => void) => { + port.onMessage._listeners.push(fn); + }, + ); + port.onMessage.removeListener.mockImplementation( + (fn: (msg: unknown) => void) => { + const idx = port.onMessage._listeners.indexOf(fn); + if (idx >= 0) { + port.onMessage._listeners.splice(idx, 1); + } + }, + ); + return port; +} + +interface OnConnectCallback { (port: PortStub): void; } +interface OnMessageCallback { + (msg: unknown, sender: { id?: string }, sendResponse: (r: unknown) => void): boolean; +} +interface OnClickedCallback { (info?: unknown): void | Promise; } +interface DownloadDelta { + id: number; + state?: { current: 'in_progress' | 'complete' | 'interrupted' }; +} +interface DownloadsOnChangedCallback { (delta: DownloadDelta): void; } + +interface BgChromeStub { + runtime: { + id: string; + getURL: (p: string) => string; + getManifest: () => { version: string }; + sendMessage: ReturnType; + onMessage: { addListener: ReturnType; _callbacks: OnMessageCallback[] }; + onConnect: { addListener: (cb: OnConnectCallback) => void; _callbacks: OnConnectCallback[] }; + onInstalled: { addListener: ReturnType }; + onStartup: { addListener: (cb: () => void) => void; _callbacks: Array<() => void> }; + }; + offscreen: { + hasDocument?: () => Promise; + createDocument: ReturnType; + Reason: { DISPLAY_MEDIA: 'DISPLAY_MEDIA' }; + }; + tabs: { + query: ReturnType; + sendMessage: ReturnType; + captureVisibleTab: ReturnType; + }; + downloads: { + download: ReturnType; + onChanged: { + addListener: ReturnType; + _callbacks: DownloadsOnChangedCallback[]; + }; + }; + action: { + onClicked: { addListener: (cb: OnClickedCallback) => void; _callbacks: OnClickedCallback[] }; + setPopup: ReturnType; + setBadgeText: ReturnType; + setBadgeBackgroundColor: ReturnType; + setTitle: ReturnType; + }; + notifications: { + create: ReturnType; + clear: ReturnType; + onClicked: { + addListener: ReturnType; + _callbacks: Array<(id: string) => void>; + }; + }; +} + +interface GlobalWithBgChrome { + chrome?: BgChromeStub; + indexedDB?: { deleteDatabase: ReturnType }; + fetch?: typeof fetch; +} + +function buildBgStub(): BgChromeStub { + const stub: BgChromeStub = { + runtime: { + id: 'ext-id-test', + getURL: (p) => `chrome-extension://ext-id-test/${p}`, + getManifest: () => ({ version: '1.0.0' }), + sendMessage: vi.fn().mockResolvedValue(undefined), + onMessage: { addListener: vi.fn(), _callbacks: [] }, + onConnect: { + addListener: (cb) => stub.runtime.onConnect._callbacks.push(cb), + _callbacks: [], + }, + onInstalled: { addListener: vi.fn() }, + onStartup: { + addListener: (cb) => stub.runtime.onStartup._callbacks.push(cb), + _callbacks: [], + }, + }, + offscreen: { + hasDocument: async () => false, + createDocument: vi.fn().mockResolvedValue(undefined), + Reason: { DISPLAY_MEDIA: 'DISPLAY_MEDIA' }, + }, + tabs: { + query: vi.fn().mockResolvedValue([ + { id: 1, url: 'https://example.com', windowId: 100 }, + ]), + sendMessage: vi.fn().mockResolvedValue({ events: [], userEvents: [] }), + captureVisibleTab: vi.fn().mockResolvedValue( + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', + ), + }, + downloads: { + download: vi.fn().mockResolvedValue(42), + onChanged: { + addListener: vi.fn(), + _callbacks: [], + }, + }, + action: { + onClicked: { + addListener: (cb) => stub.action.onClicked._callbacks.push(cb), + _callbacks: [], + }, + setPopup: vi.fn(), + setBadgeText: vi.fn(), + setBadgeBackgroundColor: vi.fn(), + setTitle: vi.fn(), + }, + notifications: { + create: vi.fn(), + clear: vi.fn(), + onClicked: { + addListener: vi.fn(), + _callbacks: [], + }, + }, + }; + stub.runtime.onMessage.addListener.mockImplementation((cb: OnMessageCallback) => { + stub.runtime.onMessage._callbacks.push(cb); + }); + stub.downloads.onChanged.addListener.mockImplementation( + (cb: DownloadsOnChangedCallback) => { + stub.downloads.onChanged._callbacks.push(cb); + }, + ); + stub.notifications.onClicked.addListener.mockImplementation( + (cb: (id: string) => void) => { + stub.notifications.onClicked._callbacks.push(cb); + }, + ); + return stub; +} + +async function stubFetch(_url: string): Promise { + const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + return { + blob: async () => new Blob([png], { type: 'image/png' }), + } as unknown as Response; +} + +// ────────────────────────────────────────────────────────────────────── +// Minimal FileReader polyfill — JSZip's Blob ingestion path calls +// `new FileReader().readAsArrayBuffer(blob)` to materialise the bytes. +// Node 24 ships native Blob (so JSZip's `support.blob` returns true) but +// does NOT ship FileReader, so the ingestion throws "Can't read the +// data of ''". The polyfill below delegates to Blob.arrayBuffer() +// (which IS available in Node 24) and dispatches onload synchronously +// on the microtask tick. +// +// This polyfill is scoped to the test process (set on globalThis); it +// does not leak into production builds because tests run under vitest +// in a separate Node process. +// ────────────────────────────────────────────────────────────────────── +class FileReaderPolyfill { + result: ArrayBuffer | string | null = null; + onload: ((ev: { target: FileReaderPolyfill }) => void) | null = null; + onerror: ((ev: { target: FileReaderPolyfill; error: unknown }) => void) | null = null; + readyState = 0; + + readAsArrayBuffer(blob: Blob): void { + blob.arrayBuffer() + .then((ab) => { + this.result = ab; + this.readyState = 2; + if (this.onload) this.onload({ target: this }); + }) + .catch((err) => { + this.readyState = 2; + if (this.onerror) this.onerror({ target: this, error: err }); + }); + } +} + +function installFileReaderPolyfill(): void { + if (typeof (globalThis as { FileReader?: unknown }).FileReader === 'undefined') { + (globalThis as { FileReader?: unknown }).FileReader = FileReaderPolyfill; + } +} + +// ────────────────────────────────────────────────────────────────────── +// Three-slice fixture segments (each is a self-contained ~10 s WebM — +// muxer-safe). Mirrors splitFixtureIntoSegments() in webm-remux.test.ts. +// ────────────────────────────────────────────────────────────────────── +function buildThreeSliceSegmentsBase64(): Array<{ data: string; type: string; timestamp: number }> { + const buf = readFileSync(FIXTURE_PATH); + const slices = [ + buf.subarray(SEG1_START, SEG2_START), + buf.subarray(SEG2_START, SEG3_START), + buf.subarray(SEG3_START, buf.length), + ]; + return slices.map((slice, i) => ({ + data: Buffer.from(slice).toString('base64'), + type: 'video/webm;codecs=vp9', + timestamp: 1_000_000 + i * 10_000, + })); +} + +/** + * Drive a SAVE_ARCHIVE flow to completion. Resolves the in-flight + * REQUEST_BUFFER with the three-slice fixture segments. + * + * @param stub - Wired chrome stub. + * @param port - Connected offscreen port. + * @returns The chrome.downloads.download arg0 payload, or undefined if + * no call landed within the drain window. + */ +async function runSaveAndCaptureDownloadArg( + stub: BgChromeStub, + port: PortStub, +): Promise<{ url: string; filename: string; saveAs?: boolean } | undefined> { + const segments = buildThreeSliceSegmentsBase64(); + + let buffered = false; + const tryFireBuffer = () => { + if (buffered) return; + const reqCalls = port.postMessage.mock.calls.filter( + (c: unknown[]) => + typeof c[0] === 'object' && + c[0] !== null && + (c[0] as { type?: unknown }).type === 'REQUEST_BUFFER', + ); + if (reqCalls.length === 0) return; + const reqMsg = reqCalls[0][0] as { requestId?: string }; + buffered = true; + port.onMessage._listeners.forEach((fn) => + fn({ + type: 'BUFFER', + requestId: reqMsg.requestId, + segments, + }), + ); + }; + + const onMsg = stub.runtime.onMessage._callbacks[0]; + onMsg( + { type: 'SAVE_ARCHIVE' }, + { id: 'ext-id-test' }, + () => undefined, + ); + + const DRAIN_ITERATIONS = 1_500; + for (let i = 0; i < DRAIN_ITERATIONS; i++) { + tryFireBuffer(); + if (stub.downloads.download.mock.calls.length > 0) break; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + + const calls = stub.downloads.download.mock.calls; + if (calls.length === 0) return undefined; + return calls[0][0] as { url: string; filename: string; saveAs?: boolean }; +} + +/** + * Load SW module, wire stubs, connect the offscreen port, and put the SW + * into REC state via toolbar onClicked. + */ +async function bootSwInRecState(stub: BgChromeStub): Promise { + installFileReaderPolyfill(); + (globalThis as unknown as GlobalWithBgChrome).chrome = stub; + (globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn() }; + (globalThis as unknown as GlobalWithBgChrome).fetch = stubFetch as unknown as typeof fetch; + + await import('../../src/background/index'); + for (let i = 0; i < 8; i++) await Promise.resolve(); + + const port = makePortStub(); + if (stub.runtime.onConnect._callbacks.length > 0) { + stub.runtime.onConnect._callbacks[0](port); + } + + const onMsg = stub.runtime.onMessage._callbacks[0]; + onMsg({ type: 'OFFSCREEN_READY' }, { id: 'ext-id-test' }, () => undefined); + for (let i = 0; i < 16; i++) await Promise.resolve(); + + const onClicked = stub.action.onClicked._callbacks[0]; + await onClicked(); + for (let i = 0; i < 32; i++) await Promise.resolve(); + + return port; +} + +// ────────────────────────────────────────────────────────────────────── +// Tests +// ────────────────────────────────────────────────────────────────────── + +describe('Plan 02-01 Task 1 RED: downloadArchive uses blob: URL (D-P2-01)', () => { + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + delete (globalThis as unknown as GlobalWithBgChrome).chrome; + }); + + // ──────────────────────────────────────────────────────────────────── + // Test 1 — WIRE-FORMAT pin. The single chrome.downloads.download call + // MUST carry a `url` starting with `blob:`. RED today because + // downloadArchive (src/background/index.ts:695-718) still mints + // `data:application/zip;base64,${base64}` via blobToBase64 + + // chrome.downloads.download. GREEN after Plan 02-02 swaps that for an + // offscreen-minted Blob URL. + // ──────────────────────────────────────────────────────────────────── + it('calls chrome.downloads.download with a blob: URL and NOT a data:application/zip;base64, URL', async () => { + const stub = buildBgStub(); + const port = await bootSwInRecState(stub); + + const downloadArg = await runSaveAndCaptureDownloadArg(stub, port); + + expect( + downloadArg, + 'chrome.downloads.download was not called within the drain window. ' + + 'Either SAVE_ARCHIVE never reached downloadArchive (BUFFER routing broken) or ' + + 'the SW module structure changed — investigate before assuming this is the RED signal for D-P2-01.', + ).toBeDefined(); + + const url = downloadArg!.url; + + expect( + url.startsWith('data:application/zip;base64,'), + `chrome.downloads.download was called with url='${url.substring(0, 60)}...' ` + + `but D-P2-01 forbids data:application/zip;base64, prefix (Chrome's ~2 MB cap ` + + `weaponises P0-6). This polarity guard makes the RED→GREEN flip explicit.`, + ).toBe(false); + + expect( + url.startsWith('blob:'), + `chrome.downloads.download was called with url='${url.substring(0, 60)}...' ` + + `but D-P2-01 requires url.startsWith('blob:'). RED today because downloadArchive ` + + `still mints data:application/zip;base64,... — flips GREEN after Plan 02-02 ` + + `swaps to offscreen-minted Blob URL.`, + ).toBe(true); + }); + + // ──────────────────────────────────────────────────────────────────── + // Test 2 — LATENCY + WIRE-FORMAT at the 6 MB scale. + // + // The synthetic 6 MB archive is injected by mocking remuxSegments + // before the SW module loads. createArchive then assembles a zip + // containing one ~6 MB "video/last_30sec.webm" entry plus meta.json + // + rrweb + events + screenshot — the zip exceeds 6 MB. Under + // Chrome's real ~2 MB data-URL cap this would reject; the vi.fn() + // stub can't model rejection, so the test's load-bearing assertion is + // the wire-format prefix at 6 MB scale (latency check captures the + // base64-encoding regression vector but is necessary-not-sufficient). + // + // RED today (data: URL prefix + variable latency under current path). + // GREEN after Plan 02-02 (blob: URL prefix; no encoding bottleneck). + // ──────────────────────────────────────────────────────────────────── + it('completes downloadArchive within 5000ms for a 6 MB archive AND emits a blob: URL', { timeout: 30_000 }, async () => { + // Pre-import: mock remuxSegments to short-circuit the muxer and + // return a synthetic 6 MB Blob directly. createArchive's downstream + // JSZip pack-up code + downloadArchive then run unchanged. + vi.doMock('../../src/background/webm-remux', () => ({ + remuxSegments: vi.fn(async (_segments: unknown[]) => { + const sixMb = new Uint8Array(SIX_MEGABYTES); + return new Blob([sixMb], { type: 'video/webm' }); + }), + })); + + const stub = buildBgStub(); + const port = await bootSwInRecState(stub); + + // Any non-empty segments array passes the createArchive empty-buffer + // guard; the mocked remuxSegments ignores the input shape. The data + // payload is a 4-byte dummy ("AAAA" base64-decoded) so the SW-side + // filter in getVideoBufferFromOffscreen (which drops zero-size + // segments per src/shared/binary.ts WR-07 fix) keeps the segment in + // the array. + const segments = [{ + data: 'AAAA', + type: 'video/webm;codecs=vp9', + timestamp: 1_000_000, + }]; + + const onMsg = stub.runtime.onMessage._callbacks[0]; + onMsg( + { type: 'SAVE_ARCHIVE' }, + { id: 'ext-id-test' }, + () => undefined, + ); + + let buffered = false; + const t0 = performance.now(); + const DRAIN_ITERATIONS = 1_500; + for (let i = 0; i < DRAIN_ITERATIONS; i++) { + if (!buffered) { + const reqCalls = port.postMessage.mock.calls.filter( + (c: unknown[]) => + typeof c[0] === 'object' && + c[0] !== null && + (c[0] as { type?: unknown }).type === 'REQUEST_BUFFER', + ); + if (reqCalls.length > 0) { + const reqMsg = reqCalls[0][0] as { requestId?: string }; + buffered = true; + port.onMessage._listeners.forEach((fn) => + fn({ + type: 'BUFFER', + requestId: reqMsg.requestId, + segments, + }), + ); + } + } + if (stub.downloads.download.mock.calls.length > 0) break; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + const elapsed = performance.now() - t0; + + expect( + stub.downloads.download.mock.calls.length, + `chrome.downloads.download was never called for the 6 MB archive after ${elapsed.toFixed(0)} ms ` + + `drain. Valid RED mode under current base64 path — the EmptyVideoBufferError throw or ` + + `the base64 encoding bottleneck can both gate the call.`, + ).toBeGreaterThan(0); + + expect( + elapsed, + `downloadArchive end-to-end took ${elapsed.toFixed(0)} ms for a 6 MB payload. ` + + `REQ-archive-export-latency floor is ${LATENCY_BUDGET_MS} ms. Above floor = ` + + `base64 encoding bottleneck (latency RED signal); below floor + wrong wire format ` + + `= polarity-guard RED signal.`, + ).toBeLessThan(LATENCY_BUDGET_MS); + + const downloadArg = stub.downloads.download.mock.calls[0][0] as { url: string }; + expect( + downloadArg.url.startsWith('blob:'), + `chrome.downloads.download was called with url='${downloadArg.url.substring(0, 60)}...' ` + + `at the 6 MB scale — D-P2-01 requires blob: prefix. The wire-format pin holds ` + + `regardless of vi.fn() rejection modeling: current HEAD's data:application/zip;base64,... ` + + `path fails this assertion under any payload size.`, + ).toBe(true); + + vi.doUnmock('../../src/background/webm-remux'); + }); + + // ──────────────────────────────────────────────────────────────────── + // Test 3 — REVOKE LIFECYCLE. URL.revokeObjectURL is scheduled for the + // same url passed to chrome.downloads.download once + // chrome.downloads.onChanged fires with state.current === 'complete'. + // + // RED today: the current downloadArchive does NOT mint a Blob URL at + // all AND does NOT register an onChanged listener, so revokeObjectURL + // is never called regardless of whether the synthetic 'complete' + // event fires. Plan 02-02 wires both the mint AND the revoke-on- + // complete listener. + // ──────────────────────────────────────────────────────────────────── + it('schedules URL.revokeObjectURL with the minted url after chrome.downloads.onChanged complete', async () => { + const stub = buildBgStub(); + const port = await bootSwInRecState(stub); + + const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + + try { + const downloadArg = await runSaveAndCaptureDownloadArg(stub, port); + expect( + downloadArg, + 'chrome.downloads.download never called — cannot probe revoke lifecycle.', + ).toBeDefined(); + + const mintedUrl = downloadArg!.url; + const downloadId = (await stub.downloads.download.mock.results[0].value) as number; + + // RED today: chrome.downloads.onChanged._callbacks is empty (no + // listener registered by current downloadArchive); the forEach + // is a no-op; revokeSpy stays uncalled; assertion below fails. + stub.downloads.onChanged._callbacks.forEach((cb) => + cb({ id: downloadId, state: { current: 'complete' } }), + ); + + for (let i = 0; i < 32; i++) await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect( + revokeSpy.mock.calls.length, + `URL.revokeObjectURL was never called after chrome.downloads.onChanged 'complete' fired. ` + + `D-P2-01 lifecycle: SW (or offscreen) MUST revoke the minted blob: URL once the ` + + `download finishes to avoid handle leaks. RED expected because the current SW does ` + + `not register an onChanged listener (chrome.downloads.onChanged._callbacks.length === ` + + `${stub.downloads.onChanged._callbacks.length} at probe time).`, + ).toBeGreaterThan(0); + + expect( + revokeSpy.mock.calls.some( + (args: unknown[]) => args[0] === mintedUrl, + ), + `URL.revokeObjectURL was called but with the wrong URL. Expected: '${mintedUrl}'. ` + + `Got: ${JSON.stringify(revokeSpy.mock.calls.map((c: unknown[]) => c[0]))}. ` + + `The mint/revoke pair must be id-coupled — revoking a different URL leaks ` + + `the current one and double-revokes a stale one.`, + ).toBe(true); + } finally { + revokeSpy.mockRestore(); + } + }); +}); diff --git a/tests/background/meta-json-urls-schema.test.ts b/tests/background/meta-json-urls-schema.test.ts new file mode 100644 index 0000000..aa5a2d1 --- /dev/null +++ b/tests/background/meta-json-urls-schema.test.ts @@ -0,0 +1,671 @@ +// tests/background/meta-json-urls-schema.test.ts +// +// Plan 02-01 Task 2 (RED gate, Wave 0 of Phase 2). +// Pins decision D-P2-02 (meta.json schema migrates `url: string` → +// `urls: string[]`) plus the F2 plan-checker-iter-1 resolution +// (empty-tracker case → `urls: []`, NO sentinel fallback). +// +// Contract pinned here (RED today; flips GREEN after Plan 02-03): +// +// 1. The SessionMetadata interface in src/shared/types.ts has a +// `urls: string[]` field AND does NOT have a `url:` field. +// Structural source-text scan; vitest.config.ts has +// typecheck:{enabled:false}, so a tsc-failure-based pin would be +// a no-op. The source-text scan is RED today because line 105 of +// src/shared/types.ts reads `url: string;`. +// +// 2. createArchive emits meta.json with `urls: string[]` (Array) +// AND `url` is undefined. The current createArchive code path +// assembles `metadata.url = new URL(chrome.runtime.getURL('')) +// .origin`, so meta.url is the extension origin and meta.urls is +// undefined — both assertions fail. +// +// 3. meta.urls is deduplicated and ordered first-seen-first. Driven +// via the tab-url-tracker module (planner-suggested name: +// `src/background/tab-url-tracker.ts`, export name: +// `getTabUrlsSeen(): string[]`). The module does not exist at +// current HEAD; the dynamic import throws and expect.fail surfaces +// the missing-module RED signal. +// +// 4. meta.urls filters chrome:// and about: URLs and includes +// chrome-extension:// URLs (per CONTEXT.md `` URL +// filter). Same tab-url-tracker dependency as Test 3. +// +// 5. Empty-tracker case (no browser interaction during recording) → +// meta.urls === [] (exact empty Array; NOT undefined, NOT +// `[extension-origin]`, NOT `null`). F2 (plan-checker iteration 1) +// resolution: the empty Array IS the canonical representation of a +// whole-desktop-no-tab session. Same tab-url-tracker dependency. +// +// Module seam (Plan 02-03 will implement): +// src/background/tab-url-tracker.ts +// export function getTabUrlsSeen(): string[] +// +// Tab tracking is fed by chrome.tabs.onUpdated + chrome.tabs.onActivated +// listeners (DEC-011 Amendment 1 grants the `tabs` permission for this +// purpose). The Set is dedup-on-add; iteration order preserves first- +// seen-first. +// +// Test architecture: same SW-driving pattern as +// tests/background/blob-url-download.test.ts (load SW module via +// dynamic import; wire chrome stub; drive SAVE_ARCHIVE via the +// runtime.onMessage callback; capture the archive Blob from +// chrome.downloads.download arg0; unzip with JSZip; JSON.parse the +// meta.json entry). +// +// Skip discipline: NONE — bare it() blocks, no skip modifiers. +// Plan 02-01 T-02-01-01 mitigation. + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve as resolvePath } from 'node:path'; +import JSZip from 'jszip'; + +// ────────────────────────────────────────────────────────────────────── +// Constants — match the canonical fixture-slice offsets used in +// tests/background/webm-remux.test.ts and blob-url-download.test.ts. +// ────────────────────────────────────────────────────────────────────── + +const here = dirname(fileURLToPath(import.meta.url)); +const FIXTURE_PATH = resolvePath( + here, '..', '..', 'tests', 'fixtures', 'raw-3ebml-concat.webm', +); +const TYPES_PATH = resolvePath( + here, '..', '..', 'src', 'shared', 'types.ts', +); +const SEG1_START = 0; +const SEG2_START = 509038; +const SEG3_START = 970967; + +// ────────────────────────────────────────────────────────────────────── +// Chrome stub — same shape as blob-url-download.test.ts. Plan 02-03 may +// extend with chrome.tabs.onUpdated + chrome.tabs.onActivated wiring +// for the tab-url-tracker module; this RED gate stays minimal. +// ────────────────────────────────────────────────────────────────────── + +interface PortStub { + name: string; + sender?: { id?: string }; + postMessage: ReturnType; + onMessage: { + addListener: ReturnType; + removeListener: ReturnType; + _listeners: Array<(msg: unknown) => void>; + }; + onDisconnect: { + addListener: (fn: () => void) => void; + _listeners: Array<() => void>; + }; + disconnect: ReturnType; +} + +function makePortStub(name = 'video-keepalive'): PortStub { + const port: PortStub = { + name, + sender: { id: 'ext-id-test' }, + postMessage: vi.fn(), + onMessage: { + addListener: vi.fn(), + removeListener: vi.fn(), + _listeners: [], + }, + onDisconnect: { + addListener: (fn: () => void) => { + port.onDisconnect._listeners.push(fn); + }, + _listeners: [], + }, + disconnect: vi.fn(), + }; + port.onMessage.addListener.mockImplementation( + (fn: (msg: unknown) => void) => { + port.onMessage._listeners.push(fn); + }, + ); + port.onMessage.removeListener.mockImplementation( + (fn: (msg: unknown) => void) => { + const idx = port.onMessage._listeners.indexOf(fn); + if (idx >= 0) { + port.onMessage._listeners.splice(idx, 1); + } + }, + ); + return port; +} + +interface OnConnectCallback { (port: PortStub): void; } +interface OnMessageCallback { + (msg: unknown, sender: { id?: string }, sendResponse: (r: unknown) => void): boolean; +} +interface OnClickedCallback { (info?: unknown): void | Promise; } +type TabUpdatedCb = (tabId: number, change: { url?: string }, tab: { url?: string }) => void; +type TabActivatedCb = (info: { tabId: number; windowId: number }) => void; + +interface BgChromeStub { + runtime: { + id: string; + getURL: (p: string) => string; + getManifest: () => { version: string }; + sendMessage: ReturnType; + onMessage: { addListener: ReturnType; _callbacks: OnMessageCallback[] }; + onConnect: { addListener: (cb: OnConnectCallback) => void; _callbacks: OnConnectCallback[] }; + onInstalled: { addListener: ReturnType }; + onStartup: { addListener: (cb: () => void) => void; _callbacks: Array<() => void> }; + }; + offscreen: { + hasDocument?: () => Promise; + createDocument: ReturnType; + Reason: { DISPLAY_MEDIA: 'DISPLAY_MEDIA' }; + }; + tabs: { + query: ReturnType; + sendMessage: ReturnType; + captureVisibleTab: ReturnType; + get?: ReturnType; + onUpdated: { addListener: (cb: TabUpdatedCb) => void; _callbacks: TabUpdatedCb[] }; + onActivated: { addListener: (cb: TabActivatedCb) => void; _callbacks: TabActivatedCb[] }; + }; + downloads: { + download: ReturnType; + onChanged: { + addListener: ReturnType; + _callbacks: Array<(delta: { id: number; state?: { current: string } }) => void>; + }; + }; + action: { + onClicked: { addListener: (cb: OnClickedCallback) => void; _callbacks: OnClickedCallback[] }; + setPopup: ReturnType; + setBadgeText: ReturnType; + setBadgeBackgroundColor: ReturnType; + setTitle: ReturnType; + }; + notifications: { + create: ReturnType; + clear: ReturnType; + onClicked: { + addListener: ReturnType; + _callbacks: Array<(id: string) => void>; + }; + }; +} + +interface GlobalWithBgChrome { + chrome?: BgChromeStub; + indexedDB?: { deleteDatabase: ReturnType }; + fetch?: typeof fetch; +} + +function buildBgStub(): BgChromeStub { + const stub: BgChromeStub = { + runtime: { + id: 'ext-id-test', + getURL: (p) => `chrome-extension://ext-id-test/${p}`, + getManifest: () => ({ version: '1.0.0' }), + sendMessage: vi.fn().mockResolvedValue(undefined), + onMessage: { addListener: vi.fn(), _callbacks: [] }, + onConnect: { + addListener: (cb) => stub.runtime.onConnect._callbacks.push(cb), + _callbacks: [], + }, + onInstalled: { addListener: vi.fn() }, + onStartup: { + addListener: (cb) => stub.runtime.onStartup._callbacks.push(cb), + _callbacks: [], + }, + }, + offscreen: { + hasDocument: async () => false, + createDocument: vi.fn().mockResolvedValue(undefined), + Reason: { DISPLAY_MEDIA: 'DISPLAY_MEDIA' }, + }, + tabs: { + query: vi.fn().mockResolvedValue([ + { id: 1, url: 'https://example.com', windowId: 100 }, + ]), + sendMessage: vi.fn().mockResolvedValue({ events: [], userEvents: [] }), + captureVisibleTab: vi.fn().mockResolvedValue( + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', + ), + get: vi.fn(async (tabId: number) => ({ id: tabId, url: 'https://example.com' })), + onUpdated: { + addListener: (cb) => stub.tabs.onUpdated._callbacks.push(cb), + _callbacks: [], + }, + onActivated: { + addListener: (cb) => stub.tabs.onActivated._callbacks.push(cb), + _callbacks: [], + }, + }, + downloads: { + download: vi.fn().mockResolvedValue(42), + onChanged: { + addListener: vi.fn(), + _callbacks: [], + }, + }, + action: { + onClicked: { + addListener: (cb) => stub.action.onClicked._callbacks.push(cb), + _callbacks: [], + }, + setPopup: vi.fn(), + setBadgeText: vi.fn(), + setBadgeBackgroundColor: vi.fn(), + setTitle: vi.fn(), + }, + notifications: { + create: vi.fn(), + clear: vi.fn(), + onClicked: { + addListener: vi.fn(), + _callbacks: [], + }, + }, + }; + stub.runtime.onMessage.addListener.mockImplementation((cb: OnMessageCallback) => { + stub.runtime.onMessage._callbacks.push(cb); + }); + stub.downloads.onChanged.addListener.mockImplementation( + (cb: (delta: { id: number; state?: { current: string } }) => void) => { + stub.downloads.onChanged._callbacks.push(cb); + }, + ); + stub.notifications.onClicked.addListener.mockImplementation( + (cb: (id: string) => void) => { + stub.notifications.onClicked._callbacks.push(cb); + }, + ); + return stub; +} + +async function stubFetch(_url: string): Promise { + const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + return { + blob: async () => new Blob([png], { type: 'image/png' }), + } as unknown as Response; +} + +// FileReader polyfill — see blob-url-download.test.ts rationale. +class FileReaderPolyfill { + result: ArrayBuffer | string | null = null; + onload: ((ev: { target: FileReaderPolyfill }) => void) | null = null; + onerror: ((ev: { target: FileReaderPolyfill; error: unknown }) => void) | null = null; + readyState = 0; + + readAsArrayBuffer(blob: Blob): void { + blob.arrayBuffer() + .then((ab) => { + this.result = ab; + this.readyState = 2; + if (this.onload) this.onload({ target: this }); + }) + .catch((err) => { + this.readyState = 2; + if (this.onerror) this.onerror({ target: this, error: err }); + }); + } +} + +function installFileReaderPolyfill(): void { + if (typeof (globalThis as { FileReader?: unknown }).FileReader === 'undefined') { + (globalThis as { FileReader?: unknown }).FileReader = FileReaderPolyfill; + } +} + +function buildThreeSliceSegmentsBase64(): Array<{ data: string; type: string; timestamp: number }> { + const buf = readFileSync(FIXTURE_PATH); + const slices = [ + buf.subarray(SEG1_START, SEG2_START), + buf.subarray(SEG2_START, SEG3_START), + buf.subarray(SEG3_START, buf.length), + ]; + return slices.map((slice, i) => ({ + data: Buffer.from(slice).toString('base64'), + type: 'video/webm;codecs=vp9', + timestamp: 1_000_000 + i * 10_000, + })); +} + +/** + * Drive a SAVE_ARCHIVE flow and capture the archive Blob from the + * chrome.downloads.download arg0. Decodes the current data: URL into a + * Buffer → wraps in a Blob → returns it. Plan 02-02 swaps to blob: + * URLs; this helper handles both — if the URL is data:, base64-decode; + * otherwise return undefined (Plan 02-02 era; this test won't reach + * that branch under current HEAD). + * + * @param stub - Wired chrome stub. + * @param port - Connected offscreen port. + * @returns The archive Blob, or undefined if no download was issued. + */ +async function runSaveAndCaptureArchiveBlob( + stub: BgChromeStub, + port: PortStub, +): Promise { + const segments = buildThreeSliceSegmentsBase64(); + + let buffered = false; + const tryFireBuffer = () => { + if (buffered) return; + const reqCalls = port.postMessage.mock.calls.filter( + (c: unknown[]) => + typeof c[0] === 'object' && + c[0] !== null && + (c[0] as { type?: unknown }).type === 'REQUEST_BUFFER', + ); + if (reqCalls.length === 0) return; + const reqMsg = reqCalls[0][0] as { requestId?: string }; + buffered = true; + port.onMessage._listeners.forEach((fn) => + fn({ + type: 'BUFFER', + requestId: reqMsg.requestId, + segments, + }), + ); + }; + + const onMsg = stub.runtime.onMessage._callbacks[0]; + onMsg( + { type: 'SAVE_ARCHIVE' }, + { id: 'ext-id-test' }, + () => undefined, + ); + + const DRAIN_ITERATIONS = 1_500; + for (let i = 0; i < DRAIN_ITERATIONS; i++) { + tryFireBuffer(); + if (stub.downloads.download.mock.calls.length > 0) break; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + + if (stub.downloads.download.mock.calls.length === 0) return undefined; + const arg = stub.downloads.download.mock.calls[0][0] as { url: string }; + // Current HEAD: data:application/zip;base64,. Base64-decode. + const DATA_PREFIX = 'data:application/zip;base64,'; + if (arg.url.startsWith(DATA_PREFIX)) { + const b64 = arg.url.substring(DATA_PREFIX.length); + const bytes = Buffer.from(b64, 'base64'); + return new Blob([bytes], { type: 'application/zip' }); + } + // Plan 02-02 era: blob: URL. The blob bytes are not directly + // recoverable from the blob: URL string in Node; this RED test + // doesn't reach that branch under current HEAD. The Plan 02-03 GREEN + // implementer will likely need a different helper (e.g. spy on + // URL.createObjectURL to capture the underlying Blob reference). + return undefined; +} + +async function bootSwInRecState(stub: BgChromeStub): Promise { + installFileReaderPolyfill(); + (globalThis as unknown as GlobalWithBgChrome).chrome = stub; + (globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn() }; + (globalThis as unknown as GlobalWithBgChrome).fetch = stubFetch as unknown as typeof fetch; + + await import('../../src/background/index'); + for (let i = 0; i < 8; i++) await Promise.resolve(); + + const port = makePortStub(); + if (stub.runtime.onConnect._callbacks.length > 0) { + stub.runtime.onConnect._callbacks[0](port); + } + + const onMsg = stub.runtime.onMessage._callbacks[0]; + onMsg({ type: 'OFFSCREEN_READY' }, { id: 'ext-id-test' }, () => undefined); + for (let i = 0; i < 16; i++) await Promise.resolve(); + + const onClicked = stub.action.onClicked._callbacks[0]; + await onClicked(); + for (let i = 0; i < 32; i++) await Promise.resolve(); + + return port; +} + +/** + * Extract the SessionMetadata block (lines after `export interface + * SessionMetadata {` up to the closing `}`) from src/shared/types.ts. + * Used by Test 1 to assert the field-level migration RED today. + * + * @returns The interface body text, or '' on read failure. + */ +function readSessionMetadataInterface(): string { + const text = readFileSync(TYPES_PATH, 'utf8'); + const startMarker = 'export interface SessionMetadata {'; + const start = text.indexOf(startMarker); + if (start < 0) return ''; + const tail = text.substring(start); + const end = tail.indexOf('\n}'); + if (end < 0) return ''; + return tail.substring(0, end + 2); +} + +// ────────────────────────────────────────────────────────────────────── +// Tests +// ────────────────────────────────────────────────────────────────────── + +describe('Plan 02-01 Task 2 RED: meta.json urls[] schema + dedup/filter + empty-tracker (D-P2-02 + F2)', () => { + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + delete (globalThis as unknown as GlobalWithBgChrome).chrome; + }); + + // ──────────────────────────────────────────────────────────────────── + // Test 1 — TYPE-SHAPE pin (source-text scan). + // + // vitest.config.ts sets typecheck:{enabled:false}, so a tsc-failure + // pin would be a no-op (the test would always GREEN under the runtime + // expect-true-toBe-true escape hatch the plan suggested). Instead, + // the RED signal is a textual scan of src/shared/types.ts: the + // SessionMetadata interface body MUST contain `urls:` and MUST NOT + // contain `url:` (singular). RED today because the current types.ts + // (line 105) reads `url: string;` and has no `urls` field. + // ──────────────────────────────────────────────────────────────────── + it('SessionMetadata interface in src/shared/types.ts declares urls: string[] and has no url: field', () => { + const interfaceBody = readSessionMetadataInterface(); + expect( + interfaceBody.length, + `Could not locate 'export interface SessionMetadata {' in src/shared/types.ts. ` + + `If the file was restructured, the regex anchor in readSessionMetadataInterface() ` + + `needs to be updated alongside.`, + ).toBeGreaterThan(0); + + // RED signal #1: the new `urls:` field must be present. + expect( + /\burls\s*:\s*string\[\]/.test(interfaceBody), + `SessionMetadata interface body does not contain a 'urls: string[]' field. ` + + `D-P2-02 requires this field replace the old 'url: string'. RED today; ` + + `Plan 02-03 amends src/shared/types.ts. Current body:\n${interfaceBody}`, + ).toBe(true); + + // RED signal #2: the old `url:` field must be gone (the line-level + // regex bounds avoid matching the substring 'url' inside 'urls'). + expect( + /^\s*url\s*:/m.test(interfaceBody), + `SessionMetadata interface body still contains the old 'url:' (singular) field. ` + + `D-P2-02 is a SCHEMA-BREAKING change: 'url: string' is REMOVED, replaced by ` + + `'urls: string[]'. RED today; Plan 02-03 amends src/shared/types.ts. Current body:\n${interfaceBody}`, + ).toBe(false); + }); + + // ──────────────────────────────────────────────────────────────────── + // Test 2 — meta.json shape pin via real createArchive. + // + // RED today: meta.url is set to the extension origin + // (`chrome.runtime.getURL('')`'s origin) and meta.urls is undefined. + // GREEN after Plan 02-03's createArchive amendment + tab-url-tracker + // wiring. + // ──────────────────────────────────────────────────────────────────── + it('createArchive emits meta.json with Array urls and no url field', async () => { + const stub = buildBgStub(); + const port = await bootSwInRecState(stub); + + const archiveBlob = await runSaveAndCaptureArchiveBlob(stub, port); + expect( + archiveBlob, + 'createArchive did not produce an archive Blob within the drain window. ' + + 'Either the SAVE_ARCHIVE flow broke or the data: URL prefix changed (Plan 02-02 era).', + ).toBeDefined(); + + const zip = await JSZip.loadAsync(archiveBlob!); + const metaEntry = zip.file('meta.json'); + expect( + metaEntry, + 'meta.json entry missing from archive. Archive layout regression — REQ-archive-layout violation.', + ).not.toBeNull(); + + const metaText = await metaEntry!.async('string'); + const meta = JSON.parse(metaText); + + expect( + Array.isArray(meta.urls), + `meta.urls is not an Array. Got: ${typeof meta.urls} (${JSON.stringify(meta.urls)}). ` + + `D-P2-02 requires 'urls' field as a string[]. RED today because createArchive ` + + `still emits 'url: string' (singular) at src/background/index.ts:676.`, + ).toBe(true); + + expect( + meta.url, + `meta.url is still defined (value: ${JSON.stringify(meta.url)}). D-P2-02 is a ` + + `SCHEMA-BREAKING change: the singular 'url' field is REMOVED entirely; multi-tab ` + + `context lives in 'urls' (plural Array). RED today.`, + ).toBeUndefined(); + }); + + // ──────────────────────────────────────────────────────────────────── + // Test 3 — DEDUP + ORDER. meta.urls === ['A', 'B'] when the tracker + // observes ['A', 'B', 'A'] (first-seen-first; A appears once). + // + // RED today: src/background/tab-url-tracker.ts does not exist. Plan + // 02-03 implements it. The expect.fail in the catch surfaces the + // missing-module RED signal with a precise marker for the GREEN-flip. + // + // Once Plan 02-03 lands the module, this test continues to be useful: + // it pins the dedup-and-order CONTRACT of getTabUrlsSeen() directly. + // ──────────────────────────────────────────────────────────────────── + it('meta.urls deduplicates repeated URLs and preserves first-seen order', async () => { + // RED gate: the tab-url-tracker module does not exist yet. The + // dynamic import throws and expect.fail emits the precise marker + // for the Plan 02-03 GREEN-flip. + let tracker: typeof import('../../src/background/tab-url-tracker'); + try { + tracker = await import('../../src/background/tab-url-tracker'); + } catch (e) { + expect.fail( + `src/background/tab-url-tracker.ts does not exist yet — this is the Plan 02-03 ` + + `GREEN gate. The module MUST export a 'getTabUrlsSeen(): string[]' function ` + + `fed by chrome.tabs.onUpdated + chrome.tabs.onActivated listeners with dedup ` + + `Set semantics + first-seen-first iteration order. Module import error: ${String(e)}`, + ); + return; + } + + // Below: GREEN-side contract (Plan 02-03 implementer codes against + // this). Tracker exposes a way to inject observations for testing, + // OR the test wires chrome.tabs.onUpdated callbacks directly. + // Both shapes are acceptable per the planner's "Claude's Discretion" + // delegation in CONTEXT.md ``. For now we document the + // expectation and let the implementer pick. + // + // The skeleton below assumes a `reset()` + observation API for + // ergonomic test wiring. Plan 02-03's implementer SHOULD provide + // such an API OR amend this test to wire callbacks directly. + type TrackerModule = { + getTabUrlsSeen: () => string[]; + _resetForTesting?: () => void; + _observeForTesting?: (url: string) => void; + }; + const t = tracker as TrackerModule; + if (typeof t._resetForTesting === 'function') t._resetForTesting(); + if (typeof t._observeForTesting === 'function') { + t._observeForTesting('https://a.example.com'); + t._observeForTesting('https://b.example.com'); + t._observeForTesting('https://a.example.com'); + } + expect(t.getTabUrlsSeen()).toEqual([ + 'https://a.example.com', + 'https://b.example.com', + ]); + }); + + // ──────────────────────────────────────────────────────────────────── + // Test 4 — URL FILTER. meta.urls includes https + chrome-extension://; + // excludes chrome:// and about:. + // + // RED today: missing module. GREEN after Plan 02-03. + // ──────────────────────────────────────────────────────────────────── + it('meta.urls filters chrome:// and about: URLs and includes chrome-extension://', async () => { + let tracker: typeof import('../../src/background/tab-url-tracker'); + try { + tracker = await import('../../src/background/tab-url-tracker'); + } catch (e) { + expect.fail( + `src/background/tab-url-tracker.ts does not exist yet — Plan 02-03 GREEN gate. ` + + `Filter contract: include https + http + chrome-extension://; exclude chrome:// + ` + + `about: + (default-deny on devtools://, file://, edge://). Per CONTEXT.md ` + + ` URL filter clause. Module import error: ${String(e)}`, + ); + return; + } + + type TrackerModule = { + getTabUrlsSeen: () => string[]; + _resetForTesting?: () => void; + _observeForTesting?: (url: string) => void; + }; + const t = tracker as TrackerModule; + if (typeof t._resetForTesting === 'function') t._resetForTesting(); + if (typeof t._observeForTesting === 'function') { + t._observeForTesting('https://example.com'); + t._observeForTesting('chrome://newtab'); + t._observeForTesting('about:blank'); + t._observeForTesting('chrome-extension://abc/popup.html'); + } + expect(t.getTabUrlsSeen()).toEqual([ + 'https://example.com', + 'chrome-extension://abc/popup.html', + ]); + }); + + // ──────────────────────────────────────────────────────────────────── + // Test 5 — EMPTY-TRACKER → urls: [] (per F2 plan-checker iter-1). + // + // The whole-desktop-no-tab session is a meaningful operator state + // (the operator recorded screen capture without any browser tab + // interaction during the 30 s window). meta.urls MUST be an empty + // Array — NOT undefined, NOT `[]`, NOT null. + // + // RED today: missing module. GREEN after Plan 02-03 lands the tracker + // + amends createArchive to call snapshotOpenTabs() + read the + // tracker output verbatim (no sentinel fallback per CONTEXT.md + // Revision Log 2026-05-20). + // ──────────────────────────────────────────────────────────────────── + it('meta.urls is exactly [] when the tracker observed no browser tabs (F2)', async () => { + let tracker: typeof import('../../src/background/tab-url-tracker'); + try { + tracker = await import('../../src/background/tab-url-tracker'); + } catch (e) { + expect.fail( + `src/background/tab-url-tracker.ts does not exist yet — Plan 02-03 GREEN gate. ` + + `F2 contract: empty-tracker → meta.urls === [] (empty Array; NOT undefined; ` + + `NOT [extension-origin]; NOT null). Whole-desktop-no-tab recording is ` + + `meaningful per CONTEXT.md Revision Log 2026-05-20. Module import error: ${String(e)}`, + ); + return; + } + + type TrackerModule = { + getTabUrlsSeen: () => string[]; + _resetForTesting?: () => void; + _observeForTesting?: (url: string) => void; + }; + const t = tracker as TrackerModule; + if (typeof t._resetForTesting === 'function') t._resetForTesting(); + // Deliberately observe nothing — simulating a session where no tab + // events fired during the 30 s window. + expect(t.getTabUrlsSeen()).toEqual([]); + expect(t.getTabUrlsSeen()).not.toBeUndefined(); + expect(t.getTabUrlsSeen()).not.toBeNull(); + }); +}); diff --git a/tests/build/strict-meta-json-validation.test.ts b/tests/build/strict-meta-json-validation.test.ts new file mode 100644 index 0000000..0ca297b --- /dev/null +++ b/tests/build/strict-meta-json-validation.test.ts @@ -0,0 +1,621 @@ +// tests/build/strict-meta-json-validation.test.ts +// +// Plan 02-01 Task 3 (RED gate, Wave 0 of Phase 2). +// Pins decision D-P2-03 (strict 8-field meta.json schema validation) +// plus the F2 plan-checker-iter-1 resolution to PERMIT empty `urls[]` +// arrays as a meaningful representation of whole-desktop-no-tab +// sessions. +// +// Contract pinned here (3 RED today; 5 GREEN-today regression guards; +// ALL 8 GREEN after Plan 02-03): +// +// 1. Object.keys(meta).length === 8. RED today (7 fields). +// 2. timestamp matches ISO-8601 Z-suffix regex. GREEN today (already +// shaped via new Date().toISOString()). +// 3. urls is an Array of valid URLs; empty IS PERMITTED per F2. +// RED today (urls is undefined). +// 4. extensionVersion matches semver. GREEN today (sourced +// from manifest version like '1.0.0'). +// 5. totalEvents is a non-negative integer. GREEN today (zero or +// positive in createArchive). +// 6. videoBufferSeconds === 30. GREEN today (literal +// in createArchive). +// 7. logDurationMinutes === 10. GREEN today (literal +// in createArchive). +// 8. No extra fields (Object.keys subset of EXPECTED_KEYS, with +// EXPECTED_KEYS including 'schemaVersion'). RED today +// (meta.url is present + meta.urls + meta.schemaVersion missing). +// +// PLANNER-RESOLVED TENSIONS (see Plan 02-01 PLAN.md block in +// Task 3 + CONTEXT.md Revision Log 2026-05-20): +// +// D-P2-03 "non-empty urls[]" vs CONTEXT.md `` permissive +// empty-array clause: RESOLVED in favor of the permissive clause +// (F2 — empty IS PERMITTED for whole-desktop-no-tab sessions). Test 3 +// relaxes the original D-P2-03 strict rule. +// +// 8th field name (`schemaVersion`): TENTATIVE PLANNER PICK to mark +// the D-P2-02 url→urls breaking-change cutover. EXPECTED_KEYS +// constant pins this choice; Plan 02-03's implementer adds +// `schemaVersion: '2'` as a constant in src/background/index.ts. If +// a stronger argument for a different 8th field surfaces, this test +// is the canonical place to amend. +// +// "ALL 8 fail" plan claim vs reality: the plan's block claims +// ALL 8 tests fail under current HEAD. In practice, Tests 2, 4, 5, +// 6, 7 already match the current 7-field meta.json (timestamp, +// extensionVersion, totalEvents, videoBufferSeconds, +// logDurationMinutes are all in the current shape and satisfy the +// regex / range / literal checks). Only Tests 1, 3, 8 are RED today; +// the other 5 are GREEN-today regression guards that ensure Plan +// 02-03 doesn't accidentally regress timestamp format, semver, +// videoBufferSeconds, etc. when adding `urls` + `schemaVersion`. +// Documented honestly in 02-01-SUMMARY.md. +// +// Test architecture: drive the SW the same way as +// tests/background/meta-json-urls-schema.test.ts (load SW module via +// dynamic import; wire chrome stub; drive SAVE_ARCHIVE; capture archive +// Blob from chrome.downloads.download arg0; unzip with JSZip; JSON.parse +// the meta.json entry). The single meta object is then probed by 8 +// independent it() blocks under a shared describe. +// +// Skip discipline: NONE — bare it() blocks, no skip modifiers. + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve as resolvePath } from 'node:path'; +import JSZip from 'jszip'; + +// ────────────────────────────────────────────────────────────────────── +// Constants +// ────────────────────────────────────────────────────────────────────── + +const here = dirname(fileURLToPath(import.meta.url)); +const FIXTURE_PATH = resolvePath( + here, '..', '..', 'tests', 'fixtures', 'raw-3ebml-concat.webm', +); +const SEG1_START = 0; +const SEG2_START = 509038; +const SEG3_START = 970967; + +// Canonical 8-field meta.json schema (post-Plan-02-03). +// EXPECTED_KEYS is the planner pin; the 8th field is `schemaVersion` +// to mark the D-P2-02 breaking-change cutover. Plan 02-03 implementer +// MUST add `schemaVersion` (recommended value: '2') to satisfy +// Tests 1 + 8 simultaneously. +const EXPECTED_KEYS: readonly string[] = Object.freeze([ + 'timestamp', + 'urls', + 'userAgent', + 'extensionVersion', + 'videoBufferSeconds', + 'logDurationMinutes', + 'totalEvents', + 'schemaVersion', +]); +const EXPECTED_FIELD_COUNT = 8; + +// Validation regexes / literals — pin D-P2-03 strict contract. +const ISO_8601_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/; +const SEMVER = /^\d+\.\d+\.\d+$/; +const URL_SCHEME_ALLOW = /^(https?|chrome-extension):\/\//; +const VIDEO_BUFFER_SECONDS_LITERAL = 30; +const LOG_DURATION_MINUTES_LITERAL = 10; + +// ────────────────────────────────────────────────────────────────────── +// Chrome stub + helpers — minimal duplication of the pattern in +// meta-json-urls-schema.test.ts and blob-url-download.test.ts. Kept +// inline (no cross-file imports between tests/ trees, per vitest +// discouragement on cross-tree relative test imports). +// ────────────────────────────────────────────────────────────────────── + +interface PortStub { + name: string; + sender?: { id?: string }; + postMessage: ReturnType; + onMessage: { + addListener: ReturnType; + removeListener: ReturnType; + _listeners: Array<(msg: unknown) => void>; + }; + onDisconnect: { + addListener: (fn: () => void) => void; + _listeners: Array<() => void>; + }; + disconnect: ReturnType; +} + +function makePortStub(name = 'video-keepalive'): PortStub { + const port: PortStub = { + name, + sender: { id: 'ext-id-test' }, + postMessage: vi.fn(), + onMessage: { + addListener: vi.fn(), + removeListener: vi.fn(), + _listeners: [], + }, + onDisconnect: { + addListener: (fn: () => void) => { + port.onDisconnect._listeners.push(fn); + }, + _listeners: [], + }, + disconnect: vi.fn(), + }; + port.onMessage.addListener.mockImplementation( + (fn: (msg: unknown) => void) => { + port.onMessage._listeners.push(fn); + }, + ); + port.onMessage.removeListener.mockImplementation( + (fn: (msg: unknown) => void) => { + const idx = port.onMessage._listeners.indexOf(fn); + if (idx >= 0) { + port.onMessage._listeners.splice(idx, 1); + } + }, + ); + return port; +} + +interface OnConnectCallback { (port: PortStub): void; } +interface OnMessageCallback { + (msg: unknown, sender: { id?: string }, sendResponse: (r: unknown) => void): boolean; +} +interface OnClickedCallback { (info?: unknown): void | Promise; } + +interface BgChromeStub { + runtime: { + id: string; + getURL: (p: string) => string; + getManifest: () => { version: string }; + sendMessage: ReturnType; + onMessage: { addListener: ReturnType; _callbacks: OnMessageCallback[] }; + onConnect: { addListener: (cb: OnConnectCallback) => void; _callbacks: OnConnectCallback[] }; + onInstalled: { addListener: ReturnType }; + onStartup: { addListener: (cb: () => void) => void; _callbacks: Array<() => void> }; + }; + offscreen: { + hasDocument?: () => Promise; + createDocument: ReturnType; + Reason: { DISPLAY_MEDIA: 'DISPLAY_MEDIA' }; + }; + tabs: { + query: ReturnType; + sendMessage: ReturnType; + captureVisibleTab: ReturnType; + }; + downloads: { + download: ReturnType; + onChanged: { + addListener: ReturnType; + _callbacks: Array<(delta: { id: number; state?: { current: string } }) => void>; + }; + }; + action: { + onClicked: { addListener: (cb: OnClickedCallback) => void; _callbacks: OnClickedCallback[] }; + setPopup: ReturnType; + setBadgeText: ReturnType; + setBadgeBackgroundColor: ReturnType; + setTitle: ReturnType; + }; + notifications: { + create: ReturnType; + clear: ReturnType; + onClicked: { + addListener: ReturnType; + _callbacks: Array<(id: string) => void>; + }; + }; +} + +interface GlobalWithBgChrome { + chrome?: BgChromeStub; + indexedDB?: { deleteDatabase: ReturnType }; + fetch?: typeof fetch; +} + +function buildBgStub(): BgChromeStub { + const stub: BgChromeStub = { + runtime: { + id: 'ext-id-test', + getURL: (p) => `chrome-extension://ext-id-test/${p}`, + getManifest: () => ({ version: '1.0.0' }), + sendMessage: vi.fn().mockResolvedValue(undefined), + onMessage: { addListener: vi.fn(), _callbacks: [] }, + onConnect: { + addListener: (cb) => stub.runtime.onConnect._callbacks.push(cb), + _callbacks: [], + }, + onInstalled: { addListener: vi.fn() }, + onStartup: { + addListener: (cb) => stub.runtime.onStartup._callbacks.push(cb), + _callbacks: [], + }, + }, + offscreen: { + hasDocument: async () => false, + createDocument: vi.fn().mockResolvedValue(undefined), + Reason: { DISPLAY_MEDIA: 'DISPLAY_MEDIA' }, + }, + tabs: { + query: vi.fn().mockResolvedValue([ + { id: 1, url: 'https://example.com', windowId: 100 }, + ]), + sendMessage: vi.fn().mockResolvedValue({ events: [], userEvents: [] }), + captureVisibleTab: vi.fn().mockResolvedValue( + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', + ), + }, + downloads: { + download: vi.fn().mockResolvedValue(42), + onChanged: { + addListener: vi.fn(), + _callbacks: [], + }, + }, + action: { + onClicked: { + addListener: (cb) => stub.action.onClicked._callbacks.push(cb), + _callbacks: [], + }, + setPopup: vi.fn(), + setBadgeText: vi.fn(), + setBadgeBackgroundColor: vi.fn(), + setTitle: vi.fn(), + }, + notifications: { + create: vi.fn(), + clear: vi.fn(), + onClicked: { + addListener: vi.fn(), + _callbacks: [], + }, + }, + }; + stub.runtime.onMessage.addListener.mockImplementation((cb: OnMessageCallback) => { + stub.runtime.onMessage._callbacks.push(cb); + }); + stub.downloads.onChanged.addListener.mockImplementation( + (cb: (delta: { id: number; state?: { current: string } }) => void) => { + stub.downloads.onChanged._callbacks.push(cb); + }, + ); + stub.notifications.onClicked.addListener.mockImplementation( + (cb: (id: string) => void) => { + stub.notifications.onClicked._callbacks.push(cb); + }, + ); + return stub; +} + +async function stubFetch(_url: string): Promise { + const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + return { + blob: async () => new Blob([png], { type: 'image/png' }), + } as unknown as Response; +} + +// FileReader polyfill — same rationale as the sibling test files. +class FileReaderPolyfill { + result: ArrayBuffer | string | null = null; + onload: ((ev: { target: FileReaderPolyfill }) => void) | null = null; + onerror: ((ev: { target: FileReaderPolyfill; error: unknown }) => void) | null = null; + readyState = 0; + + readAsArrayBuffer(blob: Blob): void { + blob.arrayBuffer() + .then((ab) => { + this.result = ab; + this.readyState = 2; + if (this.onload) this.onload({ target: this }); + }) + .catch((err) => { + this.readyState = 2; + if (this.onerror) this.onerror({ target: this, error: err }); + }); + } +} + +function installFileReaderPolyfill(): void { + if (typeof (globalThis as { FileReader?: unknown }).FileReader === 'undefined') { + (globalThis as { FileReader?: unknown }).FileReader = FileReaderPolyfill; + } +} + +function buildThreeSliceSegmentsBase64(): Array<{ data: string; type: string; timestamp: number }> { + const buf = readFileSync(FIXTURE_PATH); + const slices = [ + buf.subarray(SEG1_START, SEG2_START), + buf.subarray(SEG2_START, SEG3_START), + buf.subarray(SEG3_START, buf.length), + ]; + return slices.map((slice, i) => ({ + data: Buffer.from(slice).toString('base64'), + type: 'video/webm;codecs=vp9', + timestamp: 1_000_000 + i * 10_000, + })); +} + +/** + * Drive SAVE_ARCHIVE → unzip → parse meta.json → return the parsed + * object. The full pipeline runs once per `it()` so each test exercises + * a fresh meta.json (vi.resetModules between tests ensures the SW + * module state isn't carried over). + * + * @returns Parsed meta.json object, or undefined if download arg never + * captured. + */ +async function runAndParseMetaJson( + stub: BgChromeStub, + port: PortStub, +): Promise | undefined> { + const segments = buildThreeSliceSegmentsBase64(); + + let buffered = false; + const tryFireBuffer = () => { + if (buffered) return; + const reqCalls = port.postMessage.mock.calls.filter( + (c: unknown[]) => + typeof c[0] === 'object' && + c[0] !== null && + (c[0] as { type?: unknown }).type === 'REQUEST_BUFFER', + ); + if (reqCalls.length === 0) return; + const reqMsg = reqCalls[0][0] as { requestId?: string }; + buffered = true; + port.onMessage._listeners.forEach((fn) => + fn({ + type: 'BUFFER', + requestId: reqMsg.requestId, + segments, + }), + ); + }; + + const onMsg = stub.runtime.onMessage._callbacks[0]; + onMsg( + { type: 'SAVE_ARCHIVE' }, + { id: 'ext-id-test' }, + () => undefined, + ); + + const DRAIN_ITERATIONS = 1_500; + for (let i = 0; i < DRAIN_ITERATIONS; i++) { + tryFireBuffer(); + if (stub.downloads.download.mock.calls.length > 0) break; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + + if (stub.downloads.download.mock.calls.length === 0) return undefined; + const arg = stub.downloads.download.mock.calls[0][0] as { url: string }; + const DATA_PREFIX = 'data:application/zip;base64,'; + if (!arg.url.startsWith(DATA_PREFIX)) { + // Plan 02-02 era: blob: URL. Plan 02-03 implementer will need a + // different harness (e.g. spy on URL.createObjectURL to capture + // the underlying Blob reference). This RED test runs against the + // current data: URL path. + return undefined; + } + const b64 = arg.url.substring(DATA_PREFIX.length); + const bytes = Buffer.from(b64, 'base64'); + const archiveBlob = new Blob([bytes], { type: 'application/zip' }); + const zip = await JSZip.loadAsync(archiveBlob); + const metaEntry = zip.file('meta.json'); + if (!metaEntry) return undefined; + const text = await metaEntry.async('string'); + return JSON.parse(text) as Record; +} + +async function bootSwInRecState(stub: BgChromeStub): Promise { + installFileReaderPolyfill(); + (globalThis as unknown as GlobalWithBgChrome).chrome = stub; + (globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn() }; + (globalThis as unknown as GlobalWithBgChrome).fetch = stubFetch as unknown as typeof fetch; + + await import('../../src/background/index'); + for (let i = 0; i < 8; i++) await Promise.resolve(); + + const port = makePortStub(); + if (stub.runtime.onConnect._callbacks.length > 0) { + stub.runtime.onConnect._callbacks[0](port); + } + + const onMsg = stub.runtime.onMessage._callbacks[0]; + onMsg({ type: 'OFFSCREEN_READY' }, { id: 'ext-id-test' }, () => undefined); + for (let i = 0; i < 16; i++) await Promise.resolve(); + + const onClicked = stub.action.onClicked._callbacks[0]; + await onClicked(); + for (let i = 0; i < 32; i++) await Promise.resolve(); + + return port; +} + +/** + * Per-test scaffold: boot SW, run SAVE_ARCHIVE, return parsed meta.json + * with a precise expect-defined guard for the parse failure mode. + */ +async function getMetaOrFail(): Promise> { + const stub = buildBgStub(); + const port = await bootSwInRecState(stub); + const meta = await runAndParseMetaJson(stub, port); + expect( + meta, + 'meta.json could not be parsed from the SAVE_ARCHIVE flow. ' + + 'Either createArchive threw before the meta.json write OR the download URL changed ' + + 'shape (Plan 02-02 era — blob: URL pathway requires a different probe).', + ).toBeDefined(); + return meta!; +} + +// ────────────────────────────────────────────────────────────────────── +// Tests +// ────────────────────────────────────────────────────────────────────── + +describe('Plan 02-01 Task 3 RED: strict 8-field meta.json schema validation (D-P2-03 + F2)', () => { + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + delete (globalThis as unknown as GlobalWithBgChrome).chrome; + }); + + // ──────────────────────────────────────────────────────────────────── + // Test 1 — EXACT 8-FIELD COUNT. RED today (current shape has 7). + // GREEN after Plan 02-03 adds `urls` + `schemaVersion` and removes + // `url`. + // ──────────────────────────────────────────────────────────────────── + it('meta.json has exactly 8 fields', async () => { + const meta = await getMetaOrFail(); + const keys = Object.keys(meta); + expect( + keys.length, + `meta.json has ${keys.length} fields; D-P2-03 requires exactly ${EXPECTED_FIELD_COUNT}. ` + + `Current keys: ${JSON.stringify(keys)}. RED today; flips GREEN after Plan 02-03 ` + + `adds 'urls' + 'schemaVersion' and removes 'url'.`, + ).toBe(EXPECTED_FIELD_COUNT); + }); + + // ──────────────────────────────────────────────────────────────────── + // Test 2 — ISO-8601 TIMESTAMP. GREEN-today regression guard: the + // current createArchive uses new Date().toISOString() which already + // matches the Z-suffix regex. Stays GREEN after Plan 02-03; this test + // catches any future regression that swaps toISOString() for a + // locale-specific formatter. + // ──────────────────────────────────────────────────────────────────── + it('meta.timestamp is ISO-8601 with Z suffix', async () => { + const meta = await getMetaOrFail(); + expect( + typeof meta.timestamp === 'string' && ISO_8601_Z.test(meta.timestamp as string), + `meta.timestamp does not match ISO-8601 Z-suffix pattern. Got: ${JSON.stringify(meta.timestamp)}. ` + + `Pattern: ${ISO_8601_Z.source}. D-P2-03 strict-validation contract.`, + ).toBe(true); + }); + + // ──────────────────────────────────────────────────────────────────── + // Test 3 — URLS ARRAY OF VALID URLS (empty PERMITTED per F2). + // + // F2 resolution: the empty-tracker case is MEANINGFUL (whole-desktop + // recording with no browser tabs open). The original D-P2-03 "non- + // empty urls[]" strict clause is RELAXED here; empty IS PERMITTED. + // CONTEXT.md `` permissive empty-array clause wins over + // the original D-P2-03 strict clause (Plan 02-04 Task 4 Step 2 + // operator empirical workflow verifies the non-empty case via the + // multi-tab path). + // + // RED today (meta.urls is undefined; not an Array). + // GREEN after Plan 02-03 (urls is an Array of filter-matching URLs; + // may be empty for whole-desktop-no-tab sessions). + // ──────────────────────────────────────────────────────────────────── + it('meta.urls is an Array of valid URLs; empty is permitted (F2)', async () => { + const meta = await getMetaOrFail(); + expect( + Array.isArray(meta.urls), + `meta.urls is not an Array. Got: ${typeof meta.urls} (${JSON.stringify(meta.urls)}). ` + + `D-P2-03 + F2 contract: urls is an Array (empty is permitted for whole-desktop-no-tab ` + + `sessions; non-empty entries MUST each match ${URL_SCHEME_ALLOW.source}).`, + ).toBe(true); + + const urls = meta.urls as unknown[]; + const allValid = urls.every( + (u) => typeof u === 'string' && URL_SCHEME_ALLOW.test(u), + ); + expect( + allValid, + `meta.urls contains non-string or invalid-scheme entries. URLs: ${JSON.stringify(urls)}. ` + + `Per CONTEXT.md URL filter: include http/https + chrome-extension://; exclude ` + + `chrome:// + about://. Each entry must match ${URL_SCHEME_ALLOW.source}.`, + ).toBe(true); + }); + + // ──────────────────────────────────────────────────────────────────── + // Test 4 — EXTENSIONVERSION SEMVER. GREEN-today regression guard: + // current createArchive reads from manifest.version which is '1.0.0' + // (or any other semver). Stays GREEN; this test catches any future + // regression that emits a non-semver version (build hash, "dev", etc). + // ──────────────────────────────────────────────────────────────────── + it('meta.extensionVersion matches semver', async () => { + const meta = await getMetaOrFail(); + expect( + typeof meta.extensionVersion === 'string' && SEMVER.test(meta.extensionVersion as string), + `meta.extensionVersion does not match semver pattern. Got: ` + + `${JSON.stringify(meta.extensionVersion)}. Pattern: ${SEMVER.source}.`, + ).toBe(true); + }); + + // ──────────────────────────────────────────────────────────────────── + // Test 5 — TOTALEVENTS NON-NEGATIVE INTEGER. GREEN-today regression + // guard: current createArchive sets totalEvents = rrwebEvents.length + + // userEvents.length which is always a non-negative integer. Stays + // GREEN; catches negative/float regressions. + // ──────────────────────────────────────────────────────────────────── + it('meta.totalEvents is a non-negative integer', async () => { + const meta = await getMetaOrFail(); + expect( + Number.isInteger(meta.totalEvents) && (meta.totalEvents as number) >= 0, + `meta.totalEvents is not a non-negative integer. Got: ${JSON.stringify(meta.totalEvents)} ` + + `(typeof: ${typeof meta.totalEvents}, isInteger: ${Number.isInteger(meta.totalEvents)}).`, + ).toBe(true); + }); + + // ──────────────────────────────────────────────────────────────────── + // Test 6 — VIDEOBUFFERSECONDS LITERAL. GREEN-today regression guard: + // current createArchive emits videoBufferSeconds: 30 literal. Stays + // GREEN after Plan 02-03; this test catches any future regression that + // changes the literal (CON-video-window contract). + // ──────────────────────────────────────────────────────────────────── + it('meta.videoBufferSeconds is exactly 30 (CON-video-window)', async () => { + const meta = await getMetaOrFail(); + expect( + meta.videoBufferSeconds, + `meta.videoBufferSeconds is not the CON-video-window literal ${VIDEO_BUFFER_SECONDS_LITERAL}. ` + + `Got: ${JSON.stringify(meta.videoBufferSeconds)}.`, + ).toBe(VIDEO_BUFFER_SECONDS_LITERAL); + }); + + // ──────────────────────────────────────────────────────────────────── + // Test 7 — LOGDURATIONMINUTES LITERAL. GREEN-today regression guard: + // current createArchive emits logDurationMinutes: 10 literal. Stays + // GREEN; catches CON-rrweb-window / CON-event-log-window regressions. + // ──────────────────────────────────────────────────────────────────── + it('meta.logDurationMinutes is exactly 10 (CON-rrweb-window / CON-event-log-window)', async () => { + const meta = await getMetaOrFail(); + expect( + meta.logDurationMinutes, + `meta.logDurationMinutes is not the CON-event-log-window literal ${LOG_DURATION_MINUTES_LITERAL}. ` + + `Got: ${JSON.stringify(meta.logDurationMinutes)}.`, + ).toBe(LOG_DURATION_MINUTES_LITERAL); + }); + + // ──────────────────────────────────────────────────────────────────── + // Test 8 — NO EXTRA FIELDS. Object.keys(meta) is a subset of + // EXPECTED_KEYS (and EXPECTED_KEYS.length === 8). RED today: current + // meta has `url` (singular) which is NOT in EXPECTED_KEYS (the new + // schema has `urls` + `schemaVersion` replacing `url`). + // + // GREEN after Plan 02-03 adds `urls` + `schemaVersion` and removes + // `url`. + // ──────────────────────────────────────────────────────────────────── + it('meta.json has no extra fields beyond the 8 expected keys', async () => { + const meta = await getMetaOrFail(); + const keys = Object.keys(meta); + const extras = keys.filter((k) => !EXPECTED_KEYS.includes(k)); + const missing = EXPECTED_KEYS.filter((k) => !keys.includes(k)); + expect( + extras.length, + `meta.json contains extra (unexpected) fields: ${JSON.stringify(extras)}. ` + + `EXPECTED_KEYS = ${JSON.stringify(EXPECTED_KEYS)}. Per D-P2-03 ` + + `'8 fields exact', a strict superset is a regression vector for any future ` + + `downstream consumer (UAT harness, future server upload). RED today because ` + + `meta.url (singular) is in current shape but NOT in EXPECTED_KEYS.`, + ).toBe(0); + expect( + missing.length, + `meta.json is missing expected fields: ${JSON.stringify(missing)}. ` + + `EXPECTED_KEYS = ${JSON.stringify(EXPECTED_KEYS)}. RED today because urls + ` + + `schemaVersion are not in the current shape.`, + ).toBe(0); + }); +});