import * as JSDOM from 'jsdom' import { EventEmitter } from 'stream' import { domReady } from '../../../lib/dom-ready' import type { CollectorOptions } from '../../collector' import { EventContext } from '../../event-context' import { session, SessionOptions } from '../../session' import * as pageInfo from '../page-info' import { PageTracker, PageView } from '../page-tracker' declare global { /* eslint-disable @typescript-eslint/no-namespace */ namespace NodeJS { interface Global { document: Document window: Window navigator: Navigator } } } class TestFocusTime extends EventEmitter { public restart() { this.emit('focus_time.end', this.currentFocusTime) } public startAutocapture() {} public stopAutocapture() {} public get currentFocusTime() { return 0 } } let focusTimer = new TestFocusTime() jest.mock('../../time/focus-timer', () => ({ FocusTimer: jest.fn().mockImplementation(() => focusTimer) })) describe(PageTracker, () => { let jsd: JSDOM.JSDOM beforeEach(() => { jsd = new JSDOM.JSDOM('', { runScripts: 'dangerously', resources: 'usable', url: 'https://test.koala.live' }) global.window = jsd.window as unknown as Window & typeof globalThis global.document = jsd.window.document global.history = jsd.window.history global.location = jsd.window.location global.screen = jsd.window.screen Object.defineProperty(global.document, 'readyState', { get() { return 'complete' } }) // @ts-expect-error test global.history.back = (to: string) => { const defaults = pageInfo.pageDefaults() jest.spyOn(pageInfo, 'pageDefaults').mockReturnValueOnce({ ...defaults, path: to }) window.dispatchEvent(new window.Event('popstate')) } jest.clearAllMocks() focusTimer = new TestFocusTime() }) describe('wraps history methods', () => { test('wraps push', (done) => { const tracker = new PageTracker(new EventContext()) tracker.on('page_tracker.push', () => done()) history.pushState(null, '', '/foo') }) test('wraps replace', (done) => { const tracker = new PageTracker(new EventContext()) tracker.on('page_tracker.replace', () => done()) history.replaceState(null, '', '/foo') }) }) describe('tracking', () => { afterEach(() => { jest.useRealTimers() }) test('does not capture an initial pageview when autocapture is disabled', (done) => { expect.assertions(2) const ctx = new EventContext({ sdk_settings: { autocapture: false } } as CollectorOptions) const tracker = new PageTracker(ctx) const spy = jest.spyOn(tracker, 'emit') setTimeout(() => { expect(tracker.allPages().length).toBe(0) expect(spy).not.toHaveBeenCalled() done() }, 0) }) test('emits an initial page load when instantiated', (done) => { const tracker = new PageTracker(new EventContext()) tracker.on('page', (pages: PageView[]) => { expect(pages.length).toBe(1) expect(pages[0].page.path).toBe('/') expect(pages[0].page.url).toBe('https://test.koala.live/') done() }) }) test('emits an initial page load when autocapture is started', (done) => { expect.assertions(7) const ctx = new EventContext({ sdk_settings: { autocapture: false } } as CollectorOptions) const tracker = new PageTracker(ctx) const spy = jest.spyOn(tracker, 'emit') setTimeout(() => { expect(tracker.allPages().length).toBe(0) expect(spy).not.toHaveBeenCalled() }, 0) tracker.on('page', (pages: PageView[]) => { expect(pages.length).toBe(1) expect(pages[0].page.path).toBe('/') expect(pages[0].page.url).toBe('https://test.koala.live/') }) tracker.startAutocapture() setTimeout(() => { expect(tracker.allPages().length).toBe(1) expect(spy).toHaveBeenCalled() done() }, 0) }) test('stops capturing pageviews when autocapture is stopped', (done) => { expect.assertions(5) const tracker = new PageTracker(new EventContext()) const spy = jest.spyOn(tracker, 'emit') tracker.on('page', (pages: PageView[]) => { expect(pages.length).toBe(1) expect(pages[0].page.path).toBe('/') expect(pages[0].page.url).toBe('https://test.koala.live/') }) setTimeout(() => { spy.mockClear() tracker.stopAutocapture() history.pushState(null, '', '/pricing') setTimeout(() => { expect(spy).not.toHaveBeenCalledWith('page', expect.anything()) expect(tracker.allPages().length).toBe(1) done() }, 0) }, 0) }) test('tracks on push state', (done) => { const allPages: Record = {} const tracker = new PageTracker(new EventContext()) let receivedPage = 0 tracker.on('page', (pages: PageView[]) => { pages.forEach((page) => { receivedPage++ allPages[page.message_id ?? ''] = page }) const values = Object.values(allPages) if (values.length === 2) { expect(values[0].page.path).toBe('/') expect(values[0].visit_start).toBeTruthy() expect(values[0].visit_end).toBeTruthy() expect(values[1].page.path).toBe('/pricing') expect(values[1].visit_start).toBeTruthy() expect(values[1].visit_end).toBeFalsy() // One event for each time a page changes // 2 for the / (start and end) // 1 for /pricing (start) expect(receivedPage).toBe(3) done() } }) // push page after the event loop has run, otherwise they'll get clobbered setTimeout(() => { history.pushState(null, '', '/pricing') }, 0) }) test('tracks on replace state', (done) => { expect.assertions(1) const tracker = new PageTracker(new EventContext()) const spy = jest.spyOn(tracker, 'emit') setTimeout(() => { history.replaceState(null, '', '/integrations') expect(spy).toHaveBeenCalledWith( 'page', expect.arrayContaining([ expect.objectContaining({ page: expect.objectContaining({ path: '/integrations' }) }) ]) ) done() }, 0) }) test('tracks a new page when the session has changed', async () => { expect.assertions(3) jest.useFakeTimers() Object.defineProperty(jsd.window.document, 'visibilityState', { value: 'visible', writable: true }) const tracker = new PageTracker(new EventContext()) const spy = jest.spyOn(tracker, 'emit') jest.runOnlyPendingTimers() // Simulate first async operation await Promise.resolve().then(() => { expect(tracker.allPages().length).toBe(1) Object.defineProperty(jsd.window.document, 'visibilityState', { value: 'hidden', writable: true }) }) // Advance timers to ensure the above promise resolves jest.runOnlyPendingTimers() session.reset({} as SessionOptions) Object.defineProperty(jsd.window.document, 'visibilityState', { value: 'visible', writable: true }) // Manually calling the event handler tracker.onVisibilityChange() jest.runOnlyPendingTimers() await Promise.resolve().then(() => { expect(spy).toHaveBeenCalledWith( 'page', expect.arrayContaining([expect.objectContaining({ message_id: expect.any(String) })]) ) expect(tracker.allPages().length).toBe(2) }) }) test('ignores same-page replace state', async () => { expect.assertions(7) const tracker = new PageTracker(new EventContext()) await domReady() expect(tracker.allPages().length).toBe(1) const spy = jest.spyOn(tracker, 'emit') history.replaceState(null, '') expect(spy).not.toHaveBeenCalledWith( 'page', expect.arrayContaining([expect.objectContaining({ message_id: expect.any(String) })]) ) expect(tracker.allPages().length).toBe(1) history.replaceState(null, '', window.location.href) expect(spy).not.toHaveBeenCalledWith( 'page', expect.arrayContaining([expect.objectContaining({ message_id: expect.any(String) })]) ) expect(tracker.allPages().length).toBe(1) history.replaceState(null, '', 'new-path') expect(spy).toHaveBeenCalledWith( 'page', expect.arrayContaining([expect.objectContaining({ message_id: expect.any(String) })]) ) expect(tracker.allPages().length).toBe(2) }) test('keeps track of all page views as they change', (done) => { const allPages: Record = {} const tracker = new PageTracker(new EventContext()) let receivedPage = 0 tracker.on('page', (pages: PageView[]) => { pages.forEach((page) => { receivedPage++ allPages[page.message_id ?? ''] = page }) const values = Object.values(allPages) if (values.length === 3) { expect(values[0].page.path).toBe('/') expect(values[0].visit_start).toBeTruthy() expect(values[0].visit_end).toBeTruthy() expect(values[1].page.path).toBe('/pricing') expect(values[1].visit_start).toBeTruthy() expect(values[1].visit_end).toBeTruthy() expect(values[2].page.path).toBe('/about') expect(values[2].visit_start).toBeTruthy() expect(values[2].visit_end).toBeFalsy() // One event for each time a page changes // 2 for the / (start and end) // 2 for /pricing (start and end) // 1 for /about (start) expect(receivedPage).toBe(5) done() } }) // push page after the event loop has run, otherwise they'll get clobbered setTimeout(() => { history.pushState(null, '', '/pricing') setTimeout(() => { history.pushState(null, '', '/about') }, 0) }, 0) }) test('supports back button navigation', async () => { expect.assertions(5) const spy = jest.spyOn(focusTimer, 'restart') const tracker = new PageTracker(new EventContext()) await domReady() const pSpy = jest.spyOn(tracker, 'emit') history.replaceState(null, '', '/integrations') expect(pSpy).toHaveBeenCalledWith( 'page', expect.arrayContaining([expect.objectContaining({ page: expect.objectContaining({ path: '/integrations' }) })]) ) expect(tracker.allPages().length).toBe(2) // JSDOM doesn't support back button navigation, so we'll just simulate it // @ts-expect-error test history.back('/') expect(pSpy).toHaveBeenCalledWith( 'page', expect.arrayContaining([ expect.objectContaining({ page: expect.objectContaining({ path: '/' }) }) ]) ) expect(tracker.allPages().length).toBe(3) // one restart on every page visited expect(spy).toHaveBeenCalledTimes(2) }) test('collects page on pagehide with latest focus time', (done) => { expect.assertions(11) const spy = jest.spyOn(focusTimer, 'restart') jest.spyOn(focusTimer, 'currentFocusTime', 'get').mockReturnValue(5_000) const allPages: Record = {} const tracker = new PageTracker(new EventContext()) let receivedPage = 0 let receivedFocusTime = 0 tracker.on('new_focus_time', () => { receivedFocusTime++ }) tracker.on('page', (pages: PageView[]) => { pages.forEach((page) => { receivedPage++ allPages[page.message_id ?? ''] = page }) const values = Object.values(allPages) if (receivedPage === 1) { expect(pages.length).toBe(1) expect(pages[0].visit_end).toBeUndefined() expect(pages[0].focus_intervals.length).toBe(0) } // the second page view will have the visit_end (the first does not record it since the visit just started) if (receivedPage === 2) { try { expect(values.length).toBe(1) expect(pages.length).toBe(1) expect(pages[0].page.path).toBe('/') expect(pages[0].visit_end).toBeTruthy() expect(spy).toHaveBeenCalledTimes(1) expect(pages[0].focus_intervals.length).toBe(1) expect(pages[0].focus_intervals).toEqual(expect.arrayContaining([5000])) expect(receivedFocusTime).toBe(1) } finally { done() } } }) // push page after the event loop has run, otherwise they'll get clobbered setTimeout(() => { window.dispatchEvent(new window.PageTransitionEvent('pagehide')) }, 0) }) test('buffers a page event every first time > 1s', () => { jest.useFakeTimers() const tracker = new PageTracker(new EventContext()) const spy = jest.spyOn(tracker, 'emit') jest.runOnlyPendingTimers() spy.mockClear() focusTimer.emit('focus_time.end', 1000) expect(spy).not.toHaveBeenCalledWith('page', expect.anything()) expect(tracker.scheduled).toBeTruthy() }) test('buffers a page event once total > 1s if not yet collected', () => { jest.useFakeTimers() const tracker = new PageTracker(new EventContext()) const spy = jest.spyOn(tracker, 'emit') jest.runOnlyPendingTimers() spy.mockClear() focusTimer.emit('focus_time.end', 800) expect(spy).not.toHaveBeenCalledWith('page', expect.anything()) expect(tracker.scheduled).toBeFalsy() // > 1s combined focusTimer.emit('focus_time.end', 300) expect(spy).not.toHaveBeenCalledWith('page', expect.anything()) expect(tracker.scheduled).toBeTruthy() }) test('buffers a page event every large focus interval', () => { jest.useFakeTimers() const tracker = new PageTracker(new EventContext()) jest.runOnlyPendingTimers() // initial pageview emitted const spy = jest.spyOn(tracker, 'emit') focusTimer.emit('focus_time.end', 800) expect(spy).not.toHaveBeenCalledWith('page', expect.anything()) expect(tracker.scheduled).toBeFalsy() focusTimer.emit('focus_time.end', 10000) expect(spy).not.toHaveBeenCalledWith('page', expect.anything()) expect(tracker.scheduled).toBeTruthy() }) test('buffers a page event every 3rd focus interval', () => { jest.useFakeTimers() const tracker = new PageTracker(new EventContext()) jest.runOnlyPendingTimers() // initial pageview emitted const spy = jest.spyOn(tracker, 'emit') focusTimer.emit('focus_time.end', 800) expect(spy).not.toHaveBeenCalledWith('page', expect.anything()) expect(tracker.scheduled).toBeFalsy() focusTimer.emit('focus_time.end', 800) expect(spy).not.toHaveBeenCalledWith('page', expect.anything()) expect(tracker.scheduled).toBeTruthy() }) test('doesnt buffer multiple page events if already buffered', () => { jest.useFakeTimers() const tracker = new PageTracker(new EventContext()) jest.runOnlyPendingTimers() // initial pageview emitted const spy = jest.spyOn(tracker, 'emit') // let's hit all 3 conditions across multiple focus_time.end events focusTimer.emit('focus_time.end', 1000) // first time > 1s jest.advanceTimersByTime(500) focusTimer.emit('focus_time.end', 10000) // large jest.advanceTimersByTime(500) focusTimer.emit('focus_time.end', 800) // 3rd expect(spy).not.toHaveBeenCalledWith('page', expect.anything()) expect(tracker.scheduled).toBeTruthy() // now let's advance by the remaining time (2s - 500ms - 500ms = 1s) jest.advanceTimersByTime(1001) expect(spy).toHaveBeenCalledWith('page', expect.anything()) expect(tracker.scheduled).toBeFalsy() }) test('sends buffered page event after a couple seconds', () => { jest.useFakeTimers() const tracker = new PageTracker(new EventContext()) jest.runOnlyPendingTimers() // initial pageview emitted const spy = jest.spyOn(tracker, 'emit') focusTimer.emit('focus_time.end', 1000) expect(spy).not.toHaveBeenCalledWith('page', expect.anything()) expect(tracker.scheduled).toBeTruthy() // now let's advance by the remaining time jest.advanceTimersByTime(2001) expect(spy).toHaveBeenCalledWith('page', expect.anything()) expect(tracker.scheduled).toBeFalsy() }) test('cancels buffered page event if the page is getting collected another way', () => { jest.useFakeTimers() const tracker = new PageTracker(new EventContext()) jest.runOnlyPendingTimers() // initial pageview emitted const spy = jest.spyOn(tracker, 'emit') focusTimer.emit('focus_time.end', 1000) expect(spy).not.toHaveBeenCalledWith('page', expect.anything()) expect(tracker.scheduled).toBeTruthy() window.dispatchEvent(new window.PageTransitionEvent('pagehide')) expect(tracker.scheduled).toBeFalsy() expect(spy).toHaveBeenCalledWith( 'page', expect.arrayContaining([expect.objectContaining({ visit_end: expect.any(Date), focus_intervals: [1000] })]) ) // reset the spy spy.mockClear() // now let's advance by the remaining time jest.advanceTimersByTime(2001) expect(spy).not.toHaveBeenCalledWith('page', expect.anything()) }) test('emits a new_focus_time when there is a focus time end', () => { jest.useFakeTimers() const tracker = new PageTracker(new EventContext()) jest.runOnlyPendingTimers() const spy = jest.spyOn(tracker, 'emit') focusTimer.emit('focus_time.end', 1000) focusTimer.emit('focus_time.end', 1500) focusTimer.emit('focus_time.end', 2000) expect(spy).toHaveBeenCalledTimes(3) expect(spy).toHaveBeenCalledWith('new_focus_time') }) }) })