Milestone v1 (v2.0.0): Mokosh — Session Capture #1

Merged
strategy155 merged 297 commits from gsd/phase-04-harden-clean-up-optional into main 2026-05-31 15:34:17 +00:00
Showing only changes of commit 9e45d333cc - Show all commits

View File

@@ -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 `<specifics>` 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<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>; }
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<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>;
get?: ReturnType<typeof vi.fn>;
onUpdated: { addListener: (cb: TabUpdatedCb) => void; _callbacks: TabUpdatedCb[] };
onActivated: { addListener: (cb: TabActivatedCb) => void; _callbacks: TabActivatedCb[] };
};
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==',
),
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<Response> {
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<Blob | 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 };
// Current HEAD: data:application/zip;base64,<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<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;
}
/**
* 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 `<decisions>`. 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 ` +
`<specifics> 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 `[<extension-origin>]`, 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();
});
});