tracker: move sdk, add people/event trackers, add unit tests

This commit is contained in:
nick-delirium 2025-03-18 15:23:47 +01:00
parent 2a1c28cc49
commit 226fc867c0
No known key found for this signature in database
GPG key ID: 93ABD695DF5FDBA0
14 changed files with 1241 additions and 97 deletions

View file

@ -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) {}
}

View file

@ -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
}
}
}

View file

@ -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

View file

@ -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<Options>) {
@ -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() ?? ''
}
}
}

View file

@ -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<string, any> = []
sendInterval: ReturnType<typeof setInterval> | null = null
ownProperties: Record<string, any> = {}
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<string, any>,
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, any> | 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, any> | 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,
}
}
}

View file

@ -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)
}
}

View file

@ -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<string, any> = {}
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<string, any>) => {
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<string, any>) => {
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
}
}

View file

@ -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
}
}

View file

@ -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')
})
})

View file

@ -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)
})
})

View file

@ -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)')
})
})

View file

@ -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('')
})
})

View file

@ -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}`
}

View file

@ -75,7 +75,6 @@ describe('Singleton Testing', () => {
singleton.configure(options);
methods.forEach(method => {
console.log(method);
expect(singleton[method]).toBeDefined();
});
})