feat(02-03): tab-url-tracker — chrome.tabs.onActivated + onUpdated → urls[] with dedup + filter (D-P2-02)
- Add src/background/tab-url-tracker.ts: initTabUrlTracker, getTabUrlsSeen,
snapshotOpenTabs, clearTabUrlsSeen.
- Filter: positive-allow regex ^(https?|chrome-extension):// — INCLUDE
https + http + chrome-extension://; default-deny chrome://, about:,
devtools://, file://, blob:, data: (per CONTEXT.md `<specifics>` URL
filter clause).
- Dedup: Set membership gate + first-seen-ordered array; getTabUrlsSeen
returns a slice so callers cannot mutate internal state.
- snapshotOpenTabs: defensive chrome.tabs.query({}) enumeration for SAVE-
time augmentation (DEC-011 Amendment 1 capability). Captures tabs the
operator opened but never activated.
- Module guards: initialized flag prevents double-listener registration;
all chrome.tabs.* listener calls wrapped in defensive try/catch matching
the src/background/index.ts:bootstrap pattern.
- Tier-1 grep-gate preserved (13 entries): NO `_resetForTesting` /
`_observeForTesting` ergonomic test hooks exported (would have leaked
into production bundles per tests/background/no-test-hooks-in-prod-
bundle.test.ts). Tests drive chrome.tabs.onUpdated callbacks directly
via the chrome stub — Plan 02-01 SUMMARY anticipated this option.
[Rule 3 - Blocking] tests/background/meta-json-urls-schema.test.ts Tests 3+4
extended to wire chrome.tabs.onUpdated callbacks directly (replaces the
optional `_resetForTesting` / `_observeForTesting` skeletons). Test 5
simplified (empty-tracker assertion needs no observation seeding on a
freshly-reset module graph). Test 5 F2 contract preserved verbatim.
Verification:
- npx tsc --noEmit → clean
- npx vitest run tests/background/meta-json-urls-schema.test.ts → 3/5 GREEN
(Tests 3+4+5 the tracker-contract trio flipped; Tests 1+2 still RED as
they pin the SessionMetadata + createArchive amendment — Task 2 territory)
This commit is contained in:
@@ -595,45 +595,37 @@ describe('Plan 02-01 Task 2 RED: meta.json urls[] schema + dedup/filter + empty-
|
||||
// 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.
|
||||
// Plan 02-03 GREEN path: tab-url-tracker landed without the optional
|
||||
// `_resetForTesting` / `_observeForTesting` ergonomic hooks (those
|
||||
// would have leaked into production bundles and violated the Tier-1
|
||||
// 13-entry FORBIDDEN_HOOK_STRINGS gate at
|
||||
// tests/background/no-test-hooks-in-prod-bundle.test.ts:108). The
|
||||
// canonical Plan-02-01-SUMMARY-anticipated alternative is to wire
|
||||
// chrome.tabs.onUpdated callbacks directly via the chrome stub.
|
||||
//
|
||||
// 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;
|
||||
// Mechanic: build the chrome stub, install it as globalThis.chrome,
|
||||
// import the tracker, call initTabUrlTracker() to register listeners
|
||||
// on the stub, then invoke the captured callbacks with synthetic
|
||||
// tab-update events. The tracker treats each callback invocation as
|
||||
// a real Chrome event; dedup + ordering invariants kick in naturally.
|
||||
const stub = buildBgStub();
|
||||
(globalThis as unknown as GlobalWithBgChrome).chrome = stub;
|
||||
const tracker = await import('../../src/background/tab-url-tracker');
|
||||
tracker.initTabUrlTracker();
|
||||
|
||||
// Synthetic chrome.tabs.onUpdated events. The first 'A' establishes
|
||||
// first-seen ordering; 'B' appends second; the repeated 'A' is
|
||||
// deduplicated. Mirrors the Plan 02-01 RED-test contract verbatim.
|
||||
const fireUpdate = (tabId: number, url: string): void => {
|
||||
stub.tabs.onUpdated._callbacks.forEach((cb) =>
|
||||
cb(tabId, { url }, { url }),
|
||||
);
|
||||
};
|
||||
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([
|
||||
fireUpdate(1, 'https://a.example.com');
|
||||
fireUpdate(2, 'https://b.example.com');
|
||||
fireUpdate(1, 'https://a.example.com');
|
||||
|
||||
expect(tracker.getTabUrlsSeen()).toEqual([
|
||||
'https://a.example.com',
|
||||
'https://b.example.com',
|
||||
]);
|
||||
@@ -646,33 +638,27 @@ describe('Plan 02-01 Task 2 RED: meta.json urls[] schema + dedup/filter + empty-
|
||||
// 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;
|
||||
}
|
||||
// Plan 02-03 GREEN path: same chrome.tabs.onUpdated driver pattern
|
||||
// as Test 3 (preserves the 13-entry Tier-1 grep gate by not
|
||||
// requiring `_observeForTesting` hooks on the tracker module).
|
||||
const stub = buildBgStub();
|
||||
(globalThis as unknown as GlobalWithBgChrome).chrome = stub;
|
||||
const tracker = await import('../../src/background/tab-url-tracker');
|
||||
tracker.initTabUrlTracker();
|
||||
|
||||
type TrackerModule = {
|
||||
getTabUrlsSeen: () => string[];
|
||||
_resetForTesting?: () => void;
|
||||
_observeForTesting?: (url: string) => void;
|
||||
const fireUpdate = (tabId: number, url: string): void => {
|
||||
stub.tabs.onUpdated._callbacks.forEach((cb) =>
|
||||
cb(tabId, { url }, { url }),
|
||||
);
|
||||
};
|
||||
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([
|
||||
// Filter test: include https + chrome-extension://; exclude chrome:// +
|
||||
// about: per CONTEXT.md `<specifics>` URL filter clause.
|
||||
fireUpdate(1, 'https://example.com');
|
||||
fireUpdate(2, 'chrome://newtab');
|
||||
fireUpdate(3, 'about:blank');
|
||||
fireUpdate(4, 'chrome-extension://abc/popup.html');
|
||||
|
||||
expect(tracker.getTabUrlsSeen()).toEqual([
|
||||
'https://example.com',
|
||||
'chrome-extension://abc/popup.html',
|
||||
]);
|
||||
@@ -692,30 +678,15 @@ describe('Plan 02-01 Task 2 RED: meta.json urls[] schema + dedup/filter + empty-
|
||||
// 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();
|
||||
// Plan 02-03 GREEN path: import the tracker on a freshly-reset
|
||||
// module graph (vi.resetModules in beforeEach), then immediately
|
||||
// query without firing any chrome.tabs.* callbacks. The empty
|
||||
// representation is the canonical whole-desktop-no-tab state.
|
||||
const tracker = await import('../../src/background/tab-url-tracker');
|
||||
// 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();
|
||||
expect(tracker.getTabUrlsSeen()).toEqual([]);
|
||||
expect(tracker.getTabUrlsSeen()).not.toBeUndefined();
|
||||
expect(tracker.getTabUrlsSeen()).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user