test(02-01): RED — pin meta.json urls[] schema + dedup/filter + empty-tracker (D-P2-02 + F2)
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) <noreply@anthropic.com>
This commit is contained in:
671
tests/background/meta-json-urls-schema.test.ts
Normal file
671
tests/background/meta-json-urls-schema.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user