chore: merge executor worktree (worktree-agent-a523bf9bbd145af79) — Wave 1 Plan 02-01

This commit is contained in:
2026-05-20 15:37:03 +02:00
4 changed files with 2140 additions and 0 deletions

View File

@@ -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 [<origin>], 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 `<specifics>` 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.

View File

@@ -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<typeof vi.fn>;
onMessage: {
addListener: ReturnType<typeof vi.fn>;
removeListener: ReturnType<typeof vi.fn>;
_listeners: Array<(msg: unknown) => void>;
};
onDisconnect: {
addListener: (fn: () => void) => void;
_listeners: Array<() => void>;
};
disconnect: ReturnType<typeof vi.fn>;
}
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<void>; }
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<typeof vi.fn>;
onMessage: { addListener: ReturnType<typeof vi.fn>; _callbacks: OnMessageCallback[] };
onConnect: { addListener: (cb: OnConnectCallback) => void; _callbacks: OnConnectCallback[] };
onInstalled: { addListener: ReturnType<typeof vi.fn> };
onStartup: { addListener: (cb: () => void) => void; _callbacks: Array<() => void> };
};
offscreen: {
hasDocument?: () => Promise<boolean>;
createDocument: ReturnType<typeof vi.fn>;
Reason: { DISPLAY_MEDIA: 'DISPLAY_MEDIA' };
};
tabs: {
query: ReturnType<typeof vi.fn>;
sendMessage: ReturnType<typeof vi.fn>;
captureVisibleTab: ReturnType<typeof vi.fn>;
};
downloads: {
download: ReturnType<typeof vi.fn>;
onChanged: {
addListener: ReturnType<typeof vi.fn>;
_callbacks: DownloadsOnChangedCallback[];
};
};
action: {
onClicked: { addListener: (cb: OnClickedCallback) => void; _callbacks: OnClickedCallback[] };
setPopup: ReturnType<typeof vi.fn>;
setBadgeText: ReturnType<typeof vi.fn>;
setBadgeBackgroundColor: ReturnType<typeof vi.fn>;
setTitle: ReturnType<typeof vi.fn>;
};
notifications: {
create: ReturnType<typeof vi.fn>;
clear: ReturnType<typeof vi.fn>;
onClicked: {
addListener: ReturnType<typeof vi.fn>;
_callbacks: Array<(id: string) => void>;
};
};
}
interface GlobalWithBgChrome {
chrome?: BgChromeStub;
indexedDB?: { deleteDatabase: ReturnType<typeof vi.fn> };
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<Response> {
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 '<entry>'". 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<void>((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<PortStub> {
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<void>((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<void>((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();
}
});
});

View File

@@ -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 `<specifics>` 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<typeof vi.fn>;
onMessage: {
addListener: ReturnType<typeof vi.fn>;
removeListener: ReturnType<typeof vi.fn>;
_listeners: Array<(msg: unknown) => void>;
};
onDisconnect: {
addListener: (fn: () => void) => void;
_listeners: Array<() => void>;
};
disconnect: ReturnType<typeof vi.fn>;
}
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<void>; }
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<typeof vi.fn>;
onMessage: { addListener: ReturnType<typeof vi.fn>; _callbacks: OnMessageCallback[] };
onConnect: { addListener: (cb: OnConnectCallback) => void; _callbacks: OnConnectCallback[] };
onInstalled: { addListener: ReturnType<typeof vi.fn> };
onStartup: { addListener: (cb: () => void) => void; _callbacks: Array<() => void> };
};
offscreen: {
hasDocument?: () => Promise<boolean>;
createDocument: ReturnType<typeof vi.fn>;
Reason: { DISPLAY_MEDIA: 'DISPLAY_MEDIA' };
};
tabs: {
query: ReturnType<typeof vi.fn>;
sendMessage: ReturnType<typeof vi.fn>;
captureVisibleTab: ReturnType<typeof vi.fn>;
get?: ReturnType<typeof vi.fn>;
onUpdated: { addListener: (cb: TabUpdatedCb) => void; _callbacks: TabUpdatedCb[] };
onActivated: { addListener: (cb: TabActivatedCb) => void; _callbacks: TabActivatedCb[] };
};
downloads: {
download: ReturnType<typeof vi.fn>;
onChanged: {
addListener: ReturnType<typeof vi.fn>;
_callbacks: Array<(delta: { id: number; state?: { current: string } }) => void>;
};
};
action: {
onClicked: { addListener: (cb: OnClickedCallback) => void; _callbacks: OnClickedCallback[] };
setPopup: ReturnType<typeof vi.fn>;
setBadgeText: ReturnType<typeof vi.fn>;
setBadgeBackgroundColor: ReturnType<typeof vi.fn>;
setTitle: ReturnType<typeof vi.fn>;
};
notifications: {
create: ReturnType<typeof vi.fn>;
clear: ReturnType<typeof vi.fn>;
onClicked: {
addListener: ReturnType<typeof vi.fn>;
_callbacks: Array<(id: string) => void>;
};
};
}
interface GlobalWithBgChrome {
chrome?: BgChromeStub;
indexedDB?: { deleteDatabase: ReturnType<typeof vi.fn> };
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<Response> {
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<Blob | 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<void>((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>. 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<PortStub> {
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 `<decisions>`. 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 ` +
`<specifics> 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 `[<extension-origin>]`, 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();
});
});

View File

@@ -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 <action> block in
// Task 3 + CONTEXT.md Revision Log 2026-05-20):
//
// D-P2-03 "non-empty urls[]" vs CONTEXT.md `<specifics>` 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 <done> 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<typeof vi.fn>;
onMessage: {
addListener: ReturnType<typeof vi.fn>;
removeListener: ReturnType<typeof vi.fn>;
_listeners: Array<(msg: unknown) => void>;
};
onDisconnect: {
addListener: (fn: () => void) => void;
_listeners: Array<() => void>;
};
disconnect: ReturnType<typeof vi.fn>;
}
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<void>; }
interface BgChromeStub {
runtime: {
id: string;
getURL: (p: string) => string;
getManifest: () => { version: string };
sendMessage: ReturnType<typeof vi.fn>;
onMessage: { addListener: ReturnType<typeof vi.fn>; _callbacks: OnMessageCallback[] };
onConnect: { addListener: (cb: OnConnectCallback) => void; _callbacks: OnConnectCallback[] };
onInstalled: { addListener: ReturnType<typeof vi.fn> };
onStartup: { addListener: (cb: () => void) => void; _callbacks: Array<() => void> };
};
offscreen: {
hasDocument?: () => Promise<boolean>;
createDocument: ReturnType<typeof vi.fn>;
Reason: { DISPLAY_MEDIA: 'DISPLAY_MEDIA' };
};
tabs: {
query: ReturnType<typeof vi.fn>;
sendMessage: ReturnType<typeof vi.fn>;
captureVisibleTab: ReturnType<typeof vi.fn>;
};
downloads: {
download: ReturnType<typeof vi.fn>;
onChanged: {
addListener: ReturnType<typeof vi.fn>;
_callbacks: Array<(delta: { id: number; state?: { current: string } }) => void>;
};
};
action: {
onClicked: { addListener: (cb: OnClickedCallback) => void; _callbacks: OnClickedCallback[] };
setPopup: ReturnType<typeof vi.fn>;
setBadgeText: ReturnType<typeof vi.fn>;
setBadgeBackgroundColor: ReturnType<typeof vi.fn>;
setTitle: ReturnType<typeof vi.fn>;
};
notifications: {
create: ReturnType<typeof vi.fn>;
clear: ReturnType<typeof vi.fn>;
onClicked: {
addListener: ReturnType<typeof vi.fn>;
_callbacks: Array<(id: string) => void>;
};
};
}
interface GlobalWithBgChrome {
chrome?: BgChromeStub;
indexedDB?: { deleteDatabase: ReturnType<typeof vi.fn> };
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<Response> {
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<Record<string, unknown> | 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<void>((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<string, unknown>;
}
async function bootSwInRecState(stub: BgChromeStub): Promise<PortStub> {
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<Record<string, unknown>> {
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 `<specifics>` 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);
});
});