/** * @jest-environment jsdom */ import { Analytics, Context } from '@segment/analytics-next' import { fireEvent } from '@testing-library/dom' import Cookies from 'js-cookie' import { BootstrapData } from '../../api/bootstrap' import * as api from '../../api/collect' import { AnalyticsCollector } from '../collector' import { session } from '../session' import { user } from '../user' const location = window.location // Prevent API calls from being made jest.mock('../../api/collect', () => ({ collectIdentify: jest.fn(() => Promise.resolve(undefined)), collectEvents: jest.fn(() => Promise.resolve(undefined)), qualify: jest.fn(() => Promise.resolve({ qualification: {} })), collectPages: jest.fn(() => Promise.resolve(undefined)) })) const ogSetTimeout = global.setTimeout const sleep = (ms: number) => new Promise((resolve) => ogSetTimeout(resolve, ms)) jest.useFakeTimers() describe(AnalyticsCollector, () => { let locationSpy: jest.SpyInstance beforeEach(() => { jest.useFakeTimers() jest.spyOn(global, 'setTimeout') window.fetch = jest.fn().mockResolvedValue(undefined) jest.clearAllMocks() jest.clearAllTimers() user({} as BootstrapData).setId('testUserId') // reset traits between runs window.localStorage.setItem('kl:traits', '{}') // Clear both storage mechanisms window.localStorage.removeItem('ko_e') Cookies.remove('ko_e') locationSpy = jest.spyOn(window, 'location', 'get').mockReturnValue({ ...location }) }) afterEach(() => { locationSpy.mockReset() }) describe('koala traits', () => { test('collects traits from location.search', () => { jest.spyOn(window, 'location', 'get').mockImplementation( () => ({ search: '?ko_trait_first_name=Gustavo&ko_trait_last_name=Teixeira' }) as Location ) const collector = new AnalyticsCollector({ project: 'test' }) jest.spyOn(collector, 'emit') jest.spyOn(collector, 'identify') collector.emit('initialized', { sdk_settings: { querystring_collection: 'on' } } as BootstrapData) expect(collector.identify).toHaveBeenCalledWith( { first_name: 'Gustavo', last_name: 'Teixeira' }, { source: 'querystring' } ) }) test('do not collect traits if collection is disabled', () => { jest.spyOn(window, 'location', 'get').mockImplementation( () => ({ search: '?ko_trait_first_name=Gustavo&ko_trait_last_name=Teixeira' }) as Location ) const collector = new AnalyticsCollector({ project: 'test' }) jest.spyOn(collector, 'emit') jest.spyOn(collector, 'identify') collector.emit('initialized', { sdk_settings: { querystring_collection: 'off' } } as BootstrapData) expect(collector.identify).not.toHaveBeenCalled() }) }) describe('identify', () => { test('ignores empty or null traits', () => { const collector = new AnalyticsCollector({ project: 'test' }) jest.spyOn(collector, 'emit') // @ts-expect-error known parameters collector.identify(null) // @ts-expect-error known parameters collector.identify() collector.identify({}) // @ts-expect-error known parameters collector.identify(undefined) expect(collector.emit).not.toHaveBeenCalled() }) test('emits an event with the id and traits', () => { const collector = new AnalyticsCollector({ project: 'test' }) jest.spyOn(collector, 'emit') collector.identify({ email: 'm@example.com' }) expect(collector.emit).toHaveBeenCalledWith( 'identify', 'testUserId', expect.objectContaining({ email: 'm@example.com' }) ) }) test('persists traits locally', () => { const collector = new AnalyticsCollector({ project: 'test' }) jest.spyOn(collector, 'emit') collector.identify({ name: 'Penny', is_person: true }) expect(user({} as BootstrapData).traits()).toMatchObject({ name: 'Penny', is_person: true }) }) test('indexes the edge API inline', () => { const collector = new AnalyticsCollector({ project: 'test' }) collector.identify({ name: 'Penny', is_person: true }) expect(collector.edge.traits.has('name')).toBe(true) expect(collector.edge.traits.has('is_person')).toBe(true) }) describe('trait diffing', () => { let idSpy: jest.SpyInstance let collector: AnalyticsCollector beforeEach(() => { collector = new AnalyticsCollector({ project: 'test' }) idSpy = jest.spyOn(collector, 'emit') }) test('does not send identical traits', () => { collector.identify({ name: 'Penny', is_person: false }) collector.identify({ name: 'Penny', is_person: false }) collector.identify({ name: 'Penny', is_person: false }) expect(idSpy).toHaveBeenCalledWith( 'identify', 'testUserId', expect.objectContaining({ name: 'Penny', is_person: false }) ) // only the first call ever expect(idSpy).toHaveBeenCalledTimes(1) }) test('sends traits that have changed', () => { collector.identify({ name: 'Penny', is_person: false }) collector.identify({ name: 'Penny', is_person: true }) collector.identify({ name: 'Penny', is_person: true }) expect(idSpy).toHaveBeenCalledWith( 'identify', 'testUserId', expect.objectContaining({ name: 'Penny', is_person: false }) ) expect(idSpy).toHaveBeenCalledWith( 'identify', 'testUserId', expect.objectContaining({ is_person: true }) ) expect(idSpy).toHaveBeenCalledTimes(2) }) test('does not send dupe nested objects', () => { collector.identify({ name: 'Penny', $account: { id: '123', name: 'Acme' } }) collector.identify({ name: 'Penny', $account: { id: '123', name: 'Acme' } }) expect(idSpy).toHaveBeenCalledWith( 'identify', 'testUserId', expect.objectContaining({ name: 'Penny', $account: { id: '123', name: 'Acme' } }) ) expect(idSpy).toHaveBeenCalledTimes(1) }) test('invalidates nested objects', () => { collector.identify({ name: 'Penny', $account: { id: '123', name: 'Acme' } }) collector.identify({ name: 'Penny', $account: { id: '123', name: 'Acme for Dogs' } }) expect(idSpy).toHaveBeenCalledWith( 'identify', 'testUserId', expect.objectContaining({ name: 'Penny', $account: { id: '123', name: 'Acme' } }) ) expect(idSpy).toHaveBeenCalledWith( 'identify', 'testUserId', expect.objectContaining({ $account: { id: '123', name: 'Acme for Dogs' } }) ) expect(idSpy).toHaveBeenCalledTimes(2) }) }) test('Send new identify when email trait changes', () => { const collector = new AnalyticsCollector({ project: 'test' }) const idSpy = jest.spyOn(collector, 'emit') user({} as BootstrapData).setId('testUserId') window.localStorage.setItem('kl:traits', '{}') const oldUserId = collector.user.id() collector.identify({ email: ' m@example.com ' }) expect(idSpy).toHaveBeenCalledWith('identify', 'testUserId', expect.objectContaining({ email: 'm@example.com' })) const klTraits = window.localStorage.getItem('kl:traits') expect(klTraits).not.toBeNull() const oldSession = collector.session expect(oldSession).toBeTruthy() const pageTracker = collector.pageTracker pageTracker.emit('page_tracker.push') pageTracker.stopAutocapture() expect(pageTracker.allPages().length).toBe(1) expect(collector.profile.page_views.length).toBe(1) const eventTracker = collector.eventQueue collector.track('My custom event', { foo: 'bar' }) expect(eventTracker.events.length).toBe(1) expect(collector.profile.events?.length).toBe(1) collector.identify({ email: ' m2@example.com ' }) expect(idSpy).toHaveBeenCalledWith( 'identify', expect.not.stringContaining('testUserId'), expect.objectContaining({ email: 'm2@example.com' }) ) expect(window.localStorage.getItem('kl:traits')).not.toEqual(klTraits) expect(session.sessionId()).not.toBe(oldSession) expect(pageTracker.allPages().length).toBe(0) expect(collector.profile.page_views.length).toBe(0) expect(collector.profile.events?.length).toBe(0) expect(eventTracker.events.length).toBe(0) expect(oldUserId).not.toBe(collector.user.id()) }) }) describe('qualify', () => { test('stores and sends the email captured', () => { expect.assertions(2) const collector = new AnalyticsCollector({ project: 'test' }) collector.qualify('m@example.com') expect(api.qualify).toHaveBeenCalled() expect(collector.email).toBe('m@example.com') }) test('sends without email', () => { expect.assertions(2) const collector = new AnalyticsCollector({ project: 'test' }) collector.qualify() expect(api.qualify).toHaveBeenCalled() expect(collector.email).toBe(undefined) }) }) describe('track', () => { test('indexes the edge API inline', () => { const collector = new AnalyticsCollector({ project: 'test' }) collector.track('Signup', { name: 'Penny', is_person: true }) expect(collector.edge.events.performed('Signup')).toBe(true) expect(collector.edge.traits.has('name')).toBe(true) expect(collector.edge.traits.has('is_person')).toBe(true) }) }) describe('page tracking', () => { test('indexes the edge API inline', () => { const collector = new AnalyticsCollector({ project: 'test' }) jest.spyOn(collector.stats, 'increment').mockImplementation(() => {}) // @ts-ignore private method collector.pageTracker.emit('page', [ { page: { path: '/pricing' } } ]) expect(collector.edge.page.seen('/pricing')).toBe(true) expect(collector.edge.page.seen('/demo')).toBe(false) }) }) describe('collectForms', () => { let form: HTMLFormElement let submitButton: HTMLButtonElement beforeAll(() => { // Create a form and add it to the DOM. form = document.createElement('form') form.setAttribute('id', 'test-form') document.body.appendChild(form) // Stub the missing HTMLFormElement.prototype.submit method. // This prevents jsdom from throwing up. HTMLFormElement.prototype.submit = () => {} form.onsubmit = () => false }) beforeEach(() => { form.action = window.location.href form.innerHTML = ` ` submitButton = form.querySelector('button')! }) test('starts collecting forms when called', () => { const collector = new AnalyticsCollector({ project: 'test' }) jest.spyOn(collector, 'emit') jest.spyOn(collector, 'track').mockImplementation(() => Promise.resolve(undefined)) collector.collectForms() fireEvent( submitButton, new MouseEvent('click', { bubbles: true, cancelable: true }) ) expect(collector.track).toHaveBeenCalledWith('$submit', { context: { page: { path: '/', referrer: '', title: '', url: 'http://localhost/', host: 'localhost' }, selector: 'test-form' }, action: 'http://localhost/', formData: { company: 'bigco', name: 'kate', email: 'kate@example.com' }, method: 'get', name: 'test-form', traits: { email: 'kate@example.com', company: 'bigco', name: 'kate' } }) }) test('sends form data as traits to `identify`', () => { const collector = new AnalyticsCollector({ project: 'test' }) jest.spyOn(collector, 'emit') jest.spyOn(collector, 'identify').mockImplementation(async () => {}) jest.spyOn(collector.stats, 'increment').mockImplementation(() => {}) collector.collectForms() fireEvent( submitButton, new MouseEvent('click', { bubbles: true, cancelable: true }) ) expect(collector.identify).toHaveBeenCalledWith( { company: 'bigco', name: 'kate', email: 'kate@example.com' }, { source: 'form' } ) }) test('ignores email form fields when already identified with one', () => { const collector = new AnalyticsCollector({ project: 'test' }) jest.spyOn(collector, 'emit') jest.spyOn(collector, 'identify').mockImplementation(async () => {}) jest.spyOn(collector.stats, 'increment').mockImplementation(() => {}) // Set existing email in both storage locations window.localStorage.setItem('ko_e', 'existing.email@example.com') Cookies.set('ko_e', 'existing.email@example.com') form.innerHTML = `

Invite someone!

` submitButton = form.querySelector('button')! collector.collectForms() fireEvent( submitButton, new MouseEvent('click', { bubbles: true, cancelable: true }) ) expect(collector.identify).not.toHaveBeenCalled() }) test('autocollects when enabled in the settings', () => { const collector = new AnalyticsCollector({ project: 'test' }) jest.spyOn(collector, 'identify').mockImplementation(async () => {}) jest.spyOn(collector.stats, 'increment').mockImplementation(() => {}) // On init, when form_collection is not `off` we should autocollect forms. collector.emit('initialized', { sdk_settings: { form_collection: 'on' } } as BootstrapData) fireEvent( submitButton, new MouseEvent('click', { bubbles: true, cancelable: true }) ) expect(collector.identify).toHaveBeenCalledWith( { company: 'bigco', name: 'kate', email: 'kate@example.com' }, { source: 'form' } ) }) test("does *not* autocollect when 'off' in the settings", () => { const collector = new AnalyticsCollector({ project: 'test' }) jest.spyOn(collector, 'identify').mockImplementation(async () => {}) jest.spyOn(collector.stats, 'increment').mockImplementation(() => {}) // On init, when form_collection is not `off` we should autocollect forms. collector.emit('initialized', { sdk_settings: { form_collection: 'off' } } as BootstrapData) fireEvent( submitButton, new MouseEvent('click', { bubbles: true, cancelable: true }) ) expect(collector.identify).not.toHaveBeenCalled() }) test('does *not* capture when autocapture is disabled in the settings', () => { const collector = new AnalyticsCollector({ project: 'test', sdk_settings: { autocapture: false } }) jest.spyOn(collector, 'identify').mockImplementation(async () => {}) jest.spyOn(collector.stats, 'increment').mockImplementation(() => {}) // On init, when form_collection is not `off` we should autocollect forms. collector.emit('initialized', { sdk_settings: { autocapture: false } } as BootstrapData) fireEvent( submitButton, new MouseEvent('click', { bubbles: true, cancelable: true }) ) expect(collector.identify).not.toHaveBeenCalled() }) test('does *not* capture when manually disabled via stopAutocapture', () => { const collector = new AnalyticsCollector({ project: 'test' }) const spy = jest.spyOn(collector, 'identify').mockImplementation(async () => {}) jest.spyOn(collector.stats, 'increment').mockImplementation(() => {}) // On init, when form_collection is not `off` we should autocollect forms. collector.emit('initialized', { sdk_settings: {} } as BootstrapData) fireEvent( submitButton, new MouseEvent('click', { bubbles: true, cancelable: true }) ) // initially working! expect(collector.identify).toHaveBeenCalledWith( { company: 'bigco', name: 'kate', email: 'kate@example.com' }, { source: 'form' } ) // reset the spy spy.mockClear() // stop the collector collector.stopAutocapture() fireEvent( submitButton, new MouseEvent('click', { bubbles: true, cancelable: true }) ) expect(collector.identify).not.toHaveBeenCalled() }) }) describe('detectSegment', () => { test('does not hook into Segment when disabled', () => { // @ts-expect-error untyped global const ajs = (window.analytics = new Analytics({ writeKey: 'w_123' })) const collector = new AnalyticsCollector({ project: 'test', hookSegment: false }) jest.spyOn(collector, 'identify') jest.spyOn(collector, 'track') ajs.identify(new Context({ type: 'identify', traits: { name: 'kate' } }).event) ajs.track(new Context({ type: 'track', event: 'test' }).event) expect(collector.identify).not.toHaveBeenCalled() expect(collector.track).not.toHaveBeenCalled() }) test('hooks into Segment but skips track calls when disabled', async () => { jest.useRealTimers() global.setTimeout = ogSetTimeout expect.assertions(2) const collector = new AnalyticsCollector({ project: 'test' }) collector.emit('initialized', { sdk_settings: { segment_auto_track: 'off' } } as any) jest.spyOn(collector, 'identify') jest.spyOn(collector, 'track') // @ts-expect-error untyped global const ajs = (window.analytics = new Analytics({ writeKey: 'w_123' })) const track = new Context({ type: 'track', event: 'test', properties: {} }) const identify = new Context({ type: 'identify', traits: { name: 'kate' } }) await ajs.track(track.event) await ajs.identify(identify.event) expect(collector.identify).toHaveBeenCalledTimes(2) expect(collector.track).not.toHaveBeenCalled() }) test('hooks into Segment and skips track calls after initialization', async () => { jest.useRealTimers() global.setTimeout = ogSetTimeout expect.assertions(2) const collector = new AnalyticsCollector({ project: 'test' }) jest.spyOn(collector, 'identify') jest.spyOn(collector, 'track') // @ts-expect-error untyped global const ajs = (window.analytics = new Analytics({ writeKey: 'w_123' })) const track = new Context({ type: 'track', event: 'test', properties: {} }) const identify = new Context({ type: 'identify', traits: { name: 'kate' } }) // this call goes through await ajs.track(track.event) await ajs.identify(identify.event) collector.emit('initialized', { sdk_settings: { segment_auto_track: 'off' } } as any) // These calls will be blocked now that the collector is initialized await ajs.track(track.event) await ajs.identify(identify.event) expect(collector.identify).toHaveBeenCalledTimes(3) expect(collector.track).toHaveBeenCalledTimes(1) }) test('hooks into Segment by default when unspecified', async () => { jest.useRealTimers() global.setTimeout = ogSetTimeout expect.assertions(2) const collector = new AnalyticsCollector({ project: 'test' }) jest.spyOn(collector, 'identify') jest.spyOn(collector, 'track') // @ts-expect-error untyped global const ajs = (window.analytics = new Analytics({ writeKey: 'w_123' })) const track = new Context({ type: 'track', event: 'test', properties: {} }) const identify = new Context({ type: 'identify', traits: { name: 'kate' } }) await ajs.track(track.event) await ajs.identify(identify.event) expect(collector.identify).toHaveBeenCalledTimes(2) expect(collector.track).toHaveBeenCalledWith('test', {}) }) test('hooks into Segment when specified', async () => { jest.useRealTimers() global.setTimeout = ogSetTimeout expect.assertions(2) const collector = new AnalyticsCollector({ project: 'test', hookSegment: true }) jest.spyOn(collector, 'identify') jest.spyOn(collector, 'track') // @ts-expect-error untyped global const ajs = (window.analytics = new Analytics({ writeKey: 'w_123' })) const track = new Context({ type: 'track', event: 'test', properties: {} }) const identify = new Context({ type: 'identify', traits: { name: 'kate' } }) await ajs.track(track.event) await ajs.identify(identify.event) expect(collector.identify).toHaveBeenCalledTimes(2) expect(collector.track).toHaveBeenCalledWith('test', {}) }) test('hooks into Segment even if Segment loads after Koala', async () => { jest.useRealTimers() global.setTimeout = ogSetTimeout expect.assertions(3) const collector = new AnalyticsCollector({ project: 'test' }) jest.spyOn(collector, 'identify') jest.spyOn(collector, 'track') const run = (async () => { // @ts-expect-error untyped global const ajs = (window.analytics = new Analytics({ writeKey: 'w_123' })) const track = new Context({ type: 'track', event: 'test', properties: {} }) const identify = new Context({ type: 'identify', traits: { name: 'kate' } }) await sleep(200) await ajs.track(track.event) await ajs.identify(identify.event) expect(collector.track).toHaveBeenCalledWith('test', {}) expect(collector.identify).toHaveBeenCalledTimes(2) })() expect(await run).toBeUndefined() }) test('hooks into Segment and skips track calls even if Segment loads after Koala', async () => { jest.useRealTimers() global.setTimeout = ogSetTimeout expect.assertions(3) const collector = new AnalyticsCollector({ project: 'test' }) jest.spyOn(collector, 'identify') jest.spyOn(collector, 'track') collector.emit('initialized', { sdk_settings: { segment_auto_track: 'off' } } as any) const run = (async () => { // @ts-expect-error untyped global const ajs = (window.analytics = new Analytics({ writeKey: 'w_123' })) const track = new Context({ type: 'track', event: 'test', properties: {} }) const identify = new Context({ type: 'identify', traits: { name: 'kate' } }) await sleep(200) await ajs.track(track.event) await ajs.identify(identify.event) expect(collector.track).not.toHaveBeenCalled() expect(collector.identify).toHaveBeenCalledTimes(2) })() expect(await run).toBeUndefined() }) }) describe('reset', () => { beforeEach(() => { user({} as BootstrapData).setId('testUserId') window.localStorage.setItem('kl:traits', '{}') expect(user({} as BootstrapData).id()).toBe('testUserId') expect(window.localStorage.getItem('kl:traits')).toBe('{}') }) test('clears the user and session', async () => { const collector = new AnalyticsCollector({ project: 'test' }) expect(collector.user.id()).toBe('testUserId') const oldSession = collector.session expect(oldSession).toBeTruthy() expect(session.sessionId()).toBeTruthy() const klTraits = window.localStorage.getItem('kl:traits') expect(klTraits).not.toBeNull() await collector.reset() expect(user({} as BootstrapData).id()).not.toBe('testUserId') expect(window.localStorage.getItem('kl:traits')).not.toEqual(klTraits) expect(session.sessionId()).not.toBe(oldSession) }) test('clears all analytics data', async () => { const collector = new AnalyticsCollector({ project: 'test' }) collector.track('Hello hello') collector.identify({ name: 'kate', email: 'kate@getkoala.com' }) const spy = jest.spyOn(collector.eventQueue, 'send') expect(collector.eventQueue.queue.size).toBeGreaterThan(0) await collector.reset() expect(spy).toHaveBeenCalled() }) test('assigns a new user id', async () => { const collector = new AnalyticsCollector({ project: 'test' }) expect(collector.user.id()).toBe('testUserId') await collector.reset() expect(collector.user.id()).not.toBe('testUserId') expect(collector.user.id()).toBeDefined() }) }) })