UNPKG

inactivity-countdown-timer

Version:

A plain JS (Typescript) module that will countdown and timeout when users are inactive/idle.

414 lines (368 loc) 18.7 kB
import { InactivityCountdownTimer, IInactivityConfig, IInactivityDependencies, isNumberNotNan } from "../src/inactivity-countdown-timer"; import 'core-js/features/object/assign'; export type Spied<T> = { [Method in keyof T]: jasmine.Spy }; describe('Inactivity countdown timer', () => { function setup(params?: IInactivityConfig, deps?: IInactivityDependencies): {ict: InactivityCountdownTimer} { const ict = new InactivityCountdownTimer({}, deps); ict.setup(params); return {ict}; } function setupAndStart(params?: IInactivityConfig, deps?: IInactivityDependencies): {ict: InactivityCountdownTimer} { const {ict} = setup(params, deps); ict.start(); return {ict}; } function setupAndStartWithClock(params: IInactivityConfig, deps?: IInactivityDependencies): {ict: InactivityCountdownTimer} { jasmine.clock().install(); jasmine.clock().mockDate(); return setupAndStart(params, deps); } function cleanupWithClock(ict: InactivityCountdownTimer): void { ict.cleanup(); ict = null; jasmine.clock().uninstall(); } describe('construction', () => { it('logs to the console when the idleTimeoutTime is smaller than the startCountdownTimerAt value', () => { const log = spyOn(window.console, 'log'); const {ict} = setupAndStart({startCountDownTimerAt: 20000, idleTimeoutTime: 10000}); ict.cleanup(); expect(log).toHaveBeenCalledWith('startCountdown time must be smaller than idleTimeoutTime, setting to idleTimeoutTime') }); it('attaches event handlers to document.click, document.mousemove, document.keypress, window.load when none are passed in', () => { const documentAttachEventSpy = spyOn(document, 'addEventListener').and.callThrough(); const windowAttachEventSpy = spyOn(window, 'addEventListener').and.callThrough(); const {ict} = setupAndStart(); ['click', 'mousemove', 'keypress'].forEach((event) => { expect(documentAttachEventSpy).toHaveBeenCalledWith(event, ict as any, false); }); expect(windowAttachEventSpy).toHaveBeenCalledWith('load', ict as any, false); ict.cleanup(); }); it('Attaches custom event handlers to document and window when they are passed in', () => { const documentAttachEventSpy = spyOn(document, 'addEventListener').and.callThrough(); const windowAttachEventSpy = spyOn(window, 'addEventListener').and.callThrough(); const {ict} = setupAndStart({resetEvents: ['scroll','dblclick'], windowResetEvents: ['blur']}); ['scroll', 'dblclick'].forEach((event) => { expect(documentAttachEventSpy).toHaveBeenCalledWith(event, ict as any, false); }); expect(windowAttachEventSpy).toHaveBeenCalledWith('blur', ict as any, false); ict.cleanup(); }); }); describe('requires a number for idle timeout time, sets a default if none provided', () => { it('sets a reasonable default if maths fails to pass a number', () => { const params = { idleTimeoutTime: parseInt('aaa', 10), }; const deps = { logger: jasmine.createSpyObj(['log']) }; const {ict} = setupAndStart(params, deps); const thirtyMinutes = 30 * 60 * 1000; expect(ict['idleTimeoutTime']).toEqual(thirtyMinutes); expect(deps.logger.log).toHaveBeenCalledWith('idleTimeoutTime passed was not a number, setting to 30 minutes'); ict.cleanup(); }) }); describe('start', () => { it('sets the status to started from stopped', () => { const ict = new InactivityCountdownTimer(); expect(ict.status).toEqual('stopped'); ict.start(); expect(ict.status).toEqual('started'); expect(ict.started).toBe(true); expect(ict.stopped).toBe(false); ict.cleanup(); }); }); describe('stop', () => { it('sets the status to stopped from started', () => { const {ict} = setupAndStart(); expect(ict.status).toEqual('started'); ict.stop(); expect(ict.status).toEqual('stopped'); expect(ict.stopped).toBe(true); expect(ict.started).toBe(false); ict.cleanup(); }); }); describe('cleanup removing event listeners -', () => { it('removes event listeners and clears timers when .cleanup is called', () => { const documentRemoveEventSpy = spyOn(document, 'removeEventListener').and.callThrough(); const windowRemoveEventSpy = spyOn(window, 'removeEventListener').and.callThrough(); const clearInterval = spyOn(window, 'clearInterval').and.callThrough(); const clearTimeout = spyOn(window, 'clearTimeout').and.callThrough(); const {ict} = setupAndStart({ resetEvents: ['click', 'mousemove'], windowResetEvents: ['blur', 'load'], throttleDuration: 1000 }); ict.handleEvent('click' as any); ict.cleanup(); ['click', 'mousemove'].forEach((event) => { expect(documentRemoveEventSpy).toHaveBeenCalledWith(event, ict as any, false); }); ['blur', 'load'].forEach((event) => { expect(windowRemoveEventSpy).toHaveBeenCalledWith(event, ict as any, false); }); expect(clearTimeout).toHaveBeenCalledWith(ict['throttleTimeoutId']); expect(clearInterval).toHaveBeenCalledWith(ict['idleIntervalId']); }) }); describe('timing out -', () => { it('calls the params.timeoutCallback if one was passed in', () => { const callback = jasmine.createSpy('callback'); const {ict} = setupAndStartWithClock({idleTimeoutTime: 2000, timeoutCallback: callback}); expect(callback).not.toHaveBeenCalled(); jasmine.clock().tick(2001); expect(callback).toHaveBeenCalled(); cleanupWithClock(ict) }); it('cleanups event listeners the idleTimeout is finished', () => { const {ict} = setupAndStartWithClock({idleTimeoutTime: 2000}); // we need to call through so the interval timer stops watching const cleanup = spyOn(ict, 'cleanup').and.callThrough(); expect(cleanup).not.toHaveBeenCalled(); jasmine.clock().tick(2001); expect(cleanup).toHaveBeenCalled(); cleanupWithClock(ict); }); it(`resets the timeout time if one of the event handlers get's called`, () => { ['click', 'mousemove', 'keypress'].forEach(() => { const {ict} = setupAndStartWithClock({idleTimeoutTime: 2000}); // we need to call through so the interval timer stops watching const timeout = spyOn(ict, 'timeout' as any).and.callThrough(); jasmine.clock().tick(1001); // 1001 total time expect(timeout).not.toHaveBeenCalled(); dispatchMouseEvent('click'); // timer will reset and initialise at 2000 jasmine.clock().tick(1000); // 2001 total time expect(timeout).not.toHaveBeenCalled(); jasmine.clock().tick(4000); // 3001 expect(timeout).toHaveBeenCalledTimes(1); cleanupWithClock(ict) }); }); }); describe('counting down - ', () => { it('calls the params.countDownCallback when the time reaches the startCountdownTimerAt value', () => { const callback = jasmine.createSpy('callback'); const settings: IInactivityConfig = { idleTimeoutTime: 20000, startCountDownTimerAt: 10000, countDownCallback: callback }; const {ict} = setupAndStartWithClock(settings); jasmine.clock().tick(9000); expect(callback).not.toHaveBeenCalled(); jasmine.clock().tick(1000); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith(10); jasmine.clock().tick(1000); expect(callback).toHaveBeenCalledTimes(2); expect(callback).toHaveBeenCalledWith(9); jasmine.clock().tick(1000); expect(callback).toHaveBeenCalledWith(8); expect(callback).toHaveBeenCalledTimes(3); cleanupWithClock(ict) }); it('calls the params.countDownCancelledCallback when the countdown is aborted', () => { const countDownCallback = jasmine.createSpy('countDownCallback'); const countDownCancelledCallback = jasmine.createSpy('countDownCancelledCallback'); const settings: IInactivityConfig = { idleTimeoutTime: 20000, startCountDownTimerAt: 10000, countDownCallback: countDownCallback, countDownCancelledCallback: countDownCancelledCallback }; const {ict} = setupAndStartWithClock(settings); jasmine.clock().tick(9000); expect(countDownCallback).not.toHaveBeenCalled(); jasmine.clock().tick(1000); expect(countDownCallback).toHaveBeenCalledWith(10); jasmine.clock().tick(1000); expect(countDownCallback).toHaveBeenCalledWith(9); dispatchMouseEvent('click'); // timer will reset and initialise at 20000 jasmine.clock().tick(2000); expect(countDownCancelledCallback).toHaveBeenCalled(); cleanupWithClock(ict) }); }); describe('timeoutPrecision', () => { it('dynamically adjust the timeout precision', () => { const countDownCallback = jasmine.createSpy('countDownCallback'); const settings: IInactivityConfig = { idleTimeoutTime: 1000 * 60 * 5, // 5 minutes startCountDownTimerAt: 1000 * 30, // 30 seconds countDownCallback: countDownCallback }; const {ict} = setupAndStartWithClock(settings); const fourMins = 1000 * 60 * 4; const twentyNineSeconds = 1000 * 29; // should call countdown callback only once at 4:30 // then every second jasmine.clock().tick(fourMins + twentyNineSeconds); // 4:29 expect(countDownCallback).not.toHaveBeenCalled(); jasmine.clock().tick(1000); // 4:30 seconds expect(countDownCallback).toHaveBeenCalledTimes(1); jasmine.clock().tick(1000); // 4:31 seconds expect(countDownCallback).toHaveBeenCalledTimes(2); jasmine.clock().tick(1000); // 4:32 seconds expect(countDownCallback).toHaveBeenCalledTimes(3); dispatchMouseEvent('click'); // timer will reset and initialise at 5 mins expect(countDownCallback).toHaveBeenCalledTimes(3); jasmine.clock().tick(fourMins + twentyNineSeconds); // 4:29 expect(countDownCallback).toHaveBeenCalledTimes(3); jasmine.clock().tick(1000); // 4:30 expect(countDownCallback).toHaveBeenCalledTimes(4); cleanupWithClock(ict); }); }); describe('throttle', () => { it('throttles the handle event function when a throttle value is passed in', () => { const settings: IInactivityConfig = { idleTimeoutTime: 1000 * 60 * 5, // 5 minutes startCountDownTimerAt: 1000 * 30, // 30 seconds throttleDuration: 1000 * 30, // 30 seconds }; const {ict} = setupAndStartWithClock(settings); const handleEventSpy = spyOn(ict, 'handleEvent').and.callThrough(); jasmine.clock().tick(15000); // 15 seconds dispatchMouseEvent('click'); // event will trigger handleEvent and reset the timer expect(handleEventSpy).toHaveBeenCalledTimes(1); jasmine.clock().tick(29000); // 29 seconds after click handleEvent will not be triggered as it is throttled dispatchMouseEvent('click'); // nothing! expect(handleEventSpy).not.toHaveBeenCalledTimes(2); jasmine.clock().tick(1000); // 30 seconds clock will dispatchMouseEvent('click'); // timer will reset and initialise expect(handleEventSpy).toHaveBeenCalledTimes(2); jasmine.clock().tick(31000); // 31 more seconds clock will reset again; dispatchMouseEvent('click'); // timer will reset and initialise expect(handleEventSpy).toHaveBeenCalledTimes(3); cleanupWithClock(ict); }); it('ignores a throttle duration large than 1/5th the timeout time', () => { const log = spyOn(window.console, 'log'); const settings: IInactivityConfig = { idleTimeoutTime: 1000 * 60 * 5, // 5 minutes startCountDownTimerAt: 1000 * 30, // 30 seconds throttleDuration: 1000 * 60, // 30 seconds }; const {ict} = setupAndStartWithClock(settings); const handleEventSpy = spyOn(ict, 'handleEvent').and.callThrough(); expect(log).toHaveBeenCalledWith('throttle time must be smaller than 1/5th timeout time: 270000 setting to 54000ms'); jasmine.clock().tick(1000); dispatchMouseEvent('click'); // event will trigger handleEvent and reset the timer expect(handleEventSpy).toHaveBeenCalledTimes(1); jasmine.clock().tick(53000); // 53 seconds after click handleEvent will not be triggered as it is throttled dispatchMouseEvent('click'); // nothing! expect(handleEventSpy).not.toHaveBeenCalledTimes(2); jasmine.clock().tick(1000); // 54 seconds after click handleEvent will not be triggered as it is throttled dispatchMouseEvent('click'); expect(handleEventSpy).toHaveBeenCalledTimes(2); cleanupWithClock(ict); }) }); describe('localstorage - ', () => { it('reacts to updates by other windows through local storage', () => { const callback = jasmine.createSpy('callback'); const localStorageKey = 'idleTimeoutTimeKey'; const settings = { idleTimeoutTime: 5000, timeoutCallback: callback, localStorageKey: localStorageKey }; const {ict} = setupAndStartWithClock(settings); jasmine.clock().tick(4000); expect(callback).not.toHaveBeenCalled(); // reset the time const currentMockTime = (new Date()).getTime().toString(); localStorage.setItem(localStorageKey,currentMockTime); jasmine.clock().tick(4000); expect(callback).not.toHaveBeenCalled(); jasmine.clock().tick(5000); expect(callback).toHaveBeenCalled(); cleanupWithClock(ict) }); it('logs a message when local storage is not available', () => { spyOn(console, 'log'); // see this issue for storage.prototype https://github.com/jasmine/jasmine/issues/299; spyOn(Storage.prototype, 'setItem').and.throwError('some error'); const {ict} = setup({}); const expectedMessage = 'LOCAL STORAGE IS NOT AVAILABLE FOR SYNCING TIMEOUT ACROSS TABS'; expect(console.log).toHaveBeenCalledWith(expectedMessage, jasmine.any(Error)); ict.cleanup(); }); it('works even without local storage', () => { const countDownCallback = jasmine.createSpy('countDownCallback'); const countDownCancelledCallback = jasmine.createSpy('countDownCancelledCallback'); const settings: IInactivityConfig = { idleTimeoutTime: 20000, startCountDownTimerAt: 10000, countDownCallback: countDownCallback, countDownCancelledCallback: countDownCancelledCallback }; const {ict} = setupAndStartWithClock(settings, {localStorage: null}); jasmine.clock().tick(9000); expect(countDownCallback).not.toHaveBeenCalled(); jasmine.clock().tick(1000); expect(countDownCallback).toHaveBeenCalledWith(10); jasmine.clock().tick(1000); expect(countDownCallback).toHaveBeenCalledWith(9); dispatchMouseEvent('click'); // timer will reset and initialise at 20000 jasmine.clock().tick(2000); expect(countDownCancelledCallback).toHaveBeenCalled(); cleanupWithClock(ict); }) }); describe('isNumberNotNull', () => { it('returns true for a number', () => { const result = isNumberNotNan(10); expect(result).toBe(true); const result2 = isNumberNotNan(0); expect(result2).toBe(true); }); it('returns false for NaN', () => { // type of NaN is a number :( const result = isNumberNotNan(NaN); expect(result).toBe(false); }); it('returns false for other values', () => { // type of NaN is a number :( const result = isNumberNotNan({}); expect(result).toBe(false); const result2 = isNumberNotNan(''); expect(result2).toBe(false); const result3 = isNumberNotNan(false); expect(result3).toBe(false); }) }); }); // see this link for eventClasses https://developer.mozilla.org/en-US/docs/Web/API/Document/createEvent#Notes function dispatchMouseEvent(eventName: string): void { // http://stackoverflow.com/questions/2490825/how-to-trigger-event-in-javascript const eventClass: string = 'MouseEvents'; let docEvent: Event; if(document.createEvent){ docEvent = document.createEvent(eventClass); docEvent.initEvent(eventName, true, true); } else { docEvent = eventName as any; } // @ts-ignore dispatchEvent(document, docEvent); } function dispatchEvent(element: any, event: Event): void { if(element['dispatchEvent']){ element.dispatchEvent(event, true) } else if(element['fireEvent']){ element.fireEvent('on' + event); // ie8 fix } else { throw new Error('No dispatch event method in browser') } }