diff --git a/app/src/idle.test.ts b/app/src/idle.test.ts new file mode 100644 index 0000000000..2ff90ca5e0 --- /dev/null +++ b/app/src/idle.test.ts @@ -0,0 +1,95 @@ +import { mount } from '@vue/test-utils'; +import { afterEach, beforeEach, describe, expect, SpyInstance, test, vi } from 'vitest'; +import { DefineComponent, defineComponent, h, onMounted, onUnmounted } from 'vue'; + +import { time as timeoutDuration } from './idle'; + +vi.mock('lodash', () => ({ + throttle: vi.fn((fn, _wait) => fn), +})); + +describe('idle', () => { + let testComponent: DefineComponent; + let idleTrackerEmitSpy: SpyInstance; + + beforeEach(async () => { + vi.useFakeTimers(); + + const { idleTracker, startIdleTracking, stopIdleTracking } = await import('./idle'); + + testComponent = defineComponent({ + setup() { + onMounted(() => startIdleTracking()); + onUnmounted(() => stopIdleTracking()); + }, + render: () => h('div'), + }); + + idleTrackerEmitSpy = vi.spyOn(idleTracker, 'emit'); + }); + + afterEach(() => { + vi.useRealTimers(); + + // Ensure the internal visible & idle variables in the imported idle + // are reset before every test + vi.resetModules(); + }); + + test('should emit "hide"/"show" when document visibility changes', () => { + mount(testComponent); + + // mock document visibility state + Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true }); + + document.dispatchEvent(new Event('visibilitychange')); + + expect(idleTrackerEmitSpy).toHaveBeenCalledWith('hide'); + + // mock document visibility state + Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }); + + document.dispatchEvent(new Event('visibilitychange')); + + expect(idleTrackerEmitSpy).toHaveBeenCalledWith('show'); + }); + + test('should not emit "idle" before the timeout has passed', () => { + mount(testComponent); + + document.dispatchEvent(new PointerEvent('pointerdown')); + + // advance less than the idle timeout duration + vi.advanceTimersByTime(1000); + + expect(idleTrackerEmitSpy).not.toHaveBeenCalledWith('idle'); + }); + + test('should emit "idle" after the timeout has passed', () => { + mount(testComponent); + + document.dispatchEvent(new PointerEvent('pointerdown')); + + // advance past the idle timeout duration (added 1000 just in case there's timing issues) + vi.advanceTimersByTime(timeoutDuration + 1000); + + expect(idleTrackerEmitSpy).toHaveBeenCalledWith('idle'); + }); + + test('should emit "active" after being idle', () => { + mount(testComponent); + + document.dispatchEvent(new PointerEvent('pointerdown')); + + // advance past the idle timeout duration (added 1000 just in case there's timing issues) + vi.advanceTimersByTime(timeoutDuration + 1000); + + // stop the current idle state + document.dispatchEvent(new PointerEvent('pointerdown')); + + // advance past the throttle duration (500) + vi.advanceTimersByTime(1000); + + expect(idleTrackerEmitSpy).toHaveBeenCalledWith('active'); + }); +}); diff --git a/app/src/idle.ts b/app/src/idle.ts index c5fd8bb5ba..e63b524aff 100644 --- a/app/src/idle.ts +++ b/app/src/idle.ts @@ -1,20 +1,23 @@ +import { throttle } from 'lodash'; import mitt from 'mitt'; const events = ['pointermove', 'pointerdown', 'keydown']; -const time = 5 * 60 * 1000; // 5 min in ms +export const time = 5 * 60 * 1000; // 5 min in ms -let timeout: NodeJS.Timeout; +let timeout: number | null; let visible = true; let idle = false; export const idleTracker = mitt(); +const throttledOnIdleEvents = throttle(onIdleEvents, 500); + export function startIdleTracking(): void { document.addEventListener('visibilitychange', onVisibilityChange); for (const event of events) { - document.addEventListener(event, onIdleEvents); + document.addEventListener(event, throttledOnIdleEvents); } resetTimeout(); @@ -24,7 +27,7 @@ export function stopIdleTracking(): void { document.removeEventListener('visibilitychange', onVisibilityChange); for (const event of events) { - document.removeEventListener(event, onIdleEvents); + document.removeEventListener(event, throttledOnIdleEvents); } } @@ -51,10 +54,11 @@ function onVisibilityChange() { function resetTimeout() { if (timeout) { - clearTimeout(timeout); + window.clearTimeout(timeout); + timeout = null; } - timeout = setTimeout(() => { + timeout = window.setTimeout(() => { idle = true; idleTracker.emit('idle'); }, time);