import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import * as timer from '../session-timer'; import { READ_ONLY_CONTENT_CALLABLE } from '../session-timer'; describe('session-timer', () => { beforeEach(() => { vi.useFakeTimers(); // Reset to default config for each test timer.setConfig({ mode: 'inactivity', minutes: 15 }); timer.stopTimer(); }); afterEach(() => { timer.stopTimer(); vi.useRealTimers(); }); it('fires callback after inactivity timeout', () => { const cb = vi.fn(); timer.onExpired(cb); timer.resetTimer(); // Default is 15 minutes = 900_000 ms vi.advanceTimersByTime(899_999); expect(cb).not.toHaveBeenCalled(); vi.advanceTimersByTime(1); expect(cb).toHaveBeenCalledTimes(1); }); it('resets timer on each resetTimer() call', () => { const cb = vi.fn(); timer.onExpired(cb); timer.resetTimer(); // Advance 10 minutes vi.advanceTimersByTime(600_000); expect(cb).not.toHaveBeenCalled(); // Reset — clock should restart timer.resetTimer(); // Advance another 10 minutes (20 total, but only 10 since last reset) vi.advanceTimersByTime(600_000); expect(cb).not.toHaveBeenCalled(); // Advance remaining 5 minutes to hit the 15-minute mark from last reset vi.advanceTimersByTime(300_000); expect(cb).toHaveBeenCalledTimes(1); }); it('does not fire when mode is every_time', () => { const cb = vi.fn(); timer.onExpired(cb); timer.setConfig({ mode: 'every_time' }); timer.resetTimer(); // Advance well past the default 15 minutes vi.advanceTimersByTime(60 * 60 * 1000); expect(cb).not.toHaveBeenCalled(); }); it('respects updated minutes', () => { const cb = vi.fn(); timer.onExpired(cb); timer.setConfig({ mode: 'inactivity', minutes: 5 }); timer.resetTimer(); vi.advanceTimersByTime(4 * 60 * 1000); expect(cb).not.toHaveBeenCalled(); vi.advanceTimersByTime(60 * 1000); expect(cb).toHaveBeenCalledTimes(1); }); it('getConfig returns current config', () => { // Default expect(timer.getConfig()).toEqual({ mode: 'inactivity', minutes: 15 }); // After set timer.setConfig({ mode: 'every_time' }); expect(timer.getConfig()).toEqual({ mode: 'every_time' }); timer.setConfig({ mode: 'inactivity', minutes: 30 }); expect(timer.getConfig()).toEqual({ mode: 'inactivity', minutes: 30 }); }); it('stopTimer prevents firing', () => { const cb = vi.fn(); timer.onExpired(cb); timer.resetTimer(); vi.advanceTimersByTime(600_000); // 10 minutes in timer.stopTimer(); // Advance past what would have been the 15-minute mark vi.advanceTimersByTime(600_000); expect(cb).not.toHaveBeenCalled(); }); }); describe('READ_ONLY_CONTENT_CALLABLE — inversion exclusion set', () => { // The SW handler invokes resetTimer() on every message whose type is NOT // in this set. These cases encode the documented inversion contract from // Plan C Phase 5: popup-only messages reset, content-callable writes // reset, only passive content reads (currently just get_autofill_candidates) // do NOT reset. it('popup-only message would reset the timer (not in exclusion set)', () => { // e.g. list_items — popup interaction is unambiguously active use expect(READ_ONLY_CONTENT_CALLABLE.has('list_items')).toBe(false); }); it('content-callable get_autofill_candidates does NOT reset (in exclusion set)', () => { expect(READ_ONLY_CONTENT_CALLABLE.has('get_autofill_candidates')).toBe(true); }); it('content-callable capture_save_login DOES reset (write op = active use)', () => { expect(READ_ONLY_CONTENT_CALLABLE.has('capture_save_login')).toBe(false); }); it('content-callable check_credential DOES reset', () => { // Asking "is this credential already saved" is user-initiated. expect(READ_ONLY_CONTENT_CALLABLE.has('check_credential')).toBe(false); }); });