chore: merge executor worktree (worktree-agent-a523bf9bbd145af79) — Wave 1 Plan 02-01
This commit is contained in:
223
.planning/phases/02-stabilize-export-pipeline/02-01-SUMMARY.md
Normal file
223
.planning/phases/02-stabilize-export-pipeline/02-01-SUMMARY.md
Normal 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.
|
||||
625
tests/background/blob-url-download.test.ts
Normal file
625
tests/background/blob-url-download.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
671
tests/background/meta-json-urls-schema.test.ts
Normal file
671
tests/background/meta-json-urls-schema.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
621
tests/build/strict-meta-json-validation.test.ts
Normal file
621
tests/build/strict-meta-json-validation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user