tracker: move sdk, add people/event trackers, add unit tests
This commit is contained in:
parent
2a1c28cc49
commit
226fc867c0
14 changed files with 1241 additions and 97 deletions
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() ?? ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
135
tracker/tracker/src/main/modules/analytics/events.ts
Normal file
135
tracker/tracker/src/main/modules/analytics/events.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
20
tracker/tracker/src/main/modules/analytics/index.ts
Normal file
20
tracker/tracker/src/main/modules/analytics/index.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
120
tracker/tracker/src/main/modules/analytics/people.ts
Normal file
120
tracker/tracker/src/main/modules/analytics/people.ts
Normal 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
|
||||
}
|
||||
}
|
||||
132
tracker/tracker/src/main/modules/analytics/sharedProperties.ts
Normal file
132
tracker/tracker/src/main/modules/analytics/sharedProperties.ts
Normal 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
|
||||
}
|
||||
}
|
||||
201
tracker/tracker/src/main/modules/analytics/tests/events.test.ts
Normal file
201
tracker/tracker/src/main/modules/analytics/tests/events.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
258
tracker/tracker/src/main/modules/analytics/tests/people.test.ts
Normal file
258
tracker/tracker/src/main/modules/analytics/tests/people.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)')
|
||||
})
|
||||
})
|
||||
131
tracker/tracker/src/main/modules/analytics/tests/utils.test.ts
Normal file
131
tracker/tracker/src/main/modules/analytics/tests/utils.test.ts
Normal 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('')
|
||||
})
|
||||
})
|
||||
|
|
@ -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}`
|
||||
}
|
||||
|
|
@ -75,7 +75,6 @@ describe('Singleton Testing', () => {
|
|||
singleton.configure(options);
|
||||
|
||||
methods.forEach(method => {
|
||||
console.log(method);
|
||||
expect(singleton[method]).toBeDefined();
|
||||
});
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue