diff --git a/functional_tests/feature-flags.test.ts b/functional_tests/feature-flags.test.ts index b8a38768c..25c37f0f4 100644 --- a/functional_tests/feature-flags.test.ts +++ b/functional_tests/feature-flags.test.ts @@ -25,14 +25,14 @@ describe('FunctionalTests / Feature Flags', () => { resetRequests(token) // wait for decide callback - await new Promise((res) => setTimeout(res, 500)) + await new Promise((resolve: () => void) => setTimeout(resolve, 500)) // Person properties set here should also be sent to the decide endpoint. posthog.identify('test-id', { email: 'test@email.com', }) - await new Promise((res) => setTimeout(res, 500)) + await new Promise((resolve: () => void) => setTimeout(resolve, 500)) await waitFor(() => { expect(getRequests(token)['/decide/']).toEqual([ @@ -72,7 +72,7 @@ describe('FunctionalTests / Feature Flags', () => { // wait for decide callback // eslint-disable-next-line compat/compat - await new Promise((res) => setTimeout(res, 500)) + await new Promise((resolve: () => void) => setTimeout(resolve, 500)) // First we identify with a new distinct_id but with no properties set posthog.identify('test-id') @@ -140,7 +140,7 @@ describe('FunctionalTests / Feature Flags', () => { // wait for decide callback // eslint-disable-next-line compat/compat - await new Promise((res) => setTimeout(res, 500)) + await new Promise((resolve: () => void) => setTimeout(resolve, 500)) // now second call should've fired await waitFor(() => { diff --git a/jest.config.js b/jest.config.js index 0cfcfda00..4f36d0842 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,8 @@ +// eslint-disable-next-line no-undef module.exports = { testPathIgnorePatterns: ['/node_modules/', '/cypress/', '/react/', '/test_data/', '/testcafe/'], moduleFileExtensions: ['js', 'json', 'ts', 'tsx'], - setupFilesAfterEnv: ['given2/setup', './src/__tests__/setup.js'], + setupFilesAfterEnv: ['./src/__tests__/setup.js'], modulePathIgnorePatterns: ['src/__tests__/setup.js', 'src/__tests__/helpers/'], clearMocks: true, testEnvironment: 'jsdom', diff --git a/package.json b/package.json index 6014fd8ed..27811512b 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,6 @@ "eslint-plugin-react-hooks": "^4.6.0", "express": "^4.18.2", "fast-check": "^2.17.0", - "given2": "^2.1.7", "husky": "^8.0.1", "jest": "^27.5.1", "jsdom": "16.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0878bc90d..41803cdb6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,9 +144,6 @@ devDependencies: fast-check: specifier: ^2.17.0 version: 2.17.0 - given2: - specifier: ^2.1.7 - version: 2.1.7 husky: specifier: ^8.0.1 version: 8.0.1 @@ -5848,10 +5845,6 @@ packages: omggif: 1.0.10 dev: true - /given2@2.1.7: - resolution: {integrity: sha512-fI3VamsjN2euNVguGpSt2uExyDSMfJoK+SwDxbmV+Thf3v4oF6KKZAFE3LHHuT+PYyMwCsJYXO01TW3euFdPGA==} - dev: true - /glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} diff --git a/src/__tests__/decide.js b/src/__tests__/decide.js deleted file mode 100644 index 1c0bff886..000000000 --- a/src/__tests__/decide.js +++ /dev/null @@ -1,241 +0,0 @@ -import { Decide } from '../decide' -import { PostHogPersistence } from '../posthog-persistence' -import { RequestRouter } from '../utils/request-router' -import { expectScriptToExist, expectScriptToNotExist } from './helpers/script-utils' - -const expectDecodedSendRequest = (send_request, data, noCompression) => { - const lastCall = send_request.mock.calls[send_request.mock.calls.length - 1] - - const decoded = lastCall[0].data - // Helper to give us more accurate error messages - expect(decoded).toEqual(data) - - expect(given.posthog._send_request).toHaveBeenCalledWith({ - url: 'https://test.com/decide/?v=3', - data, - method: 'POST', - callback: expect.any(Function), - compression: noCompression ? undefined : 'base64', - timeout: undefined, - }) -} - -describe('Decide', () => { - given('decide', () => new Decide(given.posthog)) - given('posthog', () => ({ - config: given.config, - persistence: new PostHogPersistence(given.config), - register: (props) => given.posthog.persistence.register(props), - unregister: (key) => given.posthog.persistence.unregister(key), - get_property: (key) => given.posthog.persistence.props[key], - capture: jest.fn(), - _addCaptureHook: jest.fn(), - _afterDecideResponse: jest.fn(), - get_distinct_id: jest.fn().mockImplementation(() => 'distinctid'), - _send_request: jest.fn().mockImplementation(({ callback }) => callback?.({ config: given.decideResponse })), - featureFlags: { - receivedFeatureFlags: jest.fn(), - setReloadingPaused: jest.fn(), - _startReloadTimer: jest.fn(), - }, - requestRouter: new RequestRouter({ config: given.config }), - _hasBootstrappedFeatureFlags: jest.fn(), - getGroups: () => ({ organization: '5' }), - })) - - given('decideResponse', () => ({})) - - given('config', () => ({ api_host: 'https://test.com', persistence: 'memory' })) - - beforeEach(() => { - // clean the JSDOM to prevent interdependencies between tests - document.body.innerHTML = '' - document.head.innerHTML = '' - }) - - describe('constructor', () => { - given('subject', () => () => given.decide.call()) - - given('config', () => ({ - api_host: 'https://test.com', - token: 'testtoken', - persistence: 'memory', - })) - - it('should call instance._send_request on constructor', () => { - given.subject() - - expectDecodedSendRequest(given.posthog._send_request, { - token: 'testtoken', - distinct_id: 'distinctid', - groups: { organization: '5' }, - }) - }) - - it('should send all stored properties with decide request', () => { - given.posthog.register({ - $stored_person_properties: { key: 'value' }, - $stored_group_properties: { organization: { orgName: 'orgValue' } }, - }) - given.subject() - - expectDecodedSendRequest(given.posthog._send_request, { - token: 'testtoken', - distinct_id: 'distinctid', - groups: { organization: '5' }, - person_properties: { key: 'value' }, - group_properties: { organization: { orgName: 'orgValue' } }, - }) - }) - - it('should send disable flags with decide request when config is set', () => { - given('config', () => ({ - api_host: 'https://test.com', - token: 'testtoken', - persistence: 'memory', - advanced_disable_feature_flags: true, - })) - given.posthog.register({ - $stored_person_properties: { key: 'value' }, - $stored_group_properties: { organization: { orgName: 'orgValue' } }, - }) - given.subject() - - expectDecodedSendRequest(given.posthog._send_request, { - token: 'testtoken', - distinct_id: 'distinctid', - groups: { organization: '5' }, - person_properties: { key: 'value' }, - group_properties: { organization: { orgName: 'orgValue' } }, - disable_flags: true, - }) - }) - - it('should disable compression when config is set', () => { - given('config', () => ({ - api_host: 'https://test.com', - token: 'testtoken', - persistence: 'memory', - disable_compression: true, - })) - given.posthog.register({ - $stored_person_properties: {}, - $stored_group_properties: {}, - }) - given.subject() - - // noCompression is true - expectDecodedSendRequest( - given.posthog._send_request, - { - token: 'testtoken', - distinct_id: 'distinctid', - groups: { organization: '5' }, - person_properties: {}, - group_properties: {}, - }, - true - ) - }) - - it('should send disable flags with decide request when config for advanced_disable_feature_flags_on_first_load is set', () => { - given('config', () => ({ - api_host: 'https://test.com', - token: 'testtoken', - persistence: 'memory', - advanced_disable_feature_flags_on_first_load: true, - })) - given.posthog.register({ - $stored_person_properties: { key: 'value' }, - $stored_group_properties: { organization: { orgName: 'orgValue' } }, - }) - given.subject() - - expectDecodedSendRequest(given.posthog._send_request, { - token: 'testtoken', - distinct_id: 'distinctid', - groups: { organization: '5' }, - person_properties: { key: 'value' }, - group_properties: { organization: { orgName: 'orgValue' } }, - disable_flags: true, - }) - }) - }) - - describe('parseDecideResponse', () => { - given('subject', () => () => given.decide.parseDecideResponse(given.decideResponse)) - - it('properly parses decide response', () => { - given('decideResponse', () => ({})) - given.subject() - - expect(given.posthog.featureFlags.receivedFeatureFlags).toHaveBeenCalledWith(given.decideResponse, false) - expect(given.posthog._afterDecideResponse).toHaveBeenCalledWith(given.decideResponse) - }) - - it('Make sure receivedFeatureFlags is called with errors if the decide response fails', () => { - given('decideResponse', () => undefined) - window.POSTHOG_DEBUG = true - console.error = jest.fn() - - given.subject() - - expect(given.posthog.featureFlags.receivedFeatureFlags).toHaveBeenCalledWith({}, true) - expect(console.error).toHaveBeenCalledWith('[PostHog.js]', 'Failed to fetch feature flags from PostHog.') - }) - - it('Make sure receivedFeatureFlags is not called if advanced_disable_feature_flags_on_first_load is set', () => { - given('decideResponse', () => ({ - featureFlags: { 'test-flag': true }, - })) - given('config', () => ({ - api_host: 'https://test.com', - token: 'testtoken', - persistence: 'memory', - advanced_disable_feature_flags_on_first_load: true, - })) - - given.subject() - - expect(given.posthog._afterDecideResponse).toHaveBeenCalledWith(given.decideResponse) - expect(given.posthog.featureFlags.receivedFeatureFlags).not.toHaveBeenCalled() - }) - - it('Make sure receivedFeatureFlags is not called if advanced_disable_feature_flags is set', () => { - given('decideResponse', () => ({ - featureFlags: { 'test-flag': true }, - })) - given('config', () => ({ - api_host: 'https://test.com', - token: 'testtoken', - persistence: 'memory', - advanced_disable_feature_flags: true, - })) - - given.subject() - - expect(given.posthog._afterDecideResponse).toHaveBeenCalledWith(given.decideResponse) - expect(given.posthog.featureFlags.receivedFeatureFlags).not.toHaveBeenCalled() - }) - - it('runs site apps if opted in', () => { - given('config', () => ({ api_host: 'https://test.com', opt_in_site_apps: true, persistence: 'memory' })) - given('decideResponse', () => ({ siteApps: [{ id: 1, url: '/site_app/1/tokentoken/hash/' }] })) - given.subject() - expectScriptToExist('https://test.com/site_app/1/tokentoken/hash/') - }) - - it('does not run site apps code if not opted in', () => { - window.POSTHOG_DEBUG = true - given('config', () => ({ api_host: 'https://test.com', opt_in_site_apps: false, persistence: 'memory' })) - given('decideResponse', () => ({ siteApps: [{ id: 1, url: '/site_app/1/tokentoken/hash/' }] })) - expect(() => { - given.subject() - }).toThrow( - // throwing only in tests, just an error in production - 'Unexpected console.error: [PostHog.js],PostHog site apps are disabled. Enable the "opt_in_site_apps" config to proceed.' - ) - expectScriptToNotExist('https://test.com/site_app/1/tokentoken/hash/') - }) - }) -}) diff --git a/src/__tests__/decide.ts b/src/__tests__/decide.ts new file mode 100644 index 000000000..37225a1a4 --- /dev/null +++ b/src/__tests__/decide.ts @@ -0,0 +1,279 @@ +import { Decide } from '../decide' +import { PostHogPersistence } from '../posthog-persistence' +import { RequestRouter } from '../utils/request-router' +import { expectScriptToExist, expectScriptToNotExist } from './helpers/script-utils' +import { PostHog } from '../posthog-core' +import { DecideResponse, PostHogConfig, Properties } from '../types' + +const expectDecodedSendRequest = ( + send_request: PostHog['_send_request'], + data: Record, + noCompression: boolean, + posthog: PostHog +) => { + const lastCall = jest.mocked(send_request).mock.calls[jest.mocked(send_request).mock.calls.length - 1] + + const decoded = lastCall[0].data + // Helper to give us more accurate error messages + expect(decoded).toEqual(data) + + expect(posthog._send_request).toHaveBeenCalledWith({ + url: 'https://test.com/decide/?v=3', + data, + method: 'POST', + callback: expect.any(Function), + compression: noCompression ? undefined : 'base64', + timeout: undefined, + }) +} + +describe('Decide', () => { + let posthog: PostHog + + const decide = () => new Decide(posthog) + + const defaultConfig: Partial = { + token: 'testtoken', + api_host: 'https://test.com', + persistence: 'memory', + } + + beforeEach(() => { + // clean the JSDOM to prevent interdependencies between tests + document.body.innerHTML = '' + document.head.innerHTML = '' + jest.spyOn(window.console, 'error').mockImplementation() + + posthog = { + config: defaultConfig, + persistence: new PostHogPersistence(defaultConfig as PostHogConfig), + register: (props: Properties) => posthog.persistence!.register(props), + unregister: (key: string) => posthog.persistence!.unregister(key), + get_property: (key: string) => posthog.persistence!.props[key], + capture: jest.fn(), + _addCaptureHook: jest.fn(), + _afterDecideResponse: jest.fn(), + get_distinct_id: jest.fn().mockImplementation(() => 'distinctid'), + _send_request: jest.fn().mockImplementation(({ callback }) => callback?.({ config: {} })), + featureFlags: { + receivedFeatureFlags: jest.fn(), + setReloadingPaused: jest.fn(), + _startReloadTimer: jest.fn(), + }, + requestRouter: new RequestRouter({ config: defaultConfig } as unknown as PostHog), + _hasBootstrappedFeatureFlags: jest.fn(), + getGroups: () => ({ organization: '5' }), + } as unknown as PostHog + }) + + describe('constructor', () => { + it('should call instance._send_request on constructor', () => { + decide().call() + + expectDecodedSendRequest( + posthog._send_request, + { + token: 'testtoken', + distinct_id: 'distinctid', + groups: { organization: '5' }, + }, + false, + posthog + ) + }) + + it('should send all stored properties with decide request', () => { + posthog.register({ + $stored_person_properties: { key: 'value' }, + $stored_group_properties: { organization: { orgName: 'orgValue' } }, + }) + + decide().call() + + expectDecodedSendRequest( + posthog._send_request, + { + token: 'testtoken', + distinct_id: 'distinctid', + groups: { organization: '5' }, + person_properties: { key: 'value' }, + group_properties: { organization: { orgName: 'orgValue' } }, + }, + false, + posthog + ) + }) + + it('should send disable flags with decide request when config is set', () => { + posthog.config = { + api_host: 'https://test.com', + token: 'testtoken', + persistence: 'memory', + advanced_disable_feature_flags: true, + } as PostHogConfig + + posthog.register({ + $stored_person_properties: { key: 'value' }, + $stored_group_properties: { organization: { orgName: 'orgValue' } }, + }) + decide().call() + + expectDecodedSendRequest( + posthog._send_request, + { + token: 'testtoken', + distinct_id: 'distinctid', + groups: { organization: '5' }, + person_properties: { key: 'value' }, + group_properties: { organization: { orgName: 'orgValue' } }, + disable_flags: true, + }, + false, + posthog + ) + }) + + it('should disable compression when config is set', () => { + posthog.config = { + api_host: 'https://test.com', + token: 'testtoken', + persistence: 'memory', + disable_compression: true, + } as PostHogConfig + + posthog.register({ + $stored_person_properties: {}, + $stored_group_properties: {}, + }) + decide().call() + + // noCompression is true + expectDecodedSendRequest( + posthog._send_request, + { + token: 'testtoken', + distinct_id: 'distinctid', + groups: { organization: '5' }, + person_properties: {}, + group_properties: {}, + }, + true, + posthog + ) + }) + + it('should send disable flags with decide request when config for advanced_disable_feature_flags_on_first_load is set', () => { + posthog.config = { + api_host: 'https://test.com', + token: 'testtoken', + persistence: 'memory', + advanced_disable_feature_flags_on_first_load: true, + } as PostHogConfig + + posthog.register({ + $stored_person_properties: { key: 'value' }, + $stored_group_properties: { organization: { orgName: 'orgValue' } }, + }) + + decide().call() + + expectDecodedSendRequest( + posthog._send_request, + { + token: 'testtoken', + distinct_id: 'distinctid', + groups: { organization: '5' }, + person_properties: { key: 'value' }, + group_properties: { organization: { orgName: 'orgValue' } }, + disable_flags: true, + }, + false, + posthog + ) + }) + }) + + describe('parseDecideResponse', () => { + const subject = (decideResponse: DecideResponse) => decide().parseDecideResponse(decideResponse) + + it('properly parses decide response', () => { + subject({} as DecideResponse) + + expect(posthog.featureFlags.receivedFeatureFlags).toHaveBeenCalledWith({}, false) + expect(posthog._afterDecideResponse).toHaveBeenCalledWith({}) + }) + + it('Make sure receivedFeatureFlags is called with errors if the decide response fails', () => { + ;(window as any).POSTHOG_DEBUG = true + + subject(undefined as unknown as DecideResponse) + + expect(posthog.featureFlags.receivedFeatureFlags).toHaveBeenCalledWith({}, true) + expect(console.error).toHaveBeenCalledWith('[PostHog.js]', 'Failed to fetch feature flags from PostHog.') + }) + + it('Make sure receivedFeatureFlags is not called if advanced_disable_feature_flags_on_first_load is set', () => { + posthog.config = { + api_host: 'https://test.com', + token: 'testtoken', + persistence: 'memory', + advanced_disable_feature_flags_on_first_load: true, + } as PostHogConfig + + const decideResponse = { + featureFlags: { 'test-flag': true }, + } as unknown as DecideResponse + subject(decideResponse) + + expect(posthog._afterDecideResponse).toHaveBeenCalledWith(decideResponse) + expect(posthog.featureFlags.receivedFeatureFlags).not.toHaveBeenCalled() + }) + + it('Make sure receivedFeatureFlags is not called if advanced_disable_feature_flags is set', () => { + posthog.config = { + api_host: 'https://test.com', + token: 'testtoken', + persistence: 'memory', + advanced_disable_feature_flags: true, + } as PostHogConfig + + const decideResponse = { + featureFlags: { 'test-flag': true }, + } as unknown as DecideResponse + subject(decideResponse) + + expect(posthog._afterDecideResponse).toHaveBeenCalledWith(decideResponse) + expect(posthog.featureFlags.receivedFeatureFlags).not.toHaveBeenCalled() + }) + + it('runs site apps if opted in', () => { + posthog.config = { + api_host: 'https://test.com', + opt_in_site_apps: true, + persistence: 'memory', + } as PostHogConfig + + subject({ siteApps: [{ id: 1, url: '/site_app/1/tokentoken/hash/' }] } as DecideResponse) + + expectScriptToExist('https://test.com/site_app/1/tokentoken/hash/') + }) + + it('does not run site apps code if not opted in', () => { + ;(window as any).POSTHOG_DEBUG = true + // don't technically need to run this but this test assumes opt_in_site_apps is false, let's make that explicit + posthog.config = { + api_host: 'https://test.com', + opt_in_site_apps: false, + persistence: 'memory', + } as unknown as PostHogConfig + + subject({ siteApps: [{ id: 1, url: '/site_app/1/tokentoken/hash/' }] } as DecideResponse) + + expect(console.error).toHaveBeenCalledWith( + '[PostHog.js]', + 'PostHog site apps are disabled. Enable the "opt_in_site_apps" config to proceed.' + ) + expectScriptToNotExist('https://test.com/site_app/1/tokentoken/hash/') + }) + }) +}) diff --git a/src/__tests__/featureflags.js b/src/__tests__/featureflags.ts similarity index 55% rename from src/__tests__/featureflags.js rename to src/__tests__/featureflags.ts index bba3575fa..1f5b66014 100644 --- a/src/__tests__/featureflags.js +++ b/src/__tests__/featureflags.ts @@ -1,6 +1,6 @@ /*eslint @typescript-eslint/no-empty-function: "off" */ -import { PostHogFeatureFlags, parseFeatureFlagDecideResponse, filterActiveFeatureFlags } from '../posthog-featureflags' +import { filterActiveFeatureFlags, parseFeatureFlagDecideResponse, PostHogFeatureFlags } from '../posthog-featureflags' import { PostHogPersistence } from '../posthog-persistence' import { RequestRouter } from '../utils/request-router' @@ -8,40 +8,42 @@ jest.useFakeTimers() jest.spyOn(global, 'setTimeout') describe('featureflags', () => { - given('decideEndpointWasHit', () => false) + let instance + let featureFlags const config = { token: 'random fake token', persistence: 'memory', api_host: 'https://app.posthog.com', } - given('instance', () => ({ - config, - get_distinct_id: () => 'blah id', - getGroups: () => {}, - persistence: new PostHogPersistence(config), - requestRouter: new RequestRouter({ config }), - register: (props) => given.instance.persistence.register(props), - unregister: (key) => given.instance.persistence.unregister(key), - get_property: (key) => given.instance.persistence.props[key], - capture: () => {}, - decideEndpointWasHit: given.decideEndpointWasHit, - _send_request: jest.fn().mockImplementation(({ callback }) => callback(given.decideResponsePayload)), - reloadFeatureFlags: () => given.featureFlags.reloadFeatureFlags(), - })) - - given('featureFlags', () => new PostHogFeatureFlags(given.instance)) - - given('decideResponsePayload', () => ({ - statusCode: 200, - json: given.decideResponse, - })) beforeEach(() => { - jest.spyOn(given.instance, 'capture').mockReturnValue() + instance = { + config, + get_distinct_id: () => 'blah id', + getGroups: () => {}, + persistence: new PostHogPersistence(config), + requestRouter: new RequestRouter({ config }), + register: (props) => instance.persistence.register(props), + unregister: (key) => instance.persistence.unregister(key), + get_property: (key) => instance.persistence.props[key], + capture: () => {}, + decideEndpointWasHit: false, + _send_request: jest.fn().mockImplementation(({ callback }) => + callback({ + statusCode: 200, + json: {}, + }) + ), + reloadFeatureFlags: () => featureFlags.reloadFeatureFlags(), + } + + featureFlags = new PostHogFeatureFlags(instance) + + jest.spyOn(instance, 'capture').mockReturnValue() jest.spyOn(window.console, 'warn').mockImplementation() - given.instance.persistence.register({ + instance.persistence.register({ $feature_flag_payloads: { 'beta-feature': { some: 'payload', @@ -58,29 +60,29 @@ describe('featureflags', () => { $override_feature_flags: false, }) - given.instance.persistence.unregister('$flag_call_reported') + instance.persistence.unregister('$flag_call_reported') }) it('should return flags from persistence even if decide endpoint was not hit', () => { - given.featureFlags.instance.decideEndpointWasHit = false + featureFlags.instance.decideEndpointWasHit = false - expect(given.featureFlags.getFlags()).toEqual([ + expect(featureFlags.getFlags()).toEqual([ 'beta-feature', 'alpha-feature-2', 'multivariate-flag', 'disabled-flag', ]) - expect(given.featureFlags.isFeatureEnabled('beta-feature')).toEqual(true) + expect(featureFlags.isFeatureEnabled('beta-feature')).toEqual(true) }) it('should warn if decide endpoint was not hit and no flags exist', () => { window.POSTHOG_DEBUG = true - given.featureFlags.instance.decideEndpointWasHit = false - given.instance.persistence.unregister('$enabled_feature_flags') - given.instance.persistence.unregister('$active_feature_flags') + featureFlags.instance.decideEndpointWasHit = false + instance.persistence.unregister('$enabled_feature_flags') + instance.persistence.unregister('$active_feature_flags') - expect(given.featureFlags.getFlags()).toEqual([]) - expect(given.featureFlags.isFeatureEnabled('beta-feature')).toEqual(undefined) + expect(featureFlags.getFlags()).toEqual([]) + expect(featureFlags.isFeatureEnabled('beta-feature')).toEqual(undefined) expect(window.console.warn).toHaveBeenCalledWith( '[PostHog.js]', 'isFeatureEnabled for key "beta-feature" failed. Feature flags didn\'t load in time.' @@ -88,7 +90,7 @@ describe('featureflags', () => { window.console.warn.mockClear() - expect(given.featureFlags.getFeatureFlag('beta-feature')).toEqual(undefined) + expect(featureFlags.getFeatureFlag('beta-feature')).toEqual(undefined) expect(window.console.warn).toHaveBeenCalledWith( '[PostHog.js]', 'getFeatureFlag for key "beta-feature" failed. Feature flags didn\'t load in time.' @@ -96,30 +98,30 @@ describe('featureflags', () => { }) it('should return the right feature flag and call capture', () => { - given.featureFlags.instance.decideEndpointWasHit = false + featureFlags.instance.decideEndpointWasHit = false - expect(given.featureFlags.getFlags()).toEqual([ + expect(featureFlags.getFlags()).toEqual([ 'beta-feature', 'alpha-feature-2', 'multivariate-flag', 'disabled-flag', ]) - expect(given.featureFlags.getFlagVariants()).toEqual({ + expect(featureFlags.getFlagVariants()).toEqual({ 'alpha-feature-2': true, 'beta-feature': true, 'multivariate-flag': 'variant-1', 'disabled-flag': false, }) - expect(given.featureFlags.isFeatureEnabled('beta-feature')).toEqual(true) - expect(given.featureFlags.isFeatureEnabled('random')).toEqual(false) - expect(given.featureFlags.isFeatureEnabled('multivariate-flag')).toEqual(true) + expect(featureFlags.isFeatureEnabled('beta-feature')).toEqual(true) + expect(featureFlags.isFeatureEnabled('random')).toEqual(false) + expect(featureFlags.isFeatureEnabled('multivariate-flag')).toEqual(true) - expect(given.instance.capture).toHaveBeenCalledTimes(3) + expect(instance.capture).toHaveBeenCalledTimes(3) // It should not call `capture` on subsequent calls - expect(given.featureFlags.isFeatureEnabled('beta-feature')).toEqual(true) - expect(given.instance.capture).toHaveBeenCalledTimes(3) - expect(given.instance.get_property('$flag_call_reported')).toEqual({ + expect(featureFlags.isFeatureEnabled('beta-feature')).toEqual(true) + expect(instance.capture).toHaveBeenCalledTimes(3) + expect(instance.get_property('$flag_call_reported')).toEqual({ 'beta-feature': ['true'], 'multivariate-flag': ['variant-1'], random: ['undefined'], @@ -127,76 +129,76 @@ describe('featureflags', () => { }) it('should call capture for every different flag response', () => { - given.featureFlags.instance.decideEndpointWasHit = true + featureFlags.instance.decideEndpointWasHit = true - given.instance.persistence.register({ + instance.persistence.register({ $enabled_feature_flags: { 'beta-feature': true, }, }) - expect(given.featureFlags.getFlags()).toEqual(['beta-feature']) - expect(given.featureFlags.getFlagVariants()).toEqual({ + expect(featureFlags.getFlags()).toEqual(['beta-feature']) + expect(featureFlags.getFlagVariants()).toEqual({ 'beta-feature': true, }) - expect(given.featureFlags.isFeatureEnabled('beta-feature')).toEqual(true) + expect(featureFlags.isFeatureEnabled('beta-feature')).toEqual(true) - expect(given.instance.get_property('$flag_call_reported')).toEqual({ 'beta-feature': ['true'] }) + expect(instance.get_property('$flag_call_reported')).toEqual({ 'beta-feature': ['true'] }) - expect(given.instance.capture).toHaveBeenCalledTimes(1) + expect(instance.capture).toHaveBeenCalledTimes(1) // It should not call `capture` on subsequent calls - expect(given.featureFlags.isFeatureEnabled('beta-feature')).toEqual(true) - expect(given.instance.capture).toHaveBeenCalledTimes(1) + expect(featureFlags.isFeatureEnabled('beta-feature')).toEqual(true) + expect(instance.capture).toHaveBeenCalledTimes(1) - given.instance.persistence.register({ + instance.persistence.register({ $enabled_feature_flags: {}, }) - given.featureFlags.instance.decideEndpointWasHit = false - expect(given.featureFlags.getFlagVariants()).toEqual({}) - expect(given.featureFlags.isFeatureEnabled('beta-feature')).toEqual(undefined) + featureFlags.instance.decideEndpointWasHit = false + expect(featureFlags.getFlagVariants()).toEqual({}) + expect(featureFlags.isFeatureEnabled('beta-feature')).toEqual(undefined) // no extra capture call because flags haven't loaded yet. - expect(given.instance.capture).toHaveBeenCalledTimes(1) + expect(instance.capture).toHaveBeenCalledTimes(1) - given.featureFlags.instance.decideEndpointWasHit = true - given.instance.persistence.register({ + featureFlags.instance.decideEndpointWasHit = true + instance.persistence.register({ $enabled_feature_flags: { x: 'y' }, }) - expect(given.featureFlags.getFlagVariants()).toEqual({ x: 'y' }) - expect(given.featureFlags.isFeatureEnabled('beta-feature')).toEqual(false) - expect(given.instance.capture).toHaveBeenCalledTimes(2) + expect(featureFlags.getFlagVariants()).toEqual({ x: 'y' }) + expect(featureFlags.isFeatureEnabled('beta-feature')).toEqual(false) + expect(instance.capture).toHaveBeenCalledTimes(2) - given.instance.persistence.register({ + instance.persistence.register({ $enabled_feature_flags: { 'beta-feature': 'variant-1', }, }) - expect(given.featureFlags.getFlagVariants()).toEqual({ 'beta-feature': 'variant-1' }) - expect(given.featureFlags.isFeatureEnabled('beta-feature')).toEqual(true) - expect(given.instance.capture).toHaveBeenCalledTimes(3) + expect(featureFlags.getFlagVariants()).toEqual({ 'beta-feature': 'variant-1' }) + expect(featureFlags.isFeatureEnabled('beta-feature')).toEqual(true) + expect(instance.capture).toHaveBeenCalledTimes(3) - expect(given.instance.get_property('$flag_call_reported')).toEqual({ + expect(instance.get_property('$flag_call_reported')).toEqual({ 'beta-feature': ['true', 'undefined', 'variant-1'], }) }) it('should return the right feature flag and not call capture', () => { - given.featureFlags.instance.decideEndpointWasHit = true + featureFlags.instance.decideEndpointWasHit = true - expect(given.featureFlags.isFeatureEnabled('beta-feature', { send_event: false })).toEqual(true) - expect(given.instance.capture).not.toHaveBeenCalled() + expect(featureFlags.isFeatureEnabled('beta-feature', { send_event: false })).toEqual(true) + expect(instance.capture).not.toHaveBeenCalled() }) it('should return the right payload', () => { - expect(given.featureFlags.getFeatureFlagPayload('beta-feature')).toEqual({ + expect(featureFlags.getFeatureFlagPayload('beta-feature')).toEqual({ some: 'payload', }) - expect(given.featureFlags.getFeatureFlagPayload('alpha-feature-2')).toEqual(200) - expect(given.featureFlags.getFeatureFlagPayload('multivariate-flag')).toEqual(undefined) - expect(given.instance.capture).not.toHaveBeenCalled() + expect(featureFlags.getFeatureFlagPayload('alpha-feature-2')).toEqual(200) + expect(featureFlags.getFeatureFlagPayload('multivariate-flag')).toEqual(undefined) + expect(instance.capture).not.toHaveBeenCalled() }) it('supports overrides', () => { - given.instance.persistence.props = { + instance.persistence.props = { $active_feature_flags: ['beta-feature', 'alpha-feature-2', 'multivariate-flag'], $enabled_feature_flags: { 'beta-feature': true, @@ -210,8 +212,8 @@ describe('featureflags', () => { } // should return both true and false flags - expect(given.featureFlags.getFlags()).toEqual(['beta-feature', 'alpha-feature-2', 'multivariate-flag']) - expect(given.featureFlags.getFlagVariants()).toEqual({ + expect(featureFlags.getFlags()).toEqual(['beta-feature', 'alpha-feature-2', 'multivariate-flag']) + expect(featureFlags.getFlagVariants()).toEqual({ 'alpha-feature-2': 'as-a-variant', 'multivariate-flag': 'variant-1', 'beta-feature': false, @@ -219,21 +221,28 @@ describe('featureflags', () => { }) describe('onFeatureFlags', () => { - given('decideResponse', () => ({ - featureFlags: { - first: 'variant-1', - second: true, - third: false, - }, - })) + beforeEach(() => { + instance._send_request = jest.fn().mockImplementation(({ callback }) => + callback({ + statusCode: 200, + json: { + featureFlags: { + first: 'variant-1', + second: true, + third: false, + }, + }, + }) + ) + }) it('onFeatureFlags should not be called immediately if feature flags not loaded', () => { - var called = false + let called = false let _flags = [] let _variants = {} let _error = undefined - given.featureFlags.onFeatureFlags((flags, variants, errors) => { + featureFlags.onFeatureFlags((flags, variants, errors) => { called = true _flags = flags _variants = variants @@ -241,8 +250,8 @@ describe('featureflags', () => { }) expect(called).toEqual(false) - given.featureFlags.setAnonymousDistinctId('rando_id') - given.featureFlags.reloadFeatureFlags() + featureFlags.setAnonymousDistinctId('rando_id') + featureFlags.reloadFeatureFlags() jest.runAllTimers() expect(called).toEqual(true) @@ -255,19 +264,19 @@ describe('featureflags', () => { }) it('onFeatureFlags callback should be called immediately if feature flags were loaded', () => { - given.featureFlags.instance.decideEndpointWasHit = true - var called = false - given.featureFlags.onFeatureFlags(() => (called = true)) + featureFlags.instance.decideEndpointWasHit = true + let called = false + featureFlags.onFeatureFlags(() => (called = true)) expect(called).toEqual(true) called = false }) it('onFeatureFlags should not return flags that are off', () => { - given.featureFlags.instance.decideEndpointWasHit = true + featureFlags.instance.decideEndpointWasHit = true let _flags = [] let _variants = {} - given.featureFlags.onFeatureFlags((flags, variants) => { + featureFlags.onFeatureFlags((flags, variants) => { _flags = flags _variants = variants }) @@ -283,12 +292,12 @@ describe('featureflags', () => { it('onFeatureFlags should return function to unsubscribe the function from onFeatureFlags', () => { let called = false - const unsubscribe = given.featureFlags.onFeatureFlags(() => { + const unsubscribe = featureFlags.onFeatureFlags(() => { called = true }) - given.featureFlags.setAnonymousDistinctId('rando_id') - given.featureFlags.reloadFeatureFlags() + featureFlags.setAnonymousDistinctId('rando_id') + featureFlags.reloadFeatureFlags() jest.runAllTimers() expect(called).toEqual(true) @@ -297,8 +306,8 @@ describe('featureflags', () => { unsubscribe() - given.featureFlags.setAnonymousDistinctId('rando_id') - given.featureFlags.reloadFeatureFlags() + featureFlags.setAnonymousDistinctId('rando_id') + featureFlags.reloadFeatureFlags() jest.runAllTimers() expect(called).toEqual(false) @@ -307,7 +316,7 @@ describe('featureflags', () => { describe('earlyAccessFeatures', () => { afterEach(() => { - given.instance.persistence.clear() + instance.persistence.clear() }) // actually early access feature response const EARLY_ACCESS_FEATURE_FIRST = { @@ -328,70 +337,84 @@ describe('featureflags', () => { flagKey: 'second-flag', } - given('decideResponse', () => ({ - earlyAccessFeatures: [EARLY_ACCESS_FEATURE_FIRST], - })) + beforeEach(() => { + instance._send_request = jest.fn().mockImplementation(({ callback }) => + callback({ + statusCode: 200, + json: { + earlyAccessFeatures: [EARLY_ACCESS_FEATURE_FIRST], + }, + }) + ) + }) it('getEarlyAccessFeatures requests early access features if not present', () => { - given.featureFlags.getEarlyAccessFeatures((data) => { + featureFlags.getEarlyAccessFeatures((data) => { expect(data).toEqual([EARLY_ACCESS_FEATURE_FIRST]) }) - expect(given.instance._send_request).toHaveBeenCalledWith({ + expect(instance._send_request).toHaveBeenCalledWith({ url: 'https://us.i.posthog.com/api/early_access_features/?token=random fake token', method: 'GET', transport: 'XHR', callback: expect.any(Function), }) - expect(given.instance._send_request).toHaveBeenCalledTimes(1) + expect(instance._send_request).toHaveBeenCalledTimes(1) - expect(given.instance.persistence.props.$early_access_features).toEqual([EARLY_ACCESS_FEATURE_FIRST]) + expect(instance.persistence.props.$early_access_features).toEqual([EARLY_ACCESS_FEATURE_FIRST]) - given('decideResponse', () => ({ - earlyAccessFeatures: [EARLY_ACCESS_FEATURE_SECOND], - })) + instance._send_request = jest.fn().mockImplementation(({ callback }) => + callback({ + statusCode: 200, + json: { + earlyAccessFeatures: [EARLY_ACCESS_FEATURE_SECOND], + }, + }) + ) // request again, shouldn't call _send_request again - given.featureFlags.getEarlyAccessFeatures((data) => { + featureFlags.getEarlyAccessFeatures((data) => { expect(data).toEqual([EARLY_ACCESS_FEATURE_FIRST]) }) - expect(given.instance._send_request).toHaveBeenCalledTimes(1) + expect(instance._send_request).toHaveBeenCalledTimes(0) }) it('getEarlyAccessFeatures force reloads early access features when asked to', () => { - given.featureFlags.getEarlyAccessFeatures((data) => { + featureFlags.getEarlyAccessFeatures((data) => { expect(data).toEqual([EARLY_ACCESS_FEATURE_FIRST]) }) - expect(given.instance._send_request).toHaveBeenCalledWith({ + expect(instance._send_request).toHaveBeenCalledWith({ url: 'https://us.i.posthog.com/api/early_access_features/?token=random fake token', method: 'GET', callback: expect.any(Function), transport: 'XHR', }) - expect(given.instance._send_request).toHaveBeenCalledTimes(1) + expect(instance._send_request).toHaveBeenCalledTimes(1) - expect(given.instance.persistence.props.$early_access_features).toEqual([EARLY_ACCESS_FEATURE_FIRST]) + expect(instance.persistence.props.$early_access_features).toEqual([EARLY_ACCESS_FEATURE_FIRST]) - given('decideResponsePayload', () => ({ - statusCode: 200, - json: { - earlyAccessFeatures: [EARLY_ACCESS_FEATURE_SECOND], - }, - })) + instance._send_request = jest.fn().mockImplementation(({ callback }) => + callback({ + statusCode: 200, + json: { + earlyAccessFeatures: [EARLY_ACCESS_FEATURE_SECOND], + }, + }) + ) // request again, should call _send_request because we're forcing a reload - given.featureFlags.getEarlyAccessFeatures((data) => { + featureFlags.getEarlyAccessFeatures((data) => { expect(data).toEqual([EARLY_ACCESS_FEATURE_SECOND]) }, true) - expect(given.instance._send_request).toHaveBeenCalledTimes(2) + expect(instance._send_request).toHaveBeenCalledTimes(1) }) it('update enrollment should update the early access feature enrollment', () => { - given.featureFlags.updateEarlyAccessFeatureEnrollment('first-flag', true) + featureFlags.updateEarlyAccessFeatureEnrollment('first-flag', true) - expect(given.instance.capture).toHaveBeenCalledTimes(1) - expect(given.instance.capture).toHaveBeenCalledWith('$feature_enrollment_update', { + expect(instance.capture).toHaveBeenCalledTimes(1) + expect(instance.capture).toHaveBeenCalledWith('$feature_enrollment_update', { $feature_enrollment: true, $feature_flag: 'first-flag', $set: { @@ -399,7 +422,7 @@ describe('featureflags', () => { }, }) - expect(given.featureFlags.getFlagVariants()).toEqual({ + expect(featureFlags.getFlagVariants()).toEqual({ 'alpha-feature-2': true, 'beta-feature': true, 'disabled-flag': false, @@ -409,10 +432,10 @@ describe('featureflags', () => { }) // now enrollment is turned off - given.featureFlags.updateEarlyAccessFeatureEnrollment('first-flag', false) + featureFlags.updateEarlyAccessFeatureEnrollment('first-flag', false) - expect(given.instance.capture).toHaveBeenCalledTimes(2) - expect(given.instance.capture).toHaveBeenCalledWith('$feature_enrollment_update', { + expect(instance.capture).toHaveBeenCalledTimes(2) + expect(instance.capture).toHaveBeenCalledWith('$feature_enrollment_update', { $feature_enrollment: false, $feature_flag: 'first-flag', $set: { @@ -420,7 +443,7 @@ describe('featureflags', () => { }, }) - expect(given.featureFlags.getFlagVariants()).toEqual({ + expect(featureFlags.getFlagVariants()).toEqual({ 'alpha-feature-2': true, 'beta-feature': true, 'disabled-flag': false, @@ -431,10 +454,10 @@ describe('featureflags', () => { }) it('reloading flags after update enrollment should send properties', () => { - given.featureFlags.updateEarlyAccessFeatureEnrollment('x-flag', true) + featureFlags.updateEarlyAccessFeatureEnrollment('x-flag', true) - expect(given.instance.capture).toHaveBeenCalledTimes(1) - expect(given.instance.capture).toHaveBeenCalledWith('$feature_enrollment_update', { + expect(instance.capture).toHaveBeenCalledTimes(1) + expect(instance.capture).toHaveBeenCalledWith('$feature_enrollment_update', { $feature_enrollment: true, $feature_flag: 'x-flag', $set: { @@ -442,7 +465,7 @@ describe('featureflags', () => { }, }) - expect(given.featureFlags.getFlagVariants()).toEqual({ + expect(featureFlags.getFlagVariants()).toEqual({ 'alpha-feature-2': true, 'beta-feature': true, 'disabled-flag': false, @@ -451,10 +474,10 @@ describe('featureflags', () => { 'x-flag': true, }) - given.featureFlags.reloadFeatureFlags() + featureFlags.reloadFeatureFlags() jest.runAllTimers() // check the request sent person properties - expect(given.instance._send_request.mock.calls[0][0].data).toEqual({ + expect(instance._send_request.mock.calls[0][0].data).toEqual({ token: 'random fake token', distinct_id: 'blah id', person_properties: { @@ -465,26 +488,33 @@ describe('featureflags', () => { }) describe('reloadFeatureFlags', () => { - given('decideResponse', () => ({ - featureFlags: { - first: 'variant-1', - second: true, - }, - })) + beforeEach(() => { + instance._send_request = jest.fn().mockImplementation(({ callback }) => + callback({ + statusCode: 200, + json: { + featureFlags: { + first: 'variant-1', + second: true, + }, + }, + }) + ) + }) it('on providing anonDistinctId', () => { - given.featureFlags.setAnonymousDistinctId('rando_id') - given.featureFlags.reloadFeatureFlags() + featureFlags.setAnonymousDistinctId('rando_id') + featureFlags.reloadFeatureFlags() jest.runAllTimers() - expect(given.featureFlags.getFlagVariants()).toEqual({ + expect(featureFlags.getFlagVariants()).toEqual({ first: 'variant-1', second: true, }) // check the request sent $anon_distinct_id - expect(given.instance._send_request.mock.calls[0][0].data).toEqual({ + expect(instance._send_request.mock.calls[0][0].data).toEqual({ token: 'random fake token', distinct_id: 'blah id', $anon_distinct_id: 'rando_id', @@ -492,40 +522,40 @@ describe('featureflags', () => { }) it('on providing anonDistinctId and calling reload multiple times', () => { - given.featureFlags.setAnonymousDistinctId('rando_id') - given.featureFlags.reloadFeatureFlags() - given.featureFlags.reloadFeatureFlags() + featureFlags.setAnonymousDistinctId('rando_id') + featureFlags.reloadFeatureFlags() + featureFlags.reloadFeatureFlags() jest.runAllTimers() - expect(given.featureFlags.getFlagVariants()).toEqual({ + expect(featureFlags.getFlagVariants()).toEqual({ first: 'variant-1', second: true, }) // check the request sent $anon_distinct_id - expect(given.instance._send_request.mock.calls[0][0].data).toEqual({ + expect(instance._send_request.mock.calls[0][0].data).toEqual({ token: 'random fake token', distinct_id: 'blah id', $anon_distinct_id: 'rando_id', }) - given.featureFlags.reloadFeatureFlags() - given.featureFlags.reloadFeatureFlags() + featureFlags.reloadFeatureFlags() + featureFlags.reloadFeatureFlags() jest.runAllTimers() // check the request didn't send $anon_distinct_id the second time around - expect(given.instance._send_request.mock.calls[1][0].data).toEqual({ + expect(instance._send_request.mock.calls[1][0].data).toEqual({ token: 'random fake token', distinct_id: 'blah id', // $anon_distinct_id: "rando_id" }) - given.featureFlags.reloadFeatureFlags() + featureFlags.reloadFeatureFlags() jest.runAllTimers() // check the request didn't send $anon_distinct_id the second time around - expect(given.instance._send_request.mock.calls[2][0].data).toEqual({ + expect(instance._send_request.mock.calls[2][0].data).toEqual({ token: 'random fake token', distinct_id: 'blah id', // $anon_distinct_id: "rando_id" @@ -533,20 +563,20 @@ describe('featureflags', () => { }) it('on providing personProperties runs reload automatically', () => { - given.featureFlags.setPersonPropertiesForFlags({ a: 'b', c: 'd' }) + featureFlags.setPersonPropertiesForFlags({ a: 'b', c: 'd' }) jest.runAllTimers() - expect(given.featureFlags.getFlagVariants()).toEqual({ + expect(featureFlags.getFlagVariants()).toEqual({ first: 'variant-1', second: true, }) // check right compression is sent - expect(given.instance._send_request.mock.calls[0][0].compression).toEqual('base64') + expect(instance._send_request.mock.calls[0][0].compression).toEqual('base64') // check the request sent person properties - expect(given.instance._send_request.mock.calls[0][0].data).toEqual({ + expect(instance._send_request.mock.calls[0][0].data).toEqual({ token: 'random fake token', distinct_id: 'blah id', person_properties: { a: 'b', c: 'd' }, @@ -554,77 +584,84 @@ describe('featureflags', () => { }) it('on providing config advanced_disable_feature_flags', () => { - given.instance.config = { - ...given.instance.config, + instance.config = { + ...instance.config, advanced_disable_feature_flags: true, } - given.instance.persistence.register({ + instance.persistence.register({ $enabled_feature_flags: { 'beta-feature': true, 'random-feature': 'xatu', }, }) - given.featureFlags.reloadFeatureFlags() + featureFlags.reloadFeatureFlags() jest.runAllTimers() - expect(given.featureFlags.getFlagVariants()).toEqual({ + expect(featureFlags.getFlagVariants()).toEqual({ 'beta-feature': true, 'random-feature': 'xatu', }) // check reload request was not sent - expect(given.instance._send_request).not.toHaveBeenCalled() + expect(instance._send_request).not.toHaveBeenCalled() // check the same for other ways to call reload flags - given.featureFlags.setPersonPropertiesForFlags({ a: 'b', c: 'd' }) + featureFlags.setPersonPropertiesForFlags({ a: 'b', c: 'd' }) jest.runAllTimers() - expect(given.featureFlags.getFlagVariants()).toEqual({ + expect(featureFlags.getFlagVariants()).toEqual({ 'beta-feature': true, 'random-feature': 'xatu', }) // check reload request was not sent - expect(given.instance._send_request).not.toHaveBeenCalled() + expect(instance._send_request).not.toHaveBeenCalled() }) it('on providing config disable_compression', () => { - given.instance.config = { - ...given.instance.config, + instance.config = { + ...instance.config, disable_compression: true, } - given.featureFlags.reloadFeatureFlags() + featureFlags.reloadFeatureFlags() jest.runAllTimers() - expect(given.instance._send_request.mock.calls[0][0].compression).toEqual(undefined) + expect(instance._send_request.mock.calls[0][0].compression).toEqual(undefined) }) }) describe('override person and group properties', () => { - given('decideResponse', () => ({ - featureFlags: { - first: 'variant-1', - second: true, - }, - })) + beforeEach(() => { + instance._send_request = jest.fn().mockImplementation(({ callback }) => + callback({ + statusCode: 200, + json: { + featureFlags: { + first: 'variant-1', + second: true, + }, + }, + }) + ) + }) it('on providing personProperties updates properties successively', () => { - given.featureFlags.setPersonPropertiesForFlags({ a: 'b', c: 'd' }) - given.featureFlags.setPersonPropertiesForFlags({ x: 'y', c: 'e' }) + featureFlags.setPersonPropertiesForFlags({ a: 'b', c: 'd' }) + featureFlags.setPersonPropertiesForFlags({ x: 'y', c: 'e' }) jest.runAllTimers() - expect(given.featureFlags.getFlagVariants()).toEqual({ + expect(featureFlags.getFlagVariants()).toEqual({ first: 'variant-1', second: true, }) // check the request sent person properties - expect(given.instance._send_request.mock.calls[0][0].data).toEqual({ + expect(instance._send_request.mock.calls[0][0].data).toEqual({ token: 'random fake token', distinct_id: 'blah id', person_properties: { a: 'b', c: 'e', x: 'y' }, @@ -632,56 +669,56 @@ describe('featureflags', () => { }) it('doesnt reload flags if explicitly asked not to', () => { - given.featureFlags.setPersonPropertiesForFlags({ a: 'b', c: 'd' }, false) + featureFlags.setPersonPropertiesForFlags({ a: 'b', c: 'd' }, false) jest.runAllTimers() // still old flags - expect(given.featureFlags.getFlagVariants()).toEqual({ + expect(featureFlags.getFlagVariants()).toEqual({ 'alpha-feature-2': true, 'beta-feature': true, 'disabled-flag': false, 'multivariate-flag': 'variant-1', }) - expect(given.instance._send_request).not.toHaveBeenCalled() + expect(instance._send_request).not.toHaveBeenCalled() }) it('resetPersonProperties resets all properties', () => { - given.featureFlags.setPersonPropertiesForFlags({ a: 'b', c: 'd' }, false) - given.featureFlags.setPersonPropertiesForFlags({ x: 'y', c: 'e' }, false) + featureFlags.setPersonPropertiesForFlags({ a: 'b', c: 'd' }, false) + featureFlags.setPersonPropertiesForFlags({ x: 'y', c: 'e' }, false) jest.runAllTimers() - expect(given.instance.persistence.props.$stored_person_properties).toEqual({ a: 'b', c: 'e', x: 'y' }) + expect(instance.persistence.props.$stored_person_properties).toEqual({ a: 'b', c: 'e', x: 'y' }) - given.featureFlags.resetPersonPropertiesForFlags() - given.featureFlags.reloadFeatureFlags() + featureFlags.resetPersonPropertiesForFlags() + featureFlags.reloadFeatureFlags() jest.runAllTimers() // check the request did not send person properties - expect(given.instance._send_request.mock.calls[0][0].data).toEqual({ + expect(instance._send_request.mock.calls[0][0].data).toEqual({ token: 'random fake token', distinct_id: 'blah id', }) }) it('on providing groupProperties updates properties successively', () => { - given.featureFlags.setGroupPropertiesForFlags({ orgs: { a: 'b', c: 'd' }, projects: { x: 'y', c: 'e' } }) + featureFlags.setGroupPropertiesForFlags({ orgs: { a: 'b', c: 'd' }, projects: { x: 'y', c: 'e' } }) - expect(given.instance.persistence.props.$stored_group_properties).toEqual({ + expect(instance.persistence.props.$stored_group_properties).toEqual({ orgs: { a: 'b', c: 'd' }, projects: { x: 'y', c: 'e' }, }) jest.runAllTimers() - expect(given.featureFlags.getFlagVariants()).toEqual({ + expect(featureFlags.getFlagVariants()).toEqual({ first: 'variant-1', second: true, }) // check the request sent person properties - expect(given.instance._send_request.mock.calls[0][0].data).toEqual({ + expect(instance._send_request.mock.calls[0][0].data).toEqual({ token: 'random fake token', distinct_id: 'blah id', group_properties: { orgs: { a: 'b', c: 'd' }, projects: { x: 'y', c: 'e' } }, @@ -689,65 +726,72 @@ describe('featureflags', () => { }) it('handles groupProperties updates', () => { - given.featureFlags.setGroupPropertiesForFlags({ orgs: { a: 'b', c: 'd' }, projects: { x: 'y', c: 'e' } }) + featureFlags.setGroupPropertiesForFlags({ orgs: { a: 'b', c: 'd' }, projects: { x: 'y', c: 'e' } }) - expect(given.instance.persistence.props.$stored_group_properties).toEqual({ + expect(instance.persistence.props.$stored_group_properties).toEqual({ orgs: { a: 'b', c: 'd' }, projects: { x: 'y', c: 'e' }, }) - given.featureFlags.setGroupPropertiesForFlags({ orgs: { w: '1' }, other: { z: '2' } }) + featureFlags.setGroupPropertiesForFlags({ orgs: { w: '1' }, other: { z: '2' } }) - expect(given.instance.persistence.props.$stored_group_properties).toEqual({ + expect(instance.persistence.props.$stored_group_properties).toEqual({ orgs: { a: 'b', c: 'd', w: '1' }, projects: { x: 'y', c: 'e' }, other: { z: '2' }, }) - given.featureFlags.resetGroupPropertiesForFlags('orgs') + featureFlags.resetGroupPropertiesForFlags('orgs') - expect(given.instance.persistence.props.$stored_group_properties).toEqual({ + expect(instance.persistence.props.$stored_group_properties).toEqual({ orgs: {}, projects: { x: 'y', c: 'e' }, other: { z: '2' }, }) - given.featureFlags.resetGroupPropertiesForFlags() + featureFlags.resetGroupPropertiesForFlags() - expect(given.instance.persistence.props.$stored_group_properties).toEqual(undefined) + expect(instance.persistence.props.$stored_group_properties).toEqual(undefined) jest.runAllTimers() }) it('doesnt reload group flags if explicitly asked not to', () => { - given.featureFlags.setGroupPropertiesForFlags({ orgs: { a: 'b', c: 'd' } }, false) + featureFlags.setGroupPropertiesForFlags({ orgs: { a: 'b', c: 'd' } }, false) jest.runAllTimers() // still old flags - expect(given.featureFlags.getFlagVariants()).toEqual({ + expect(featureFlags.getFlagVariants()).toEqual({ 'alpha-feature-2': true, 'beta-feature': true, 'disabled-flag': false, 'multivariate-flag': 'variant-1', }) - expect(given.instance._send_request).not.toHaveBeenCalled() + expect(instance._send_request).not.toHaveBeenCalled() }) }) describe('when subsequent decide calls return partial results', () => { - given('decideResponse', () => ({ - featureFlags: { 'x-flag': 'x-value', 'feature-1': false }, - errorsWhileComputingFlags: true, - })) + beforeEach(() => { + instance._send_request = jest.fn().mockImplementation(({ callback }) => + callback({ + statusCode: 200, + json: { + featureFlags: { 'x-flag': 'x-value', 'feature-1': false }, + errorsWhileComputingFlags: true, + }, + }) + ) + }) it('should return combined results', () => { - given.featureFlags.reloadFeatureFlags() + featureFlags.reloadFeatureFlags() jest.runAllTimers() - expect(given.featureFlags.getFlagVariants()).toEqual({ + expect(featureFlags.getFlagVariants()).toEqual({ 'alpha-feature-2': true, 'beta-feature': true, 'multivariate-flag': 'variant-1', @@ -759,17 +803,24 @@ describe('featureflags', () => { }) describe('when subsequent decide calls return results without errors', () => { - given('decideResponse', () => ({ - featureFlags: { 'x-flag': 'x-value', 'feature-1': false }, - errorsWhileComputingFlags: false, - })) + beforeEach(() => { + instance._send_request = jest.fn().mockImplementation(({ callback }) => + callback({ + statusCode: 200, + json: { + featureFlags: { 'x-flag': 'x-value', 'feature-1': false }, + errorsWhileComputingFlags: false, + }, + }) + ) + }) it('should return combined results', () => { - given.featureFlags.reloadFeatureFlags() + featureFlags.reloadFeatureFlags() jest.runAllTimers() - expect(given.featureFlags.getFlagVariants()).toEqual({ + expect(featureFlags.getFlagVariants()).toEqual({ 'x-flag': 'x-value', 'feature-1': false, }) @@ -777,39 +828,43 @@ describe('featureflags', () => { }) describe('when decide times out or errors out', () => { - given('decideResponsePayload', () => ({ - statusCode: 500, - text: 'Internal Server Error', - })) + beforeEach(() => { + instance._send_request = jest.fn().mockImplementation(({ callback }) => + callback({ + statusCode: 500, + text: 'Internal Server Error', + }) + ) + }) it('should not change the existing flags', () => { - given.instance.persistence.register({ + instance.persistence.register({ $enabled_feature_flags: { 'beta-feature': true, 'random-feature': 'xatu', }, }) - given.featureFlags.reloadFeatureFlags() + featureFlags.reloadFeatureFlags() jest.runAllTimers() - expect(given.featureFlags.getFlagVariants()).toEqual({ + expect(featureFlags.getFlagVariants()).toEqual({ 'beta-feature': true, 'random-feature': 'xatu', }) }) it('should call onFeatureFlags even when decide errors out', () => { - var called = false + let called = false let _flags = [] let _variants = {} let _errors = undefined - given.instance.persistence.register({ + instance.persistence.register({ $enabled_feature_flags: {}, }) - given.featureFlags.onFeatureFlags((flags, variants, errors) => { + featureFlags.onFeatureFlags((flags, variants, errors) => { called = true _flags = flags _variants = variants @@ -817,7 +872,7 @@ describe('featureflags', () => { }) expect(called).toEqual(false) - given.featureFlags.reloadFeatureFlags() + featureFlags.reloadFeatureFlags() jest.runAllTimers() expect(called).toEqual(true) @@ -827,12 +882,12 @@ describe('featureflags', () => { }) it('should call onFeatureFlags with existing flags', () => { - var called = false + let called = false let _flags = [] let _variants = {} let _errors = undefined - given.featureFlags.onFeatureFlags((flags, variants, errors) => { + featureFlags.onFeatureFlags((flags, variants, errors) => { called = true _flags = flags _variants = variants @@ -840,7 +895,7 @@ describe('featureflags', () => { }) expect(called).toEqual(false) - given.featureFlags.reloadFeatureFlags() + featureFlags.reloadFeatureFlags() jest.runAllTimers() expect(called).toEqual(true) @@ -854,17 +909,19 @@ describe('featureflags', () => { }) it('should call onFeatureFlags with existing flags on timeouts', () => { - given('decideResponsePayload', () => ({ - statusCode: 0, - text: '', - })) + instance._send_request = jest.fn().mockImplementation(({ callback }) => + callback({ + statusCode: 0, + text: '', + }) + ) - var called = false + let called = false let _flags = [] let _variants = {} let _errors = undefined - given.featureFlags.onFeatureFlags((flags, variants, errors) => { + featureFlags.onFeatureFlags((flags, variants, errors) => { called = true _flags = flags _variants = variants @@ -872,7 +929,7 @@ describe('featureflags', () => { }) expect(called).toEqual(false) - given.featureFlags.reloadFeatureFlags() + featureFlags.reloadFeatureFlags() jest.runAllTimers() expect(called).toEqual(true) @@ -888,12 +945,14 @@ describe('featureflags', () => { }) describe('parseFeatureFlagDecideResponse', () => { - given('decideResponse', () => {}) - given('persistence', () => ({ register: jest.fn(), unregister: jest.fn() })) - given('subject', () => () => parseFeatureFlagDecideResponse(given.decideResponse, given.persistence)) + let persistence + + beforeEach(() => { + persistence = { register: jest.fn(), unregister: jest.fn() } + }) it('enables multivariate feature flags from decide v2^ response', () => { - given('decideResponse', () => ({ + const decideResponse = { featureFlags: { 'beta-feature': true, 'alpha-feature-2': true, @@ -903,10 +962,10 @@ describe('parseFeatureFlagDecideResponse', () => { 'beta-feature': 300, 'alpha-feature-2': 'fake-payload', }, - })) - given.subject() + } + parseFeatureFlagDecideResponse(decideResponse, persistence) - expect(given.persistence.register).toHaveBeenCalledWith({ + expect(persistence.register).toHaveBeenCalledWith({ $active_feature_flags: ['beta-feature', 'alpha-feature-2', 'multivariate-flag'], $enabled_feature_flags: { 'beta-feature': true, @@ -922,21 +981,21 @@ describe('parseFeatureFlagDecideResponse', () => { it('enables feature flags from decide response (v1 backwards compatibility)', () => { // checks that nothing fails when asking for ?v=2 and getting a ?v=1 response - given('decideResponse', () => ({ featureFlags: ['beta-feature', 'alpha-feature-2'] })) - given.subject() + const decideResponse = { featureFlags: ['beta-feature', 'alpha-feature-2'] } + + parseFeatureFlagDecideResponse(decideResponse, persistence) - expect(given.persistence.register).toHaveBeenLastCalledWith({ + expect(persistence.register).toHaveBeenLastCalledWith({ $active_feature_flags: ['beta-feature', 'alpha-feature-2'], $enabled_feature_flags: { 'beta-feature': true, 'alpha-feature-2': true }, }) }) it('doesnt remove existing feature flags when no flags are returned', () => { - given('decideResponse', () => ({})) - given.subject() + parseFeatureFlagDecideResponse({}, persistence) - expect(given.persistence.register).not.toHaveBeenCalled() - expect(given.persistence.unregister).not.toHaveBeenCalled() + expect(persistence.register).not.toHaveBeenCalled() + expect(persistence.unregister).not.toHaveBeenCalled() }) }) diff --git a/src/__tests__/posthog-core.js b/src/__tests__/posthog-core.js deleted file mode 100644 index 97b3802eb..000000000 --- a/src/__tests__/posthog-core.js +++ /dev/null @@ -1,1152 +0,0 @@ -import { PostHogPersistence } from '../posthog-persistence' -import { Decide } from '../decide' - -import { Info } from '../utils/event-utils' -import { document, window } from '../utils/globals' -import { uuidv7 } from '../uuidv7' -import * as globals from '../utils/globals' -import { USER_STATE } from '../constants' -import { defaultPostHog } from './helpers/posthog-instance' - -jest.mock('../decide') - -describe('posthog core', () => { - const baseUTCDateTime = new Date(Date.UTC(2020, 0, 1, 0, 0, 0)) - - given('lib', () => { - const posthog = defaultPostHog().init('testtoken', given.config, uuidv7()) - posthog.debug() - return Object.assign(posthog, given.overrides) - }) - - beforeEach(() => { - jest.useFakeTimers().setSystemTime(baseUTCDateTime) - }) - - afterEach(() => { - jest.useRealTimers() - // Make sure there's no cached persistence - given.lib.persistence?.clear?.() - }) - describe('capture()', () => { - given('eventName', () => '$event') - - given('subject', () => () => given.lib.capture(given.eventName, given.eventProperties, given.options)) - - given('config', () => ({ - api_host: 'https://app.posthog.com', - property_denylist: [], - property_blacklist: [], - _onCapture: jest.fn(), - get_device_id: jest.fn().mockReturnValue('device-id'), - })) - - given('overrides', () => ({ - __loaded: true, - config: { - api_host: 'https://app.posthog.com', - ...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: {}, - get_property: () => 'anonymous', - set_initial_person_info: jest.fn(), - get_initial_props: () => ({}), - }, - sessionPersistence: { - update_search_keyword: jest.fn(), - update_campaign_params: jest.fn(), - update_referrer_info: jest.fn(), - update_config: jest.fn(), - properties: jest.fn(), - get_property: () => 'anonymous', - }, - _send_request: jest.fn(), - compression: {}, - __captureHooks: [], - rateLimiter: { - isServerRateLimited: () => false, - clientRateLimitContext: () => false, - }, - })) - - it('adds a UUID to each message', () => { - const captureData = given.subject() - expect(captureData).toHaveProperty('uuid') - }) - - it('adds system time to events', () => { - const captureData = given.subject() - console.log(captureData) - expect(captureData).toHaveProperty('timestamp') - // timer is fixed at 2020-01-01 - expect(captureData.timestamp).toEqual(baseUTCDateTime) - }) - - it('captures when time is overriden by caller', () => { - given.options = { timestamp: new Date(2020, 0, 2, 12, 34) } - const captureData = given.subject() - expect(captureData).toHaveProperty('timestamp') - expect(captureData.timestamp).toEqual(new Date(2020, 0, 2, 12, 34)) - expect(captureData.properties['$event_time_override_provided']).toEqual(true) - expect(captureData.properties['$event_time_override_system_time']).toEqual(baseUTCDateTime) - }) - - it('handles recursive objects', () => { - given('eventProperties', () => { - const props = {} - props.recurse = props - return props - }) - - expect(() => given.subject()).not.toThrow() - }) - - it('calls callbacks added via _addCaptureHook', () => { - const hook = jest.fn() - - given.lib._addCaptureHook(hook) - - given.subject() - expect(hook).toHaveBeenCalledWith( - '$event', - expect.objectContaining({ - event: '$event', - }) - ) - }) - - it('calls update_campaign_params and update_referrer_info on sessionPersistence', () => { - given('config', () => ({ - property_denylist: [], - property_blacklist: [], - _onCapture: jest.fn(), - store_google: true, - save_referrer: true, - })) - - given.subject() - - expect(given.lib.sessionPersistence.update_campaign_params).toHaveBeenCalled() - expect(given.lib.sessionPersistence.update_referrer_info).toHaveBeenCalled() - }) - - it('errors with undefined event name', () => { - given('eventName', () => undefined) - console.error = jest.fn() - - const hook = jest.fn() - given.lib._addCaptureHook(hook) - - expect(() => given.subject()).not.toThrow() - expect(hook).not.toHaveBeenCalled() - expect(console.error).toHaveBeenCalledWith('[PostHog.js]', 'No event name provided to posthog.capture') - }) - - it('errors with object event name', () => { - given('eventName', () => ({ event: 'object as name' })) - console.error = jest.fn() - - const hook = jest.fn() - given.lib._addCaptureHook(hook) - - expect(() => given.subject()).not.toThrow() - expect(hook).not.toHaveBeenCalled() - expect(console.error).toHaveBeenCalledWith('[PostHog.js]', 'No event name provided to posthog.capture') - }) - - it('respects opt_out_useragent_filter (default: false)', () => { - const originalUseragent = globals.userAgent - // eslint-disable-next-line no-import-assign - globals['userAgent'] = - 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Googlebot/2.1; +http://www.google.com/bot.html) Chrome/W.X.Y.Z Safari/537.36' - - const hook = jest.fn() - given.lib._addCaptureHook(hook) - given.subject() - expect(hook).not.toHaveBeenCalledWith('$event') - - // eslint-disable-next-line no-import-assign - globals['userAgent'] = originalUseragent - }) - - it('respects opt_out_useragent_filter', () => { - const originalUseragent = globals.userAgent - - given('config', () => ({ - opt_out_useragent_filter: true, - property_denylist: [], - property_blacklist: [], - _onCapture: jest.fn(), - })) - - // eslint-disable-next-line no-import-assign - globals['userAgent'] = - 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Googlebot/2.1; +http://www.google.com/bot.html) Chrome/W.X.Y.Z Safari/537.36' - - const hook = jest.fn() - given.lib._addCaptureHook(hook) - const event = given.subject() - expect(hook).toHaveBeenCalledWith( - '$event', - expect.objectContaining({ - event: '$event', - }) - ) - expect(event.properties['$browser_type']).toEqual('bot') - - // eslint-disable-next-line no-import-assign - globals['userAgent'] = originalUseragent - }) - - it('truncates long properties', () => { - given('config', () => ({ - properties_string_max_length: 1000, - property_denylist: [], - property_blacklist: [], - _onCapture: jest.fn(), - })) - given('eventProperties', () => ({ - key: 'value'.repeat(10000), - })) - const event = given.subject() - expect(event.properties.key.length).toBe(1000) - }) - - it('keeps long properties if null', () => { - given('config', () => ({ - properties_string_max_length: null, - property_denylist: [], - property_blacklist: [], - _onCapture: jest.fn(), - })) - given('eventProperties', () => ({ - key: 'value'.repeat(10000), - })) - const event = given.subject() - expect(event.properties.key.length).toBe(50000) - }) - - 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!' } } - ) - - // 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('updates persisted person properties for feature flags if $set is present', () => { - given('config', () => ({ - property_denylist: [], - property_blacklist: [], - _onCapture: jest.fn(), - })) - given('eventProperties', () => ({ - $set: { foo: 'bar' }, - })) - given.subject() - expect(given.overrides.persistence.props.$stored_person_properties).toMatchObject({ foo: 'bar' }) - }) - - 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 /e/ by default', () => { - given.lib.capture('event-name', { foo: 'bar', length: 0 }) - expect(given.lib._send_request).toHaveBeenCalledWith( - expect.objectContaining({ - url: 'https://us.i.posthog.com/e/', - }) - ) - }) - - 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 }) - - expect(given.lib._send_request).toHaveBeenCalledWith( - expect.objectContaining({ - url: 'https://us.i.posthog.com/i/v0/e/', - }) - ) - }) - - it('sends payloads to overriden endpoint if given', () => { - given.lib.capture('event-name', { foo: 'bar', length: 0 }, { _url: 'https://app.posthog.com/s/' }) - expect(given.lib._send_request).toHaveBeenCalledWith( - expect.objectContaining({ - url: 'https://app.posthog.com/s/', - }) - ) - }) - - it('sends payloads to overriden _url, even if alternative endpoint is set', () => { - given.lib._afterDecideResponse({ analytics: { endpoint: '/i/v0/e/' } }) - given.lib.capture('event-name', { foo: 'bar', length: 0 }, { _url: 'https://app.posthog.com/s/' }) - expect(given.lib._send_request).toHaveBeenCalledWith( - expect.objectContaining({ - url: 'https://app.posthog.com/s/', - }) - ) - }) - }) - - describe('_afterDecideResponse', () => { - given('subject', () => () => given.lib._afterDecideResponse(given.decideResponse)) - - it('enables compression from decide response', () => { - given('decideResponse', () => ({ supportedCompression: ['gzip-js', 'base64'] })) - given.subject() - - expect(given.lib.compression).toEqual('gzip-js') - }) - - it('enables compression from decide response when only one received', () => { - given('decideResponse', () => ({ supportedCompression: ['base64'] })) - given.subject() - - expect(given.lib.compression).toEqual('base64') - }) - - it('does not enable compression from decide response if compression is disabled', () => { - given('config', () => ({ disable_compression: true, persistence: 'memory' })) - given('decideResponse', () => ({ supportedCompression: ['gzip-js', 'base64'] })) - given.subject() - - expect(given.lib.compression).toEqual(undefined) - }) - - it('defaults to /e if no endpoint is given', () => { - given('decideResponse', () => ({})) - given.subject() - - expect(given.lib.analyticsDefaultEndpoint).toEqual('/e/') - }) - - 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/') - }) - }) - - 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', $is_identified: false }), - remove_event_timer: jest.fn(), - get_property: () => 'anonymous', - }, - sessionPersistence: { - properties: () => ({ distinct_id: 'abc', persistent: 'prop' }), - get_property: () => 'anonymous', - }, - sessionManager: { - checkAndGetSessionAndWindowId: jest.fn().mockReturnValue({ - windowId: 'windowId', - sessionId: 'sessionId', - }), - }, - })) - - given('config', () => ({ - api_host: 'https://app.posthog.com', - token: 'testtoken', - property_denylist: given.property_denylist, - property_blacklist: given.property_blacklist, - sanitize_properties: given.sanitize_properties, - })) - given('property_denylist', () => []) - given('property_blacklist', () => []) - - beforeEach(() => { - jest.spyOn(Info, 'properties').mockReturnValue({ $lib: 'web' }) - }) - - 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', - $is_identified: false, - $process_person_profile: true, - }) - }) - - it('sets $lib_custom_api_host if api_host is not the default', () => { - given('config', () => ({ - api_host: 'https://custom.posthog.com', - token: 'testtoken', - property_denylist: given.property_denylist, - property_blacklist: given.property_blacklist, - sanitize_properties: given.sanitize_properties, - })) - expect(given.subject).toEqual({ - token: 'testtoken', - event: 'prop', - $lib: 'web', - distinct_id: 'abc', - persistent: 'prop', - $window_id: 'windowId', - $session_id: 'sessionId', - $lib_custom_api_host: 'https://custom.posthog.com', - $is_identified: false, - $process_person_profile: true, - }) - }) - - it("can't deny or blacklist $process_person_profile", () => { - given('property_denylist', () => ['$process_person_profile']) - given('property_blacklist', () => ['$process_person_profile']) - - expect(given.subject['$process_person_profile']).toEqual(true) - }) - - 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() - }) - - it('calls sanitize_properties', () => { - given('sanitize_properties', () => (props, event_name) => ({ token: props.token, event_name })) - - expect(given.subject).toEqual({ - event_name: given.event_name, - token: 'testtoken', - $process_person_profile: true, - }) - }) - - it('saves $snapshot data and token for $snapshot events', () => { - given('event_name', () => '$snapshot') - given('properties', () => ({ $snapshot_data: {} })) - - expect(given.subject).toEqual({ - token: 'testtoken', - $snapshot_data: {}, - distinct_id: 'abc', - }) - }) - - 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) - - expect(Object.keys(properties)).toEqual(['prop1', 'prop2']) - }) - - it('adds page title to $pageview', () => { - document.title = 'test' - - given('event_name', () => '$pageview') - - expect(given.subject).toEqual(expect.objectContaining({ title: 'test' })) - }) - }) - - 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.capturePageview, - capture_pageleave: given.capturePageleave, - request_batching: given.batching, - })) - - given('capturePageview', () => true) - given('capturePageleave', () => 'if_capture_pageview') - given('batching', () => true) - - it('captures $pageleave', () => { - given.subject() - - expect(given.overrides.capture).toHaveBeenCalledWith('$pageleave') - }) - - it('does not capture $pageleave when capture_pageview=false and capture_pageleave=if_capture_pageview', () => { - given('capturePageleave', () => 'if_capture_pageview') - given('capturePageview', () => false) - - given.subject() - - expect(given.overrides.capture).not.toHaveBeenCalled() - }) - - it('does capture $pageleave when capture_pageview=true and capture_pageleave=if_capture_pageview', () => { - given('capturePageleave', () => 'if_capture_pageview') - given('capturePageview', () => true) - - given.subject() - - expect(given.overrides.capture).toHaveBeenCalled() - }) - - it('does capture $pageleave when capture_pageview=false and capture_pageleave=true', () => { - given('capturePageleave', () => true) - given('capturePageview', () => false) - - given.subject() - - expect(given.overrides.capture).toHaveBeenCalled() - }) - - it('calls requestQueue unload', () => { - given.subject() - - expect(given.overrides._requestQueue.unload).toHaveBeenCalledTimes(1) - }) - - describe('without batching', () => { - given('batching', () => false) - - it('captures $pageleave', () => { - given.subject() - - expect(given.overrides.capture).toHaveBeenCalledWith('$pageleave', null, { transport: 'sendBeacon' }) - }) - - it('does not capture $pageleave when capture_pageview=false', () => { - given('capturePageview', () => false) - - given.subject() - - expect(given.overrides.capture).not.toHaveBeenCalled() - }) - }) - }) - - describe('bootstrapping feature flags', () => { - given('overrides', () => ({ - _send_request: jest.fn(), - capture: jest.fn(), - })) - - afterEach(() => { - given.lib.reset() - }) - - it('sets the right distinctID', () => { - given('config', () => ({ - bootstrap: { - distinctID: 'abcd', - }, - })) - - expect(given.lib.get_distinct_id()).toBe('abcd') - expect(given.lib.get_property('$device_id')).toBe('abcd') - expect(given.lib.persistence.get_property(USER_STATE)).toBe('anonymous') - - given.lib.identify('efgh') - - expect(given.overrides.capture).toHaveBeenCalledWith( - '$identify', - { - distinct_id: 'efgh', - $anon_distinct_id: 'abcd', - }, - { $set: {}, $set_once: {} } - ) - }) - - it('treats identified distinctIDs appropriately', () => { - given('config', () => ({ - bootstrap: { - distinctID: 'abcd', - isIdentifiedID: true, - }, - get_device_id: () => 'og-device-id', - })) - - expect(given.lib.get_distinct_id()).toBe('abcd') - expect(given.lib.get_property('$device_id')).toBe('og-device-id') - expect(given.lib.persistence.get_property(USER_STATE)).toBe('identified') - - given.lib.identify('efgh') - expect(given.overrides.capture).not.toHaveBeenCalled() - }) - - it('sets the right feature flags', () => { - given('config', () => ({ - bootstrap: { - featureFlags: { multivariant: 'variant-1', enabled: true, disabled: false, undef: undefined }, - }, - })) - - 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 }) - }) - - 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.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) - }) - - it('does nothing when empty', () => { - jest.spyOn(console, 'warn').mockImplementation() - - given('config', () => ({ - bootstrap: {}, - })) - - 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({}) - }) - - it('onFeatureFlags should be called immediately if feature flags are bootstrapped', () => { - let called = false - - given('config', () => ({ - bootstrap: { - featureFlags: { multivariant: 'variant-1' }, - }, - })) - - given.lib.featureFlags.onFeatureFlags(() => (called = true)) - expect(called).toEqual(true) - }) - - it('onFeatureFlags should not be called immediately if feature flags bootstrap is empty', () => { - let called = false - - given('config', () => ({ - bootstrap: { - featureFlags: {}, - }, - })) - - given.lib.featureFlags.onFeatureFlags(() => (called = true)) - expect(called).toEqual(false) - }) - - it('onFeatureFlags should not be called immediately if feature flags bootstrap is undefined', () => { - let called = false - - given('config', () => ({ - bootstrap: { - featureFlags: undefined, - }, - })) - - given.lib.featureFlags.onFeatureFlags(() => (called = true)) - expect(called).toEqual(false) - }) - }) - - describe('init()', () => { - jest.spyOn(window, 'window', 'get') - - 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() - }) - - given('advanced_disable_decide', () => true) - - it('can set an xhr error handler', () => { - 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 load decide endpoint on advanced_disable_decide', () => { - expect(given.decide).toBe(undefined) - expect(given.overrides._send_request.mock.calls.length).toBe(0) // No outgoing requests - }) - - it('does not load feature flags, toolbar, session recording', () => { - given('overrides', () => ({ - sessionRecording: { - afterDecideResponse: jest.fn(), - startIfEnabledOrStop: jest.fn(), - }, - toolbar: { - maybeLoadToolbar: jest.fn(), - afterDecideResponse: jest.fn(), - }, - persistence: { - register: jest.fn(), - update_config: jest.fn(), - }, - })) - - jest.spyOn(given.lib.toolbar, 'afterDecideResponse').mockImplementation() - jest.spyOn(given.lib.sessionRecording, 'afterDecideResponse').mockImplementation() - jest.spyOn(given.lib.persistence, 'register').mockImplementation() - - // 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() - }) - - describe('device id behavior', () => { - it('sets a random UUID as distinct_id/$device_id if distinct_id is unset', () => { - given('distinct_id', () => undefined) - given('config', () => ({ - get_device_id: (uuid) => uuid, - })) - - expect(given.lib.persistence.props).toMatchObject({ - $device_id: expect.stringMatching(/^[0-9a-f-]+$/), - distinct_id: expect.stringMatching(/^[0-9a-f-]+$/), - }) - - expect(given.lib.persistence.props.$device_id).toEqual(given.lib.persistence.props.distinct_id) - }) - - it('does not set distinct_id/$device_id if distinct_id is unset', () => { - given('distinct_id', () => 'existing-id') - - expect(given.lib.persistence.props.distinct_id).not.toEqual('existing-id') - }) - - 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.lib.persistence.props).toMatchObject({ - $device_id: expect.stringMatching(/^custom-[0-9a-f-]+$/), - distinct_id: expect.stringMatching(/^custom-[0-9a-f-]+$/), - }) - }) - }) - }) - - describe('skipped init()', () => { - it('capture() does not throw', () => { - expect(() => given.lib.capture('$pageview')).not.toThrow() - }) - }) - - 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_denylist: [], - property_blacklist: [], - _onCapture: jest.fn(), - })) - - 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' }) - - 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: {} }) - - // 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: {}, - }) - - 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) - }) - - it('does not result in a capture call', () => { - given.lib.group('organization', 'org::5') - - expect(given.overrides.capture).not.toHaveBeenCalled() - }) - - 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.reloadFeatureFlags).toHaveBeenCalledTimes(2) - }) - - 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') - - expect(given.overrides.reloadFeatureFlags).toHaveBeenCalledTimes(3) - }) - - 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, - }, - }) - }) - - describe('subsequent capture calls', () => { - given('overrides', () => ({ - __loaded: true, - config: { - api_host: 'https://app.posthog.com', - ...given.config, - }, - persistence: new PostHogPersistence(given.config), - sessionPersistence: new PostHogPersistence(given.config), - _requestQueue: { - enqueue: given.captureQueue, - }, - reloadFeatureFlags: jest.fn(), - })) - - it('sends group information in event properties', () => { - given.lib.group('organization', 'org::5') - given.lib.group('instance', 'app.posthog.com') - - given.lib.capture('some_event', { prop: 5 }) - - expect(given.captureQueue).toHaveBeenCalledTimes(1) - - const eventPayload = given.captureQueue.mock.calls[0][0] - expect(eventPayload.data.event).toEqual('some_event') - expect(eventPayload.data.properties.$groups).toEqual({ - organization: 'org::5', - instance: 'app.posthog.com', - }) - }) - }) - - describe('error handling', () => { - given('overrides', () => ({ - register: jest.fn(), - })) - - it('handles blank keys being passed', () => { - window.console.error = jest.fn() - window.console.warn = jest.fn() - - 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() - }) - }) - - 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()) - - 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) - }) - - it('handles loaded config option throwing gracefully', () => { - given('config', () => ({ - loaded: () => { - throw Error() - }, - })) - console.error = jest.fn() - - given.subject() - - expect(console.error).toHaveBeenCalledWith('[PostHog.js]', '`loaded` function failed', expect.anything()) - }) - - describe('/decide', () => { - beforeEach(() => { - const call = jest.fn() - Decide.mockImplementation(() => ({ call })) - }) - - afterEach(() => { - Decide.mockReset() - }) - - 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() - }) - }) - - describe('capturing pageviews', () => { - it('captures not capture pageview if disabled', async () => { - given('config', () => ({ - capture_pageview: false, - loaded: jest.fn(), - })) - - given.subject() - - expect(given.overrides.capture).not.toHaveBeenCalled() - }) - - it('captures pageview if enabled', async () => { - jest.useFakeTimers() - given('config', () => ({ - capture_pageview: true, - loaded: jest.fn(), - })) - - given.subject() - - jest.runOnlyPendingTimers() - - 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') - }) - - it('returns the replay URL', () => { - expect(given.lib.get_session_replay_url()).toEqual( - 'https://us.posthog.com/project/testtoken/replay/sessionId' - ) - }) - - it('returns the replay URL including timestamp', () => { - expect(given.lib.get_session_replay_url({ withTimestamp: true })).toEqual( - 'https://us.posthog.com/project/testtoken/replay/sessionId?t=20' // default lookback is 10 seconds - ) - - expect(given.lib.get_session_replay_url({ withTimestamp: true, timestampLookBack: 0 })).toEqual( - 'https://us.posthog.com/project/testtoken/replay/sessionId?t=30' - ) - }) - }) - - test('deprecated web performance observer still exposes _forceAllowLocalhost', () => { - expect(given.lib.webPerformance._forceAllowLocalhost).toBe(false) - expect(() => given.lib.webPerformance._forceAllowLocalhost).not.toThrow() - }) -}) diff --git a/src/__tests__/posthog-core.ts b/src/__tests__/posthog-core.ts new file mode 100644 index 000000000..2ce103037 --- /dev/null +++ b/src/__tests__/posthog-core.ts @@ -0,0 +1,1221 @@ +import { Decide } from '../decide' + +import { Info } from '../utils/event-utils' +import { document, window } from '../utils/globals' +import { uuidv7 } from '../uuidv7' +import * as globals from '../utils/globals' +import { USER_STATE } from '../constants' +import { createPosthogInstance, defaultPostHog } from './helpers/posthog-instance' +import { logger } from '../utils/logger' +import { PostHogConfig } from '../types' +import { PostHog } from '../posthog-core' +import { PostHogPersistence } from '../posthog-persistence' +import { SessionIdManager } from '../sessionid' +import { RequestQueue } from '../request-queue' +import { SessionRecording } from '../extensions/replay/sessionrecording' +import { PostHogFeatureFlags } from '../posthog-featureflags' + +jest.mock('../decide') + +describe('posthog core', () => { + const baseUTCDateTime = new Date(Date.UTC(2020, 0, 1, 0, 0, 0)) + const eventName = '$event' + + const defaultConfig = {} + + const defaultOverrides = { + _send_request: jest.fn(), + } + + const posthogWith = (config: Partial, overrides?: Partial) => { + const posthog = defaultPostHog().init('testtoken', config, uuidv7()) + return Object.assign(posthog, overrides || {}) + } + + beforeEach(() => { + jest.useFakeTimers().setSystemTime(baseUTCDateTime) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + describe('capture()', () => { + it('adds a UUID to each message', () => { + const captureData = posthogWith(defaultConfig, defaultOverrides).capture(eventName, {}, {}) + expect(captureData).toHaveProperty('uuid') + }) + + it('adds system time to events', () => { + const captureData = posthogWith(defaultConfig, defaultOverrides).capture(eventName, {}, {}) + + expect(captureData).toHaveProperty('timestamp') + // timer is fixed at 2020-01-01 + expect(captureData.timestamp).toEqual(baseUTCDateTime) + }) + + it('captures when time is overriden by caller', () => { + const captureData = posthogWith(defaultConfig, defaultOverrides).capture( + eventName, + {}, + { timestamp: new Date(2020, 0, 2, 12, 34) } + ) + expect(captureData).toHaveProperty('timestamp') + expect(captureData.timestamp).toEqual(new Date(2020, 0, 2, 12, 34)) + expect(captureData.properties['$event_time_override_provided']).toEqual(true) + expect(captureData.properties['$event_time_override_system_time']).toEqual(baseUTCDateTime) + }) + + it('handles recursive objects', () => { + const props: Record = {} + props.recurse = props + + expect(() => + posthogWith(defaultConfig, defaultOverrides).capture(eventName, props, { + timestamp: new Date(2020, 0, 2, 12, 34), + }) + ).not.toThrow() + }) + + it('calls callbacks added via _addCaptureHook', () => { + const hook = jest.fn() + const posthog = posthogWith(defaultConfig, defaultOverrides) + posthog._addCaptureHook(hook) + + posthog.capture(eventName, {}, {}) + expect(hook).toHaveBeenCalledWith( + '$event', + expect.objectContaining({ + event: '$event', + }) + ) + }) + + it('calls update_campaign_params and update_referrer_info on sessionPersistence', () => { + const posthog = posthogWith( + { + property_denylist: [], + property_blacklist: [], + _onCapture: jest.fn(), + store_google: true, + save_referrer: true, + }, + { + ...defaultOverrides, + sessionPersistence: { + update_search_keyword: jest.fn(), + update_campaign_params: jest.fn(), + update_referrer_info: jest.fn(), + update_config: jest.fn(), + properties: jest.fn(), + get_property: () => 'anonymous', + } as unknown as PostHogPersistence, + } + ) + + posthog.capture(eventName, {}, {}) + + expect(posthog.sessionPersistence.update_campaign_params).toHaveBeenCalled() + expect(posthog.sessionPersistence.update_referrer_info).toHaveBeenCalled() + }) + + it('errors with undefined event name', () => { + const hook = jest.fn() + + const posthog = posthogWith(defaultConfig, defaultOverrides) + posthog._addCaptureHook(hook) + jest.spyOn(logger, 'error').mockImplementation() + + expect(() => posthog.capture(undefined)).not.toThrow() + expect(hook).not.toHaveBeenCalled() + expect(logger.error).toHaveBeenCalledWith('No event name provided to posthog.capture') + }) + + it('errors with object event name', () => { + const hook = jest.fn() + jest.spyOn(logger, 'error').mockImplementation() + + const posthog = posthogWith(defaultConfig, defaultOverrides) + posthog._addCaptureHook(hook) + + expect(() => posthog.capture({ event: 'object as name' })).not.toThrow() + expect(hook).not.toHaveBeenCalled() + expect(logger.error).toHaveBeenCalledWith('No event name provided to posthog.capture') + }) + + it('respects opt_out_useragent_filter (default: false)', () => { + const originalUseragent = globals.userAgent + ;(globals as any)['userAgent'] = + 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Googlebot/2.1; +http://www.google.com/bot.html) Chrome/W.X.Y.Z Safari/537.36' + + const hook = jest.fn() + const posthog = posthogWith(defaultConfig, defaultOverrides) + posthog._addCaptureHook(hook) + + posthog.capture(eventName, {}, {}) + expect(hook).not.toHaveBeenCalledWith('$event') + ;(globals as any)['userAgent'] = originalUseragent + }) + + it('respects opt_out_useragent_filter', () => { + const originalUseragent = globals.userAgent + + ;(globals as any)['userAgent'] = + 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Googlebot/2.1; +http://www.google.com/bot.html) Chrome/W.X.Y.Z Safari/537.36' + + const hook = jest.fn() + const posthog = posthogWith( + { + opt_out_useragent_filter: true, + property_denylist: [], + property_blacklist: [], + _onCapture: jest.fn(), + }, + defaultOverrides + ) + posthog._addCaptureHook(hook) + + const event = posthog.capture(eventName, {}, {}) + + expect(hook).toHaveBeenCalledWith( + '$event', + expect.objectContaining({ + event: '$event', + }) + ) + expect(event.properties['$browser_type']).toEqual('bot') + ;(globals as any)['userAgent'] = originalUseragent + }) + + it('truncates long properties', () => { + const posthog = posthogWith( + { + properties_string_max_length: 1000, + property_denylist: [], + property_blacklist: [], + _onCapture: jest.fn(), + }, + defaultOverrides + ) + + const event = posthog.capture( + eventName, + { + key: 'value'.repeat(10000), + }, + {} + ) + + expect(event.properties.key.length).toBe(1000) + }) + + it('keeps long properties if undefined', () => { + const posthog = posthogWith( + { + properties_string_max_length: undefined, + property_denylist: [], + property_blacklist: [], + _onCapture: jest.fn(), + }, + defaultOverrides + ) + + const event = posthog.capture( + eventName, + { + key: 'value'.repeat(10000), + }, + {} + ) + + expect(event.properties.key.length).toBe(50000) + }) + + 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 posthog = posthogWith(defaultConfig, defaultOverrides) + + const captureResult = posthog.capture( + '$identify', + { distinct_id: 'some-distinct-id' }, + { $set: { email: 'john@example.com' }, $set_once: { howOftenAmISet: 'once!' } } + ) + + // 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: expect.objectContaining({ howOftenAmISet: 'once!' }), + }) + ) + }) + + it('updates persisted person properties for feature flags if $set is present', () => { + const posthog = posthogWith( + { + property_denylist: [], + property_blacklist: [], + _onCapture: jest.fn(), + }, + defaultOverrides + ) + + posthog.capture(eventName, { + $set: { foo: 'bar' }, + }) + expect(posthog.persistence.props.$stored_person_properties).toMatchObject({ foo: 'bar' }) + }) + + it('correctly handles the "length" property', () => { + const posthog = posthogWith(defaultConfig, defaultOverrides) + const captureResult = posthog.capture('event-name', { foo: 'bar', length: 0 }) + expect(captureResult.properties).toEqual(expect.objectContaining({ foo: 'bar', length: 0 })) + }) + + it('sends payloads to /e/ by default', () => { + const posthog = posthogWith({ ...defaultConfig, request_batching: false }, defaultOverrides) + + posthog.capture('event-name', { foo: 'bar', length: 0 }) + + expect(posthog._send_request).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://us.i.posthog.com/e/', + }) + ) + }) + + it('sends payloads to alternative endpoint if given', () => { + const posthog = posthogWith({ ...defaultConfig, request_batching: false }, defaultOverrides) + posthog._afterDecideResponse({ analytics: { endpoint: '/i/v0/e/' } }) + + posthog.capture('event-name', { foo: 'bar', length: 0 }) + + expect(posthog._send_request).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://us.i.posthog.com/i/v0/e/', + }) + ) + }) + + it('sends payloads to overriden endpoint if given', () => { + const posthog = posthogWith({ ...defaultConfig, request_batching: false }, defaultOverrides) + + posthog.capture('event-name', { foo: 'bar', length: 0 }, { _url: 'https://app.posthog.com/s/' }) + + expect(posthog._send_request).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://app.posthog.com/s/', + }) + ) + }) + + it('sends payloads to overriden _url, even if alternative endpoint is set', () => { + const posthog = posthogWith({ ...defaultConfig, request_batching: false }, defaultOverrides) + posthog._afterDecideResponse({ analytics: { endpoint: '/i/v0/e/' } }) + + posthog.capture('event-name', { foo: 'bar', length: 0 }, { _url: 'https://app.posthog.com/s/' }) + + expect(posthog._send_request).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://app.posthog.com/s/', + }) + ) + }) + }) + + describe('_afterDecideResponse', () => { + it('enables compression from decide response', () => { + const posthog = posthogWith({}) + + posthog._afterDecideResponse({ supportedCompression: ['gzip-js', 'base64'] }) + + expect(posthog.compression).toEqual('gzip-js') + }) + + it('enables compression from decide response when only one received', () => { + const posthog = posthogWith({}) + + posthog._afterDecideResponse({ supportedCompression: ['base64'] }) + + expect(posthog.compression).toEqual('base64') + }) + + it('does not enable compression from decide response if compression is disabled', () => { + const posthog = posthogWith({ disable_compression: true, persistence: 'memory' }) + + posthog._afterDecideResponse({ supportedCompression: ['gzip-js', 'base64'] }) + + expect(posthog.compression).toEqual(undefined) + }) + + it('defaults to /e if no endpoint is given', () => { + const posthog = posthogWith({}) + + posthog._afterDecideResponse({}) + + expect(posthog.analyticsDefaultEndpoint).toEqual('/e/') + }) + + it('uses the specified analytics endpoint if given', () => { + const posthog = posthogWith({}) + + posthog._afterDecideResponse({ analytics: { endpoint: '/i/v0/e/' } }) + + expect(posthog.analyticsDefaultEndpoint).toEqual('/i/v0/e/') + }) + }) + + describe('_calculate_event_properties()', () => { + let posthog: PostHog + + const overrides: Partial = { + persistence: { + properties: () => ({ distinct_id: 'abc', persistent: 'prop', $is_identified: false }), + remove_event_timer: jest.fn(), + get_property: () => 'anonymous', + } as unknown as PostHogPersistence, + sessionPersistence: { + properties: () => ({ distinct_id: 'abc', persistent: 'prop' }), + get_property: () => 'anonymous', + } as unknown as PostHogPersistence, + sessionManager: { + checkAndGetSessionAndWindowId: jest.fn().mockReturnValue({ + windowId: 'windowId', + sessionId: 'sessionId', + }), + } as unknown as SessionIdManager, + } + + beforeEach(() => { + jest.spyOn(Info, 'properties').mockReturnValue({ $lib: 'web' }) + + posthog = posthogWith( + { + api_host: 'https://app.posthog.com', + token: 'testtoken', + property_denylist: [], + property_blacklist: [], + sanitize_properties: undefined, + }, + overrides + ) + }) + + it('returns calculated properties', () => { + expect(posthog._calculate_event_properties('custom_event', { event: 'prop' })).toEqual({ + token: 'testtoken', + event: 'prop', + $lib: 'web', + distinct_id: 'abc', + persistent: 'prop', + $window_id: 'windowId', + $session_id: 'sessionId', + $is_identified: false, + $process_person_profile: true, + }) + }) + + it('sets $lib_custom_api_host if api_host is not the default', () => { + posthog = posthogWith( + { + api_host: 'https://custom.posthog.com', + }, + overrides + ) + + expect(posthog._calculate_event_properties('custom_event', { event: 'prop' })).toEqual({ + token: 'testtoken', + event: 'prop', + $lib: 'web', + distinct_id: 'abc', + persistent: 'prop', + $window_id: 'windowId', + $session_id: 'sessionId', + $lib_custom_api_host: 'https://custom.posthog.com', + $is_identified: false, + $process_person_profile: true, + }) + }) + + it("can't deny or blacklist $process_person_profile", () => { + posthog = posthogWith( + { + api_host: 'https://custom.posthog.com', + property_denylist: ['$process_person_profile'], + property_blacklist: ['$process_person_profile'], + }, + overrides + ) + + expect( + posthog._calculate_event_properties('custom_event', { event: 'prop' })['$process_person_profile'] + ).toEqual(true) + }) + + it('only adds token and distinct_id if event_name is $snapshot', () => { + posthog = posthogWith( + { + api_host: 'https://custom.posthog.com', + }, + overrides + ) + + expect(posthog._calculate_event_properties('$snapshot', { event: 'prop' })).toEqual({ + token: 'testtoken', + event: 'prop', + distinct_id: 'abc', + }) + expect(posthog.sessionManager!.checkAndGetSessionAndWindowId).not.toHaveBeenCalled() + }) + + it('calls sanitize_properties', () => { + posthog = posthogWith( + { + api_host: 'https://custom.posthog.com', + sanitize_properties: (props, event_name) => ({ token: props.token, event_name }), + }, + overrides + ) + + expect(posthog._calculate_event_properties('custom_event', { event: 'prop' })).toEqual({ + event_name: 'custom_event', + token: 'testtoken', + $process_person_profile: true, + }) + }) + + it('saves $snapshot data and token for $snapshot events', () => { + posthog = posthogWith({}, overrides) + + expect(posthog._calculate_event_properties('$snapshot', { $snapshot_data: {} })).toEqual({ + token: 'testtoken', + $snapshot_data: {}, + distinct_id: 'abc', + }) + }) + + it("doesn't modify properties passed into it", () => { + const properties = { prop1: 'val1', prop2: 'val2' } + + posthog._calculate_event_properties('custom_event', properties) + + expect(Object.keys(properties)).toEqual(['prop1', 'prop2']) + }) + + it('adds page title to $pageview', () => { + document!.title = 'test' + + expect(posthog._calculate_event_properties('$pageview', {})).toEqual( + expect.objectContaining({ title: 'test' }) + ) + }) + }) + + describe('_handle_unload()', () => { + it('captures $pageleave', () => { + const posthog = posthogWith( + { + capture_pageview: true, + capture_pageleave: 'if_capture_pageview', + request_batching: true, + }, + { capture: jest.fn() } + ) + + posthog._handle_unload() + + expect(posthog.capture).toHaveBeenCalledWith('$pageleave') + }) + + it('does not capture $pageleave when capture_pageview=false and capture_pageleave=if_capture_pageview', () => { + const posthog = posthogWith( + { + capture_pageview: false, + capture_pageleave: 'if_capture_pageview', + request_batching: true, + }, + { capture: jest.fn() } + ) + + posthog._handle_unload() + + expect(posthog.capture).not.toHaveBeenCalled() + }) + + it('does capture $pageleave when capture_pageview=false and capture_pageleave=true', () => { + const posthog = posthogWith( + { + capture_pageview: false, + capture_pageleave: true, + request_batching: true, + }, + { capture: jest.fn() } + ) + + posthog._handle_unload() + + expect(posthog.capture).toHaveBeenCalledWith('$pageleave') + }) + + it('calls requestQueue unload', () => { + const posthog = posthogWith( + { + capture_pageview: true, + capture_pageleave: 'if_capture_pageview', + request_batching: true, + }, + { _requestQueue: { enqueue: jest.fn(), unload: jest.fn() } as unknown as RequestQueue } + ) + + posthog._handle_unload() + + expect(posthog._requestQueue.unload).toHaveBeenCalledTimes(1) + }) + + describe('without batching', () => { + it('captures $pageleave', () => { + const posthog = posthogWith( + { + capture_pageview: true, + capture_pageleave: 'if_capture_pageview', + request_batching: false, + }, + { capture: jest.fn() } + ) + posthog._handle_unload() + + expect(posthog.capture).toHaveBeenCalledWith('$pageleave', null, { transport: 'sendBeacon' }) + }) + + it('does not capture $pageleave when capture_pageview=false', () => { + const posthog = posthogWith( + { + capture_pageview: false, + capture_pageleave: 'if_capture_pageview', + request_batching: false, + }, + { capture: jest.fn() } + ) + posthog._handle_unload() + + expect(posthog.capture).not.toHaveBeenCalled() + }) + }) + }) + + describe('bootstrapping feature flags', () => { + it('sets the right distinctID', () => { + const posthog = posthogWith( + { + bootstrap: { + distinctID: 'abcd', + }, + }, + { capture: jest.fn() } + ) + + expect(posthog.get_distinct_id()).toBe('abcd') + expect(posthog.get_property('$device_id')).toBe('abcd') + expect(posthog.persistence.get_property(USER_STATE)).toBe('anonymous') + + posthog.identify('efgh') + + expect(posthog.capture).toHaveBeenCalledWith( + '$identify', + { + distinct_id: 'efgh', + $anon_distinct_id: 'abcd', + }, + { $set: {}, $set_once: {} } + ) + }) + + it('treats identified distinctIDs appropriately', () => { + const posthog = posthogWith( + { + bootstrap: { + distinctID: 'abcd', + isIdentifiedID: true, + }, + get_device_id: () => 'og-device-id', + }, + { capture: jest.fn() } + ) + + expect(posthog.get_distinct_id()).toBe('abcd') + expect(posthog.get_property('$device_id')).toBe('og-device-id') + expect(posthog.persistence.get_property(USER_STATE)).toBe('identified') + + posthog.identify('efgh') + expect(posthog.capture).not.toHaveBeenCalled() + }) + + it('sets the right feature flags', () => { + const posthog = posthogWith({ + bootstrap: { + featureFlags: { + multivariant: 'variant-1', + enabled: true, + disabled: false, + // TODO why are we testing that undefined is passed through? + undef: undefined as unknown as string | boolean, + }, + }, + }) + + expect(posthog.get_distinct_id()).not.toBe('abcd') + expect(posthog.get_distinct_id()).not.toEqual(undefined) + expect(posthog.getFeatureFlag('multivariant')).toBe('variant-1') + expect(posthog.getFeatureFlag('disabled')).toBe(undefined) + expect(posthog.getFeatureFlag('undef')).toBe(undefined) + expect(posthog.featureFlags.getFlagVariants()).toEqual({ multivariant: 'variant-1', enabled: true }) + }) + + it('sets the right feature flag payloads', () => { + const posthog = posthogWith({ + bootstrap: { + featureFlags: { + multivariant: 'variant-1', + enabled: true, + jsonString: true, + disabled: false, + // TODO why are we testing that undefined is passed through? + undef: undefined as unknown as string | boolean, + }, + featureFlagPayloads: { + multivariant: 'some-payload', + enabled: { + another: 'value', + }, + disabled: 'should not show', + undef: 200, + jsonString: '{"a":"payload"}', + }, + }, + }) + + expect(posthog.getFeatureFlagPayload('multivariant')).toBe('some-payload') + expect(posthog.getFeatureFlagPayload('enabled')).toEqual({ another: 'value' }) + expect(posthog.getFeatureFlagPayload('jsonString')).toEqual({ a: 'payload' }) + expect(posthog.getFeatureFlagPayload('disabled')).toBe(undefined) + expect(posthog.getFeatureFlagPayload('undef')).toBe(undefined) + }) + + it('does nothing when empty', () => { + jest.spyOn(logger, 'warn').mockImplementation() + + const posthog = posthogWith({ + bootstrap: {}, + persistence: 'memory', + }) + + expect(posthog.get_distinct_id()).not.toBe('abcd') + expect(posthog.get_distinct_id()).not.toEqual(undefined) + expect(posthog.getFeatureFlag('multivariant')).toBe(undefined) + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('getFeatureFlag for key "multivariant" failed') + ) + expect(posthog.getFeatureFlag('disabled')).toBe(undefined) + expect(posthog.getFeatureFlag('undef')).toBe(undefined) + expect(posthog.featureFlags.getFlagVariants()).toEqual({}) + }) + + it('onFeatureFlags should be called immediately if feature flags are bootstrapped', () => { + let called = false + const posthog = posthogWith({ + bootstrap: { + featureFlags: { multivariant: 'variant-1' }, + }, + }) + + posthog.featureFlags.onFeatureFlags(() => (called = true)) + expect(called).toEqual(true) + }) + + it('onFeatureFlags should not be called immediately if feature flags bootstrap is empty', () => { + let called = false + + const posthog = posthogWith({ + bootstrap: { + featureFlags: {}, + }, + }) + + posthog.featureFlags.onFeatureFlags(() => (called = true)) + expect(called).toEqual(false) + }) + + it('onFeatureFlags should not be called immediately if feature flags bootstrap is undefined', () => { + let called = false + + const posthog = posthogWith({ + bootstrap: { + featureFlags: undefined, + }, + }) + + posthog.featureFlags.onFeatureFlags(() => (called = true)) + expect(called).toEqual(false) + }) + }) + + describe('init()', () => { + // @ts-expect-error - it's fine to spy on window + jest.spyOn(window, 'window', 'get') + + beforeEach(() => { + // @ts-expect-error - it's fine to spy on window + jest.spyOn(window.console, 'warn').mockImplementation() + // @ts-expect-error - it's fine to spy on window + jest.spyOn(window.console, 'error').mockImplementation() + }) + + it('can set an xhr error handler', () => { + const fakeOnXHRError = jest.fn() + const posthog = posthogWith({ + on_xhr_error: fakeOnXHRError, + }) + expect(posthog.config.on_xhr_error).toBe(fakeOnXHRError) + }) + + it.skip('does not load feature flags, session recording', () => { + // TODO this didn't make a tonne of sense in the given form + // it makes no sense now + // of course mocks added _after_ init will not be called + const posthog = defaultPostHog().init('testtoken', defaultConfig, uuidv7())! + + posthog.sessionRecording = { + afterDecideResponse: jest.fn(), + startIfEnabledOrStop: jest.fn(), + } as unknown as SessionRecording + posthog.persistence = { + register: jest.fn(), + update_config: jest.fn(), + } as unknown as PostHogPersistence + + // Feature flags + expect(posthog.persistence.register).not.toHaveBeenCalled() // FFs are saved this way + + // Session recording + expect(posthog.sessionRecording.afterDecideResponse).not.toHaveBeenCalled() + }) + + describe('device id behavior', () => { + let uninitialisedPostHog: PostHog + beforeEach(() => { + uninitialisedPostHog = defaultPostHog() + }) + + it('sets a random UUID as distinct_id/$device_id if distinct_id is unset', () => { + uninitialisedPostHog.persistence = { + props: { distinct_id: undefined }, + } as unknown as PostHogPersistence + const posthog = uninitialisedPostHog.init( + uuidv7(), + { + get_device_id: (uuid) => uuid, + }, + uuidv7() + )! + + expect(posthog.persistence!.props).toMatchObject({ + $device_id: expect.stringMatching(/^[0-9a-f-]+$/), + distinct_id: expect.stringMatching(/^[0-9a-f-]+$/), + }) + + expect(posthog.persistence!.props.$device_id).toEqual(posthog.persistence!.props.distinct_id) + }) + + it('does not set distinct_id/$device_id if distinct_id is unset', () => { + uninitialisedPostHog.persistence = { + props: { distinct_id: 'existing-id' }, + } as unknown as PostHogPersistence + const posthog = uninitialisedPostHog.init( + uuidv7(), + { + get_device_id: (uuid) => uuid, + }, + uuidv7() + )! + + expect(posthog.persistence!.props.distinct_id).not.toEqual('existing-id') + }) + + it('uses config.get_device_id for uuid generation if passed', () => { + const posthog = uninitialisedPostHog.init( + uuidv7(), + { + get_device_id: (uuid) => 'custom-' + uuid.slice(0, 8), + persistence: 'memory', + }, + uuidv7() + )! + + expect(posthog.persistence!.props).toMatchObject({ + $device_id: expect.stringMatching(/^custom-[0-9a-f-]+$/), + distinct_id: expect.stringMatching(/^custom-[0-9a-f-]+$/), + }) + }) + }) + }) + + describe('skipped init()', () => { + it('capture() does not throw', () => { + jest.spyOn(logger, 'error').mockImplementation() + + expect(() => defaultPostHog().capture('$pageview')).not.toThrow() + + expect(logger.error).toHaveBeenCalledWith('You must initialize PostHog before calling posthog.capture') + }) + }) + + describe('group()', () => { + let posthog: PostHog + + beforeEach(() => { + posthog = defaultPostHog().init( + 'testtoken', + { + persistence: 'memory', + }, + uuidv7() + )! + posthog.persistence!.clear() + posthog.reloadFeatureFlags = jest.fn() + posthog.capture = jest.fn() + }) + + it('records info on groups', () => { + posthog.group('organization', 'org::5') + expect(posthog.getGroups()).toEqual({ organization: 'org::5' }) + + posthog.group('organization', 'org::6') + expect(posthog.getGroups()).toEqual({ organization: 'org::6' }) + + posthog.group('instance', 'app.posthog.com') + expect(posthog.getGroups()).toEqual({ organization: 'org::6', instance: 'app.posthog.com' }) + }) + + it('records info on groupProperties for groups', () => { + posthog.group('organization', 'org::5', { name: 'PostHog' }) + expect(posthog.getGroups()).toEqual({ organization: 'org::5' }) + + expect(posthog.persistence!.props['$stored_group_properties']).toEqual({ + organization: { name: 'PostHog' }, + }) + + posthog.group('organization', 'org::6') + expect(posthog.getGroups()).toEqual({ organization: 'org::6' }) + expect(posthog.persistence!.props['$stored_group_properties']).toEqual({ organization: {} }) + + posthog.group('instance', 'app.posthog.com') + expect(posthog.getGroups()).toEqual({ organization: 'org::6', instance: 'app.posthog.com' }) + expect(posthog.persistence!.props['$stored_group_properties']).toEqual({ organization: {}, instance: {} }) + + // now add properties to the group + posthog.group('organization', 'org::7', { name: 'PostHog2' }) + expect(posthog.getGroups()).toEqual({ organization: 'org::7', instance: 'app.posthog.com' }) + expect(posthog.persistence!.props['$stored_group_properties']).toEqual({ + organization: { name: 'PostHog2' }, + instance: {}, + }) + + posthog.group('instance', 'app.posthog.com', { a: 'b' }) + expect(posthog.getGroups()).toEqual({ organization: 'org::7', instance: 'app.posthog.com' }) + expect(posthog.persistence!.props['$stored_group_properties']).toEqual({ + organization: { name: 'PostHog2' }, + instance: { a: 'b' }, + }) + + posthog.resetGroupPropertiesForFlags() + expect(posthog.persistence!.props['$stored_group_properties']).toEqual(undefined) + }) + + it('does not result in a capture call', () => { + posthog.group('organization', 'org::5') + + expect(posthog.capture).not.toHaveBeenCalled() + }) + + it('results in a reloadFeatureFlags call if group changes', () => { + posthog.group('organization', 'org::5', { name: 'PostHog' }) + posthog.group('instance', 'app.posthog.com') + posthog.group('organization', 'org::5') + + expect(posthog.reloadFeatureFlags).toHaveBeenCalledTimes(2) + }) + + it('results in a reloadFeatureFlags call if group properties change', () => { + posthog.group('organization', 'org::5') + posthog.group('instance', 'app.posthog.com') + posthog.group('organization', 'org::5', { name: 'PostHog' }) + posthog.group('instance', 'app.posthog.com') + + expect(posthog.reloadFeatureFlags).toHaveBeenCalledTimes(3) + }) + + it('captures $groupidentify event', () => { + posthog.group('organization', 'org::5', { group: 'property', foo: 5 }) + + expect(posthog.capture).toHaveBeenCalledWith('$groupidentify', { + $group_type: 'organization', + $group_key: 'org::5', + $group_set: { + group: 'property', + foo: 5, + }, + }) + }) + + describe('subsequent capture calls', () => { + beforeEach(() => { + posthog = defaultPostHog().init( + 'testtoken', + { + persistence: 'memory', + }, + uuidv7() + )! + posthog.persistence!.clear() + // mock this internal queue - not capture + posthog._requestQueue = { + enqueue: jest.fn(), + } as unknown as RequestQueue + }) + + it('sends group information in event properties', () => { + posthog.group('organization', 'org::5') + posthog.group('instance', 'app.posthog.com') + + posthog.capture('some_event', { prop: 5 }) + + expect(posthog._requestQueue!.enqueue).toHaveBeenCalledTimes(1) + + const eventPayload = jest.mocked(posthog._requestQueue!.enqueue).mock.calls[0][0] + // need to help TS know event payload data is not an array + // eslint-disable-next-line posthog-js/no-direct-array-check + if (Array.isArray(eventPayload.data!)) { + throw new Error('') + } + expect(eventPayload.data!.event).toEqual('some_event') + expect(eventPayload.data!.properties.$groups).toEqual({ + organization: 'org::5', + instance: 'app.posthog.com', + }) + }) + }) + + describe('error handling', () => { + it('handles blank keys being passed', () => { + ;(window as any).console.error = jest.fn() + ;(window as any).console.warn = jest.fn() + + posthog.register = jest.fn() + + posthog.group(null as unknown as string, 'foo') + posthog.group('organization', null as unknown as string) + posthog.group('organization', undefined as unknown as string) + posthog.group('organization', '') + posthog.group('', 'foo') + + expect(posthog.register).not.toHaveBeenCalled() + }) + }) + + describe('reset group', () => { + it('groups property is empty and reloads feature flags', () => { + posthog.group('organization', 'org::5') + posthog.group('instance', 'app.posthog.com', { group: 'property', foo: 5 }) + + expect(posthog.persistence!.props['$groups']).toEqual({ + organization: 'org::5', + instance: 'app.posthog.com', + }) + + expect(posthog.persistence!.props['$stored_group_properties']).toEqual({ + organization: {}, + instance: { + group: 'property', + foo: 5, + }, + }) + + posthog.resetGroups() + + expect(posthog.persistence!.props['$groups']).toEqual({}) + expect(posthog.persistence!.props['$stored_group_properties']).toEqual(undefined) + + expect(posthog.reloadFeatureFlags).toHaveBeenCalledTimes(3) + }) + }) + }) + + describe('_loaded()', () => { + it('calls loaded config option', () => { + const posthog = posthogWith( + { loaded: jest.fn() }, + { + capture: jest.fn(), + featureFlags: { + setReloadingPaused: jest.fn(), + resetRequestQueue: jest.fn(), + _startReloadTimer: jest.fn(), + } as unknown as PostHogFeatureFlags, + _start_queue_if_opted_in: jest.fn(), + } + ) + + posthog._loaded() + + expect(posthog.config.loaded).toHaveBeenCalledWith(posthog) + }) + + it('handles loaded config option throwing gracefully', () => { + jest.spyOn(logger, 'critical').mockImplementation() + + const posthog = posthogWith( + { + loaded: () => { + throw Error() + }, + }, + { + capture: jest.fn(), + featureFlags: { + setReloadingPaused: jest.fn(), + resetRequestQueue: jest.fn(), + _startReloadTimer: jest.fn(), + } as unknown as PostHogFeatureFlags, + _start_queue_if_opted_in: jest.fn(), + } + ) + + posthog._loaded() + + expect(logger.critical).toHaveBeenCalledWith('`loaded` function failed', expect.anything()) + }) + + describe('/decide', () => { + beforeEach(() => { + const call = jest.fn() + ;(Decide as any).mockImplementation(() => ({ call })) + }) + + afterEach(() => { + ;(Decide as any).mockReset() + }) + + it('is called by default', async () => { + const instance = await createPosthogInstance(uuidv7()) + instance.featureFlags.setReloadingPaused = jest.fn() + instance._loaded() + + expect(new Decide(instance).call).toHaveBeenCalled() + expect(instance.featureFlags.setReloadingPaused).toHaveBeenCalledWith(true) + }) + + it('does not call decide if disabled', async () => { + const instance = await createPosthogInstance(uuidv7(), { + advanced_disable_decide: true, + }) + instance.featureFlags.setReloadingPaused = jest.fn() + instance._loaded() + + expect(new Decide(instance).call).not.toHaveBeenCalled() + expect(instance.featureFlags.setReloadingPaused).not.toHaveBeenCalled() + }) + }) + }) + + describe('capturing pageviews', () => { + it('captures not capture pageview if disabled', async () => { + jest.useFakeTimers() + + const instance = await createPosthogInstance(uuidv7(), { + capture_pageview: false, + }) + instance.capture = jest.fn() + + // TODO you shouldn't need to emit an event to get the pending timer to emit the pageview + // but you do :shrug: + instance.capture('not a pageview', {}) + + jest.runOnlyPendingTimers() + + expect(instance.capture).not.toHaveBeenLastCalledWith( + '$pageview', + { title: 'test' }, + { send_instantly: true } + ) + }) + + it('captures pageview if enabled', async () => { + jest.useFakeTimers() + + const instance = await createPosthogInstance(uuidv7(), { + capture_pageview: true, + }) + instance.capture = jest.fn() + + // TODO you shouldn't need to emit an event to get the pending timer to emit the pageview + // but you do :shrug: + instance.capture('not a pageview', {}) + + jest.runOnlyPendingTimers() + + expect(instance.capture).toHaveBeenLastCalledWith('$pageview', { title: 'test' }, { send_instantly: true }) + }) + }) + + describe('session_id', () => { + let instance: PostHog + let token: string + + beforeEach(async () => { + token = uuidv7() + instance = await createPosthogInstance(token, { + api_host: 'https://us.posthog.com', + }) + instance.sessionManager!.checkAndGetSessionAndWindowId = jest.fn().mockReturnValue({ + windowId: 'windowId', + sessionId: 'sessionId', + sessionStartTimestamp: new Date().getTime() - 30000, + }) + }) + + it('returns the session_id', () => { + expect(instance.get_session_id()).toEqual('sessionId') + }) + + it('returns the replay URL', () => { + expect(instance.get_session_replay_url()).toEqual( + `https://us.posthog.com/project/${token}/replay/sessionId` + ) + }) + + it('returns the replay URL including timestamp', () => { + expect(instance.get_session_replay_url({ withTimestamp: true })).toEqual( + `https://us.posthog.com/project/${token}/replay/sessionId?t=20` // default lookback is 10 seconds + ) + + expect(instance.get_session_replay_url({ withTimestamp: true, timestampLookBack: 0 })).toEqual( + `https://us.posthog.com/project/${token}/replay/sessionId?t=30` + ) + }) + }) + + it('deprecated web performance observer still exposes _forceAllowLocalhost', async () => { + const posthog = await createPosthogInstance(uuidv7()) + expect(posthog.webPerformance._forceAllowLocalhost).toBe(false) + expect(() => posthog.webPerformance._forceAllowLocalhost).not.toThrow() + }) +})