From 9e45d333ccde3407ff296873d2641d6aea6ae77e Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 20 May 2026 15:27:39 +0200 Subject: [PATCH] =?UTF-8?q?test(02-01):=20RED=20=E2=80=94=20pin=20meta.jso?= =?UTF-8?q?n=20urls[]=20schema=20+=20dedup/filter=20+=20empty-tracker=20(D?= =?UTF-8?q?-P2-02=20+=20F2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 02-01 Task 2 RED gate. Five failing tests pin D-P2-02 (meta.json url→urls migration) and the F2 plan-checker-iter-1 resolution (empty- tracker → urls:[], no sentinel fallback) ahead of Plan 02-03. Tests: 1. SessionMetadata interface in src/shared/types.ts has 'urls: string[]' and no 'url:' field. Source-text scan (typecheck disabled in vitest.config.ts so tsc-failure pin would be a no-op). 2. createArchive emits meta.json with Array urls and no url field. 3. meta.urls deduplicates repeated URLs (first-seen-first order). 4. meta.urls filters chrome:// + about:; includes chrome-extension://. 5. Empty tracker → meta.urls === [] (NOT undefined/null/[origin]). RED evidence (vitest 4.1.6 against current HEAD): × Test 1: SessionMetadata interface body does not contain a 'urls: string[]' field (and still contains 'url:'). × Test 2: meta.urls is not an Array. Got: undefined. × Tests 3+4+5: src/background/tab-url-tracker.ts does not exist — Plan 02-03 GREEN gate. Each expect.fail emits the precise contract for the GREEN flip (export name getTabUrlsSeen(), dedup Set semantics, first-seen-first order, URL filter spec, empty-array empty-tracker resolution). Module seam (Plan 02-03 implements): src/background/tab-url-tracker.ts export function getTabUrlsSeen(): string[] Fed by chrome.tabs.onUpdated + chrome.tabs.onActivated (per DEC-011 Amendment 1 'tabs' permission grant). Baseline: 155 GREEN preserved (no regressions); this plan now has 8 NEW RED tests total (Task 1: 3 + Task 2: 5). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../background/meta-json-urls-schema.test.ts | 671 ++++++++++++++++++ 1 file changed, 671 insertions(+) create mode 100644 tests/background/meta-json-urls-schema.test.ts diff --git a/tests/background/meta-json-urls-schema.test.ts b/tests/background/meta-json-urls-schema.test.ts new file mode 100644 index 0000000..aa5a2d1 --- /dev/null +++ b/tests/background/meta-json-urls-schema.test.ts @@ -0,0 +1,671 @@ +// tests/background/meta-json-urls-schema.test.ts +// +// Plan 02-01 Task 2 (RED gate, Wave 0 of Phase 2). +// Pins decision D-P2-02 (meta.json schema migrates `url: string` → +// `urls: string[]`) plus the F2 plan-checker-iter-1 resolution +// (empty-tracker case → `urls: []`, NO sentinel fallback). +// +// Contract pinned here (RED today; flips GREEN after Plan 02-03): +// +// 1. The SessionMetadata interface in src/shared/types.ts has a +// `urls: string[]` field AND does NOT have a `url:` field. +// Structural source-text scan; vitest.config.ts has +// typecheck:{enabled:false}, so a tsc-failure-based pin would be +// a no-op. The source-text scan is RED today because line 105 of +// src/shared/types.ts reads `url: string;`. +// +// 2. createArchive emits meta.json with `urls: string[]` (Array) +// AND `url` is undefined. The current createArchive code path +// assembles `metadata.url = new URL(chrome.runtime.getURL('')) +// .origin`, so meta.url is the extension origin and meta.urls is +// undefined — both assertions fail. +// +// 3. meta.urls is deduplicated and ordered first-seen-first. Driven +// via the tab-url-tracker module (planner-suggested name: +// `src/background/tab-url-tracker.ts`, export name: +// `getTabUrlsSeen(): string[]`). The module does not exist at +// current HEAD; the dynamic import throws and expect.fail surfaces +// the missing-module RED signal. +// +// 4. meta.urls filters chrome:// and about: URLs and includes +// chrome-extension:// URLs (per CONTEXT.md `` 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, + }), + ); + }; + + 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 }; + // Current HEAD: data:application/zip;base64,. Base64-decode. + const DATA_PREFIX = 'data:application/zip;base64,'; + if (arg.url.startsWith(DATA_PREFIX)) { + const b64 = arg.url.substring(DATA_PREFIX.length); + const bytes = Buffer.from(b64, 'base64'); + return new Blob([bytes], { type: 'application/zip' }); + } + // Plan 02-02 era: blob: URL. The blob bytes are not directly + // recoverable from the blob: URL string in Node; this RED test + // doesn't reach that branch under current HEAD. The Plan 02-03 GREEN + // implementer will likely need a different helper (e.g. spy on + // URL.createObjectURL to capture the underlying Blob reference). + return undefined; +} + +async function bootSwInRecState(stub: BgChromeStub): Promise { + 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(); + }); +});