// tests/build/strict-meta-json-validation.test.ts // // Plan 02-01 Task 3 (RED gate, Wave 0 of Phase 2). // Pins decision D-P2-03 (strict 8-field meta.json schema validation) // plus the F2 plan-checker-iter-1 resolution to PERMIT empty `urls[]` // arrays as a meaningful representation of whole-desktop-no-tab // sessions. // // Contract pinned here (3 RED today; 5 GREEN-today regression guards; // ALL 8 GREEN after Plan 02-03): // // 1. Object.keys(meta).length === 8. RED today (7 fields). // 2. timestamp matches ISO-8601 Z-suffix regex. GREEN today (already // shaped via new Date().toISOString()). // 3. urls is an Array of valid URLs; empty IS PERMITTED per F2. // RED today (urls is undefined). // 4. extensionVersion matches semver. GREEN today (sourced // from manifest version like '1.0.0'). // 5. totalEvents is a non-negative integer. GREEN today (zero or // positive in createArchive). // 6. videoBufferSeconds === 30. GREEN today (literal // in createArchive). // 7. logDurationMinutes === 10. GREEN today (literal // in createArchive). // 8. No extra fields (Object.keys subset of EXPECTED_KEYS, with // EXPECTED_KEYS including 'schemaVersion'). RED today // (meta.url is present + meta.urls + meta.schemaVersion missing). // // PLANNER-RESOLVED TENSIONS (see Plan 02-01 PLAN.md block in // Task 3 + CONTEXT.md Revision Log 2026-05-20): // // D-P2-03 "non-empty urls[]" vs CONTEXT.md `` permissive // empty-array clause: RESOLVED in favor of the permissive clause // (F2 — empty IS PERMITTED for whole-desktop-no-tab sessions). Test 3 // relaxes the original D-P2-03 strict rule. // // 8th field name (`schemaVersion`): TENTATIVE PLANNER PICK to mark // the D-P2-02 url→urls breaking-change cutover. EXPECTED_KEYS // constant pins this choice; Plan 02-03's implementer adds // `schemaVersion: '2'` as a constant in src/background/index.ts. If // a stronger argument for a different 8th field surfaces, this test // is the canonical place to amend. // // "ALL 8 fail" plan claim vs reality: the plan's block claims // ALL 8 tests fail under current HEAD. In practice, Tests 2, 4, 5, // 6, 7 already match the current 7-field meta.json (timestamp, // extensionVersion, totalEvents, videoBufferSeconds, // logDurationMinutes are all in the current shape and satisfy the // regex / range / literal checks). Only Tests 1, 3, 8 are RED today; // the other 5 are GREEN-today regression guards that ensure Plan // 02-03 doesn't accidentally regress timestamp format, semver, // videoBufferSeconds, etc. when adding `urls` + `schemaVersion`. // Documented honestly in 02-01-SUMMARY.md. // // Test architecture: drive the SW the same way as // tests/background/meta-json-urls-schema.test.ts (load SW module via // dynamic import; wire chrome stub; drive SAVE_ARCHIVE; capture archive // Blob from chrome.downloads.download arg0; unzip with JSZip; JSON.parse // the meta.json entry). The single meta object is then probed by 8 // independent it() blocks under a shared describe. // // Skip discipline: NONE — bare it() blocks, no skip modifiers. import { describe, it, expect, vi, beforeEach } from 'vitest'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, resolve as resolvePath } from 'node:path'; import JSZip from 'jszip'; // ────────────────────────────────────────────────────────────────────── // Constants // ────────────────────────────────────────────────────────────────────── const here = dirname(fileURLToPath(import.meta.url)); const FIXTURE_PATH = resolvePath( here, '..', '..', 'tests', 'fixtures', 'raw-3ebml-concat.webm', ); const SEG1_START = 0; const SEG2_START = 509038; const SEG3_START = 970967; // Canonical 8-field meta.json schema (post-Plan-02-03). // EXPECTED_KEYS is the planner pin; the 8th field is `schemaVersion` // to mark the D-P2-02 breaking-change cutover. Plan 02-03 implementer // MUST add `schemaVersion` (recommended value: '2') to satisfy // Tests 1 + 8 simultaneously. const EXPECTED_KEYS: readonly string[] = Object.freeze([ 'timestamp', 'urls', 'userAgent', 'extensionVersion', 'videoBufferSeconds', 'logDurationMinutes', 'totalEvents', 'schemaVersion', ]); const EXPECTED_FIELD_COUNT = 8; // Validation regexes / literals — pin D-P2-03 strict contract. const ISO_8601_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/; const SEMVER = /^\d+\.\d+\.\d+$/; const URL_SCHEME_ALLOW = /^(https?|chrome-extension):\/\//; const VIDEO_BUFFER_SECONDS_LITERAL = 30; const LOG_DURATION_MINUTES_LITERAL = 10; // ────────────────────────────────────────────────────────────────────── // Chrome stub + helpers — minimal duplication of the pattern in // meta-json-urls-schema.test.ts and blob-url-download.test.ts. Kept // inline (no cross-file imports between tests/ trees, per vitest // discouragement on cross-tree relative test imports). // ────────────────────────────────────────────────────────────────────── interface PortStub { name: string; sender?: { id?: string }; postMessage: ReturnType; onMessage: { addListener: ReturnType; removeListener: ReturnType; _listeners: Array<(msg: unknown) => void>; }; onDisconnect: { addListener: (fn: () => void) => void; _listeners: Array<() => void>; }; disconnect: ReturnType; } function makePortStub(name = 'video-keepalive'): PortStub { const port: PortStub = { name, sender: { id: 'ext-id-test' }, postMessage: vi.fn(), onMessage: { addListener: vi.fn(), removeListener: vi.fn(), _listeners: [], }, onDisconnect: { addListener: (fn: () => void) => { port.onDisconnect._listeners.push(fn); }, _listeners: [], }, disconnect: vi.fn(), }; port.onMessage.addListener.mockImplementation( (fn: (msg: unknown) => void) => { port.onMessage._listeners.push(fn); }, ); port.onMessage.removeListener.mockImplementation( (fn: (msg: unknown) => void) => { const idx = port.onMessage._listeners.indexOf(fn); if (idx >= 0) { port.onMessage._listeners.splice(idx, 1); } }, ); return port; } interface OnConnectCallback { (port: PortStub): void; } interface OnMessageCallback { (msg: unknown, sender: { id?: string }, sendResponse: (r: unknown) => void): boolean; } interface OnClickedCallback { (info?: unknown): void | Promise; } interface BgChromeStub { runtime: { id: string; getURL: (p: string) => string; getManifest: () => { version: string }; sendMessage: ReturnType; onMessage: { addListener: ReturnType; _callbacks: OnMessageCallback[] }; onConnect: { addListener: (cb: OnConnectCallback) => void; _callbacks: OnConnectCallback[] }; onInstalled: { addListener: ReturnType }; onStartup: { addListener: (cb: () => void) => void; _callbacks: Array<() => void> }; }; offscreen: { hasDocument?: () => Promise; createDocument: ReturnType; Reason: { DISPLAY_MEDIA: 'DISPLAY_MEDIA' }; }; tabs: { query: ReturnType; sendMessage: ReturnType; captureVisibleTab: ReturnType; }; downloads: { download: ReturnType; onChanged: { addListener: ReturnType; _callbacks: Array<(delta: { id: number; state?: { current: string } }) => void>; }; }; action: { onClicked: { addListener: (cb: OnClickedCallback) => void; _callbacks: OnClickedCallback[] }; setPopup: ReturnType; setBadgeText: ReturnType; setBadgeBackgroundColor: ReturnType; setTitle: ReturnType; }; notifications: { create: ReturnType; clear: ReturnType; onClicked: { addListener: ReturnType; _callbacks: Array<(id: string) => void>; }; }; } interface GlobalWithBgChrome { chrome?: BgChromeStub; indexedDB?: { deleteDatabase: ReturnType }; fetch?: typeof fetch; } function buildBgStub(): BgChromeStub { const stub: BgChromeStub = { runtime: { id: 'ext-id-test', getURL: (p) => `chrome-extension://ext-id-test/${p}`, getManifest: () => ({ version: '1.0.0' }), sendMessage: vi.fn().mockResolvedValue(undefined), onMessage: { addListener: vi.fn(), _callbacks: [] }, onConnect: { addListener: (cb) => stub.runtime.onConnect._callbacks.push(cb), _callbacks: [], }, onInstalled: { addListener: vi.fn() }, onStartup: { addListener: (cb) => stub.runtime.onStartup._callbacks.push(cb), _callbacks: [], }, }, offscreen: { hasDocument: async () => false, createDocument: vi.fn().mockResolvedValue(undefined), Reason: { DISPLAY_MEDIA: 'DISPLAY_MEDIA' }, }, tabs: { query: vi.fn().mockResolvedValue([ { id: 1, url: 'https://example.com', windowId: 100 }, ]), sendMessage: vi.fn().mockResolvedValue({ events: [], userEvents: [] }), captureVisibleTab: vi.fn().mockResolvedValue( 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', ), }, downloads: { download: vi.fn().mockResolvedValue(42), onChanged: { addListener: vi.fn(), _callbacks: [], }, }, action: { onClicked: { addListener: (cb) => stub.action.onClicked._callbacks.push(cb), _callbacks: [], }, setPopup: vi.fn(), setBadgeText: vi.fn(), setBadgeBackgroundColor: vi.fn(), setTitle: vi.fn(), }, notifications: { create: vi.fn(), clear: vi.fn(), onClicked: { addListener: vi.fn(), _callbacks: [], }, }, }; stub.runtime.onMessage.addListener.mockImplementation((cb: OnMessageCallback) => { stub.runtime.onMessage._callbacks.push(cb); }); stub.downloads.onChanged.addListener.mockImplementation( (cb: (delta: { id: number; state?: { current: string } }) => void) => { stub.downloads.onChanged._callbacks.push(cb); }, ); stub.notifications.onClicked.addListener.mockImplementation( (cb: (id: string) => void) => { stub.notifications.onClicked._callbacks.push(cb); }, ); return stub; } async function stubFetch(_url: string): Promise { const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); return { blob: async () => new Blob([png], { type: 'image/png' }), } as unknown as Response; } // FileReader polyfill — same rationale as the sibling test files. class FileReaderPolyfill { result: ArrayBuffer | string | null = null; onload: ((ev: { target: FileReaderPolyfill }) => void) | null = null; onerror: ((ev: { target: FileReaderPolyfill; error: unknown }) => void) | null = null; readyState = 0; readAsArrayBuffer(blob: Blob): void { blob.arrayBuffer() .then((ab) => { this.result = ab; this.readyState = 2; if (this.onload) this.onload({ target: this }); }) .catch((err) => { this.readyState = 2; if (this.onerror) this.onerror({ target: this, error: err }); }); } } function installFileReaderPolyfill(): void { if (typeof (globalThis as { FileReader?: unknown }).FileReader === 'undefined') { (globalThis as { FileReader?: unknown }).FileReader = FileReaderPolyfill; } } function buildThreeSliceSegmentsBase64(): Array<{ data: string; type: string; timestamp: number }> { const buf = readFileSync(FIXTURE_PATH); const slices = [ buf.subarray(SEG1_START, SEG2_START), buf.subarray(SEG2_START, SEG3_START), buf.subarray(SEG3_START, buf.length), ]; return slices.map((slice, i) => ({ data: Buffer.from(slice).toString('base64'), type: 'video/webm;codecs=vp9', timestamp: 1_000_000 + i * 10_000, })); } /** * Drive SAVE_ARCHIVE → unzip → parse meta.json → return the parsed * object. The full pipeline runs once per `it()` so each test exercises * a fresh meta.json (vi.resetModules between tests ensures the SW * module state isn't carried over). * * @returns Parsed meta.json object, or undefined if download arg never * captured. */ async function runAndParseMetaJson( stub: BgChromeStub, port: PortStub, ): Promise | undefined> { const segments = buildThreeSliceSegmentsBase64(); let buffered = false; const tryFireBuffer = () => { if (buffered) return; const reqCalls = port.postMessage.mock.calls.filter( (c: unknown[]) => typeof c[0] === 'object' && c[0] !== null && (c[0] as { type?: unknown }).type === 'REQUEST_BUFFER', ); if (reqCalls.length === 0) return; const reqMsg = reqCalls[0][0] as { requestId?: string }; buffered = true; port.onMessage._listeners.forEach((fn) => fn({ type: 'BUFFER', requestId: reqMsg.requestId, segments, }), ); }; const onMsg = stub.runtime.onMessage._callbacks[0]; onMsg( { type: 'SAVE_ARCHIVE' }, { id: 'ext-id-test' }, () => undefined, ); const DRAIN_ITERATIONS = 1_500; for (let i = 0; i < DRAIN_ITERATIONS; i++) { tryFireBuffer(); if (stub.downloads.download.mock.calls.length > 0) break; await new Promise((resolve) => setTimeout(resolve, 5)); } if (stub.downloads.download.mock.calls.length === 0) return undefined; const arg = stub.downloads.download.mock.calls[0][0] as { url: string }; const DATA_PREFIX = 'data:application/zip;base64,'; if (!arg.url.startsWith(DATA_PREFIX)) { // Plan 02-02 era: blob: URL. Plan 02-03 implementer will need a // different harness (e.g. spy on URL.createObjectURL to capture // the underlying Blob reference). This RED test runs against the // current data: URL path. return undefined; } const b64 = arg.url.substring(DATA_PREFIX.length); const bytes = Buffer.from(b64, 'base64'); const archiveBlob = new Blob([bytes], { type: 'application/zip' }); const zip = await JSZip.loadAsync(archiveBlob); const metaEntry = zip.file('meta.json'); if (!metaEntry) return undefined; const text = await metaEntry.async('string'); return JSON.parse(text) as Record; } async function bootSwInRecState(stub: BgChromeStub): Promise { installFileReaderPolyfill(); (globalThis as unknown as GlobalWithBgChrome).chrome = stub; (globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn() }; (globalThis as unknown as GlobalWithBgChrome).fetch = stubFetch as unknown as typeof fetch; await import('../../src/background/index'); for (let i = 0; i < 8; i++) await Promise.resolve(); const port = makePortStub(); if (stub.runtime.onConnect._callbacks.length > 0) { stub.runtime.onConnect._callbacks[0](port); } const onMsg = stub.runtime.onMessage._callbacks[0]; onMsg({ type: 'OFFSCREEN_READY' }, { id: 'ext-id-test' }, () => undefined); for (let i = 0; i < 16; i++) await Promise.resolve(); const onClicked = stub.action.onClicked._callbacks[0]; await onClicked(); for (let i = 0; i < 32; i++) await Promise.resolve(); return port; } /** * Per-test scaffold: boot SW, run SAVE_ARCHIVE, return parsed meta.json * with a precise expect-defined guard for the parse failure mode. */ async function getMetaOrFail(): Promise> { const stub = buildBgStub(); const port = await bootSwInRecState(stub); const meta = await runAndParseMetaJson(stub, port); expect( meta, 'meta.json could not be parsed from the SAVE_ARCHIVE flow. ' + 'Either createArchive threw before the meta.json write OR the download URL changed ' + 'shape (Plan 02-02 era — blob: URL pathway requires a different probe).', ).toBeDefined(); return meta!; } // ────────────────────────────────────────────────────────────────────── // Tests // ────────────────────────────────────────────────────────────────────── describe('Plan 02-01 Task 3 RED: strict 8-field meta.json schema validation (D-P2-03 + F2)', () => { beforeEach(() => { vi.resetModules(); vi.restoreAllMocks(); delete (globalThis as unknown as GlobalWithBgChrome).chrome; }); // ──────────────────────────────────────────────────────────────────── // Test 1 — EXACT 8-FIELD COUNT. RED today (current shape has 7). // GREEN after Plan 02-03 adds `urls` + `schemaVersion` and removes // `url`. // ──────────────────────────────────────────────────────────────────── it('meta.json has exactly 8 fields', async () => { const meta = await getMetaOrFail(); const keys = Object.keys(meta); expect( keys.length, `meta.json has ${keys.length} fields; D-P2-03 requires exactly ${EXPECTED_FIELD_COUNT}. ` + `Current keys: ${JSON.stringify(keys)}. RED today; flips GREEN after Plan 02-03 ` + `adds 'urls' + 'schemaVersion' and removes 'url'.`, ).toBe(EXPECTED_FIELD_COUNT); }); // ──────────────────────────────────────────────────────────────────── // Test 2 — ISO-8601 TIMESTAMP. GREEN-today regression guard: the // current createArchive uses new Date().toISOString() which already // matches the Z-suffix regex. Stays GREEN after Plan 02-03; this test // catches any future regression that swaps toISOString() for a // locale-specific formatter. // ──────────────────────────────────────────────────────────────────── it('meta.timestamp is ISO-8601 with Z suffix', async () => { const meta = await getMetaOrFail(); expect( typeof meta.timestamp === 'string' && ISO_8601_Z.test(meta.timestamp as string), `meta.timestamp does not match ISO-8601 Z-suffix pattern. Got: ${JSON.stringify(meta.timestamp)}. ` + `Pattern: ${ISO_8601_Z.source}. D-P2-03 strict-validation contract.`, ).toBe(true); }); // ──────────────────────────────────────────────────────────────────── // Test 3 — URLS ARRAY OF VALID URLS (empty PERMITTED per F2). // // F2 resolution: the empty-tracker case is MEANINGFUL (whole-desktop // recording with no browser tabs open). The original D-P2-03 "non- // empty urls[]" strict clause is RELAXED here; empty IS PERMITTED. // CONTEXT.md `` permissive empty-array clause wins over // the original D-P2-03 strict clause (Plan 02-04 Task 4 Step 2 // operator empirical workflow verifies the non-empty case via the // multi-tab path). // // RED today (meta.urls is undefined; not an Array). // GREEN after Plan 02-03 (urls is an Array of filter-matching URLs; // may be empty for whole-desktop-no-tab sessions). // ──────────────────────────────────────────────────────────────────── it('meta.urls is an Array of valid URLs; empty is permitted (F2)', async () => { const meta = await getMetaOrFail(); expect( Array.isArray(meta.urls), `meta.urls is not an Array. Got: ${typeof meta.urls} (${JSON.stringify(meta.urls)}). ` + `D-P2-03 + F2 contract: urls is an Array (empty is permitted for whole-desktop-no-tab ` + `sessions; non-empty entries MUST each match ${URL_SCHEME_ALLOW.source}).`, ).toBe(true); const urls = meta.urls as unknown[]; const allValid = urls.every( (u) => typeof u === 'string' && URL_SCHEME_ALLOW.test(u), ); expect( allValid, `meta.urls contains non-string or invalid-scheme entries. URLs: ${JSON.stringify(urls)}. ` + `Per CONTEXT.md URL filter: include http/https + chrome-extension://; exclude ` + `chrome:// + about://. Each entry must match ${URL_SCHEME_ALLOW.source}.`, ).toBe(true); }); // ──────────────────────────────────────────────────────────────────── // Test 4 — EXTENSIONVERSION SEMVER. GREEN-today regression guard: // current createArchive reads from manifest.version which is '1.0.0' // (or any other semver). Stays GREEN; this test catches any future // regression that emits a non-semver version (build hash, "dev", etc). // ──────────────────────────────────────────────────────────────────── it('meta.extensionVersion matches semver', async () => { const meta = await getMetaOrFail(); expect( typeof meta.extensionVersion === 'string' && SEMVER.test(meta.extensionVersion as string), `meta.extensionVersion does not match semver pattern. Got: ` + `${JSON.stringify(meta.extensionVersion)}. Pattern: ${SEMVER.source}.`, ).toBe(true); }); // ──────────────────────────────────────────────────────────────────── // Test 5 — TOTALEVENTS NON-NEGATIVE INTEGER. GREEN-today regression // guard: current createArchive sets totalEvents = rrwebEvents.length + // userEvents.length which is always a non-negative integer. Stays // GREEN; catches negative/float regressions. // ──────────────────────────────────────────────────────────────────── it('meta.totalEvents is a non-negative integer', async () => { const meta = await getMetaOrFail(); expect( Number.isInteger(meta.totalEvents) && (meta.totalEvents as number) >= 0, `meta.totalEvents is not a non-negative integer. Got: ${JSON.stringify(meta.totalEvents)} ` + `(typeof: ${typeof meta.totalEvents}, isInteger: ${Number.isInteger(meta.totalEvents)}).`, ).toBe(true); }); // ──────────────────────────────────────────────────────────────────── // Test 6 — VIDEOBUFFERSECONDS LITERAL. GREEN-today regression guard: // current createArchive emits videoBufferSeconds: 30 literal. Stays // GREEN after Plan 02-03; this test catches any future regression that // changes the literal (CON-video-window contract). // ──────────────────────────────────────────────────────────────────── it('meta.videoBufferSeconds is exactly 30 (CON-video-window)', async () => { const meta = await getMetaOrFail(); expect( meta.videoBufferSeconds, `meta.videoBufferSeconds is not the CON-video-window literal ${VIDEO_BUFFER_SECONDS_LITERAL}. ` + `Got: ${JSON.stringify(meta.videoBufferSeconds)}.`, ).toBe(VIDEO_BUFFER_SECONDS_LITERAL); }); // ──────────────────────────────────────────────────────────────────── // Test 7 — LOGDURATIONMINUTES LITERAL. GREEN-today regression guard: // current createArchive emits logDurationMinutes: 10 literal. Stays // GREEN; catches CON-rrweb-window / CON-event-log-window regressions. // ──────────────────────────────────────────────────────────────────── it('meta.logDurationMinutes is exactly 10 (CON-rrweb-window / CON-event-log-window)', async () => { const meta = await getMetaOrFail(); expect( meta.logDurationMinutes, `meta.logDurationMinutes is not the CON-event-log-window literal ${LOG_DURATION_MINUTES_LITERAL}. ` + `Got: ${JSON.stringify(meta.logDurationMinutes)}.`, ).toBe(LOG_DURATION_MINUTES_LITERAL); }); // ──────────────────────────────────────────────────────────────────── // Test 8 — NO EXTRA FIELDS. Object.keys(meta) is a subset of // EXPECTED_KEYS (and EXPECTED_KEYS.length === 8). RED today: current // meta has `url` (singular) which is NOT in EXPECTED_KEYS (the new // schema has `urls` + `schemaVersion` replacing `url`). // // GREEN after Plan 02-03 adds `urls` + `schemaVersion` and removes // `url`. // ──────────────────────────────────────────────────────────────────── it('meta.json has no extra fields beyond the 8 expected keys', async () => { const meta = await getMetaOrFail(); const keys = Object.keys(meta); const extras = keys.filter((k) => !EXPECTED_KEYS.includes(k)); const missing = EXPECTED_KEYS.filter((k) => !keys.includes(k)); expect( extras.length, `meta.json contains extra (unexpected) fields: ${JSON.stringify(extras)}. ` + `EXPECTED_KEYS = ${JSON.stringify(EXPECTED_KEYS)}. Per D-P2-03 ` + `'8 fields exact', a strict superset is a regression vector for any future ` + `downstream consumer (UAT harness, future server upload). RED today because ` + `meta.url (singular) is in current shape but NOT in EXPECTED_KEYS.`, ).toBe(0); expect( missing.length, `meta.json is missing expected fields: ${JSON.stringify(missing)}. ` + `EXPECTED_KEYS = ${JSON.stringify(EXPECTED_KEYS)}. RED today because urls + ` + `schemaVersion are not in the current shape.`, ).toBe(0); }); });