import { afterEach, describe, expect, it, vi } from 'vitest'; import { HeaderStash, normalizeHeaders, type OnBeforeSendHeadersDetails, type WebRequestLike, } from '../../src/background/capture/headers.js'; function details(over: Partial = {}): OnBeforeSendHeadersDetails { return { requestId: '1', url: 'https://cdn.example.com/big.zip', method: 'GET', tabId: 7, requestHeaders: [ { name: 'User-Agent', value: 'Firefox' }, { name: 'Referer', value: 'https://example.com/page' }, { name: 'Cookie', value: 'sid=abc' }, ], ...over, }; } describe('normalizeHeaders', () => { it('lower-cases names and drops valueless entries', () => { expect( normalizeHeaders([ { name: 'Accept-Encoding', value: 'gzip' }, { name: 'X-Empty' }, ]), ).toEqual({ 'accept-encoding': 'gzip' }); }); it('joins repeated header names with ", "', () => { expect( normalizeHeaders([ { name: 'X-Fwd', value: 'a' }, { name: 'x-fwd', value: 'b' }, ]), ).toEqual({ 'x-fwd': 'a, b' }); }); it('returns {} for missing headers', () => { expect(normalizeHeaders(undefined)).toEqual({}); }); }); describe('HeaderStash', () => { let now = 1_000_000; const clock = () => now; afterEach(() => { now = 1_000_000; }); it('stashes and reads back normalized headers', () => { const s = new HeaderStash({ now: clock, sweepIntervalMs: 0 }); s.put(details()); const got = s.peek('1'); expect(got).toMatchObject({ requestId: '1', method: 'GET', tabId: 7, headers: { 'user-agent': 'Firefox', referer: 'https://example.com/page', cookie: 'sid=abc' }, }); }); it('take() reads once then the entry is gone', () => { const s = new HeaderStash({ now: clock, sweepIntervalMs: 0 }); s.put(details()); expect(s.take('1')).toBeDefined(); expect(s.take('1')).toBeUndefined(); expect(s.size).toBe(0); }); it('evicts the oldest entry past the size cap', () => { const s = new HeaderStash({ now: clock, maxEntries: 2, sweepIntervalMs: 0 }); s.put(details({ requestId: 'a' })); s.put(details({ requestId: 'b' })); s.put(details({ requestId: 'c' })); expect(s.size).toBe(2); expect(s.peek('a')).toBeUndefined(); expect(s.peek('b')).toBeDefined(); expect(s.peek('c')).toBeDefined(); }); it('re-putting a requestId refreshes its recency and TTL (redirect case)', () => { const s = new HeaderStash({ now: clock, maxEntries: 2, ttlMs: 1000, sweepIntervalMs: 0 }); s.put(details({ requestId: 'a' })); now += 10; s.put(details({ requestId: 'b' })); now += 10; s.put(details({ requestId: 'a', url: 'https://cdn.example.com/redirected.zip' })); // a moves to newest s.put(details({ requestId: 'c' })); // evicts the now-oldest, which is b expect(s.peek('b')).toBeUndefined(); expect(s.peek('a')?.url).toBe('https://cdn.example.com/redirected.zip'); now += 995; // < 1000 since the re-put of a expect(s.peek('a')).toBeDefined(); }); it('treats an entry past its TTL as absent and deletes it on read', () => { const s = new HeaderStash({ now: clock, ttlMs: 5 * 60_000, sweepIntervalMs: 0 }); s.put(details()); now += 5 * 60_000 + 1; expect(s.peek('1')).toBeUndefined(); expect(s.size).toBe(0); }); it('sweep() drops expired entries and keeps fresh ones', () => { const s = new HeaderStash({ now: clock, ttlMs: 1000, sweepIntervalMs: 0 }); s.put(details({ requestId: 'old1' })); s.put(details({ requestId: 'old2' })); now += 1001; s.put(details({ requestId: 'fresh' })); expect(s.sweep()).toBe(2); expect(s.size).toBe(1); expect(s.peek('fresh')).toBeDefined(); }); it('drop() forgets one request', () => { const s = new HeaderStash({ now: clock, sweepIntervalMs: 0 }); s.put(details({ requestId: 'x' })); s.drop('x'); expect(s.peek('x')).toBeUndefined(); }); }); // --- attach() wiring --------------------------------------------------------------- class FakeEvent void> { listeners: L[] = []; addListener = (l: L): void => void this.listeners.push(l); removeListener = (l: L): void => { this.listeners = this.listeners.filter((x) => x !== l); }; emit(...args: Parameters): void { for (const l of this.listeners) (l as (...a: Parameters) => void)(...args); } } function fakeWebRequest() { return { onBeforeSendHeaders: new FakeEvent<(d: OnBeforeSendHeadersDetails) => void>(), onCompleted: new FakeEvent<(d: { requestId: string }) => void>(), onErrorOccurred: new FakeEvent<(d: { requestId: string }) => void>(), }; } describe('HeaderStash.attach', () => { it('stashes on onBeforeSendHeaders and forgets on onCompleted / onErrorOccurred', () => { const wr = fakeWebRequest(); const s = new HeaderStash({ sweepIntervalMs: 0 }); s.attach(wr as unknown as WebRequestLike); wr.onBeforeSendHeaders.emit(details({ requestId: 'done' })); wr.onBeforeSendHeaders.emit(details({ requestId: 'failed' })); expect(s.size).toBe(2); wr.onCompleted.emit({ requestId: 'done' }); wr.onErrorOccurred.emit({ requestId: 'failed' }); expect(s.size).toBe(0); s.detach(); wr.onBeforeSendHeaders.emit(details({ requestId: 'after-detach' })); expect(s.size).toBe(0); expect(wr.onBeforeSendHeaders.listeners).toHaveLength(0); }); it('runs a background sweep on its interval', () => { vi.useFakeTimers(); let t = 0; const s = new HeaderStash({ now: () => t, ttlMs: 1000, sweepIntervalMs: 500 }); const wr = fakeWebRequest(); s.attach(wr as unknown as WebRequestLike); wr.onBeforeSendHeaders.emit(details({ requestId: 'a' })); t = 2000; vi.advanceTimersByTime(500); expect(s.size).toBe(0); s.detach(); vi.useRealTimers(); }); it('refuses a double attach', () => { const s = new HeaderStash({ sweepIntervalMs: 0 }); const wr = fakeWebRequest(); s.attach(wr as unknown as WebRequestLike); expect(() => s.attach(wr as unknown as WebRequestLike)).toThrow(/already attached/); s.detach(); }); });