From 226fc867c0c1db637462bc2007f9cc3b442d63b2 Mon Sep 17 00:00:00 2001 From: nick-delirium Date: Tue, 18 Mar 2025 15:23:47 +0100 Subject: [PATCH] tracker: move sdk, add people/event trackers, add unit tests --- .../tracker/src/main/app/analytics/events.ts | 7 - .../main/app/analytics/sharedProperties.ts | 71 ----- tracker/tracker/src/main/entry.ts | 8 +- tracker/tracker/src/main/index.ts | 24 +- .../src/main/modules/analytics/events.ts | 135 +++++++++ .../src/main/modules/analytics/index.ts | 20 ++ .../src/main/modules/analytics/people.ts | 120 ++++++++ .../modules/analytics/sharedProperties.ts | 132 +++++++++ .../modules/analytics/tests/events.test.ts | 201 ++++++++++++++ .../modules/analytics/tests/people.test.ts | 258 ++++++++++++++++++ .../analytics/tests/sharedProperties.test.ts | 184 +++++++++++++ .../modules/analytics/tests/utils.test.ts | 131 +++++++++ .../main/{app => modules}/analytics/utils.ts | 46 +++- tracker/tracker/src/tests/singleton.test.ts | 1 - 14 files changed, 1241 insertions(+), 97 deletions(-) delete mode 100644 tracker/tracker/src/main/app/analytics/events.ts delete mode 100644 tracker/tracker/src/main/app/analytics/sharedProperties.ts create mode 100644 tracker/tracker/src/main/modules/analytics/events.ts create mode 100644 tracker/tracker/src/main/modules/analytics/index.ts create mode 100644 tracker/tracker/src/main/modules/analytics/people.ts create mode 100644 tracker/tracker/src/main/modules/analytics/sharedProperties.ts create mode 100644 tracker/tracker/src/main/modules/analytics/tests/events.test.ts create mode 100644 tracker/tracker/src/main/modules/analytics/tests/people.test.ts create mode 100644 tracker/tracker/src/main/modules/analytics/tests/sharedProperties.test.ts create mode 100644 tracker/tracker/src/main/modules/analytics/tests/utils.test.ts rename tracker/tracker/src/main/{app => modules}/analytics/utils.ts (83%) diff --git a/tracker/tracker/src/main/app/analytics/events.ts b/tracker/tracker/src/main/app/analytics/events.ts deleted file mode 100644 index 7a7177ec0..000000000 --- a/tracker/tracker/src/main/app/analytics/events.ts +++ /dev/null @@ -1,7 +0,0 @@ -import SharedProperties from './sharedProperties.js' - -export default class Events { - constructor(private readonly sharedProperties: SharedProperties, private readonly token: string) {} - - sendEvent(eventName: string) {} -} \ No newline at end of file diff --git a/tracker/tracker/src/main/app/analytics/sharedProperties.ts b/tracker/tracker/src/main/app/analytics/sharedProperties.ts deleted file mode 100644 index e19723030..000000000 --- a/tracker/tracker/src/main/app/analytics/sharedProperties.ts +++ /dev/null @@ -1,71 +0,0 @@ -import App from '../index.js' -import { uaParse } from './utils.js' - -const refKey = '$__initial_ref__$' -const distinctIdKey = '$__distinct_device_id__$' - -export default class SharedProperties { - os: string - browser: string - device: string - screenHeight: number - screenWidth: number - initialReferrer: string - utmSource: string | null - utmMedium: string | null - utmCampaign: string | null - deviceId: string - - constructor(private readonly app: App) { - const { width, height, browser, browserVersion, browserMajorVersion, os, osVersion, mobile } = - uaParse(window) - this.os = `${os} ${osVersion}` - this.browser = `${browser} ${browserVersion} (${browserMajorVersion})` - this.device = mobile ? 'Mobile' : 'Desktop' - this.screenHeight = height - this.screenWidth = width - this.initialReferrer = this.getReferrer() - const searchParams = new URLSearchParams(window.location.search) - this.utmSource = searchParams.get('utm_source') || null - this.utmMedium = searchParams.get('utm_medium') || null - this.utmCampaign = searchParams.get('utm_campaign') || null - this.deviceId = this.getDistinctDeviceId() - } - - get all() { - return { - os: this.os, - browser: this.browser, - device: this.device, - screenHeight: this.screenHeight, - screenWidth: this.screenWidth, - initialReferrer: this.initialReferrer, - utmSource: this.utmSource, - utmMedium: this.utmMedium, - utmCampaign: this.utmCampaign, - deviceId: this.deviceId, - } - } - - private getDistinctDeviceId() { - const potentialStored = this.app.localStorage.getItem(distinctIdKey) - if (potentialStored) { - return potentialStored - } else { - const distinctId = `${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}` - this.app.localStorage.setItem(distinctIdKey, distinctId) - return distinctId - } - } - - private getReferrer() { - const potentialStored = this.app.sessionStorage.getItem(refKey) - if (potentialStored) { - return potentialStored - } else { - const ref = document.referrer - this.app.sessionStorage.setItem(refKey, ref) - return ref - } - } -} diff --git a/tracker/tracker/src/main/entry.ts b/tracker/tracker/src/main/entry.ts index 8ae5b535e..27113b1bd 100644 --- a/tracker/tracker/src/main/entry.ts +++ b/tracker/tracker/src/main/entry.ts @@ -1,6 +1,6 @@ -import TrackerClass from './index.js' +import TrackerClass from './index' -export { default as App } from './app/index.js' -export { SanitizeLevel, Messages, Options } from './index.js' -export { default as tracker } from './singleton.js' +export { default as App } from './app/index' +export { default as tracker, default as openReplay } from './singleton' +export { SanitizeLevel, Messages, Options } from './index' export default TrackerClass diff --git a/tracker/tracker/src/main/index.ts b/tracker/tracker/src/main/index.ts index 765f30d94..765890123 100644 --- a/tracker/tracker/src/main/index.ts +++ b/tracker/tracker/src/main/index.ts @@ -1,4 +1,4 @@ -import App from './app/index.js' +import App from './app/index' export { default as App } from './app/index.js' @@ -28,6 +28,7 @@ import Network from './modules/network.js' import ConstructedStyleSheets from './modules/constructedStyleSheets.js' import Selection from './modules/selection.js' import Tabs from './modules/tabs.js' +import AnalyticsSDK from './modules/analytics/index.js' import { IN_BROWSER, deprecationWarn, DOCS_HOST, inIframe } from './utils.js' import FeatureFlags, { IFeatureFlag } from './modules/featureFlags.js' @@ -107,6 +108,7 @@ export default class API { public featureFlags: FeatureFlags private readonly app: App | null = null + public readonly analytics: AnalyticsSDK | null = null private readonly crossdomainMode: boolean = false constructor(public readonly options: Partial) { @@ -178,6 +180,11 @@ export default class API { this.signalStartIssue, this.crossdomainMode, ) + this.analytics = new AnalyticsSDK( + options.localStorage ?? localStorage, + options.sessionStorage ?? sessionStorage, + this.getAnalyticsToken, + ) this.app = app if (!this.crossdomainMode) { // no need to send iframe viewport data since its a node for us @@ -528,4 +535,19 @@ export default class API { } } } + + private analyticsToken: string | null = null + /** + * Use custom token for analytics events without session recording + * */ + public setAnalyticsToken = (token: string) => { + this.analyticsToken = token + } + public getAnalyticsToken = () => { + if (this.analyticsToken) { + return this.analyticsToken + } else { + return this.app?.session.getSessionToken() ?? '' + } + } } diff --git a/tracker/tracker/src/main/modules/analytics/events.ts b/tracker/tracker/src/main/modules/analytics/events.ts new file mode 100644 index 000000000..0e4bcbddf --- /dev/null +++ b/tracker/tracker/src/main/modules/analytics/events.ts @@ -0,0 +1,135 @@ +import SharedProperties from './sharedProperties.js' +import { isObject } from './utils.js' + +const reservedProps = ['distinct_id', 'event_name', 'properties'] + +export default class Events { + queue: Record = [] + sendInterval: ReturnType | null = null + ownProperties: Record = {} + + constructor( + private readonly sharedProperties: SharedProperties, + private readonly getToken: () => string, + private readonly getTimestamp: () => number, + private readonly batchInterval = 5000, + ) { + this.sendInterval = setInterval(() => { + void this.sendBatch() + }, batchInterval) + } + + /** + * Add event to batch with option to send it immediately, + * properties are optional and will not be saved as super prop + * */ + sendEvent = ( + eventName: string, + properties?: Record, + options?: { send_immediately: boolean }, + ) => { + // add properties + const eventProps = {} + if (properties) { + if (!isObject(properties)) { + throw new Error('Properties must be an object') + } + Object.entries(properties).forEach(([key, value]) => { + if (!reservedProps.includes(key)) { + eventProps[key] = value + } + }) + } + const event = { + event: eventName, + properties: { ...this.sharedProperties.all, ...this.ownProperties, ...eventProps }, + timestamp: this.getTimestamp(), + } + if (options?.send_immediately) { + void this.sendSingle(event) + } else { + this.queue.push(event) + } + } + + sendBatch = async () => { + if (this.queue.length === 0) { + return + } + const headers = { + Authorization: `Bearer ${this.getToken()}`, + 'Content-Type': 'application/json', + } + // await fetch blah blah + token + } + + sendSingle = async (event) => { + const headers = { + Authorization: `Bearer ${this.getToken()}`, + 'Content-Type': 'application/json', + } + // await fetch blah blah + token + } + + /** + * creates super property for all events + * TODO: export as tracker.register + * */ + setProperty = (nameOrProperties: Record | string, value?: any) => { + if (isObject(nameOrProperties)) { + Object.entries(nameOrProperties).forEach(([key, val]) => { + if (!reservedProps.includes(key)) { + this.ownProperties[key] = val + } + }) + } + if (typeof nameOrProperties === 'string' && value !== undefined) { + if (!reservedProps.includes(nameOrProperties)) { + this.ownProperties[nameOrProperties] = value + } + } + } + + /** + * set super property only if it doesn't exist + * TODO: export as register_once + * */ + setPropertiesOnce = (nameOrProperties: Record | string, value?: any) => { + if (isObject(nameOrProperties)) { + Object.entries(nameOrProperties).forEach(([key, val]) => { + if (!this.ownProperties[key] && !reservedProps.includes(key)) { + this.ownProperties[key] = val + } + }) + } + if (typeof nameOrProperties === 'string' && value !== undefined) { + if (!this.ownProperties[nameOrProperties] && !reservedProps.includes(nameOrProperties)) { + this.ownProperties[nameOrProperties] = value + } + } + } + + /** + * removes properties from list + * TODO: export as unregister + * */ + unsetProperties = (properties: string | string[]) => { + if (Array.isArray(properties)) { + properties.forEach((key) => { + if (this.ownProperties[key] && !reservedProps.includes(key)) { + delete this.ownProperties[key] + } + }) + } else if (this.ownProperties[properties] && !reservedProps.includes(properties)) { + delete this.ownProperties[properties] + } + } + + generateDynamicProperties = () => { + return { + $auto_captured: false, + $current_url: window.location.href, + $referrer: document.referrer, + } + } +} diff --git a/tracker/tracker/src/main/modules/analytics/index.ts b/tracker/tracker/src/main/modules/analytics/index.ts new file mode 100644 index 000000000..b852332eb --- /dev/null +++ b/tracker/tracker/src/main/modules/analytics/index.ts @@ -0,0 +1,20 @@ +import SharedProperties from './sharedProperties.js' +import type { StorageLike } from './sharedProperties.js' +import Events from './events.js' +import People from './people.js' + +export default class Analytics { + public readonly events: Events + public readonly sharedProperties: SharedProperties + public readonly people: People + + constructor( + private readonly localStorage: StorageLike, + private readonly sessionStorage: StorageLike, + private readonly getToken: () => string, + ) { + this.sharedProperties = new SharedProperties(localStorage, sessionStorage) + this.events = new Events(this.sharedProperties, getToken, Date.now) + this.people = new People(this.sharedProperties, getToken, Date.now) + } +} diff --git a/tracker/tracker/src/main/modules/analytics/people.ts b/tracker/tracker/src/main/modules/analytics/people.ts new file mode 100644 index 000000000..7668686e4 --- /dev/null +++ b/tracker/tracker/src/main/modules/analytics/people.ts @@ -0,0 +1,120 @@ +import SharedProperties from './sharedProperties.js' +import { isObject } from './utils.js' + +const reservedProps = ['distinct_id', 'event_name', 'properties'] + +export default class People { + ownProperties: Record = {} + user_id: string | null = null + + constructor( + private readonly sharedProperties: SharedProperties, + private readonly getToken: () => string, + private readonly getTimestamp: () => number, + ) {} + + identify = (user_id: string) => { + this.user_id = user_id + + // TODO: fetch endpoint when it will be here + } + + // TODO: what exactly we're removing here besides properties and id? + deleteUser = () => { + this.user_id = null + this.ownProperties = {} + + // TODO: fetch endpoint when it will be here + } + + /** + * set ownProperties, overwriting entire object + * + * TODO: exported as people.set + * */ + setProperties = (properties: Record) => { + if (!isObject(properties)) { + throw new Error('Properties must be an object') + } + Object.entries(properties).forEach(([key, value]) => { + if (!reservedProps.includes(key)) { + this.ownProperties[key] = value + } + }) + } + + /** + * Set property if it doesn't exist yet + * + * TODO: exported as people.set_once + * */ + setPropertiesOnce = (properties: Record) => { + if (!isObject(properties)) { + throw new Error('Properties must be an object') + } + Object.entries(properties).forEach(([key, value]) => { + if (!reservedProps.includes(key) && !this.ownProperties[key]) { + this.ownProperties[key] = value + } + }) + } + + /** + * Add value to property (will turn string prop into array) + * + * TODO: exported as people.append + * */ + appendValues = (key: string, value: string | number) => { + if (!reservedProps.includes(key) && this.ownProperties[key]) { + if (Array.isArray(this.ownProperties[key])) { + this.ownProperties[key].push(value) + } else { + this.ownProperties[key] = [this.ownProperties[key], value] + } + } + } + + /** + * Add unique values to property (will turn string prop into array) + * + * TODO: exported as people.union + * */ + appendUniqueValues = (key: string, value: string | number) => { + if (!this.ownProperties[key]) return + if (Array.isArray(this.ownProperties[key])) { + if (!this.ownProperties[key].includes(value)) { + this.appendValues(key, value) + } + } else if (this.ownProperties[key] !== value) { + this.appendValues(key, value) + } + } + + /** + * Adds value (incl. negative) to existing numerical property + * + * TODO: exported as people.increment + * */ + increment = (key: string, value: number) => { + if (!reservedProps.includes(key) && typeof this.ownProperties[key] === 'number') { + this.ownProperties[key] += value + } + } + + fetchUserProperties = async () => { + if (!this.user_id) return + const userObj = { + user_id: this.user_id, + distinct_id: this.sharedProperties.distinctId, + properties: { + ...this.sharedProperties.all, + ...this.ownProperties, + }, + } + const headers = { + Authorization: `Bearer ${this.getToken()}`, + } + + // fetch user properties + } +} diff --git a/tracker/tracker/src/main/modules/analytics/sharedProperties.ts b/tracker/tracker/src/main/modules/analytics/sharedProperties.ts new file mode 100644 index 000000000..6347efa56 --- /dev/null +++ b/tracker/tracker/src/main/modules/analytics/sharedProperties.ts @@ -0,0 +1,132 @@ +import { getUTCOffsetString, uaParse } from './utils.js' + +export interface StorageLike { + getItem: (key: string) => string | null + setItem: (key: string, value: string) => void +} + +const refKey = '$__initial_ref__$' +const distinctIdKey = '$__distinct_device_id__$' +const prefix = '$' + +const searchEngineList = [ + 'google', + 'bing', + 'yahoo', + 'baidu', + 'yandex', + 'duckduckgo', + 'ecosia', + 'ask', + 'aol', + 'wolframalpha', + 'startpage', + 'swisscows', + 'qwant', + 'lycos', + 'dogpile', + 'info', + 'teoma', + 'webcrawler', + 'naver', + 'seznam', + 'perplexity', +] + +export default class SharedProperties { + os: string + osVersion: string + browser: string + browserVersion: string + device: string + screenHeight: number + screenWidth: number + initialReferrer: string + utmSource: string | null + utmMedium: string | null + utmCampaign: string | null + deviceId: string + searchEngine: string | null + + constructor( + private readonly localStorage: StorageLike, + private readonly sessionStorage: StorageLike, + ) { + const { width, height, browser, browserVersion, browserMajorVersion, os, osVersion, mobile } = + uaParse(window) + this.os = os + this.osVersion = osVersion + this.browser = `${browser}` + this.browserVersion = `${browserVersion} (${browserMajorVersion})` + this.device = mobile ? 'Mobile' : 'Desktop' + this.screenHeight = height + this.screenWidth = width + this.initialReferrer = this.getReferrer() + const searchParams = new URLSearchParams(window.location.search) + this.utmSource = searchParams.get('utm_source') || null + this.utmMedium = searchParams.get('utm_medium') || null + this.utmCampaign = searchParams.get('utm_campaign') || null + this.deviceId = this.getDistinctDeviceId() + this.searchEngine = this.getSerachEngine(this.initialReferrer) + } + + public get all() { + return { + [`${prefix}os`]: this.os, + [`${prefix}os_version`]: this.osVersion, + [`${prefix}browser`]: this.browser, + [`${prefix}browser_version`]: this.browserVersion, + [`${prefix}device`]: this.device, + [`${prefix}screen_height`]: this.screenHeight, + [`${prefix}screen_width`]: this.screenWidth, + [`${prefix}initial_referrer`]: this.initialReferrer, + [`${prefix}utm_source`]: this.utmSource, + [`${prefix}utm_medium`]: this.utmMedium, + [`${prefix}utm_campaign`]: this.utmCampaign, + [`${prefix}distinct_id`]: this.deviceId, + [`${prefix}sdk_edition`]: 'web', + [`${prefix}sdk_version`]: 'TRACKER_VERSION', + [`${prefix}timezone`]: getUTCOffsetString(), + [`${prefix}search_engine`]: this.searchEngine, + } + } + + public get defaultPropertyKeys() { + return Object.keys(this.all) + } + + public get distinctId() { + return this.deviceId + } + + private getDistinctDeviceId = () => { + const potentialStored = this.localStorage.getItem(distinctIdKey) + if (potentialStored) { + return potentialStored + } else { + const distinctId = `${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}` + this.localStorage.setItem(distinctIdKey, distinctId) + return distinctId + } + } + + private getReferrer = () => { + const potentialStored = this.sessionStorage.getItem(refKey) + if (potentialStored) { + return potentialStored + } else { + const ref = document.referrer + this.sessionStorage.setItem(refKey, ref) + return ref + } + } + + private getSerachEngine = (ref: string) => { + for (const searchEngine of searchEngineList) { + if (ref.includes(searchEngine)) { + return searchEngine + } + } + return null + } +} diff --git a/tracker/tracker/src/main/modules/analytics/tests/events.test.ts b/tracker/tracker/src/main/modules/analytics/tests/events.test.ts new file mode 100644 index 000000000..3bb59b325 --- /dev/null +++ b/tracker/tracker/src/main/modules/analytics/tests/events.test.ts @@ -0,0 +1,201 @@ +// @ts-nocheck +import { jest, describe, test, expect, beforeEach, afterEach } from '@jest/globals' +import Events from '../events.js' + +describe('Events', () => { + let mockSharedProperties + let events + let mockTimestamp + let mockToken + let mockGetTimestamp + let originalSetInterval + let originalClearInterval + let setIntervalMock + + beforeEach(() => { + originalSetInterval = global.setInterval + originalClearInterval = global.clearInterval + + setIntervalMock = jest.fn(() => 123) + global.setInterval = setIntervalMock + global.clearInterval = jest.fn() + + mockTimestamp = 1635186000000 // Example timestamp + mockGetTimestamp = jest.fn(() => mockTimestamp) + + mockSharedProperties = { + all: { + $__os: 'Windows 10', + $__browser: 'Chrome 91.0.4472.124 (91)', + $__device: 'Desktop', + $__screenHeight: 1080, + $__screenWidth: 1920, + $__initialReferrer: 'https://example.com', + $__utmSource: 'test_source', + $__utmMedium: 'test_medium', + $__utmCampaign: 'test_campaign', + $__deviceId: 'test-device-id', + }, + } + + mockToken = 'test-token-123' + + events = new Events(mockSharedProperties, mockToken, mockGetTimestamp, 1000) + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true }), + }) + }) + + afterEach(() => { + global.setInterval = originalSetInterval + global.clearInterval = originalClearInterval + + if (events.sendInterval) { + clearInterval(events.sendInterval) + } + + jest.restoreAllMocks() + }) + + test('constructor sets up event queue and batch interval', () => { + expect(events.queue).toEqual([]) + expect(events.ownProperties).toEqual({}) + expect(setIntervalMock).toHaveBeenCalledWith(expect.any(Function), 1000) + expect(events.sendInterval).toBe(123) + }) + + test('sendEvent adds event to queue with correct properties', () => { + events.sendEvent('test_event', { testProp: 'value' }) + + expect(events.queue).toHaveLength(1) + expect(events.queue[0]).toEqual({ + event: 'test_event', + properties: { + ...mockSharedProperties.all, + testProp: 'value', + }, + timestamp: mockTimestamp, + }) + }) + + test('sendEvent validates properties are objects', () => { + expect(() => { + events.sendEvent('test_event', 'not an object') + }).toThrow('Properties must be an object') + }) + + test('sendEvent with send_immediately option calls sendSingle', async () => { + const sendSingleSpy = jest.spyOn(events, 'sendSingle').mockResolvedValue(undefined) + + await events.sendEvent('immediate_test', { testProp: 'value' }, { send_immediately: true }) + + expect(sendSingleSpy).toHaveBeenCalledWith({ + event: 'immediate_test', + properties: { + ...mockSharedProperties.all, + testProp: 'value', + }, + timestamp: mockTimestamp, + }) + expect(events.queue).toHaveLength(0) // Should not add to queue + }) + + test('sendBatch does nothing when queue is empty', async () => { + await events.sendBatch() + expect(global.fetch).not.toHaveBeenCalled() + }) + + test('setProperty sets a single property correctly', () => { + events.setProperty('testProp', 'value') + expect(events.ownProperties).toEqual({ testProp: 'value' }) + + events.setProperty('event_name', 'should not be set') + expect(events.ownProperties.event_name).toBeUndefined() + }) + + test('setProperty sets multiple properties from object', () => { + events.setProperty({ + prop1: 'value1', + prop2: 'value2', + event_name: 'should not be set', // Reserved + }) + + expect(events.ownProperties).toEqual({ + prop1: 'value1', + prop2: 'value2', + }) + }) + + test('setPropertiesOnce only sets properties that do not exist', () => { + events.setProperty('existingProp', 'initial value') + + events.setPropertiesOnce({ + existingProp: 'new value', + newProp: 'value', + }) + + expect(events.ownProperties).toEqual({ + existingProp: 'initial value', // Should not change + newProp: 'value', // Should be added + }) + }) + + test('setPropertiesOnce sets a single property if it does not exist', () => { + events.setPropertiesOnce('newProp', 'value') + expect(events.ownProperties).toEqual({ newProp: 'value' }) + + events.setPropertiesOnce('newProp', 'new value') + expect(events.ownProperties).toEqual({ newProp: 'value' }) // Should not change + }) + + test('unsetProperties removes a single property', () => { + events.setProperty({ + prop1: 'value1', + prop2: 'value2', + }) + events.unsetProperties('prop1') + + expect(events.ownProperties).toEqual({ + prop2: 'value2', + }) + }) + + test('unsetProperties removes multiple properties', () => { + events.setProperty({ + prop1: 'value1', + prop2: 'value2', + prop3: 'value3', + }) + events.unsetProperties(['prop1', 'prop3']) + + expect(events.ownProperties).toEqual({ + prop2: 'value2', + }) + }) + + test('unsetProperties does not remove reserved properties', () => { + events.ownProperties.event_name = 'test' + events.unsetProperties('event_name') + + expect(events.ownProperties.event_name).toBe('test') + }) + + test('events include both shared and own properties', () => { + events.setProperty('customProp', 'custom value') + events.sendEvent('test_event') + + expect(events.queue[0].properties).toEqual({ + ...mockSharedProperties.all, + customProp: 'custom value', + }) + }) + + test('event properties override own properties', () => { + events.setProperty('customProp', 'own value') + + events.sendEvent('test_event', { customProp: 'event value' }) + expect(events.queue[0].properties.customProp).toBe('event value') + }) +}) diff --git a/tracker/tracker/src/main/modules/analytics/tests/people.test.ts b/tracker/tracker/src/main/modules/analytics/tests/people.test.ts new file mode 100644 index 000000000..0ac7900f7 --- /dev/null +++ b/tracker/tracker/src/main/modules/analytics/tests/people.test.ts @@ -0,0 +1,258 @@ +// @ts-nocheck +import { jest, describe, test, expect, beforeEach, afterEach } from '@jest/globals' +import People from '../people.js' +import * as utils from '../utils.js' + +jest.spyOn(utils, 'isObject') + +describe('People', () => { + let mockSharedProperties + let mockGetToken + let mockGetTimestamp + let people + + beforeEach(() => { + // Mock shared properties + mockSharedProperties = { + all: { + $__os: 'Windows 10', + $__browser: 'Chrome 91.0.4472.124 (91)', + $__deviceId: 'test-device-id', + }, + } + + // Mock token and timestamp functions + mockGetToken = jest.fn(() => 'test-token-123') + mockGetTimestamp = jest.fn(() => 1635186000000) + + // Create People instance + people = new People(mockSharedProperties, mockGetToken, mockGetTimestamp) + + // Mock fetch globally if needed for future implementations + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true }), + }) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + test('constructor initializes with empty properties and null user_id', () => { + expect(people.ownProperties).toEqual({}) + expect(people.user_id).toBeNull() + }) + + test('identify sets user_id correctly', () => { + people.identify('test-user-123') + expect(people.user_id).toBe('test-user-123') + // Note: We're not testing the fetch endpoint as it's marked as TODO in the code + }) + + test('deleteUser resets user_id and properties', () => { + // Set up initial state + people.user_id = 'test-user-123' + people.ownProperties = { + name: 'Test User', + email: 'test@example.com', + } + + // Call deleteUser + people.deleteUser() + + // Check results + expect(people.user_id).toBeNull() + expect(people.ownProperties).toEqual({}) + }) + + test('setProperties adds properties correctly', () => { + people.setProperties({ + name: 'Test User', + email: 'test@example.com', + age: 30, + }) + + expect(people.ownProperties).toEqual({ + name: 'Test User', + email: 'test@example.com', + age: 30, + }) + }) + + test('setProperties validates properties are objects', () => { + expect(() => { + people.setProperties('not an object') + }).toThrow('Properties must be an object') + }) + + test('setProperties ignores reserved properties', () => { + people.setProperties({ + name: 'Test User', + distinct_id: 'should-be-ignored', + event_name: 'also-ignored', + properties: 'ignored-too', + }) + + expect(people.ownProperties).toEqual({ + name: 'Test User', + }) + + // Reserved properties should not be present + expect(people.ownProperties.distinct_id).toBeUndefined() + expect(people.ownProperties.event_name).toBeUndefined() + expect(people.ownProperties.properties).toBeUndefined() + }) + + test('setPropertiesOnce only sets properties that do not exist', () => { + // Set initial property + people.ownProperties = { + name: 'Initial Name', + } + + // Try to set name again and add new properties + people.setPropertiesOnce({ + name: 'New Name', + email: 'test@example.com', + age: 30, + }) + + expect(people.ownProperties).toEqual({ + name: 'Initial Name', // Should not change + email: 'test@example.com', // Should be added + age: 30, // Should be added + }) + }) + + test('setPropertiesOnce validates properties are objects', () => { + expect(() => { + people.setPropertiesOnce('not an object') + }).toThrow('Properties must be an object') + }) + + test('appendValues adds value to existing property turning it into an array', () => { + // Set initial string property + people.ownProperties = { + tags: 'tag1', + } + + // Append a value + people.appendValues('tags', 'tag2') + + expect(people.ownProperties.tags).toEqual(['tag1', 'tag2']) + }) + + test('appendValues adds value to existing array property', () => { + // Set initial array property + people.ownProperties = { + tags: ['tag1', 'tag2'], + } + + // Append a value + people.appendValues('tags', 'tag3') + + expect(people.ownProperties.tags).toEqual(['tag1', 'tag2', 'tag3']) + }) + + test('appendValues does nothing if property does not exist', () => { + people.ownProperties = {} + + people.appendValues('nonexistent', 'value') + + expect(people.ownProperties.nonexistent).toBeUndefined() + }) + + test('appendValues ignores reserved properties', () => { + people.ownProperties = { + distinct_id: 'reserved', + } + + people.appendValues('distinct_id', 'new-value') + + expect(people.ownProperties.distinct_id).toBe('reserved') + }) + + test('appendUniqueValues adds unique value to array property', () => { + // Set initial array property + people.ownProperties = { + tags: ['tag1', 'tag2'], + } + + // Append a unique value + people.appendUniqueValues('tags', 'tag3') + + expect(people.ownProperties.tags).toEqual(['tag1', 'tag2', 'tag3']) + + // Try to append a duplicate value + people.appendUniqueValues('tags', 'tag2') + + // Array should remain unchanged + expect(people.ownProperties.tags).toEqual(['tag1', 'tag2', 'tag3']) + }) + + test('appendUniqueValues adds unique value to string property', () => { + // Set initial string property + people.ownProperties = { + tag: 'tag1', + } + + // Append a different value + people.appendUniqueValues('tag', 'tag2') + + expect(people.ownProperties.tag).toEqual(['tag1', 'tag2']) + + // Try to append the same value + people.appendUniqueValues('tag', 'tag1') + + // Array should remain unchanged + expect(people.ownProperties.tag).toEqual(['tag1', 'tag2']) + }) + + test('appendUniqueValues does nothing if property does not exist', () => { + people.ownProperties = {} + + people.appendUniqueValues('nonexistent', 'value') + + expect(people.ownProperties.nonexistent).toBeUndefined() + }) + + test('increment adds value to existing numerical property', () => { + // Set initial numerical property + people.ownProperties = { + count: 10, + } + + // Increment it + people.increment('count', 5) + + expect(people.ownProperties.count).toBe(15) + + // Decrement it + people.increment('count', -3) + + expect(people.ownProperties.count).toBe(12) + }) + + test('increment does nothing for non-numerical properties', () => { + people.ownProperties = { + name: 'Test', + arrayProp: [1, 2, 3], + } + + people.increment('name', 5) + people.increment('arrayProp', 5) + + expect(people.ownProperties.name).toBe('Test') + expect(people.ownProperties.arrayProp).toEqual([1, 2, 3]) + }) + + test('increment ignores reserved properties', () => { + people.ownProperties = { + distinct_id: 10, + } + + people.increment('distinct_id', 5) + + expect(people.ownProperties.distinct_id).toBe(10) + }) +}) diff --git a/tracker/tracker/src/main/modules/analytics/tests/sharedProperties.test.ts b/tracker/tracker/src/main/modules/analytics/tests/sharedProperties.test.ts new file mode 100644 index 000000000..3b6fbde12 --- /dev/null +++ b/tracker/tracker/src/main/modules/analytics/tests/sharedProperties.test.ts @@ -0,0 +1,184 @@ +// @ts-nocheck +import { jest, describe, test, expect, beforeEach, afterEach } from '@jest/globals' +import SharedProperties from '../sharedProperties.js' +import * as utils from '../utils.js' + +// Mock the utils module +jest.mock('../utils.js', () => ({ + uaParse: jest.fn(), + isObject: jest.requireActual('../utils.js').isObject, + getUTCOffsetString: () => 'UTC+01:00' +})) + +describe('SharedProperties', () => { + let mockApp + let mockWindow + let sessionStorage + let localStorage + + const refKey = '$__initial_ref__$' + const distinctIdKey = '$__distinct_device_id__$' + + beforeEach(() => { + // Create mock storage implementations + sessionStorage = { + [refKey]: 'https://example.com', + } + localStorage = {} + + // Mock app with storage methods + mockApp = { + sessionStorage: { + getItem: jest.fn((key) => sessionStorage[key] || null), + setItem: jest.fn((key, value) => { + sessionStorage[key] = value + }), + }, + localStorage: { + getItem: jest.fn((key) => localStorage[key] || null), + setItem: jest.fn((key, value) => { + localStorage[key] = value + }), + }, + } + + // Mock window + mockWindow = { + location: { + search: '?utm_source=test_source&utm_medium=test_medium&utm_campaign=test_campaign', + }, + } + + // Mock document + global.document = { + referrer: 'https://example.com', + } + + // Mock window globally to make it available to the constructor + global.window = mockWindow + + // Setup mock data for uaParse + utils.uaParse.mockReturnValue({ + width: 1920, + height: 1080, + browser: 'Chrome', + browserVersion: '91.0.4472.124', + browserMajorVersion: 91, + os: 'Windows', + osVersion: '10', + mobile: false, + }) + + // Reset Math.random to ensure predictable device IDs for testing + jest + .spyOn(Math, 'random') + .mockReturnValueOnce(0.1) // First call + .mockReturnValueOnce(0.2) // Second call + .mockReturnValueOnce(0.3) // Third call + }) + + afterEach(() => { + jest.restoreAllMocks() + delete global.document + delete global.window + }) + + test('constructs with correct properties', () => { + const properties = new SharedProperties(mockApp.localStorage, mockApp.sessionStorage) + + expect(utils.uaParse).toHaveBeenCalledWith(window) + expect(properties.os).toBe('Windows') + expect(properties.osVersion).toBe('10') + expect(properties.browser).toBe('Chrome') + expect(properties.browserVersion).toBe('91.0.4472.124 (91)') + expect(properties.device).toBe('Desktop') + expect(properties.screenHeight).toBe(1080) + expect(properties.screenWidth).toBe(1920) + }) + + test('detects UTM parameters correctly', () => { + const properties = new SharedProperties(mockApp.localStorage, mockApp.sessionStorage) + + expect(properties.utmSource).toBe('test_source') + expect(properties.utmMedium).toBe('test_medium') + expect(properties.utmCampaign).toBe('test_campaign') + }) + + test('handles missing UTM parameters', () => { + mockWindow.location.search = '' + const properties = new SharedProperties(mockApp.localStorage, mockApp.sessionStorage) + + expect(properties.utmSource).toBeNull() + expect(properties.utmMedium).toBeNull() + expect(properties.utmCampaign).toBeNull() + }) + + test('generates new device ID if none exists', () => { + const properties = new SharedProperties(mockApp.localStorage, mockApp.sessionStorage) + + expect(mockApp.localStorage.getItem).toHaveBeenCalledWith(distinctIdKey) + expect(mockApp.localStorage.setItem).toHaveBeenCalled() + // (a-z0-9)\-(a-z0-9)\-(a-z0-9) + expect(properties.deviceId).toMatch(/^[a-z0-9]{6,12}-[a-z0-9]{6,12}-[a-z0-9]{6,12}$/) + }) + + test('uses existing device ID if available', () => { + localStorage[distinctIdKey] = 'existing-device-id' + const properties = new SharedProperties(mockApp.localStorage, mockApp.sessionStorage) + + expect(mockApp.localStorage.getItem).toHaveBeenCalledWith(distinctIdKey) + expect(mockApp.localStorage.setItem).not.toHaveBeenCalled() + expect(properties.deviceId).toBe('existing-device-id') + }) + + test('gets referrer from session storage if available', () => { + sessionStorage[refKey] = 'https://stored-referrer.com' + const properties = new SharedProperties(mockApp.localStorage, mockApp.sessionStorage) + + expect(mockApp.sessionStorage.getItem).toHaveBeenCalledWith(refKey) + expect(mockApp.sessionStorage.setItem).not.toHaveBeenCalled() + expect(properties.initialReferrer).toBe('https://stored-referrer.com') + }) + + test('returns all properties with correct prefixes', () => { + const properties = new SharedProperties(mockApp.localStorage, mockApp.sessionStorage) + const allProps = properties.all + + expect(allProps).toMatchObject({ + $os: 'Windows', + $os_version: '10', + $browser: 'Chrome', + $browser_version: '91.0.4472.124 (91)', + $device: 'Desktop', + $screen_height: 1080, + $screen_width: 1920, + $initial_referrer: expect.stringMatching(/^https:\/\/example\.com$/), + $utm_source: 'test_source', + $utm_medium: 'test_medium', + $utm_campaign: 'test_campaign', + $distinct_id: expect.stringMatching(/^[a-z0-9]{6,12}-[a-z0-9]{6,12}-[a-z0-9]{6,12}$/), + $search_engine: null + }) + }) + + test('handles mobile device detection', () => { + utils.uaParse.mockReturnValue({ + width: 375, + height: 812, + browser: 'Safari', + browserVersion: '14.1.1', + browserMajorVersion: 14, + os: 'iOS', + osVersion: '14.6', + mobile: true, + }) + + const properties = new SharedProperties(mockApp.localStorage, mockApp.sessionStorage) + + expect(properties.device).toBe('Mobile') + expect(properties.os).toBe('iOS') + expect(properties.osVersion).toBe('14.6') + expect(properties.browser).toBe('Safari') + expect(properties.browserVersion).toBe('14.1.1 (14)') + }) +}) diff --git a/tracker/tracker/src/main/modules/analytics/tests/utils.test.ts b/tracker/tracker/src/main/modules/analytics/tests/utils.test.ts new file mode 100644 index 000000000..f4eacad62 --- /dev/null +++ b/tracker/tracker/src/main/modules/analytics/tests/utils.test.ts @@ -0,0 +1,131 @@ +// @ts-nocheck +import { jest, describe, test, expect, beforeEach, afterEach } from '@jest/globals' +import { uaParse, isObject } from '../utils.js' + +describe('isObject', () => { + test('returns true for objects', () => { + expect(isObject({})).toBe(true) + expect(isObject({ a: 1 })).toBe(true) + expect(isObject(new Object())).toBe(true) + }) + + test('returns false for non-objects', () => { + expect(isObject(null)).toBe(false) + expect(isObject(undefined)).toBe(false) + expect(isObject([])).toBe(false) + expect(isObject('string')).toBe(false) + expect(isObject(123)).toBe(false) + expect(isObject(true)).toBe(false) + expect(isObject(function () {})).toBe(false) + }) +}) + +describe('uaParse', () => { + let originalNavigator + let originalScreen + let originalDocument + let mockWindow + + beforeEach(() => { + // Save original objects + originalNavigator = global.navigator + originalScreen = global.screen + originalDocument = global.document + + // Create mock screen + global.screen = { + width: 1920, + height: 1080, + } + + // Setup mock document + global.document = { + cookie: 'testcookie=1', + } + + // Setup mock window with basic navigator + mockWindow = { + navigator: { + appVersion: 'test version', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + appName: 'Netscape', + cookieEnabled: true, + }, + screen: { + width: 1920, + height: 1080, + }, + document: global.document, + } + + // Set global navigator + global.navigator = mockWindow.navigator + }) + + afterEach(() => { + // Restore original objects + global.navigator = originalNavigator + global.screen = originalScreen + global.document = originalDocument + }) + + test('detects Chrome browser correctly', () => { + const result = uaParse(mockWindow) + expect(result.browser).toBe('Chrome') + expect(result.browserMajorVersion).toBe(91) + expect(result.os).toBe('Windows') + }) + + test('detects screen dimensions correctly', () => { + const result = uaParse(mockWindow) + expect(result.width).toBe(1920) + expect(result.height).toBe(1080) + expect(result.screen).toBe('1920 x 1080') + }) + + test('detects mobile devices correctly', () => { + mockWindow.navigator.userAgent = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1' + mockWindow.navigator.appVersion = 'iPhone' + const result = uaParse(mockWindow) + expect(result.mobile).toBe(true) + expect(result.os).toBe('iOS') + }) + + test('detects Firefox browser correctly', () => { + mockWindow.navigator.userAgent = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0' + const result = uaParse(mockWindow) + expect(result.browser).toBe('Firefox') + expect(result.browserMajorVersion).toBe(89) + }) + + test('detects Edge browser correctly', () => { + mockWindow.navigator.userAgent = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.59' + const result = uaParse(mockWindow) + expect(result.browser).toBe('Microsoft Edge') + }) + + test('detects cookies correctly', () => { + const result = uaParse(mockWindow) + expect(result.cookies).toBe(true) + + mockWindow.navigator.cookieEnabled = false + const result2 = uaParse(mockWindow) + expect(result2.cookies).toBe(false) + }) + + test('handles undefined screen dimensions', () => { + delete global.screen.width + delete global.screen.height + delete mockWindow.screen.width + delete mockWindow.screen.height + + const result = uaParse(mockWindow) + expect(result.width).toBe(0) + expect(result.height).toBe(0) + expect(result.screen).toBe('') + }) +}) diff --git a/tracker/tracker/src/main/app/analytics/utils.ts b/tracker/tracker/src/main/modules/analytics/utils.ts similarity index 83% rename from tracker/tracker/src/main/app/analytics/utils.ts rename to tracker/tracker/src/main/modules/analytics/utils.ts index 74269eea5..44b0b089f 100644 --- a/tracker/tracker/src/main/app/analytics/utils.ts +++ b/tracker/tracker/src/main/modules/analytics/utils.ts @@ -19,7 +19,7 @@ interface ClientOS { /** * Detects client browser, OS, and device information */ -export function uaParse(window: Window & typeof globalThis): ClientData { +export function uaParse(sWindow: Window & typeof globalThis): ClientData { const unknown = '-' // Screen detection @@ -27,16 +27,16 @@ export function uaParse(window: Window & typeof globalThis): ClientData { let height: number = 0 let screenSize = '' - if (screen.width) { - width = screen.width - height = screen.height + if (sWindow.screen.width) { + width = sWindow.screen.width + height = sWindow.screen.height screenSize = `${width} x ${height}` } // Browser detection - const nVer: string = navigator.appVersion - const nAgt: string = navigator.userAgent - let browser: string = navigator.appName + const nVer: string = sWindow.navigator.appVersion + const nAgt: string = sWindow.navigator.userAgent + let browser: string = sWindow.navigator.appName let version: string = String(parseFloat(nVer)) let nameOffset: number let verOffset: number @@ -89,7 +89,7 @@ export function uaParse(window: Window & typeof globalThis): ClientData { browser = nAgt.substring(nameOffset, verOffset) version = nAgt.substring(verOffset + 1) if (browser.toLowerCase() === browser.toUpperCase()) { - browser = navigator.appName + browser = sWindow.navigator.appName } } @@ -114,11 +114,11 @@ export function uaParse(window: Window & typeof globalThis): ClientData { const mobile: boolean = /Mobile|mini|Fennec|Android|iP(ad|od|hone)/.test(nVer) // Cookie detection - let cookieEnabled: boolean = navigator.cookieEnabled || false + let cookieEnabled: boolean = sWindow.navigator.cookieEnabled || false if (typeof navigator.cookieEnabled === 'undefined' && !cookieEnabled) { - document.cookie = 'testcookie' - cookieEnabled = document.cookie.indexOf('testcookie') !== -1 + sWindow.document.cookie = 'testcookie' + cookieEnabled = sWindow.document.cookie.indexOf('testcookie') !== -1 } // OS detection @@ -172,7 +172,7 @@ export function uaParse(window: Window & typeof globalThis): ClientData { if (matches && matches[1]) { osVersion = matches[1] // Handle Windows 10/11 detection with newer API if available - if (osVersion === '10' && 'userAgentData' in navigator) { + if (osVersion === '10' && 'userAgentData' in sWindow.navigator) { const nav = navigator as Navigator & { userAgentData?: { getHighEntropyValues(values: string[]): Promise<{ platformVersion: string }> @@ -184,7 +184,7 @@ export function uaParse(window: Window & typeof globalThis): ClientData { .getHighEntropyValues(['platformVersion']) .then((ua) => { const version = parseInt(ua.platformVersion.split('.')[0], 10) - ;(window as any).jscd.osVersion = version < 13 ? '10' : '11' + osVersion = version < 13 ? '10' : '11' }) .catch(() => { // Fallback if high entropy values not available @@ -228,3 +228,23 @@ export function uaParse(window: Window & typeof globalThis): ClientData { cookies: cookieEnabled, } } + +export function isObject(item: any): boolean { + const isNull = item === null + return Boolean(item && typeof item === 'object' && !Array.isArray(item) && !isNull) +} + +export function getUTCOffsetString() { + const date = new Date() + const offsetMinutes = date.getTimezoneOffset() + + const hours = Math.abs(Math.floor(offsetMinutes / 60)) + const minutes = Math.abs(offsetMinutes % 60) + + const sign = offsetMinutes <= 0 ? '+' : '-' + + const hoursStr = hours.toString().padStart(2, '0') + const minutesStr = minutes.toString().padStart(2, '0') + + return `UTC${sign}${hoursStr}:${minutesStr}` +} diff --git a/tracker/tracker/src/tests/singleton.test.ts b/tracker/tracker/src/tests/singleton.test.ts index 1587985f8..b228ab820 100644 --- a/tracker/tracker/src/tests/singleton.test.ts +++ b/tracker/tracker/src/tests/singleton.test.ts @@ -75,7 +75,6 @@ describe('Singleton Testing', () => { singleton.configure(options); methods.forEach(method => { - console.log(method); expect(singleton[method]).toBeDefined(); }); })