// 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 `` 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; 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; } 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; 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; get?: ReturnType; onUpdated: { addListener: (cb: TabUpdatedCb) => void; _callbacks: TabUpdatedCb[] }; onActivated: { addListener: (cb: TabActivatedCb) => void; _callbacks: TabActivatedCb[] }; }; 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==', ), 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 { 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 { 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, }), ); }; // Plan 02-02 (D-P2-01): simulate the offscreen-side CREATE_DOWNLOAD_URL // handler. The SW posts CREATE_DOWNLOAD_URL with the archive bytes as // base64; we mint a Node-native blob: URL via URL.createObjectURL and // reply DOWNLOAD_URL{requestId,url}. The archive bytes are captured // here BEFORE minting so meta.json extraction works regardless of // whether the SW ever calls chrome.downloads.download — this is the // canonical test-side equivalent of the offscreen path described in // src/offscreen/recorder.ts handleCreateDownloadUrl. const mintedRequestIds = new Set(); let capturedArchiveBytes: Uint8Array | null = null; const tryFireDownloadUrl = () => { const mintCalls = port.postMessage.mock.calls.filter( (c: unknown[]) => typeof c[0] === 'object' && c[0] !== null && (c[0] as { type?: unknown }).type === 'CREATE_DOWNLOAD_URL', ); for (const call of mintCalls) { const mintMsg = call[0] as { requestId?: string; dataBase64?: string; mimeType?: string; }; const requestId = mintMsg.requestId; if (typeof requestId !== 'string' || requestId.length === 0) continue; if (mintedRequestIds.has(requestId)) continue; mintedRequestIds.add(requestId); let url = ''; try { const binary = atob(mintMsg.dataBase64 ?? ''); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); // Capture the archive bytes for downstream meta.json extraction. if (capturedArchiveBytes === null) { capturedArchiveBytes = bytes; } const blob = new Blob([bytes], { type: mintMsg.mimeType ?? 'application/zip' }); url = URL.createObjectURL(blob); } catch { url = ''; } port.onMessage._listeners.forEach((fn) => fn({ type: 'DOWNLOAD_URL', requestId, url }), ); } }; 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(); tryFireDownloadUrl(); 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 }; // Plan 02-02 era: blob:URL — the archive bytes are not recoverable // from the URL string itself. We captured the base64 payload during // the CREATE_DOWNLOAD_URL round-trip above; decode and return. if (arg.url.startsWith('blob:') && capturedArchiveBytes !== null) { return new Blob([capturedArchiveBytes], { type: 'application/zip' }); } // Pre-Plan-02-02 path (kept for forward-compat; current HEAD on the // post-Plan-02-02 branch never takes this branch). 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' }); } return undefined; } 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; } /** * 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 ``. 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 ` + ` 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 `[]`, 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(); }); });