import { setHTMLFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'helpers/test_constants'; import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; import { getExperimentData, getAllExperimentContexts } from '~/experimentation/utils'; import Tracking, { initUserTracking, initDefaultTrackers } from '~/tracking'; import { REFERRER_TTL, URLS_CACHE_STORAGE_KEY } from '~/tracking/constants'; import getStandardContext from '~/tracking/get_standard_context'; jest.mock('~/experimentation/utils', () => ({ getExperimentData: jest.fn(), getAllExperimentContexts: jest.fn(), })); describe('Tracking', () => { let standardContext; let snowplowSpy; let bindDocumentSpy; let trackLoadEventsSpy; let enableFormTracking; let setAnonymousUrlsSpy; beforeAll(() => { window.gl = window.gl || {}; window.gl.snowplowUrls = {}; window.gl.snowplowStandardContext = { schema: 'iglu:com.gitlab/gitlab_standard', data: { environment: 'testing', source: 'unknown', extra: {}, }, }; standardContext = getStandardContext(); }); beforeEach(() => { getExperimentData.mockReturnValue(undefined); getAllExperimentContexts.mockReturnValue([]); window.snowplow = window.snowplow || (() => {}); window.snowplowOptions = { namespace: '_namespace_', hostname: 'app.gitfoo.com', cookieDomain: '.gitfoo.com', }; snowplowSpy = jest.spyOn(window, 'snowplow'); }); describe('initUserTracking', () => { it('calls through to get a new tracker with the expected options', () => { initUserTracking(); expect(snowplowSpy).toHaveBeenCalledWith('newTracker', '_namespace_', 'app.gitfoo.com', { namespace: '_namespace_', hostname: 'app.gitfoo.com', cookieDomain: '.gitfoo.com', appId: '', userFingerprint: false, respectDoNotTrack: true, forceSecureTracker: true, eventMethod: 'post', contexts: { webPage: true, performanceTiming: true }, formTracking: false, linkClickTracking: false, pageUnloadTimer: 10, formTrackingConfig: { fields: { allow: [] }, forms: { allow: [] }, }, }); }); }); describe('initDefaultTrackers', () => { beforeEach(() => { bindDocumentSpy = jest.spyOn(Tracking, 'bindDocument').mockImplementation(() => null); trackLoadEventsSpy = jest.spyOn(Tracking, 'trackLoadEvents').mockImplementation(() => null); enableFormTracking = jest .spyOn(Tracking, 'enableFormTracking') .mockImplementation(() => null); setAnonymousUrlsSpy = jest.spyOn(Tracking, 'setAnonymousUrls').mockImplementation(() => null); }); it('should activate features based on what has been enabled', () => { initDefaultTrackers(); expect(snowplowSpy).toHaveBeenCalledWith('enableActivityTracking', 30, 30); expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', null, [standardContext]); expect(snowplowSpy).not.toHaveBeenCalledWith('enableFormTracking'); expect(snowplowSpy).not.toHaveBeenCalledWith('enableLinkClickTracking'); window.snowplowOptions = { ...window.snowplowOptions, formTracking: true, linkClickTracking: true, formTrackingConfig: { forms: { whitelist: ['foo'] }, fields: { whitelist: ['bar'] } }, }; initDefaultTrackers(); expect(enableFormTracking).toHaveBeenCalledWith(window.snowplowOptions.formTrackingConfig); expect(snowplowSpy).toHaveBeenCalledWith('enableLinkClickTracking'); }); it('binds the document event handling', () => { initDefaultTrackers(); expect(bindDocumentSpy).toHaveBeenCalled(); }); it('tracks page loaded events', () => { initDefaultTrackers(); expect(trackLoadEventsSpy).toHaveBeenCalled(); }); it('calls the anonymized URLs method', () => { initDefaultTrackers(); expect(setAnonymousUrlsSpy).toHaveBeenCalled(); }); describe('when there are experiment contexts', () => { const experimentContexts = [ { schema: TRACKING_CONTEXT_SCHEMA, data: { experiment: 'experiment1', variant: 'control' }, }, { schema: TRACKING_CONTEXT_SCHEMA, data: { experiment: 'experiment_two', variant: 'candidate' }, }, ]; beforeEach(() => { getAllExperimentContexts.mockReturnValue(experimentContexts); }); it('includes those contexts alongside the standard context', () => { initDefaultTrackers(); expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', null, [ standardContext, ...experimentContexts, ]); }); }); }); describe('.event', () => { afterEach(() => { window.doNotTrack = undefined; navigator.doNotTrack = undefined; navigator.msDoNotTrack = undefined; }); it('tracks to snowplow (our current tracking system)', () => { Tracking.event('_category_', '_eventName_', { label: '_label_' }); expect(snowplowSpy).toHaveBeenCalledWith( 'trackStructEvent', '_category_', '_eventName_', '_label_', undefined, undefined, [standardContext], ); }); it('allows adding extra data to the default context', () => { const extra = { foo: 'bar' }; Tracking.event('_category_', '_eventName_', { extra }); expect(snowplowSpy).toHaveBeenCalledWith( 'trackStructEvent', '_category_', '_eventName_', undefined, undefined, undefined, [ { ...standardContext, data: { ...standardContext.data, extra, }, }, ], ); }); it('skips tracking if snowplow is unavailable', () => { window.snowplow = false; Tracking.event('_category_', '_eventName_'); expect(snowplowSpy).not.toHaveBeenCalled(); }); it('skips tracking if the user does not want to be tracked (general spec)', () => { window.doNotTrack = '1'; Tracking.event('_category_', '_eventName_'); expect(snowplowSpy).not.toHaveBeenCalled(); }); it('skips tracking if the user does not want to be tracked (firefox legacy)', () => { navigator.doNotTrack = 'yes'; Tracking.event('_category_', '_eventName_'); expect(snowplowSpy).not.toHaveBeenCalled(); }); it('skips tracking if the user does not want to be tracked (IE legacy)', () => { navigator.msDoNotTrack = '1'; Tracking.event('_category_', '_eventName_'); expect(snowplowSpy).not.toHaveBeenCalled(); }); }); describe('.enableFormTracking', () => { it('tells snowplow to enable form tracking, with only explicit contexts', () => { const config = { forms: { allow: ['form-class1'] }, fields: { allow: ['input-class1'] } }; Tracking.enableFormTracking(config, ['_passed_context_', standardContext]); expect(snowplowSpy).toHaveBeenCalledWith( 'enableFormTracking', { forms: { whitelist: ['form-class1'] }, fields: { whitelist: ['input-class1'] } }, ['_passed_context_'], ); }); it('throws an error if no allow rules are provided', () => { const expectedError = new Error('Unable to enable form event tracking without allow rules.'); expect(() => Tracking.enableFormTracking()).toThrow(expectedError); expect(() => Tracking.enableFormTracking({ fields: { allow: true } })).toThrow(expectedError); expect(() => Tracking.enableFormTracking({ fields: { allow: [] } })).not.toThrow( expectedError, ); }); it('does not add empty form whitelist rules', () => { Tracking.enableFormTracking({ fields: { allow: ['input-class1'] } }); expect(snowplowSpy).toHaveBeenCalledWith( 'enableFormTracking', { fields: { whitelist: ['input-class1'] } }, [], ); }); describe('when `document.readyState` does not equal `complete`', () => { const originalReadyState = document.readyState; const setReadyState = (value) => { Object.defineProperty(document, 'readyState', { value, configurable: true, }); }; const fireReadyStateChangeEvent = () => { document.dispatchEvent(new Event('readystatechange')); }; beforeEach(() => { setReadyState('interactive'); }); afterEach(() => { setReadyState(originalReadyState); }); it('does not call `window.snowplow` until `readystatechange` is fired and `document.readyState` equals `complete`', () => { Tracking.enableFormTracking({ fields: { allow: ['input-class1'] } }); expect(snowplowSpy).not.toHaveBeenCalled(); fireReadyStateChangeEvent(); expect(snowplowSpy).not.toHaveBeenCalled(); setReadyState('complete'); fireReadyStateChangeEvent(); expect(snowplowSpy).toHaveBeenCalled(); }); }); }); describe('.flushPendingEvents', () => { it('flushes any pending events', () => { Tracking.initialized = false; Tracking.event('_category_', '_eventName_', { label: '_label_' }); expect(snowplowSpy).not.toHaveBeenCalled(); Tracking.flushPendingEvents(); expect(snowplowSpy).toHaveBeenCalledWith( 'trackStructEvent', '_category_', '_eventName_', '_label_', undefined, undefined, [standardContext], ); }); }); describe('.setAnonymousUrls', () => { afterEach(() => { window.gl.snowplowPseudonymizedPageUrl = ''; localStorage.removeItem(URLS_CACHE_STORAGE_KEY); }); it('does nothing if URLs are not provided', () => { Tracking.setAnonymousUrls(); expect(snowplowSpy).not.toHaveBeenCalled(); expect(localStorage.getItem(URLS_CACHE_STORAGE_KEY)).toBe(null); }); it('sets the page URL when provided and populates the cache', () => { window.gl.snowplowPseudonymizedPageUrl = TEST_HOST; Tracking.setAnonymousUrls(); expect(snowplowSpy).toHaveBeenCalledWith('setCustomUrl', TEST_HOST); expect(JSON.parse(localStorage.getItem(URLS_CACHE_STORAGE_KEY))[0]).toStrictEqual({ url: TEST_HOST, referrer: '', originalUrl: window.location.href, timestamp: Date.now(), }); }); it('appends the hash/fragment to the pseudonymized URL', () => { const hash = 'first-heading'; window.gl.snowplowPseudonymizedPageUrl = TEST_HOST; window.location.hash = hash; Tracking.setAnonymousUrls(); expect(snowplowSpy).toHaveBeenCalledWith('setCustomUrl', `${TEST_HOST}#${hash}`); }); it('does not set the referrer URL by default', () => { window.gl.snowplowPseudonymizedPageUrl = TEST_HOST; Tracking.setAnonymousUrls(); expect(snowplowSpy).not.toHaveBeenCalledWith('setReferrerUrl', expect.any(String)); }); describe('with referrers cache', () => { const testUrl = '/namespace:1/project:2/-/merge_requests/5'; const testOriginalUrl = '/my-namespace/my-project/-/merge_requests/'; const setUrlsCache = (data) => localStorage.setItem(URLS_CACHE_STORAGE_KEY, JSON.stringify(data)); beforeEach(() => { window.gl.snowplowPseudonymizedPageUrl = TEST_HOST; Object.defineProperty(document, 'referrer', { value: '', configurable: true }); }); it('does nothing if a referrer can not be found', () => { setUrlsCache([ { url: testUrl, originalUrl: TEST_HOST, timestamp: Date.now(), }, ]); Tracking.setAnonymousUrls(); expect(snowplowSpy).not.toHaveBeenCalledWith('setReferrerUrl', expect.any(String)); }); it('sets referrer URL from the page URL found in cache', () => { Object.defineProperty(document, 'referrer', { value: testOriginalUrl }); setUrlsCache([ { url: testUrl, originalUrl: testOriginalUrl, timestamp: Date.now(), }, ]); Tracking.setAnonymousUrls(); expect(snowplowSpy).toHaveBeenCalledWith('setReferrerUrl', testUrl); }); it('ignores and removes old entries from the cache', () => { const oldTimestamp = Date.now() - (REFERRER_TTL + 1); Object.defineProperty(document, 'referrer', { value: testOriginalUrl }); setUrlsCache([ { url: testUrl, originalUrl: testOriginalUrl, timestamp: oldTimestamp, }, ]); Tracking.setAnonymousUrls(); expect(snowplowSpy).not.toHaveBeenCalledWith('setReferrerUrl', testUrl); expect(localStorage.getItem(URLS_CACHE_STORAGE_KEY)).not.toContain(oldTimestamp); }); }); }); describe.each` term ${'event'} ${'action'} `('tracking interface events with data-track-$term', ({ term }) => { let eventSpy; beforeEach(() => { eventSpy = jest.spyOn(Tracking, 'event'); Tracking.bindDocument('_category_'); // only happens once setHTMLFixture(`