From e93a90301758418a683d2702c5e2e8ae89b57c38 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Fri, 27 Oct 2023 12:56:47 +0100 Subject: [PATCH] omg our tests --- .../__snapshots__/posthog-core.js.snap | 13 + .../error-conversion.test.ts | 3 +- src/__tests__/gdpr-utils.js | 3 +- src/__tests__/posthog-core.js | 1739 +++++++++-------- src/__tests__/send-request.ts | 3 +- src/autocapture-utils.ts | 4 +- src/autocapture.ts | 6 +- src/decide.ts | 4 +- .../exception-autocapture/error-conversion.ts | 3 +- src/extensions/exception-autocapture/index.ts | 4 +- .../exception-autocapture/stack-trace.ts | 2 +- .../exception-autocapture/type-checking.ts | 2 +- .../replay/sessionrecording-utils.ts | 3 +- src/extensions/replay/sessionrecording.ts | 14 +- src/extensions/replay/web-performance.ts | 4 +- src/extensions/surveys.ts | 3 +- src/gdpr-utils.ts | 4 +- src/loader-exception-autocapture.ts | 3 +- src/loader-recorder-v2.ts | 3 +- src/loader-recorder.ts | 3 +- src/loader-surveys.ts | 3 +- src/posthog-core.ts | 9 +- src/posthog-featureflags.ts | 4 +- src/posthog-persistence.ts | 4 +- src/request-queue.ts | 4 +- src/request-utils.ts | 4 +- src/retry-queue.ts | 4 +- src/send-request.ts | 4 +- src/sessionid.ts | 4 +- src/storage.ts | 4 +- src/type-utils.ts | 63 + src/utils.ts | 93 +- src/uuidv7.ts | 4 +- 33 files changed, 1034 insertions(+), 993 deletions(-) create mode 100644 src/type-utils.ts diff --git a/src/__tests__/__snapshots__/posthog-core.js.snap b/src/__tests__/__snapshots__/posthog-core.js.snap index 4c5097de6..604bb4d19 100644 --- a/src/__tests__/__snapshots__/posthog-core.js.snap +++ b/src/__tests__/__snapshots__/posthog-core.js.snap @@ -12,3 +12,16 @@ Array [ ], ] `; + +exports[`posthog core __compress_and_send_json_request handles base64 compression 1`] = ` +Array [ + Array [ + "/e/", + Object { + "data": "eyJsYXJnZV9rZXkiOiJhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmNhYmMifQ==", + }, + undefined, + [MockFunction], + ], +] +`; diff --git a/src/__tests__/extensions/exception-autocapture/error-conversion.test.ts b/src/__tests__/extensions/exception-autocapture/error-conversion.test.ts index f74799e94..944419f75 100644 --- a/src/__tests__/extensions/exception-autocapture/error-conversion.test.ts +++ b/src/__tests__/extensions/exception-autocapture/error-conversion.test.ts @@ -5,7 +5,8 @@ import { ErrorProperties, unhandledRejectionToProperties, } from '../../../extensions/exception-autocapture/error-conversion' -import { _isNull } from '../../../utils' + +import { _isNull } from '../../../type-utils' // ugh, jest // can't reference PromiseRejectionEvent to construct it 🤷 diff --git a/src/__tests__/gdpr-utils.js b/src/__tests__/gdpr-utils.js index d52406018..2b4f261b8 100644 --- a/src/__tests__/gdpr-utils.js +++ b/src/__tests__/gdpr-utils.js @@ -1,7 +1,8 @@ import sinon from 'sinon' import * as gdpr from '../gdpr-utils' -import { _isNull } from '../utils' + +import { _isNull } from '../type-utils' const TOKENS = [ `test-token`, diff --git a/src/__tests__/posthog-core.js b/src/__tests__/posthog-core.js index 2e6252bc5..732e4953c 100644 --- a/src/__tests__/posthog-core.js +++ b/src/__tests__/posthog-core.js @@ -2,7 +2,7 @@ import { init_as_module, PostHog } from '../posthog-core' import { PostHogPersistence } from '../posthog-persistence' import { Decide } from '../decide' import { autocapture } from '../autocapture' -import { _info } from '../utils' +import { _info, window, document } from '../utils' import { truth } from './helpers/truth' @@ -11,10 +11,6 @@ jest.mock('../gdpr-utils', () => ({ addOptOutCheck: (fn) => fn, })) jest.mock('../decide') -jest.mock('../utils', () => ({ - ...jest.requireActual('../utils'), - document: { title: 'test' }, -})) given('lib', () => { const posthog = new PostHog() @@ -23,433 +19,418 @@ given('lib', () => { return Object.assign(posthog, given.overrides) }) -describe('capture()', () => { - given('eventName', () => '$event') - - given( - 'subject', - () => () => given.lib.capture(given.eventName, given.eventProperties, given.options, given.callback) - ) - - given('config', () => ({ - api_host: 'https://app.posthog.com', - property_blacklist: [], - _onCapture: jest.fn(), - get_device_id: jest.fn().mockReturnValue('device-id'), - })) - - given('overrides', () => ({ - __loaded: true, - config: given.config, - persistence: { - remove_event_timer: jest.fn(), - properties: jest.fn(), - update_config: jest.fn(), - register(properties) { - // Simplified version of the real thing - Object.assign(this.props, properties) +describe('posthog core', () => { + describe('capture()', () => { + given('eventName', () => '$event') + + given( + 'subject', + () => () => given.lib.capture(given.eventName, given.eventProperties, given.options, given.callback) + ) + + given('config', () => ({ + api_host: 'https://app.posthog.com', + property_blacklist: [], + _onCapture: jest.fn(), + get_device_id: jest.fn().mockReturnValue('device-id'), + })) + + given('overrides', () => ({ + __loaded: true, + config: given.config, + persistence: { + remove_event_timer: jest.fn(), + properties: jest.fn(), + update_config: jest.fn(), + register(properties) { + // Simplified version of the real thing + Object.assign(this.props, properties) + }, + props: {}, }, - props: {}, - }, - sessionPersistence: { - update_search_keyword: jest.fn(), - update_campaign_params: jest.fn(), - update_referrer_info: jest.fn(), - update_config: jest.fn(), - properties: jest.fn(), - }, - _send_request: jest.fn(), - compression: {}, - __captureHooks: [], - rateLimiter: { - isRateLimited: () => false, - }, - })) - - it('adds a UUID to each message', () => { - const captureData = given.subject() - expect(captureData).toHaveProperty('uuid') - }) + sessionPersistence: { + update_search_keyword: jest.fn(), + update_campaign_params: jest.fn(), + update_referrer_info: jest.fn(), + update_config: jest.fn(), + properties: jest.fn(), + }, + _send_request: jest.fn(), + compression: {}, + __captureHooks: [], + rateLimiter: { + isRateLimited: () => false, + }, + })) - it('handles recursive objects', () => { - given('eventProperties', () => { - const props = {} - props.recurse = props - return props + it('adds a UUID to each message', () => { + const captureData = given.subject() + expect(captureData).toHaveProperty('uuid') }) - expect(() => given.subject()).not.toThrow() - }) + it('handles recursive objects', () => { + given('eventProperties', () => { + const props = {} + props.recurse = props + return props + }) - it('calls callbacks added via _addCaptureHook', () => { - const hook = jest.fn() + expect(() => given.subject()).not.toThrow() + }) - given.lib._addCaptureHook(hook) + it('calls callbacks added via _addCaptureHook', () => { + const hook = jest.fn() - given.subject() + given.lib._addCaptureHook(hook) - expect(hook).toHaveBeenCalledWith('$event') - }) + given.subject() - it('calls update_campaign_params and update_referrer_info on sessionPersistence', () => { - given('config', () => ({ - property_blacklist: [], - _onCapture: jest.fn(), - store_google: true, - save_referrer: true, - })) + expect(hook).toHaveBeenCalledWith('$event') + }) - given.subject() + it('calls update_campaign_params and update_referrer_info on sessionPersistence', () => { + given('config', () => ({ + property_blacklist: [], + _onCapture: jest.fn(), + store_google: true, + save_referrer: true, + })) - expect(given.lib.sessionPersistence.update_campaign_params).toHaveBeenCalled() - expect(given.lib.sessionPersistence.update_referrer_info).toHaveBeenCalled() - }) + given.subject() - it('errors with undefined event name', () => { - given('eventName', () => undefined) - console.error = jest.fn() + expect(given.lib.sessionPersistence.update_campaign_params).toHaveBeenCalled() + expect(given.lib.sessionPersistence.update_referrer_info).toHaveBeenCalled() + }) - const hook = jest.fn() - given.lib._addCaptureHook(hook) + it('errors with undefined event name', () => { + given('eventName', () => undefined) + console.error = jest.fn() - expect(() => given.subject()).not.toThrow() - expect(hook).not.toHaveBeenCalled() - expect(console.error).toHaveBeenCalledWith('[PostHog.js]', 'No event name provided to posthog.capture') - }) + const hook = jest.fn() + given.lib._addCaptureHook(hook) - it('errors with object event name', () => { - given('eventName', () => ({ event: 'object as name' })) - console.error = jest.fn() + expect(() => given.subject()).not.toThrow() + expect(hook).not.toHaveBeenCalled() + expect(console.error).toHaveBeenCalledWith('[PostHog.js]', 'No event name provided to posthog.capture') + }) - const hook = jest.fn() - given.lib._addCaptureHook(hook) + it('errors with object event name', () => { + given('eventName', () => ({ event: 'object as name' })) + console.error = jest.fn() - expect(() => given.subject()).not.toThrow() - expect(hook).not.toHaveBeenCalled() - expect(console.error).toHaveBeenCalledWith('[PostHog.js]', 'No event name provided to posthog.capture') - }) + const hook = jest.fn() + given.lib._addCaptureHook(hook) - it('truncates long properties', () => { - given('config', () => ({ - properties_string_max_length: 1000, - property_blacklist: [], - _onCapture: jest.fn(), - })) - given('eventProperties', () => ({ - key: 'value'.repeat(10000), - })) - const event = given.subject() - expect(event.properties.key.length).toBe(1000) - }) + expect(() => given.subject()).not.toThrow() + expect(hook).not.toHaveBeenCalled() + expect(console.error).toHaveBeenCalledWith('[PostHog.js]', 'No event name provided to posthog.capture') + }) - it('keeps long properties if null', () => { - given('config', () => ({ - properties_string_max_length: null, - property_blacklist: [], - _onCapture: jest.fn(), - })) - given('eventProperties', () => ({ - key: 'value'.repeat(10000), - })) - const event = given.subject() - expect(event.properties.key.length).toBe(50000) - }) + it('truncates long properties', () => { + given('config', () => ({ + properties_string_max_length: 1000, + property_blacklist: [], + _onCapture: jest.fn(), + })) + given('eventProperties', () => ({ + key: 'value'.repeat(10000), + })) + const event = given.subject() + expect(event.properties.key.length).toBe(1000) + }) - it('passes through $set and $set_once into the request, if the event is an $identify event', () => { - // NOTE: this is slightly unusual to test capture for this specific case - // of being called with $identify as the event name. It might be that we - // decide that this shouldn't be a special case of capture in this case, - // but I'll add the case to capture current functionality. - // - // We check that if identify is called with user $set and $set_once - // properties, we also want to ensure capture does the expected thing - // with them. - const captureResult = given.lib.capture( - '$identify', - { distinct_id: 'some-distinct-id' }, - { $set: { email: 'john@example.com' }, $set_once: { howOftenAmISet: 'once!' } } - ) + it('keeps long properties if null', () => { + given('config', () => ({ + properties_string_max_length: null, + property_blacklist: [], + _onCapture: jest.fn(), + })) + given('eventProperties', () => ({ + key: 'value'.repeat(10000), + })) + const event = given.subject() + expect(event.properties.key.length).toBe(50000) + }) - // We assume that the returned result is the object we would send to the - // server. - expect(captureResult).toEqual( - expect.objectContaining({ $set: { email: 'john@example.com' }, $set_once: { howOftenAmISet: 'once!' } }) - ) - }) + it('passes through $set and $set_once into the request, if the event is an $identify event', () => { + // NOTE: this is slightly unusual to test capture for this specific case + // of being called with $identify as the event name. It might be that we + // decide that this shouldn't be a special case of capture in this case, + // but I'll add the case to capture current functionality. + // + // We check that if identify is called with user $set and $set_once + // properties, we also want to ensure capture does the expected thing + // with them. + const captureResult = given.lib.capture( + '$identify', + { distinct_id: 'some-distinct-id' }, + { $set: { email: 'john@example.com' }, $set_once: { howOftenAmISet: 'once!' } } + ) - it('updates persisted person properties for feature flags if $set is present', () => { - given('config', () => ({ - property_blacklist: [], - _onCapture: jest.fn(), - })) - given('eventProperties', () => ({ - $set: { foo: 'bar' }, - })) - given.subject() - expect(given.overrides.persistence.props.$stored_person_properties).toMatchObject({ foo: 'bar' }) - }) + // We assume that the returned result is the object we would send to the + // server. + expect(captureResult).toEqual( + expect.objectContaining({ $set: { email: 'john@example.com' }, $set_once: { howOftenAmISet: 'once!' } }) + ) + }) - it('correctly handles the "length" property', () => { - const captureResult = given.lib.capture('event-name', { foo: 'bar', length: 0 }) - expect(captureResult.properties).toEqual(expect.objectContaining({ foo: 'bar', length: 0 })) - }) + it('updates persisted person properties for feature flags if $set is present', () => { + given('config', () => ({ + property_blacklist: [], + _onCapture: jest.fn(), + })) + given('eventProperties', () => ({ + $set: { foo: 'bar' }, + })) + given.subject() + expect(given.overrides.persistence.props.$stored_person_properties).toMatchObject({ foo: 'bar' }) + }) - it('sends payloads to /e/ by default', () => { - given.lib.capture('event-name', { foo: 'bar', length: 0 }) - expect(given.lib._send_request).toHaveBeenCalledWith( - 'https://app.posthog.com/e/', - expect.any(Object), - expect.any(Object), - undefined - ) - }) + it('correctly handles the "length" property', () => { + const captureResult = given.lib.capture('event-name', { foo: 'bar', length: 0 }) + expect(captureResult.properties).toEqual(expect.objectContaining({ foo: 'bar', length: 0 })) + }) - it('sends payloads to alternative endpoint if given', () => { - given.lib._afterDecideResponse({ analytics: { endpoint: '/i/v0/e/' } }) - given.lib.capture('event-name', { foo: 'bar', length: 0 }) + it('sends payloads to /e/ by default', () => { + given.lib.capture('event-name', { foo: 'bar', length: 0 }) + expect(given.lib._send_request).toHaveBeenCalledWith( + 'https://app.posthog.com/e/', + expect.any(Object), + expect.any(Object), + undefined + ) + }) - expect(given.lib._send_request).toHaveBeenCalledWith( - 'https://app.posthog.com/i/v0/e/', - expect.any(Object), - expect.any(Object), - undefined - ) - }) + it('sends payloads to alternative endpoint if given', () => { + given.lib._afterDecideResponse({ analytics: { endpoint: '/i/v0/e/' } }) + given.lib.capture('event-name', { foo: 'bar', length: 0 }) - it('sends payloads to overriden endpoint if given', () => { - given.lib.capture('event-name', { foo: 'bar', length: 0 }, { endpoint: '/s/' }) - expect(given.lib._send_request).toHaveBeenCalledWith( - 'https://app.posthog.com/s/', - expect.any(Object), - expect.any(Object), - undefined - ) - }) + expect(given.lib._send_request).toHaveBeenCalledWith( + 'https://app.posthog.com/i/v0/e/', + expect.any(Object), + expect.any(Object), + undefined + ) + }) - it('sends payloads to overriden endpoint, even if alternative endpoint is set', () => { - given.lib._afterDecideResponse({ analytics: { endpoint: '/i/v0/e/' } }) - given.lib.capture('event-name', { foo: 'bar', length: 0 }, { endpoint: '/s/' }) - expect(given.lib._send_request).toHaveBeenCalledWith( - 'https://app.posthog.com/s/', - expect.any(Object), - expect.any(Object), - undefined - ) + it('sends payloads to overriden endpoint if given', () => { + given.lib.capture('event-name', { foo: 'bar', length: 0 }, { endpoint: '/s/' }) + expect(given.lib._send_request).toHaveBeenCalledWith( + 'https://app.posthog.com/s/', + expect.any(Object), + expect.any(Object), + undefined + ) + }) + + it('sends payloads to overriden endpoint, even if alternative endpoint is set', () => { + given.lib._afterDecideResponse({ analytics: { endpoint: '/i/v0/e/' } }) + given.lib.capture('event-name', { foo: 'bar', length: 0 }, { endpoint: '/s/' }) + expect(given.lib._send_request).toHaveBeenCalledWith( + 'https://app.posthog.com/s/', + expect.any(Object), + expect.any(Object), + undefined + ) + }) }) -}) -describe('_afterDecideResponse', () => { - given('subject', () => () => given.lib._afterDecideResponse(given.decideResponse)) + describe('_afterDecideResponse', () => { + given('subject', () => () => given.lib._afterDecideResponse(given.decideResponse)) - it('enables compression from decide response', () => { - given('decideResponse', () => ({ supportedCompression: ['gzip', 'lz64'] })) - given.subject() + it('enables compression from decide response', () => { + given('decideResponse', () => ({ supportedCompression: ['gzip', 'lz64'] })) + given.subject() - expect(given.lib.compression['gzip']).toBe(true) - expect(given.lib.compression['lz64']).toBe(true) - }) + expect(given.lib.compression['gzip']).toBe(true) + expect(given.lib.compression['lz64']).toBe(true) + }) - it('enables compression from decide response when only one received', () => { - given('decideResponse', () => ({ supportedCompression: ['lz64'] })) - given.subject() + it('enables compression from decide response when only one received', () => { + given('decideResponse', () => ({ supportedCompression: ['lz64'] })) + given.subject() - expect(given.lib.compression).not.toHaveProperty('gzip') - expect(given.lib.compression['lz64']).toBe(true) - }) + expect(given.lib.compression).not.toHaveProperty('gzip') + expect(given.lib.compression['lz64']).toBe(true) + }) - it('does not enable compression from decide response if compression is disabled', () => { - given('config', () => ({ disable_compression: true, persistence: 'memory' })) - given('decideResponse', () => ({ supportedCompression: ['gzip', 'lz64'] })) - given.subject() + it('does not enable compression from decide response if compression is disabled', () => { + given('config', () => ({ disable_compression: true, persistence: 'memory' })) + given('decideResponse', () => ({ supportedCompression: ['gzip', 'lz64'] })) + given.subject() - expect(given.lib.compression).toEqual({}) - }) + expect(given.lib.compression).toEqual({}) + }) - it('defaults to /e if no endpoint is given', () => { - given('decideResponse', () => ({})) - given.subject() + it('defaults to /e if no endpoint is given', () => { + given('decideResponse', () => ({})) + given.subject() - expect(given.lib.analyticsDefaultEndpoint).toEqual('/e/') - }) + expect(given.lib.analyticsDefaultEndpoint).toEqual('/e/') + }) - it('uses the specified analytics endpoint if given', () => { - given('decideResponse', () => ({ analytics: { endpoint: '/i/v0/e/' } })) - given.subject() + it('uses the specified analytics endpoint if given', () => { + given('decideResponse', () => ({ analytics: { endpoint: '/i/v0/e/' } })) + given.subject() - expect(given.lib.analyticsDefaultEndpoint).toEqual('/i/v0/e/') + expect(given.lib.analyticsDefaultEndpoint).toEqual('/i/v0/e/') + }) }) -}) -describe('_calculate_event_properties()', () => { - given('subject', () => - given.lib._calculate_event_properties(given.event_name, given.properties, given.start_timestamp, given.options) - ) - - given('event_name', () => 'custom_event') - given('properties', () => ({ event: 'prop' })) - - given('options', () => ({})) - - given('overrides', () => ({ - config: given.config, - persistence: { - properties: () => ({ distinct_id: 'abc', persistent: 'prop' }), - remove_event_timer: jest.fn(), - }, - sessionPersistence: { - properties: () => ({ distinct_id: 'abc', persistent: 'prop' }), - }, - sessionManager: { - checkAndGetSessionAndWindowId: jest.fn().mockReturnValue({ - windowId: 'windowId', - sessionId: 'sessionId', - }), - }, - })) - - given('config', () => ({ - token: 'testtoken', - property_blacklist: given.property_blacklist, - sanitize_properties: given.sanitize_properties, - })) - given('property_blacklist', () => []) - - beforeEach(() => { - jest.spyOn(_info, 'properties').mockReturnValue({ $lib: 'web' }) - }) + describe('_calculate_event_properties()', () => { + given('subject', () => + given.lib._calculate_event_properties( + given.event_name, + given.properties, + given.start_timestamp, + given.options + ) + ) - it('returns calculated properties', () => { - expect(given.subject).toEqual({ - token: 'testtoken', - event: 'prop', - $lib: 'web', - distinct_id: 'abc', - persistent: 'prop', - $window_id: 'windowId', - $session_id: 'sessionId', - }) - }) + given('event_name', () => 'custom_event') + given('properties', () => ({ event: 'prop' })) - it('respects property_blacklist', () => { - given('property_blacklist', () => ['$lib', 'persistent']) + given('options', () => ({})) - expect(given.subject).toEqual({ - token: 'testtoken', - event: 'prop', - distinct_id: 'abc', - $window_id: 'windowId', - $session_id: 'sessionId', - }) - }) + given('overrides', () => ({ + config: given.config, + persistence: { + properties: () => ({ distinct_id: 'abc', persistent: 'prop' }), + remove_event_timer: jest.fn(), + }, + sessionPersistence: { + properties: () => ({ distinct_id: 'abc', persistent: 'prop' }), + }, + sessionManager: { + checkAndGetSessionAndWindowId: jest.fn().mockReturnValue({ + windowId: 'windowId', + sessionId: 'sessionId', + }), + }, + })) - it('only adds token and distinct_id if event_name is $snapshot', () => { - given('event_name', () => '$snapshot') - expect(given.subject).toEqual({ + given('config', () => ({ token: 'testtoken', - event: 'prop', - distinct_id: 'abc', + property_blacklist: given.property_blacklist, + sanitize_properties: given.sanitize_properties, + })) + given('property_blacklist', () => []) + + beforeEach(() => { + jest.spyOn(_info, 'properties').mockReturnValue({ $lib: 'web' }) }) - expect(given.overrides.sessionManager.checkAndGetSessionAndWindowId).not.toHaveBeenCalled() - }) - it('only adds a few propertes if event is $performance_event', () => { - given('event_name', () => '$performance_event') - expect(given.subject).toEqual({ - distinct_id: 'abc', - event: 'prop', // from actual mock event properties - $current_url: undefined, - $session_id: 'sessionId', - $window_id: 'windowId', - token: 'testtoken', + it('returns calculated properties', () => { + expect(given.subject).toEqual({ + token: 'testtoken', + event: 'prop', + $lib: 'web', + distinct_id: 'abc', + persistent: 'prop', + $window_id: 'windowId', + $session_id: 'sessionId', + }) }) - }) - it('calls sanitize_properties', () => { - given('sanitize_properties', () => (props, event_name) => ({ token: props.token, event_name })) + it('respects property_blacklist', () => { + given('property_blacklist', () => ['$lib', 'persistent']) - expect(given.subject).toEqual({ - event_name: given.event_name, - token: 'testtoken', + expect(given.subject).toEqual({ + token: 'testtoken', + event: 'prop', + distinct_id: 'abc', + $window_id: 'windowId', + $session_id: 'sessionId', + }) }) - }) - it('saves $snapshot data and token for $snapshot events', () => { - given('event_name', () => '$snapshot') - given('properties', () => ({ $snapshot_data: {} })) + it('only adds token and distinct_id if event_name is $snapshot', () => { + given('event_name', () => '$snapshot') + expect(given.subject).toEqual({ + token: 'testtoken', + event: 'prop', + distinct_id: 'abc', + }) + expect(given.overrides.sessionManager.checkAndGetSessionAndWindowId).not.toHaveBeenCalled() + }) - expect(given.subject).toEqual({ - token: 'testtoken', - $snapshot_data: {}, - distinct_id: 'abc', + it('only adds a few propertes if event is $performance_event', () => { + given('event_name', () => '$performance_event') + expect(given.subject).toEqual({ + distinct_id: 'abc', + event: 'prop', // from actual mock event properties + $current_url: undefined, + $session_id: 'sessionId', + $window_id: 'windowId', + token: 'testtoken', + }) }) - }) - it("doesn't modify properties passed into it", () => { - const properties = { prop1: 'val1', prop2: 'val2' } - given.lib._calculate_event_properties(given.event_name, properties, given.start_timestamp, given.options) + it('calls sanitize_properties', () => { + given('sanitize_properties', () => (props, event_name) => ({ token: props.token, event_name })) - expect(Object.keys(properties)).toEqual(['prop1', 'prop2']) - }) + expect(given.subject).toEqual({ + event_name: given.event_name, + token: 'testtoken', + }) + }) - it('adds page title to $pageview', () => { - given('event_name', () => '$pageview') + it('saves $snapshot data and token for $snapshot events', () => { + given('event_name', () => '$snapshot') + given('properties', () => ({ $snapshot_data: {} })) - expect(given.subject).toEqual(expect.objectContaining({ title: 'test' })) - }) -}) + expect(given.subject).toEqual({ + token: 'testtoken', + $snapshot_data: {}, + distinct_id: 'abc', + }) + }) -describe('_handle_unload()', () => { - given('subject', () => () => given.lib._handle_unload()) - - given('overrides', () => ({ - config: given.config, - capture: jest.fn(), - compression: {}, - _requestQueue: { - unload: jest.fn(), - }, - _retryQueue: { - unload: jest.fn(), - }, - })) - - given('config', () => ({ - capture_pageview: given.capturePageviews, - capture_pageleave: given.capturePageleave, - request_batching: given.batching, - })) - - given('capturePageviews', () => true) - given('capturePageleave', () => true) - given('batching', () => true) - - it('captures $pageleave', () => { - given.subject() - - expect(given.overrides.capture).toHaveBeenCalledWith('$pageleave') - }) + it("doesn't modify properties passed into it", () => { + const properties = { prop1: 'val1', prop2: 'val2' } + given.lib._calculate_event_properties(given.event_name, properties, given.start_timestamp, given.options) - it('does not capture $pageleave when capture_pageview=false', () => { - given('capturePageviews', () => false) + expect(Object.keys(properties)).toEqual(['prop1', 'prop2']) + }) + + it('adds page title to $pageview', () => { + document.title = 'test' - given.subject() + given('event_name', () => '$pageview') - expect(given.overrides.capture).not.toHaveBeenCalled() + expect(given.subject).toEqual(expect.objectContaining({ title: 'test' })) + }) }) - it('calls requestQueue unload', () => { - given.subject() + describe('_handle_unload()', () => { + given('subject', () => () => given.lib._handle_unload()) - expect(given.overrides._requestQueue.unload).toHaveBeenCalledTimes(1) - }) + given('overrides', () => ({ + config: given.config, + capture: jest.fn(), + compression: {}, + _requestQueue: { + unload: jest.fn(), + }, + _retryQueue: { + unload: jest.fn(), + }, + })) - describe('without batching', () => { - given('batching', () => false) + given('config', () => ({ + capture_pageview: given.capturePageviews, + capture_pageleave: given.capturePageleave, + request_batching: given.batching, + })) + + given('capturePageviews', () => true) + given('capturePageleave', () => true) + given('batching', () => true) it('captures $pageleave', () => { given.subject() - expect(given.overrides.capture).toHaveBeenCalledWith('$pageleave', null, { transport: 'sendBeacon' }) + expect(given.overrides.capture).toHaveBeenCalledWith('$pageleave') }) it('does not capture $pageleave when capture_pageview=false', () => { @@ -459,654 +440,680 @@ describe('_handle_unload()', () => { expect(given.overrides.capture).not.toHaveBeenCalled() }) - }) -}) - -describe('__compress_and_send_json_request', () => { - given( - 'subject', - () => () => given.lib.__compress_and_send_json_request('/e/', given.jsonData, given.options, jest.fn()) - ) - given('jsonData', () => JSON.stringify({ large_key: new Array(500).join('abc') })) + it('calls requestQueue unload', () => { + given.subject() - given('overrides', () => ({ - compression: {}, - _send_request: jest.fn(), - config: {}, - })) + expect(given.overrides._requestQueue.unload).toHaveBeenCalledTimes(1) + }) - it('handles base64 compression', () => { - given('compression', () => ({})) + describe('without batching', () => { + given('batching', () => false) - given.subject() + it('captures $pageleave', () => { + given.subject() - expect(given.overrides._send_request.mock.calls).toMatchSnapshot() - }) -}) + expect(given.overrides.capture).toHaveBeenCalledWith('$pageleave', null, { transport: 'sendBeacon' }) + }) -describe('bootstrapping feature flags', () => { - given('subject', () => () => given.lib._init('posthog', given.config, 'testhog')) + it('does not capture $pageleave when capture_pageview=false', () => { + given('capturePageviews', () => false) - given('overrides', () => ({ - _send_request: jest.fn(), - capture: jest.fn(), - })) + given.subject() - afterEach(() => { - given.lib.reset() + expect(given.overrides.capture).not.toHaveBeenCalled() + }) + }) }) - it('sets the right distinctID', () => { - given('config', () => ({ - bootstrap: { - distinctID: 'abcd', - }, + describe('__compress_and_send_json_request', () => { + given( + 'subject', + () => () => given.lib.__compress_and_send_json_request('/e/', given.jsonData, given.options, jest.fn()) + ) + + given('jsonData', () => JSON.stringify({ large_key: new Array(500).join('abc') })) + + given('overrides', () => ({ + compression: {}, + _send_request: jest.fn(), + config: {}, })) - given.subject() - expect(given.lib.get_distinct_id()).toBe('abcd') - expect(given.lib.get_property('$device_id')).toBe('abcd') - expect(given.lib.persistence.get_user_state()).toBe('anonymous') + it('handles base64 compression', () => { + given('compression', () => ({})) - given.lib.identify('efgh') + given.subject() - expect(given.overrides.capture).toHaveBeenCalledWith( - '$identify', - { - distinct_id: 'efgh', - $anon_distinct_id: 'abcd', - }, - { $set: {}, $set_once: {} } - ) + expect(given.overrides._send_request.mock.calls).toMatchSnapshot() + }) }) - it('treats identified distinctIDs appropriately', () => { - given('config', () => ({ - bootstrap: { - distinctID: 'abcd', - isIdentifiedID: true, - }, - get_device_id: () => 'og-device-id', + describe('bootstrapping feature flags', () => { + given('subject', () => () => given.lib._init('posthog', given.config, 'testhog')) + + given('overrides', () => ({ + _send_request: jest.fn(), + capture: jest.fn(), })) - given.subject() - expect(given.lib.get_distinct_id()).toBe('abcd') - expect(given.lib.get_property('$device_id')).toBe('og-device-id') - expect(given.lib.persistence.get_user_state()).toBe('identified') + afterEach(() => { + given.lib.reset() + }) - given.lib.identify('efgh') - expect(given.overrides.capture).not.toHaveBeenCalled() - }) + it('sets the right distinctID', () => { + given('config', () => ({ + bootstrap: { + distinctID: 'abcd', + }, + })) - it('sets the right feature flags', () => { - given('config', () => ({ - bootstrap: { - featureFlags: { multivariant: 'variant-1', enabled: true, disabled: false, undef: undefined }, - }, - })) + given.subject() + expect(given.lib.get_distinct_id()).toBe('abcd') + expect(given.lib.get_property('$device_id')).toBe('abcd') + expect(given.lib.persistence.get_user_state()).toBe('anonymous') - given.subject() - expect(given.lib.get_distinct_id()).not.toBe('abcd') - expect(given.lib.get_distinct_id()).not.toEqual(undefined) - expect(given.lib.getFeatureFlag('multivariant')).toBe('variant-1') - expect(given.lib.getFeatureFlag('disabled')).toBe(undefined) - expect(given.lib.getFeatureFlag('undef')).toBe(undefined) - expect(given.lib.featureFlags.getFlagVariants()).toEqual({ multivariant: 'variant-1', enabled: true }) - }) + given.lib.identify('efgh') - it('sets the right feature flag payloads', () => { - given('config', () => ({ - bootstrap: { - featureFlags: { - multivariant: 'variant-1', - enabled: true, - jsonString: true, - disabled: false, - undef: undefined, - }, - featureFlagPayloads: { - multivariant: 'some-payload', - enabled: { - another: 'value', - }, - disabled: 'should not show', - undef: 200, - jsonString: '{"a":"payload"}', + expect(given.overrides.capture).toHaveBeenCalledWith( + '$identify', + { + distinct_id: 'efgh', + $anon_distinct_id: 'abcd', }, - }, - })) - - given.subject() - expect(given.lib.getFeatureFlagPayload('multivariant')).toBe('some-payload') - expect(given.lib.getFeatureFlagPayload('enabled')).toEqual({ another: 'value' }) - expect(given.lib.getFeatureFlagPayload('jsonString')).toEqual({ a: 'payload' }) - expect(given.lib.getFeatureFlagPayload('disabled')).toBe(undefined) - expect(given.lib.getFeatureFlagPayload('undef')).toBe(undefined) - }) + { $set: {}, $set_once: {} } + ) + }) - it('does nothing when empty', () => { - jest.spyOn(console, 'warn').mockImplementation() + it('treats identified distinctIDs appropriately', () => { + given('config', () => ({ + bootstrap: { + distinctID: 'abcd', + isIdentifiedID: true, + }, + get_device_id: () => 'og-device-id', + })) - given('config', () => ({ - bootstrap: {}, - })) + given.subject() + expect(given.lib.get_distinct_id()).toBe('abcd') + expect(given.lib.get_property('$device_id')).toBe('og-device-id') + expect(given.lib.persistence.get_user_state()).toBe('identified') - given.subject() - expect(given.lib.get_distinct_id()).not.toBe('abcd') - expect(given.lib.get_distinct_id()).not.toEqual(undefined) - expect(given.lib.getFeatureFlag('multivariant')).toBe(undefined) - expect(console.warn).toHaveBeenCalledWith( - '[PostHog.js]', - expect.stringContaining('getFeatureFlag for key "multivariant" failed') - ) - expect(given.lib.getFeatureFlag('disabled')).toBe(undefined) - expect(given.lib.getFeatureFlag('undef')).toBe(undefined) - expect(given.lib.featureFlags.getFlagVariants()).toEqual({}) - }) + given.lib.identify('efgh') + expect(given.overrides.capture).not.toHaveBeenCalled() + }) - it('onFeatureFlags should be called immediately if feature flags are bootstrapped', () => { - let called = false + it('sets the right feature flags', () => { + given('config', () => ({ + bootstrap: { + featureFlags: { multivariant: 'variant-1', enabled: true, disabled: false, undef: undefined }, + }, + })) - given('config', () => ({ - bootstrap: { - featureFlags: { multivariant: 'variant-1' }, - }, - })) + given.subject() + expect(given.lib.get_distinct_id()).not.toBe('abcd') + expect(given.lib.get_distinct_id()).not.toEqual(undefined) + expect(given.lib.getFeatureFlag('multivariant')).toBe('variant-1') + expect(given.lib.getFeatureFlag('disabled')).toBe(undefined) + expect(given.lib.getFeatureFlag('undef')).toBe(undefined) + expect(given.lib.featureFlags.getFlagVariants()).toEqual({ multivariant: 'variant-1', enabled: true }) + }) - given.subject() - given.lib.featureFlags.onFeatureFlags(() => (called = true)) - expect(called).toEqual(true) - }) + it('sets the right feature flag payloads', () => { + given('config', () => ({ + bootstrap: { + featureFlags: { + multivariant: 'variant-1', + enabled: true, + jsonString: true, + disabled: false, + undef: undefined, + }, + featureFlagPayloads: { + multivariant: 'some-payload', + enabled: { + another: 'value', + }, + disabled: 'should not show', + undef: 200, + jsonString: '{"a":"payload"}', + }, + }, + })) - it('onFeatureFlags should not be called immediately if feature flags bootstrap is empty', () => { - let called = false + given.subject() + expect(given.lib.getFeatureFlagPayload('multivariant')).toBe('some-payload') + expect(given.lib.getFeatureFlagPayload('enabled')).toEqual({ another: 'value' }) + expect(given.lib.getFeatureFlagPayload('jsonString')).toEqual({ a: 'payload' }) + expect(given.lib.getFeatureFlagPayload('disabled')).toBe(undefined) + expect(given.lib.getFeatureFlagPayload('undef')).toBe(undefined) + }) - given('config', () => ({ - bootstrap: { - featureFlags: {}, - }, - })) + it('does nothing when empty', () => { + jest.spyOn(console, 'warn').mockImplementation() - given.subject() - given.lib.featureFlags.onFeatureFlags(() => (called = true)) - expect(called).toEqual(false) - }) + given('config', () => ({ + bootstrap: {}, + })) - it('onFeatureFlags should not be called immediately if feature flags bootstrap is undefined', () => { - let called = false + given.subject() + expect(given.lib.get_distinct_id()).not.toBe('abcd') + expect(given.lib.get_distinct_id()).not.toEqual(undefined) + expect(given.lib.getFeatureFlag('multivariant')).toBe(undefined) + expect(console.warn).toHaveBeenCalledWith( + '[PostHog.js]', + expect.stringContaining('getFeatureFlag for key "multivariant" failed') + ) + expect(given.lib.getFeatureFlag('disabled')).toBe(undefined) + expect(given.lib.getFeatureFlag('undef')).toBe(undefined) + expect(given.lib.featureFlags.getFlagVariants()).toEqual({}) + }) - given('config', () => ({ - bootstrap: { - featureFlags: undefined, - }, - })) + it('onFeatureFlags should be called immediately if feature flags are bootstrapped', () => { + let called = false - given.subject() - given.lib.featureFlags.onFeatureFlags(() => (called = true)) - expect(called).toEqual(false) - }) -}) + given('config', () => ({ + bootstrap: { + featureFlags: { multivariant: 'variant-1' }, + }, + })) -describe('init()', () => { - jest.spyOn(window, 'window', 'get') - given('subject', () => () => given.lib._init('posthog', given.config, 'testhog')) - - given('overrides', () => ({ - get_distinct_id: () => given.distinct_id, - advanced_disable_decide: given.advanced_disable_decide, - _send_request: jest.fn(), - capture: jest.fn(), - register_once: jest.fn(), - })) - - beforeEach(() => { - jest.spyOn(window.console, 'warn').mockImplementation() - jest.spyOn(window.console, 'error').mockImplementation() - jest.spyOn(autocapture, 'init').mockImplementation() - jest.spyOn(autocapture, 'afterDecideResponse').mockImplementation() - }) + given.subject() + given.lib.featureFlags.onFeatureFlags(() => (called = true)) + expect(called).toEqual(true) + }) - given('advanced_disable_decide', () => true) + it('onFeatureFlags should not be called immediately if feature flags bootstrap is empty', () => { + let called = false - it('can set an xhr error handler', () => { - init_as_module() - const fakeOnXHRError = 'configured error' - given('subject', () => - given.lib.init( - 'a-token', - { - on_xhr_error: fakeOnXHRError, + given('config', () => ({ + bootstrap: { + featureFlags: {}, }, - 'a-name' - ) - ) - expect(given.subject.config.on_xhr_error).toBe(fakeOnXHRError) - }) + })) - it('does not load decide endpoint on advanced_disable_decide', () => { - given.subject() - expect(given.decide).toBe(undefined) - expect(given.overrides._send_request.mock.calls.length).toBe(0) // No outgoing requests - }) + given.subject() + given.lib.featureFlags.onFeatureFlags(() => (called = true)) + expect(called).toEqual(false) + }) - it('does not set __loaded_recorder_version flag if recording script has not been included', () => { - given('overrides', () => ({ - __loaded_recorder_version: undefined, - })) - delete window.rrweb - window.rrweb = { record: undefined } - delete window.rrwebRecord - window.rrwebRecord = undefined - given.subject() - expect(given.lib.__loaded_recorder_version).toEqual(undefined) - }) + it('onFeatureFlags should not be called immediately if feature flags bootstrap is undefined', () => { + let called = false - it('set __loaded_recorder_version flag to v1 if recording script has been included', () => { - given('overrides', () => ({ - __loaded_recorder_version: undefined, - })) - delete window.rrweb - window.rrweb = { record: 'anything', version: '1.1.3' } - delete window.rrwebRecord - window.rrwebRecord = 'is possible' - given.subject() - expect(given.lib.__loaded_recorder_version).toMatch(/^1\./) // start with 1.?.? - }) + given('config', () => ({ + bootstrap: { + featureFlags: undefined, + }, + })) - it('set __loaded_recorder_version flag to v1 if recording script has been included', () => { - given('overrides', () => ({ - __loaded_recorder_version: undefined, - })) - delete window.rrweb - window.rrweb = { record: 'anything', version: '2.0.0-alpha.6' } - delete window.rrwebRecord - window.rrwebRecord = 'is possible' - given.subject() - expect(given.lib.__loaded_recorder_version).toMatch(/^2\./) // start with 2.?.? + given.subject() + given.lib.featureFlags.onFeatureFlags(() => (called = true)) + expect(called).toEqual(false) + }) }) - it('does not load autocapture, feature flags, toolbar, session recording or compression', () => { + describe('init()', () => { + jest.spyOn(window, 'window', 'get') + given('subject', () => () => given.lib._init('posthog', given.config, 'testhog')) + given('overrides', () => ({ - sessionRecording: { - afterDecideResponse: jest.fn(), - startRecordingIfEnabled: jest.fn(), - }, - toolbar: { - afterDecideResponse: jest.fn(), - }, - persistence: { - register: jest.fn(), - update_config: jest.fn(), - }, + get_distinct_id: () => given.distinct_id, + advanced_disable_decide: given.advanced_disable_decide, + _send_request: jest.fn(), + capture: jest.fn(), + register_once: jest.fn(), })) - given.subject() - - jest.spyOn(given.lib.toolbar, 'afterDecideResponse').mockImplementation() - jest.spyOn(given.lib.sessionRecording, 'afterDecideResponse').mockImplementation() - jest.spyOn(given.lib.persistence, 'register').mockImplementation() - - // Autocapture - expect(given.lib.__autocapture).toEqual(undefined) - expect(autocapture.init).not.toHaveBeenCalled() - expect(autocapture.afterDecideResponse).not.toHaveBeenCalled() - - // Feature flags - expect(given.lib.persistence.register).not.toHaveBeenCalled() // FFs are saved this way - - // Toolbar - expect(given.lib.toolbar.afterDecideResponse).not.toHaveBeenCalled() - - // Session recording - expect(given.lib.sessionRecording.afterDecideResponse).not.toHaveBeenCalled() - - // Compression - expect(given.lib['compression']).toEqual({}) - }) - - describe('device id behavior', () => { - it('sets a random UUID as distinct_id/$device_id if distinct_id is unset', () => { - given('distinct_id', () => undefined) + beforeEach(() => { + jest.spyOn(window.console, 'warn').mockImplementation() + jest.spyOn(window.console, 'error').mockImplementation() + jest.spyOn(autocapture, 'init').mockImplementation() + jest.spyOn(autocapture, 'afterDecideResponse').mockImplementation() + }) - given.subject() + given('advanced_disable_decide', () => true) - expect(given.lib.register_once).toHaveBeenCalledWith( - { - $device_id: truth((val) => val.match(/^[0-9a-f-]+$/)), - distinct_id: truth((val) => val.match(/^[0-9a-f-]+$/)), - }, - '' + it('can set an xhr error handler', () => { + init_as_module() + const fakeOnXHRError = 'configured error' + given('subject', () => + given.lib.init( + 'a-token', + { + on_xhr_error: fakeOnXHRError, + }, + 'a-name' + ) ) + expect(given.subject.config.on_xhr_error).toBe(fakeOnXHRError) }) - it('does not set distinct_id/$device_id if distinct_id is unset', () => { - given('distinct_id', () => 'existing-id') - + it('does not load decide endpoint on advanced_disable_decide', () => { given.subject() - - expect(given.lib.register_once).not.toHaveBeenCalled() + expect(given.decide).toBe(undefined) + expect(given.overrides._send_request.mock.calls.length).toBe(0) // No outgoing requests }) - it('uses config.get_device_id for uuid generation if passed', () => { - given('distinct_id', () => undefined) - given('config', () => ({ - get_device_id: (uuid) => 'custom-' + uuid.slice(0, 8), + it('does not set __loaded_recorder_version flag if recording script has not been included', () => { + given('overrides', () => ({ + __loaded_recorder_version: undefined, })) - + delete window.rrweb + window.rrweb = { record: undefined } + delete window.rrwebRecord + window.rrwebRecord = undefined given.subject() - - expect(given.lib.register_once).toHaveBeenCalledWith( - { - $device_id: truth((val) => val.match(/^custom-[0-9a-f]+/)), - distinct_id: truth((val) => val.match(/^custom-[0-9a-f]+/)), - }, - '' - ) + expect(given.lib.__loaded_recorder_version).toEqual(undefined) }) - }) -}) -describe('skipped init()', () => { - it('capture() does not throw', () => { - expect(() => given.lib.capture('$pageview')).not.toThrow() - }) -}) + it('set __loaded_recorder_version flag to v1 if recording script has been included', () => { + given('overrides', () => ({ + __loaded_recorder_version: undefined, + })) + delete window.rrweb + window.rrweb = { record: 'anything', version: '1.1.3' } + delete window.rrwebRecord + window.rrwebRecord = 'is possible' + given.subject() + expect(given.lib.__loaded_recorder_version).toMatch(/^1\./) // start with 1.?.? + }) -describe('group()', () => { - given('captureQueue', () => jest.fn()) - given('overrides', () => ({ - persistence: new PostHogPersistence(given.config), - capture: jest.fn(), - _captureMetrics: { - incr: jest.fn(), - }, - reloadFeatureFlags: jest.fn(), - })) - given('config', () => ({ - request_batching: true, - persistence: 'memory', - property_blacklist: [], - _onCapture: jest.fn(), - })) - - beforeEach(() => { - given.overrides.persistence.clear() - }) + it('set __loaded_recorder_version flag to v1 if recording script has been included', () => { + given('overrides', () => ({ + __loaded_recorder_version: undefined, + })) + delete window.rrweb + window.rrweb = { record: 'anything', version: '2.0.0-alpha.6' } + delete window.rrwebRecord + window.rrwebRecord = 'is possible' + given.subject() + expect(given.lib.__loaded_recorder_version).toMatch(/^2\./) // start with 2.?.? + }) - it('records info on groups', () => { - given.lib.group('organization', 'org::5') - expect(given.lib.getGroups()).toEqual({ organization: 'org::5' }) + it('does not load autocapture, feature flags, toolbar, session recording or compression', () => { + given('overrides', () => ({ + sessionRecording: { + afterDecideResponse: jest.fn(), + startRecordingIfEnabled: jest.fn(), + }, + toolbar: { + afterDecideResponse: jest.fn(), + }, + persistence: { + register: jest.fn(), + update_config: jest.fn(), + }, + })) - given.lib.group('organization', 'org::6') - expect(given.lib.getGroups()).toEqual({ organization: 'org::6' }) + given.subject() - given.lib.group('instance', 'app.posthog.com') - expect(given.lib.getGroups()).toEqual({ organization: 'org::6', instance: 'app.posthog.com' }) - }) + jest.spyOn(given.lib.toolbar, 'afterDecideResponse').mockImplementation() + jest.spyOn(given.lib.sessionRecording, 'afterDecideResponse').mockImplementation() + jest.spyOn(given.lib.persistence, 'register').mockImplementation() - it('records info on groupProperties for groups', () => { - given.lib.group('organization', 'org::5', { name: 'PostHog' }) - expect(given.lib.getGroups()).toEqual({ organization: 'org::5' }) + // Autocapture + expect(given.lib.__autocapture).toEqual(undefined) + expect(autocapture.init).not.toHaveBeenCalled() + expect(autocapture.afterDecideResponse).not.toHaveBeenCalled() - expect(given.lib.persistence.props['$stored_group_properties']).toEqual({ organization: { name: 'PostHog' } }) + // Feature flags + expect(given.lib.persistence.register).not.toHaveBeenCalled() // FFs are saved this way - given.lib.group('organization', 'org::6') - expect(given.lib.getGroups()).toEqual({ organization: 'org::6' }) - expect(given.lib.persistence.props['$stored_group_properties']).toEqual({ organization: {} }) + // Toolbar + expect(given.lib.toolbar.afterDecideResponse).not.toHaveBeenCalled() - given.lib.group('instance', 'app.posthog.com') - expect(given.lib.getGroups()).toEqual({ organization: 'org::6', instance: 'app.posthog.com' }) - expect(given.lib.persistence.props['$stored_group_properties']).toEqual({ organization: {}, instance: {} }) + // Session recording + expect(given.lib.sessionRecording.afterDecideResponse).not.toHaveBeenCalled() - // now add properties to the group - given.lib.group('organization', 'org::7', { name: 'PostHog2' }) - expect(given.lib.getGroups()).toEqual({ organization: 'org::7', instance: 'app.posthog.com' }) - expect(given.lib.persistence.props['$stored_group_properties']).toEqual({ - organization: { name: 'PostHog2' }, - instance: {}, + // Compression + expect(given.lib['compression']).toEqual({}) }) - given.lib.group('instance', 'app.posthog.com', { a: 'b' }) - expect(given.lib.getGroups()).toEqual({ organization: 'org::7', instance: 'app.posthog.com' }) - expect(given.lib.persistence.props['$stored_group_properties']).toEqual({ - organization: { name: 'PostHog2' }, - instance: { a: 'b' }, - }) + describe('device id behavior', () => { + it('sets a random UUID as distinct_id/$device_id if distinct_id is unset', () => { + given('distinct_id', () => undefined) - given.lib.resetGroupPropertiesForFlags() - expect(given.lib.persistence.props['$stored_group_properties']).toEqual(undefined) - }) + given.subject() - it('does not result in a capture call', () => { - given.lib.group('organization', 'org::5') + expect(given.lib.register_once).toHaveBeenCalledWith( + { + $device_id: truth((val) => val.match(/^[0-9a-f-]+$/)), + distinct_id: truth((val) => val.match(/^[0-9a-f-]+$/)), + }, + '' + ) + }) - expect(given.overrides.capture).not.toHaveBeenCalled() - }) + it('does not set distinct_id/$device_id if distinct_id is unset', () => { + given('distinct_id', () => 'existing-id') - it('results in a reloadFeatureFlags call if group changes', () => { - given.lib.group('organization', 'org::5', { name: 'PostHog' }) - given.lib.group('instance', 'app.posthog.com') - given.lib.group('organization', 'org::5') + given.subject() - expect(given.overrides.reloadFeatureFlags).toHaveBeenCalledTimes(2) - }) + expect(given.lib.register_once).not.toHaveBeenCalled() + }) - it('results in a reloadFeatureFlags call if group properties change', () => { - given.lib.group('organization', 'org::5') - given.lib.group('instance', 'app.posthog.com') - given.lib.group('organization', 'org::5', { name: 'PostHog' }) - given.lib.group('instance', 'app.posthog.com') + it('uses config.get_device_id for uuid generation if passed', () => { + given('distinct_id', () => undefined) + given('config', () => ({ + get_device_id: (uuid) => 'custom-' + uuid.slice(0, 8), + })) - expect(given.overrides.reloadFeatureFlags).toHaveBeenCalledTimes(3) - }) + given.subject() - it('captures $groupidentify event', () => { - given.lib.group('organization', 'org::5', { group: 'property', foo: 5 }) + expect(given.lib.register_once).toHaveBeenCalledWith( + { + $device_id: truth((val) => val.match(/^custom-[0-9a-f]+/)), + distinct_id: truth((val) => val.match(/^custom-[0-9a-f]+/)), + }, + '' + ) + }) + }) + }) - expect(given.overrides.capture).toHaveBeenCalledWith('$groupidentify', { - $group_type: 'organization', - $group_key: 'org::5', - $group_set: { - group: 'property', - foo: 5, - }, + describe('skipped init()', () => { + it('capture() does not throw', () => { + expect(() => given.lib.capture('$pageview')).not.toThrow() }) }) - describe('subsequent capture calls', () => { + describe('group()', () => { + given('captureQueue', () => jest.fn()) given('overrides', () => ({ - __loaded: true, - config: given.config, persistence: new PostHogPersistence(given.config), - sessionPersistence: new PostHogPersistence(given.config), - _requestQueue: { - enqueue: given.captureQueue, + capture: jest.fn(), + _captureMetrics: { + incr: jest.fn(), }, reloadFeatureFlags: jest.fn(), })) + given('config', () => ({ + request_batching: true, + persistence: 'memory', + property_blacklist: [], + _onCapture: jest.fn(), + })) - it('sends group information in event properties', () => { + beforeEach(() => { + given.overrides.persistence.clear() + }) + + it('records info on groups', () => { given.lib.group('organization', 'org::5') + expect(given.lib.getGroups()).toEqual({ organization: 'org::5' }) + + given.lib.group('organization', 'org::6') + expect(given.lib.getGroups()).toEqual({ organization: 'org::6' }) + given.lib.group('instance', 'app.posthog.com') + expect(given.lib.getGroups()).toEqual({ organization: 'org::6', instance: 'app.posthog.com' }) + }) + + it('records info on groupProperties for groups', () => { + given.lib.group('organization', 'org::5', { name: 'PostHog' }) + expect(given.lib.getGroups()).toEqual({ organization: 'org::5' }) - given.lib.capture('some_event', { prop: 5 }) + expect(given.lib.persistence.props['$stored_group_properties']).toEqual({ + organization: { name: 'PostHog' }, + }) + + given.lib.group('organization', 'org::6') + expect(given.lib.getGroups()).toEqual({ organization: 'org::6' }) + expect(given.lib.persistence.props['$stored_group_properties']).toEqual({ organization: {} }) + + given.lib.group('instance', 'app.posthog.com') + expect(given.lib.getGroups()).toEqual({ organization: 'org::6', instance: 'app.posthog.com' }) + expect(given.lib.persistence.props['$stored_group_properties']).toEqual({ organization: {}, instance: {} }) - expect(given.captureQueue).toHaveBeenCalledTimes(1) + // now add properties to the group + given.lib.group('organization', 'org::7', { name: 'PostHog2' }) + expect(given.lib.getGroups()).toEqual({ organization: 'org::7', instance: 'app.posthog.com' }) + expect(given.lib.persistence.props['$stored_group_properties']).toEqual({ + organization: { name: 'PostHog2' }, + instance: {}, + }) - const [, eventPayload] = given.captureQueue.mock.calls[0] - expect(eventPayload.event).toEqual('some_event') - expect(eventPayload.properties.$groups).toEqual({ - organization: 'org::5', - instance: 'app.posthog.com', + given.lib.group('instance', 'app.posthog.com', { a: 'b' }) + expect(given.lib.getGroups()).toEqual({ organization: 'org::7', instance: 'app.posthog.com' }) + expect(given.lib.persistence.props['$stored_group_properties']).toEqual({ + organization: { name: 'PostHog2' }, + instance: { a: 'b' }, }) + + given.lib.resetGroupPropertiesForFlags() + expect(given.lib.persistence.props['$stored_group_properties']).toEqual(undefined) }) - }) - describe('error handling', () => { - given('overrides', () => ({ - register: jest.fn(), - })) + it('does not result in a capture call', () => { + given.lib.group('organization', 'org::5') - it('handles blank keys being passed', () => { - window.console.error = jest.fn() - window.console.warn = jest.fn() + expect(given.overrides.capture).not.toHaveBeenCalled() + }) - given.lib.group(null, 'foo') - given.lib.group('organization', null) - given.lib.group('organization', undefined) - given.lib.group('organization', '') - given.lib.group('', 'foo') + it('results in a reloadFeatureFlags call if group changes', () => { + given.lib.group('organization', 'org::5', { name: 'PostHog' }) + given.lib.group('instance', 'app.posthog.com') + given.lib.group('organization', 'org::5') - expect(given.overrides.register).not.toHaveBeenCalled() + expect(given.overrides.reloadFeatureFlags).toHaveBeenCalledTimes(2) }) - }) - describe('reset group', () => { - it('groups property is empty and reloads feature flags', () => { + it('results in a reloadFeatureFlags call if group properties change', () => { given.lib.group('organization', 'org::5') - given.lib.group('instance', 'app.posthog.com', { group: 'property', foo: 5 }) + given.lib.group('instance', 'app.posthog.com') + given.lib.group('organization', 'org::5', { name: 'PostHog' }) + given.lib.group('instance', 'app.posthog.com') - expect(given.lib.persistence.props['$groups']).toEqual({ - organization: 'org::5', - instance: 'app.posthog.com', - }) + expect(given.overrides.reloadFeatureFlags).toHaveBeenCalledTimes(3) + }) - expect(given.lib.persistence.props['$stored_group_properties']).toEqual({ - organization: {}, - instance: { + it('captures $groupidentify event', () => { + given.lib.group('organization', 'org::5', { group: 'property', foo: 5 }) + + expect(given.overrides.capture).toHaveBeenCalledWith('$groupidentify', { + $group_type: 'organization', + $group_key: 'org::5', + $group_set: { group: 'property', foo: 5, }, }) + }) - given.lib.resetGroups() + describe('subsequent capture calls', () => { + given('overrides', () => ({ + __loaded: true, + config: given.config, + persistence: new PostHogPersistence(given.config), + sessionPersistence: new PostHogPersistence(given.config), + _requestQueue: { + enqueue: given.captureQueue, + }, + reloadFeatureFlags: jest.fn(), + })) - expect(given.lib.persistence.props['$groups']).toEqual({}) - expect(given.lib.persistence.props['$stored_group_properties']).toEqual(undefined) + it('sends group information in event properties', () => { + given.lib.group('organization', 'org::5') + given.lib.group('instance', 'app.posthog.com') - expect(given.overrides.reloadFeatureFlags).toHaveBeenCalledTimes(3) - }) - }) -}) + given.lib.capture('some_event', { prop: 5 }) -describe('_loaded()', () => { - given('subject', () => () => given.lib._loaded()) - - given('overrides', () => ({ - config: given.config, - capture: jest.fn(), - featureFlags: { - setReloadingPaused: jest.fn(), - resetRequestQueue: jest.fn(), - _startReloadTimer: jest.fn(), - }, - _start_queue_if_opted_in: jest.fn(), - })) - given('config', () => ({ loaded: jest.fn() })) - - it('calls loaded config option', () => { - given.subject() - - expect(given.config.loaded).toHaveBeenCalledWith(given.lib) - }) + expect(given.captureQueue).toHaveBeenCalledTimes(1) - it('handles loaded config option throwing gracefully', () => { - given('config', () => ({ - loaded: () => { - throw Error() - }, - })) - console.error = jest.fn() + const [, eventPayload] = given.captureQueue.mock.calls[0] + expect(eventPayload.event).toEqual('some_event') + expect(eventPayload.properties.$groups).toEqual({ + organization: 'org::5', + instance: 'app.posthog.com', + }) + }) + }) - given.subject() + describe('error handling', () => { + given('overrides', () => ({ + register: jest.fn(), + })) - expect(console.error).toHaveBeenCalledWith('[PostHog.js]', '`loaded` function failed', expect.anything()) - }) + it('handles blank keys being passed', () => { + window.console.error = jest.fn() + window.console.warn = jest.fn() - describe('/decide', () => { - beforeEach(() => { - const call = jest.fn() - Decide.mockImplementation(() => ({ call })) + given.lib.group(null, 'foo') + given.lib.group('organization', null) + given.lib.group('organization', undefined) + given.lib.group('organization', '') + given.lib.group('', 'foo') + + expect(given.overrides.register).not.toHaveBeenCalled() + }) }) - afterEach(() => { - Decide.mockReset() + describe('reset group', () => { + it('groups property is empty and reloads feature flags', () => { + given.lib.group('organization', 'org::5') + given.lib.group('instance', 'app.posthog.com', { group: 'property', foo: 5 }) + + expect(given.lib.persistence.props['$groups']).toEqual({ + organization: 'org::5', + instance: 'app.posthog.com', + }) + + expect(given.lib.persistence.props['$stored_group_properties']).toEqual({ + organization: {}, + instance: { + group: 'property', + foo: 5, + }, + }) + + given.lib.resetGroups() + + expect(given.lib.persistence.props['$groups']).toEqual({}) + expect(given.lib.persistence.props['$stored_group_properties']).toEqual(undefined) + + expect(given.overrides.reloadFeatureFlags).toHaveBeenCalledTimes(3) + }) }) + }) + + describe('_loaded()', () => { + given('subject', () => () => given.lib._loaded()) - it('is called by default', () => { + given('overrides', () => ({ + config: given.config, + capture: jest.fn(), + featureFlags: { + setReloadingPaused: jest.fn(), + resetRequestQueue: jest.fn(), + _startReloadTimer: jest.fn(), + }, + _start_queue_if_opted_in: jest.fn(), + })) + given('config', () => ({ loaded: jest.fn() })) + + it('calls loaded config option', () => { given.subject() - expect(new Decide().call).toHaveBeenCalled() - expect(given.overrides.featureFlags.setReloadingPaused).toHaveBeenCalledWith(true) + expect(given.config.loaded).toHaveBeenCalledWith(given.lib) }) - it('does not call decide if disabled', () => { + it('handles loaded config option throwing gracefully', () => { given('config', () => ({ - advanced_disable_decide: true, - loaded: jest.fn(), + loaded: () => { + throw Error() + }, })) + console.error = jest.fn() given.subject() - expect(new Decide().call).not.toHaveBeenCalled() - expect(given.overrides.featureFlags.setReloadingPaused).not.toHaveBeenCalled() + expect(console.error).toHaveBeenCalledWith('[PostHog.js]', '`loaded` function failed', expect.anything()) }) - }) - describe('capturing pageviews', () => { - it('captures not capture pageview if disabled', () => { - given('config', () => ({ - capture_pageview: false, - loaded: jest.fn(), - })) + describe('/decide', () => { + beforeEach(() => { + const call = jest.fn() + Decide.mockImplementation(() => ({ call })) + }) - given.subject() + afterEach(() => { + Decide.mockReset() + }) - expect(given.overrides.capture).not.toHaveBeenCalled() + it('is called by default', () => { + given.subject() + + expect(new Decide().call).toHaveBeenCalled() + expect(given.overrides.featureFlags.setReloadingPaused).toHaveBeenCalledWith(true) + }) + + it('does not call decide if disabled', () => { + given('config', () => ({ + advanced_disable_decide: true, + loaded: jest.fn(), + })) + + given.subject() + + expect(new Decide().call).not.toHaveBeenCalled() + expect(given.overrides.featureFlags.setReloadingPaused).not.toHaveBeenCalled() + }) }) - it('captures pageview if enabled', () => { - given('config', () => ({ - capture_pageview: true, - loaded: jest.fn(), - })) + describe('capturing pageviews', () => { + it('captures not capture pageview if disabled', () => { + given('config', () => ({ + capture_pageview: false, + loaded: jest.fn(), + })) - given.subject() + given.subject() - expect(given.overrides.capture).toHaveBeenCalledWith( - '$pageview', - { title: 'test' }, - { send_instantly: true } - ) + expect(given.overrides.capture).not.toHaveBeenCalled() + }) + + it('captures pageview if enabled', () => { + given('config', () => ({ + capture_pageview: true, + loaded: jest.fn(), + })) + + given.subject() + + expect(given.overrides.capture).toHaveBeenCalledWith( + '$pageview', + { title: 'test' }, + { send_instantly: true } + ) + }) }) }) -}) -describe('session_id', () => { - given('overrides', () => ({ - sessionManager: { - checkAndGetSessionAndWindowId: jest.fn().mockReturnValue({ - windowId: 'windowId', - sessionId: 'sessionId', - sessionStartTimestamp: new Date().getTime() - 30000, - }), - }, - })) - it('returns the session_id', () => { - expect(given.lib.get_session_id()).toEqual('sessionId') - }) + describe('session_id', () => { + given('overrides', () => ({ + sessionManager: { + checkAndGetSessionAndWindowId: jest.fn().mockReturnValue({ + windowId: 'windowId', + sessionId: 'sessionId', + sessionStartTimestamp: new Date().getTime() - 30000, + }), + }, + })) + it('returns the session_id', () => { + expect(given.lib.get_session_id()).toEqual('sessionId') + }) - it('returns the replay URL', () => { - expect(given.lib.get_session_replay_url()).toEqual('https://app.posthog.com/replay/sessionId') - }) + it('returns the replay URL', () => { + expect(given.lib.get_session_replay_url()).toEqual('https://app.posthog.com/replay/sessionId') + }) - it('returns the replay URL including timestamp', () => { - expect(given.lib.get_session_replay_url({ withTimestamp: true })).toEqual( - 'https://app.posthog.com/replay/sessionId?t=20' // default lookback is 10 seconds - ) + it('returns the replay URL including timestamp', () => { + expect(given.lib.get_session_replay_url({ withTimestamp: true })).toEqual( + 'https://app.posthog.com/replay/sessionId?t=20' // default lookback is 10 seconds + ) - expect(given.lib.get_session_replay_url({ withTimestamp: true, timestampLookBack: 0 })).toEqual( - 'https://app.posthog.com/replay/sessionId?t=30' - ) + expect(given.lib.get_session_replay_url({ withTimestamp: true, timestampLookBack: 0 })).toEqual( + 'https://app.posthog.com/replay/sessionId?t=30' + ) + }) }) }) diff --git a/src/__tests__/send-request.ts b/src/__tests__/send-request.ts index e28770c01..9d5dc4d09 100644 --- a/src/__tests__/send-request.ts +++ b/src/__tests__/send-request.ts @@ -3,7 +3,8 @@ import { addParamsToURL, encodePostData, xhr } from '../send-request' import { assert, boolean, property, uint8Array, VerbosityLevel } from 'fast-check' import { Compression, PostData, XHROptions, XHRParams } from '../types' -import { _isUndefined } from '../utils' + +import { _isUndefined } from '../type-utils' jest.mock('../config', () => ({ DEBUG: false, LIB_VERSION: '1.23.45' })) diff --git a/src/autocapture-utils.ts b/src/autocapture-utils.ts index c0d3dc97f..1638f0ea3 100644 --- a/src/autocapture-utils.ts +++ b/src/autocapture-utils.ts @@ -4,7 +4,9 @@ * @returns {string} the element's class */ import { AutocaptureConfig } from 'types' -import { _each, _includes, _isNull, _isString, _isUndefined, _trim, logger } from './utils' +import { _each, _includes, _trim, logger } from './utils' + +import { _isNull, _isString, _isUndefined } from './type-utils' export function getClassName(el: Element): string { switch (typeof el.className) { diff --git a/src/autocapture.ts b/src/autocapture.ts index a96bff61a..19884510d 100644 --- a/src/autocapture.ts +++ b/src/autocapture.ts @@ -3,10 +3,6 @@ import { _each, _extend, _includes, - _isBoolean, - _isFunction, - _isNull, - _isUndefined, _register_event, _safewrap_instance_methods, logger, @@ -31,6 +27,8 @@ import { AutocaptureConfig, AutoCaptureCustomProperty, DecideResponse, Propertie import { PostHog } from './posthog-core' import { AUTOCAPTURE_DISABLED_SERVER_SIDE } from './constants' +import { _isBoolean, _isFunction, _isNull, _isUndefined } from './type-utils' + function limitText(length: number, text: string): string { if (text.length > length) { return text.slice(0, length) + '...' diff --git a/src/decide.ts b/src/decide.ts index 297000bde..e8ab3e090 100644 --- a/src/decide.ts +++ b/src/decide.ts @@ -1,9 +1,11 @@ import { autocapture } from './autocapture' -import { _base64Encode, _isUndefined, loadScript, logger } from './utils' +import { _base64Encode, loadScript, logger } from './utils' import { PostHog } from './posthog-core' import { DecideResponse } from './types' import { STORED_GROUP_PROPERTIES_KEY, STORED_PERSON_PROPERTIES_KEY } from './constants' +import { _isUndefined } from './type-utils' + export class Decide { instance: PostHog diff --git a/src/extensions/exception-autocapture/error-conversion.ts b/src/extensions/exception-autocapture/error-conversion.ts index 276f1d41e..bfbad8781 100644 --- a/src/extensions/exception-autocapture/error-conversion.ts +++ b/src/extensions/exception-autocapture/error-conversion.ts @@ -9,7 +9,8 @@ import { isPrimitive, } from './type-checking' import { defaultStackParser, StackFrame } from './stack-trace' -import { _isNumber, _isString, _isUndefined } from '../../utils' + +import { _isNumber, _isString, _isUndefined } from '../../type-utils' /** * based on the very wonderful MIT licensed Sentry SDK diff --git a/src/extensions/exception-autocapture/index.ts b/src/extensions/exception-autocapture/index.ts index 7994d8cb6..efddeecf1 100644 --- a/src/extensions/exception-autocapture/index.ts +++ b/src/extensions/exception-autocapture/index.ts @@ -1,9 +1,11 @@ -import { _isArray, _isObject, _isUndefined, logger, window } from '../../utils' +import { logger, window } from '../../utils' import { PostHog } from '../../posthog-core' import { DecideResponse, Properties } from '../../types' import { ErrorEventArgs, ErrorProperties, errorToProperties, unhandledRejectionToProperties } from './error-conversion' import { isPrimitive } from './type-checking' +import { _isArray, _isObject, _isUndefined } from '../../type-utils' + const EXCEPTION_INGESTION_ENDPOINT = '/e/' export const extendPostHog = (instance: PostHog, response: DecideResponse) => { diff --git a/src/extensions/exception-autocapture/stack-trace.ts b/src/extensions/exception-autocapture/stack-trace.ts index a8e292125..008520799 100644 --- a/src/extensions/exception-autocapture/stack-trace.ts +++ b/src/extensions/exception-autocapture/stack-trace.ts @@ -26,7 +26,7 @@ // CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE // OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -import { _isUndefined } from '../../utils' +import { _isUndefined } from '../../type-utils' const WEBPACK_ERROR_REGEXP = /\(error: (.*)\)/ const STACKTRACE_FRAME_LIMIT = 50 diff --git a/src/extensions/exception-autocapture/type-checking.ts b/src/extensions/exception-autocapture/type-checking.ts index e8f6f5abe..45f9b677d 100644 --- a/src/extensions/exception-autocapture/type-checking.ts +++ b/src/extensions/exception-autocapture/type-checking.ts @@ -1,4 +1,4 @@ -import { _isFunction, _isNull, _isObject, _isUndefined } from '../../utils' +import { _isFunction, _isNull, _isObject, _isUndefined } from '../../type-utils' export function isEvent(candidate: unknown): candidate is Event { return !_isUndefined(Event) && isInstanceOf(candidate, Event) diff --git a/src/extensions/replay/sessionrecording-utils.ts b/src/extensions/replay/sessionrecording-utils.ts index 69e20be04..503418bd7 100644 --- a/src/extensions/replay/sessionrecording-utils.ts +++ b/src/extensions/replay/sessionrecording-utils.ts @@ -11,7 +11,8 @@ import type { mutationCallbackParam, } from '@rrweb/types' import type { Mirror, MaskInputOptions, MaskInputFn, MaskTextFn, SlimDOMOptions, DataURLOptions } from 'rrweb-snapshot' -import { _isObject } from '../../utils' + +import { _isObject } from '../../type-utils' export const replacementImageURI = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiBmaWxsPSJibGFjayIvPgo8cGF0aCBkPSJNOCAwSDE2TDAgMTZWOEw4IDBaIiBmaWxsPSIjMkQyRDJEIi8+CjxwYXRoIGQ9Ik0xNiA4VjE2SDhMMTYgOFoiIGZpbGw9IiMyRDJEMkQiLz4KPC9zdmc+Cg==' diff --git a/src/extensions/replay/sessionrecording.ts b/src/extensions/replay/sessionrecording.ts index 15b3aae62..1bface4a4 100644 --- a/src/extensions/replay/sessionrecording.ts +++ b/src/extensions/replay/sessionrecording.ts @@ -17,17 +17,9 @@ import { PostHog } from '../../posthog-core' import { DecideResponse, NetworkRequest, Properties } from '../../types' import { EventType, type eventWithTime, type listenerHandler } from '@rrweb/types' import Config from '../../config' -import { - _isBoolean, - _isNull, - _isNumber, - _isObject, - _isString, - _isUndefined, - _timestamp, - loadScript, - logger, -} from '../../utils' +import { _timestamp, loadScript, logger } from '../../utils' + +import { _isBoolean, _isNull, _isNumber, _isObject, _isString, _isUndefined } from '../../type-utils' const BASE_ENDPOINT = '/s/' diff --git a/src/extensions/replay/web-performance.ts b/src/extensions/replay/web-performance.ts index 61791a75b..bc5b5fdfa 100644 --- a/src/extensions/replay/web-performance.ts +++ b/src/extensions/replay/web-performance.ts @@ -1,8 +1,10 @@ -import { _isUndefined, logger } from '../../utils' +import { logger } from '../../utils' import { PostHog } from '../../posthog-core' import { DecideResponse, NetworkRequest } from '../../types' import { isLocalhost } from '../../request-utils' +import { _isUndefined } from '../../type-utils' + const PERFORMANCE_EVENTS_MAPPING: { [key: string]: number } = { // BASE_PERFORMANCE_EVENT_COLUMNS entryType: 0, diff --git a/src/extensions/surveys.ts b/src/extensions/surveys.ts index 970477ce3..1e1fb6d8c 100644 --- a/src/extensions/surveys.ts +++ b/src/extensions/surveys.ts @@ -8,7 +8,8 @@ import { SurveyAppearance, SurveyQuestion, } from '../posthog-surveys-types' -import { _isUndefined } from '../utils' + +import { _isUndefined } from '../type-utils' const satisfiedEmoji = '' diff --git a/src/gdpr-utils.ts b/src/gdpr-utils.ts index 33604fd0f..f58138855 100644 --- a/src/gdpr-utils.ts +++ b/src/gdpr-utils.ts @@ -11,11 +11,13 @@ * These functions are used internally by the SDK and are not intended to be publicly exposed. */ -import { _each, _includes, _isNumber, _isString, logger, window } from './utils' +import { _each, _includes, logger, window } from './utils' import { cookieStore, localStore, localPlusCookieStore } from './storage' import { GDPROptions, PersistentStore } from './types' import { PostHog } from './posthog-core' +import { _isNumber, _isString } from './type-utils' + /** * A function used to capture a PostHog event (e.g. PostHogLib.capture) * @callback captureFunction diff --git a/src/loader-exception-autocapture.ts b/src/loader-exception-autocapture.ts index 64596eb57..f555d3825 100644 --- a/src/loader-exception-autocapture.ts +++ b/src/loader-exception-autocapture.ts @@ -1,5 +1,6 @@ import { extendPostHog } from './extensions/exception-autocapture' -import { _isUndefined } from './utils' + +import { _isUndefined } from './type-utils' const win: Window & typeof globalThis = _isUndefined(window) ? ({} as typeof window) : window diff --git a/src/loader-recorder-v2.ts b/src/loader-recorder-v2.ts index 7d6fcd6ea..853f1b80a 100644 --- a/src/loader-recorder-v2.ts +++ b/src/loader-recorder-v2.ts @@ -7,7 +7,8 @@ import rrwebRecord from 'rrweb/es/rrweb/packages/rrweb/src/record' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore import { getRecordConsolePlugin } from 'rrweb/es/rrweb/packages/rrweb/src/plugins/console/record' -import { _isUndefined } from './utils' + +import { _isUndefined } from './type-utils' const win: Window & typeof globalThis = _isUndefined(window) ? ({} as typeof window) : window diff --git a/src/loader-recorder.ts b/src/loader-recorder.ts index 3531a54c8..ee2a21194 100644 --- a/src/loader-recorder.ts +++ b/src/loader-recorder.ts @@ -7,7 +7,8 @@ import rrwebRecord from 'rrweb-v1/es/rrweb/packages/rrweb/src/record' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore import { getRecordConsolePlugin } from 'rrweb-v1/es/rrweb/packages/rrweb/src/plugins/console/record' -import { _isUndefined } from './utils' + +import { _isUndefined } from './type-utils' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore diff --git a/src/loader-surveys.ts b/src/loader-surveys.ts index e0a0886eb..8c3ee891d 100644 --- a/src/loader-surveys.ts +++ b/src/loader-surveys.ts @@ -1,5 +1,6 @@ import { generateSurveys } from './extensions/surveys' -import { _isUndefined } from './utils' + +import { _isUndefined } from './type-utils' const win: Window & typeof globalThis = _isUndefined(window) ? ({} as typeof window) : window diff --git a/src/posthog-core.ts b/src/posthog-core.ts index e6ae3802f..003ba19ac 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -5,20 +5,13 @@ import { _eachArray, _extend, _info, - _isArray, _isBlockedUA, - _isEmptyObject, - _isObject, - _isUndefined, _register_event, _safewrap_class, - document, userAgent, window, logger, isCrossDomainCookie, - _isString, - _isFunction, } from './utils' import { autocapture } from './autocapture' import { PostHogFeatureFlags } from './posthog-featureflags' @@ -63,6 +56,8 @@ import { PostHogSurveys } from './posthog-surveys' import { RateLimiter } from './rate-limiter' import { uuidv7 } from './uuidv7' import { SurveyCallback } from './posthog-surveys-types' +import { document } from './utils' +import { _isArray, _isEmptyObject, _isFunction, _isObject, _isString, _isUndefined } from './type-utils' /* SIMPLE STYLE GUIDE: diff --git a/src/posthog-featureflags.ts b/src/posthog-featureflags.ts index 5b5ad2a05..02aec5d91 100644 --- a/src/posthog-featureflags.ts +++ b/src/posthog-featureflags.ts @@ -1,4 +1,4 @@ -import { _base64Encode, _entries, _extend, _isArray, logger } from './utils' +import { _base64Encode, _entries, _extend, logger } from './utils' import { PostHog } from './posthog-core' import { DecideResponse, @@ -19,6 +19,8 @@ import { FLAG_CALL_REPORTED, } from './constants' +import { _isArray } from './type-utils' + const PERSISTENCE_ACTIVE_FEATURE_FLAGS = '$active_feature_flags' const PERSISTENCE_OVERRIDE_FEATURE_FLAGS = '$override_feature_flags' const PERSISTENCE_FEATURE_FLAG_PAYLOADS = '$feature_flag_payloads' diff --git a/src/posthog-persistence.ts b/src/posthog-persistence.ts index 319e9851c..19d425d36 100644 --- a/src/posthog-persistence.ts +++ b/src/posthog-persistence.ts @@ -1,6 +1,6 @@ /* eslint camelcase: "off" */ -import { _each, _extend, _include, _info, _isObject, _isUndefined, _strip_empty_properties, logger } from './utils' +import { _each, _extend, _include, _info, _strip_empty_properties, logger } from './utils' import { cookieStore, localStore, localPlusCookieStore, memoryStore, sessionStore } from './storage' import { PersistentStore, PostHogConfig, Properties } from './types' import { @@ -11,6 +11,8 @@ import { USER_STATE, } from './constants' +import { _isObject, _isUndefined } from './type-utils' + const CASE_INSENSITIVE_PERSISTENCE_TYPES: readonly Lowercase[] = [ 'cookie', 'localstorage', diff --git a/src/request-queue.ts b/src/request-queue.ts index 2d3e7277c..3a01c0f83 100644 --- a/src/request-queue.ts +++ b/src/request-queue.ts @@ -1,7 +1,9 @@ import { RequestQueueScaffold } from './base-request-queue' -import { _each, _isUndefined } from './utils' +import { _each } from './utils' import { Properties, QueuedRequestData, XHROptions } from './types' +import { _isUndefined } from './type-utils' + export class RequestQueue extends RequestQueueScaffold { handlePollRequest: (url: string, data: Properties, options?: XHROptions) => void diff --git a/src/request-utils.ts b/src/request-utils.ts index da77d5a76..81a73d02b 100644 --- a/src/request-utils.ts +++ b/src/request-utils.ts @@ -1,4 +1,6 @@ -import { _each, _isNull, _isString, _isUndefined, _isValidRegex, logger } from './utils' +import { _each, _isValidRegex, logger } from './utils' + +import { _isNull, _isString, _isUndefined } from './type-utils' const localDomains = ['localhost', '127.0.0.1'] diff --git a/src/retry-queue.ts b/src/retry-queue.ts index a6151c3ac..80d03b31d 100644 --- a/src/retry-queue.ts +++ b/src/retry-queue.ts @@ -1,9 +1,11 @@ import { RequestQueueScaffold } from './base-request-queue' import { encodePostData, xhr } from './send-request' import { QueuedRequestData, RetryQueueElement } from './types' -import { _isUndefined, logger } from './utils' +import { logger } from './utils' import { RateLimiter } from './rate-limiter' +import { _isUndefined } from './type-utils' + const thirtyMinutes = 30 * 60 * 1000 /** diff --git a/src/send-request.ts b/src/send-request.ts index 4cc341613..2df931d0c 100644 --- a/src/send-request.ts +++ b/src/send-request.ts @@ -1,8 +1,10 @@ -import { _each, _isArray, _isFunction, _isUint8Array, logger } from './utils' +import { _each, logger } from './utils' import Config from './config' import { PostData, XHROptions, XHRParams } from './types' import { _HTTPBuildQuery } from './request-utils' +import { _isArray, _isFunction, _isUint8Array } from './type-utils' + export const addParamsToURL = ( url: string, urlQueryArgs: Record | undefined, diff --git a/src/sessionid.ts b/src/sessionid.ts index deb86d3c8..26beca1e5 100644 --- a/src/sessionid.ts +++ b/src/sessionid.ts @@ -3,7 +3,9 @@ import { SESSION_ID } from './constants' import { sessionStore } from './storage' import { PostHogConfig, SessionIdChangedCallback } from './types' import { uuidv7 } from './uuidv7' -import { _isArray, _isNumber, _isUndefined, logger, window } from './utils' +import { logger, window } from './utils' + +import { _isArray, _isNumber, _isUndefined } from './type-utils' const MAX_SESSION_IDLE_TIMEOUT = 30 * 60 // 30 minutes const MIN_SESSION_IDLE_TIMEOUT = 60 // 1 minute diff --git a/src/storage.ts b/src/storage.ts index bd92a1ec3..af0c68f8e 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -1,7 +1,9 @@ -import { _extend, _isNull, _isUndefined, logger } from './utils' +import { _extend, logger } from './utils' import { PersistentStore, Properties } from './types' import { DISTINCT_ID, SESSION_ID, SESSION_RECORDING_IS_SAMPLED } from './constants' +import { _isNull, _isUndefined } from './type-utils' + const DOMAIN_MATCH_REGEX = /[a-z0-9][a-z0-9-]+\.[a-z]{2,}$/i // Methods partially borrowed from quirksmode.org/js/cookies.html diff --git a/src/type-utils.ts b/src/type-utils.ts new file mode 100644 index 000000000..eeb1049ae --- /dev/null +++ b/src/type-utils.ts @@ -0,0 +1,63 @@ +// eslint-disable-next-line posthog-js/no-direct-array-check +const nativeIsArray = Array.isArray +const ObjProto = Object.prototype +export const hasOwnProperty = ObjProto.hasOwnProperty + +export const _isArray = + nativeIsArray || + function (obj: any): obj is any[] { + return toString.call(obj) === '[object Array]' + } +export const _isUint8Array = function (x: unknown): x is Uint8Array { + return Object.prototype.toString.call(x) === '[object Uint8Array]' +} +// from a comment on http://dbj.org/dbj/?p=286 +// fails on only one very rare and deliberate custom object: +// let bomb = { toString : undefined, valueOf: function(o) { return "function BOMBA!"; }}; +export const _isFunction = function (f: any): f is (...args: any[]) => any { + try { + // eslint-disable-next-line posthog-js/no-direct-function-check + return /^\s*\bfunction\b/.test(f) + } catch (x) { + return false + } +} +// Underscore Addons +export const _isObject = function (x: unknown): x is Record { + // eslint-disable-next-line posthog-js/no-direct-object-check + return x === Object(x) && !_isArray(x) +} +export const _isEmptyObject = function (x: unknown): x is Record { + if (_isObject(x)) { + for (const key in x) { + if (hasOwnProperty.call(x, key)) { + return false + } + } + return true + } + return false +} +export const _isUndefined = function (x: unknown): x is undefined { + return x === void 0 +} +export const _isString = function (x: unknown): x is string { + // eslint-disable-next-line posthog-js/no-direct-string-check + return toString.call(x) == '[object String]' +} +export const _isNull = function (x: unknown): x is null { + // eslint-disable-next-line posthog-js/no-direct-null-check + return x === null +} +export const _isDate = function (x: unknown): x is Date { + // eslint-disable-next-line posthog-js/no-direct-date-check + return toString.call(x) == '[object Date]' +} +export const _isNumber = function (x: unknown): x is number { + // eslint-disable-next-line posthog-js/no-direct-number-check + return toString.call(x) == '[object Number]' +} +export const _isBoolean = function (x: unknown): x is boolean { + // eslint-disable-next-line posthog-js/no-direct-boolean-check + return toString.call(x) === '[object Boolean]' +} diff --git a/src/utils.ts b/src/utils.ts index 21b82c021..5db2c43e2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,26 +1,30 @@ import Config from './config' import { Breaker, EventHandler, Properties } from './types' import { _getQueryParam } from './request-utils' +import { + _isArray, + _isDate, + _isFunction, + _isNull, + _isObject, + _isString, + _isUndefined, + hasOwnProperty, +} from './type-utils' /* * Saved references to long variable names, so that closure compiler can * minimize file size. */ -const ArrayProto = Array.prototype -const ObjProto = Object.prototype -const toString = ObjProto.toString -const hasOwnProperty = ObjProto.hasOwnProperty +export const ArrayProto = Array.prototype +export const nativeForEach = ArrayProto.forEach +export const nativeIndexOf = ArrayProto.indexOf const win: Window & typeof globalThis = typeof window !== 'undefined' ? window : ({} as typeof window) const navigator = win.navigator || { userAgent: '' } const document = win.document || {} const userAgent = navigator.userAgent - -const nativeForEach = ArrayProto.forEach, - nativeIndexOf = ArrayProto.indexOf, - // eslint-disable-next-line posthog-js/no-direct-array-check - nativeIsArray = Array.isArray, - breaker: Breaker = {} +const breaker: Breaker = {} const LOGGER_PREFIX = '[PostHog.js]' @@ -124,28 +128,6 @@ export const _extend = function (obj: Record, ...args: Record any { - try { - // eslint-disable-next-line posthog-js/no-direct-function-check - return /^\s*\bfunction\b/.test(f) - } catch (x) { - return false - } -} - export const _include = function ( obj: null | string | Array | Record, target: any @@ -185,53 +167,6 @@ export function _entries(obj: Record): [string, T][] { return resArray } -// Underscore Addons -export const _isObject = function (x: unknown): x is Record { - // eslint-disable-next-line posthog-js/no-direct-object-check - return x === Object(x) && !_isArray(x) -} - -export const _isEmptyObject = function (x: unknown): x is Record { - if (_isObject(x)) { - for (const key in x) { - if (hasOwnProperty.call(x, key)) { - return false - } - } - return true - } - return false -} - -export const _isUndefined = function (x: unknown): x is undefined { - return x === void 0 -} - -export const _isString = function (x: unknown): x is string { - // eslint-disable-next-line posthog-js/no-direct-string-check - return toString.call(x) == '[object String]' -} - -export const _isNull = function (x: unknown): x is null { - // eslint-disable-next-line posthog-js/no-direct-null-check - return x === null -} - -export const _isDate = function (x: unknown): x is Date { - // eslint-disable-next-line posthog-js/no-direct-date-check - return toString.call(x) == '[object Date]' -} - -export const _isNumber = function (x: unknown): x is number { - // eslint-disable-next-line posthog-js/no-direct-number-check - return toString.call(x) == '[object Number]' -} - -export const _isBoolean = function (x: unknown): x is boolean { - // eslint-disable-next-line posthog-js/no-direct-boolean-check - return toString.call(x) === '[object Boolean]' -} - export const _isValidRegex = function (str: string): boolean { try { new RegExp(str) diff --git a/src/uuidv7.ts b/src/uuidv7.ts index a8ec0ac5e..e39ef78cb 100644 --- a/src/uuidv7.ts +++ b/src/uuidv7.ts @@ -9,7 +9,9 @@ */ // polyfill for IE11 -import { _isNumber, _isUndefined, window } from './utils' +import { window } from './utils' + +import { _isNumber, _isUndefined } from './type-utils' if (!Math.trunc) { Math.trunc = function (v) {