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().mockReturnValue([]), })); const TEST_CATEGORY = 'root:index'; const TEST_ACTION = 'generic'; const TEST_LABEL = 'button'; describe('Tracking', () => { let standardContext; let snowplowSpy; beforeAll(() => { window.gl = window.gl || {}; window.gl.snowplowUrls = {}; window.gl.snowplowStandardContext = { schema: 'iglu:com.gitlab/gitlab_standard', data: { environment: 'testing', source: 'unknown', extra: {}, }, }; window.snowplowOptions = { namespace: 'gl_test', hostname: 'app.test.com', cookieDomain: '.test.com', formTracking: true, linkClickTracking: true, formTrackingConfig: { forms: { allow: ['foo'] }, fields: { allow: ['bar'] } }, }; standardContext = getStandardContext(); window.snowplow = window.snowplow || (() => {}); document.body.dataset.page = TEST_CATEGORY; initUserTracking(); initDefaultTrackers(); }); beforeEach(() => { getExperimentData.mockReturnValue(undefined); getAllExperimentContexts.mockReturnValue([]); snowplowSpy = jest.spyOn(window, 'snowplow'); }); describe('.event', () => { afterEach(() => { window.doNotTrack = undefined; navigator.doNotTrack = undefined; navigator.msDoNotTrack = undefined; jest.clearAllMocks(); }); it('tracks to snowplow (our current tracking system)', () => { Tracking.event(TEST_CATEGORY, TEST_ACTION, { label: TEST_LABEL }); expect(snowplowSpy).toHaveBeenCalledWith( 'trackStructEvent', TEST_CATEGORY, TEST_ACTION, TEST_LABEL, undefined, undefined, [standardContext], ); }); it('returns `true` if the Snowplow library was called without issues', () => { expect(Tracking.event(TEST_CATEGORY, TEST_ACTION)).toBe(true); }); it('returns `false` if the Snowplow library throws an error', () => { snowplowSpy.mockImplementation(() => { throw new Error(); }); expect(Tracking.event(TEST_CATEGORY, TEST_ACTION)).toBe(false); }); it('allows adding extra data to the default context', () => { const extra = { foo: 'bar' }; Tracking.event(TEST_CATEGORY, TEST_ACTION, { extra }); expect(snowplowSpy).toHaveBeenCalledWith( 'trackStructEvent', TEST_CATEGORY, TEST_ACTION, undefined, undefined, undefined, [ { ...standardContext, data: { ...standardContext.data, extra, }, }, ], ); }); it('skips tracking if snowplow is unavailable', () => { window.snowplow = false; Tracking.event(TEST_CATEGORY, TEST_ACTION); expect(snowplowSpy).not.toHaveBeenCalled(); }); it('skips tracking if the user does not want to be tracked (general spec)', () => { window.doNotTrack = '1'; Tracking.event(TEST_CATEGORY, TEST_ACTION); expect(snowplowSpy).not.toHaveBeenCalled(); }); it('skips tracking if the user does not want to be tracked (firefox legacy)', () => { navigator.doNotTrack = 'yes'; Tracking.event(TEST_CATEGORY, TEST_ACTION); expect(snowplowSpy).not.toHaveBeenCalled(); }); it('skips tracking if the user does not want to be tracked (IE legacy)', () => { navigator.msDoNotTrack = '1'; Tracking.event(TEST_CATEGORY, TEST_ACTION); expect(snowplowSpy).not.toHaveBeenCalled(); }); }); describe('.definition', () => { const TEST_VALID_BASENAME = '202108302307_default_click_button'; const TEST_EVENT_DATA = { category: undefined, action: 'click_button' }; let eventSpy; let dispatcherSpy; beforeAll(() => { Tracking.definitionsManifest = { '202108302307_default_click_button': 'config/events/202108302307_default_click_button.yml', }; }); beforeEach(() => { eventSpy = jest.spyOn(Tracking, 'event'); dispatcherSpy = jest.spyOn(Tracking, 'dispatchFromDefinition'); }); it('throws an error if the definition does not exists', () => { const basename = '20220230_default_missing_definition'; const expectedError = new Error(`Missing Snowplow event definition "${basename}"`); expect(() => Tracking.definition(basename)).toThrow(expectedError); }); it('dispatches an event from a definition present in the manifest', () => { Tracking.definition(TEST_VALID_BASENAME); expect(dispatcherSpy).toHaveBeenCalledWith(TEST_VALID_BASENAME, {}); }); it('push events to the queue if not loaded', () => { Tracking.definitionsLoaded = false; Tracking.definitionsEventsQueue = []; const dispatched = Tracking.definition(TEST_VALID_BASENAME); expect(dispatched).toBe(false); expect(Tracking.definitionsEventsQueue[0]).toStrictEqual([TEST_VALID_BASENAME, {}]); expect(eventSpy).not.toHaveBeenCalled(); }); it('dispatch events when the definition is loaded', () => { const definition = { key: TEST_VALID_BASENAME, ...TEST_EVENT_DATA }; Tracking.definitions = [{ ...definition }]; Tracking.definitionsEventsQueue = []; Tracking.definitionsLoaded = true; const dispatched = Tracking.definition(TEST_VALID_BASENAME); expect(dispatched).not.toBe(false); expect(Tracking.definitionsEventsQueue).toEqual([]); expect(eventSpy).toHaveBeenCalledWith(definition.category, definition.action, {}); }); it('lets defined event data takes precedence', () => { const definition = { key: TEST_VALID_BASENAME, category: undefined, action: 'click_button' }; const eventData = { category: TEST_CATEGORY }; Tracking.definitions = [{ ...definition }]; Tracking.definitionsLoaded = true; Tracking.definition(TEST_VALID_BASENAME, eventData); expect(eventSpy).toHaveBeenCalledWith(TEST_CATEGORY, definition.action, eventData); }); }); 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 allow 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(TEST_CATEGORY, TEST_ACTION, { label: TEST_LABEL }); expect(snowplowSpy).not.toHaveBeenCalled(); Tracking.flushPendingEvents(); expect(snowplowSpy).toHaveBeenCalledWith( 'trackStructEvent', TEST_CATEGORY, TEST_ACTION, TEST_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('does not appends the hash/fragment to the pseudonymized URL', () => { window.gl.snowplowPseudonymizedPageUrl = TEST_HOST; window.location.hash = 'first-heading'; Tracking.setAnonymousUrls(); expect(snowplowSpy).toHaveBeenCalledWith('setCustomUrl', TEST_HOST); }); describe('allowed hashes/fragments', () => { it.each` hash | appends | description ${'note_abc_123'} | ${true} | ${'appends'} ${'diff-content-819'} | ${true} | ${'appends'} ${'first_heading'} | ${false} | ${'does not append'} `('$description `$hash` hash', ({ hash, appends }) => { window.gl.snowplowPseudonymizedPageUrl = TEST_HOST; window.location.hash = hash; Tracking.setAnonymousUrls(); const url = appends ? `${TEST_HOST}#${hash}` : TEST_HOST; expect(snowplowSpy).toHaveBeenCalledWith('setCustomUrl', url); }); }); 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.toString()); }); }); }); describe('tracking interface events with data-track-action', () => { let eventSpy; beforeEach(() => { eventSpy = jest.spyOn(Tracking, 'event'); setHTMLFixture(`