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:
2026-05-20 16:06:06 +02:00
parent d3aa567a54
commit 7beb69059e
2 changed files with 302 additions and 85 deletions

View File

@@ -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();
});
});