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