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) <noreply@anthropic.com>
622 lines
27 KiB
TypeScript
622 lines
27 KiB
TypeScript
// 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);
|
|
});
|
|
});
|