diff --git a/posthog-core/src/index.ts b/posthog-core/src/index.ts index a0026406..8fbe8ef4 100644 --- a/posthog-core/src/index.ts +++ b/posthog-core/src/index.ts @@ -93,6 +93,10 @@ export abstract class PostHogCoreStateless { } this.requestTimeout = options?.requestTimeout ?? 10000 // 10 seconds this.disableGeoip = options?.disableGeoip ?? true + + if (options?.__onConstructed) { + options.__onConstructed(this) + } } protected getCommonEventProperties(): any { diff --git a/posthog-core/src/types.ts b/posthog-core/src/types.ts index d9ef3a22..cfa099bd 100644 --- a/posthog-core/src/types.ts +++ b/posthog-core/src/types.ts @@ -1,3 +1,5 @@ +import type { PostHogCoreStateless } from './index' + export type PosthogCoreOptions = { // PostHog API host (https://app.posthog.com by default) host?: string @@ -29,6 +31,9 @@ export type PosthogCoreOptions = { // Whether to post events to PostHog in JSON or compressed format captureMode?: 'json' | 'form' disableGeoip?: boolean + + // For testing purposes only, called at the end of the PostHogCoreStateless constructor + __onConstructed?: (posthog: PostHogCoreStateless) => void } export enum PostHogPersistedProperty { diff --git a/posthog-react-native/src/posthog-rn.ts b/posthog-react-native/src/posthog-rn.ts index 83b026b5..6a6d6fac 100644 --- a/posthog-react-native/src/posthog-rn.ts +++ b/posthog-react-native/src/posthog-rn.ts @@ -32,6 +32,7 @@ export class PostHog extends PostHogCore { private _memoryStorage = new PostHogMemoryStorage() private _semiAsyncStorage?: SemiAsyncStorage private _appProperties: PostHogCustomAppProperties = {} + private _setupPromise?: Promise static _resetClientCache(): void { // NOTE: this method is intended for testing purposes only @@ -56,7 +57,9 @@ export class PostHog extends PostHogCore { console.warn('PostHog.initAsync called twice with the same apiKey. The first instance will be used.') } - return posthog + const resolved = await posthog + await resolved._setupPromise + return resolved } constructor(apiKey: string, options?: PostHogOptions, storage?: SemiAsyncStorage) { @@ -110,7 +113,7 @@ export class PostHog extends PostHogCore { } } - void setupAsync() + this._setupPromise = setupAsync() } getPersistedProperty(key: PostHogPersistedProperty): T | undefined { diff --git a/posthog-react-native/test/posthog.spec.ts b/posthog-react-native/test/posthog.spec.ts index 49d050c3..08687a0e 100644 --- a/posthog-react-native/test/posthog.spec.ts +++ b/posthog-react-native/test/posthog.spec.ts @@ -1,5 +1,9 @@ import { PostHogPersistedProperty } from 'posthog-core' import { PostHog, PostHogCustomAsyncStorage } from '../index' +import { Linking, AppState, AppStateStatus } from 'react-native' + +Linking.getInitialURL = jest.fn(() => Promise.resolve(null)) +AppState.addEventListener = jest.fn() describe('PostHog React Native', () => { let mockStorage: PostHogCustomAsyncStorage @@ -154,4 +158,169 @@ describe('PostHog React Native', () => { expect(posthog.getPersistedProperty(PostHogPersistedProperty.Props)).toEqual(undefined) }) }) + + describe('captureNativeAppLifecycleEvents', () => { + it('should trigger an Application Installed event', async () => { + // arrange + const onCapture = jest.fn() + + // act + posthog = await PostHog.initAsync('test-install', { + customAsyncStorage: mockStorage, + flushInterval: 0, + captureNativeAppLifecycleEvents: true, + __onConstructed: (p) => { + p.on('capture', onCapture) + }, + customAppProperties: { + $app_build: '1', + $app_version: '1.0.0', + }, + }) + + // assert + expect(onCapture).toHaveBeenCalledTimes(2) + expect(onCapture.mock.calls[0][0]).toMatchObject({ + event: 'Application Installed', + properties: { + $app_build: '1', + $app_version: '1.0.0', + }, + }) + expect(onCapture.mock.calls[1][0]).toMatchObject({ + event: 'Application Opened', + properties: { + $app_build: '1', + $app_version: '1.0.0', + from_background: false, + }, + }) + }) + it('should trigger an Application Updated event', async () => { + // arrange + const onCapture = jest.fn() + posthog = await PostHog.initAsync('test-update', { + customAsyncStorage: mockStorage, + captureNativeAppLifecycleEvents: true, + customAppProperties: { + $app_build: '1', + $app_version: '1.0.0', + }, + }) + PostHog._resetClientCache() + + // act + posthog = await PostHog.initAsync('test-update', { + customAsyncStorage: mockStorage, + flushInterval: 0, + captureNativeAppLifecycleEvents: true, + __onConstructed: (p) => { + p.on('capture', onCapture) + }, + customAppProperties: { + $app_build: '2', + $app_version: '2.0.0', + }, + }) + + // assert + expect(onCapture).toHaveBeenCalledTimes(2) + expect(onCapture.mock.calls[0][0]).toMatchObject({ + event: 'Application Updated', + properties: { + $app_build: '2', + $app_version: '2.0.0', + previous_build: '1', + previous_version: '1.0.0', + }, + }) + expect(onCapture.mock.calls[1][0]).toMatchObject({ + event: 'Application Opened', + properties: { + $app_build: '2', + $app_version: '2.0.0', + from_background: false, + }, + }) + }) + it('should only trigger an open event if the build number has not changed', async () => { + // arrange + Linking.getInitialURL = jest.fn(() => Promise.resolve('https://example.com')) + const onCapture = jest.fn() + posthog = await PostHog.initAsync('test-open', { + customAsyncStorage: mockStorage, + captureNativeAppLifecycleEvents: true, + customAppProperties: { + $app_build: '1', + $app_version: '1.0.0', + }, + }) + PostHog._resetClientCache() + + // act + posthog = await PostHog.initAsync('test-open', { + customAsyncStorage: mockStorage, + flushInterval: 0, + captureNativeAppLifecycleEvents: true, + __onConstructed: (p) => { + p.on('capture', onCapture) + }, + customAppProperties: { + $app_build: '1', + $app_version: '1.0.0', + }, + }) + + // assert + expect(onCapture).toHaveBeenCalledTimes(1) + expect(onCapture.mock.calls[0][0]).toMatchObject({ + event: 'Application Opened', + properties: { + $app_build: '1', + $app_version: '1.0.0', + from_background: false, + url: 'https://example.com', + }, + }) + }) + + it('should track app background and foreground', async () => { + // arrange + const onCapture = jest.fn() + posthog = await PostHog.initAsync('test-change', { + customAsyncStorage: mockStorage, + captureNativeAppLifecycleEvents: true, + __onConstructed: (p) => { + p.on('capture', onCapture) + }, + customAppProperties: { + $app_build: '1', + $app_version: '1.0.0', + }, + }) + const cb: (state: AppStateStatus) => void = (AppState.addEventListener as jest.Mock).mock.calls[1][1] + + // act + cb('background') + cb('active') + + // assert + expect(onCapture).toHaveBeenCalledTimes(4) + expect(onCapture.mock.calls[2][0]).toMatchObject({ + event: 'Application Backgrounded', + properties: { + $app_build: '1', + $app_version: '1.0.0', + }, + }) + expect(onCapture.mock.calls[3][0]).toMatchObject({ + event: 'Application Opened', + properties: { + $app_build: '1', + $app_version: '1.0.0', + from_background: true, + }, + }) + }) + }) })